PHPの静的解析 Phan/Psalm/PHPStan の違い
エラーチェックのためにPHPで静的解析ツールをする場合、Phan, Psalm, PHPStan を使えば良いということは検索ですぐ出てくるのだが、どれを使えばいいのか。
それぞれのツールで検知できるものが微妙に異なっているので、全部使うのが安全ではある。
それでも動作の違いや思想の違いを知っておきたいので調べる。
github.com
github.com
github.com
自分がエラーを検知してほしいまたは正しく型チェックしてほしいと思うコードを用意してそれぞれチェックしてみるのが手っ取り早い。
思いつくコードを調べてみた。
設定は基本的に初期のまま。PhanはREADMEにある設定、Psalmはレベル1、PHPStanはレベル8にした。(PsalmとPHPStanで検知レベルの向きが逆である)
<?php /** * @param array<int,string> $arr */ function test1(array $arr, ?int $index): ?string { $ret = null; if (isset($arr[$index])) $ret = $arr[$index]; return $ret; }
Phan | Psalm | PHPStan |
---|---|---|
PhanTypeMismatchDimFetchNullable | エラーなし | エラーなし |
Phan は配列のインデックスに nullable な値を指定するとエラーになる。
<?php /** * @param array<int,string> $arr */ function test1(array $arr): ?string { if (isset($arr[null])) return $arr[null]; return null; }
Phan | Psalm | PHPStan |
---|---|---|
PhanTypeMismatchDimFetchNullable | NullArrayOffset | エラーなし |
こちらはPsalmでもエラー。
PHPの文法的にはOKだが、null は自動的に空文字 '' に変換されるので、PHPStanも正しくはない。
<?php /** @return mixed */ function test2() { $a = $_GET['a']; return $a; }
Phan | Psalm | PHPStan |
---|---|---|
エラーなし | MixedAssignment | エラーなし |
Psalm は mixed の扱いに厳しい。
<?php function test3(): int { throw new Exception('err'); return 0; }
Phan | Psalm | PHPStan |
---|---|---|
PhanPluginUnreachableCode | エラーなし | Unreachable statement |
Psalm は未到達コードをチェックしないようである。
https://github.com/vimeo/psalm/issues/403#issuecomment-354441656
オプションを付ければチェックされるようだが、デフォルトではreturnやexitの後のコードはチェックされないようだ。
<?php /** * @template T of DateTimeInterface * @param T $d * @return T */ function test4($d) { $d->format('d'); return $d; }
Phan | Psalm | PHPStan |
---|---|---|
PhanUndeclaredClassMethod | エラーなし | エラーなし |
Phanはジェネリクスに of が使えないようだ。
<?php function test4(): void { var_dump(1); }
Phan | Psalm | PHPStan |
---|---|---|
エラーなし | ForbiddenCode | エラーなし |
Psalm は var_dump がエラーする。
エラーを見る限り、危険な関数の使用自体がチェックされるようである。
<?php /** * @template T of DateTime * @template U of DateTime * @param iterable<T,iterable<string,U>> $dss * @param callable(string):int $f * @return ?int */ function test5($dss, $f) { foreach ($dss as $k => $ds){ foreach ($ds as $s => $d){ $diff = $k->diff($d); if ($diff->format('%d') == 10) return $f($s); } } return null; }
Phan | Psalm | PHPStan |
---|---|---|
PhanUndeclaredClassMethod | エラーなし | エラーなし |
Phan は of が使えないのでエラー。PsalmもPHPStan も入れ子のジェネリクスとコールバック関数の型を理解できる。試しに戻り値の型を ?string にするとエラーになる。
<?php function test6($a) { echo 'a'; }
Phan | Psalm | PHPStan |
---|---|---|
エラーなし | MissingParamType, MissingReturnType | parameter $a with no typehint specified, no return typehint specified |
PHPStanの方がエラーが詳しいような書き方になってしまったがそうではなく、Psalm のようなエラーの型がないだけである。Psalmも詳しいメッセージは出力されている。
Psalm と PHPStan は型が必須のようだ。
<?php /** * @template T * @param class-string<T> $c * @return T */ function test7($c) { return new $c; }
Phan | Psalm | PHPStan |
---|---|---|
エラーなし | MixedMethodCall | エラーなし |
Psalm は mixed に厳しい。コンストラクタの引数が分からないのでnew できるかは不明というのは正しい。
<?php interface Foo { public function bar(string $bar, int $baz): int; } class FooImpl implements Foo { public function bar(string $_, int $i): int { return $i++; } }
Phan | Psalm | PHPStan |
---|---|---|
PhanParamNameIndicatingUnused | ParamNameMismatch | エラーなし |
PhanとPsalmはパラメーター名が異なればエラー。
今までは単なるコード整形だったけれども、PHP8 で named parameters が来ると意味が変わってくる。
<?php abstract class P { abstract protected function a(DateTimeImmutable $d): DateTimeInterface; } class C extends P { protected function a(DateTimeInterface $d): DateTimeImmutable { return new DateTimeImmutable($d->format('y-m-d')); } }
Phan | Psalm | PHPStan |
---|---|---|
PhanParamSignatureRealMismatchParamType, PhanParamSignatureRealMismatchReturnType | MethodSignatureMismatch | エラーなし |
PHPStanだけPHPの文法に合うようになっている。 (※コメントも参照)
PhanとPsalmはわざとエラーにしている?でもエラーメッセージを見ると共変性と反変性をあえて禁止にしているようには見えない。
<?php function test8(int $i): bool { switch(true){ case $i <= 10: return true; break; case $i > 10 && $i <= 20: return false; break; default: return true; break; } return true; }
Phan | Psalm | PHPStan |
---|---|---|
PhanPluginUnreachableCode | エラーなし | Unreachable statement, Comparison operation ">" between int<11, max> and 10 is always true. |
Psalm はデッドコードをチェックしない。
PHPStanは、条件10がかぶっていることを見つけてエラーにする。
<?php function test9(string $s): string { return sprintf('foo%s, %s', $s); }
Phan | Psalm | PHPStan |
---|---|---|
PhanPluginPrintfNonexistentArgument | エラーなし | Call to sprintf contains 2 placeholders, 1 value given. |
PhanとPHPStan はsprintfのフォーマットをチェックする。
<?php function test9(string $s): string { return sprintf('foo%s, %s', $s, 1); }
Phan | Psalm | PHPStan |
---|---|---|
PhanPluginPrintfIncompatibleArgumentType | エラーなし | エラーなし |
Phanは更に型までチェックする。
PHPStan も実は型をチェックしていて、1をnew DateTimeに変えるとエラーになる。bool|float|int|string|null を受け付けるようだ。
さて、大まかな違いが見えてきた。
Phanは全体的にバランス良くチェックしようとしているが、ジェネリクスの機能が弱い。関数のシグネチャに型がなくてもスルーする。レガシーコードに利用するのに向いていそうだ。
Psalmはmixedに厳しく、全ての型を指定しろという強い意志を感じる。新規プロジェクトに向いている。
PHPStanはPHPが許す文法には寛容に見えるし、mixedをスルーして共変性と反変性を正しく実装しているのは元々のPHPの動作を尊重しているからかもしれない。
※ この記事はQiitaの PHP その2 Advent Calendar 2020 の5日目の記事、今空いてたから登録した。
※ 12/7 追記
コメントをもらったので再チェック。
psalm の使い方が分かってないのかも…。
% ./vendor/bin/psalm --clear-cache Cache directory deleted % ./vendor/bin/psalm --version Psalm 4.2.1@ea9cb72143b77e7520c52fa37290bd8d8bc88fd9 % % ./vendor/bin/psalm Scanning files... Analyzing files... E ERROR: MethodSignatureMismatch - src/functions.php:9:5 - Method C::a with return type 'DateTimeImmutable' is different to return type 'DateTimeInterface' of inherited method P::a (see https://psalm.dev/042) public function a(DateTimeInterface $d): DateTimeImmutable ERROR: MethodSignatureMismatch - src/functions.php:9:41 - Argument 1 of C::a has wrong type 'DateTimeInterface', expecting 'DateTimeImmutable' as defined by P::a (see https://psalm.dev/042) public function a(DateTimeInterface $d): DateTimeImmutable ------------------------------ 2 errors found ------------------------------ Checks took 0.44 seconds and used 57.117MB of memory Psalm was able to infer types for 100% of the codebase % % cat src/functions.php <?php abstract class P { abstract public function a(DateTimeImmutable $d): DateTimeInterface; } class C extends P { public function a(DateTimeInterface $d): DateTimeImmutable { return new DateTimeImmutable($d->format('y-m-d')); } }
https://psalm.dev/r/52f57aeabe と結果が異なる。