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
pull/48/head
韩天峰 2 weeks ago
parent 01ea7c40be
commit 2a1ca3b1a0
  1. 3
      docs/INCOMPATIBLE_PHP_FEATURES.md
  2. 23
      src/Transform/PropertyHookLowering.php
  3. 37
      src/gen_stub.php
  4. 51
      tests/compiler/object_property/property-hooks-introspection.phpt
  5. 29
      tests/compiler/object_property/property-hooks-reflection.phpt
  6. 30
      tests/compiler/reflection/lazy-object-aot-class-limit.phpt

@ -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()` 不允许返回值。

@ -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<Stmt> $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;
}
}

@ -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,

@ -0,0 +1,51 @@
--TEST--
PHP 8.4 object introspection invokes property get hooks
--FILE--
<?php
final class IntrospectedPropertyHooks
{
private string $stored = 'initial';
public string $backed = 'raw' {
get => 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";}

@ -0,0 +1,29 @@
--TEST--
PHP 8.4 property hooks expose Zend reflection metadata
--FILE--
<?php
final class ReflectedPropertyHooks
{
public string $virtual {
get => '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

@ -0,0 +1,30 @@
--TEST--
PHP 8.4 lazy objects reject persistent AOT class entries safely
--SKIPIF--
<?php
if (PHP_VERSION_ID < 80400) {
die('skip requires PHP 8.4');
}
?>
--FILE--
<?php
final class AotLazyObjectTarget
{
public string $name;
}
function main(): void
{
$reflection = new ReflectionClass(AotLazyObjectTarget::class);
try {
$reflection->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
Loading…
Cancel
Save