超PHPerになろう

Enjoy PHP Programming

PHPDocベースのassert、list型とPHPStan 1.9.0の新機能

この記事はPHPStan開発者のOndřej Mirtesによって2022年11月3日にPHPStan Blogに書かれた記事を翻訳したものです。

phpstan.org

PHPStan 1.9.0はまさにコミュニティの尽力によるものです。目玉機能はすべて、メンテナーである私(Ondřej)以外の誰かの貢献です。コードを書くのが嫌になったわけではないのですが、私が草むらで謎のバグを追いかけている間にも、ほかの人は新しい機能をより早く実装できるようになります。

私はここのところ緑色の「Merge」ボタンを一日に何度も押しています。私の役割はコードの主要な貢献者から、品質保障(QA)とプロジェクトビジョン1、そして継続的インテグレーション(CI)パイプラインの処理に移行しています。私は最近、貢献者向けのレターContributors update #1 2022でそれを認めました。これはあなたが貢献者ではなくとも読む価値があるでしょう。

github.com

PHPDocによるアサーション

この機能はRichard van Velzenによって開発されました。

ジェネリクス条件付き戻り値型が実装されたあと、PHPStanの高度な機能はさらに民主化されています。

次のような自作の型チェック関数を考えてみましょう。

<?php

public function foo(object $object): void
{
    $this->checkType($object);
    $object->doSomething(); //  未定義メソッド object::doSomething() をコールしている
}

public function checkType(object $object): void
{
    if (!$object instanceof BarService) {
        throw new WrongObjectTypeException();
    }
}

foo()メソッドを分析するとき、PHPStanはその関数で呼び出した関数やシンボルに降りず型宣言とPHPDocを読み取るだけなので、$objectBarService に絞り込まれたことを理解しません。

このようなケースにおいては、これまでも自作の型指定拡張(type-specifying extensions)を作成することで型を記述できました2。しかしそのためには抽象構文木(AST)や型システムなど、PHPStanを構成するコアの概念を理解する必要があります。

PHPStan 1.9.0はそれを誰にとっても簡単にします。PHPDocで@phpstan-assert, @phpstan-assert-if-true, @phpstan-assert-if-false タグを記述することで、コールされた関数で型がどのように絞り込まれるのか記述することができるようになります。引数だけでなく、同じオブジェクトのほかのメソッドから返されるプロパティの絞り込みもサポートしています。

<?php

public function foo(object $object): void
{
    $this->checkType($object);
    $object->doSomething(); // エラーなし
    \PHPStan\dumpType($object); // BarService
}

/** @phpstan-assert BarService $object */
public function checkType(object $object): void
{
    if (!$object instanceof BarService) {
        throw new WrongObjectTypeException();
    }
}

この機能の新機能についてはドキュメントを参照してください。

phpstan.org

list型

この機能はRichard van Velzenによって開発されました。

PHPの配列はとても強力ですが、複数のコンピュータサイエンスの概念を単一のデータ構造として表しているので、扱いが難しいことがあります。そのためリストのような単一の概念のみが必要なときには絞り込めると便利です。

PHPStanでのリストは0から始まる連続した整数のキーを持つ隙間のない配列のことです。これはPHPDocで表現できる多くの高度な型のひとつとして追加されます。

<?php

/** @param list<int> $listOfIntegers */
public function doFoo(array $listOfIntegers): void
{
}

この機能で困難だったのは、PHPには配列を操作する数多くの方法があるということです。これらをすべて検討し、次のように決定する必要がありました。

  • リストでなかった配列をリストにするか → array_values() が該当
  • リストが既にリストだったらリストを維持するか → array_map() が該当
  • リストだったものをリストではなくするか → array_filter() が該当

そのため、list型は実験的なものとして導入されており、bleeding edgeでのみ利用できます。今すぐ全ユーザーに適用するのは混乱をもたらす可能性があるので、アーリーアダプターと問題を解決してPHPStan 2.0のリリースに備える予定です。

いますぐ試してみたい場合は、設定ファイル bleedingEdge.neon に以下を追加してください。

includes:
    - phar://phpstan.phar/conf/bleedingEdge.neon

(訳注: bleedingEdgeを追加しなくても従来のバージョンから list 型は array<int, T>エイリアス扱いで利用可能でした。そのため、list 型は積極的に使いはじめることができます)

リファレンスによって代入されるパラメータ型

この機能はMarkus Staabによって開発されました。

先ほども述べたように、PHPStanは呼び出した関数やメソッドの内側で何が起こっているのかを関知しません。そのため、パラメータがリファレンス(参照)によって代入される場合、関数呼び出し後の型は以下のように常に mixed になってしまいます。

<?php

function foo(mixed &$i): void
{
    $i = 5;
}

foo($a);
\PHPStan\dumpType($a); // mixed

今回の新機能では、PHPStanは @param-out で出力される型を記述できるようになります。

<?php

/**
 * @param-out int $i
 */
function foo(mixed &$i): void
{
    $i = 5;
}

foo($a);
\PHPStan\dumpType($a); // int

開発者がこのタグで関数をマークできるのみならず、PHPStan内部でも30以上の組み込み関数に注釈を追加したので、PHPStanによって実行される型推論もかなりスマートになりました。

メソッドを呼び出した後の現在のオブジェクトの型を記述する

この機能はRichard van Velzenによって開発されました。

ミュータブルなオブジェクトの型は、状態を変異させるメソッドの後に変更されることがあります。整数のみのジェネリックなコレクションがあり、addメソッドが別の型の値を許可するとしましょう。

<?php

/**
 * @template TValue
 */
class Collection
{

    // ...

    /**
    * @template TItemValue
    * @param TItemValue $item
    * @phpstan-self-out self<TValue|TItemValue>
    */
    public function add($item): void
    {
        // ...
    }

}

コレクションの型はコールされた後に変更され、それが @phpstan-self-out (または @phpstan-this-out)に記述されています。

<?php

/** @param Collection<int> $c */
function foo(Collection $c, string $s): void
{
    $c->add($s);
    \PHPStan\dumpType($c); // Collection<int|string>
}

許可されたサブタイプを記述するための新しい拡張

この機能はJiří Pudilによって実装されました。

PHPStanはエンドユーザーがコードのバグを探すためのツールであるだけでなく、さまざまな静的解析のニーズをカバーするためのフレームワークでもあります。自作の拡張を作成してコード内の魔術がどのように動作するのかをPHPStanに正確に教えることができます。データベースのクエリでは結果の型を教えることができます。自作のルールを作成してコードベース内のトリッキーな状況をチェックできます。

既に実装できる拡張の種類はかなり多いのですが、今回また新しいものが追加されました。

PHP言語にはsealedクラスの概念がありません。sealedはクラスの階層を制限し、継承を制限する方法です。そのためPHPでは(finalではない)クラスやインターフェイスは無限の子クラスを持つことができてしまいます。AllowedSubTypesClassReflectionExtensionインターフェイスを実装することによって、ある親クラスに対して許可される子クラスの完全なリストをPHPStanに伝えます。

この拡張タイプはクラスをハードコーディングすることでも簡単に記述できますが、独自PHPDocまたはアトリビュートにアクセスする方法もあります。

もちろん作成者のJiří Pudil自身が作成した #[Sealed] アトリビュートを追加するPHPStan拡張パッケージを導入することも間違いありません!

github.com

リファクタリング、はじめました

PHPStanの開発を最初からやりなおせるならば、継承は絶対に避けたいところです。開発者はみんなこのような構造に出会ったに違いありません。

<?php

class User {}
class Admin extends User {}
class Editor extends User {}
class Customer extends User {}

これは全て正しく、たくましく働きます。……誰かが複数の役割を持たざるを得なくなるまでは。複数のアカウントを使いこなすか、合成(コンポジション)できるように継承の階層をリファクタリングする必要があります。

PHPStanの内部においては、それが型システムを利用している箇所にあたります。

PHPは複雑な言語です。多くの場合においてある型は別の型の代用になります。たとえば文字列はcallableにすることができます。callableは配列にすることもできます。ほかの多くのType実装も文字列になる可能性があるため、 $type instanceof StringType と尋ねることは想像する全ての状況をカバーするわけではありません。

そのため、「これは配列ですか?」と Type::isArray(): TrinaryLogic で尋ねるように推奨の方法を変更しました。「これは文字列ですか?」は Type::isString(): TrinaryLogic です。このような過程は多くのバグを取り除くことに役立ちます。

すべての $type instanceof *Type インスタンスを置き換えることと、 Type インターフェイスには数百のメソッドが含まれます。私はこれが正しい解決策だと確信しています 🤣

さらに $type instanceof *Type を非推奨にしてすべてのサードパーティPHPStan拡張から根絶できれば、最終的に型を互いに分離できるようになります。これにより、すべてのテンプレート型の境界を自動でサポートできるようになるなど、よりよい機能が実現できます3。同様にすべての型を自動的に減算可能にもできます4

Martin HerndlはPHPStan 1.9.0において、配列型のさまざまなユースケースに取り組むことでリファクタリングを開始しました。これがリリースノートのInternalsセクションがいつもより多くなっている理由です。最終的にはそれだけの大きな価値があるため、ほかの人もこの作業に参加してくれることを願っています。

もっと!

その他の改善やバグ修正を含めた100以上の改善のリストは完全なリリースノートを確認してください。

github.com

訳者によるまとめ

この1.9.xはPHPDocベースの@phpstan-assert@param-out 、bleedingEdge扱いではあるもののlist型、などPsalmに実装されていた目玉の機能が多く追加されており、ユーザーとしても非常に使いでのあるリリースになっています。

PHPStanはOndřej Mirtesという非常にパワフルな開発者のもとで主導されていますが、冒頭の通り目玉機能から些細なバグ修正に至るまでコミュニティによる互助が非常に重要なファクターになっています。

PHPStanは決して完璧なものではないですし、完成することなく前進し続けるプロダクトです。PHPStanを使っていて思うように型がつかないとか変な挙動になると思ったらissueで報告をするか、Twitterなどで共有するだけでもほかのユーザーにとっても役立つ貢献になります。


  1. A thousand No’s for every Yes.

  2. そうしなければ、PHPUnit テストケースの分析は機能しません。

  3. いまはすべての型境界(@template T of int など)が個別の TemplateIntegerType を必要とすることで、$type instanceof IntegerType$type instanceof TemplateType を両立させています

  4. いまは mixedobject のような少数の限られた型のみが減算できます。PHPStan Playgroundでの例