Skip to content

Type the array_reduce() carry as a loop variable - #6996

Open
gnutix wants to merge 1 commit into
phpstan:2.3.xfrom
gnutix:array-reduce-carry-type
Open

gnutix wants to merge 1 commit into
phpstan:2.3.xfrom
gnutix:array-reduce-carry-type

Conversation

@gnutix

@gnutix gnutix commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Closes phpstan/phpstan#7280

Supersedes #5978, which was closed as solved by #6957. #6957 fixed phpstan/phpstan#15388, which #5978 also fixed as a side effect, but not phpstan/phpstan#7280.

array_reduce() is a loop: the carry starts as the initial value, and each pass replaces it with what the callback returns. PHPStan typed the callback's carry from the stub's callable(TReturn, TIn): TReturn, with TReturn inferred from the callback's own return type before the callback had a carry type. So the carry ended up as mixed, or a vague non-empty-array, and code reached for /** @var list<X> $carry */ inside the callback.

This change types the carry the way a foreach types a variable it writes on every pass:

  1. Start with the type of the initial value (null when there is none).
  2. Get the callback's return type with the carry typed as that, and union the two.
  3. Repeat until nothing new comes in. From the second pass on, widen the carry with the same rules a loop variable is widened with (MutatingScope::generalizeWith()), so array{} → array{x} → … becomes list<x>, and 0 → 0|1 → … becomes int<0, max>. Use the loop's own LOOP_SCOPE_ITERATIONS and GENERALIZE_AFTER_ITERATION constants.
  4. If no fixed point is reached in that budget, keep today's behaviour. A carry the callback can still outgrow would be unsound.
  5. If the callback's return type comes out as an implicit never, also keep today's behaviour. That happens when its body's inference gave up, as $carry[count($carry)] = … currently makes it. It says nothing about the carry, and taking the initial value as a fixed point would be unsound. An explicit never (a callback that always throws) still counts, so the result is the initial value.

This works only when the callback is a closure or arrow function written in the call: that is the only case where the carry is inferred rather than declared. A named function, a first-class callable or a variable keeps its declared signature, as before.

There are three pieces:

  • ArrayReduceCarryTypeResolver runs the fixed point. It asks ClosureTypeResolver::getClosureType() for the return type, given a CallableType that types the parameters. That call is cached per parameter typing, so asking again for the same carry is cheap.
  • ArrayReduceParameterClosureTypeExtension (a new FunctionParameterClosureTypeExtension) gives the closure callable(<carry>, <item>), so its body is walked with the converged carry.
  • ArrayReduceFunctionReturnTypeExtension takes the callback's return type from the same resolver. It no longer relies on the closure having been walked first, which also helps a synthetic array_reduce call that a wrapper's return type extension builds. It now decides between "initial", "callback return" and "both" with isIterableAtLeastOnce() on any array type, not only on constant arrays. A non-empty-list never returns the initial value.

No class shadowed by the Turbo extension is touched, and nothing under turbo-ext/ changes. The widening goes through the scope's public assignVariable() and generalizeWith() on a throwaway variable, so it adds no new method to MutatingScope.

Before / after

Code Before After
issue's carry (initial: ['starts' => [], 'ends' => []]) non-empty-array array{starts: list<'test1'|'test2'>, ends: list<…>}
issue's result non-empty-array&hasOffsetValue('ends', mixed)&hasOffsetValue('starts', mixed) array{starts: non-empty-list<…>, ends: non-empty-list<…>}
fn ($curr, $n) => $curr + $n, 0, over array<int, int> (float|int) int
$carry[$s] = strlen($s); return $carry;, [] mixed array<string, int<0, max>>
$carry[$s[0] ?? ''][] = $s;, [] array array<string, non-empty-list<string>>
fn ($c) => $c + 1, 0, over non-empty-list (float|int) int<1, max>
nested sum over list<list<int>> (float|int) int
$carry[count($carry)] = $v; return $carry;, [] mixed mixed (unchanged, see 5.)

Two existing expectations change because they become more precise:

  • bug-15388.php: (float|int) becomes int. That issue complained that summing ints gave float|int.
  • array-functions.php: a reduce over array_unique([1, 2, 3]), which is a non-empty-array, no longer includes the initial value.

Note on the issue's own expectation: it asked for the carry to be array{starts: array<never>, ends: array<never>} inside the callback. That is only true on the first pass; on the second pass the lists are non-empty. The carry is now the union over all passes, list<…>, which is the shape asked for in the review of #5168.

Compared with #5978

#5978 ran the fixed point inside ClosureTypeResolver and needed a new ArrayReduceArgVisitor, a ScopeOps::nodeKey() change and their C++ mirrors (+1031 lines, about 600 of them C++). This PR keeps everything in src/Type/Php/ (+343 lines, no C++).

Because it widens the carry with the loop's rules instead of giving up after one widening, it also handles carries that grow on every pass. For example, $carry[] = $v over list<int> with [] gives list<int>, where #5978 gave mixed.

Turbo

  • The full suite passes with the extension loaded, built from this branch (make build-turbo, version 4a6e0e9), and without it: 23,568 tests each, PHP 8.5.12, 4 workers.
  • turbo-ext/tests/smoke.php: ALL OK.
  • Analysis output (--error-format=raw) is byte-identical with the extension on and off for:

Performance

Measured as user CPU, base (4a6e0e907) against this PR, in alternating runs with the result cache cleared each time and 4 workers. The machine had other load, so single runs vary by up to 15%.

PHPStan's self-analysis (build/phpstan.neon on src/, 3 pairs): no measurable difference. Without Turbo the means were 245 s → 258 s; with Turbo, 141 s → 133 s. Both gaps are inside the spread. src/ calls array_reduce() only twice, and the output is identical.

Worst case: a file made of nothing but reduces. It has 150 functions, each running four array_reduce() closures over list<array{id: int, name: string, tags: list<string>}>, 600 calls in all (mean of 4 pairs):

base this PR
without Turbo 6.27 s 14.62 s (2.3×)
with Turbo 3.01 s 5.03 s (1.7×)

On the same file, base reports 300 level-9 errors (Cannot access offset … on mixed on the untyped carry), and this PR reports none.

By callback, 150 calls each, without Turbo, mean of 2 pairs. These figures include about 1.3 s of startup, which an empty file also takes:

Callback base this PR
$c[$r['name']][] = $r['id'], [] 3.07 s 4.43 s
fn ($c, $r) => $c + count($r['tags']), 0 2.97 s 4.02 s
$c['ids'][] = …; $c['tags'] = array_merge($c['tags'], …), ['ids' => [], 'tags' => []] 4.22 s 8.35 s
nested: $c + array_reduce($r['tags'], fn ($x, $t) => $x + strlen($t), 0), 0 5.18 s 9.13 s

The cost is the extra walks of the callback body, one per pass, so at most LOOP_SCOPE_ITERATIONS + 1 walks. It grows with the number of passes a carry needs to settle; a shape whose lists fill up takes the most. Nested reduces grow linearly with depth, not exponentially: from 2 to 5 levels deep, one call goes from 0.66 s to 1.30 s, startup included.

Limits

Verification

  • Every new or changed assertion fails with the source change stashed and passes with it.
  • Full suite, with and without Turbo: see above. phpcs is clean, and PHPStan's self-analysis output is unchanged.

🤖 Generated with Claude Code

The callback's carry starts as the initial value and grows by what the
closure returns for it, widened with the loop's generalization rules,
until the closure adds nothing new. The closure body is walked with
that carry, and the call returns what the closure returns for it - or
the initial value too, when the array may be empty.

A closure whose return type comes out as an implicit never (its body's
inference gave up) says nothing about the carry, so it keeps the
callback's declared typing instead of passing for a fixed point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant