Skip to content
298 changes: 298 additions & 0 deletions src/Type/FiniteTypeSet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type;

use PHPStan\TrinaryLogic;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\Constant\ConstantFloatType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\Enum\EnumCaseObjectType;
use PHPStan\Type\Generic\TemplateType;
use function array_diff_key;
use function array_key_exists;
use function count;
use function get_class;
use function is_bool;
use function is_int;
use function is_string;

/**
* A union's members indexed by value identity.
*
* Membership among finite values - null, constant scalars other than floats, and enum
* cases - is exact-value equality: two of them are interchangeable iff they are equals(),
* and any two that are not equals() are disjoint. Comparing a value against a union is
* therefore a set lookup, which an identity-keyed map answers in O(1) instead of scanning
* every member. A trie would only pay off for prefix or pattern queries, and none of these
* comparisons are that. Comparing two such unions drops from O(n*m) member comparisons to
* O(n+m).
*
* Members that cannot be keyed this way are kept aside in $others, so a single object type
* next to fifty constant strings does not defeat the optimization - it only means callers
* still have to consult those few members the slow way.
*
* @see UnionType::getFiniteTypeSet()
* @internal
*/
final class FiniteTypeSet
Comment thread
staabm marked this conversation as resolved.
{

private const NULL_KEY = 'null';

private const INTEGER_KEY_PREFIX = 'i:';

private const BOOLEAN_KEY_PREFIX = 'b:';

private const STRING_KEY_PREFIX = 's:';

private const ENUM_CASE_KEY_PREFIX = 'enum:';

private ?bool $hasClassStringMember = null;

/**
* @param array<string, Type> $members
* @param array<string, Type> $membersByKind
* @param list<Type> $others
*/
private function __construct(private array $members, private array $membersByKind, private array $others)
{
}

/**
* Returns null when none of the types is a finite value - there is nothing to look up
* then, and the caller would only pay for building an empty map.
*
* Two types standing for the same value are not merged: the second one goes to $others
* so that the set never claims a union has fewer members than it does.
*
* @param list<Type> $types
*/
public static function create(array $types): ?self
{
$members = [];
$membersByKind = [];
$others = [];
foreach ($types as $type) {
$key = self::key($type);
if ($key === null || array_key_exists($key, $members)) {
$others[] = $type;
continue;
}

$members[$key] = $type;
$membersByKind[self::kind($type)] ??= $type;
}

if ($members === []) {
return null;
}

return new self($members, $membersByKind, $others);
}

/**
* Identity key of a single finite value: two types share a key iff they are equals(),
* and types with different keys are disjoint.
*
* Returns null for anything else. Floats are excluded because equals() does not agree
* with value identity for them (-0.0 === 0.0, NAN !== NAN). A type that merely contains
* a finite value - an intersection with an accessory type, a whole single-case enum, a
* conditional type resolving to a constant - is excluded by the equals() check: only a
* type that *is* the value can stand in for it. Template types are excluded outright,
* their comparison semantics are not value identity.
*/
public static function key(Type $type): ?string
{
// A union of finite values is made of these five classes, and every comparison against
// such a union derives at least one key - so the general path below, three virtual
// calls and an array allocation, is worth skipping for them. Matching the class
// exactly is what makes that safe: for these, getConstantScalarTypes() is [$this] and
// equals() is value identity, which is precisely what the general path checks.
// Subclasses - template constant types among them - do not match and fall through.
switch (get_class($type)) {
case ConstantStringType::class:
return self::STRING_KEY_PREFIX . $type->getValue();
case ConstantIntegerType::class:
return self::INTEGER_KEY_PREFIX . $type->getValue();
case ConstantBooleanType::class:
return self::BOOLEAN_KEY_PREFIX . ($type->getValue() ? '1' : '0');
case NullType::class:
return self::NULL_KEY;
case ConstantFloatType::class:
// Excluded on purpose, see above - said here too so that a union of constant
// floats pays one get_class() per member instead of walking the general path.
return null;
}

if ($type instanceof TemplateType) {
return null;
}

// Only a bare case is safe to key by class + case name: for anything else -
// $this & Enum::C, a whole single-case enum, an enum subtracted to one case -
// EnumCaseObjectType::equals() is false because it requires an EnumCaseObjectType,
// which makes instanceof exactly the question being asked here. Type::getEnumCases()
// would answer it too, but only by resolving a ClassReflection - and a key has to be
// derivable from the type alone, on every comparison, without reflection.
// Key by class + case name, the identity equals() compares (describe() would also
// fold in a subtracted type, which equals() ignores).
if ($type instanceof EnumCaseObjectType) { // @phpstan-ignore phpstanApi.instanceofType
return self::kind($type) . '::' . $type->getEnumCaseName();
}

if (!$type->isConstantScalarValue()->yes()) {
return null;
}

$scalarTypes = $type->getConstantScalarTypes();
if (count($scalarTypes) !== 1 || !$scalarTypes[0]->equals($type)) {
return null;
}

$value = $scalarTypes[0]->getValue();
if ($value === null) {
return self::NULL_KEY;
}
if (is_int($value)) {
return self::INTEGER_KEY_PREFIX . $value;
}
if (is_bool($value)) {
return self::BOOLEAN_KEY_PREFIX . ($value ? '1' : '0');
}
if (is_string($value)) {
return self::STRING_KEY_PREFIX . $value;
}

return null;
}

/**
* The kind of value a type stands for.
*
* Members of one kind answer accepts() identically for every value none of them holds,
* which is what lets one of them stand in for all its siblings there. Being of the same
* class is enough for that - accepts() on a constant scalar only asks whether the other
* type equals it - except for enum cases, where the enum is part of the answer.
*
* Only meaningful for a type that key() keys; anything else gets a kind of its own,
* which merely costs it a representative.
*/
private static function kind(Type $type): string
{
if ($type instanceof EnumCaseObjectType) { // @phpstan-ignore phpstanApi.instanceofType
return self::ENUM_CASE_KEY_PREFIX . $type->getClassName();
}

return get_class($type);
}

/**
* One member per kind other than $type's own, in the union's order.
*
* For a value the set does not hold, every member of $type's kind answers accepts() no,
* and the remaining members answer per kind - so or()-ing over these few is the same
* answer as or()-ing over all of them.
*
* @return list<Type>
*/
public function getRepresentativesOfOtherKinds(Type $type): array
{
$kind = self::kind($type);
$representatives = [];
foreach ($this->membersByKind as $memberKind => $member) {
if ($memberKind === $kind) {
continue;
}

$representatives[] = $member;
}

return $representatives;
}

public function has(string $key): bool
{
return array_key_exists($key, $this->members);
}

/** Whether every member of the union is keyed, so the map answers for the whole union. */
public function isComplete(): bool
{
return $this->others === [];
}

/**
* Members in the union's own order.
*
* @return array<string, Type>
*/
public function getMembers(): array
{
return $this->members;
}

/** @return list<Type> */
public function getOthers(): array
{
return $this->others;
}

/**
* Yes when every keyed member is also in $other, no when none of them is.
*
* Only keyed members are compared - call isComplete() first when the answer has to
* hold for the whole union.
*/
public function containedIn(self $other): TrinaryLogic
{
// One array_diff_key() rather than a lookup per member: the keys are what both sets
// are indexed by, so the whole comparison is a single C-level hash join. Asking for
// what is missing rather than for what is shared makes the yes answer the cheap one -
// it is the one that costs nothing to collect, and the one all three callers are
// after (isAcceptedBy() and equals() want nothing else).
$missing = count(array_diff_key($this->members, $other->members));

if ($missing === 0) {
return TrinaryLogic::createYes();
}

if ($missing === count($this->members)) {
return TrinaryLogic::createNo();
}

return TrinaryLogic::createMaybe();
}

/**
* Whether a constant string member might also be a class-string.
*
* The class-string flag is part of a constant string's representation but not of its
* value, so operations that pick a member to hand back - as opposed to merely comparing
* values - cannot treat two same-valued constant strings as interchangeable. Answering
* this costs a reflection lookup per string member, so it is computed on demand: only
* combining operations ask.
*/
public function hasClassStringMember(): bool
{
if ($this->hasClassStringMember !== null) {
return $this->hasClassStringMember;
}

$this->hasClassStringMember = false;
foreach ($this->members as $member) {
if (!$member->isString()->yes()) {
continue;
}
if ($member->isClassString()->no()) {
continue;
}

$this->hasClassStringMember = true;
break;
}

return $this->hasClassStringMember;
}

}
59 changes: 12 additions & 47 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,7 @@
use function get_class;
use function implode;
use function in_array;
use function is_bool;
use function is_int;
use function is_string;
use function sprintf;
use function usort;
use const PHP_INT_MAX;
Expand Down Expand Up @@ -1581,60 +1579,27 @@ private static function intersectFiniteUnions(UnionType $a, UnionType $b): ?Type
}

/**
* Keys a union's members by identity for the finite-union fast path in intersect().
* The union's members keyed by identity, or null when the fast path does not apply.
*
* Handles constant scalars and enum cases: each stands for one concrete value, so two
* members are interchangeable iff they share a key and are otherwise disjoint. Returns
* null if any member is not such a value. Class-string constant strings are excluded
* (the class-string flag is not captured by the value) and floats are excluded (-0.0 /
* NAN comparison quirks). Enum cases are keyed by class + case name, the identity
* EnumCaseObjectType::equals() compares.
* FiniteTypeSet does the keying and the union caches it; on top of that, intersect()
* hands one of the two members back instead of only comparing them, so a constant string
* that is also a class-string is not interchangeable with a same-valued one that is not -
* the class-string flag would be lost. Such unions go the slow way.
*
* @return array<string, Type>|null
*/
private static function finiteUnionMembers(UnionType $union): ?array
{
$members = [];
foreach ($union->getTypes() as $member) {
$enumCase = $member->getEnumCaseObject();
if ($member->isNull()->yes()) {
$key = 'null';
} elseif ($enumCase !== null) {
// getEnumCaseObject() also returns the case for a refined member - an
// intersection like $this & Enum::C, a whole single-case enum, or an enum
// subtracted to one case - none of which are a bare EnumCaseObjectType.
// Only a bare case is safe to key by class + case name; for the rest,
// EnumCaseObjectType::equals() is false (it requires an EnumCaseObjectType),
// so bail to the slow path rather than collapse the refinement.
if (!$enumCase->equals($member)) {
return null;
}

// Key by class + case name, the identity EnumCaseObjectType::equals() compares
// (describe() would also fold in a subtracted type, which equals() ignores).
$key = 'enum:' . $enumCase->getClassName() . '::' . $enumCase->getEnumCaseName();
} else {
$values = $member->getConstantScalarValues();
if (count($values) !== 1) {
return null;
}

$value = $values[0];
if (is_int($value)) {
$key = 'i:' . $value;
} elseif (is_bool($value)) {
$key = $value ? 'b:1' : 'b:0';
} elseif (is_string($value) && $member->isClassString()->no()) {
$key = 's:' . $value;
} else {
return null;
}
}
$finiteTypeSet = $union->getFiniteTypeSet();
if ($finiteTypeSet === null || !$finiteTypeSet->isComplete()) {
return null;
}

$members[$key] = $member;
if ($finiteTypeSet->hasClassStringMember()) {
return null;
}

return $members;
return $finiteTypeSet->getMembers();
}

public static function intersect(Type ...$types): Type
Expand Down
Loading
Loading