超PHPerになろう

Enjoy PHP Programming

PHPStan 1.10には嘘発見器が付属しています

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

phpstan.org

私(Ondřej)はPHPStan 1.10のアイディアを実装してリリースすることを長い間たのしみにしていました。

インラインPHPDoc @var タグを検証する

PHPStan 1.0以降の私の使命はインライン @var を根絶することです。開発者は問題の姑息的な対処として @var に手を出しますが、これは最悪の解決策です。

@var を使用すると型安全な静的解析が提供するすべての機能を放棄することになります。

インラインの @var PHPDocタグには複数の問題があります。PHP開発者たちがこれを利用する理由として、主に2つのシナリオがあります。

  • サードパーティの間違ったPHPDocを修正する。(おそらく静的解析されていない)依存関係のPHPDocには @return string と書かれているが、実際には null が返される。
  • 返された型を絞り込む。関数は string を返すが、この場合は non-empty-string しか返されないことがわかっている。

解析されたコードを見ても、それがどのシナリオにあたるかを実際に判断することはできません。これまでPHPStanは常に入力を信頼し、エラーの可能性を報告しませんでした。型の入力が同期しなくなり、@var はいともたやすく間違ってしまう可能性があるので、これは明らかに危険です。しかし既存のユースケースを念頭に置いて、誤検知なく何を報告できるかというアイディアを思い付きました。

最新のリリースとbleeding edgeが有効になっているとき、PHPStanはインライン @var のタグの型をネイティブ型宣言された型に対して検査して@var タグでひろまった嘘を見付けます:

<?php

function doFoo(): string
{
    // ...
}

/** @var string|null $a */
$a = doFoo();

// PHPDocタグの @var string|null はネイティブ型宣言の string のサブタイプではない

型が null になることはありえないため、 string|null を許可することは無意味です。PHPStanはstring|null is not subtype of native type string (string|null はネイティブ型の string のサブタイプではありません)と警告し、サブタイプのみが許可されることを暗示しています。サブタイプとは、同じかそれよりも狭い型で、つまり string または non-empty-string であれば大丈夫です。

デフォルトでPHPStanは以下のコードに対しては何も報告しません。

<?php

/** @return string */
function doFoo()
{
    // ...
}

/** @var string|null $a */
$a = doFoo();

PHPDocの @return が間違っている可能性があり、 @var タグでそれを補正しようとしている可能性があるためです。このシナリオも報告したい場合は reportWrongPhpDocTypeInVarTagを有効化するか、phpstan-strict-rulesをインストールしてください。

私はPHPコミュニティに @var の使用を徐々に減らしてもらいたいと考えています。条件付き戻り値型@phpstan-assertジェネリクス(日本語訳: PHPDocを使ったPHPのジェネリクス - 超PHPerになろう)、サードパーティのPHPDocを上書きするスタブファイルDynamicReturnType拡張など、コードの重複を排除するための良いプラクティスや代替手段がいくつもあります。

すべてのenumケースのハンドリングを推奨する

次のコードの何が問題なのでしょうか?

<?php

enum Foo
{
    case ONE;
    case TWO;

    public function getLabel(): string
    {
        return match ($this) {
            self::ONE => 'One',
            self::TWO => 'Two',
            default => throw new \Exception('Unexpected case'),
        };
    }
}

PHPStan 1.9はこのコードについて何も警告しませんでしたが、以下のような問題があります:

  • default 節は実際には必要がない
  • もし case THREE が追加されたときPHPStanは未処理のケースを警告せず、実行時エラーに直面する

PHPStan 1.10は default ケースの例について Match arm is unreachable because previous comparison is always true. (直前の比較が常にtrueになるので、このマッチ節には到達しません)と報告します。enumに対しての matchdefault を書かないことで両方の問題を一度に解決できるので推奨します。

case THREE を追加すると、PHPStanは Match expression does not handle remaining value: Foo::THREE (match式が残りの Foo::THREE を処理していません)と報告するようになります。defaultがまだあったら警告されません。

「always true」と到達不能コードについての変更

これまでの話題に関連する変更がいくつかあります。長い間、PHPStanは一貫性のないalways true (常に真) とalways false (常に偽) の条件を報告していました。それにはいくつかのロジックがあり、私はみなさんにデッドコードを残してほしくはありませんでした。

<?php

function doFoo(\Exception $o): void
{
    // 報告されない
    if ($o instanceof \Exception) {
        // このコードは常に実行される
    }
}

接続される else 節があるとき、PHPStanは以下のように報告します

<?php

function doFoo(\Exception $o): void {
    if ($o instanceof \Exception) {
    } else {
        // ここはデッドコード
        // reports:
        // Else branch is unreachable because previous condition is always true.
        // (以前の条件が常に真になるため、else分岐には到達しません)
    }
}

いくつかのオプションを有効化するかphpstan-strict-rulesをインストールすると、instanceofalways trueとして報告できます。

私がこのような手段をとることにしたのは、以下のような「安全」なコードを書くことを躊躇させたくなかったからです:

<?php

// $foo は One|Two
if ($foo instanceof One) {

} elseif ($foo instanceof Two) {
    // PHPStan は"instanceof always true" と報告し、 "else {" と書き換えられます
}

上記の match の例と非常によく似ています。そして、これらの例を除いて多くの場合ではPHPStanが常にalways trueと報告することを期待していることにも気付きました。

bleeding edgeを有効化するか次のメジャーバージョンの全ユーザーに対して、PHPStanはデフォルトでalways trueを報告するようになります。追加のオプション設定は必要ありません。「最後の elseifユースケースをサポートするために、最後のelseifmatchの条件節には報告しません。この振る舞いはreportAlwaysTrueInLastConditionを設定すると上書きできます。

到達不能な条件分岐を報告する必要はもうなくなりました。それまでの条件分岐からのalways trueエラーのおかげで、それに気付くことができるようになります。これらのルールはbleeding edgeで完全に無効化されます。

これらの変更により、enummatchdefault を指定したときの警告はPHPStan 1.10でbleeding edgeのあるなしによって変わります。

<?php

enum Foo
{
    case ONE;
    case TWO;

    public function getLabel(): string
    {
        return match ($this) {
            self::ONE => 'One',
            self::TWO => 'Two',
            default => throw new \Exception('Unexpected case'),
        };
    }
}
-13     Match arm is unreachable because previous comparison is always true.
+12     Match arm comparison between $this(aaa\Foo)&aaa\Foo::TWO and aaa\Foo::TWO is always true.
+       💡 Remove remaining cases below this one and this error will disappear too.

PHPStanは役に立つように努めており、コマンドラインで💡絵文字の横にTips(ヒント)を表示します。これらのTipsは現在プレイグラウンドにも組み込まれていますdefault節を削除すると、PHPStanは(enumに新たなcaseが追加されるまで)このコードに文句を言わなくなります。

私の型が受け入れられないのはどうして?

PHPStanが何について文句をつけているのかは、必ずしも自明ではありません。型安全性はもっとも重要であり、潜在的な実行時エラーが起こる余地を残したくありません。開発者は特定のチェックによって回避しようとしている状況や、なぜそれを修正する必要があるのかを理解していない可能性があります。

PHPStan 1.10には直感的ではないシナリオで役立つ文脈に沿ったTipが含まれています。私のお気に入りは次のとおりです。

instanceof *Type の非推奨化

PHPStan 1.10ではカスタムルールやその他の拡張機能の理想的とは言えないコードパターンの非推奨化も含まれています。

これらについては、2週間前に別の記事を書きました。

phpstan.org

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


訳者によるまとめ

このバージョンでも数多くの変更や改善が含まれていますが、この記事で述べられているように野放図な@varに掣肘を加える機能が導入されたことは大きいでしょう。@varによって型安全性が壊されるという問題は、TypeScriptのasについて日本語でも敗北者のTypeScriptのような記事で指摘されています。

qiita.com

私がPHPerKaigi 2023のパンフレットに寄稿した「PHPStanクイックガイド2023」においても@varに頼らない型付けをするように構成しました。ただ内容的にかなり詰め込んでいるので、型付けの方法について別に記事を書いていきたいところです。

instanceof *Type の非推奨化」の問題はPHPStanの内部実装を追っている人以外にはぴんとこないかもしれませんが、1.9のリリース記事に書かれていた型リファクタリングの延長線上の話題なので興味があればぜひ読んでみてください。

www.phper.ninja

PHPStanはアップデートする度に進化していくので、軽率にアップデートして、できればbleeding edgeも有効化して使ってみましょう。