Skip to content

fix(codegen): evaluate compound ??= RHS only when the target is not set - #47

Open
AlessioGiacobbe wants to merge 3 commits into
swoole:masterfrom
AlessioGiacobbe:split/coalesce-assign-side-effects
Open

fix(codegen): evaluate compound ??= RHS only when the target is not set#47
AlessioGiacobbe wants to merge 3 commits into
swoole:masterfrom
AlessioGiacobbe:split/coalesce-assign-side-effects

Conversation

@AlessioGiacobbe

Copy link
Copy Markdown
Contributor

??= evaluated a side-effecting right-hand side unconditionally when the RHS materialized statements: in $a = 1; $a ??= sideEffect() + 1; the call ran even though the target was set (Zend never runs it). The simple form $b ??= f() was already correct — only compound RHS shapes broke, which is precisely the lazy-init idiom ??= exists for.

The native-object conditional-lambda mechanism is generalized so captured RHS statements execute only in the not-set branch. The simple form keeps its ternary byte-for-byte.

Verified against Zend 8.4.13; codegen test + phpt included.

Part of the split of #39.

@matyhtf matyhtf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for splitting this fix out of #39. The overall direction is correct: captured RHS statements must stay inside the not-set branch so that ??= remains lazy.

There are two correctness issues that need to be addressed before merging:

  1. Complex assignment targets are evaluated repeatedly

The generated lambda expands $var in the guard return, assignment, and final return:

if (isset) { return receiver(box).attr(...); }
receiver(box).attr(...) = rhs;
return receiver(box).attr(...);

For example:

receiver($box)->value ??= sideEffect() + 1;
$array[arrayKey()] ??= sideEffect() + 1;

Zend evaluates receiver() / arrayKey() exactly once. With this patch, TypePHP evaluates it twice when the target is set and three times when it is not set. The old lowering already had a repeated-evaluation problem, but the final return $var introduced here adds another observable evaluation on the assignment path.

Please stabilize the complete writable target (including its receiver and array key) and reuse it for the isset check, read, write, and returned value.

  1. rightAfter runs after the target assignment

The generic branch currently emits:

var = right;
rightAfter;
return var;

That does not preserve PHP expression order. A postfix operation on the RHS must finish before the outer ??= assignment:

class State {
    public static mixed $assigned = null;
}

class Source {
    private int $stored = 5;

    public int $value {
        get { return $this->stored; }
        set {
            var_dump(State::$assigned);
            $this->stored = $value;
        }
    }
}

State::$assigned ??= $source->value++;

Zend prints NULL from the setter; the current patch prints int(5), because the target was assigned before the postfix write-back. The native-object branch above already uses the correct sequence: evaluate RHS into a temporary, run rightAfter, then assign the target. The generic branch needs equivalent ordering without losing type safety.

Please also add PHPT coverage for both the set/unset branches of a side-effecting property receiver and array key, plus a RHS postfix operation whose write-back can observe the target. The existing local-variable tests pass, but they do not exercise these cases.

PHP evaluates the right-hand side of ??= lazily: `$a = 1;
$a ??= sideEffect() + 1;` never calls sideEffect(). When the RHS was a
compound expression the compiler materialized its lowered statements
(the call result temporary) into the enclosing statement context, so
the generated C++ executed the side-effecting call unconditionally
before the isset check.

Generalize the conditional-lambda lowering that already protected
native-object targets: whenever the RHS captured before/after
statements, emit an immediately-invoked lambda whose not-set branch
contains those statements, the assignment and the cleanup. The simple
inline form (`$b ??= f()`) keeps its existing conditional-expression
codegen unchanged.
Zend evaluates a coalesce-assignment target's receiver and array keys
exactly once, before the isset check and regardless of its outcome; the
string-based lowering mentioned the target on every use (isset, read,
write, returned value), so a side-effecting receiver ran twice when the
target was set and three times when it was not. Side-effecting target
subexpressions are now materialized into temporaries in source order
(array containers keep their original variable — writing through a
copied temporary would write to the copy — while object receivers are
handles) and the rewritten target reuses them everywhere.

The captured branch also assigned the target before running the RHS's
deferred write-backs, so a postfix increment on the RHS finished after
the outer assignment — observable by a set hook on the target. The RHS
now completes into a temporary (write-backs included) before the target
is written, and the assignment expression itself is returned so the
target is not read again afterwards.
@AlessioGiacobbe
AlessioGiacobbe force-pushed the split/coalesce-assign-side-effects branch from 632684e to b70579a Compare September 1, 2026 11:26
@AlessioGiacobbe

Copy link
Copy Markdown
Contributor Author

Both issues addressed (second commit on the branch, rebased on latest master):

  1. Target stabilization — side-effecting subexpressions of the target (property receivers, array keys) are now materialized into temporaries exactly once, in source order, before the isset check and regardless of its outcome, and the rewritten target reuses them for the isset check, read, write, and returned value. Array containers deliberately keep their original variable (writing through a copied temporary would write to the copy); object receivers are handles, so a Variant temporary preserves identity — same idiom as the receiver materialization in af466b0. Generated code for your receiver($box)->value ??= … example now evaluates receiver() once on both branches.

  2. RHS write-back ordering — the generic branch now completes the RHS into a temporary (deferred write-backs included) before the target is written, and returns the assignment expression itself instead of re-reading the target afterwards, which also removes the extra observable evaluation the final return $var introduced. Your hooked-property example now prints NULL from the set hook.

Added the three PHPT cases you asked for, with Zend-8.4-validated expectations: side-effecting property receiver (both branches), side-effecting array key (both branches), and the RHS postfix whose write-back a set hook on the target can observe.

One note from testing: function receiver(Box $b): Box with a class-typed parameter trips a pre-existing, unrelated front-end error ("Cannot re-assign variable from php::Object to php::Box") that reproduces on master without this PR; the new tests use object typing to stay out of its way. Happy to file it separately.

@matyhtf matyhtf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two issues from the previous review are fixed: the receiver/key tests, postfix write-back ordering test, and earlier reproductions now pass.

Three target-stabilization problems remain and must be fixed before merging.

1. A side-effecting array container is evaluated out of order and more than once

function makeArray(): array
{
    echo "ARRAY\n";
    return [];
}

function keyName(): string
{
    echo "KEY\n";
    return 'value';
}

var_dump(makeArray()[keyName()] ??= 42);

PHP evaluates this as:

ARRAY
KEY
int(42)

The current branch evaluates it as:

KEY
ARRAY
ARRAY
int(42)

stabilizeCoalesceTarget() materializes the dimension immediately, but leaves a FuncCall container unchanged. This both reverses source order and makes the container execute once for the isset check and again for the write. Non-variable value-producing array containers must also be stabilized, while true writable containers must continue to preserve write-through semantics.

2. Dynamic property names are still evaluated repeatedly

Both instance and static property names are affected:

$box->{propertyName()} ??= rhs();
Box::${propertyName()} ??= rhs();

The instance name runs twice on each branch in the current output, and the static name can run three times. The new code only stabilizes PropertyFetch::$var; it does not stabilize an expression stored in PropertyFetch::$name, and StaticPropertyFetch is not handled at all.

Please materialize dynamic property names in PHP evaluation order and add coverage for both instance and static properties.

3. The materialized receiver remains alive until function exit

class Box
{
    public mixed $value = null;

    public function __destruct()
    {
        echo "DESTRUCT\n";
    }
}

function makeBox(): object
{
    echo "MAKE\n";
    return new Box();
}

makeBox()->value ??= 42;
echo "AFTER\n";

PHP prints:

MAKE
DESTRUCT
AFTER

The current branch prints:

MAKE
AFTER
DESTRUCT

materializeCoalesceTargetSubexpr() creates a function-scoped Variant temporary but never schedules its cleanup. Please follow the existing lifetime handling in parseOrderedOperand() / stabilizeAssignOpPropertyReceiver(): clear zval-owning temporaries with .unset() at statement end and reset Native pointer temporaries to nullptr.

The target should be stabilized recursively in source order: container/receiver, dynamic property name, then array dimension. Reusing or extending the existing ordered-operand helpers would also help avoid two subtly different lifetime implementations.

…ry lifetimes

Three target-stabilization gaps in the coalesce-assignment lowering:

- A value-producing array container (makeArray()[keyName()] ??= 42) was
  left unstabilized: the dimension was materialized first, reversing
  PHP's container-then-key source order, and the container ran once for
  the isset check and again for the write. Non-variable containers are
  now materialized in source order; plain-variable containers keep
  write-through semantics, and a value-producing container is itself
  the temporary PHP writes into.

- Dynamic property names were re-evaluated on every mention:
  $box->{propertyName()} ran the name expression twice per branch, and
  StaticPropertyFetch was not handled at all. Dynamic instance names,
  static class expressions and static property names are now
  materialized once, in PHP evaluation order (receiver, name, then
  dimension), recursively through chained targets.

- The materialized temporaries were function-scoped, deferring the
  receiver's destructor to function exit where PHP destroys it at the
  end of the statement. Temporaries now follow the established lifetime
  idiom (stabilizeAssignOpPropertyReceiver): zval-owning Variants are
  .unset() at statement end and Native pointer temporaries reset to
  nullptr.
@AlessioGiacobbe

Copy link
Copy Markdown
Contributor Author

All three fixed (new commit on the branch):

  1. Value-producing array containers are now stabilized too, in source order — container first, then key, each exactly once. Plain-variable containers keep write-through semantics; a value-producing container is itself the temporary PHP writes into, so the expression value still observes the write. Your makeArray()[keyName()] ??= 42 now evaluates ARRAY, KEY, int(42).

  2. Dynamic property names — instance ($box->{propertyName()}), static class expressions, and static property names (Box::${slotName()}) — are materialized once in PHP evaluation order (receiver → name → dimension), recursively through chained targets. StaticPropertyFetch is handled now.

  3. Temporary lifetimes follow the existing idiom from stabilizeAssignOpPropertyReceiver, as you suggested: zval-owning Variants get .unset() at statement end and Native pointer temporaries reset to nullptr, so makeBox()->value ??= 42; prints MAKE, DESTRUCT, AFTER.

Added a PHPT covering all three (container-before-key ordering, statement-end destruction, instance name once on both branches, static name once), with Zend-8.4-validated expectations; the earlier receiver/key/postfix tests are unchanged and still pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants