Skip to content

Commit 041824d

Browse files
authored
Constant-scalar and enum-case union fast path in TypeCombinator::intersect (#5935)
1 parent a9a9630 commit 041824d

4 files changed

Lines changed: 769 additions & 0 deletions

File tree

src/Type/TypeCombinator.php

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@
4141
use function get_class;
4242
use function implode;
4343
use function in_array;
44+
use function is_bool;
4445
use function is_int;
46+
use function is_string;
4547
use function sprintf;
4648
use function usort;
4749
use const PHP_INT_MAX;
@@ -1498,6 +1500,98 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged
14981500
return array_merge($newArrays, $arraysToProcess);
14991501
}
15001502

1503+
/**
1504+
* Fast path for intersect(): the intersection of two unions whose members are all
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.
1509+
*/
1510+
private static function intersectFiniteUnions(UnionType $a, UnionType $b): ?Type
1511+
{
1512+
$membersA = self::finiteUnionMembers($a);
1513+
if ($membersA === null) {
1514+
return null;
1515+
}
1516+
1517+
$membersB = self::finiteUnionMembers($b);
1518+
if ($membersB === null) {
1519+
return null;
1520+
}
1521+
1522+
$common = [];
1523+
foreach ($membersA as $key => $member) {
1524+
if (!array_key_exists($key, $membersB)) {
1525+
continue;
1526+
}
1527+
1528+
$common[] = $member;
1529+
}
1530+
1531+
if ($common === []) {
1532+
return new NeverType();
1533+
}
1534+
1535+
return self::union(...$common);
1536+
}
1537+
1538+
/**
1539+
* Keys a union's members by identity for the finite-union fast path in intersect().
1540+
*
1541+
* Handles constant scalars and enum cases: each stands for one concrete value, so two
1542+
* members are interchangeable iff they share a key and are otherwise disjoint. Returns
1543+
* null if any member is not such a value. Class-string constant strings are excluded
1544+
* (the class-string flag is not captured by the value) and floats are excluded (-0.0 /
1545+
* NAN comparison quirks). Enum cases are keyed by class + case name, the identity
1546+
* EnumCaseObjectType::equals() compares.
1547+
*
1548+
* @return array<string, Type>|null
1549+
*/
1550+
private static function finiteUnionMembers(UnionType $union): ?array
1551+
{
1552+
$members = [];
1553+
foreach ($union->getTypes() as $member) {
1554+
$enumCase = $member->getEnumCaseObject();
1555+
if ($member->isNull()->yes()) {
1556+
$key = 'null';
1557+
} elseif ($enumCase !== null) {
1558+
// getEnumCaseObject() also returns the case for a refined member - an
1559+
// intersection like $this & Enum::C, a whole single-case enum, or an enum
1560+
// subtracted to one case - none of which are a bare EnumCaseObjectType.
1561+
// Only a bare case is safe to key by class + case name; for the rest,
1562+
// EnumCaseObjectType::equals() is false (it requires an EnumCaseObjectType),
1563+
// so bail to the slow path rather than collapse the refinement.
1564+
if (!$enumCase->equals($member)) {
1565+
return null;
1566+
}
1567+
1568+
// Key by class + case name, the identity EnumCaseObjectType::equals() compares
1569+
// (describe() would also fold in a subtracted type, which equals() ignores).
1570+
$key = 'enum:' . $enumCase->getClassName() . '::' . $enumCase->getEnumCaseName();
1571+
} else {
1572+
$values = $member->getConstantScalarValues();
1573+
if (count($values) !== 1) {
1574+
return null;
1575+
}
1576+
1577+
$value = $values[0];
1578+
if (is_int($value)) {
1579+
$key = 'i:' . $value;
1580+
} elseif (is_bool($value)) {
1581+
$key = $value ? 'b:1' : 'b:0';
1582+
} elseif (is_string($value) && $member->isClassString()->no()) {
1583+
$key = 's:' . $value;
1584+
} else {
1585+
return null;
1586+
}
1587+
}
1588+
1589+
$members[$key] = $member;
1590+
}
1591+
1592+
return $members;
1593+
}
1594+
15011595
public static function intersect(Type ...$types): Type
15021596
{
15031597
$typesCount = count($types);
@@ -1516,6 +1610,22 @@ public static function intersect(Type ...$types): Type
15161610
}
15171611
}
15181612

1613+
// Fast path: the intersection of two plain unions whose members are all finite,
1614+
// mutually-disjoint values (constant scalars and/or enum cases) is their
1615+
// identity-keyed set intersection (O(n)), avoiding the O(n*m) `A & (B|C)`
1616+
// distribution + union rebuild below. Restricted to the exact UnionType class so
1617+
// BenevolentUnionType and the template union types keep their dedicated handling.
1618+
if (
1619+
$typesCount === 2
1620+
&& get_class($types[0]) === UnionType::class
1621+
&& get_class($types[1]) === UnionType::class
1622+
) {
1623+
$finiteIntersection = self::intersectFiniteUnions($types[0], $types[1]);
1624+
if ($finiteIntersection !== null) {
1625+
return $finiteIntersection;
1626+
}
1627+
}
1628+
15191629
$sortTypes = static function (Type $a, Type $b): int {
15201630
if (!$a instanceof UnionType || !$b instanceof UnionType) {
15211631
return 0;

tests/PHPStan/Type/TypeCombinatorTest.php

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5632,6 +5632,133 @@ public static function dataIntersect(): iterable
56325632
ConstantArrayType::class,
56335633
'array{a: int}',
56345634
];
5635+
5636+
// intersection of two constant-scalar unions (constant-union fast path)
5637+
yield [
5638+
[
5639+
'0|1|2|3',
5640+
'2|3|4|5',
5641+
],
5642+
UnionType::class,
5643+
'2|3',
5644+
];
5645+
5646+
yield [
5647+
[
5648+
"'a'|'b'|'c'",
5649+
"'b'|'c'|'d'",
5650+
],
5651+
UnionType::class,
5652+
"'b'|'c'",
5653+
];
5654+
5655+
yield [
5656+
[
5657+
'1|2',
5658+
'3|4',
5659+
],
5660+
NeverType::class,
5661+
'*NEVER*=implicit',
5662+
];
5663+
5664+
yield [
5665+
[
5666+
'0|1',
5667+
'1|2',
5668+
],
5669+
ConstantIntegerType::class,
5670+
'1',
5671+
];
5672+
5673+
yield [
5674+
[
5675+
"0|1|'a'|'b'|null",
5676+
"1|2|'a'|'c'|null",
5677+
],
5678+
UnionType::class,
5679+
"1|'a'|null",
5680+
];
5681+
5682+
// a non-constant member makes the fast path bail to the normal distribution
5683+
yield [
5684+
[
5685+
'0|1|2|non-empty-string',
5686+
'1|2',
5687+
],
5688+
UnionType::class,
5689+
'1|2',
5690+
];
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+
];
56355762
}
56365763

56375764
/**

0 commit comments

Comments
 (0)