PHPDocのジェネリクスで静的解析にコレクションの中身を教える

PHPDocのジェネリクスとは、@templatearray<int, User> といった注釈で「入れ物の中身の型」を静的解析ツールに伝える書き方です。PHP本体は無視しますが、PHPStanやPsalmはこれを型として読みます。

PHPには言語機能としてのジェネリクスがありません。なので array $users と書いた時点で、そこに何が入っているかは誰にも分からなくなります。ドキュメントコメントに書けば、少なくとも解析ツールと未来の自分には伝わる、というのが今回の話です。

なぜ array だけでは足りないのか

型宣言で array と書けるのは「配列であること」までで、要素の型はそこで消えます。IDEも解析ツールも、その先は推測するしかありません。中身をPHPDocで書き足すと、ループ変数の型まで追跡できるようになります。

<?php
/**
 * @param array<int, User> $users
 */
function activeNames(array $users): array
{
    $names = [];
    foreach ($users as $user) {
        // $user が User だと解析ツールが分かるので、
        // タイポしたメソッド名はここで検出される
        if ($user->isActive()) {
            $names[] = $user->getName();
        }
    }
    return $names;
}

これだけで、$user->getNmae() のような打ち間違いが実行前に落ちます。テストを書く前に潰せるバグが増えるのが、地味にいちばん効きます。

array と list はどう違う?

array<int, User> は「キーがint、値がUser」の配列です。一方 list<User> は、キーが0から連番で欠けがない配列を指します。array_filter を通した配列はキーが飛ぶので list ではなくなる、という区別ですね。

<?php
/** @return list<User> */
function loadUsers(): array
{
    return [new User('a'), new User('b')];
}

/** @return list<User> */
function onlyActive(): array
{
    // array_filter はキーを保持するので list ではなくなる。
    // array_values を通して連番に戻すのが定石
    return array_values(array_filter(loadUsers(), fn (User $u) => $u->isActive()));
}

嬉しいのは、JSONにしたときに配列になるかオブジェクトになるか、という定番の事故を型のレベルで予防できることです。list を守っている限り json_encode はJSON配列を返します。

@template で自作クラスをジェネリックにする

自作のコレクションクラスは、@template を付けると呼び出し側で中身の型が決まります。クラスに @template T を宣言し、使う側は @var Collection<User> のように書きます。

<?php
/**
 * @template T
 */
final class Collection
{
    /** @var list<T> */
    private array $items;

    /** @param list<T> $items */
    public function __construct(array $items)
    {
        $this->items = $items;
    }

    /** @return T|null */
    public function first(): mixed
    {
        return $this->items[0] ?? null;
    }
}

/** @var Collection<User> $c */
$c = new Collection([new User('a')]);
$u = $c->first(); // 解析上は User|null として扱われる

ランタイムの戻り値型は mixed のままですが、解析ツールから見た型は User|null になります。実行時の安全は変わらないのに、エディタの補完とエラー検出だけが賢くなる、という不思議な効き方をします。

class-string<T> がいちばん実務で効く

個人的にいちばん出番が多いのはこれです。クラス名の文字列を受け取ってインスタンスを返す、いわゆるファクトリやコンテナの get() ですね。素直に書くと戻り値は object にしかなりませんが、class-string<T> を使うと引数と戻り値が結び付きます。

<?php
final class Container
{
    /**
     * @template T of object
     * @param class-string<T> $className
     * @return T
     */
    public function get(string $className): object
    {
        return new $className();
    }
}

$container = new Container();
$repo = $container->get(UserRepository::class); // UserRepository と推論される

これを入れておくと、get() の戻り値に対して /** @var UserRepository $repo */ を毎回書く必要がなくなります。DIコンテナを自作している現場なら、まずここから足すのがおすすめです。

ハマりどころ

まず、PHPDocのジェネリクスは実行時に一切効きません。Collection<User> に別の型を突っ込んでも例外は飛ばず、静的解析を回さなければ何も起きない。ここを勘違いすると「書いたのに落ちない」と悩むことになります。

もうひとつ、既存のクラスを継承・実装するときは @extends@implements が要ります。たとえば IteratorAggregate を実装したなら、@implements IteratorAggregate<int, User> を付けないと、foreach の中で型が mixed に戻ってしまいます。

<?php
/**
 * @implements IteratorAggregate<int, User>
 */
final class UserList implements IteratorAggregate
{
    /** @param list<User> $users */
    public function __construct(private array $users) {}

    /** @return Traversable<int, User> */
    public function getIterator(): Traversable
    {
        return new ArrayIterator($this->users);
    }
}

最後に、注釈と実装がずれたまま放置されると、嘘のドキュメントが型として通用してしまいます。だからこそ、PHPDocのジェネリクスは静的解析をCIで回す前提とセットで導入したほうがいい、という気がしています。書くだけでは誰も守ってくれません。

まとめ

PHPにジェネリクスが来るのを待つより、いま使えるPHPDocで中身の型を書いてしまったほうが早いです。array<K, V>list<T> から始めて、自作コレクションに @template、コンテナに class-string<T>。この三段階で入れると、既存コードを壊さずに効果だけ増えていきます。実行時の保証にはならないので、解析ツールを回す運用まで含めて初めて意味を持つ、というところだけ忘れずに。

よくある質問

Q. PHPDocに書いた型は実行時にチェックされますか?
A. されません。PHPにとっては単なるコメントで、PHPStanやPsalmなどの静的解析ツールが読んで初めて効果が出ます。実行時の保証が必要なら、コンストラクタで自前に検証してください。

Q. array<int, User> と list<User> はどちらを使えばいいですか?
A. キーが0から連番であることを保証したいなら list<User> のほうが強い表明になります。array_filter などでキーが飛ぶ可能性がある場合は、array_values を通すか array<int, User> にしておくのが無難です。

Q. PHPStanとPsalmで書き方は同じですか?
A. @templateclass-string<T>@extends といった基本的な記法はどちらでも通ります。細かい独自タグは異なるので、ツール固有の指定が必要なときは @phpstan-@psalm- の接頭辞付きタグを使い分けます。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です