feat(generator): add PHP 8.4 interface property hook contracts support

- Update gen_stub.php to handle abstract property hooks with array{get?: string|true, set?: string|true}
- Add abstractHooks property and logic to detect abstract hook implementations
- Modify property hook registration to use registerAbstractPropertyHooks for abstract contracts
- Register ZEND_ACC_ABSTRACT flag for abstract properties in PHP 8.4+
- Reorder code generation to declare interface properties before implementing classes
- Set abstract flag from hook metadata during parsing
- Add InterfaceDef::properties array to store abstract property contracts
- Create InterfacePropertyDef entity for interface property contract modeling
- Add InterfaceDef::hasProperty method for contract existence checking
- Validate interface property requirements during class implementation checks
- Add compile-time error handling for invalid interface property hook syntax
- Implement directional variance checking for interface property types
- Generate proper Zend metadata for abstract property hooks in PHP 8.4
- Add comprehensive tests for interface property hook contracts and errors
- Document interface property hook implementation design and usage patterns
master
韩天峰 2 weeks ago
parent ea08344c92
commit 2f229ee0ac
  1. 123
      docs/INTERFACE_PROPERTY_HOOKS.md
  2. 2
      docs/PROPERTY_HOOKS.md
  3. 1
      docs/README.md
  4. 8
      phpunit/code/interface_property_hook_body.php
  5. 6
      phpunit/code/interface_property_hook_explicit_setter.php
  6. 10
      phpunit/code/interface_property_hook_missing.php
  7. 6
      phpunit/code/interface_property_hook_plain.php
  8. 11
      phpunit/code/interface_property_hook_private.php
  9. 13
      phpunit/code/interface_property_hook_setter.php
  10. 11
      phpunit/code/interface_property_hook_type.php
  11. 29
      phpunit/code/interface_property_hook_variance.php
  12. 92
      phpunit/src/InterfacePropertyHookTest.php
  13. 12
      src/Entity/InterfaceDef.php
  14. 28
      src/Entity/InterfacePropertyDef.php
  15. 1
      src/Entity/PropertyDef.php
  16. 90
      src/Preprocessor.php
  17. 20
      src/Transform/PropertyHookLowering.php
  18. 9
      src/Transform/Visitor.php
  19. 110
      src/Translator.php
  20. 50
      src/gen_stub.php
  21. 113
      tests/compiler/object_property/interface-property-hooks.phpt

@ -0,0 +1,123 @@
# Interface Property Hooks 实现方案
本文记录 TP-AOT-010 的设计与实施计划。目标是支持 PHP 8.4 的 Interface Property Hook 契约,同时保持 TypePHP Native 调用的零成本抽象,并让 PHP 8.4 ZendVM 的 Reflection、动态类链接和继承检查获得完整元数据。
## 当前状态(2026-08-14)
第一阶段已经落地:Interface 契约模型、AOT 实现检查、get/set 方向方差、PHPX 抽象 Hook 元数据、Reflection、动态 PHP 实现类及回归测试均已接通。显式 setter 参数类型仍按下文约定在编译期拒绝;完成独立写入类型模型后再开放。
## 1. 设计结论
Interface 中的 Hooked Property 只表示属性契约:
```php
interface Named
{
public string $name { get; set; }
}
```
- Interface 不持有属性槽,不生成 getter/setter 实现,也不产生访问时的契约检查。
- TypePHP 在编译期验证已知 AOT 类是否满足属性的可见性、类型和 `get`/`set` 能力。
- PHP 8.4 目标在 MINIT 注册原生 Zend Hook 元数据,使 Reflection 和动态 PHP 类获得相同契约。
- 编译器前端解析和验证该语法不依赖 PHP 8.4;但使用 Property Hooks 的最终目标运行时必须链接 PHP 8.4 或更高版本。
- PHP 8.3 不提供静默降级。缺少 Zend Hook 元数据会使动态属性访问、Reflection、JSON、序列化和 `eval()` 类链接的行为取决于执行路径,不能视为可靠支持。
## 2. 语法与诊断
支持三类契约:
```php
public string $readable { get; }
public string $writable { set; }
public string $readWrite { get; set; }
```
Interface Property Hook 必须是 `public`、非 `static`、无默认值且 Hook 不得包含函数体。普通 Interface Property、`private`/`protected`、`readonly`、重复或未知 Hook,以及带实现体的 Hook 均在 TypePHP 编译期抛出 FatalError。错误信息应尽可能与 PHP 8.4 一致。
第一阶段只接收隐式 setter 参数:
```php
public string $name { set; }
```
PHP 8.4 还允许 `set(string|Stringable $value)` 这类显式、可逆变的 setter 参数。该语法需要让编译期契约模型与 Zend Hook `arg_info` 同时保存独立于属性读取类型的写入类型;在这部分完成前,TypePHP 会给出明确的编译期错误,不生成可能错误的运行时元数据。
## 3. 编译器模型
Interface Property Hook 不应伪装成普通属性或 lowering 后的普通方法。为其建立独立契约模型,至少保存:
- 属性名和声明节点;
- 解析后的 TypePHP 类型与类类型;
- 是否要求 `get`
- 是否要求 `set`
- 可见性及其他用于诊断的标志。
契约存放在 `InterfaceDef` 中。AST/预处理阶段只收集和验证声明,不为 Interface 分配属性槽,不运行具体类使用的 `PropertyHookLowering`,也不生成隐藏方法。
所有类型完成预处理后再执行契约链接:展开父 Interface 契约,然后检查实现类自身或父类提供的属性。普通 public backed property 同时满足读写契约;Hooked Property 根据实际 Hook 能力判断。get-only 类型按读取方向协变,set-only 类型按写入方向逆变,同时包含 get/set 时保持不变。
## 4. PHPX 与 Zend 元数据
现有 `php::registerPropertyHooks()` 用于具有真实 AOT getter/setter 的具体类,不能复用于抽象 Interface Hook。
PHPX 增加独立 helper:
```cpp
php::registerAbstractPropertyHooks(
zend_class_entry *interface_ce,
zend_property_info *property_info,
bool readable,
bool writable
);
```
它只在 `PHP_VERSION_ID >= 80400` 下访问 PHP 8.4 ABI,并负责:
- 持久化分配 `zend_property_info::hooks`
- 创建没有 handler 的 abstract `get`/`set` `zend_internal_function` 元数据;
- 设置 `ZEND_ACC_PUBLIC | ZEND_ACC_ABSTRACT`、正确的参数/返回类型及 `common.prop_info`
- 更新 `num_hooked_props`,使 Zend inheritance 和 Reflection 识别该契约;
- 保证所有字符串、Hook 表和函数描述具有 MINIT 级持久生命周期。
生成代码先注册 Interface,再以 `IS_UNDEF`、`ZEND_ACC_PUBLIC | ZEND_ACC_ABSTRACT | ZEND_ACC_VIRTUAL` 声明属性并挂载抽象 Hook,最后才注册和链接实现类。
## 5. PHP 版本边界
TypePHP 应区分编译器宿主与目标 PHP:
- PHP Parser 和 TypePHP 前端可以在 PHP 8.3 环境解析该语法;
- 构建后端以项目选择的 PHP language/target version 以及最终链接的 PHP headers/`libphp` 作为能力依据;编译器进程自身可以运行在更旧的 PHP 上;
- 发现 Property Hooks 且目标低于 PHP 8.4 时,在 C++ 编译前报告:
```text
Property Hooks require PHP 8.4 or later as the target runtime
```
PHPX 仍使用条件编译作为 ABI 防线,但不应把清晰的功能诊断推迟为 C++ 编译错误。
## 6. TDD 覆盖
实现前先加入失败测试,覆盖:
1. get-only、set-only、get/set Interface 契约;
2. 普通 backed property、Hooked Property 和继承属性满足契约;
3. 缺失属性、缺少 get/set、非 public 和类型不兼容的编译错误;
4. Interface 继承、多个契约的合并与冲突;
5. Reflection 的 abstract、virtual、hasHook/getHook 元数据;
6. PHP 8.4 动态 PHP 类的成功与失败链接;
7. O0/O3 结果一致,Interface 不生成属性槽或 Native Hook 实现;
8. PHP 8.3 目标得到明确的构建期错误;
9. PHPX helper 在 NTS/ZTS 和 PHP 8.4/8.5 下的生命周期与 ABI 回归。
## 7. 实施顺序
1. 添加 TP-AOT-010 正常场景及语法错误 PHPT,确认当前失败。
2. 增加 Interface Property Contract 模型和预处理收集逻辑。
3. 实现 Interface 继承与实现类的编译期契约检查。
4. 在 PHPX 增加抽象 Hook 元数据 helper。
5. 修改 stub 生成和类注册顺序,接入 PHP 8.4 Zend 元数据。
6. 添加 Reflection、动态类链接、目标版本和生成代码测试。
7. 执行 Interface、Property Hook、Reflection 及全量编译器回归。
完成后的运行时属性访问仍直接进入实现类的普通属性或 Native Hook;Interface 契约本身只存在于编译期模型和 MINIT 元数据中,不进入请求热路径。

@ -2,6 +2,8 @@
本文记录 TypePHP 编译器与 PHPX 对 PHP 8.4 Property Hook 的实现方式,重点说明 Zend 元数据注册、对象内省、内存生命周期和版本兼容边界。本文是内部维护文档;用户侧语法说明应放在外部文档仓库。
Interface 中不带实现体的 Property Hook 属于抽象属性契约,不走本文描述的具体类 lowering 流程;其模型、方差检查和 Zend 元数据注册见 [Interface Property Hook 实现方案](INTERFACE_PROPERTY_HOOKS.md)。
## 1. 背景
TypePHP 会把 Property Hook 的函数体编译成隐藏的 AOT getter/setter。仅完成这一步,可以满足编译器明确识别出的属性读写,但 ZendVM 并不知道这些隐藏方法代表 Property Hook,因此以下动态能力会与 PHP 8.4 不一致:

@ -23,6 +23,7 @@
- [核心重构计划](REFACTORING_PLAN.md)
- [作用域管理设计](SCOPE_MANAGEMENT.md):`CallableScope`、`UserCodeScopeGuard` 与 `FakeScopeGuard` 的职责和使用边界。
- [PHP 8.4 Property Hook 集成设计](PROPERTY_HOOKS.md):编译期 lowering、Zend Hook 元数据、对象内省及 PHPX ABI 边界。
- [Interface Property Hook 实现方案](INTERFACE_PROPERTY_HOOKS.md):接口属性契约、编译期方差检查及 PHP 8.4 抽象 Hook 元数据。
- [构建速度研究](AOT_BUILD_SPEED_RESEARCH.md)
- [优化优先级](aot-optimization-priority.md)
- [高精度类型原地运算优化方案](BIG_NUMBER_INPLACE_OPTIMIZATION_PLAN.md)

@ -0,0 +1,8 @@
<?php
interface InvalidHookBody
{
public string $name {
get => 'name';
}
}

@ -0,0 +1,6 @@
<?php
interface ExplicitSetter
{
public string $value { set(string|int $value); }
}

@ -0,0 +1,10 @@
<?php
interface ReadableName
{
public string $name { get; }
}
final class MissingName implements ReadableName
{
}

@ -0,0 +1,6 @@
<?php
interface InvalidProperty
{
public string $name;
}

@ -0,0 +1,11 @@
<?php
interface ReadableName
{
public string $name { get; }
}
final class PrivateName implements ReadableName
{
private string $name = '';
}

@ -0,0 +1,13 @@
<?php
interface MutableName
{
public string $name { get; set; }
}
final class ReadOnlyName implements MutableName
{
public string $name {
get => 'name';
}
}

@ -0,0 +1,11 @@
<?php
interface ReadableName
{
public string $name { get; }
}
final class IntegerName implements ReadableName
{
public int $name = 1;
}

@ -0,0 +1,29 @@
<?php
class Animal
{
}
class Dog extends Animal
{
}
interface ReadsAnimal
{
public Animal $value { get; }
}
final class ReadsDog implements ReadsAnimal
{
public Dog $value;
}
interface WritesDog
{
public Dog $value { set; }
}
final class WritesAnimal implements WritesDog
{
public Animal $value;
}

@ -0,0 +1,92 @@
<?php
use PHPUnit\Framework\TestCase;
use TypePhp\CompilerTest;
use TypePhp\Exception\TestError;
final class InterfacePropertyHookTest extends TestCase
{
private function compileFixture(string $file): void
{
global $translator;
$compiler = CompilerTest::create(ROOT_PATH);
$translator = $compiler;
$path = __DIR__ . '/../code/' . $file;
$compiler->addFiles([$path]);
$compiler->prepareFile($path);
$compiler->convertFile($path);
}
private function assertCompileError(string $file, string $message): void
{
try {
$this->compileFixture($file);
} catch (TestError $error) {
self::assertStringContainsString($message, $error->getMessage());
return;
}
self::fail('Expected compilation to fail');
}
public function testMissingPropertyIsRejected(): void
{
$this->assertCompileError(
'interface_property_hook_missing.php',
'must implement property `ReadableName::$name`',
);
}
public function testNonPublicPropertyIsRejected(): void
{
$this->assertCompileError(
'interface_property_hook_private.php',
'must be public to satisfy `ReadableName::$name`',
);
}
public function testIncompatiblePropertyTypeIsRejected(): void
{
$this->assertCompileError(
'interface_property_hook_type.php',
'must be compatible with `ReadableName::$name`',
);
}
public function testMissingSetterIsRejected(): void
{
$this->assertCompileError(
'interface_property_hook_setter.php',
'does not satisfy the required hooks of `MutableName::$name`',
);
}
public function testPlainInterfacePropertyIsRejected(): void
{
$this->assertCompileError(
'interface_property_hook_plain.php',
'Interfaces may only include hooked properties',
);
}
public function testInterfaceHookBodyIsRejected(): void
{
$this->assertCompileError(
'interface_property_hook_body.php',
'Abstract property hook cannot have body',
);
}
public function testExplicitSetterParameterIsRejectedUntilItsIndependentTypeIsModeled(): void
{
$this->assertCompileError(
'interface_property_hook_explicit_setter.php',
'Explicit setter parameters in interface property hooks are not supported yet',
);
}
public function testDirectionalPropertyVarianceIsAccepted(): void
{
$this->compileFixture('interface_property_hook_variance.php');
$this->addToAssertionCount(1);
}
}

@ -20,6 +20,13 @@ class InterfaceDef extends ClassLikeDef
*/
public array $constants = [];
/**
* Abstract hooked-property contracts, keyed by the case-sensitive property name.
*
* @var array<string, InterfacePropertyDef>
*/
public array $properties = [];
/**
* @var string[]
*/
@ -44,4 +51,9 @@ class InterfaceDef extends ClassLikeDef
{
return isset($this->constants[$name]);
}
public function hasProperty(string $name): bool
{
return isset($this->properties[$name]);
}
}

@ -0,0 +1,28 @@
<?php
/**
* This file is part of TypePHP.
*
* Describes an abstract property contract declared by an interface.
*/
namespace TypePhp\Entity;
use PhpParser\Node\Stmt\Property;
final class InterfacePropertyDef
{
public string $class = '';
public array $typeCheck = [];
public string $typeStr = '';
public function __construct(
public readonly string $name,
public readonly int $flags,
public readonly string $type,
public readonly bool $nullable,
public readonly bool $readable,
public readonly bool $writable,
public readonly Property $node,
) {
}
}

@ -25,6 +25,7 @@ class PropertyDef
public bool $readonly = false;
public ?string $getter = null;
public ?string $setter = null;
public bool $virtual = false;
public function __construct(string $name, int $flags, string $type, ?string $default = null, bool $nullable = false)
{

@ -15,6 +15,7 @@ use TypePhp\Entity\ClassDef;
use TypePhp\Entity\ConstantDef;
use TypePhp\Entity\FunctionDef;
use TypePhp\Entity\InterfaceDef;
use TypePhp\Entity\InterfacePropertyDef;
use TypePhp\Entity\MethodDef;
use TypePhp\Entity\PropertyDef;
use TypePhp\Diagnostics\CompileTimeAttributeDiagnostic;
@ -1439,6 +1440,9 @@ class Preprocessor extends CompilerBase
protected function parseClassPropertyDef(Node\Stmt\Property $v): void
{
if ($v->hooks !== [] && version_compare($this->phpVersion, '8.4', '<')) {
$this->fatalError($v, 'Property Hooks require PHP 8.4 or later as the target runtime');
}
$oriCtx = $this->context;
$this->context = $this->classDef->propertyContext;
$nullable = $v->type instanceof NullableType;
@ -1446,6 +1450,8 @@ class Preprocessor extends CompilerBase
foreach ($v->props as $prop) {
$propName = $this->parseIdentifier($prop->name);
$propDef = $this->addClassProperty($propName, $v->flags, $v->type, $prop->default, $nullable, $v);
$hookMetadata = $v->getAttribute(PropertyHookLowering::PROPERTY_ATTRIBUTE, []);
$propDef->virtual = (bool) ($hookMetadata['virtual'] ?? false);
foreach ($v->hooks as $hook) {
$kind = strtolower($hook->name->toString());
if ($kind === 'get') {
@ -1639,6 +1645,11 @@ class Preprocessor extends CompilerBase
continue;
}
if ($stmt instanceof Node\Stmt\Property) {
$this->prepareInterfaceProperty($stmt);
continue;
}
if (!$stmt instanceof Node\Stmt\Nop) {
$this->fatalError($stmt, 'Unsupported interface statement: ' . $stmt->getType());
}
@ -1650,6 +1661,85 @@ class Preprocessor extends CompilerBase
$this->interfaceDef = null;
}
private function prepareInterfaceProperty(Node\Stmt\Property $property): void
{
if ($property->hooks === []) {
$this->fatalError($property, 'Interfaces may only include hooked properties');
}
if (version_compare($this->phpVersion, '8.4', '<')) {
$this->fatalError($property, 'Property Hooks require PHP 8.4 or later as the target runtime');
}
if ($property->flags & (Modifiers::PRIVATE | Modifiers::PROTECTED)) {
$this->fatalError($property, 'Property in interface cannot be protected or private');
}
if ($property->flags & Modifiers::STATIC) {
$this->fatalError($property, 'Cannot declare hooks for static property');
}
if ($property->flags & Modifiers::READONLY) {
$this->fatalError($property, 'Hooked properties cannot be readonly');
}
$readable = false;
$writable = false;
foreach ($property->hooks as $hook) {
if ($hook->body !== null) {
$this->fatalError($hook, 'Abstract property hook cannot have body');
}
$kind = strtolower($hook->name->toString());
if ($kind === 'get') {
if ($readable) {
$this->fatalError($hook, 'Cannot redeclare property hook "get"');
}
$readable = true;
} elseif ($kind === 'set') {
if ($writable) {
$this->fatalError($hook, 'Cannot redeclare property hook "set"');
}
if ($hook->params !== []) {
$this->fatalError(
$hook,
'Explicit setter parameters in interface property hooks are not supported yet',
);
}
$writable = true;
} else {
$this->fatalError($hook, "Unknown hook `{$kind}`, expected `get` or `set`");
}
}
[$type, $class] = $this->resolveTypeDecl($property->type, self::DECL_TYPE_OF_PROPERTY);
$nullable = $property->type instanceof NullableType;
foreach ($property->props as $prop) {
$name = $this->parseIdentifier($prop->name);
if ($this->interfaceDef->hasProperty($name)) {
$this->fatalError($property, "Duplicate property `{$name}`");
}
if ($prop->default !== null) {
$this->fatalError($property, "Cannot specify default value for virtual hooked property {$this->interfaceDef->getNamespacedName(false)}::\${$name}");
}
$definition = new InterfacePropertyDef(
$name,
$this->parseModifiers($property->flags),
$type,
$nullable,
$readable,
$writable,
$property,
);
$definition->class = $class;
if ($property->type instanceof NullableType
|| $property->type instanceof UnionType
|| $property->type instanceof IntersectionType
) {
$typeInfo = $this->buildTypeCheckFromNode($property->type);
$definition->typeCheck = $typeInfo['check'];
$definition->typeStr = $typeInfo['typeStr'];
}
$this->interfaceDef->properties[$name] = $definition;
}
}
protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$aliases, array &$ignored): void
{
foreach ($traitUse->adaptations as $adaptation) {

@ -127,6 +127,26 @@ final class PropertyHookLowering
return $methods;
}
public static function markAbstractInterfaceProperty(Stmt\Property $property): void
{
if ($property->hooks === []) {
return;
}
$hooks = [];
foreach ($property->hooks as $hook) {
$kind = strtolower($hook->name->toString());
if ($kind === 'get' || $kind === 'set') {
$hooks[$kind] = true;
}
}
$property->setAttribute(self::PROPERTY_ATTRIBUTE, [
'methods' => $hooks,
'virtual' => true,
'abstract' => true,
]);
}
public static function lowerPromotedProperty(Param $param): ?Stmt\ClassMethod
{
if (!$param->isPromoted() || !is_string($param->var->name)) {

@ -46,6 +46,15 @@ class Visitor extends NodeVisitorAbstract
$this->guard($node, static fn () => ParameterValidationLowering::rejectArrowFunction($node));
}
if ($node instanceof Stmt\Interface_) {
foreach ($node->stmts as $stmt) {
if ($stmt instanceof Stmt\Property) {
PropertyHookLowering::markAbstractInterfaceProperty($stmt);
}
}
return null;
}
if (!$node instanceof Stmt\Class_ && !$node instanceof Stmt\Trait_ && !$node instanceof Stmt\Enum_) {
return null;
}

@ -28,6 +28,7 @@ use TypePhp\Entity\ClassLikeDef;
use TypePhp\Entity\ConstantDef;
use TypePhp\Entity\FunctionDef;
use TypePhp\Entity\InterfaceDef;
use TypePhp\Entity\InterfacePropertyDef;
use TypePhp\Entity\MethodDef;
use TypePhp\Entity\PropertyDef;
use TypePhp\Exception\Redo;
@ -4405,11 +4406,120 @@ CODE;
);
}
foreach ($interfaceDef->properties as $property) {
$this->checkInterfacePropertyImplementation($node, $classDef, $interfaceName, $property);
}
foreach ($interfaceDef->extendsList ?: ($interfaceDef->extends ? [$interfaceDef->extends] : []) as $parentInterface) {
$this->checkInterfaceImplementation($node, $classDef, $parentInterface);
}
}
private function checkInterfacePropertyImplementation(
NodeAbstract $node,
ClassDef $classDef,
string $interfaceName,
InterfacePropertyDef $contract,
): void {
$property = $this->findClassPropertyDef($classDef, $contract->name);
if ($property === null) {
if ($classDef->isAbstract()) {
return;
}
$this->fatalError(
$node,
"Class `{$classDef->getNamespacedName(false)}` must implement property " .
"`{$interfaceName}::\${$contract->name}`",
);
}
if (!$property->isPublic()) {
$this->fatalError(
$node,
"Property `{$classDef->getNamespacedName(false)}::\${$contract->name}` must be public " .
"to satisfy `{$interfaceName}::\${$contract->name}`",
);
}
$readable = $property->getter !== null || !$property->virtual;
$writable = $property->setter !== null
|| (!$property->virtual
&& !$property->isReadonly()
&& !$property->isPrivateSet()
&& !$property->isProtectedSet());
if (($contract->readable && !$readable) || ($contract->writable && !$writable)) {
$this->fatalError(
$node,
"Property `{$classDef->getNamespacedName(false)}::\${$contract->name}` does not satisfy " .
"the required hooks of `{$interfaceName}::\${$contract->name}`",
);
}
$implementationTypes = $this->getPropertyAcceptedTypes($property);
$contractTypes = $this->getPropertyAcceptedTypes($contract);
$compatible = match (true) {
$contract->readable && !$contract->writable =>
$this->isPropertyTypeSubset($implementationTypes, $contractTypes),
$contract->writable && !$contract->readable =>
$this->isPropertyTypeSubset($contractTypes, $implementationTypes),
default =>
$this->isPropertyTypeSubset($implementationTypes, $contractTypes)
&& $this->isPropertyTypeSubset($contractTypes, $implementationTypes),
};
if (!$compatible) {
$this->fatalError(
$node,
"Property `{$classDef->getNamespacedName(false)}::\${$contract->name}` must be compatible " .
"with `{$interfaceName}::\${$contract->name}`",
);
}
}
/**
* @return list<array<string, mixed>>
*/
private function getPropertyAcceptedTypes(PropertyDef|InterfacePropertyDef $property): array
{
if ($property->typeCheck !== []) {
return $property->typeCheck;
}
return match ($property->type) {
Type::INT => [['kind' => 'isInt']],
Type::FLOAT => [['kind' => 'isFloat']],
Type::BOOL => [['kind' => 'isBool']],
Type::STR => [['kind' => 'isString']],
Type::ARRAY => [['kind' => 'isArray']],
Type::RESOURCE => [['kind' => 'isResource']],
Type::OBJECT => $property->class !== ''
? [['kind' => 'instanceof', 'class' => $property->class]]
: [['kind' => 'isObject']],
default => [['kind' => 'isMixed']],
};
}
private function isPropertyTypeSubset(array $candidateTypes, array $acceptedTypes): bool
{
foreach ($candidateTypes as $candidateType) {
if (!$this->isReturnTypeCoveredBy($candidateType, $acceptedTypes)) {
return false;
}
}
return true;
}
private function findClassPropertyDef(ClassDef $classDef, string $propertyName): ?PropertyDef
{
$current = $classDef;
while (true) {
if ($current->hasProperty($propertyName)) {
return $current->getProperty($propertyName);
}
if (!$current->extends || !$this->hasClass($current->extends)) {
return null;
}
$current = $this->getClass($current->extends);
}
}
private function findClassMethodDef(ClassDef $classDef, string $methodName, bool $includeAbstract = true): ?MethodDef
{
$current = $classDef;

@ -3248,8 +3248,9 @@ class PropertyInfo extends VariableLike
private /* readonly */ ?string $defaultValueString;
private /* readonly */ bool $isDocReadonly;
private /* readonly */ bool $isVirtual;
/** @var array{get?: string, set?: string} */
/** @var array{get?: string|true, set?: string|true} */
private /* readonly */ array $hooks;
private /* readonly */ bool $abstractHooks;
private /* readonly */ bool $isPromoted;
/**
@ -3279,6 +3280,8 @@ class PropertyInfo extends VariableLike
$this->isDocReadonly = $isDocReadonly;
$this->isVirtual = $isVirtual;
$this->hooks = $hooks;
$this->abstractHooks = isset($hooks['get']) && $hooks['get'] === true
|| isset($hooks['set']) && $hooks['set'] === true;
$this->isPromoted = $isPromoted;
parent::__construct($flags, $type, $phpDocType, $link, $phpVersionIdMinimumCompatibility, $attributes, $exposedDocComment);
}
@ -3408,6 +3411,11 @@ class PropertyInfo extends VariableLike
$code .= $stringRelease;
if ($this->hooks !== []) {
if ($this->abstractHooks) {
$getter = isset($this->hooks['get']) ? 'true' : 'false';
$setter = isset($this->hooks['set']) ? 'true' : 'false';
$code .= "\tphp::registerAbstractPropertyHooks(class_entry, property_{$propertyName}, {$getter}, {$setter});\n";
} else {
$getter = isset($this->hooks['get'])
? 'std::string_view{"' . addslashes($this->hooks['get']) . '"}'
: 'std::string_view{}';
@ -3416,6 +3424,7 @@ class PropertyInfo extends VariableLike
: 'std::string_view{}';
$code .= "\tphp::registerPropertyHooks(class_entry, property_{$propertyName}, {$getter}, {$setter});\n";
}
}
return $code;
}
@ -3432,6 +3441,10 @@ class PropertyInfo extends VariableLike
$flags->addForVersionsAbove("ZEND_ACC_FINAL", PHP_84_VERSION_ID);
}
if ($this->flags & Modifiers::ABSTRACT) {
$flags->addForVersionsAbove("ZEND_ACC_ABSTRACT", PHP_84_VERSION_ID);
}
if ($this->flags & Modifiers::READONLY) {
$flags->addForVersionsAbove("ZEND_ACC_READONLY", PHP_81_VERSION_ID);
} elseif ($this->classFlags & Modifiers::READONLY) {
@ -3836,21 +3849,6 @@ class ClassInfo {
}
}
$implements = array_map(
function (Name $item) {
return "class_entry_" . implode("_", $item->getParts());
},
$this->type === "interface" ? $this->extends : $this->implements
);
if (!empty($implements)) {
$code .= "\tzend_class_implements(class_entry, " . count($implements) . ", " . implode(", ", $implements) . ");\n";
}
if ($this->alias) {
$code .= "\tzend_register_class_alias(\"" . str_replace("\\", "\\\\", $this->alias) . "\", class_entry);\n";
}
$code .= generateCodeWithConditions(
$this->constInfos,
'',
@ -3864,6 +3862,23 @@ class ClassInfo {
foreach ($this->propertyInfos as $property) {
$code .= $property->getDeclaration($allConstInfos);
}
// Zend merges interface property contracts immediately. Declare the
// class/interface's own properties first so an implementation can
// replace a virtual abstract contract with its real property slot.
$implements = array_map(
function (Name $item) {
return "class_entry_" . implode("_", $item->getParts());
},
$this->type === "interface" ? $this->extends : $this->implements
);
if (!empty($implements)) {
$code .= "\tzend_class_implements(class_entry, " . count($implements) . ", " . implode(", ", $implements) . ");\n";
}
if ($this->alias) {
$code .= "\tzend_register_class_alias(\"" . str_replace("\\", "\\\\", $this->alias) . "\", class_entry);\n";
}
// Reusable strings for wrapping conditional PHP 8.0+ code
if ($php80MinimumCompatibility) {
$php80CondStart = '';
@ -5342,6 +5357,9 @@ function parseProperty(
$link = $tagMap['link'] ?? null;
$isVirtual = $hookMetadata['virtual'] ?? array_key_exists('virtual', $tagMap);
$hooks = $hookMetadata['methods'] ?? [];
if ($hookMetadata['abstract'] ?? false) {
$flags |= Modifiers::ABSTRACT;
}
foreach ($tags as $tag) {
if ($tag->name === 'var') {

@ -0,0 +1,113 @@
--TEST--
PHP 8.4 interface property hooks define abstract property contracts
--FILE--
<?php
interface NamedContract
{
public string $displayName { get; }
}
interface MutableNameContract
{
public string $displayName { get; set; }
}
interface NameSinkContract
{
public string $displayName { set; }
}
interface ExtendedNamedContract extends NamedContract
{
}
final class BackedName implements NamedContract
{
public string $displayName = 'backed';
}
final class HookedName implements NamedContract
{
public string $displayName {
get => 'hooked';
}
}
class InheritedName
{
public string $displayName = 'inherited';
}
final class MutableName extends InheritedName implements MutableNameContract
{
}
final class ExtendedName implements ExtendedNamedContract
{
public string $displayName = 'extended';
}
final class NameSink implements NameSinkContract
{
private string $stored = '';
public string $displayName {
set => $this->stored = $value;
}
public function stored(): string
{
return $this->stored;
}
}
function main(): void
{
$backed = new BackedName();
$hooked = new HookedName();
echo $backed->displayName, "\n";
echo $hooked->displayName, "\n";
$mutable = new MutableName();
$mutable->displayName = 'changed';
echo $mutable->displayName, "\n";
$extended = new ExtendedName();
echo $extended->displayName, "\n";
$sink = new NameSink();
$sink->displayName = 'sink';
echo $sink->stored(), "\n";
$property = new ReflectionProperty(NamedContract::class, 'displayName');
var_dump($property->isAbstract());
var_dump($property->isVirtual());
foreach ($property->getHooks() as $kind => $hook) {
echo $kind, ':', $hook->getName(), ':', $hook->isAbstract() ? 'abstract' : 'concrete', "\n";
}
$mutableProperty = new ReflectionProperty(MutableNameContract::class, 'displayName');
foreach ($mutableProperty->getHooks() as $kind => $hook) {
echo $kind, ':', $hook->getName(), ':', $hook->isAbstract() ? 'abstract' : 'concrete', "\n";
}
eval('final class DynamicName implements NamedContract { public string $displayName = "dynamic"; }');
$dynamic = new DynamicName();
echo $dynamic->displayName, "\n";
eval('final class DynamicMutableName implements MutableNameContract { public string $displayName = "before"; }');
$dynamicMutable = new DynamicMutableName();
$dynamicMutable->displayName = 'dynamic-write';
echo $dynamicMutable->displayName, "\n";
}
?>
--EXPECT--
backed
hooked
changed
extended
sink
bool(true)
bool(true)
get:$displayName::get:abstract
get:$displayName::get:abstract
set:$displayName::set:abstract
dynamic
dynamic-write
Loading…
Cancel
Save