超PHPerになろう

Enjoy PHP Programming

エラー識別子を備えたPHPStan 1.11、PHPStan Proの再始動など

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

phpstan.org

エラー識別子を備えたPHPStan 1.11、PHPStan Proの再始動など

このリリースは一年にわたって取り組んできました。それは一年何もリリースしてこなかったということではありません。PHPStan 1.11の開発に着手してからも私は並行して1.10.x系の作業を継続し、2023年4月から2024年の間に54ものリリースを世に送り出しましたが、私は今年始めのある時期に「もう十分だ!(enough!)」と叫びました。そして、1.11の素晴らしい改善がゴールテープを切って世界に出ることを止められませんでした。

エラー識別子 (Error Identifiers)

エラー識別子を追加しようと決めたとき、それが大変な作業になることはわかっていました。これらはPHPStanが報告するエラーにラベルを付けて分類する方法で、特に特定のカテゴリのエラーを無視するのに役立ちます。

既存のPHPStanルールをすべて検討して、それらの識別子がどのように見えるかを決める必要がありました。私は読みやすく覚えやすいという理由で、かなり早いうちに2部構成の category.subtype に落ち着きました。TypeScriptのようなほかのツールは TS2322 のような単純な数値を採用しています。私の考えでは、これはIPアドレスと同じくらい人間に優しいものです。これが私たちがインターネットでDNS(ドメインネームシステム)を必要としている理由でもあります。

もうひとつの簡単な方法はルールの実装クラス名を識別子としてマークすることですが、これらがユーザー向けにわかりやすく安定したものだとは考えていません。クラスという実装詳細に過ぎないものにパブリックな意味を与えることは、PHPStanを発展させていく上で足枷になってしまいかねません。一部のルールクラスは複数の識別子として認識すべきまったく異なる問題を報告する一方で、同じ問題として認識できるよく似た問題を報告するための複数のルールクラスがあることもあります。

そのため、より柔軟なアプローチが必要でした。それがどのようなものかは、ご自身の目で確認してみてください。PHPStanのWebサイトには、すべての識別子のカタログがあります。エラー識別子カタログは、識別子によるグループ表示ルールクラスによるグループ表示があります。このPHPStanのWebサイトのページは、PHPStanのソースコードを分析することで生成されています。(おそらくそこには『インセプション』や『Yo Dawg』のネタが含まれているはずです)

エラーがあり、それを無視するために識別子を探したいとしましょう。デフォルトのtableフォーマッタで出力しているときは、PHPStanに -v オプションをつけて実行することで出力内容の識別子を直接確認できます。

または、CLIからPHPStanに --pro オプションをつけて実行して、報告されたエラーの横にあるUIで識別子を確認することもできます。

識別子がわかったら、新しい@phpstan-ignoreコメントのアノテーションで使用できます。これは、特定の行にある全てのエラーを無視してしまう@phpstan-ignore-line@phpstan-ignore-next-lineアノテーションに代わるものです。@phpstan-ignoreを使用すると、PHPStanは現在行にコメント以外のコードがあるかを判別して、コメント行内と次の行のどちらのエラーを無視するかを判断します。

<?php
// どちらで書いても機能します

// @phpstan-ignore echo.nonString
echo [];

echo []; // @phpstan-ignore echo.nonString

@phpstan-ignore は常に識別子の指定が必須です。

同じ行で発生する複数のエラーを無視したい場合は、@phpstan-ignoreアノテーション内に複数の識別子をカンマ区切りで記述できます。

<?php

echo $foo, $bar;  // @phpstan-ignore variable.undefined, variable.undefined

そう、同じ行内に同じ種類の2つのエラーを無視したければ、同じ識別子を2回繰り返さなければいけません。安全第一!

エラーが無視される理由も説明したい場合があるでしょう。説明は識別子の後に括弧で囲んでください。

// @phpstan-ignore offsetAccess.notFound (exists, set by a reference)
data_set($target[$segment], $segments, $value, $overwrite);

設定のignoreErrorsセクションでも識別子を使用できます。これによって、メッセージと識別子の両方に一致するすべてのエラーを無視できます。

parameters:
    ignoreErrors:
        -
            message: '#Access to an undefined property Foo::\$[a-zA-Z0-9\\_]#'
            identifier: property.notFound

あるいは、特定の識別子のすべてのエラーを無視することもできます。

parameters:
    ignoreErrors:
        -
            identifier: property.notFound

PHPStanコアとファーストパーティの拡張機能には、364個のルールクラスに合計728個の識別子があります1。粒度は粗すぎもなく細かすぎもしない、中間点をうまくとれたのではないかと思います。

PHPStan Proウィザード

私は2020年9月にPHPStanの有料アドオンとしてPHPStan Proを導入しました

phpstan.org

これは、みなさまに愛していただいているオープンソースのPHPStanから機能を削って有料化したものではなく、PHPStanをさらに使いやすくする機能を追加するものです。Proの提供を開始してから着実に成長し、私のPHPStanからの収入のおよそ3分の1に貢献しています。そのおかげで私のオープンソースの仕事が持続可能になり、本当に感謝しています。

PHPStan Proをより便利で興味深いものにする方法にはたくさんのアイディアがありましたが、リリース開始から3年間はそれよりもオープンソースのPHPStanに力を注ぐことにしました。

エラー識別子はPHPStan Proに再び目を向ける絶好の機会になりました。現時点では、コードベースはおそらく @phpstan-ignore-line@phpstan-ignore-next-line でいっぱいでしょう。特定の行のエラーはもれなく無視されます。これらのアノテーションが書き込まれてから今後発生するあらゆるエラーは、すべて表示されることもなく見逃がされるため危険です。

パチンと指を鳴らせば、これらのアノテーションが魔法のように@phpstan-ignoreに正しい識別子が入力された新しいアノテーションに置き換わるとしたらどうでしょうか。

-// @phpstan-ignore-next-line
+// @phpstan-ignore argument.type
 $this->requireInt($string);

PHPStan Proでは、これらを自動的に実行する移行ウィザードが導入されています。CLIでPHPStanに--proオプションをつけて起動し、実際の動作を確認してみましょう。

[動画]

このウィザードのおかげで、ほんの数回クリックするだけでコードベースをモダンでより安全にできます。

PHPStan Proにはいまのところはこの1個のウィザードが内蔵されていますが、型ヒントやPHPDocなど静的解析に関連するさまざまな関心事を更新してよりよい状態に保つ方法について、もっと多くのアイディアがあります。私は「Wizard Drops」として、定期的に新機能を提供するつもりです(feature dropsのように)。基盤が整ったので、もっとすごいウィザードを提供できるまでは、それほどお待たせしないはずです!

PHPStan ProのUI刷新

ところで、PHPStan Proで変わったのはウィザードだけではありません。PHPStan Proは最初から、ターミナルではなくWeb UIでのエラーブラウジングを扱ってきました。

このような名言があります:

もし製品の最初のバージョンが恥ずかしくなかったら、それはローンチが遅すぎたということだ。

まあ、それについてはまったく罪悪感はありません🤣 これまでPHPStan Proには良いUIがありませんでした。それぞれのエラーは個別のボックスに表示されるので、連続した行にいくつものエラーがあったときはすぐに文脈を見失いかねません。それらは明確でわかりやすく表示されていたとはいえません。そしてWebページを再ロードすると、表示していたファイルへのフォーカスが失われる可能性があります。サイドバーパネルは固定されていなかったので、長大なファイルをスクロールするとずれてしまいました。また、Dockerパスをホスト側のファイルシステムに再マッピングもできませんでした。

これまでのバージョンの欠点についての恥ずかしいリストはまだ無限にありますが… このくらいで勘弁してください。

PHPStanを1.11に更新して--proオプションで起動すると手に入るPHPStan Proでは、もっと良くなったはずなので見てみましょう。

レイアウトはより自然で、IDEのようになりました。各ファイルは1回にまとめてレンダリングされ、報告のある行ごとにエラーが表示されます。隠された行を展開すると、エラーの周りの詳細な文脈を確認できます。エディタへのリンクは、正しいファイルを開けるようにDockerのパスを再マッピングできるようになりました。

PHPStanでは無視されたエラーも確認できます。あなたがプロジェクトを引き継いだとして、前の開発者が無視したエラーを確認したい、あるいは巨大なベースラインに潜むエラーを確認したいとき、PHPStan Proを使えばとても簡単にできます。デフォルトでは通常報告されるエラーの近くに無視されたエラーを表示するための小さなボタンが表示されますが、すべての無視されたエラーを表示および参照できるようにすることもできます。

PHPStan Proはバックグラウンドでコードベースを分析して、新しいエラーを自動的にUIに反映します。頻繁すぎる分析に追いつこうと熱を発しているノートPCで手を温めたくないときは、PHPStan Proのウィンドウがフォーカスしているときのみ実行されるようにするか、完全に分析を一時停止するかを選べます。

PHPStan Proの料金は、個人開発者は月額7ユーロ、チームは月額70ユーロです。年払いを選択すると、10か月の料金で12か月利用できます。個人は年額70ユーロ、チームは年額700ユーロになります。

すべてのプランは30日の無料試用期間があります。プロジェクト数に制限はありません。あなたのコンピュータ上のあらゆるコードにPHPStan Proを実行できます。

PHPStanを--proオプション付きで起動するか、account.phpstan.comにアクセスしてアカウントを作成してください。

この関数は本当に純粋?

PHPStan 1.11の新機能リストはここからが本番です。

@phpstan-pure アノテーションは長い間、副作用のない関数をマークするためにサポートされてきました。これは特に戻り値を覚えたり忘れたりするのに役に立ちます

www.phper.ninja

しかし、PHPStanはこのアノテーションの真実性を強制しませんでした。コード上に@phpstan-pureと書かれていれば、それを信用して常に純粋(pure)なものだと扱ってきました。PHPStan 1.11からはそれが変わります。私(Ondřej Mirtes)は、すべての種類の文(statement)と式(expression)について、どれが純粋でどれが純粋でないかを調べ尽しました。最新のPHPStanでは、 $a + $b は常に純粋であるが、 sleep(5) のような呼び出しやコンストラクタ外でのプロパティ代入が不純(impure)であることを理解できるようになりました。

その情報に基いて、いくつかのコード部分が正しくないとマークするなど複数の機会に役立てられるようになりました。純粋な式は常に使用する必要があるので、結果を使用せずに単独の行に置くことは無駄になります。

<?php

$a + $b;
new ClassWithNoConstructor();
$cb = static function () {
    return 1 + 1;
};
$cb(); // 何も起こらないよ

@phpstan-pureタグが付いているにも関わらず副作用がある関数を誤りとして報告するだけでなく、@phpstan-impureタグで注釈されているにも関わらず副作用のない関数も誤りとしてマークできるようになりました。さらには副作用のない void 戻り値型の関数も、間違っていると報告されます。副作用がなく、値も返さない関数を呼び出すことに何の意味があるのでしょうか。

この問題領域を全方位から締め付けることで、実際のプロジェクトで開発版をテストすることによりいくつものバグを取り除けました。実際のケースに適用できるようにするために、純粋関数の中でも呼び出せる関数を表す型としてPHPDocにpure-callablepure-Closureを追加しました。

これらのルール機能を有効化するには必ずbleeding edgeを有効化してください。

渡したcallableはいつ呼ばれますか?

PHPプロジェクトでは一般に、callableの文書化が不十分になりがちです。静的解析のためにcallableのシグネチャを記入して強制することは既に可能です:

/**
 * @param callable(int, int): string $cb
 */

しかしPHPStanにとっては、callableの入出力の型以外の情報は謎のままでした。コールバックが別の関数に渡されると、どうなるのでしょうか? すぐに呼び出されるのでしょうか、それともずっと後で呼び出されるのでしょうか。この情報は一つ前のセクションで説明した純粋性のチェックや、例外の制御に役立ちます。

<?php

$this->doFoo(function () {
    if (rand(0, 1)) {
        throw new MyException();
    }
});

PHPStanにとって、doFooメソッドの呼び出しをtry-catchで囲う必要があるか、送出されたMyExceptionをPHPDocの@throwsタグで書く必要があるかどうかを知ることは有意義です。それには、このコールバックがすぐに呼び出されるのか、後で呼び出すために保存されているのかを知らなければいけません。

PHPStan 1.11では以下の2つのPHPDocタグが導入されました:

  • @param-immediately-invoked-callable
  • @param-later-invoked-callable

これらのタグがなかったときのデフォルトとして、関数に渡されたコールバックは直ちに呼び出され、クラスに属するメソッドに渡された場合は保存されて後で呼び出されるという規約で取り扱われます。

このPHPDocタグはデフォルトを上書きする場合にのみ必要で、メソッドの上には@param-immediately-invoked-callableを、関数の上には@param-later-invoked-callableを書きます。

渡されたクロージャには何がバインド(束縛)されますか?

PHPClosure::bindClosure::bindToメソッドは、staticではないクロージャから参照の$thisへの参照を差し替えるために用いられますが、これを用いるとPHPStanが混乱することがありました。

PHP's methods Closure::bind and Closure::bindTo are used to change what $this refers to in a non-static closure. Doing that can lead to confusing PHPStan:

<?php

// Fooクラス内:
// ここでは $this は Foo です
$this->doSomething(function () {
    // PHPStanは、この $this は Foo だと考えています
    // しかし、実際には違うかもしれません
});

関数が渡されたクロージャを他のオブジェクトにバインド(束縛)される場合、 @param-closure-this という新しいPHPDocタグを使用してPHPStanに教えることができるようになりました。

<?php

/**
 * @param-closure-this \stdClass $cb
 */
function doFoo(callable $cb): void
{
    $cb->bindTo(new \stdClass());
    // ...
}

これはジェネリクスや条件付戻り値型でも完全に機能するので、これらと組み合せてなんでもできます。

www.phper.ninja www.phper.ninja

配列内に存在しない可能性のあるオフセットのための新しいオプション

PHPStanはデフォルトでは、まったく問題なさそうなコードについては、うるさく文句を言わないようにしています。1つの例は、先にオフセットが実際に存在するかどうかを確認せずに配列のオフセットにアクセスすることです。

<?php

/**
 * @param array<string, Item> $items
 */
function doFoo(array $items, string $key): void
{
    // this might exist but might not
    $selectedItem = $items[$key];
}

Tom De Wit氏はこのような潜在的な問題を報告するための新しいオプションをいくつか提案してくれました

上記のような例を報告するには、reportPossiblyNonexistentGeneralArrayOffsetをオンにします。

定数配列(array shapesとも呼ばれる)に対して同じようにエラーを報告するには、reportPossiblyNonexistentConstantArrayOffsetをオンにします。

<?php

public function doFoo(string $s): void
{
    $a = ['foo' => 1];
    echo $a[$s];
}

個人的な好みでは後者のオプションのみオンにしますが、あなたの経験によっては意見が異なるかもしれません。


私とPHPStanの貢献者たちは、このリリースのために多大な労力を費しました。みなさんがこのリリースを本当に楽しんで、これらの新機能を活用していただけると幸いです。GitHubでみなさんのフィードバックをお待ちしています!


PHPStanが好きで、毎日使用していますか? GitHub Sponsorsで PHPStan のさらなる開発をサポートすることを検討してください。本当にありがとう!

phpstan.org

訳者あとがき

長く続いたPHPStan 1.10.xが終わり、待ちに待った1.11シリーズです。PHPStanとしてはまだマイナーバージョンアップの扱いで、bleeding edgeもリセットされていません。詳しくはbleedingEdgeとは何か、いますぐ有効にすべき設定10選などを参照のこと。実際に大きな新機能追加や内部的な変更はありますが、拡張も含めて基本的に後方互換性は保たれています。

ことあるごとに言及していますが、PHPStanは最新のものを使い続けるのが一番安全です。PHPStan 1.11.0リリースから2週間が経っていて、本稿公開時現在で1.11.2までリリースされています。怖がらず最新版にアップデートしていきましょう。

冒頭から紹介しているPHPStan Proですが、私の所属しているピクシブ株式会社では2020年のリリース当初から現在までチームアカウント毎年更新し続けています。今回のPHPStan 1.11に伴うリリースでもとても使いやすくなったので、みなさまにもPro契約を強くおすすめしたいところです。


  1. ルールクラスごとに平均して 2個の識別子が存在するのは、まったくの偶然です。それらをカウントするアルゴリズムを再確認する必要がありました。