Skip to content

Commit 34175c4

Browse files
authored
Answer UnionType comparisons from an identity-keyed FiniteTypeSet instead of scanning every member (#6116)
1 parent 0af438b commit 34175c4

6 files changed

Lines changed: 1584 additions & 48 deletions

File tree

src/Type/FiniteTypeSet.php

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type;
4+
5+
use PHPStan\TrinaryLogic;
6+
use PHPStan\Type\Generic\TemplateType;
7+
use function array_diff_key;
8+
use function array_key_exists;
9+
use function count;
10+
use function get_class;
11+
use function is_bool;
12+
use function is_int;
13+
use function is_string;
14+
15+
/**
16+
* A union's members indexed by value identity.
17+
*
18+
* Membership among finite values - null, constant scalars other than floats, and enum
19+
* cases - is exact-value equality: two of them are interchangeable iff they are equals(),
20+
* and any two that are not equals() are disjoint. Comparing a value against a union is
21+
* therefore a set lookup, which an identity-keyed map answers in O(1) instead of scanning
22+
* every member. A trie would only pay off for prefix or pattern queries, and none of these
23+
* comparisons are that. Comparing two such unions drops from O(n*m) member comparisons to
24+
* O(n+m).
25+
*
26+
* Members that cannot be keyed this way are kept aside in $others, so a single object type
27+
* next to fifty constant strings does not defeat the optimization - it only means callers
28+
* still have to consult those few members the slow way.
29+
*
30+
* @see UnionType::getFiniteTypeSet()
31+
*/
32+
final class FiniteTypeSet
33+
{
34+
35+
private const NULL_KEY = 'null';
36+
37+
private const INTEGER_KEY_PREFIX = 'i:';
38+
39+
private const BOOLEAN_KEY_PREFIX = 'b:';
40+
41+
private const STRING_KEY_PREFIX = 's:';
42+
43+
private const ENUM_CASE_KEY_PREFIX = 'enum:';
44+
45+
private ?bool $hasClassStringMember = null;
46+
47+
/**
48+
* @param array<string, Type> $members
49+
* @param array<string, Type> $membersByKind
50+
* @param list<Type> $others
51+
*/
52+
private function __construct(private array $members, private array $membersByKind, private array $others)
53+
{
54+
}
55+
56+
/**
57+
* Returns null when none of the types is a finite value - there is nothing to look up
58+
* then, and the caller would only pay for building an empty map.
59+
*
60+
* Two types standing for the same value are not merged: the second one goes to $others
61+
* so that the set never claims a union has fewer members than it does. TypeCombinator
62+
* never builds such a union, but the UnionType constructor is @api and does not dedupe,
63+
* and a union holding one value twice is not the union holding it once - merging them
64+
* would let equals() call 'a'|'a' and 'a'|'b' the same type, and leave tryRemove() with
65+
* no member to build a union from.
66+
*
67+
* @param list<Type> $types
68+
*/
69+
public static function create(array $types): ?self
70+
{
71+
$members = [];
72+
$membersByKind = [];
73+
$others = [];
74+
foreach ($types as $type) {
75+
$key = self::key($type);
76+
if ($key === null || array_key_exists($key, $members)) {
77+
$others[] = $type;
78+
continue;
79+
}
80+
81+
$members[$key] = $type;
82+
$membersByKind[self::kind($type)] ??= $type;
83+
}
84+
85+
if ($members === []) {
86+
return null;
87+
}
88+
89+
return new self($members, $membersByKind, $others);
90+
}
91+
92+
/**
93+
* Identity key of a single finite value: two types share a key iff they are equals(),
94+
* and types with different keys are disjoint.
95+
*
96+
* Returns null for anything else. Floats are excluded because equals() does not agree
97+
* with value identity for them (-0.0 === 0.0, NAN !== NAN). A type that merely contains
98+
* a finite value - an intersection with an accessory type, a whole single-case enum, a
99+
* conditional type resolving to a constant - is excluded by the equals() check: only a
100+
* type that *is* the value can stand in for it. Template types are excluded outright,
101+
* their comparison semantics are not value identity.
102+
*/
103+
public static function key(Type $type): ?string
104+
{
105+
if ($type instanceof TemplateType) {
106+
return null;
107+
}
108+
109+
// Only a bare case is safe to key by class + case name: for anything else -
110+
// $this & Enum::C, a whole single-case enum, an enum subtracted to one case -
111+
// EnumCaseObjectType::equals() is false because it requires an EnumCaseObjectType,
112+
// which makes instanceof exactly the question being asked here. Type::getEnumCases()
113+
// would answer it too, but only by resolving a ClassReflection - and a key has to be
114+
// derivable from the type alone, on every comparison, without reflection.
115+
// Key by class + case name, the identity equals() compares (describe() would also
116+
// fold in a subtracted type, which equals() ignores).
117+
$enumCaseObject = $type->getEnumCaseObject();
118+
if ($enumCaseObject !== null && $enumCaseObject->equals($type)) {
119+
return self::ENUM_CASE_KEY_PREFIX . $enumCaseObject->getClassName() . '::' . $enumCaseObject->getEnumCaseName();
120+
}
121+
122+
$scalarTypes = $type->getConstantScalarTypes();
123+
if (count($scalarTypes) === 1 && $scalarTypes[0]->equals($type)) {
124+
$value = $scalarTypes[0]->getValue();
125+
if ($value === null) {
126+
return self::NULL_KEY;
127+
}
128+
if (is_int($value)) {
129+
return self::INTEGER_KEY_PREFIX . $value;
130+
}
131+
if (is_bool($value)) {
132+
return self::BOOLEAN_KEY_PREFIX . ($value ? '1' : '0');
133+
}
134+
if (is_string($value)) {
135+
return self::STRING_KEY_PREFIX . $value;
136+
}
137+
}
138+
139+
return null;
140+
}
141+
142+
/**
143+
* The kind of value a type stands for.
144+
*
145+
* Members of one kind answer accepts() identically for every value none of them holds,
146+
* which is what lets one of them stand in for all its siblings there. Being of the same
147+
* class is enough for that - accepts() on a constant scalar only asks whether the other
148+
* type equals it - except for enum cases, where the enum is part of the answer.
149+
*
150+
* Only meaningful for a type that key() keys; anything else gets a kind of its own,
151+
* which merely costs it a representative.
152+
*/
153+
private static function kind(Type $type): string
154+
{
155+
$enumCaseObject = $type->getEnumCaseObject();
156+
if ($enumCaseObject !== null && $enumCaseObject->equals($type)) {
157+
return self::ENUM_CASE_KEY_PREFIX . $enumCaseObject->getClassName();
158+
}
159+
160+
return get_class($type);
161+
}
162+
163+
/**
164+
* One member per kind other than $type's own, in the union's order.
165+
*
166+
* For a value the set does not hold, every member of $type's kind answers accepts() no,
167+
* and the remaining members answer per kind - so or()-ing over these few is the same
168+
* answer as or()-ing over all of them.
169+
*
170+
* @return list<Type>
171+
*/
172+
public function getRepresentativesOfOtherKinds(Type $type): array
173+
{
174+
$kind = self::kind($type);
175+
$representatives = [];
176+
foreach ($this->membersByKind as $memberKind => $member) {
177+
if ($memberKind === $kind) {
178+
continue;
179+
}
180+
181+
$representatives[] = $member;
182+
}
183+
184+
return $representatives;
185+
}
186+
187+
public function has(string $key): bool
188+
{
189+
return array_key_exists($key, $this->members);
190+
}
191+
192+
/** Whether every member of the union is keyed, so the map answers for the whole union. */
193+
public function isComplete(): bool
194+
{
195+
return $this->others === [];
196+
}
197+
198+
/**
199+
* Members in the union's own order.
200+
*
201+
* @return array<string, Type>
202+
*/
203+
public function getMembers(): array
204+
{
205+
return $this->members;
206+
}
207+
208+
/** @return list<Type> */
209+
public function getOthers(): array
210+
{
211+
return $this->others;
212+
}
213+
214+
/**
215+
* Yes when every keyed member is also in $other, no when none of them is.
216+
*
217+
* Only keyed members are compared - call isComplete() first when the answer has to
218+
* hold for the whole union.
219+
*/
220+
public function containedIn(self $other): TrinaryLogic
221+
{
222+
// One array_diff_key() rather than a lookup per member: the keys are what both sets
223+
// are indexed by, so the whole comparison is a single C-level hash join. Asking for
224+
// what is missing rather than for what is shared makes the yes answer the cheap one -
225+
// it is the one that costs nothing to collect, and the one all three callers are
226+
// after (isAcceptedBy() and equals() want nothing else).
227+
$missing = count(array_diff_key($this->members, $other->members));
228+
229+
if ($missing === 0) {
230+
return TrinaryLogic::createYes();
231+
}
232+
233+
if ($missing === count($this->members)) {
234+
return TrinaryLogic::createNo();
235+
}
236+
237+
return TrinaryLogic::createMaybe();
238+
}
239+
240+
/**
241+
* containedIn() against the one-member set holding just $key.
242+
*
243+
* Yes only when this set holds nothing besides that value, no when it does not hold it
244+
* at all, maybe in between - the same three answers as containedIn(), which is what a
245+
* single value is being compared as here.
246+
*
247+
* Only keyed members are compared - call isComplete() first when the answer has to
248+
* hold for the whole union.
249+
*/
250+
public function containedInKey(string $key): TrinaryLogic
251+
{
252+
if (!$this->has($key)) {
253+
return TrinaryLogic::createNo();
254+
}
255+
256+
if (count($this->members) === 1) {
257+
return TrinaryLogic::createYes();
258+
}
259+
260+
return TrinaryLogic::createMaybe();
261+
}
262+
263+
/**
264+
* Whether a constant string member might also be a class-string.
265+
*
266+
* The class-string flag is part of a constant string's representation but not of its
267+
* value, so operations that pick a member to hand back - as opposed to merely comparing
268+
* values - cannot treat two same-valued constant strings as interchangeable. Answering
269+
* this costs a reflection lookup per string member, so it is computed on demand: only
270+
* combining operations ask.
271+
*
272+
* Every member is asked, no matter its kind: a keyed member is an instance of one of the
273+
* five classes key() accepts, and every one of them but ConstantStringType answers
274+
* isClassString() no outright - which is also the only one whose answer costs anything.
275+
*/
276+
public function hasClassStringMember(): bool
277+
{
278+
if ($this->hasClassStringMember !== null) {
279+
return $this->hasClassStringMember;
280+
}
281+
282+
$this->hasClassStringMember = false;
283+
foreach ($this->members as $member) {
284+
if ($member->isClassString()->no()) {
285+
continue;
286+
}
287+
288+
$this->hasClassStringMember = true;
289+
break;
290+
}
291+
292+
return $this->hasClassStringMember;
293+
}
294+
295+
}

src/Type/TypeCombinator.php

Lines changed: 12 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,7 @@
4343
use function get_class;
4444
use function implode;
4545
use function in_array;
46-
use function is_bool;
4746
use function is_int;
48-
use function is_string;
4947
use function sprintf;
5048
use function usort;
5149
use const PHP_INT_MAX;
@@ -1581,60 +1579,27 @@ private static function intersectFiniteUnions(UnionType $a, UnionType $b): ?Type
15811579
}
15821580

15831581
/**
1584-
* Keys a union's members by identity for the finite-union fast path in intersect().
1582+
* The union's members keyed by identity, or null when the fast path does not apply.
15851583
*
1586-
* Handles constant scalars and enum cases: each stands for one concrete value, so two
1587-
* members are interchangeable iff they share a key and are otherwise disjoint. Returns
1588-
* null if any member is not such a value. Class-string constant strings are excluded
1589-
* (the class-string flag is not captured by the value) and floats are excluded (-0.0 /
1590-
* NAN comparison quirks). Enum cases are keyed by class + case name, the identity
1591-
* EnumCaseObjectType::equals() compares.
1584+
* FiniteTypeSet does the keying and the union caches it; on top of that, intersect()
1585+
* hands one of the two members back instead of only comparing them, so a constant string
1586+
* that is also a class-string is not interchangeable with a same-valued one that is not -
1587+
* the class-string flag would be lost. Such unions go the slow way.
15921588
*
15931589
* @return array<string, Type>|null
15941590
*/
15951591
private static function finiteUnionMembers(UnionType $union): ?array
15961592
{
1597-
$members = [];
1598-
foreach ($union->getTypes() as $member) {
1599-
$enumCase = $member->getEnumCaseObject();
1600-
if ($member->isNull()->yes()) {
1601-
$key = 'null';
1602-
} elseif ($enumCase !== null) {
1603-
// getEnumCaseObject() also returns the case for a refined member - an
1604-
// intersection like $this & Enum::C, a whole single-case enum, or an enum
1605-
// subtracted to one case - none of which are a bare EnumCaseObjectType.
1606-
// Only a bare case is safe to key by class + case name; for the rest,
1607-
// EnumCaseObjectType::equals() is false (it requires an EnumCaseObjectType),
1608-
// so bail to the slow path rather than collapse the refinement.
1609-
if (!$enumCase->equals($member)) {
1610-
return null;
1611-
}
1612-
1613-
// Key by class + case name, the identity EnumCaseObjectType::equals() compares
1614-
// (describe() would also fold in a subtracted type, which equals() ignores).
1615-
$key = 'enum:' . $enumCase->getClassName() . '::' . $enumCase->getEnumCaseName();
1616-
} else {
1617-
$values = $member->getConstantScalarValues();
1618-
if (count($values) !== 1) {
1619-
return null;
1620-
}
1621-
1622-
$value = $values[0];
1623-
if (is_int($value)) {
1624-
$key = 'i:' . $value;
1625-
} elseif (is_bool($value)) {
1626-
$key = $value ? 'b:1' : 'b:0';
1627-
} elseif (is_string($value) && $member->isClassString()->no()) {
1628-
$key = 's:' . $value;
1629-
} else {
1630-
return null;
1631-
}
1632-
}
1593+
$finiteTypeSet = $union->getFiniteTypeSet();
1594+
if ($finiteTypeSet === null || !$finiteTypeSet->isComplete()) {
1595+
return null;
1596+
}
16331597

1634-
$members[$key] = $member;
1598+
if ($finiteTypeSet->hasClassStringMember()) {
1599+
return null;
16351600
}
16361601

1637-
return $members;
1602+
return $finiteTypeSet->getMembers();
16381603
}
16391604

16401605
public static function intersect(Type ...$types): Type

0 commit comments

Comments
 (0)