13 KiB
Zend Object 创建与属性默认值初始化
本文记录 TypePHP 生成的 Zend Class 在 MINIT 和对象创建阶段的初始化职责,重点说明何时需要自定义 create_object、其中允许执行哪些行为,以及对象创建热路径上的性能边界。
本文只讨论注册到 ZendVM 的普通 TypePHP Class。#[Native] Class 使用 Native Heap 与 GC,不走本文流程。
1. 两个初始化阶段必须分开
TypePHP Class 的属性初始化分为两个阶段:
gen_stub.php在 MINIT 生成register_class_*(),建立zend_class_entry、属性元数据和默认属性表;- 只有默认属性表无法准确表达的值,才在每次创建对象时由自定义
create_object补充。
这两个阶段不能重复执行相同的属性赋值。register_class_*() 已写入的值会由 Zend 的 object_properties_init() 复制到新对象;再次调用 zend_update_property() 不仅没有语义价值,还会进入属性名查找、类型检查、handler 分派和引用计数路径。
2. gen_stub.php 负责的默认值
以下值可以准确写入 Zend Class 的默认属性表:
| 源代码默认值 | 注册阶段表示 | 是否需要在 create_object 中再次写入 |
|---|---|---|
null |
ZVAL_NULL |
否 |
bool |
ZVAL_TRUE/FALSE |
否 |
int |
ZVAL_LONG |
否 |
float |
ZVAL_DOUBLE |
否 |
string |
持久化 zend_string |
否 |
| 标量常量表达式 | 编译期求值后的标量 zval | 否 |
[] |
ZVAL_EMPTY_ARRAY |
否 |
| 没有显式默认值的 TypePHP typed property | TypePHP 规定的零值、空字符串、空数组、null 或 UNDEF |
否 |
例如:
class Value
{
private const BASE = 20;
public int $id = self::BASE + 3;
public string $name = 'type' . 'php';
public array $items = [];
}
只要表达式能够在编译期安全求值,以上三个属性都应完全依赖 Zend Class 默认属性表。创建 Value 时不得再次调用 zend_update_property()。
3. 默认值何时需要运行时补充
当前 gen_stub.php 不能在默认属性表中准确表示以下值。
3.1 非空数组
非空数组默认值当前在注册函数中使用 ZVAL_EMPTY_ARRAY 作为占位值。每个对象必须构造独立、语义正确的数组值:
class Request
{
public array $options = ['timeout' => 10];
}
因此 Request::$options 需要在 create_object 中补充。多个对象仍遵守 PHP 数组的 copy-on-write 语义;修改一个对象的数组不得影响其他对象。
数组常量也遵守相同规则。若编译器只能确定它是数组、不能证明它为空,则保守地保留运行时初始化。
3.2 Enum case
Enum case 是对象,不是标量常量:
enum State
{
case Ready;
}
class Task
{
public State $state = State::Ready;
}
类注册代码当前只能先生成占位值,create_object 再取得真正的 enum case 对象并写入属性。因此“只有非空数组才需要自定义 create_object”并不成立,enum case 是明确的第二类反例。
3.3 无法安全解析的常量表达式
若预处理阶段无法证明默认值可由 Zend 默认属性表准确表达,编译器必须保守地保留运行时初始化。优化只能删除已证明冗余的工作,不能根据表达式外形猜测其运行时类型。
4. handlers 与父类 allocator
4.1 Property Hook 与非对称 set 可见性不单独触发
PHP 8.4 Property Hook、private(set) 和 protected(set) 会安装 TypePHP 自定义 object handlers,但这本身不要求覆盖 create_object。Zend 8.4 的 object_properties_init() 直接复制 class default table,不调用 read/write handler;普通 php_std_create_object 已能正确设置最终 handlers。
只有该类同时含有非空数组、enum case 等运行时默认值时,才需要自定义创建流程。补充初始化必须绕过 setter;即使使用 zend_std_write_property(),PHP 8.4 也会根据 Hook 元数据调用 setter。当前生成代码因此使用编译期已知的 property offset,经 PHPX Object::attr(offset) 直接更新 backing slot。
4.2 父类自定义对象分配器
若父类来自 PHP 内置扩展,或祖先类拥有自定义对象存储布局,子类不能绕过父类的 allocator。当前类因运行时默认值确实需要自定义创建流程时,必须先调用保存的父类 create_object,再补充当前类的值。
TypePHP 父类已经安装自定义 allocator 时,普通子类通常直接继承它。只有子类自身也需要补充初始化时,才生成新的委派层。
5. 自定义 create_object 的执行流程
生成代码通过 typephp_create_object_with_defaults() 完成以下步骤:
- 保存类最终的
default_object_handlers; - 若必须尊重父类对象布局,调用保存的父类 allocator;否则执行
zend_objects_new()与object_properties_init(); - 临时把新对象切换到 Zend 标准 object handlers,确保异常路径和其他对象操作处于可控状态;
- 只执行标记为
requiresRuntimeDefaultInit的属性初始化,并通过缓存的 declared-property offset 直接写 backing slot; - 每次写入后检查 Zend 异常;
- 无论正常返回还是发生 C++ 异常,都恢复最终 handlers;
- 返回已完整初始化的
zend_object *。
初始化器是模板参数和编译期 lambda,不使用 std::function,也不会为 lambda 动态分配内存。delegate_to_base 是调用点确定的布尔值,优化构建中通常可被 C++ 编译器折叠。
以下行为不属于 create_object:
- PHP
__construct()的函数体; - static property 默认值初始化;它在
php_app_init()中完成; - 已由默认属性表表达的标量、
null和空数组赋值; - clone 后重新应用默认值;clone 应复制源对象当前状态,而不是重新创建默认状态。
6. 已修复的主要性能问题
旧生成逻辑只要类中存在任意显式非 static 默认值,就安装自定义 create_object,并在每次创建对象时重新 update 所有默认属性。这会产生两层重复成本:
- 只含
public int $value = 0的普通类也绕过标准快速创建路径; - 一个类只要含有一个非空数组,其他标量属性也会被逐个重复 update。
当前规则已经调整为:
- 只有确实需要运行时补充的属性才使
requireCtor生效; - 已由
gen_stub.php准确注册的属性不会出现在运行时初始化 block 中; - 只有 Hook/非对称可见性而没有运行时默认值的类不再生成空的自定义 allocator;
- Hook 与运行时默认值同时存在时,使用固定 property offset 更新 backing slot,不调用 setter。
在 micro benchmark 中,仅包含标量属性的 new Foo() 已从约 1.8s 降至约 0.78s,与同环境 ZendPHP 扣除空循环后的约 0.83s 接近。该数字只用于记录优化量级,不是跨机器性能承诺。
7. 剩余性能成本与后续方向
7.1 非空数组应改为请求级模板与 copy-on-write
当前实现仍在每个对象中重新构建 php::Array 及所有元素,这是剩余的最大常见成本。不能把非空数组放进 internal class 的默认属性表,但这不等于必须为每个对象重新构建数组。
推荐生成一个请求级默认值模板:
- 每个包含运行时数组默认值的类拥有一组
THREAD_LOCAL php::Var模板和一个初始化状态; - 第一次创建该类对象时,通过
UNEXPECTED(!initialized)惰性构建该类的全部模板; - 后续创建对象时只把模板 zval 复制到目标 backing slot,即增加一次数组引用计数;
- 某个对象第一次修改该属性时,由 Zend/PHPX 的
SEPARATE_ARRAY执行 copy-on-write; - 在
php_app_clean()中释放模板并重置初始化状态,不能让 request allocator 分配的 HashTable 跨越 RSHUTDOWN。
以如下默认值为例:
class Request
{
public array $options = [
'timeout' => 10,
'headers' => ['Accept' => 'application/json'],
];
}
若创建一万个对象但不修改 $options,数组及嵌套数组只构建一次;每个对象只持有共享 zval。若其中一个对象执行 $request->options['timeout'] = 30,只有该对象在写入时分离,其他对象和模板保持不变。嵌套数组也继续使用 Zend 原有的逐层 copy-on-write 规则。
PHP 属性默认数组不能包含引用,允许出现在常量表达式中的对象主要是不可变的 enum case,因此共享模板符合默认属性语义。实现后仍须用 PHPT 覆盖顶层写入、嵌套写入、unset、引用写入和动态 ZendVM 写入,确认所有路径都会正确分离。
不能简单地在 MINIT 构造持久化数组并传给 zend_declare_typed_property()。TypePHP 注册的是 ZEND_INTERNAL_CLASS,Zend 8.4 明确禁止 internal property 使用 refcounted default zval;_object_properties_init() 的 internal-class 快速路径也不会增加默认值引用计数。非空 array 与 enum object 都属于 refcounted value。
因此,在不改变“TypePHP Class 注册为 internal class”这一基础设计、也不修改 Zend ABI 的前提下,非空数组仍不能进入 class default table;但可以在表外维护请求级模板,使数组构造成本从“每个对象一次”降为“每个请求、每个默认值一次”。
模板建议按类惰性初始化,而不是在 RINIT 无条件构建全部模板:大型项目中很多类在一次请求内不会实例化。每个对象只增加一个高度可预测的初始化状态分支;第一次之后该分支稳定为 false。
第二阶段可以为完全由标量、字符串和嵌套字面量组成的数组生成模块生命周期的 persistent immutable template。它能进一步消除每个请求的一次构建,但需要正确处理 persistent HashTable、interned string、嵌套数组、MSHUTDOWN 和 ZTS,并把包含运行时常量或 enum case 的数组留在请求级路径。该方案侵入性和验证成本明显更高,不应作为第一阶段实现。
7.2 已改为固定属性槽写入
运行时补充的属性在编译期已经知道 class、属性名、offset 和类型。当前实现复用 persistent property-offset cache,并通过 php::Object::attr(offset) 更新槽位,已经省去每个对象上的属性名 hash 查询、通用 write handler 和 Property Hook setter。
这里仍会为 initializer 建立一个短生命周期 php::Object carrier,并读取 offset cache。后续若 profiling 证明它是热点,可以在 MINIT 后直接保存最终 offset,或在 PHPX 增加不取得对象所有权的初始化 helper。任何进一步优化都必须继续处理旧值析构、引用计数、父类 private slot、Hook backing slot 和异常安全,不能退回裸指针的无保护赋值。
7.3 Enum case 可提前绑定
Enum case 同样是 refcounted object,不能直接作为 internal class 默认 zval。可考虑在 MINIT 缓存稳定的 enum case 指针或 zval,再在每次创建对象时执行正确的引用计数复制,从而省去重复 class/case 查找;仍不能省略对象属性写入本身。
7.4 继承链上的多层 allocator
父类和子类都拥有运行时默认值时,创建流程会逐层委派并执行各自初始化,成本随相关继承层数增长。未来可以对完全由 TypePHP 控制、且没有特殊对象布局的继承链合并初始化计划;内置扩展父类仍必须调用其 allocator。
7.5 保守常量可能产生不必要的 allocator
无法在预处理阶段解析的常量会保守进入运行时路径。可以在符号准备完成后增加一次统一的常量默认值分类,减少“实际是标量,但早期无法证明”的自定义 allocator。该优化必须保留 enum case 和数组常量的区别。
7.6 自定义 handlers 的动态访问成本
TypePHP 当前为普通 Zend Class 安装属性 handlers,以支持 typed property 的 unset 语义、Property Hook 和非对称写可见性。安装发生在 MINIT,不等同于安装自定义 create_object;但动态属性读写仍可能进入 handler。已被编译器解析为固定槽位的 Native 属性访问不应因此退化。
8. 回归测试要求
修改该流程至少应覆盖:
- 标量、标量常量表达式和空数组不生成自定义 allocator;
- 非空数组生成 allocator,且两个对象的数组修改互不影响;
- enum case 默认值在对象创建后是真正的 enum object;
- 仅含 Property Hook 或非对称 set 可见性的类不生成空 allocator,且 Reflection、动态读写行为不退化;
- Property Hook/非对称属性与运行时默认值组合时不触发 setter;
- 父子类分别声明运行时默认值时,父类和子类属性都正确;
- 继承内置扩展类时不破坏其对象布局;
- 异常路径恢复 object handlers;
- 自举编译和完整 PHPUnit/PHPT 回归通过。
当前针对代码生成的核心断言位于 NewObjectCodegenTest,运行语义由 default-initialization-paths.phpt、default-expressions-inheritance.phpt 和 Property Hook 测试组覆盖。