Repository navigation
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'scallable(TReturn, TIn): TReturn, withTReturninferred from the callback's own return type before the callback had a carry type. So the carry ended up asmixed, or a vaguenon-empty-array, and code reached for/** @var list<X> $carry */inside the callback.This change types the carry the way a
foreachtypes a variable it writes on every pass:nullwhen there is none).MutatingScope::generalizeWith()), soarray{}→array{x}→ … becomeslist<x>, and0→0|1→ … becomesint<0, max>. Use the loop's ownLOOP_SCOPE_ITERATIONSandGENERALIZE_AFTER_ITERATIONconstants.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 explicitnever(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:
ArrayReduceCarryTypeResolverruns the fixed point. It asksClosureTypeResolver::getClosureType()for the return type, given aCallableTypethat types the parameters. That call is cached per parameter typing, so asking again for the same carry is cheap.ArrayReduceParameterClosureTypeExtension(a newFunctionParameterClosureTypeExtension) gives the closurecallable(<carry>, <item>), so its body is walked with the converged carry.ArrayReduceFunctionReturnTypeExtensiontakes the callback's return type from the same resolver. It no longer relies on the closure having been walked first, which also helps a syntheticarray_reducecall that a wrapper's return type extension builds. It now decides between "initial", "callback return" and "both" withisIterableAtLeastOnce()on any array type, not only on constant arrays. Anon-empty-listnever 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 publicassignVariable()andgeneralizeWith()on a throwaway variable, so it adds no new method toMutatingScope.Before / after
initial: ['starts' => [], 'ends' => []])non-empty-arrayarray{starts: list<'test1'|'test2'>, ends: list<…>}non-empty-array&hasOffsetValue('ends', mixed)&hasOffsetValue('starts', mixed)array{starts: non-empty-list<…>, ends: non-empty-list<…>}fn ($curr, $n) => $curr + $n,0, overarray<int, int>(float|int)int$carry[$s] = strlen($s); return $carry;,[]mixedarray<string, int<0, max>>$carry[$s[0] ?? ''][] = $s;,[]arrayarray<string, non-empty-list<string>>fn ($c) => $c + 1,0, overnon-empty-list(float|int)int<1, max>list<list<int>>(float|int)int$carry[count($carry)] = $v; return $carry;,[]mixedmixed(unchanged, see 5.)Two existing expectations change because they become more precise:
bug-15388.php:(float|int)becomesint. That issue complained that summing ints gavefloat|int.array-functions.php: a reduce overarray_unique([1, 2, 3]), which is anon-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
ClosureTypeResolverand needed a newArrayReduceArgVisitor, aScopeOps::nodeKey()change and their C++ mirrors (+1031 lines, about 600 of them C++). This PR keeps everything insrc/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[] = $voverlist<int>with[]giveslist<int>, where #5978 gavemixed.Turbo
make build-turbo, version4a6e0e9), and without it: 23,568 tests each, PHP 8.5.12, 4 workers.turbo-ext/tests/smoke.php: ALL OK.--error-format=raw) is byte-identical with the extension on and off for:src/;bug-7280.php,bug-15388.php,array-reduce-inferred-carry.php, and Infer array_reduce() callback parameter types from the initial and array arguments #5978's test data files.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.neononsrc/, 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/callsarray_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 overlist<array{id: int, name: string, tags: list<string>}>, 600 calls in all (mean of 4 pairs):On the same file, base reports 300 level-9 errors (
Cannot access offset … on mixedon 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:
$c[$r['name']][] = $r['id'],[]fn ($c, $r) => $c + count($r['tags']),0$c['ids'][] = …; $c['tags'] = array_merge($c['tags'], …),['ids' => [], 'tags' => []]$c + array_reduce($r['tags'], fn ($x, $t) => $x + strlen($t), 0),0The cost is the extra walks of the callback body, one per pass, so at most
LOOP_SCOPE_ITERATIONS + 1walks. 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
TReturn (function array_reduce(), parameter)into the type of a callback that nests its carry (fn ($c, $v) => ['x' => $c]).(float|int); two levels giveint. This is the global body-walk depth guard of Template from a callable's iterable return type resolves to mixed three closure levels deep (regression in 2.3.0) phpstan#15406. With TrackClosureTypeResolver's body-walk depth guard per closure node instead of globally #6807 applied on top of this PR, they giveintat every depth I tried (2 to 6). The number of body walks then grows roughly quadratically with depth (22 → 280), not exponentially.ClosureTypeResolver's cache: the function-call stack is part of the cache key, and only the argument processing runs witharray_reduce()on it. So about 3 of the 11 body walks per call are repeats. Leave the function-call stack out of the closure type cache key #6997 leaves the stack out of the cache key; on top of this PR, it brings a call down to 7 body walks and the reduce-heavy file above from 14.53 s to 10.80 s without Turbo.partitionArray()finding in Infer array_reduce() callback parameter types from the initial and array arguments #5978's integration run.Verification
🤖 Generated with Claude Code