Skip to content

fix(optimizer): stop folding count() on unfoldable array literals - #24

Open
Giandonn wants to merge 3 commits into
swoole:masterfrom
Giandonn:fix/count-literal-fold-side-effects
Open

fix(optimizer): stop folding count() on unfoldable array literals#24
Giandonn wants to merge 3 commits into
swoole:masterfrom
Giandonn:fix/count-literal-fold-side-effects

Conversation

@Giandonn

Copy link
Copy Markdown

Fixes #23

doFoldCountLiteral replaced count([...]) with the number of AST items and
dropped the array literal. That holds only when the item count equals the
runtime element count and no element carries an observable effect. Three common
shapes break both assumptions:

count([bump(), bump()]);      // folded to 2, bump() never ran
count(['a' => 1, 'a' => 2]);  // folded to 2, PHP counts 1
count([...$rest, 9]);         // folded to 2, PHP counts 6 for a 5-element $rest

The change

The fold now applies only when every item is unkeyed, is not a spread, and
holds an expression whose evaluation cannot be observed: a scalar, a constant
fetch (which also covers true / false / null), a class constant fetch, a
unary sign over any of those, a nested literal that is itself foldable, or a
variable already known to be defined. An undefined variable still falls through
to the dynamic path so it reports the same diagnostic as PHP.

Everything else keeps the runtime php::fn::count() call. The existing
optimization is untouched for the shapes it was written for:
count([1, 2, 3]), count([[1, 2], [3]]), count([$a, -2, true, null]) and
count([]) all still fold to a constant with no runtime call.

Keyed literals are conservatively excluded because an integer-like key can
collapse onto an earlier element (['1' => 'a', 1 => 'b'] counts as one).
They could be folded later by normalizing the keys first; this PR keeps the
correctness fix minimal.

Tests

  • tests/compiler/array/count-literal-fold.phpt - runtime semantics, with the
    expected block taken from PHP 8.5.4.
  • phpunit/src/CountLiteralFoldTest.php - asserts the fold/no-fold decision in
    the generated C++, so it runs without a native toolchain.

Both fail on master (Failed asserting that 0 is identical to 4) and pass with
the change. The full PHPUnit suite reports the same results as master, and
PHPStan reports no new findings for the touched file.

Giandonn added 2 commits August 29, 2026 19:52
doFoldCountLiteral replaced `count([...])` with the number of AST items
and dropped the array literal entirely. That is only correct when the
item count equals the runtime element count and no element carries an
observable effect. Three common shapes break both assumptions:

    count([bump(), bump()]);        // folded to 2, bump() never ran
    count(['a' => 1, 'a' => 2]);    // folded to 2, PHP counts 1
    count([...$rest, 9]);           // folded to 2, PHP counts 6

The spread case is the most damaging: it silently yields a wrong number
in ordinary code that compiles without any diagnostic.

The fold now applies only when every item is unkeyed, is not a spread,
and holds an expression whose evaluation cannot be observed - a scalar,
a constant fetch, a unary sign over either, a nested literal that is
itself foldable, or a variable already known to be defined. An undefined
variable still reaches the dynamic path so it reports the same
diagnostic as PHP. Everything else keeps the runtime php::fn::count()
call, so `count([1, 2, 3])` and friends still fold as before.

Covered by tests/compiler/array/count-literal-fold.phpt for the runtime
semantics and by CountLiteralFoldTest for the fold/no-fold decision in
the generated C++.

@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 investigating this and for adding both PHPT and code-generation coverage. The reported spread, duplicate-key, call, and increment cases are real, and this PR fixes those examples.

I found that the new "effect-free" whitelist is still too broad, however, so I'm requesting changes before merge.

ConstFetch, ClassConstFetch, and the base Node\Scalar type cannot all be discarded safely. For example:

count([UNDEFINED_COUNT_LITERAL]);
count([KnownClass::MISSING]);
count(["{$object->property}"]);

PHP must evaluate each element. The first two expressions throw Error; the interpolated string may invoke __get(). With this PR, all three calls are folded to the integer literal 1, so the errors and property side effect disappear. Node\Scalar includes interpolated strings, not only literal int/float/string nodes.

The defined-variable check is also not a sufficient purity proof. hasVar() only shows that a variable has a compiler slot; it does not prove that the variable remains initialized after unset() or on every control-flow path. Array items with byRef should also be rejected explicitly.

Please make the fold conservative. A safe initial whitelist would be:

  • literal Int_, Float_, and non-interpolated String_;
  • the language constants true, false, and null only;
  • unary plus/minus over proven-safe numeric literals;
  • recursively safe nested arrays with no key, unpack, or by-reference item.

Please leave ordinary variables, general constant/class-constant fetches, interpolated strings, references, and all other expressions on the runtime path. This optimization is small enough that safety should take priority over maximizing the number of folded forms.

The existing added tests pass locally, but please add negative fold-decision/runtime cases for at least an undefined constant, a missing class constant, and an interpolated magic-property read. There are currently no GitHub checks reported for this PR, so CI should also complete before merge.

…tems

The first whitelist was too broad. ConstFetch, ClassConstFetch and the
base Node\Scalar type all admit expressions PHP must still evaluate, so
count([UNDEFINED_COUNT_LITERAL]), count([KnownClass::MISSING]) and
count(["{$object->property}"]) folded to 1, dropping two Errors and a
__get() call. The defined-variable check was not a purity proof either:
hasVar() only reports a compiler slot, not that the variable is still
initialized on every path after unset().

Narrow the fold to items whose evaluation cannot be observed:

- literal Int_, Float_ and String_ (an interpolated string is a distinct
  InterpolatedString node, so String_ already excludes it);
- the language constants true, false and null only;
- unary plus/minus over a literal int or float;
- recursively safe nested arrays.

Variables, general constant and class constant fetches, interpolated
strings and every other expression stay on the runtime path, and
by-reference items are now rejected explicitly alongside keys and
unpacking.

Cover the three reported cases plus a by-reference item, a plain
variable read and a defined class constant in both the fold-decision
test and the PHPT.
@Giandonn

Copy link
Copy Markdown
Author

Thank you for the detailed review — you were right on every point, and the three examples all reproduce. I have pushed a commit that narrows the fold to the whitelist you specified.

Confirming the reported failures. Running the new PHPT against the previous version of this PR:

int(1)
constant-error-not-thrown
int(1)
class-constant-error-not-thrown
int(1)

Both Errors were swallowed, and get-property never appeared, so the __get() call was discarded exactly as you described.

What changed. isCountFoldableItem() now accepts only:

  • literal Int_, Float_ and String_;
  • the language constants true, false and null;
  • unary plus/minus over a literal int or float;
  • recursively safe nested arrays.

Everything else — variables, general ConstFetch and ClassConstFetch, interpolated strings, calls — stays on the runtime path. The hasVar() check is gone; as you noted, a compiler slot is not a proof that the variable is still initialized on every path after unset(). isCountFoldableArray() now rejects $item->byRef explicitly alongside keys and unpacking.

One note on the Node\Scalar point, in case it is useful for the other handlers: under php-parser 5.x an interpolated string is Node\Scalar\InterpolatedString, a sibling of Node\Scalar\String_ rather than a subclass. So matching String_ exactly already excludes interpolation; it was the base Node\Scalar type in the old check that let it through.

Test coverage. The fold-decision fixture now holds ten calls that must all stay dynamic: element side effects, a duplicate key, a spread, a by-reference item, a plain variable read, an undefined constant, a missing class constant on a known class, an interpolated magic-property read, and a defined class constant. The PHPT covers the same cases at runtime, asserting both Error messages and the get-property output.

Verified locally with the ZTS 8.5 embed toolchain: the PHPT compiles and runs green, the targeted PHPUnit tests pass, and PHPStan is clean on the changed file. CI reported green on the previous push, so the full suite should now run on this one as well.

Separately — I noticed 057c217 adding the arity guard to doFoldKnownClass() after #26 landed. That was my omission in that PR; thank you for catching and fixing it. I will apply the same guard shape to #28 and #30 rather than waiting for it to be pointed out again.

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.

count() on an array literal folds away spreads, duplicate keys and element side effects

2 participants