PHPDocのジェネリクスで静的解析にコレクションの中身を教える
PHPDocのジェネリクスとは、@template や array<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. @template、class-string<T>、@extends といった基本的な記法はどちらでも通ります。細かい独自タグは異なるので、ツール固有の指定が必要なときは @phpstan- や @psalm- の接頭辞付きタグを使い分けます。