From 2f229ee0ac967456485b660c61931f99a205c85b Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Fri, 14 Aug 2026 17:23:43 +0800 Subject: [PATCH] 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 --- docs/INTERFACE_PROPERTY_HOOKS.md | 123 ++++++++++++++++++ docs/PROPERTY_HOOKS.md | 2 + docs/README.md | 1 + phpunit/code/interface_property_hook_body.php | 8 ++ ...nterface_property_hook_explicit_setter.php | 6 + .../code/interface_property_hook_missing.php | 10 ++ .../code/interface_property_hook_plain.php | 6 + .../code/interface_property_hook_private.php | 11 ++ .../code/interface_property_hook_setter.php | 13 ++ phpunit/code/interface_property_hook_type.php | 11 ++ .../code/interface_property_hook_variance.php | 29 +++++ phpunit/src/InterfacePropertyHookTest.php | 92 +++++++++++++ src/Entity/InterfaceDef.php | 12 ++ src/Entity/InterfacePropertyDef.php | 28 ++++ src/Entity/PropertyDef.php | 1 + src/Preprocessor.php | 90 +++++++++++++ src/Transform/PropertyHookLowering.php | 20 +++ src/Transform/Visitor.php | 9 ++ src/Translator.php | 110 ++++++++++++++++ src/gen_stub.php | 64 +++++---- .../interface-property-hooks.phpt | 113 ++++++++++++++++ 21 files changed, 736 insertions(+), 23 deletions(-) create mode 100644 docs/INTERFACE_PROPERTY_HOOKS.md create mode 100644 phpunit/code/interface_property_hook_body.php create mode 100644 phpunit/code/interface_property_hook_explicit_setter.php create mode 100644 phpunit/code/interface_property_hook_missing.php create mode 100644 phpunit/code/interface_property_hook_plain.php create mode 100644 phpunit/code/interface_property_hook_private.php create mode 100644 phpunit/code/interface_property_hook_setter.php create mode 100644 phpunit/code/interface_property_hook_type.php create mode 100644 phpunit/code/interface_property_hook_variance.php create mode 100644 phpunit/src/InterfacePropertyHookTest.php create mode 100644 src/Entity/InterfacePropertyDef.php create mode 100644 tests/compiler/object_property/interface-property-hooks.phpt diff --git a/docs/INTERFACE_PROPERTY_HOOKS.md b/docs/INTERFACE_PROPERTY_HOOKS.md new file mode 100644 index 00000000..d5e792a6 --- /dev/null +++ b/docs/INTERFACE_PROPERTY_HOOKS.md @@ -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 元数据中,不进入请求热路径。 diff --git a/docs/PROPERTY_HOOKS.md b/docs/PROPERTY_HOOKS.md index cc1fa5b6..0c4fb8a1 100644 --- a/docs/PROPERTY_HOOKS.md +++ b/docs/PROPERTY_HOOKS.md @@ -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 不一致: diff --git a/docs/README.md b/docs/README.md index d7ccfa21..51495cb3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/phpunit/code/interface_property_hook_body.php b/phpunit/code/interface_property_hook_body.php new file mode 100644 index 00000000..bf3c4dee --- /dev/null +++ b/phpunit/code/interface_property_hook_body.php @@ -0,0 +1,8 @@ + 'name'; + } +} diff --git a/phpunit/code/interface_property_hook_explicit_setter.php b/phpunit/code/interface_property_hook_explicit_setter.php new file mode 100644 index 00000000..acea227c --- /dev/null +++ b/phpunit/code/interface_property_hook_explicit_setter.php @@ -0,0 +1,6 @@ + 'name'; + } +} diff --git a/phpunit/code/interface_property_hook_type.php b/phpunit/code/interface_property_hook_type.php new file mode 100644 index 00000000..c2a6e776 --- /dev/null +++ b/phpunit/code/interface_property_hook_type.php @@ -0,0 +1,11 @@ +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); + } +} diff --git a/src/Entity/InterfaceDef.php b/src/Entity/InterfaceDef.php index 4920c185..e481dafd 100644 --- a/src/Entity/InterfaceDef.php +++ b/src/Entity/InterfaceDef.php @@ -20,6 +20,13 @@ class InterfaceDef extends ClassLikeDef */ public array $constants = []; + /** + * Abstract hooked-property contracts, keyed by the case-sensitive property name. + * + * @var array + */ + 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]); + } } diff --git a/src/Entity/InterfacePropertyDef.php b/src/Entity/InterfacePropertyDef.php new file mode 100644 index 00000000..8642a83c --- /dev/null +++ b/src/Entity/InterfacePropertyDef.php @@ -0,0 +1,28 @@ +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) { diff --git a/src/Transform/PropertyHookLowering.php b/src/Transform/PropertyHookLowering.php index 32ae5ebd..566d6db4 100644 --- a/src/Transform/PropertyHookLowering.php +++ b/src/Transform/PropertyHookLowering.php @@ -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)) { diff --git a/src/Transform/Visitor.php b/src/Transform/Visitor.php index 715612c5..611e8081 100644 --- a/src/Transform/Visitor.php +++ b/src/Transform/Visitor.php @@ -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; } diff --git a/src/Translator.php b/src/Translator.php index 4e763b87..b374a08e 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -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> + */ + 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; diff --git a/src/gen_stub.php b/src/gen_stub.php index 125785cd..ef8acecd 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -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,13 +3411,19 @@ class PropertyInfo extends VariableLike $code .= $stringRelease; if ($this->hooks !== []) { - $getter = isset($this->hooks['get']) - ? 'std::string_view{"' . addslashes($this->hooks['get']) . '"}' - : 'std::string_view{}'; - $setter = isset($this->hooks['set']) - ? 'std::string_view{"' . addslashes($this->hooks['set']) . '"}' - : 'std::string_view{}'; - $code .= "\tphp::registerPropertyHooks(class_entry, property_{$propertyName}, {$getter}, {$setter});\n"; + 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{}'; + $setter = isset($this->hooks['set']) + ? 'std::string_view{"' . addslashes($this->hooks['set']) . '"}' + : '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') { diff --git a/tests/compiler/object_property/interface-property-hooks.phpt b/tests/compiler/object_property/interface-property-hooks.phpt new file mode 100644 index 00000000..c0867b4f --- /dev/null +++ b/tests/compiler/object_property/interface-property-hooks.phpt @@ -0,0 +1,113 @@ +--TEST-- +PHP 8.4 interface property hooks define abstract property contracts +--FILE-- + '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