【静的解析】PHPStanのstubファイルを書いてみよう
こんにちは、でぃーほりです。
PHPerのみなさま、静的解析されていますか?
筆者はもっぱらPHPStan、LaravelプロジェクトではLarastanを使用しています。
最近は、不具合修正の合間に静的解析エラーを潰してコツコツレベル上げするのが日課になっています。
現在使用しているバージョンでは解析レベル8(max)でようやくnull絡みのエラーを検出できます。
レベル5からスタートし、死んだ魚の眼をしながら7->8のレベル上げ作業中です…
さて、LaravelのCollectionまわりでこんな解析エラーが出て困ったことはありませんか?
/** @var \Illuminate\Support\Collection<int, User> $collection */
$collection = collect(
[
$user1,
$user2,
$user3,
]
);
$collection->groupBy('status')
->map(
function (\Illuminate\Support\Collection $group) {
// do stuff...
}
);
------ -----------------------------------------------------------------------
Line xxx.php
------ -----------------------------------------------------------------------
59 Parameter #1 $callback of method
Illuminate\Support\Collection<int,Illuminate\Foundation\Auth\User>::m
ap() expects callable(Illuminate\Foundation\Auth\User, int): mixed,
Closure(Illuminate\Support\Collection): mixed given.
------ -----------------------------------------------------------------------
groupBy後のコレクションの次数が適切に上がらず、後続の処理で型が合いません。
@var
で型情報を上書きしたり@phpstan-ignore-next-line
で黙らせたりすることはできますが、臭い物に蓋、本末転倒と言わざるを得ません。
// bad hack
/** @phpstan-ignore-next-line */
$collection->groupBy('status')
->map(
function (\Illuminate\Support\Collection $group) {
// do stuff...
}
);
こういった静的解析エラーをignoreせずに解消する方法を紹介します。
条件
- Laravel 6.x
- Larastan v0.5.7
- PHPStan 0.12.19
- 解析レベル 7
そもそもどうやって解析しているのか
基本は実装 + PHPDoc
Laravel 6.x
Illuminate\Support\Collection@groupBy
/**
* Group an associative array by a field or using a callback.
*
* @param array|callable|string $groupBy
* @param bool $preserveKeys
* @return static
*/
public function groupBy($groupBy, $preserveKeys = false)
Laravelは歴史的経緯と後方互換のため、実装側に型情報がなくPHPDocがメインになることが多いです。
さて、この @return static
が曲者で、
/** @var \Illuminate\Support\Collection<int, User> $collection */
$collection = collect(
[
$user1,
$user2,
$user3,
]
);
$collection->groupBy('status') // \Illuminate\Support\Collection<int, User> $collection
上記サンプルコードでは 「UserのCollection」を「UserのCollectionのCollection」にグルーピングすべきところが、「UserのCollection」のままとなってしまいます。
型情報の修正が必要です。
stubファイルで型情報を補う
こういった問題を「型情報を後付けする」ことで対処するために、PHPStanには「stubファイル」という仕組みがあります。
自作することもできますし、既存のものをインストールして使うこともできます。
Larastanでは、LaravelのEloquentまわりやCollectionのstubファイルを提供しています。
vendor/nunomaduro/larastan/stubs/Collection.stub 抜粋
<?php
namespace Illuminate\Support;
/**
* @template TKey
* @template TValue
* @implements \ArrayAccess<TKey, TValue>
* @implements Enumerable<TKey, TValue>
*/
class Collection implements \ArrayAccess, Enumerable
{
/**
* @param callable|null $callback
* @param mixed $default
* @return TValue|null
*/
public function first(callable $callback = null, $default = null){}
...
stubファイルを書き換えてみよう
vendor/nunomaduro/larastan/stubs/Collection.stub に下記を追記します:
/**
* @param (callable|string)[]|callable|string $groupBy
* @param bool $preserveKeys
* @return static<int|string,static<TKey,TValue>>
*/
public function groupBy($groupBy, $preserveKeys = false) {}
groupByの戻り値がCollectionで1層包まれるように型定義を修正してみました:
@return static<int|string,static<TKey,TValue>>
再度PHPStan(Larastan)を実行してみます。
[OK] No errors
解析エラーが解消しました。
Let's Contribute!
残念ながら、上記の修正は下記の点において不十分で、そのままLarastanにPRを出すことはできません。
- \Illuminate\Support\Collection を継承している \Illuminate\Database\Eloquent\Collection でうまく機能しない
$groupBy
引数に配列を渡して2重3重にグルーピングしたときに結局型が合わなくなる
おそらく、これがLarastanのCollection.stubファイルでgroupByメソッドがサポートされていない理由なのだと思います。
しかしながら、Collectionのstubが整備され始めたのは今年2020に入ってから。
nunomaduro/larastan@94c862b
まだまだ発展途上なのです。
型が未整備の部分はまだまだあるはずです。皆さん奮ってコントリビュートしましょう!
まとめ
- PHPStanではstubファイルで型情報を付与できる
- まだまだ発展途上。皆で良くしていこう