- 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
parent
ea08344c92
commit
2f229ee0ac
21 changed files with 736 additions and 23 deletions
@ -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 元数据中,不进入请求热路径。 |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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, |
||||||
|
) { |
||||||
|
} |
||||||
|
} |
||||||
@ -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…
Reference in new issue