Environment
- TypePHP: current
upstream/master at a6cd38a
- Zend baselines: PHP 8.5.6 (Linux) and PHP 8.5.10 ZTS (Windows)
- AOT backend: Windows MSVC, freshly built and executed at both
-O0 and -O3
Minimal reproducer
<?php
declare(strict_types=1);
final class Bag implements ArrayAccess
{
public array $data = [];
public function offsetExists(mixed $offset): bool
{
echo "exists:$offset\n";
return array_key_exists($offset, $this->data);
}
public function offsetGet(mixed $offset): mixed
{
echo "get:$offset\n";
return $this->data[$offset] ?? null;
}
public function offsetSet(mixed $offset, mixed $value): void
{
echo "set:$offset=$value\n";
$this->data[$offset] = $value;
}
public function offsetUnset(mixed $offset): void
{
unset($this->data[$offset]);
}
}
function main(): void
{
$bag = new Bag();
$result = ($bag['service'] ??= 42);
var_dump($result, $bag->data);
}
For Zend, invoke main() from a small runner. TypePHP binary mode invokes it directly.
Expected (Zend 8.5.6 and 8.5.10)
exists:service
set:service=42
int(42)
array(1) {
["service"]=>
int(42)
}
Actual TypePHP output (-O0 and -O3)
exists:service
get:service
int(42)
array(0) {
}
The assignment expression returns the RHS, but the default is not stored.
Semantic invariant and failure mechanism
For an ArrayAccess target, ??= must use Zend's dimension-operation sequence:
- missing offset:
offsetExists() then offsetSet();
- present non-null offset:
offsetExists() then offsetGet(), without evaluating the RHS;
offsetExists() === true and offsetGet() === null: evaluate the RHS and call offsetSet().
The generated C++ for the reproducer is:
result = (php::exists(bag, {{php::ArrayDimFetch, php::Var(get_str(2))}})
? bag.item(get_str(2), true)
: (bag.item(get_str(2), true) = 42LL));
bag.item(..., true) dispatches to offsetGet() for this userland ArrayAccess object. Assigning to that returned value mutates a temporary instead of dispatching to offsetSet().
At the compiler level, parseAssignOpCoalesce() uses the same parseWritableIdentifier() result for the selected read and the miss-branch write. That is valid for a real array bucket but not for an ArrayAccess operation. Ordinary assignment already distinguishes these paths in parseAssignArrayDim() and emits offsetSet() for object containers.
A robust boundary appears to require separate presence/read/write lowering (or reuse of the shared assignment-target emitter), while preserving the target stabilization and lazy-RHS ordering added by #47.
Relevance matrix
| Dimension |
Result |
Userland ArrayAccess, missing key |
broken: calls offsetGet(), never offsetSet() |
Userland ArrayAccess, offsetExists() === true, offsetGet() === null |
broken: returns null and skips the default/write |
Userland ArrayAccess, present non-null key |
correct: offsetExists() then offsetGet() |
Concrete class and ArrayAccess-typed parameter |
both reproduce |
| Assignment result used / unused |
storage is missing in both contexts |
| Side-effecting receiver/key |
evaluated once after #47; the wrong write dispatch remains |
Built-in ArrayObject |
stores the value, but the same lowering emits an unexpected undefined-key warning on a miss |
| Ordinary PHP arrays |
correct after #47; true bucket lvalues are unaffected |
Native Class ArrayAccess |
explicitly rejected by the Native object boundary; out of scope |
std containers |
separate native lowering; out of scope |
| Optimization level |
reproduced with fresh O0 and O3 executables |
| Backend |
Windows MSVC executed; generated lowering is backend-neutral, but Linux AOT was not executed locally because this checkout lacks Linux libphp/phpx runtime libraries |
docs/en/INCOMPATIBLE_PHP_FEATURES.md does not list ordinary Zend ArrayAccess coalesce assignment as unsupported.
Environment
upstream/masterata6cd38a-O0and-O3Minimal reproducer
For Zend, invoke
main()from a small runner. TypePHP binary mode invokes it directly.Expected (Zend 8.5.6 and 8.5.10)
Actual TypePHP output (
-O0and-O3)The assignment expression returns the RHS, but the default is not stored.
Semantic invariant and failure mechanism
For an
ArrayAccesstarget,??=must use Zend's dimension-operation sequence:offsetExists()thenoffsetSet();offsetExists()thenoffsetGet(), without evaluating the RHS;offsetExists() === trueandoffsetGet() === null: evaluate the RHS and calloffsetSet().The generated C++ for the reproducer is:
result = (php::exists(bag, {{php::ArrayDimFetch, php::Var(get_str(2))}}) ? bag.item(get_str(2), true) : (bag.item(get_str(2), true) = 42LL));bag.item(..., true)dispatches tooffsetGet()for this userlandArrayAccessobject. Assigning to that returned value mutates a temporary instead of dispatching tooffsetSet().At the compiler level,
parseAssignOpCoalesce()uses the sameparseWritableIdentifier()result for the selected read and the miss-branch write. That is valid for a real array bucket but not for anArrayAccessoperation. Ordinary assignment already distinguishes these paths inparseAssignArrayDim()and emitsoffsetSet()for object containers.A robust boundary appears to require separate presence/read/write lowering (or reuse of the shared assignment-target emitter), while preserving the target stabilization and lazy-RHS ordering added by #47.
Relevance matrix
ArrayAccess, missing keyoffsetGet(), neveroffsetSet()ArrayAccess,offsetExists() === true,offsetGet() === nullArrayAccess, present non-null keyoffsetExists()thenoffsetGet()ArrayAccess-typed parameterArrayObjectArrayAccessstdcontainerslibphp/phpxruntime librariesdocs/en/INCOMPATIBLE_PHP_FEATURES.mddoes not list ordinary ZendArrayAccesscoalesce assignment as unsupported.