fix: preserve ArrayAccess coalesce assignment semantics (fix gh-70)

master
韩天峰 6 hours ago
parent 7768ce177e
commit 4e13e7b0ab
  1. 104
      src/Parser/AssignOpTrait.php
  2. 281
      tests/compiler/coalesce/array-access-assign.phpt

@ -1718,6 +1718,19 @@ trait AssignOpTrait
if ($this->isVarExpr($expr->expr) and !$this->hasVar($right)) {
$this->errorUndefinedVariable($expr->expr);
}
$arrayAccessTarget = $this->resolveCoalesceArrayAccessTarget($expr->var);
if ($arrayAccessTarget !== null) {
return $this->emitCoalesceArrayAccessAssignment(
$arrayAccessTarget,
$isset,
$var,
$right,
$rightBefore,
$rightAfter,
);
}
$targetClass = $var !== null && $this->isNativeObjectVar($var)
? $this->getNativeObjectVarClass($var)
: $this->detectClassOfExpr($expr->var);
@ -1761,6 +1774,82 @@ trait AssignOpTrait
return '(' . $isset . '?' . $var . ':(' . $var . ' = ' . $right . '))';
}
/**
* ArrayAccess dimensions do not expose writable buckets: offsetGet()
* returns a value, while a write must dispatch through offsetSet(). Keep
* ordinary arrays on the existing lvalue path so assignments into array
* references continue to update the referenced bucket in place.
*
* @return array{container: string, key: string, objectCondition: string}|null
*/
private function resolveCoalesceArrayAccessTarget(Expr $target): ?array
{
if (!$target instanceof Expr\ArrayDimFetch
|| $target->dim === null
|| !$this->isVarExpr($target->var)
|| $this->isStdContainerExpr($target)
) {
return null;
}
$container = $this->parseIdentifier($target->var);
$containerType = $this->getVarType($container);
if (!in_array($containerType, [Type::OBJECT, Type::VAR, Type::REF], true)) {
return null;
}
return [
'container' => $container,
'key' => $this->parseIdentifier($target->dim),
// Even a statically object-typed PHP variable may currently hold
// null. PHP converts that null to an array on dimension write, so
// only the runtime object case may dispatch through offsetSet().
'objectCondition' => $container . '.isObject()',
];
}
/**
* @param array{container: string, key: string, objectCondition: string} $target
* @param list<string> $rightBefore
* @param list<string> $rightAfter
*/
private function emitCoalesceArrayAccessAssignment(
array $target,
string $isset,
string $readTarget,
string $right,
array $rightBefore,
array $rightAfter,
): string {
$current = $this->genTmpVarName();
$rhs = $this->genTmpVarName();
$container = $target['container'];
$key = $target['key'];
$isObject = $this->genTmpVarName();
$code = '[&]() -> php::Var {' . PHP_EOL;
$code .= $this->getIndent() . 'const bool ' . $isObject . ' = '
. $target['objectCondition'] . ';' . PHP_EOL;
$code .= $this->getIndent() . 'if (' . $isset . ') {' . PHP_EOL;
$code .= $this->getIndent(2) . 'php::Var ' . $current . ' = ' . $isObject
. ' ? ' . $container . '.offsetGet(' . $key . ') : ' . $readTarget . ';' . PHP_EOL;
$code .= $this->getIndent(2) . 'if (!' . $current . '.isNull()) {' . PHP_EOL;
$code .= $this->getIndent(3) . 'return ' . $current . ';' . PHP_EOL;
$code .= $this->getIndent(2) . '}' . PHP_EOL;
$code .= $this->getIndent() . '}' . PHP_EOL;
$code .= $this->formatCapturedStmtLines($rightBefore);
$code .= $this->getIndent() . 'php::Var ' . $rhs . ' = ' . $right . ';' . PHP_EOL;
$code .= $this->formatCapturedStmtLines($rightAfter);
$code .= $this->getIndent() . 'if (' . $isObject . ') {' . PHP_EOL;
$code .= $this->getIndent(2) . $container . '.offsetSet(' . $key . ', ' . $rhs . ');' . PHP_EOL;
$code .= $this->getIndent() . '} else {' . PHP_EOL;
$code .= $this->getIndent(2) . $readTarget . ' = ' . $rhs . ';' . PHP_EOL;
$code .= $this->getIndent() . '}' . PHP_EOL;
$code .= $this->getIndent() . 'return ' . $rhs . ';' . PHP_EOL;
$code .= $this->getIndent() . '}()';
return $code;
}
/**
* Rewrite a coalesce-assignment target so that every side-effecting
* subexpression is evaluated exactly once, in PHP source order —
@ -1805,6 +1894,21 @@ trait AssignOpTrait
writableContainer: true,
);
}
// An object container is a handle, so copying it into a temporary
// preserves identity and lets ArrayAccess presence/read/write all
// use the same property or nested-dimension result. Do not do this
// for arrays: their write must continue through the original
// bucket/property lvalue.
if (!$this->isVarExpr($target->var)
&& $this->detectTypeOfExpr($target->var) === Type::OBJECT
&& !$this->isNativeObjectClass($this->detectClassOfExpr($target->var))
) {
$target->var = $this->materializeCoalesceTargetSubexpr(
$target->var,
isReceiver: true,
writableContainer: true,
);
}
if ($target->dim !== null && !$this->isCoalesceTargetTrivialSubexpr($target->dim)) {
$target->dim = $this->materializeCoalesceTargetSubexpr($target->dim, false);
}

@ -0,0 +1,281 @@
--TEST--
ArrayAccess ??= preserves offsetExists, offsetGet, offsetSet and lazy evaluation semantics
--FILE--
<?php
declare(strict_types=1);
final class CoalesceBag 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]);
}
}
final class CoalesceHolder
{
public function __construct(public CoalesceBag $bag)
{
}
}
function coalesceRhs(string $label, int $value): int
{
echo "rhs:$label\n";
return $value;
}
function throwingCoalesceRhs(): int
{
echo "rhs:throw\n";
throw new RuntimeException('failed');
}
function coalesceThroughInterface(ArrayAccess $target, string $key, int $value): mixed
{
return $target[$key] ??= coalesceRhs('interface', $value);
}
function coalesceThroughMixed(mixed $target, string $key, int $value): array
{
$result = $target[$key] ??= coalesceRhs('mixed', $value);
return [$result, $target];
}
function coalesceThroughNullableObject(?ArrayAccess $target): mixed
{
return $target['nullable'] ??= coalesceRhs('nullable-object', 34);
}
function coalesceReceiver(CoalesceBag $target, int &$calls): CoalesceBag
{
$calls++;
echo "receiver:$calls\n";
return $target;
}
function coalesceKey(int &$calls): string
{
$calls++;
echo "key:$calls\n";
return 'side';
}
function main(): void
{
echo "-- missing --\n";
$missing = new CoalesceBag();
var_dump($missing['service'] ??= coalesceRhs('missing', 42));
var_dump($missing->data);
echo "-- present --\n";
$present = new CoalesceBag();
$present->data['service'] = 7;
var_dump($present['service'] ??= coalesceRhs('present', 99));
var_dump($present->data);
echo "-- null --\n";
$null = new CoalesceBag();
$null->data['service'] = null;
var_dump($null['service'] ??= coalesceRhs('null', 21));
var_dump($null->data);
echo "-- interface --\n";
$interface = new CoalesceBag();
var_dump(coalesceThroughInterface($interface, 'typed', 13));
var_dump($interface->data);
echo "-- object property container --\n";
$holder = new CoalesceHolder(new CoalesceBag());
var_dump($holder->bag['property'] ??= coalesceRhs('property', 14));
var_dump($holder->bag->data);
echo "-- mixed object --\n";
$mixedObject = new CoalesceBag();
[$mixedObjectResult] = coalesceThroughMixed($mixedObject, 'dynamic', 31);
var_dump($mixedObjectResult, $mixedObject->data);
echo "-- mixed object present --\n";
$mixedPresent = new CoalesceBag();
$mixedPresent->data['dynamic'] = 8;
[$mixedPresentResult] = coalesceThroughMixed($mixedPresent, 'dynamic', 99);
var_dump($mixedPresentResult, $mixedPresent->data);
echo "-- mixed object null --\n";
$mixedNull = new CoalesceBag();
$mixedNull->data['dynamic'] = null;
[$mixedNullResult] = coalesceThroughMixed($mixedNull, 'dynamic', 33);
var_dump($mixedNullResult, $mixedNull->data);
echo "-- mixed array --\n";
[$mixedArrayResult, $mixedArray] = coalesceThroughMixed([], 'dynamic', 32);
var_dump($mixedArrayResult, $mixedArray);
echo "-- nullable object currently null --\n";
var_dump(coalesceThroughNullableObject(null));
echo "-- receiver and key once --\n";
$sideEffect = new CoalesceBag();
$receiverCalls = 0;
$keyCalls = 0;
var_dump(coalesceReceiver($sideEffect, $receiverCalls)[coalesceKey($keyCalls)]
??= coalesceRhs('side', 55));
var_dump($receiverCalls, $keyCalls, $sideEffect->data);
echo "-- unused result --\n";
$unused = new CoalesceBag();
$unused['value'] ??= coalesceRhs('unused', 66);
var_dump($unused->data);
echo "-- throwing rhs --\n";
$throwing = new CoalesceBag();
try {
$throwing['value'] ??= throwingCoalesceRhs();
} catch (RuntimeException $e) {
echo "caught\n";
}
var_dump($throwing->data);
echo "-- ArrayObject --\n";
$arrayObject = new ArrayObject();
var_dump($arrayObject['value'] ??= coalesceRhs('array-object', 77));
var_dump($arrayObject->getArrayCopy());
}
?>
--EXPECT--
-- missing --
exists:service
rhs:missing
set:service=42
int(42)
array(1) {
["service"]=>
int(42)
}
-- present --
exists:service
get:service
int(7)
array(1) {
["service"]=>
int(7)
}
-- null --
exists:service
get:service
rhs:null
set:service=21
int(21)
array(1) {
["service"]=>
int(21)
}
-- interface --
exists:typed
rhs:interface
set:typed=13
int(13)
array(1) {
["typed"]=>
int(13)
}
-- object property container --
exists:property
rhs:property
set:property=14
int(14)
array(1) {
["property"]=>
int(14)
}
-- mixed object --
exists:dynamic
rhs:mixed
set:dynamic=31
int(31)
array(1) {
["dynamic"]=>
int(31)
}
-- mixed object present --
exists:dynamic
get:dynamic
int(8)
array(1) {
["dynamic"]=>
int(8)
}
-- mixed object null --
exists:dynamic
get:dynamic
rhs:mixed
set:dynamic=33
int(33)
array(1) {
["dynamic"]=>
int(33)
}
-- mixed array --
rhs:mixed
int(32)
array(1) {
["dynamic"]=>
int(32)
}
-- nullable object currently null --
rhs:nullable-object
int(34)
-- receiver and key once --
receiver:1
key:1
exists:side
rhs:side
set:side=55
int(55)
int(1)
int(1)
array(1) {
["side"]=>
int(55)
}
-- unused result --
exists:value
rhs:unused
set:value=66
array(1) {
["value"]=>
int(66)
}
-- throwing rhs --
exists:value
rhs:throw
caught
array(0) {
}
-- ArrayObject --
rhs:array-object
int(77)
array(1) {
["value"]=>
int(77)
}
Loading…
Cancel
Save