From 2a1ca3b1a020ebea33f446075933c25218abd168 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Tue, 11 Aug 2026 18:46:10 +0800 Subject: [PATCH] feat(compiler): add PHP 8.4 property hooks support with Zend metadata registration - Add hooks property to PropertyInfo class with get/set method mappings - Register property hooks as Zend metadata during AOT compilation - Implement proper virtual property handling with ZEND_VIRTUAL_PROPERTY_OFFSET - Support object introspection via Reflection and iteration with hook invocation - Add reflection metadata exposure through ReflectionProperty::hasHooks() and getHooks() - Implement backing storage detection to distinguish virtual vs backed properties - Add comprehensive test coverage for property hooks introspection and reflection - Document lazy object limitation with persistent AOT class entries in PHP 8.4 - Update documentation to reflect property hooks compilation strategy changes --- docs/INCOMPATIBLE_PHP_FEATURES.md | 3 +- src/Transform/PropertyHookLowering.php | 23 +++++++++ src/gen_stub.php | 37 ++++++++++++-- .../property-hooks-introspection.phpt | 51 +++++++++++++++++++ .../property-hooks-reflection.phpt | 29 +++++++++++ .../lazy-object-aot-class-limit.phpt | 30 +++++++++++ 6 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 tests/compiler/object_property/property-hooks-introspection.phpt create mode 100644 tests/compiler/object_property/property-hooks-reflection.phpt create mode 100644 tests/compiler/reflection/lazy-object-aot-class-limit.phpt diff --git a/docs/INCOMPATIBLE_PHP_FEATURES.md b/docs/INCOMPATIBLE_PHP_FEATURES.md index 80941bdb..fa3cfdc7 100644 --- a/docs/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/INCOMPATIBLE_PHP_FEATURES.md @@ -14,7 +14,8 @@ ## 声明与类型 - 不支持可变变量 `$$var`。 -- PHP 8.4 property hooks 会降级为 AOT getter/setter;直接属性读写和动态对象读写均受支持。当前不支持对 hook 属性取引用。 +- PHP 8.4 property hooks 会编译为 AOT getter/setter,并注册对应的 Zend hook 元数据;直接属性读写、Reflection 和对象遍历均受支持。当前不支持对 hook 属性取引用。 +- PHP 8.4 Reflection Lazy Object 不能用于 TypePHP AOT 类。AOT 类以 persistent internal class 注册,而 Zend 的 `zend_object_make_lazy()` 明确拒绝 internal class;运行时动态加载的 ZendPHP user class 不受此限制。 - 支持 `private(set)` 与 `protected(set)` 非对称属性可见性;在 PHP 8.2~8.4 后端通过自定义属性写 handler 执行同等作用域检查。 - 不支持闭包或箭头函数按引用返回。 - `__construct()` 不允许返回值。 diff --git a/src/Transform/PropertyHookLowering.php b/src/Transform/PropertyHookLowering.php index 02fc2e9e..32ae5ebd 100644 --- a/src/Transform/PropertyHookLowering.php +++ b/src/Transform/PropertyHookLowering.php @@ -14,6 +14,7 @@ use PhpParser\Node\Expr; use PhpParser\Node\Param; use PhpParser\Node\Stmt; use PhpParser\NodeAbstract; +use PhpParser\NodeFinder; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; @@ -21,6 +22,7 @@ final class PropertyHookLowering { public const string BACKING_ACCESS_ATTRIBUTE = 'typephpPropertyHookBackingAccess'; public const string METHOD_ATTRIBUTE = 'typephpPropertyHookMethod'; + public const string PROPERTY_ATTRIBUTE = 'typephpPropertyHooks'; private const string GET_PREFIX = '__typephp_property_get_'; private const string SET_PREFIX = '__typephp_property_set_'; private const string PRIVATE_SET_PREFIX = '__typephp_property_private_set_'; @@ -61,6 +63,8 @@ final class PropertyHookLowering $propertyName = $property->props[0]->name->toString(); $methods = []; + $hookMethods = []; + $hasBackingStorage = false; if ($property->flags & Modifiers::PRIVATE_SET) { $methods[] = self::visibilityMarker( self::PRIVATE_SET_PREFIX . bin2hex($propertyName), @@ -109,6 +113,15 @@ final class PropertyHookLowering 'property' => $propertyName, ]); $methods[] = $method; + $hookMethods[$kind] = $methodName; + $hasBackingStorage = $hasBackingStorage || self::containsBackingAccess($stmts); + } + + if ($hookMethods !== []) { + $property->setAttribute(self::PROPERTY_ATTRIBUTE, [ + 'methods' => $hookMethods, + 'virtual' => !$hasBackingStorage, + ]); } return $methods; @@ -201,4 +214,14 @@ final class PropertyHookLowering }); $traverser->traverse($nodes); } + + /** @param list $stmts */ + private static function containsBackingAccess(array $stmts): bool + { + $finder = new NodeFinder(); + return $finder->findFirst( + $stmts, + static fn (Node $node): bool => $node->getAttribute(self::BACKING_ACCESS_ATTRIBUTE, false) === true, + ) !== null; + } } diff --git a/src/gen_stub.php b/src/gen_stub.php index ee054fc8..5b19f312 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -3251,6 +3251,8 @@ class PropertyInfo extends VariableLike private /* readonly */ ?string $defaultValueString; private /* readonly */ bool $isDocReadonly; private /* readonly */ bool $isVirtual; + /** @var array{get?: string, set?: string} */ + private /* readonly */ array $hooks; private /* readonly */ bool $isPromoted; /** @@ -3266,6 +3268,7 @@ class PropertyInfo extends VariableLike ?string $defaultValueString, bool $isDocReadonly, bool $isVirtual, + array $hooks, bool $isPromoted, ?string $link, ?int $phpVersionIdMinimumCompatibility, @@ -3278,6 +3281,7 @@ class PropertyInfo extends VariableLike $this->defaultValueString = $defaultValueString; $this->isDocReadonly = $isDocReadonly; $this->isVirtual = $isVirtual; + $this->hooks = $hooks; $this->isPromoted = $isPromoted; parent::__construct($flags, $type, $phpDocType, $link, $phpVersionIdMinimumCompatibility, $attributes, $exposedDocComment); } @@ -3347,7 +3351,12 @@ class PropertyInfo extends VariableLike } $zvalName = "property_{$propertyName}_default_value"; - if ($useEmptyArrayDefault) { + if ($this->isVirtual) { + // Zend only assigns ZEND_VIRTUAL_PROPERTY_OFFSET when a virtual + // property's declaration value is IS_UNDEF. + $code .= "\tzval $zvalName;\n"; + $code .= "\tZVAL_UNDEF(&$zvalName);\n"; + } elseif ($useEmptyArrayDefault) { $code .= "\tzval $zvalName;\n"; $code .= "\tZVAL_EMPTY_ARRAY(&$zvalName);\n"; } elseif ($this->defaultValue === null && $this->type !== null) { @@ -3376,7 +3385,7 @@ class PropertyInfo extends VariableLike $commentCode = "NULL"; } - if (!empty($this->attributes)) { + if (!empty($this->attributes) || $this->hooks !== []) { $template = "\tzend_property_info *property_" . $this->name->getDeclarationName() . " = "; } else { $template = "\t"; @@ -3401,6 +3410,16 @@ 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"; + } + return $code; } @@ -4695,7 +4714,12 @@ class FileInfo { $classStmt->getComments(), $prettyPrinter, $this->getMinimumPhpVersionIdCompatibility(), - AttributeInfo::createFromGroups($classStmt->attrGroups) + AttributeInfo::createFromGroups($classStmt->attrGroups), + false, + $classStmt->getAttribute( + TypePhp\Transform\PropertyHookLowering::PROPERTY_ATTRIBUTE, + [], + ), ); } } else if ($classStmt instanceof Stmt\ClassMethod) { @@ -5309,7 +5333,8 @@ function parseProperty( PrettyPrinterAbstract $prettyPrinter, ?int $phpVersionIdMinimumCompatibility, array $attributes, - bool $isPromoted = false + bool $isPromoted = false, + array $hookMetadata = [], ): PropertyInfo { $phpDocType = null; @@ -5318,7 +5343,8 @@ function parseProperty( $isDocReadonly = array_key_exists('readonly', $tagMap); $link = $tagMap['link'] ?? null; - $isVirtual = array_key_exists('virtual', $tagMap); + $isVirtual = $hookMetadata['virtual'] ?? array_key_exists('virtual', $tagMap); + $hooks = $hookMetadata['methods'] ?? []; foreach ($tags as $tag) { if ($tag->name === 'var') { @@ -5351,6 +5377,7 @@ function parseProperty( $property->default ? $prettyPrinter->prettyPrintExpr($property->default) : null, $isDocReadonly, $isVirtual, + $hooks, $isPromoted, $link, $phpVersionIdMinimumCompatibility, diff --git a/tests/compiler/object_property/property-hooks-introspection.phpt b/tests/compiler/object_property/property-hooks-introspection.phpt new file mode 100644 index 00000000..fb3dcce3 --- /dev/null +++ b/tests/compiler/object_property/property-hooks-introspection.phpt @@ -0,0 +1,51 @@ +--TEST-- +PHP 8.4 object introspection invokes property get hooks +--FILE-- + strtoupper($this->backed); + set (string $value) { + $this->backed = trim($value); + } + } + + public string $virtual { + get => 'virtual:' . $this->stored; + set (string $value) { + $this->stored = $value; + } + } +} + +function main(): void +{ + $object = new IntrospectedPropertyHooks(); + $object->backed = ' value '; + $object->virtual = 'changed'; + + echo json_encode($object), "\n"; + var_dump(get_object_vars($object)); + foreach ($object as $name => $value) { + echo $name, '=', $value, "\n"; + } + + // PHP serialization exposes backing storage, not computed virtual values. + echo serialize($object), "\n"; +} +?> +--EXPECTF-- +{"backed":"VALUE","virtual":"virtual:changed"} +array(2) { + ["backed"]=> + string(5) "VALUE" + ["virtual"]=> + string(15) "virtual:changed" +} +backed=VALUE +virtual=virtual:changed +O:25:"IntrospectedPropertyHooks":2:{s:33:"%0IntrospectedPropertyHooks%0stored";s:7:"changed";s:6:"backed";s:5:"value";} diff --git a/tests/compiler/object_property/property-hooks-reflection.phpt b/tests/compiler/object_property/property-hooks-reflection.phpt new file mode 100644 index 00000000..3c897a70 --- /dev/null +++ b/tests/compiler/object_property/property-hooks-reflection.phpt @@ -0,0 +1,29 @@ +--TEST-- +PHP 8.4 property hooks expose Zend reflection metadata +--FILE-- + 'value'; + set { + } + } +} + +function main(): void +{ + $property = new ReflectionProperty(ReflectedPropertyHooks::class, 'virtual'); + var_dump($property->hasHooks()); + var_dump($property->isVirtual()); + foreach ($property->getHooks() as $kind => $hook) { + echo $kind, ':', $hook->getName(), ':', $hook->isFinal() ? 'final' : 'not-final', "\n"; + } +} +?> +--EXPECT-- +bool(true) +bool(true) +get:$virtual::get:not-final +set:$virtual::set:not-final diff --git a/tests/compiler/reflection/lazy-object-aot-class-limit.phpt b/tests/compiler/reflection/lazy-object-aot-class-limit.phpt new file mode 100644 index 00000000..69f4609e --- /dev/null +++ b/tests/compiler/reflection/lazy-object-aot-class-limit.phpt @@ -0,0 +1,30 @@ +--TEST-- +PHP 8.4 lazy objects reject persistent AOT class entries safely +--SKIPIF-- + +--FILE-- +newLazyGhost(function (AotLazyObjectTarget $target): void { + $target->name = 'loaded'; + }); + } catch (Error $error) { + echo $error->getMessage(), "\n"; + } +} +?> +--EXPECT-- +Cannot make instance of internal class lazy: AotLazyObjectTarget is internal