Skip to content

Commit ada0ab5

Browse files
SanderMullerclaude
andcommitted
Extend the finite-union fast path to enum-case unions
Per review feedback: the same intersect() distribution blowup happens for unions of enum cases (a variable assigned enum cases across many === branches over a large enum), about 30s at N=400 before. Key enum-case members by class + case name, the identity EnumCaseObjectType::equals() uses (describe() would also fold in a subtracted type, which equals() ignores, so a narrowed case and a bare one would get different keys). The enum check runs before the constant scalar branch so a backed enum case is keyed as a case, not by its backing scalar. The fast path now also covers mixed constant-scalar + enum-case unions. Same reproducer: ~30s -> 3.8s at N=400. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ee96b50 commit ada0ab5

2 files changed

Lines changed: 115 additions & 37 deletions

File tree

src/Type/TypeCombinator.php

Lines changed: 44 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1502,14 +1502,15 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged
15021502

15031503
/**
15041504
* Fast path for intersect(): the intersection of two unions whose members are all
1505-
* disjoint constant scalars is their value-keyed set intersection. Returns null when
1506-
* either union has a member that is not safe to compare by value, in which case the
1507-
* caller falls back to the general A & (B|C) distribution.
1505+
* finite, mutually-disjoint values (constant scalars and/or enum cases) is their
1506+
* identity-keyed set intersection. Returns null when either union has a member that is
1507+
* not such a value, in which case the caller falls back to the general A & (B|C)
1508+
* distribution.
15081509
*/
1509-
private static function intersectConstScalarUnions(UnionType $a, UnionType $b): ?Type
1510+
private static function intersectFiniteUnions(UnionType $a, UnionType $b): ?Type
15101511
{
1511-
$membersA = self::constScalarUnionMembers($a);
1512-
$membersB = self::constScalarUnionMembers($b);
1512+
$membersA = self::finiteUnionMembers($a);
1513+
$membersB = self::finiteUnionMembers($b);
15131514
if ($membersA === null || $membersB === null) {
15141515
return null;
15151516
}
@@ -1531,38 +1532,44 @@ private static function intersectConstScalarUnions(UnionType $a, UnionType $b):
15311532
}
15321533

15331534
/**
1534-
* Keys a union's members by value for the constant-scalar fast path in intersect().
1535+
* Keys a union's members by identity for the finite-union fast path in intersect().
15351536
*
1536-
* Returns a value-key => member map, or null if any member is not a constant scalar
1537-
* that is safe to compare by value alone. Class-string constant strings are excluded
1538-
* (the class-string flag is not captured by the value), and floats are excluded
1539-
* (-0.0 / NAN comparison quirks). Two members are interchangeable iff they share a key.
1537+
* Handles constant scalars and enum cases: each stands for one concrete value, so two
1538+
* members are interchangeable iff they share a key and are otherwise disjoint. Returns
1539+
* null if any member is not such a value. Class-string constant strings are excluded
1540+
* (the class-string flag is not captured by the value) and floats are excluded (-0.0 /
1541+
* NAN comparison quirks). Enum cases are keyed by class + case name, the identity
1542+
* EnumCaseObjectType::equals() compares.
15401543
*
15411544
* @return array<string, Type>|null
15421545
*/
1543-
private static function constScalarUnionMembers(UnionType $union): ?array
1546+
private static function finiteUnionMembers(UnionType $union): ?array
15441547
{
15451548
$members = [];
15461549
foreach ($union->getTypes() as $member) {
1550+
$enumCase = $member->getEnumCaseObject();
15471551
if ($member->isNull()->yes()) {
1548-
$members['null'] = $member;
1549-
continue;
1550-
}
1551-
1552-
$values = $member->getConstantScalarValues();
1553-
if (count($values) !== 1) {
1554-
return null;
1555-
}
1556-
1557-
$value = $values[0];
1558-
if (is_int($value)) {
1559-
$key = 'i:' . $value;
1560-
} elseif (is_bool($value)) {
1561-
$key = $value ? 'b:1' : 'b:0';
1562-
} elseif (is_string($value) && $member->isClassString()->no()) {
1563-
$key = 's:' . $value;
1552+
$key = 'null';
1553+
} elseif ($enumCase !== null) {
1554+
// Key by class + case name, the identity EnumCaseObjectType::equals() uses.
1555+
// describe() would also fold in a subtracted type, which equals() ignores.
1556+
$key = 'enum:' . $enumCase->getClassName() . '::' . $enumCase->getEnumCaseName();
15641557
} else {
1565-
return null;
1558+
$values = $member->getConstantScalarValues();
1559+
if (count($values) !== 1) {
1560+
return null;
1561+
}
1562+
1563+
$value = $values[0];
1564+
if (is_int($value)) {
1565+
$key = 'i:' . $value;
1566+
} elseif (is_bool($value)) {
1567+
$key = $value ? 'b:1' : 'b:0';
1568+
} elseif (is_string($value) && $member->isClassString()->no()) {
1569+
$key = 's:' . $value;
1570+
} else {
1571+
return null;
1572+
}
15661573
}
15671574

15681575
$members[$key] = $member;
@@ -1589,19 +1596,19 @@ public static function intersect(Type ...$types): Type
15891596
}
15901597
}
15911598

1592-
// Fast path: the intersection of two plain unions whose members are all
1593-
// disjoint constant scalars is their value-keyed set intersection (O(n)),
1594-
// avoiding the O(n*m) `A & (B|C)` distribution + union rebuild below.
1595-
// Restricted to the exact UnionType class so BenevolentUnionType and the
1596-
// template union types keep their dedicated handling.
1599+
// Fast path: the intersection of two plain unions whose members are all finite,
1600+
// mutually-disjoint values (constant scalars and/or enum cases) is their
1601+
// identity-keyed set intersection (O(n)), avoiding the O(n*m) `A & (B|C)`
1602+
// distribution + union rebuild below. Restricted to the exact UnionType class so
1603+
// BenevolentUnionType and the template union types keep their dedicated handling.
15971604
if (
15981605
$typesCount === 2
15991606
&& get_class($types[0]) === UnionType::class
16001607
&& get_class($types[1]) === UnionType::class
16011608
) {
1602-
$constScalarIntersection = self::intersectConstScalarUnions($types[0], $types[1]);
1603-
if ($constScalarIntersection !== null) {
1604-
return $constScalarIntersection;
1609+
$finiteIntersection = self::intersectFiniteUnions($types[0], $types[1]);
1610+
if ($finiteIntersection !== null) {
1611+
return $finiteIntersection;
16051612
}
16061613
}
16071614

tests/PHPStan/Type/TypeCombinatorTest.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5688,6 +5688,77 @@ public static function dataIntersect(): iterable
56885688
UnionType::class,
56895689
'1|2',
56905690
];
5691+
5692+
// finite-union fast path: enum-case unions
5693+
yield [
5694+
[
5695+
new UnionType([
5696+
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'),
5697+
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'),
5698+
new EnumCaseObjectType('PHPStan\Fixture\AnotherTestEnum', 'ONE'),
5699+
]),
5700+
new UnionType([
5701+
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'),
5702+
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'),
5703+
new EnumCaseObjectType('PHPStan\Fixture\AnotherTestEnum', 'TWO'),
5704+
]),
5705+
],
5706+
UnionType::class,
5707+
'PHPStan\Fixture\TestEnum::ONE|PHPStan\Fixture\TestEnum::TWO',
5708+
];
5709+
5710+
// finite-union fast path: mixed constant scalars + enum cases
5711+
yield [
5712+
[
5713+
new UnionType([
5714+
new ConstantIntegerType(0),
5715+
new ConstantIntegerType(1),
5716+
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'),
5717+
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'),
5718+
]),
5719+
new UnionType([
5720+
new ConstantIntegerType(1),
5721+
new ConstantIntegerType(2),
5722+
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'),
5723+
]),
5724+
],
5725+
UnionType::class,
5726+
'1|PHPStan\Fixture\TestEnum::ONE',
5727+
];
5728+
5729+
// a backed enum case (TestEnum::TWO = 2) must not be conflated with the integer 2
5730+
yield [
5731+
[
5732+
new UnionType([
5733+
new ConstantIntegerType(2),
5734+
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'),
5735+
]),
5736+
new UnionType([
5737+
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'),
5738+
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'),
5739+
]),
5740+
],
5741+
EnumCaseObjectType::class,
5742+
'PHPStan\Fixture\TestEnum::TWO',
5743+
];
5744+
5745+
// a constant string '0'/'1' must not be conflated with the integer 0/1
5746+
yield [
5747+
[
5748+
new UnionType([
5749+
new ConstantIntegerType(0),
5750+
new ConstantStringType('0'),
5751+
new ConstantIntegerType(1),
5752+
new ConstantStringType('1'),
5753+
]),
5754+
new UnionType([
5755+
new ConstantStringType('0'),
5756+
new ConstantIntegerType(1),
5757+
]),
5758+
],
5759+
UnionType::class,
5760+
"1|'0'",
5761+
];
56915762
}
56925763

56935764
/**

0 commit comments

Comments
 (0)