この記事はPHPStan開発者のOndřej Mirtesによって2019年12月2日に書かれた記事を翻訳したものです。記事の末尾には訳者(@tadsan)の観点によるPhan, Psalm, PhpStormとの互換性についての情報も記述しています。
2年前、私(Ondřej Mirtes)はユニオン型と交差型についての衝撃的な記事を書きました。PHPコミュニティがこれらの概念に馴染むのを手助けし、PhpStormでの交差型サポートにつながりました。
ユニオン型と交差型の違いは開発者が認識すべき静的解析に役立つ重要な概念なので、私はその記事を書きました。今回は同様に、PHPStan 0.12で導入されたジェネリクスについて、それが何であるかを説明したいと思います。
無限のシグネチャ
関数宣言するとき、我々は関数に付属する単一のシグネチャを書いていました。ほかの選択肢はありません。関数が特定の型の引数を受け入れ、特定の型を返すことを宣言します。
<?php /** * @param string $param * @return string */ function foo($param) { ... }
これらの型は固定されています。もし関数呼び出しの引数に渡された型によって別の型の値を返すとき、ユニオン型(A|B
)またはobject
やmixedのような汎用的な型を書くしかありませんでした。
<?php function findEntity(string $className, int $id): ?object { // $className とプライマリキー $id をもとに エンティティを検索して返す }
これは静的解析にとって理想的ではありません。コードを型安全に保つためには不十分です。われわれは常に正確な型を知りたいのです。それこそがジェネリクスの目的です。それは開発者自身で定義できるルールを基に、関数およびメソッドに無限のシグネチャを生成できるのです。
型変数
これらのルールは型変数を使って定義します。ジェネリクスを持つほかの言語もこの用語を仕様します。PHPDocでは @template
タグを使って注釈を付けます。引数にとった値と同じ型の値を返す関数を考えてみましょう。
<?php /** * @template T * @param T $a * @return T */ function foo($a) { return $a; }
型変数の名前は既存のクラス名と重複しない限り自由です。 of
キーワードを用いて、型を制約することもできます。
<?php /** * @template T of \Exception * @param T $exception * @return T */ function foo($exception) { ... }
この関数は Exception
を継承したクラスのオブジェクトのみを引数に受け入れて、返します。
クラス名
型解決に型名を用いるときは class-string
という擬似型を使用できます。
/** * @template T * @param class-string<T> $className * @param int $id * @return T|null */ function findEntity(string $className, int $id) { // ... }
findEntity(Article::class, 1)
を呼び出すと、PHPStanはArticle
オブジェクトまたはnull
を返すことを認識してくれます。たとえば findAll()
のようなメソッドに戻り値の型 T[]
として書くと、Article
オブジェクトが含まれる配列として推論します。
クラスレベルのジェネリクス
ここまでは関数またはメソッドレベルのジェネリクスについて書いてきました。@template
タグはclass
およびinterface
の上にも記述できます。
<?php /** * @template T */ interface Collection { }
その型変数はメソッドのPHPDocから参照できます。
<?php /** * @template T */ interface Collection { /** * @param T $item */ public function add($item): void; /** * @return T */ public function get(int $index); }
コレクションの型は他の場所から書くこともできます。
<?php /** * @param Collection<Dog> $dogs */ function foo(Collection $dogs) { // Dog expected, Cat given $dogs->add(new Cat()); }
ジェネリックなinterfaceの実装およびジェネリックなクラスの継承をするには、二つの選択肢があります。
ジェネリック性を維持するには子クラスの上に同じ @template
を繰り返し、@extends
タグまたは@implements
タグに渡します。
<?php /** * @template T * @implements Collection<T> */ class PersistentCollection implements Collection { }
クラスをジェネリックにしたくない場合は @extends
または @implements
だけを書きます。
<?php /** * @implements Collection<Dog> */ class DogCollection implements Collection { }
共変性(covariance)と反変性(contravariance)
ジェネリクスが解決するユースケースがもうひとつありますが、その前にこれら二つの用語について説明する必要があります。
Covariance(共変性)とContravariance(反変性)は関連する型の間の関係を示します。
共変(covariant)である型を記述するとき、その型は親クラスまたはインターフェイスに対して具体的であることを意味します。
子クラスまたは実装に対してより一般的(generic/ジェネリック)な場合、その型は反変(contravariant)です。
言語が引数の型の制約および子クラスまたはインターフェイス実装において型の安全性を保証するために、これらの概念が重要です。
引数の型は反変(contravariant)でなくてはならない
たとえばDogFeeder
というインターフェイスがあるとして、DogFeeder
と型付けされたところにはどこでも任意のDog
オブジェクトをfeed
メソッドに渡すことができます。
<?php interface DogFeeder { function feed(Dog $dog); } function feedChihuahua(DogFeeder $feeder) { $feeder->feed(new Chihuahua()); // this code is OK }
型を狭めるBulldogFeeder
を実装したとき、問題が発生します。(それは反変ではなく共変になってしまいます!) BulldogFeeder
をfeedChihuahua()
関数に渡してしまったとき、BulldogFeeder::feed()
はChihuahua
を受け入れないため、コードはクラッシュします。
<?php class BulldogFeeder implements DogFeeder { function feed(Bulldog $dog) { ... } } feedChihuahua(new BulldogFeeder()); // 💥
幸運にも、PHPはこれを許可しません。しかし多くの型がPHPDocだけに書かれていることがあるために、静的解析ではこれらのエラーを捕捉する必要があります。
その一方で DogFeeder
を Dog
よりも一般的な型(Animal
など)で実装する場合は問題ありません。
<?php class AnimalFeeder implements DogFeeder { public function feed(Animal $animal) { ... } }
このクラスは全てのDog
を受け入れた上で、Animal
も受け入れます。Dog
とAnimal
は反変(contravariant)の関係にあります。
戻り値は共変(covariant)でなくてはならない
いままでは関数/メソッドの引数についての話でしたが、戻り値に関しては話は別です。戻り値は子クラスではより具体的になります。
今度はDogShelter
というインターフェイスがあるとしましょう。
<?php interface DogShelter { function get(): Dog; }
このインターフェイスを実装するとき、それが何を返すとしても、(Dog
クラスに実装された) bark()
メソッドが実行できることを確認しなければいけません。Animal
のようにDog
よりも具体的ではないものを返すのは間違っていますが、Chihuahua
クラスのオブジェクトを返すのは問題ありません。
これらのルールは便利だが、制約もある
禁止されていたとしても、たまに引数に共変の型を使用したくなることもあります。RabbitMQのメッセージを処理するConsumer
インターフェイスがあるとしましょう。
<?php interface Consumer { function consume(Message $message); }
特定の種類のメッセージを使用するようにインターフェイスを実装するとき、引数の型を指定したくなります。
<?php class SendMailMessageConsumer implements Consumer { function consume(SendMailMessage $message) { ... } }
型が反変ではないため、これは違法です。しかし実装されたインフラストラクチャのコードのお陰で、このSendMailMessageConsumer
がほかの種類のメッセージに対しては呼び出されないことを、私たちは知っています。
私たちはどうすれば良いのでしょうか。
ひとつの方法はインターフェイスのメソッドをコメントアウトしてしまい、未定義メソッドを呼び出している事実から目を逸らすことです。
<?php interface Consumer { // function consume(Message $message); }
もちろんそれは危険な方法です。
ジェネリクスのお陰で、完全に型安全な優れた方法があります。 Consumer
インターフェイスをジェネリックにし、引数の型を型変数によって指定することです。
<?php /** * @template T of Message */ interface Consumer { /** * @param T $message */ function consume(Message $message); }
Consumer
の実装は@implements
タグでメッセージの種類を指定します。
<?php /** * @implements Consumer<SendMailMessage> */ class SendMailMessageConsumer implements Consumer { function consume(Message $message) { ... } }
メソッドのPHPDocを省略したとしても$message
はSendMailMessage
であることをPHPStanは認識できています。また、SendMailMessageConsumer::consume()
の呼び出しすべてを調べてSendMailMessage
が渡されているかどうかを報告します。
IDEを使用していて自動補完を利用したい場合は、メソッドのPHPDocに@param SendMailMessage $message
を追加できます。
この方法は完全に型安全です。PHPStanは型システムに矛盾のある型を報告します。これでBarbara Liskovも満足です。(リスコフの置換原則)
IDEとの互換性
残念なことに現行のIDEは@template
などのタグを解釈しません。型変数は@phpstan-
プレフィクスを付けたタグでだけ使用することにして、プレフィクスのないタグは旧来のIDEや他のツールが理解できる型のままにします。
<?php /** * @phpstan-template T of \Exception * * @param \Exception $param * @return \Exception * * @phpstan-param T $param * @phpstan-return T */ function foo($param) { ... }
型安全なイテレータとジェネレータ
一部のPHP組み込みクラスは本質的にジェネリックです。型安全にイテレータを使用するには、含まれるキーと値の型を指定する必要があります。これらの例は全てPHPDocに型として記述可能です。
iterable<Value> iterable<Key, Value> Traversable<Value> Traversable<Key, Value> Iterator<Value> Iterator<Key, Value> IteratorAggregate<Value> IteratorAggregate<Key, Value>
ジェネレータ(Generator
)はPHPの複雑な言語機能です。ジェネレータを繰り返し処理してキーと値を取得するだけでなく、値をジェネレータに戻したり、yield
だけでなくreturn
キーワードで値を戻すこともできます。そのため、さらに複雑なジェネリクスのシグネチャが必要です。
Generator<TKey, TValue, TSend, TReturn>
PHPStanはすべてを型検査できます。オンラインのPHPStan playgroundで実際に試してみてください:
次はあなたの番
ジェネリクスの目的を理解したので、実際のコードベースでの用途を考えるのはあなた次第です。関数やメソッドに出入りする型を具体的に記述できるようになります。現在object
またはmixed
を使用しているが、より正確な型を付けられる箇所にはどこでもジェネリクスが役立ちます。いままでobject
またはmixed
と書くしかなった場所を型安全にできます。
PHPStan 0.12にはジェネリックを含む多くの改善が含まれています。
PHPStanが好きで毎日使っていますか?GitHub SponsorsでPHPStanの開発をサポートしてください。本当に感謝しています!
(翻訳ここまで)
訳者による補足情報
@template
によるジェネリクスはPhanおよびPsalmでも実現されています。
また、Phanのリポジトリにはジェネリクスを利用したOption
(Some
/None
)の実装があるので参考になるでしょう。PHPStanとPsalmでは@extends
が、Phanのドキュメントでは@inherits
が採用されていますが、それぞれの最新版では同一視される実装が入っていますので、どちらを使っても構いません。ただし、現状のPhan(2.5.0)ではof
キーワードを解釈せず、実験的な実装というステータスなので注意してください。
……つまり、最近arrayの型と向き合う #phpstudyにも書いたばかりなのですが、PhpStormが足を引っ張ってる状況です。
ということで、PhpStormにジェネリクスを使いたい各位はJetBrainsアカウントでYouTrackに投票しましょう。
@template
タグ自体は2016年にPHPカンファレンスで紹介したとき既にPhanに実装されていましたが、その後Psalmが登場し、PHPStanにも@template
が実装されて、晴れて実用できる体制が整ったのが2019年末というタイミングでした。PhpStormさえ無視すれば十分に実用できる状況です。 (これまでPhpStormでは.phpstorm.meta.php
というメタファイルを記述すれば@template T @param class-string<T> @return T
に相当する型は書けましたが、それ以外の静的解析ツールがこの部分については追い付いたという状況でもあります)
現状ではPhanとPsalmでもサポートしている型が異なるので、複数のツールで不整合なく型を書くのにはちょっとした工夫を要する状況でもあります。
また、現状ではSymfonyなどメジャーなライブラリがまだ@template
を実装していないので、スタブファイルを追加しなければ静的解析できない余地が生じてしまう状況ではあります。これはほどなく解消されていくことでしょう。
2021年7月16日追記: PhpStorm
PhpStorm 2021.2 Betaでジェネリクスのサポートが追加されました。
ただし、正式リリース前のPhpStorm 2021.2 Betaの段階では of
キーワードは機能していない(無視されている)ように見えます。ただ、object
やmixed
と書かずに済むようになるのは喜ばしいことです。