diff --git a/docs/OBJECT_CREATION.md b/docs/OBJECT_CREATION.md new file mode 100644 index 00000000..55163abc --- /dev/null +++ b/docs/OBJECT_CREATION.md @@ -0,0 +1,212 @@ +# Zend Object 创建与属性默认值初始化 + +本文记录 TypePHP 生成的 Zend Class 在 MINIT 和对象创建阶段的初始化职责,重点说明何时需要自定义 `create_object`、其中允许执行哪些行为,以及对象创建热路径上的性能边界。 + +本文只讨论注册到 ZendVM 的普通 TypePHP Class。`#[Native]` Class 使用 Native Heap 与 GC,不走本文流程。 + +## 1. 两个初始化阶段必须分开 + +TypePHP Class 的属性初始化分为两个阶段: + +1. `gen_stub.php` 在 MINIT 生成 `register_class_*()`,建立 `zend_class_entry`、属性元数据和默认属性表; +2. 只有默认属性表无法准确表达的值,才在每次创建对象时由自定义 `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` | 否 | + +例如: + +```php +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` 作为占位值。每个对象必须构造独立、语义正确的数组值: + +```php +class Request +{ + public array $options = ['timeout' => 10]; +} +``` + +因此 `Request::$options` 需要在 `create_object` 中补充。多个对象仍遵守 PHP 数组的 copy-on-write 语义;修改一个对象的数组不得影响其他对象。 + +数组常量也遵守相同规则。若编译器只能确定它是数组、不能证明它为空,则保守地保留运行时初始化。 + +### 3.2 Enum case + +Enum case 是对象,不是标量常量: + +```php +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()` 完成以下步骤: + +1. 保存类最终的 `default_object_handlers`; +2. 若必须尊重父类对象布局,调用保存的父类 allocator;否则执行 `zend_objects_new()` 与 `object_properties_init()`; +3. 临时把新对象切换到 Zend 标准 object handlers,确保异常路径和其他对象操作处于可控状态; +4. 只执行标记为 `requiresRuntimeDefaultInit` 的属性初始化,并通过缓存的 declared-property offset 直接写 backing slot; +5. 每次写入后检查 Zend 异常; +6. 无论正常返回还是发生 C++ 异常,都恢复最终 handlers; +7. 返回已完整初始化的 `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 所有默认属性。这会产生两层重复成本: + +1. 只含 `public int $value = 0` 的普通类也绕过标准快速创建路径; +2. 一个类只要含有一个非空数组,其他标量属性也会被逐个重复 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 的默认属性表,但这不等于必须为每个对象重新构建数组。 + +推荐生成一个请求级默认值模板: + +1. 每个包含运行时数组默认值的类拥有一组 `THREAD_LOCAL php::Var` 模板和一个初始化状态; +2. 第一次创建该类对象时,通过 `UNEXPECTED(!initialized)` 惰性构建该类的全部模板; +3. 后续创建对象时只把模板 zval 复制到目标 backing slot,即增加一次数组引用计数; +4. 某个对象第一次修改该属性时,由 Zend/PHPX 的 `SEPARATE_ARRAY` 执行 copy-on-write; +5. 在 `php_app_clean()` 中释放模板并重置初始化状态,不能让 request allocator 分配的 HashTable 跨越 RSHUTDOWN。 + +以如下默认值为例: + +```php +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 测试组覆盖。 diff --git a/docs/README.md b/docs/README.md index c5e654aa..b5048d0b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,7 @@ - [重建 PHPX WASM 静态库](PHPX_WASM_BUILD.md):增量重编 `libphpx.a`、数值依赖重建与完整 SDK 重建边界。 - [核心重构计划](REFACTORING_PLAN.md) - [作用域管理设计](SCOPE_MANAGEMENT.md):`CallableScope`、`UserCodeScopeGuard` 与 `FakeScopeGuard` 的职责和使用边界。 +- [Zend Object 创建与属性默认值初始化](OBJECT_CREATION.md):`gen_stub.php` 默认属性表、自定义 `create_object` 的触发条件、执行流程与性能边界。 - [Native Class Object 设计](NATIVE_CLASS_OBJECT.md) 与 [实现验收矩阵](NATIVE_CLASS_IMPLEMENTATION_AUDIT.md)。 - [PHP 8.4 Property Hook 集成设计](PROPERTY_HOOKS.md):编译期 lowering、Zend Hook 元数据、对象内省及 PHPX ABI 边界。 - [Interface Property Hook 实现方案](INTERFACE_PROPERTY_HOOKS.md):接口属性契约、编译期方差检查及 PHP 8.4 抽象 Hook 元数据。 diff --git a/examples/bench.php b/examples/bench.php index ec3dd722..b8ed3f9f 100644 --- a/examples/bench.php +++ b/examples/bench.php @@ -1,7 +1,7 @@ )|&IH%*#"; //float r, i, z, Z, t, c, C; for ($y=30; printf("\n"), $C = $y*0.1 - 1.5, $y--;){ @@ -104,20 +104,20 @@ function mandel2() { /****/ -function Ack(int $m, int $n){ +function Ack(int $m, int $n): int { if($m == 0) return $n+1; if($n == 0) return Ack($m-1, 1); return Ack($m - 1, Ack($m, ($n - 1))); } -function ackermann(int $n) { +function ackermann(int $n): void { $r = Ack(3, $n); print "Ack(3,$n): $r\n"; } /****/ -function ary($n) { +function ary(int $n): void { for ($i=0; $i<$n; $i++) { $X[$i] = $i; } @@ -130,7 +130,7 @@ function ary($n) { /****/ -function ary2($n) { +function ary2(int $n): void { for ($i=0; $i<$n;) { $X[$i] = $i; ++$i; $X[$i] = $i; ++$i; @@ -163,7 +163,7 @@ function ary2($n) { /****/ -function ary3(int $n) { +function ary3(int $n): void { for ($i=0; $i<$n; $i++) { $X[$i] = $i + 1; $Y[$i] = 0; @@ -190,7 +190,7 @@ function fibo(int $n): void { /****/ -function hash1(int $n) { +function hash1(int $n): void { for ($i = 1; $i <= $n; $i++) { $X[dechex($i)] = $i; } @@ -203,7 +203,7 @@ function hash1(int $n) { /****/ -function hash2(int $n) { +function hash2(int $n): void { for ($i = 0; $i < $n; $i++) { $hash1["foo_$i"] = $i; $hash2["foo_$i"] = 0; @@ -218,12 +218,12 @@ function hash2(int $n) { /****/ -function gen_random (int $n) { +function gen_random(int $n): float { global $LAST; return( ($n * ($LAST = ($LAST * IA + IC) % IM)) / IM ); } -function heapsort_r(int $n, &$ra) { +function heapsort_r(int $n, array &$ra): void { $l = ($n >> 1) + 1; $ir = $n; @@ -255,7 +255,7 @@ function heapsort_r(int $n, &$ra) { } } -function heapsort(int $N) { +function heapsort(int $N): void { global $LAST; define("IM", 139968); @@ -272,7 +272,7 @@ function heapsort(int $N) { /****/ -function mkmatrix ($rows, $cols) { +function mkmatrix(int $rows, int $cols): array { $count = 1; $mx = array(); for ($i=0; $i<$rows; $i++) { @@ -283,7 +283,7 @@ function mkmatrix ($rows, $cols) { return ($mx); } -function mmult ($rows, $cols, $m1, $m2) { +function mmult(int $rows, int $cols, array $m1, array $m2): array { $m3 = array(); for ($i=0; $i<$rows; $i++) { for ($j=0; $j<$cols; $j++) { @@ -297,7 +297,7 @@ function mmult ($rows, $cols, $m1, $m2) { return($m3); } -function matrix(int $n) { +function matrix(int $n): void { $SIZE = 30; $m1 = mkmatrix($SIZE, $SIZE); $m2 = mkmatrix($SIZE, $SIZE); @@ -309,7 +309,7 @@ function matrix(int $n) { /****/ -function nestedloop($n) { +function nestedloop(int $n): void { $x = 0; for ($a=0; $a<$n; $a++) for ($b=0; $b<$n; $b++) @@ -323,7 +323,7 @@ function nestedloop($n) { /****/ -function sieve(int $n) { +function sieve(int $n): void { $count = 0; while ($n-- > 0) { $count = 0; @@ -342,7 +342,7 @@ function sieve(int $n) { /****/ -function strcat($n) { +function strcat(int $n): void { $str = ""; while ($n-- > 0) { $str .= "hello\n"; @@ -359,13 +359,13 @@ function gethrtime(): float return (($hrtime[0] * 1000000000.0 + $hrtime[1]) / 1000000000.0); } -function start_test() +function start_test(): float { ob_start(); return gethrtime(); } -function end_test($start, $name) +function end_test(float $start, string $name): float { global $total; $end = gethrtime(); @@ -379,7 +379,7 @@ function end_test($start, $name) return gethrtime(); } -function total() +function total(): void { global $total; $pad = str_repeat("-", 24); @@ -389,7 +389,7 @@ function total() echo "Total" . $pad . $num . "\n"; } -function main() +function main(): void { if (function_exists("date_default_timezone_set")) { date_default_timezone_set("UTC"); diff --git a/examples/micro_bench.php b/examples/micro_bench.php index 378be03d..8d408c77 100644 --- a/examples/micro_bench.php +++ b/examples/micro_bench.php @@ -348,7 +348,7 @@ function main() $x->call(N); $t = end_test($t, '$this->f()', $overhead); $x->read_const(N); - $t = end_test($t, '$x = Foo::TEST', $overhead); + $t = end_test($t, '$x = $this::TEST', $overhead); create_object(N); $t = end_test($t, 'new Foo()', $overhead); read_const(N); diff --git a/phpunit/code/class-constant-codegen.php b/phpunit/code/class-constant-codegen.php new file mode 100644 index 00000000..15dd9b9b --- /dev/null +++ b/phpunit/code/class-constant-codegen.php @@ -0,0 +1,16 @@ + 0) { + ++$value; + } + + return $assigned + $length + strlen($safe) + strlen($ordered) + $value; +} diff --git a/phpunit/code/new-object-codegen.php b/phpunit/code/new-object-codegen.php new file mode 100644 index 00000000..c1954618 --- /dev/null +++ b/phpunit/code/new-object-codegen.php @@ -0,0 +1,70 @@ + $this->value; + set => $value; + } +} + +class AsymmetricOnlyDefaultCodegen +{ + public private(set) int $value = 0; +} + +function createKnownObjects(int $count): void +{ + for ($i = 0; $i < $count; ++$i) { + $object = new KnownNewObjectCodegen(); + } +} + +function createRuntimeObject(): object +{ + return new RuntimeProvidedObjectCodegen(); +} + +function main(): void +{ +} diff --git a/phpunit/src/ClassConstantCodegenTest.php b/phpunit/src/ClassConstantCodegenTest.php new file mode 100644 index 00000000..12529427 --- /dev/null +++ b/phpunit/src/ClassConstantCodegenTest.php @@ -0,0 +1,34 @@ +compileFixture(); + + self::assertMatchesRegularExpression( + '/php_classconstantcodegen__readthis\([^)]*\)[\s\S]*?= \(23L\);/', + $code, + ); + self::assertStringContainsString('php::classConstant(', $code); + self::assertStringNotContainsString('php::constant(php::concat({', $code); + } + + private function compileFixture(): string + { + global $translator; + + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $source = ROOT_PATH . '/phpunit/code/class-constant-codegen.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + return $code; + } +} diff --git a/phpunit/src/HotPathCodegenTest.php b/phpunit/src/HotPathCodegenTest.php new file mode 100644 index 00000000..d3df9603 --- /dev/null +++ b/phpunit/src/HotPathCodegenTest.php @@ -0,0 +1,54 @@ +compileFixture(); + + self::assertStringContainsString('items.item(0L, true) = value;', $code); + self::assertStringContainsString('items.append(value);', $code); + self::assertStringContainsString('items.item(0L, true) += value;', $code); + self::assertStringContainsString('items.item(0L, true) += other.item(0L, false);', $code); + self::assertStringContainsString('items.item(2L, true) = other.item(0L, false);', $code); + self::assertStringContainsString('items.offsetSet(0L,', $code); + } + + public function testSafeTwoOperandConcatAndExactStringArgumentStayUnboxed(): void + { + $code = $this->compileFixture(); + + self::assertMatchesRegularExpression( + '/php::concat\(_literal_strings\[\d+\], limit\)/', + $code, + ); + self::assertStringContainsString('php::concat({', $code); + self::assertStringNotContainsString('php::fn::strlen(php::toString(php::concat(', $code); + } + + public function testNativePostDecrementConditionUsesNativeTemporary(): void + { + $code = $this->compileFixture(); + + self::assertMatchesRegularExpression('/php::Int (tmp_var_\d+) = 0;[\s\S]*?\\1 = php::toInt\(limit--\);/', $code); + self::assertDoesNotMatchRegularExpression('/php::Var (tmp_var_\d+);[\s\S]*?\\1 = limit--;/', $code); + } + + private function compileFixture(): string + { + global $translator; + + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $source = ROOT_PATH . '/phpunit/code/hot-path-codegen.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + return $code; + } +} diff --git a/phpunit/src/LocalVariableInitializerTest.php b/phpunit/src/LocalVariableInitializerTest.php index 51b9364b..fbf54b3b 100644 --- a/phpunit/src/LocalVariableInitializerTest.php +++ b/phpunit/src/LocalVariableInitializerTest.php @@ -117,6 +117,6 @@ final class LocalVariableInitializerTest extends \BaseTest self::assertStringContainsString('php::Var runtimeClassConstant;', $code); self::assertStringContainsString('runtimeClassConstant = php::constant(', $code); self::assertStringContainsString('php::Var dynamicClass;', $code); - self::assertStringContainsString('dynamicClass = php::constant(', $code); + self::assertStringContainsString('dynamicClass = php::classConstant(', $code); } } diff --git a/phpunit/src/NativePropertyTest.php b/phpunit/src/NativePropertyTest.php index fedcccb2..cf9e288f 100644 --- a/phpunit/src/NativePropertyTest.php +++ b/phpunit/src/NativePropertyTest.php @@ -86,7 +86,9 @@ class NativePropertyTest extends \BaseTest $code = file_get_contents($outputFile); $this->assertStringContainsString('typephp_static_int_ref(this_.attr(', $code); - $this->assertStringContainsString('&= (~php::toInt(php::constant(', $code); + // A TypePHP class constant is available during conversion and is + // folded before the native property operation is emitted. + $this->assertStringContainsString('&= (~php::toInt(1L));', $code); } public function testNativePropertyWriteConvertsOnlyWhenTypesDiffer(): void diff --git a/phpunit/src/NewObjectCodegenTest.php b/phpunit/src/NewObjectCodegenTest.php new file mode 100644 index 00000000..0b5de62a --- /dev/null +++ b/phpunit/src/NewObjectCodegenTest.php @@ -0,0 +1,134 @@ +compileFixture(); + + self::assertMatchesRegularExpression( + '/php_createknownobjects\([^)]*\) \{[\s\S]*?zend_class_entry \*(tmp_var_\d+) = php_get_persistent_class\([^;]+;[\s\S]*?php::newObject\(\1\)/', + $code, + ); + self::assertStringNotContainsString( + 'php::newObject(php_get_persistent_class(', + $code, + ); + } + + public function testRuntimeProvidedClassStillResolvesAtNewExpression(): void + { + $code = $this->compileFixture(); + + self::assertMatchesRegularExpression( + '/php_createruntimeobject\([^)]*\)[\s\S]*?php::newObject\(php_get_class\(/', + $code, + ); + } + + public function testStubRepresentablePropertyDefaultsUseZendDefaultTable(): void + { + [, $extension] = $this->compileFixtureAndExtension(); + + self::assertStringNotContainsString( + 'create_object_KnownNewObjectCodegen = php_get_create_object_fn', + $extension, + ); + self::assertStringNotContainsString( + 'create_object_EmptyArrayDefaultCodegen = php_get_create_object_fn', + $extension, + ); + self::assertStringNotContainsString( + 'create_object_ScalarExpressionDefaultCodegen = php_get_create_object_fn', + $extension, + ); + self::assertStringNotContainsString( + 'create_object_ScalarConstantDefaultCodegen = php_get_create_object_fn', + $extension, + ); + self::assertStringContainsString( + 'create_object_RuntimeArrayDefaultCodegen = php_get_create_object_fn', + $extension, + ); + self::assertStringNotContainsString( + 'zend_update_property(php_class_entry_RuntimeArrayDefaultCodegen, obj, ZEND_STRL("scalar")', + $extension, + ); + self::assertStringContainsString( + 'create_object_EnumPropertyDefaultCodegen = php_get_create_object_fn', + $extension, + ); + self::assertStringNotContainsString( + 'create_object_HookOnlyDefaultCodegen = php_get_create_object_fn', + $extension, + ); + self::assertStringNotContainsString( + 'create_object_AsymmetricOnlyDefaultCodegen = php_get_create_object_fn', + $extension, + ); + } + + public function testRuntimeArrayDefaultsUseLazyRequestTemplates(): void + { + [, $extension] = $this->compileFixtureAndExtension(); + + self::assertStringContainsString( + 'THREAD_LOCAL bool php_request_array_defaults_initialized_RuntimeArrayDefaultCodegen = false;', + $extension, + ); + self::assertStringContainsString( + 'THREAD_LOCAL php::Var php_request_array_default_runtimearraydefaultcodegen__values;', + $extension, + ); + self::assertStringContainsString( + 'THREAD_LOCAL php::Var php_request_array_default_runtimearraydefaultcodegen__labels;', + $extension, + ); + self::assertMatchesRegularExpression( + '/if \(UNEXPECTED\(!php_request_array_defaults_initialized_RuntimeArrayDefaultCodegen\)\) \{[\s\S]*prepared_default_0[\s\S]*prepared_default_1[\s\S]*php_request_array_defaults_initialized_RuntimeArrayDefaultCodegen = true;/', + $extension, + ); + self::assertMatchesRegularExpression( + '/create_object_RuntimeArrayDefaultCodegen[^=]*= \[\][\s\S]*php_ensure_request_array_defaults_RuntimeArrayDefaultCodegen\(\);[\s\S]*typephp_create_object_with_defaults/', + $extension, + ); + self::assertStringContainsString( + '= php_request_array_default_runtimearraydefaultcodegen__values;', + $extension, + ); + self::assertStringContainsString( + 'php_request_array_default_runtimearraydefaultcodegen__values.unset();', + $extension, + ); + self::assertStringContainsString( + 'php_request_array_default_runtimearraydefaultcodegen__labels.unset();', + $extension, + ); + } + + private function compileFixture(): string + { + return $this->compileFixtureAndExtension()[0]; + } + + /** @return array{string, string} */ + private function compileFixtureAndExtension(): array + { + global $translator; + + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $source = ROOT_PATH . '/phpunit/code/new-object-codegen.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + $extension = file_get_contents($compiler->genExtension()); + + self::assertIsString($code); + self::assertIsString($extension); + return [$code, $extension]; + } +} diff --git a/phpunit/src/OperatorTest.php b/phpunit/src/OperatorTest.php index c0f1e30b..c175f591 100644 --- a/phpunit/src/OperatorTest.php +++ b/phpunit/src/OperatorTest.php @@ -50,10 +50,10 @@ class OperatorTest extends \BaseTest $cppFile = $compiler->convertFile($testFile); $cpp = file_get_contents($cppFile); - $this->assertMatchesRegularExpression( - '/return php::toBool\(php::call\([^\n]+\)\);/', - $cpp, - ); + // Ordered operands may be materialized before the return statement, + // but the dynamic call result must still cross an explicit Bool + // conversion boundary before C++ logical operators consume it. + $this->assertStringContainsString('php::toBool(php::call(', $cpp); } public function testLiteralIntDivideByZeroDoesNotCompile(): void diff --git a/phpunit/src/StringConcatAssignTest.php b/phpunit/src/StringConcatAssignTest.php index b988d08a..2d54f5bd 100644 --- a/phpunit/src/StringConcatAssignTest.php +++ b/phpunit/src/StringConcatAssignTest.php @@ -18,7 +18,7 @@ final class StringConcatAssignTest extends \BaseTest self::assertIsString($code); self::assertGreaterThanOrEqual(3, substr_count($code, 'value.append(')); - self::assertStringContainsString('value.append(php::concat({', $code); + self::assertStringContainsString('value.append(php::concat(', $code); self::assertStringNotContainsString('value = php::concat({value,', $code); } } diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 99ba6d8d..777ff9dc 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -1337,6 +1337,25 @@ class CompilerBase implements PropertyAccessContext return $helper . '(' . $id . ', ' . $this->getLiteralString($className) . ')'; } + /** + * Resolve a process-stable class once at function entry. This keeps class + * table/cache lookup out of loops containing `new KnownClass()` while + * leaving runtime-provided classes at their original expression site. + */ + protected function getLocalClassEntryPtr(string $className): string + { + if (!$this->isProcessStableClass($className)) { + return $this->getClassEntryPtr($className); + } + if (isset($this->context->classEntryPtrs[$className])) { + return $this->context->classEntryPtrs[$className]; + } + + $entry = $this->genTmpVarName(); + $this->context->classEntryPtrs[$className] = $entry; + return $entry; + } + /** The declaring class controls visibility; the runtime called class does not. */ protected function getCallableScopeExpr(): string { @@ -3859,7 +3878,7 @@ class CompilerBase implements PropertyAccessContext . $this->getNativeObjectInitializerName($className) . '(this_); ' . self::PREFIX . $nativeCtor . '(this_' . $nativeArgs . '); })'; } - $cePtr = $this->getClassEntryPtr($className); + $cePtr = $this->getLocalClassEntryPtr($className); } } else { $cePtr = $className; @@ -5102,6 +5121,10 @@ class CompilerBase implements PropertyAccessContext . ', this_);' . PHP_EOL; } $code .= $this->genLocalVarDecl($this->context->localVars); + foreach ($this->context->classEntryPtrs as $className => $entry) { + $code .= $this->getIndent() . 'zend_class_entry *' . $entry . ' = ' + . $this->getClassEntryPtr($className) . ';' . PHP_EOL; + } if ($this->context->nativeObjects !== []) { $rootSlots = []; foreach ($this->context->nativeObjects as $name => $_class) { diff --git a/src/Context/FunctionContext.php b/src/Context/FunctionContext.php index 36796ce6..9a0dda35 100644 --- a/src/Context/FunctionContext.php +++ b/src/Context/FunctionContext.php @@ -68,6 +68,8 @@ class FunctionContext * @var array */ public array $ceWrappers = []; + /** @var array Process-stable class name => function-local zend_class_entry* variable. */ + public array $classEntryPtrs = []; /** Reusable php::CallableScope local, created only when this function performs scoped calls. */ public ?string $callableScopeVar = null; /** This generated body needs a temporary lexical scope on the nearest user-code frame. */ @@ -124,6 +126,7 @@ class FunctionContext $this->unsafeObjectProps = []; $this->staticPropRefs = []; $this->ceWrappers = []; + $this->classEntryPtrs = []; $this->callableScopeVar = null; $this->tmpVarIndex = 0; $this->scopeLayouts = []; @@ -167,6 +170,7 @@ class FunctionContext $this->objectProps = []; $this->hoistedProps = []; $this->staticPropRefs = []; + $this->classEntryPtrs = []; $this->scopeLayouts = []; $this->scopeLevel = 0; $this->inLoop = false; diff --git a/src/Entity/PropertyDef.php b/src/Entity/PropertyDef.php index 9daa7ee9..331c34e3 100644 --- a/src/Entity/PropertyDef.php +++ b/src/Entity/PropertyDef.php @@ -27,6 +27,10 @@ class PropertyDef public string $typeStr = ''; public bool $promoted = false; public bool $readonly = false; + /** The generated Zend property table cannot represent this default exactly. */ + public bool $requiresRuntimeDefaultInit = false; + /** Cached declared-property offset used by the runtime default initializer. */ + public string $runtimeDefaultOffset = ''; public ?string $getter = null; public ?string $setter = null; public bool $virtual = false; diff --git a/src/Optimizer/FuncCallOptimizer.php b/src/Optimizer/FuncCallOptimizer.php index d8b4c2f7..eec93303 100644 --- a/src/Optimizer/FuncCallOptimizer.php +++ b/src/Optimizer/FuncCallOptimizer.php @@ -402,15 +402,51 @@ trait FuncCallOptimizer protected function resolveArg(Node\Expr\FuncCall $expr, int $index, string $type): string { $base = ($type[0] ?? '') === self::ARG_OPTIONAL ? substr($type, 1) : $type; + $arg = $expr->args[$index]->value; + $raw = ($base === self::ARG_TYPE_REF) ? $this->getRefArg($expr, $index) : $this->getArg($expr, $index); if ($base === self::ARG_TYPE_ARRAY) { - return $this->convertStdContainerArrayExpr($expr, $index, $this->getArg($expr, $index)); + if ($this->argumentAlreadyHasExactType($arg, Type::ARRAY)) { + return $raw; + } + return $this->convertStdContainerArrayExpr($expr, $index, $raw); + } + + $exactType = match ($base) { + self::ARG_TYPE_STR => Type::STR, + self::ARG_TYPE_INT => Type::INT, + self::ARG_TYPE_FLOAT => Type::FLOAT, + self::ARG_TYPE_BOOL => Type::BOOL, + default => null, + }; + if ($exactType !== null && $this->argumentAlreadyHasExactType($arg, $exactType)) { + return $raw; } - $raw = ($base === self::ARG_TYPE_REF) ? $this->getRefArg($expr, $index) : $this->getArg($expr, $index); return $this->applyArgConversion($raw, $type); } + protected function argumentAlreadyHasExactType(Node\Expr $arg, string $type): bool + { + if ($this->isVarExpr($arg)) { + return !$this->isStdContainer($arg->name) + && $this->hasVar($arg->name) + && $this->getVarType($arg->name) === $type; + } + + // Semantic type information is not enough here: a dynamically read + // typed property, for example, is still represented by php::Variant. + // Limit the shortcut to expressions whose generated C++ value has a + // fixed representation without an implicit PHPX conversion. + $fixedRepresentation = $arg instanceof Node\Scalar + || $arg instanceof Node\Expr\Cast + || $arg instanceof Node\Expr\Array_ + || $arg instanceof Node\Expr\BinaryOp\Concat + || $arg instanceof Node\Expr\ConstFetch; + + return $fixedRepresentation && $this->detectTypeOfExpr($arg) === $type; + } + protected function applyArgConversion(string $cxxExpr, string $type): string { $base = ($type[0] ?? '') === self::ARG_OPTIONAL ? substr($type, 1) : $type; diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index f48f772d..6777c170 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -19,7 +19,11 @@ use PhpParser\NodeAbstract; trait AssignOpTrait { - protected function parseAssignArrayDim(NodeAbstract $left, NodeAbstract $right): string + protected function parseAssignArrayDim( + NodeAbstract $left, + NodeAbstract $right, + bool $resultUnused = false, + ): string { if ($left instanceof Expr\ArrayDimFetch && $left->dim !== null) { $this->assertNotNativeObjectArrayKey($left->dim); @@ -58,9 +62,6 @@ trait AssignOpTrait $value = $this->parseExprAsValue($right); - $tmp = $this->genTmpVarName(); - $this->addLocalVar($tmp, Type::VAR); - // item(dim, true) updates an existing reference's value, while offsetSet() // replaces the array bucket and breaks the reference. Keep offsetSet() for // ArrayAccess objects; dynamically typed/reference containers need a @@ -68,10 +69,26 @@ trait AssignOpTrait $arrayType = $this->getVarType($array); if ($left->dim === null) { + if ($resultUnused + && $arrayType === Type::ARRAY + && $this->canEmitDirectArrayWriteOperand($right) + ) { + return $code . $array . '.append(' . $value . ')'; + } + $tmp = $this->addTmpVar(Type::VAR); return $code . '((' . $tmp . ' = ' . $value . ', ' . "{$array}.offsetSet(" . self::VALUE_NULL . ", {$tmp})" . '), ' . $tmp . ')'; } $dim = $this->parseIdentifier($left->dim); + if ($resultUnused + && $arrayType === Type::ARRAY + && !$this->shouldMaterializeOrderedOperand($left->dim) + && $this->canEmitDirectArrayWriteOperand($right) + ) { + return $code . $array . '.item(' . $dim . ', true) = ' . $value; + } + + $tmp = $this->addTmpVar(Type::VAR); if ($arrayType === Type::ARRAY) { return $code . '((' . $tmp . ' = ' . $value . ', ' . "{$array}.item({$dim}, true) = {$tmp}" . '), ' . $tmp . ')'; } @@ -156,6 +173,7 @@ trait AssignOpTrait $left, $right, $this->canFoldLocalInitializerIntoDeclaration($v), + $v->getAttribute(self::ATTR_STATEMENT_EXPRESSION, false), ); } @@ -283,6 +301,7 @@ trait AssignOpTrait Expr $left, Expr $right, bool $foldIntoDeclaration = false, + bool $resultUnused = false, ): string { $this->assertImmutableMutationTarget($left); @@ -635,7 +654,7 @@ trait AssignOpTrait if ($this->isStdContainerExpr($left)) { return $this->parseStdContainerAssign($left, $right); } - return $this->parseAssignArrayDim($left, $right); + return $this->parseAssignArrayDim($left, $right, $resultUnused); } elseif ($this->isArrayDimFetch($left) and $this->isPropertyFetch($left->var)) { return $this->parseAssignPropertyArrayDim($left, $right); } elseif ($this->isArrayDimFetch($left) and $this->isStaticPropertyFetch($left->var)) { @@ -891,6 +910,12 @@ trait AssignOpTrait if ($this->isStdContainerExpr($node->var)) { return $this->parseStdContainerAssignOp($node, $op); } + if ($this->canUpdateKnownArraySlotInPlace($node, $op)) { + $array = $this->parseWritableIdentifier($node->var->var); + $dim = $this->parseIdentifier($node->var->dim); + return $array . '.item(' . $dim . ', true) ' . $op . ' ' + . $this->parseExprAsValue($node->expr); + } /** * $count[$r] -= 1; * 需要转为下面语句: @@ -1095,6 +1120,41 @@ trait AssignOpTrait return 'php::' . $class . '::' . $method . '(' . $leftExpr . ', ' . $convertedRight . ')'; } + private function canUpdateKnownArraySlotInPlace(Expr\AssignOp $node, string $op): bool + { + if (!$node->getAttribute(self::ATTR_STATEMENT_EXPRESSION, false) + || !$node->var instanceof Expr\ArrayDimFetch + || $node->var->dim === null + || !$this->isVarExpr($node->var->var) + || $this->shouldMaterializeOrderedOperand($node->var->dim) + || !$this->canEmitDirectArrayWriteOperand($node->expr) + ) { + return false; + } + + $array = $this->parseIdentifier($node->var->var); + return $this->hasVar($array) + && $this->getVarType($array) === Type::ARRAY + && in_array($op, ['+=', '-=', '*=', '/=', '%=', '<<=', '>>=', '&=', '|=', '^='], true); + } + + private function canEmitDirectArrayWriteOperand(NodeAbstract $expr): bool + { + if (!$this->shouldMaterializeOrderedOperand($expr)) { + return true; + } + if (!$expr instanceof Expr\ArrayDimFetch + || $expr->dim === null + || !$this->isVarExpr($expr->var) + || $this->shouldMaterializeOrderedOperand($expr->dim) + ) { + return false; + } + + $array = $this->parseIdentifier($expr->var); + return $this->hasVar($array) && $this->getVarType($array) === Type::ARRAY; + } + protected function parseAssignOpConcat(Expr\AssignOp\Concat $expr): string { return $this->parseAssignOp($expr, '.='); diff --git a/src/Parser/BinaryOpTrait.php b/src/Parser/BinaryOpTrait.php index 768e3e18..f186acae 100644 --- a/src/Parser/BinaryOpTrait.php +++ b/src/Parser/BinaryOpTrait.php @@ -566,6 +566,20 @@ trait BinaryOpTrait protected function getOrderedOperandTmpType(NodeAbstract $expr, string $value): string { + if ( + ($expr instanceof Expr\PostInc + || $expr instanceof Expr\PostDec + || $expr instanceof Expr\PreInc + || $expr instanceof Expr\PreDec) + && $this->isVarExpr($expr->var) + ) { + $type = $this->getVarType($this->parseIdentifier($expr->var)); + if ($this->nativeTypes && $this->isNativeType($type)) { + return $type; + } + return Type::VAR; + } + if ( $expr instanceof Expr\BinaryOp || $expr instanceof Expr\FuncCall @@ -647,6 +661,14 @@ trait BinaryOpTrait $items = []; $this->flattenConcatExpr($expr, $items); + // C++ does not order regular function arguments. The two-argument + // overload is therefore only safe for scalar operands whose parsing + // and conversion cannot execute user code or mutate state. All other + // concatenations use the braced-list overload, whose elements are + // sequenced left-to-right by C++17. + $useTwoOperandOverload = $prefixExpressions === [] + && $this->canUseTwoOperandConcatOverload($items); + $argList = $prefixExpressions; foreach ($items as $item) { // Keep one operand so concat still performs PHP string coercion. @@ -672,9 +694,37 @@ trait BinaryOpTrait $argList[] = $this->prepareConcatOperand($parsed, $type); } + if ($useTwoOperandOverload && count($argList) === 2) { + return Symbol::concat() . '(' . $argList[0] . ', ' . $argList[1] . ')'; + } + return Symbol::concat() . '({' . implode(', ', $argList) . '})'; } + protected function canUseTwoOperandConcatOverload(array $items): bool + { + if (count($items) !== 2) { + return false; + } + foreach ($items as $item) { + // Even a scalar-typed binary expression may emit a warning or + // throw (division by zero, failed conversion, overloaded object + // operation). Restrict the unordered overload to values that are + // already materialized and cannot execute during argument setup. + if (!($item instanceof Node\Scalar || $this->isVarExpr($item)) + || $this->shouldMaterializeOrderedOperand($item) + || !in_array( + $this->detectTypeOfExpr($item), + [Type::STR, Type::INT, Type::FLOAT, Type::BOOL], + true, + ) + ) { + return false; + } + } + return true; + } + protected function prepareConcatOperand(string $expr, string $type): string { if (in_array($type, [Type::STR, Type::INT, Type::FLOAT, Type::BOOL], true)) { diff --git a/src/Parser/ClassConstantFetchTrait.php b/src/Parser/ClassConstantFetchTrait.php index 521035f3..957271f4 100644 --- a/src/Parser/ClassConstantFetchTrait.php +++ b/src/Parser/ClassConstantFetchTrait.php @@ -177,14 +177,41 @@ trait ClassConstantFetchTrait protected function parseDynamicClassConstFetch(Expr\ClassConstFetch $expr): string { $const = $this->escapeString($this->parseIdentifier($expr->name)); + + // PhpParser represents `$this::CONST` as a dynamic class target even + // though the receiver class is the class currently being compiled. + // TypePHP class constants are compile-time data, so do not route this + // common path through get_class(), string concatenation and ZendVM's + // global constant table. + if ($this->isVarExpr($expr->class) + && $this->parseVariable($expr->class) === 'this_' + && $this->classDef + && $this->hasClass($this->getFullClassName()) + ) { + $nativeConst = $this->findNativeClassConst( + $expr, + $this->getFullClassName(), + $const, + ); + if ($nativeConst !== false) { + return $nativeConst; + } + } + $target = $this->materializeDynamicClassConstTarget($expr->class); if ($const === 'class') { return 'php::fn::get_class(' . $target . ')'; } - $className = '(' . $target . '.isObject() ? php::fn::get_class(' . $target . ') : ' . $target . ')'; - return Symbol::constant() . '(php::concat({' . $className . ', "::", ' . $this->getLiteralString($const) . '}))'; + $scope = $this->methodDef && $this->classDef + ? $this->getClassEntryPtr($this->getFullClassName()) + : 'nullptr'; + return 'php::classConstant(' + . $target . ', ' + . $this->getLiteralString($const) . ', ' + . $scope + . ')'; } protected function parseDynamicClassConstNameFetch(Expr\ClassConstFetch $expr): string diff --git a/src/Preprocessor.php b/src/Preprocessor.php index ef72cdd5..766d6795 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1435,6 +1435,7 @@ class Preprocessor extends CompilerBase $propDef->readonly = (bool) (($flags | $this->classDef->flags) & Modifiers::READONLY); $propDef->class = $class; $propDef->arrayInitPlan = $arrayInitPlan; + $propDef->requiresRuntimeDefaultInit = $this->propertyDefaultRequiresRuntimeInit($defaultNode); $propDef->promoted = $promoted; if ($typeNode instanceof NullableType || $typeNode instanceof UnionType || $typeNode instanceof IntersectionType) { $typeInfo = $this->buildTypeCheckFromNode($typeNode); @@ -1445,6 +1446,30 @@ class Preprocessor extends CompilerBase return $propDef; } + /** + * gen_stub emits compile-time scalar values and empty arrays exactly into + * the internal class default-property table. Non-empty arrays are emitted + * there as an empty-array placeholder, while enum cases need a live object; + * both must therefore be restored by create_object. Keep an unresolved + * expression on that conservative runtime path as well. + */ + private function propertyDefaultRequiresRuntimeInit(?NodeAbstract $default): bool + { + if ($default === null) { + return false; + } + if ($default instanceof Node\Expr\Array_) { + return $default->items !== []; + } + + // A null result includes enum cases and constants which cannot be + // resolved safely during preprocessing. Their runtime initialization + // must not be removed merely because their source syntax resembles a + // scalar constant expression. + $type = $this->detectDefaultValueType($default); + return $type === null || $type === 'array'; + } + /** * Diagnose, during preprocessing, whether a property's default value is * compatible with its declared type. diff --git a/src/Translator.php b/src/Translator.php index 87fc0581..9f896e19 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -946,6 +946,7 @@ CODE; } $code .= "// class \n"; + $code .= $this->genRequestArrayDefaultStorage(); foreach ($this->getClassLikesWithConstants() as $classDef) { if ($classDef instanceof ClassDef && !$classDef->nativeObject @@ -962,6 +963,7 @@ CODE; } } } + $code .= $this->genRequestArrayDefaultInitializers(); $code .= "// clang-format off\n"; $code .= "static const zend_function_entry ext_functions[] = {\n"; @@ -1107,6 +1109,7 @@ CODE; foreach ($this->nativeStaticInitializers as $name => $_) { $code .= $this->escapeGlobalVar($name) . ' = false;' . PHP_EOL; } + $code .= $this->genRequestArrayDefaultCleanup(); foreach ($this->constants as $name => $const) { if ($const->type !== Type::VAR) { continue; @@ -1974,6 +1977,134 @@ CODE; return implode(PHP_EOL, $lines) . PHP_EOL; } + /** @return array */ + private function getRequestArrayDefaultProperties(ClassDef $classDef): array + { + $properties = []; + foreach ($classDef->properties as $property) { + if (!$property->isStatic() + && $property->requiresRuntimeDefaultInit + && $property->arrayInitPlan !== null + ) { + $properties[] = $property; + } + } + return $properties; + } + + private function getRequestArrayDefaultInitializedName(ClassDef $classDef): string + { + return self::PREFIX . 'request_array_defaults_initialized_' . $classDef->getNamespacedName(); + } + + private function getRequestArrayDefaultInitializerName(ClassDef $classDef): string + { + return self::PREFIX . 'ensure_request_array_defaults_' . $classDef->getNamespacedName(); + } + + private function getRequestArrayDefaultTemplateName(ClassDef $classDef, PropertyDef $property): string + { + return self::PREFIX . 'request_array_default_' + . $this->getNativeName($property->name, $classDef->namespace, $classDef->name); + } + + private function indentGeneratedBlock(string $code, int $level): string + { + $code = rtrim($code, "\r\n"); + if ($code === '') { + return ''; + } + $indent = str_repeat(' ', $level); + return $indent . str_replace("\n", "\n{$indent}", $code) . PHP_EOL; + } + + private function genRequestArrayDefaultStorage(): string + { + $code = ''; + foreach ($this->symbols->classes() as $classDef) { + if ($classDef->trait || $classDef->enum || $classDef->nativeObject) { + continue; + } + $properties = $this->getRequestArrayDefaultProperties($classDef); + if (!$properties) { + continue; + } + $code .= 'THREAD_LOCAL bool ' + . $this->getRequestArrayDefaultInitializedName($classDef) + . ' = false;' . PHP_EOL; + foreach ($properties as $property) { + $code .= 'THREAD_LOCAL php::Var ' + . $this->getRequestArrayDefaultTemplateName($classDef, $property) + . ';' . PHP_EOL; + } + } + return $code === '' ? '' : "// request array property defaults\n{$code}"; + } + + private function genRequestArrayDefaultInitializers(): string + { + $code = ''; + foreach ($this->symbols->classes() as $classDef) { + if ($classDef->trait || $classDef->enum || $classDef->nativeObject) { + continue; + } + $properties = $this->getRequestArrayDefaultProperties($classDef); + if (!$properties) { + continue; + } + + $initialized = $this->getRequestArrayDefaultInitializedName($classDef); + $code .= 'static inline void ' + . $this->getRequestArrayDefaultInitializerName($classDef) + . '() {' . PHP_EOL; + $code .= " if (UNEXPECTED(!{$initialized})) {" . PHP_EOL; + + foreach ($properties as $index => $_property) { + $code .= " php::Var prepared_default_{$index};" . PHP_EOL; + } + foreach ($properties as $index => $property) { + $plan = $property->arrayInitPlan; + $code .= ' do {' . PHP_EOL; + $code .= $this->indentGeneratedBlock($plan->init, 3); + $code .= " prepared_default_{$index} = {$plan->expr};" . PHP_EOL; + $code .= $this->indentGeneratedBlock($plan->clean, 3); + $code .= ' } while (0);' . PHP_EOL; + } + foreach ($properties as $index => $property) { + $template = $this->getRequestArrayDefaultTemplateName($classDef, $property); + $code .= " {$template} = std::move(prepared_default_{$index});" . PHP_EOL; + } + $code .= " {$initialized} = true;" . PHP_EOL; + $code .= ' }' . PHP_EOL; + $code .= '}' . PHP_EOL . PHP_EOL; + } + return $code; + } + + private function genRequestArrayDefaultCleanup(): string + { + $code = ''; + foreach ($this->symbols->classes() as $classDef) { + if ($classDef->trait || $classDef->enum || $classDef->nativeObject) { + continue; + } + $properties = $this->getRequestArrayDefaultProperties($classDef); + if (!$properties) { + continue; + } + $initialized = $this->getRequestArrayDefaultInitializedName($classDef); + $code .= "if ({$initialized}) {" . PHP_EOL; + foreach ($properties as $property) { + $code .= ' ' + . $this->getRequestArrayDefaultTemplateName($classDef, $property) + . '.unset();' . PHP_EOL; + } + $code .= " {$initialized} = false;" . PHP_EOL; + $code .= '}' . PHP_EOL; + } + return $code === '' ? '' : "// request array property defaults\n{$code}"; + } + public function genClassPropertyInit(): string { $code = ''; @@ -1988,41 +2119,64 @@ CODE; if ($classDef && !$classDef->trait && !$classDef->enum) { $className = $classDef->getNamespacedName(); $handlers = "property_handlers_{$className}"; + $requestArrayDefaults = $this->getRequestArrayDefaultProperties($classDef); + $ensureRequestArrayDefaults = $requestArrayDefaults + ? $this->getRequestArrayDefaultInitializerName($classDef) . '();' . PHP_EOL + : ''; $initBlock = ''; foreach ($classDef->properties as $property) { - if ($property->isStatic() || $property->default === null) { + // Scalar/null/empty-array defaults already live in the + // Zend class default-property table generated by + // gen_stub.php. Only values represented there by a + // placeholder (for example non-empty arrays and enum + // cases) belong in the per-object initialization path. + if ($property->isStatic() + || $property->default === null + || !$property->requiresRuntimeDefaultInit + ) { continue; } if ($property->arrayInitPlan) { - $init = "auto value = {$property->arrayInitPlan->expr};\n"; - $init .= 'zend_update_property(' . $ce . ', obj, ' . $this->genZendStrl($property->name) . ", value.ptr());\n"; - $init .= "php::throwErrorIfOccurred();\n"; - $initBlock .= $this->wrapArrayInitPlan($property->arrayInitPlan, $init); + $initBlock .= 'target.attr(' . $property->runtimeDefaultOffset + . ', php::AttrMode::Update) = ' + . $this->getRequestArrayDefaultTemplateName($classDef, $property) + . ';' . PHP_EOL; } else { - // Scalar / constant / null default value. Wrap it in a - // php::Var so it can be stored as a zval in the object's - // property table via zend_update_property. Each property is - // wrapped in its own block so the local `value` does not - // clash with siblings declared in the same create_object body. + // Runtime-only constant value (for example an enum + // case). Wrap it in php::Var and write the already + // resolved backing slot directly. Each property uses a + // separate block so its local `value` cannot clash with + // siblings in the same create_object body. $default = $property->type === Type::FLOAT ? $this->convertFloatExpr($property->default) : $property->default; $init = "do {\n"; $init .= "auto value = php::Var({$default});\n"; - $init .= 'zend_update_property(' . $ce . ', obj, ' . $this->genZendStrl($property->name) . ", value.ptr());\n"; - $init .= "php::throwErrorIfOccurred();\n"; + $init .= 'target.attr(' . $property->runtimeDefaultOffset + . ", php::AttrMode::Update) = value;\n"; $init .= "} while (0);\n"; $initBlock .= $init; } } $delegateToParentAllocator = $this->parentHasCustomCreateObjectOnPhp84($classDef); - $buildCreateBody = function () use ($classDef, $className, $handlers, $initBlock, $delegateToParentAllocator): string { + $buildCreateBody = function () use ( + $classDef, + $className, + $handlers, + $ensureRequestArrayDefaults, + $initBlock, + $delegateToParentAllocator, + ): string { $body = $classDef->ctorInit; + $body .= $ensureRequestArrayDefaults; $body .= "auto obj = typephp_create_object_with_defaults(\n"; $body .= "class_type, create_object_{$className}, "; $body .= ($delegateToParentAllocator ? 'true' : 'false') . ",\n"; $body .= "[&](zend_object *obj) {\n"; + if ($initBlock !== '') { + $body .= "php::Object target{obj};\n"; + } $body .= $initBlock; $body .= "});\n"; $body .= $classDef->ctorClean; @@ -2030,7 +2184,7 @@ CODE; }; $code .= "typephp_install_property_handlers({$ce}, &{$handlers});\n"; - if ($classDef->requireCtor || $this->classHasAsymmetricOrHookedProperty($classDef)) { + if ($classDef->requireCtor) { $code .= "create_object_{$className} = php_get_create_object_fn({$ce});\n"; $code .= "{$ce}->create_object = [](zend_class_entry *class_type) -> zend_object* {\n"; $code .= $buildCreateBody(); @@ -2041,46 +2195,6 @@ CODE; return $code; } - /** - * Whether the given class (or any of its ancestors) declares an asymmetric - * visibility property (private(set)/protected(set)) or a hooked property - * (getter/setter). Such classes install a custom write_property handler, and - * on PHP >= 8.4 that handler lives in the class's default object handlers, so - * the engine's object_properties_init would reject inherited default values - * unless we generate our own create_object that initializes with the standard - * handlers first. - */ - private function classHasAsymmetricOrHookedProperty(ClassDef $classDef): bool - { - $current = $classDef; - $seen = []; - while ($current !== null) { - $key = strtolower(ltrim($current->getNamespacedName(), '\\')); - if (isset($seen[$key])) { - break; - } - $seen[$key] = true; - foreach ($current->properties as $property) { - if ($property->isPrivateSet() - || $property->isProtectedSet() - || $property->getter !== null - || $property->setter !== null - ) { - return true; - } - } - if (!$current->extends) { - break; - } - $parent = $this->getClassDef($current->extends); - if ($parent === null) { - break; - } - $current = $parent; - } - return false; - } - private function parentHasCustomCreateObjectOnPhp84(ClassDef $classDef): bool { if ($classDef->extends === '') { @@ -2093,13 +2207,10 @@ CODE; $parent = $this->getClassDef($classDef->extends); while ($parent !== null) { foreach ($parent->properties as $property) { - if (!$property->isStatic() && $property->default !== null) { + if (!$property->isStatic() && $property->requiresRuntimeDefaultInit) { return true; } } - if ($this->classHasAsymmetricOrHookedProperty($parent)) { - return true; - } if ($parent->extends === '') { break; } @@ -3655,7 +3766,11 @@ CODE; } $defaultPropCount = 0; foreach ($classDef->properties as $property) { - if (!$property->isStatic() && $property->default !== null) { + if (!$property->isStatic() && $property->requiresRuntimeDefaultInit) { + $property->runtimeDefaultOffset = $this->getPropertyOffset( + $classDef->getNamespacedName(false), + $property->name, + ); $defaultPropCount++; } } diff --git a/tests/compiler/class/new-known-class-entry-hoisted.phpt b/tests/compiler/class/new-known-class-entry-hoisted.phpt new file mode 100644 index 00000000..b5fbdbf4 --- /dev/null +++ b/tests/compiler/class/new-known-class-entry-hoisted.phpt @@ -0,0 +1,21 @@ +--TEST-- +known class entries can be reused across repeated object creation +--FILE-- +value); +} +?> +--EXPECT-- +int(7) diff --git a/tests/compiler/class/this-class-constant-static-expansion.phpt b/tests/compiler/class/this-class-constant-static-expansion.phpt new file mode 100644 index 00000000..60e1118b --- /dev/null +++ b/tests/compiler/class/this-class-constant-static-expansion.phpt @@ -0,0 +1,35 @@ +--TEST-- +$this class constants resolve from the current TypePHP class +--FILE-- +values()); +} +?> +--EXPECT-- +array(3) { + [0]=> + int(23) + [1]=> + string(7) "private" + [2]=> + string(6) "parent" +} diff --git a/tests/compiler/object_property/default-initialization-paths.phpt b/tests/compiler/object_property/default-initialization-paths.phpt new file mode 100644 index 00000000..8c7181af --- /dev/null +++ b/tests/compiler/object_property/default-initialization-paths.phpt @@ -0,0 +1,59 @@ +--TEST-- +Property defaults use Zend table values and restore runtime-only defaults +--FILE-- +values[] = 'second'; + + var_dump($first->scalar, $first->text, $first->empty); + var_dump($first->values, $second->values); + var_dump($first->state === DefaultInitializationState::Ready); + + $exception = new DefaultInitializationException('message'); + var_dump($exception->getMessage(), $exception->context); +} +?> +--EXPECT-- +int(3) +string(7) "typephp" +array(0) { +} +array(2) { + [0]=> + string(5) "first" + [1]=> + string(6) "second" +} +array(1) { + [0]=> + string(5) "first" +} +bool(true) +string(7) "message" +array(1) { + [0]=> + string(7) "runtime" +} diff --git a/tests/compiler/object_property/runtime-default-custom-handlers.phpt b/tests/compiler/object_property/runtime-default-custom-handlers.phpt new file mode 100644 index 00000000..40c8f3f9 --- /dev/null +++ b/tests/compiler/object_property/runtime-default-custom-handlers.phpt @@ -0,0 +1,47 @@ +--TEST-- +Runtime property defaults bypass hooks and asymmetric write handlers +--FILE-- + $this->values; + set { + ++$this->writes; + $this->values = $value; + } + } + + public function writes(): int + { + return $this->writes; + } +} + +class AsymmetricRuntimeDefault +{ + public private(set) array $values = ['initial']; +} + +function main(): void +{ + $hooked = new HookRuntimeDefault(); + $asymmetric = new AsymmetricRuntimeDefault(); + + var_dump($hooked->values, $hooked->writes()); + var_dump($asymmetric->values); +} +?> +--EXPECT-- +array(1) { + [0]=> + string(7) "initial" +} +int(0) +array(1) { + [0]=> + string(7) "initial" +} diff --git a/tests/compiler/operator/array-statement-write-optimized.phpt b/tests/compiler/operator/array-statement-write-optimized.phpt new file mode 100644 index 00000000..ce8177b6 --- /dev/null +++ b/tests/compiler/operator/array-statement-write-optimized.phpt @@ -0,0 +1,44 @@ +--TEST-- +Known array statement writes preserve references, keys and expression results +--FILE-- + +--EXPECT-- +int(3) +array(2) { + [0]=> + int(0) + [1]=> + int(2) +} +array(2) { + [0]=> + &int(3) + [2]=> + int(5) +} +int(10) +int(9) +int(11)