16 KiB
Zend Object Creation and Property Default Value Initialization
This document records the initialization responsibilities of the Zend Classes generated by TypePHP during MINIT and object creation, focusing on when a custom create_object is required, what behavior is allowed within it, and the performance boundary on the object-creation hot path.
This document only discusses ordinary TypePHP Classes registered with ZendVM. #[Native] Classes use the Native Heap and GC and do not follow the flow described here.
1. The two initialization stages must be kept separate
Property initialization of a TypePHP Class is split into two stages:
gen_stub.phpgeneratesregister_class_*()during MINIT, establishing thezend_class_entry, property metadata, and the default property table;- only values that cannot be accurately expressed by the default property table are supplemented by a custom
create_objecteach time an object is created.
These two stages must not perform the same property assignment twice. Values already written by register_class_*() are copied to the new object by Zend's object_properties_init(); calling zend_update_property() again has no semantic value and additionally enters the property-name lookup, type check, handler dispatch, and reference-counting paths.
2. Default values handled by gen_stub.php
The following values can be accurately written into the Zend Class default property table:
| Source default value | Registration-stage representation | Must be written again in create_object |
|---|---|---|
null |
ZVAL_NULL |
No |
bool |
ZVAL_TRUE/FALSE |
No |
int |
ZVAL_LONG |
No |
float |
ZVAL_DOUBLE |
No |
string |
persistent zend_string |
No |
| scalar constant expression | the scalar zval evaluated at compile time | No |
[] |
ZVAL_EMPTY_ARRAY |
No |
| TypePHP typed property without an explicit default | the zero value, empty string, empty array, null, or UNDEF defined by TypePHP |
No |
For example:
class Value
{
private const BASE = 20;
public int $id = self::BASE + 3;
public string $name = 'type' . 'php';
public array $items = [];
}
As long as the expressions can be safely evaluated at compile time, the three properties above should rely entirely on the Zend Class default property table. When creating Value, zend_update_property() must not be called again.
3. When default values need runtime supplementation
The current gen_stub.php cannot accurately represent the following values in the default property table.
3.1 Non-empty arrays
Non-empty array defaults currently use ZVAL_EMPTY_ARRAY as a placeholder value in the registration function. Each object must construct an independent, semantically correct array value:
class Request
{
public array $options = ['timeout' => 10];
}
Therefore Request::$options needs to be supplemented in create_object. Multiple objects still follow PHP array copy-on-write semantics; modifying one object's array must not affect other objects.
Array constants follow the same rule. If the compiler can only determine that it is an array but cannot prove it is empty, it conservatively keeps the runtime initialization.
3.2 Enum case
An enum case is an object, not a scalar constant:
enum State
{
case Ready;
}
class Task
{
public State $state = State::Ready;
}
The class registration code currently can only generate a placeholder value first; create_object then obtains the real enum case object and writes it into the property. Therefore "only non-empty arrays need a custom create_object" is not correct — enum case is a clear second category of counterexample.
3.3 Constant expressions that cannot be safely resolved
If the preprocessing stage cannot prove that a default value can be accurately expressed by the Zend default property table, the compiler must conservatively keep the runtime initialization. Optimization can only remove work that is proven redundant; it must not guess the runtime type based on the expression's shape.
4. handlers and parent allocator
4.1 Property Hooks and asymmetric set visibility do not trigger on their own
PHP 8.4 Property Hooks, private(set), and protected(set) install TypePHP custom object handlers, but this by itself does not require overriding create_object. Zend 8.4's object_properties_init() directly copies the class default table and does not call read/write handlers; ordinary php::stdCreateObject() already sets the final handlers correctly.
A custom creation flow is required only when the class also has runtime defaults such as non-empty arrays or enum cases. Supplemental initialization must bypass setters; even using zend_std_write_property(), PHP 8.4 would call the setter based on the Hook metadata. The current generated code therefore uses the property offset known at compile time to directly update the backing slot via PHPX Object::attr(offset).
4.2 Parent custom object allocator
If the parent class comes from a built-in PHP extension, or an ancestor class has a custom object storage layout, the child class cannot bypass the parent's allocator. When the current class genuinely needs a custom creation flow due to runtime defaults, it must first call the saved parent create_object, then supplement the current class's values.
When the TypePHP parent has already installed a custom allocator, ordinary child classes usually inherit it directly. A new delegation layer is generated only when the child class itself also needs supplemental initialization.
5. Execution flow of a custom create_object
The generated code performs the following steps through typephp_create_object_with_defaults():
- save the class's final
default_object_handlers; - if the parent object layout must be respected, call the saved parent allocator; otherwise run
zend_objects_new()andobject_properties_init(); - temporarily switch the new object to Zend standard object handlers to keep the exception path and other object operations in a controlled state;
- initialize only the properties marked
requiresRuntimeDefaultInit, writing directly to backing slots via cached declared-property offsets; - check for Zend exceptions after each write;
- restore the final handlers whether returning normally or after a C++ exception;
- return the fully initialized
zend_object *.
The initializer is a template parameter and a compile-time lambda; it does not use std::function and does not dynamically allocate memory for the lambda. delegate_to_base is a call-site-determined boolean that can usually be folded by the C++ compiler in optimized builds.
The following behavior does not belong to create_object:
- the function body of PHP
__construct(); - static property default-value initialization; that happens in
module_init(); - scalar,
null, and empty-array assignments already expressed by the default property table; - reapplying defaults after clone; clone should copy the source object's current state, not recreate the default state.
6. Main performance issues already fixed
The old generation logic installed a custom create_object whenever any explicit non-static default existed in the class, and re-updated all default properties on every object creation. This produced two layers of duplicate cost:
- an ordinary class containing only
public int $value = 0also bypassed the standard fast creation path; - a class containing even one non-empty array caused all other scalar properties to be re-updated one by one.
The current rules have been adjusted to:
- only properties that truly need runtime supplementation trigger
requireCtor; - properties already accurately registered by
gen_stub.phpdo not appear in the runtime initialization block; - classes with only Hooks/asymmetric visibility and no runtime defaults no longer generate an empty custom allocator;
- when Hooks and runtime defaults coexist, a fixed property offset updates the backing slot without calling the setter.
In a micro benchmark, new Foo() containing only scalar properties dropped from about 1.8s to about 0.78s, close to the approximately 0.83s of ZendPHP in the same environment after subtracting the empty loop. This number is only used to record the magnitude of the optimization, not a cross-machine performance promise.
7. Implemented optimizations, remaining costs, and future directions
7.1 Non-empty arrays use a request-level template and copy-on-write
Non-empty arrays cannot be placed in an internal class's default property table, but that does not mean the array must be rebuilt for every object. The current generator already uses request-level default-value templates:
- each class containing runtime array defaults owns a set of
THREAD_LOCAL php::Vartemplates and an initialization state; NTS builds introduce no locking; - the first time an object of the class is created, all its templates are lazily built via
UNEXPECTED(!initialized); - templates are committed and the initialization flag is set only after all templates are successfully built in local temporaries; construction exceptions do not publish a half-initialized state;
- template initialization happens before object allocation, so a failure leaves no unreturned object;
- subsequent object creations just copy the template zval into the target backing slot, i.e. increment the array reference count once;
- the first time an object modifies that property, Zend/PHPX's
SEPARATE_ARRAYperforms copy-on-write; module_clean()releases the templates and resets the initialization state; the HashTable allocated by the request allocator does not survive RSHUTDOWN.
Take the following default value as an example:
class Request
{
public array $options = [
'timeout' => 10,
'headers' => ['Accept' => 'application/json'],
];
}
If ten thousand objects are created but $options is not modified, the array and nested arrays are built only once; each object only holds the shared zval. If one object executes $request->options['timeout'] = 30, only that object is separated at write time, while the other objects and the template remain unchanged. Nested arrays also continue to use Zend's existing per-level copy-on-write rules.
PHP property default arrays cannot contain references, and the objects allowed in constant expressions are mainly immutable enum cases, so sharing the template conforms to default-property semantics. PHPT already covers top-level writes, nested writes, unset, reference writes, and dynamic object writes, confirming that these paths all separate correctly.
A persistent array cannot simply be constructed in MINIT and passed to zend_declare_typed_property(). TypePHP registers ZEND_INTERNAL_CLASS, and Zend 8.4 explicitly forbids internal properties from using refcounted default zvals; the internal-class fast path of _object_properties_init() also does not increment the reference count of defaults. Non-empty arrays and enum objects are both refcounted values.
Therefore, without changing the foundational design of "TypePHP Classes are registered as internal classes" and without modifying the Zend ABI, non-empty arrays still do not enter the class default table; the out-of-table request-level template reduces array construction cost from "once per object" to "once per request per default value". Objects that do not modify the default array only bear the zval copy and reference-counting cost; only objects that actually modify it bear the array-separation cost.
Templates are initialized lazily per class rather than unconditionally building all templates in RINIT: in large projects, many classes are never instantiated within a single request. Each object only adds one highly predictable initialization-state branch; after the first time, the branch stably evaluates to false.
A persistent immutable template with module lifetime is not generated for now. That approach requires fully validating persistent HashTables, interned strings, nested arrays, MSHUTDOWN, and ZTS, and arrays containing runtime constants or enum cases would still need the request-level path. Until ZendVM's constraints on these combinations are sufficiently validated, the request-level template is the safety boundary.
7.2 Changed to fixed property-slot writes
Properties supplemented at runtime already have their class, property name, offset, and type known at compile time. The current implementation reuses the persistent property-offset cache and updates slots via php::Object::attr(offset), already eliminating the property-name hash lookup, the generic write handler, and the Property Hook setter on every object.
It still builds a short-lived php::Object carrier for the initializer and reads the offset cache. If profiling later proves this is a hot spot, the final offset can be saved directly after MINIT, or PHPX can add an initialization helper that does not take object ownership. Any further optimization must continue to handle old-value destruction, reference counting, parent-class private slots, Hook backing slots, and exception safety, and must not regress to unprotected raw-pointer assignment.
7.3 Enum case can be bound early
An enum case is likewise a refcounted object and cannot directly serve as an internal-class default zval. One could cache the stable enum case pointer or zval in MINIT and then perform correct reference-count copying on each object creation, eliminating the repeated class/case lookup; the object property write itself still cannot be omitted.
7.4 Multi-layer allocators on the inheritance chain
When both parent and child classes have runtime defaults, the creation flow delegates layer by layer and runs each initialization, with cost growing with the number of involved inheritance layers. In the future, inheritance chains fully controlled by TypePHP with no special object layout could have their initialization plans merged; built-in-extension parent classes must still call their allocator.
7.5 Conservative constants can produce unnecessary allocators
Constants that cannot be resolved in the preprocessing stage conservatively enter the runtime path. A unified constant-default classification pass could be added after symbol preparation to reduce custom allocators for cases that are "actually scalars but unprovable early". That optimization must preserve the distinction between enum cases and array constants.
7.6 Dynamic access cost of custom handlers
TypePHP currently installs property handlers for ordinary Zend Classes to support typed-property unset semantics, Property Hooks, and asymmetric write visibility. Installation happens in MINIT and is not equivalent to installing a custom create_object; however, dynamic property reads/writes may still enter the handler. Native property accesses already resolved to fixed slots by the compiler must not degrade because of this.
8. Regression test requirements
Changes to this flow should at least cover:
- scalars, scalar constant expressions, and empty arrays do not generate a custom allocator;
- non-empty arrays generate an allocator, and array modifications on two objects do not affect each other;
- enum case defaults are real enum objects after object creation;
- classes containing only Property Hooks or asymmetric set visibility do not generate an empty allocator, and Reflection and dynamic read/write behavior do not degrade;
- Property Hook/asymmetric properties combined with runtime defaults do not trigger setters;
- when parent and child classes each declare runtime defaults, both parent and child properties are correct;
- inheriting a built-in extension class does not break its object layout;
- the exception path restores object handlers;
- bootstrap compilation and full PHPUnit/PHPT regression pass.
Current core assertions on code generation live in NewObjectCodegenTest; runtime semantics are covered by default-initialization-paths.phpt, default-expressions-inheritance.phpt, and the Property Hook test group.