Repository navigation
Absorb refined constant scalars into their plain constant within the scalar buckets of TypeCombinator::union() - #6869
Merged
Conversation
…scalar buckets of `TypeCombinator::union()`
`'Foo'|('Foo'&hasMethod(m1))` was never collapsed to `'Foo'` because the scalar
buckets are compared only against the non-scalar union members. Repeated
`method_exists()` / `property_exists()` / `is_a()` checks on a loop-invariant
constant class-string grew the union to 2^N members.
Moving these intersections out of the buckets (the previous fix) sent them
through the generic scalar shortcut, which drops every plain constant of the
same kind once any type with isInteger()/isBoolean()/isFloat() yes is present
(`1&T|2` became `1&T`), and made unions of many refined constants quadratic.
The intersections now stay in their bucket. Before the bucket is compared
against the other members, each one is dropped when the plain constant of the
same value is present, or when a less refined intersection of the same value
contains it. Different values are disjoint, so the cost stays linear.
Ported to the native mirror in turbo-ext/src/TypeCombinator.cpp.
Amp-Thread-ID: https://ampcode.com/threads/T-01a1819f-fbb7-7460-b1bd-aab545ccfc0a
…value of that scalar is present The generic scalar shortcut in `TypeCombinator::union()` removed every constant of a scalar once any member answered isInteger()/isBoolean()/isFloat() with yes, so `T of int|5` became `T of int` (and likewise for `T of bool` and `T of float`). The member now also has to be a supertype of the whole scalar type. Ported to the native mirror. The prefixed differential test computed `$a - 3` over a native int and the PHP twin's constant 3 and only passed because the shortcut swallowed the twin's 3; the resulting mixed-family union is now tolerated there like the other prefix-boundary TypeErrors. Amp-Thread-ID: https://ampcode.com/threads/T-01a1819f-fbb7-7460-b1bd-aab545ccfc0a
Contributor
|
this PR seem to also fix phpstan/phpstan#6567 |
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.
Follow-up to #6863 (phpstan/phpstan#15591). That PR addressed the right root cause, but its implementation introduced false positives and a performance regression. This PR replaces it.
Root cause (unchanged)
TypeCombinator::union()puts every type withisConstantScalarValue()->yes()into per-class scalar buckets. Bucket members are compared only against the non-scalar union members, never against each other or against other buckets. That is correct for plain constants, because distinct values are disjoint. It is wrong for constants refined by another type:'Foo'(ConstantStringTypebucket) and'Foo'&hasMethod(m1)(IntersectionTypebucket) were never compared, so'Foo'|('Foo'&hasMethod(m1))never collapsed to'Foo'. Eachmethod_exists()check on a loop-invariant constant class-string doubled the union. 2.2.17 builds the same 2^N union; 2.3 only spends more per merge. The same happens withproperty_exists(),is_a(),is_subclass_of()andis_callable([$class, $m]).What was wrong with #6863
#6863 moved these intersections out of the buckets into the general path. There they hit the "generic scalar" shortcut (
$hasGenericScalarTypes). The shortcut treats any type whoseisInteger()/isBoolean()/isFloat()is yes as plainint/bool/floatand deletes every plain constant of that kind. It does the same for strings when the refinement isn't an accessory type. The results are unsound unions and false positives that were not reported before #6863:T of string:$s === 'a' ? $s : 'b'loses'b', somatchreports Match arm comparison between *NEVER* and 'b' is always false.T of bool: === between T of bool&true and false will always evaluate to false.This matters for future nominal types too.
5&user-id | 7would lose the7.Refined constants also went through the O(n²) pairwise loop. A union of 1000
'aN'&T(a literal list narrowed byin_array()on a template string) went from 2.0 s to 43.7 s.This change
The intersections stay in their bucket. Before the bucket is compared against the other members,
reduceRefinedConstantScalars()drops each one when the plain constant of the same value is present, or when a less refined intersection of the same value contains it. Different values can never overlap, so only same-value refinements are compared, the cost stays linear, and nothing reaches the generic-scalar shortcut. The same change is in the native mirrorturbo-ext/src/TypeCombinator.cpp. Theinstanceof IntersectionTypebaseline count goes back to 7.'Foo''Foo'(Turbo 1.0 s)1&T | 2, string and bool variantsTests
nsrt/bug-15591.phpnow also asserts that2|(1&T),'b'|('a'&TString)and(TBool&true)|falseare kept. Compare constant scalars refined by accessory types against other union members inTypeCombinator::union()#6863's code fails these assertions.'Foo'&hasMethod(m1)in a 12-element loop, with no plain constant present. It hangs if the same-value comparison is removed.ALL OK.make csand self-analysis are clean.I also probed 23 loop-invariant narrowing variants at 12–16 elements: union class-strings,
class-string<Foo>, objects,$this,static::class,isset()/array_key_exists()on general and shaped arrays,instanceofloops, templates, collecting into arrays, concatenation, and nesting. None is still exponential.Second commit: the older
T of int | 5bugThe same shortcut already made
T of int | 5collapse toT of int(likewiseT of bool | trueandT of float | 1.5). 2.2.17 has this too. Any member whoseisInteger()/isBoolean()/isFloat()was yes was treated as the whole scalar. Now the member must also be a supertype of the whole scalar ($type->isSuperTypeOf(new IntegerType())->yes(), likewise for bool and float).T of intand1&Tno longer qualify, whileint,boolandfloatstill take the fast path. This is covered bynsrt/union-template-scalar-constant.php, which fails without the fix. The same change is in the native mirror.The prefixed differential test in
turbo-ext/tests/initializer-escape.phphad been passing only because of this bug. It computes$a - 3from a nativePHPStanTurbo\IntegerTypeand the PHP twin's constant3. The shortcut used to swallow the3. Now the union correctly keeps it, because a native int is not a supertype of the twin's3. That union mixes both class families, and the prefix doesn't allow that, so the result is aTypeError. The test now accepts that error forBinaryOpHandler, the same way it already accepts theClassConstFetchHandlerone, and expects 15 kept callbacks instead of 16. In real activation there is only one class family, so this can't happen there.This second commit also fixes phpstan/phpstan#6567.
$p + 1000with@template T of intwas typedT of int, because the shortcut swallowed the constant part of the result, so returning it asTwasn't reported.ReturnTypeRuleTest::testBug6567now expects Function add() should return T of int but returns int. It fails without the fix, and it also pins theT of boolandT of floatcases from the issue comments.Closes phpstan/phpstan#6567
/cc @staabm