🐶

PHPStanにおけるGenericsとLateResolvableType

2023/10/22に公開

まずは結果から。

<?php
/**
 * @template T of string|int
 * @param T $t
 * @return (T is string ? string : int)
 */
function f($t) {
    return $t;
}
\PHPStan\dumpType(f(13));            // Dumped type: int
\PHPStan\dumpType(f('BABY STAND'));  // Dumped type: string

f関数の戻り値の型を指定しておらず、PHPDocでGenericsの宣言をしている。関数の宣言において型が決まっていないが、コールする際の引数の型に応じて戻り値の型が正しく推論されていることがわかる。

ところでPHPStan Playground上でdumpType()を使って、型がどのように評価されるのかを手軽に確認できる。これ便利。

以下はPHPStanのバージョン1.11.x-dev@d02cc99で観察した結果。

ConditionalType

PHPDocで宣言されたf関数の戻り値は上記の例の場合ConditionalTypeと評価される。プロパティのシグネチャは以下の通り。@return (T is string ? string : int)のどの部分に対応するかをコメントで示す。

public function __construct(
    private Type $subject,  // T
    private Type $target,   // string(is stringのstring)
    private Type $if,       // string(? stringのstring)
    private Type $else,     // int
    private bool $negated,  // is(isの場合false、is notの場合true)
) {}

f(13)の実行結果の型(戻り値の型)を評価する際にTがint型の一種に置き換わり、f('BABY STAND')を評価する際にはTがstring型の一種に置き換わる。

LateResolvableType

ConditionalTypeはLateResolvableTypeを継承している。

interface LateResolvableType
{
    public function resolve(): Type;
    public function isResolvable(): bool;
}

isResolvable()がtrue等の条件を満たす時resolve()を実行するという使い方がなされる。

trait LateResolvableTypeTrait
{
    private ?Type $result = null;
    // 略
    public function resolve(): Type
    {
        if ($this->result === null) {
            return $this->result = $this->getResult();
        }
        return $this->result;
    }

resolve()はLateResolvableTypeTraitを使用するクラスー例えばConditionalTypeーのgetResult()。

final class ConditionalType implements CompoundType, LateResolvableType
{
    use LateResolvableTypeTrait;
    // 略
    public function isResolvable(): bool
    {
        return !TypeUtils::containsTemplateType($this->subject) && !TypeUtils::containsTemplateType($this->target);
    }

subjectやtargetがテンプレート(@template T等)を含まない時trueとなる。例えば、f(13)の実行結果の型を評価する際にTがint型の一種に置き換わっているような時にtrue。

    protected function getResult(): Type
    {
        $isSuperType = $this->target->isSuperTypeOf($this->subject);
        $yesType = fn () => TypeTraverser::map(!$this->negated ? $this->if : $this->else, function (Type $type, callable $traverse) {
            if ($type->equals($this->subject)) {
                return !$this->negated ? TypeCombinator::intersect($type, $this->target) : TypeCombinator::remove($type, $this->target);
            }
            return $traverse($type);
        });

        $noType = fn () => TypeTraverser::map(!$this->negated ? $this->else : $this->if, function (Type $type, callable $traverse) {
            if ($type->equals($this->subject)) {
                return !$this->negated ? TypeCombinator::remove($type, $this->target) : TypeCombinator::intersect($type, $this->target);
            }
            return $traverse($type);
        });

        if ($isSuperType->yes()) {
            return $yesType();
        }

        if ($isSuperType->no()) {
            return $noType();
        }
        return TypeCombinator::union($yesType(), $noType());
    }

上述した通りsubjectはTであり文脈に応じて具体的な型になる。それを踏まえて戻り値の型が決定される。

yes、no以外は何かというとmaybe。処理時点でyesともnoとも断定できない場合に、どちらにもなりうるということで両者を含むUnion型を返す。ところでTrinary Logicおもしろい。

Many methods in PHPStan do not return a two-state boolean, but a three-state PHPStan\TrinaryLogic object.

https://phpstan.org/developing-extensions/trinary-logic

以上

Discussion