超PHPerになろう

Enjoy PHP Programming

PHPDocを使ったPHPのジェネリクス

この記事はPHPStan開発者のOndřej Mirtesによって2019年12月2日に書かれた記事を翻訳したものです。記事の末尾には訳者(@tadsan)の観点によるPhan, Psalm, PhpStormとの互換性についての情報も記述しています。

medium.com

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(反変性)は関連する型の間の関係を示します。

f:id:zonu_exe:20200301023328p:plain:w500

共変(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を実装したとき、問題が発生します。(それは反変ではなく共変になってしまいます!) BulldogFeederfeedChihuahua()関数に渡してしまったとき、BulldogFeeder::feed()Chihuahuaを受け入れないため、コードはクラッシュします。

<?php

class BulldogFeeder implements DogFeeder
{
    function feed(Bulldog $dog) { ... }
}
feedChihuahua(new BulldogFeeder()); // 💥

幸運にも、PHPはこれを許可しません。しかし多くの型がPHPDocだけに書かれていることがあるために、静的解析ではこれらのエラーを捕捉する必要があります。

その一方で DogFeederDog よりも一般的な型(Animalなど)で実装する場合は問題ありません。

<?php

class AnimalFeeder implements DogFeeder
{
    public function feed(Animal $animal) { ... }
}

このクラスは全てのDogを受け入れた上で、Animalも受け入れます。DogAnimalは反変(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を省略したとしても$messageSendMailMessageであることを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で実際に試してみてください:

f:id:zonu_exe:20200301035259p:plain:w500

次はあなたの番

ジェネリクスの目的を理解したので、実際のコードベースでの用途を考えるのはあなた次第です。関数やメソッドに出入りする型を具体的に記述できるようになります。現在objectまたはmixedを使用しているが、より正確な型を付けられる箇所にはどこでもジェネリクスが役立ちます。いままでobjectまたはmixedと書くしかなった場所を型安全にできます。

PHPStan 0.12にはジェネリックを含む多くの改善が含まれています。

www.phper.ninja

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でもサポートしている型が異なるので、複数のツールで不整合なく型を書くのにはちょっとした工夫を要する状況でもあります。

github.com

また、現状ではSymfonyなどメジャーなライブラリがまだ@templateを実装していないので、スタブファイルを追加しなければ静的解析できない余地が生じてしまう状況ではあります。これはほどなく解消されていくことでしょう。

ジェネリクスPHPに楽しく型付けしていきましょう。

2021年7月16日追記: PhpStorm

PhpStorm 2021.2 Betaでジェネリクスのサポートが追加されました。

blog.jetbrains.com

ただし、正式リリース前のPhpStorm 2021.2 Betaの段階では of キーワードは機能していない(無視されている)ように見えます。ただ、objectmixedと書かずに済むようになるのは喜ばしいことです。