fix(codegen): keep Zend operand read order around hoisted side effects (#52) --skip-tests
* fix(codegen): keep Zend operand read order around hoisted side effects
Lowering a later call argument or concat operand that materializes
captured statements (an assignment, a call result) appended them to the
enclosing statement, executing the side effect before earlier operands
were read: two($j, $j = 5) with $j = 1 produced "5,5" (Zend "1,5") and
$m . "," . ($m = 9) produced "9,9" (Zend "1,9").
Call arguments: Zend SENDs strictly left to right, so when a later
argument hoists statements, every earlier by-value plain-variable
argument is snapshotted into a temporary at its own argument position.
By-reference parameters, unpacked arguments, $this and $GLOBALS are
left alone.
Concat chains: Zend reads a CV operand when its CONCAT opcode executes,
so in the left-associated chain the first two items are read together
at the first op (after both items' side effects: $s . ($s = 'b') . $s
is "bbb") and each later item after the side effects of everything up
to itself. The flattened braced-list lowering now snapshots a
plain-variable item exactly at that read position, deferring the first
item's snapshot until the second item has been lowered.
Plain arithmetic is intentionally unchanged: Zend's ADD reads the CV at
op time, so $k + ($k = 5) is 10 in both worlds, and the existing
codegen already matches.
* test(codegen): platform-neutral integer-literal suffixes in eval-order assertions
* fix(codegen): detect wrapped side effects when ordering operand reads
Zend sends call arguments strictly left to right and reads a concat
operand's CV when its opcode executes: an assignment nested in a later
argument runs after every earlier by-value argument has been sent, so
pairValue($i, (int) ($i = 5)) passes the old value ("1,5"), and the
same holds when the assignment is wrapped in !, unary +/-, ~, or @.
shouldMaterializeOrderedOperand() only descended through BinaryOp
nodes, so an assignment inside any other expression wrapper was not
classified as side-effecting: no earlier operand was snapshotted and
the assignment could even stay inline in the C++ argument list
(php::toInt(i = 5LL)), mutating the variable at an unsequenced point.
Deriving the decision from the captured statements lowering produces
would miss exactly these inline cases, so the classifier now recurses
structurally through every sub-expression: any wrapper of a
side-effecting node is itself side-effecting. Closure and arrow
function bodies do not run at creation time and stop the walk; throw,
yield, yield from, and backtick expressions join the side-effecting
leaves.
Zend's ADD opcode still reads its CV at op time, so $k + ($k = 5)
keeps its existing codegen (10 in both worlds, no snapshot).
* fix(codegen): type materialized wrapper operands as dynamic temporaries
A wrapper expression around a side effect (-strlen($s), a cast, error
suppression) is now materialized for evaluation order, but the temp-type
fallback typed it by the detected PHP type. The lowered C++ can still be
dynamic — an unqualified namespaced call lowers to php::call(...), a
Variant — so a php::Int temporary failed to compile in the self-build.
Follow the call/binary-op policy: native scalar types only in
native-types mode, otherwise a dynamic temporary.
master
parent
8f8ae77ec8
commit
ea0ea4414a
5 changed files with 410 additions and 16 deletions
@ -0,0 +1,41 @@ |
||||
<?php |
||||
|
||||
function pair(int $a, int $b): string |
||||
{ |
||||
return $a . ',' . $b; |
||||
} |
||||
|
||||
function callArgOrder(): string |
||||
{ |
||||
$j = 1; |
||||
return pair($j, $j = 5); |
||||
} |
||||
|
||||
function concatOrder(): string |
||||
{ |
||||
$m = 1; |
||||
return $m . ',' . ($m = 9); |
||||
} |
||||
|
||||
function plainArithmeticUnchanged(): int |
||||
{ |
||||
$k = 1; |
||||
return $k + ($k = 5); |
||||
} |
||||
|
||||
function pairValue(mixed $a, mixed $b): string |
||||
{ |
||||
return $a . ',' . $b; |
||||
} |
||||
|
||||
function castWrappedCallArgOrder(): string |
||||
{ |
||||
$i = 1; |
||||
return pair($i, (int) ($i = 5)); |
||||
} |
||||
|
||||
function notWrappedCallArgOrder(): string |
||||
{ |
||||
$k = 1; |
||||
return pairValue($k, !($k = 0)); |
||||
} |
||||
@ -0,0 +1,122 @@ |
||||
<?php |
||||
|
||||
use TypePhp\CompilerTest; |
||||
|
||||
/** |
||||
* PHP evaluates call arguments and concat operands left to right. When a |
||||
* later operand hoists captured statements (an assignment), earlier |
||||
* plain-variable reads must be snapshotted at their own position, or the |
||||
* hoisted side effect executes first: pair($j, $j = 5) must return "1,5" |
||||
* and $m . ',' . ($m = 9) must be "1,9". Plain arithmetic is exempt: |
||||
* Zend's ADD opcode reads the CV at op time, so $k + ($k = 5) is 10 in |
||||
* both worlds and must keep its existing codegen. |
||||
*/ |
||||
final class EvalOrderSideEffectsCodegenTest extends \BaseTest |
||||
{ |
||||
public function testCallArgumentReadIsSnapshottedBeforeLaterAssignment(): void |
||||
{ |
||||
$code = $this->compileFixture(); |
||||
$body = $this->extractFunctionBody($code, 'php_callargorder()'); |
||||
|
||||
self::assertMatchesRegularExpression( |
||||
'/(tmp_var_\d+) = j;\s*\n\s*(tmp_var_\d+) = j = 5L{1,2};/', |
||||
$body, |
||||
'the old value of $j must be captured before $j = 5 executes', |
||||
); |
||||
self::assertDoesNotMatchRegularExpression( |
||||
'/php_pair\(php::toIntArgExact\(j,/', |
||||
$body, |
||||
'$j must not be read directly after the hoisted assignment', |
||||
); |
||||
} |
||||
|
||||
public function testConcatOperandReadIsSnapshottedBeforeLaterAssignment(): void |
||||
{ |
||||
$code = $this->compileFixture(); |
||||
$body = $this->extractFunctionBody($code, 'php_concatorder()'); |
||||
|
||||
self::assertMatchesRegularExpression( |
||||
'/(tmp_var_\d+) = m;\s*\n\s*(tmp_var_\d+) = m = 9L{1,2};/', |
||||
$body, |
||||
'the old value of $m must be captured before $m = 9 executes', |
||||
); |
||||
self::assertDoesNotMatchRegularExpression( |
||||
'/php::concat\(\{php::toString\(m\)/', |
||||
$body, |
||||
'$m must not be read directly after the hoisted assignment', |
||||
); |
||||
} |
||||
|
||||
public function testCastWrappedAssignmentStillSnapshotsEarlierArgument(): void |
||||
{ |
||||
$code = $this->compileFixture(); |
||||
$body = $this->extractFunctionBody($code, 'php_castwrappedcallargorder()'); |
||||
|
||||
self::assertMatchesRegularExpression( |
||||
'/(tmp_var_\d+) = i;\s*\n\s*(tmp_var_\d+) = php::toInt\(i = 5L{1,2}\);/', |
||||
$body, |
||||
'the old value of $i must be captured before the cast-wrapped $i = 5 executes', |
||||
); |
||||
self::assertDoesNotMatchRegularExpression( |
||||
'/php_pair\(php::toIntArgExact\(i,/', |
||||
$body, |
||||
'$i must not be read directly alongside the wrapped assignment', |
||||
); |
||||
} |
||||
|
||||
public function testBooleanNotWrappedAssignmentStillSnapshotsEarlierArgument(): void |
||||
{ |
||||
$code = $this->compileFixture(); |
||||
$body = $this->extractFunctionBody($code, 'php_notwrappedcallargorder()'); |
||||
|
||||
self::assertMatchesRegularExpression( |
||||
'/(tmp_var_\d+) = k;\s*\n\s*(tmp_var_\d+) = !\(php::toBool\(k = 0L{1,2}\)\);/', |
||||
$body, |
||||
'the old value of $k must be captured before the negated $k = 0 executes', |
||||
); |
||||
self::assertDoesNotMatchRegularExpression( |
||||
'/php_pairvalue\(k,/', |
||||
$body, |
||||
'$k must not be read directly alongside the wrapped assignment', |
||||
); |
||||
} |
||||
|
||||
public function testPlainArithmeticKeepsZendCvReadSemantics(): void |
||||
{ |
||||
$code = $this->compileFixture(); |
||||
$body = $this->extractFunctionBody($code, 'php_plainarithmeticunchanged()'); |
||||
|
||||
// Zend reads the CV when the ADD executes, i.e. after the nested |
||||
// assignment; the direct read of k matches that and must stay. |
||||
self::assertMatchesRegularExpression( |
||||
'/(tmp_var_\d+) = k = 5L{1,2};\s*\n[^\n]*\(\(k\) \+ \(\1\)\)/', |
||||
$body, |
||||
); |
||||
self::assertStringNotContainsString('= k;', $body); |
||||
} |
||||
|
||||
private function extractFunctionBody(string $code, string $marker): string |
||||
{ |
||||
$start = strpos($code, $marker); |
||||
self::assertIsInt($start, "missing function: {$marker}"); |
||||
$end = strpos($code, "\n}", $start); |
||||
self::assertIsInt($end); |
||||
return substr($code, $start, $end - $start); |
||||
} |
||||
|
||||
private function compileFixture(): string |
||||
{ |
||||
global $translator; |
||||
|
||||
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); |
||||
$translator = $compiler; |
||||
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/eval-order-side-effects.php'; |
||||
$compiler->addFiles([$source]); |
||||
$compiler->prepareFile($source); |
||||
$generated = $compiler->convertFile($source); |
||||
$code = file_get_contents($generated); |
||||
|
||||
self::assertIsString($code); |
||||
return $code; |
||||
} |
||||
} |
||||
@ -0,0 +1,105 @@ |
||||
--TEST-- |
||||
Call arguments and concat operands follow Zend's operand read order around side effects |
||||
--FILE-- |
||||
<?php |
||||
declare(strict_types=1); |
||||
|
||||
function pair(int $a, int $b): string |
||||
{ |
||||
return $a . ',' . $b; |
||||
} |
||||
|
||||
function pairValue(mixed $a, mixed $b): string |
||||
{ |
||||
return $a . ',' . $b; |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
// Call arguments are sent strictly left to right. |
||||
$j = 1; |
||||
var_dump(pair($j, $j = 5)); |
||||
var_dump($j); |
||||
|
||||
$n = 2; |
||||
var_dump(pair($n, $n *= 3)); |
||||
var_dump($n); |
||||
|
||||
// Concat chains: a variable is read when its concat op executes, so it |
||||
// sees the side effects of everything up to and including the operand it |
||||
// is combined with, but nothing later. |
||||
$m = 1; |
||||
var_dump($m . ',' . ($m = 9)); |
||||
var_dump($m); |
||||
|
||||
// The first two operands are read together at the first op, after the |
||||
// second operand's assignment. |
||||
$s = 'a'; |
||||
var_dump($s . ($s = 'b') . $s); |
||||
|
||||
$a = 'a'; |
||||
var_dump($a . ($a = 'x') . $a . ($a = 'y')); |
||||
|
||||
$t = 'p'; |
||||
$u = 'a'; |
||||
$t .= $u . ($u = 'b'); |
||||
var_dump($t); |
||||
|
||||
$t2 = 'p'; |
||||
$u2 = 'a'; |
||||
$t2 .= $u2 . ',' . ($u2 = 'b'); |
||||
var_dump($t2); |
||||
|
||||
$w = 'a'; |
||||
var_dump('pre' . $w . ($w = 'z')); |
||||
|
||||
// Zend reads the CV when the ADD executes, after the nested assignment. |
||||
$k = 1; |
||||
var_dump($k + ($k = 5)); |
||||
|
||||
// A side-effecting argument stays side-effecting inside an expression |
||||
// wrapper (a cast, boolean not, unary minus, error suppression): the |
||||
// earlier by-value argument still reads the value before the assignment. |
||||
$c = 1; |
||||
var_dump(pair($c, (int) ($c = 5))); |
||||
var_dump($c); |
||||
|
||||
$b = 1; |
||||
var_dump(pairValue($b, !($b = 0))); |
||||
var_dump($b); |
||||
|
||||
$d = 1; |
||||
var_dump(pair($d, -($d = 5))); |
||||
|
||||
$e = 1; |
||||
var_dump(pairValue($e, @($e = 5))); |
||||
|
||||
// Wrapped side effects inside a concat chain follow the same read order. |
||||
$g = 1; |
||||
var_dump($g . ',' . (int) ($g = 9)); |
||||
|
||||
$h = 1; |
||||
var_dump($h . ',' . !($h = 0)); |
||||
} |
||||
?> |
||||
--EXPECT-- |
||||
string(3) "1,5" |
||||
int(5) |
||||
string(3) "2,6" |
||||
int(6) |
||||
string(3) "1,9" |
||||
int(9) |
||||
string(3) "bbb" |
||||
string(4) "xxxy" |
||||
string(3) "pbb" |
||||
string(4) "pa,b" |
||||
string(5) "preaz" |
||||
int(10) |
||||
string(3) "1,5" |
||||
int(5) |
||||
string(3) "1,1" |
||||
int(0) |
||||
string(4) "1,-5" |
||||
string(3) "1,5" |
||||
string(3) "1,9" |
||||
string(3) "1,1" |
||||
Loading…
Reference in new issue