PHPのstrict_typesと型宣言、なんとなく付けていませんか

declare(strict_types=1)、ファイルの先頭に一応書いてはいるけど、正直なぜ効いているのか説明できない、という人は意外と多い気がしています。今日はこのstrict_typesと型宣言まわりの、実務でつまずきやすいポイントを整理してみます。

strict_typesはファイル単位でしか効かない

まず大前提として、declare(strict_types=1)は「呼び出す側のファイル」に書くものです。関数を定義したファイルではなく、その関数を呼び出すファイルの先頭に書く必要があります。ここを勘違いしていると、ライブラリ側に型宣言をびっしり書いても、呼び出し側でうっかり緩い型を渡してしまうことがあります。

チームで開発していると、あるファイルにはstrict_typesがあり、別のファイルにはない、という状態になりがちです。個人的には、PHPStanやRectorのルールでdeclare忘れを機械的に検出する仕組みを最初に入れておくのが一番楽だと思います。

strict_typesなしだと、思ったより緩い変換が起きる

strict_typesを付けていない状態(弱い型付けモード)では、スカラー型の間で暗黙の型変換が起きます。これが原因のバグは、テストでは気づきにくいのが厄介なところです。

function addPoint(int $point): int
{
    return $point + 10;
}

// strict_types=1 がない場合
echo addPoint("5");   // "5" が int(5) に変換されて 15
echo addPoint("5abc"); // PHP8だと Deprecated 警告を出しつつ 15
echo addPoint("abc");  // TypeError

“5abc”のようなケースは特に見落としやすいです。フォームからの入力値やCSVの読み込み結果をそのまま関数に渡していると、こういう「一応動くけど意図と違う」変換が紛れ込みます。strict_typesを有効にしておけば、この手の暗黙変換はTypeErrorとして即座に落ちるので、原因不明の挙動に悩まされる時間が減ります。

戻り値の型宣言を省略しがちな問題

引数の型は書くのに、戻り値の型(:int や :array)は省略している、というコードをよく見かけます。気持ちはわかるのですが、戻り値の型がないと、関数の実装が変わったときに呼び出し側で静かに壊れることがあります。

// Before: 戻り値の型なし
function findUser(int $id)
{
    // 見つからなければ null を返すつもりだった
    return $this->repository->find($id);
}

// After: 戻り値の型あり
function findUser(int $id): ?User
{
    return $this->repository->find($id);
}

戻り値の型を書いておくと、リポジトリの実装がnullを返さなくなった、あるいは配列を返すようになった、といった変更をIDEやPHPStanがその場で検出してくれます。実装の意図をコードに残すという意味でも、書いておいて損はありません。

Union型とnull許容は、なんでもunionにしない

PHP8以降はUnion型(int|string など)が書けるようになりましたが、これに頼りすぎると、結局呼び出し側で型を判定する分岐が増えて、型を書いた意味が薄れてしまいます。

個人的には、Union型は「本当に複数の型を受け入れる必要がある場所」に限定し、多くの場合は素直にnull許容(?User のような書き方)にとどめるのが読みやすいと思っています。int|string|falseのような組み合わせを見かけたら、そもそも設計を見直すサインかもしれません。

readonlyプロパティと型宣言の相性

PHP8.1で入ったreadonlyプロパティは、型宣言と組み合わせるとかなり強力です。値オブジェクトを作るときに、型で入力を絞り込み、readonlyで再代入を禁止する、という書き方をよくします。

final class Money
{
    public readonly int $amount;
    public readonly string $currency;

    public function __construct(int $amount, string $currency)
    {
        if ($amount < 0) {
            throw new InvalidArgumentException('amount must not be negative');
        }
        $this->amount = $amount;
        $this->currency = $currency;
    }
}

コンストラクタでバリデーションを済ませてしまえば、以降このオブジェクトが存在している限り不正な値は入り得ない、という前提でコードが書けます。呼び出し側でのnullチェックや型チェックが減るので、結果的にコード全体が読みやすくなる感触があります。

まとめ

strict_typesと型宣言は、書いておけば安心という魔法のおまじないではなく、どこまで厳密にするかの判断が伴うものだと思っています。特にstrict_typesはファイル単位でしか効かないという性質を忘れていると、せっかくの型宣言が呼び出し側でザルになってしまうので、そこだけは意識しておいて損はないという気がしています。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です