PHPのコンストラクタプロモーションで書き味と可読性を両立する

コンストラクタプロモーションとは、PHP 8.0で追加された、コンストラクタの引数にvisibility修飾子を付けるだけでプロパティ宣言と代入をまとめて済ませられる書き方です。

値オブジェクトやDTOをたくさん書いていると、プロパティ宣言・引数宣言・$this->foo = $foo;という代入の3点セットを、クラスの数だけ延々と書き写すことになります。正直、あの作業は好きではありませんでした。コンストラクタプロモーションはその繰り返しを削るための機能で、地味ですが書く量も読む量も減るので、実務ではかなりありがたい存在です。

従来の書き方は何が冗長だったのか

プロモーション以前は、こう書く必要がありました。

class Point
{
    protected int $x;
    protected int $y;

    public function __construct(int $x, int $y = 0)
    {
        $this->x = $x;
        $this->y = $y;
    }
}

プロパティ名・型・引数名を3箇所に書いているだけで、実質やっていることは「そのまま受け取って、そのまま入れる」の1行です。PHP 8.0からは、引数にvisibility修飾子を付けるとプロパティ宣言と代入を兼ねてくれます。

class Point
{
    public function __construct(protected int $x, protected int $y = 0)
    {
    }
}

これだけで$point->x相当のプロパティが定義され、コンストラクタに渡した値がそのまま入ります。呼び出す側のコードは一切変わらないので、既存のクラスに後から適用しても影響範囲は小さいです。

readonlyと組み合わせるとどうなる?

PHP 8.1で追加されたreadonlyもプロモーション対象の修飾子として使えるので、イミュータブルな値オブジェクトはかなり短く書けます。

final class Money
{
    public function __construct(
        public readonly int $amount,
        public readonly string $currency = 'JPY',
    ) {
    }
}

$price = new Money(1500);
// $price->amount = 2000; // Error: プロパティは初期化後に変更できません

金額や日時のような「一度作ったら変わらない」データをクラスにするとき、これまでは個別に代入とガードを書いていましたが、readonlyプロモーションなら宣言部分だけでイミュータブルであることが保証できます。値オブジェクトを量産する場面では特に効きます。

プロモートしないプロパティと混在させてもいいのか

全部の引数をプロモートする必要はありません。修飾子を付けた引数とそうでない普通の引数を、順番を気にせず混在させられます。

class OrderItem
{
    private array $tags = [];

    public function __construct(
        public readonly string $name,
        int $quantity,
    ) {
        $this->quantity = max(1, $quantity);
    }

    private int $quantity;
}

この例のように、単純な代入で済まない引数(バリデーションや加工が必要なもの)は普通の引数のままにして、コンストラクタ本体で処理する、という使い分けができます。「とりあえず全部プロモートする」よりも、素通しでよいものだけプロモートする、くらいの温度感がちょうどいいと思っています。

デフォルト値の落とし穴はどこにあるのか

プロモートした引数にもデフォルト値は書けますが、これは「引数のデフォルト値」であって「プロパティのデフォルト値」ではありません。PHPの公式マニュアルにも、プロモートした引数に付けたデフォルト値は引数側にのみ複製され、プロパティ宣言側には複製されないと明記されています。

class Config
{
    public function __construct(public readonly string $env = 'production')
    {
    }
}

実用上は、コンストラクタを通して値が入る限りこの違いを意識する場面はほとんどありません。ただしReflectionでデフォルト値を調べるようなコードを書くときは、ReflectionProperty側にはデフォルト値が乗ってこないので注意が必要です。地味にハマりやすいポイントだと思います。

なぜcallable型はプロモートできないのか

もう一つ実務で引っかかりやすいのが、プロモートする引数の型にcallableを指定できないという制限です。

class Handler
{
    // これはエラーになる
    // public function __construct(private callable $fn) {}
}

これはPHPの言語仕様上の都合で、プロパティの型としてcallableを使うとエンジン内部で解釈が曖昧になってしまうためです。回避するには、型を\Closureにするか、型指定を諦めてmixedや無指定にするのが定石です。

class Handler
{
    public function __construct(private \Closure $fn)
    {
    }
}

コールバックを受け取るクラスを書くときは、最初からclosure化を前提にしておくと、この制限に振り回されずに済みます。

まとめ

コンストラクタプロモーションは、単なる糖衣構文ではありますが、プロパティ宣言・引数・代入という三重の記述を一箇所にまとめてくれるので、値オブジェクトやDTOのようなクラスとは特に相性がいい機能です。readonlyと組み合わせればイミュータブルなクラスもほぼ宣言だけで書けます。ただし、デフォルト値の複製先やcallable型の制限のように、見た目のシンプルさの裏に細かい仕様が隠れているので、そこだけ知っておけば十分実務で使い倒せる機能だと思います。

よくある質問

Q. コンストラクタプロモーションはPHPの何のバージョンから使えますか?
A. PHP 8.0.0から使えます。readonly修飾子と組み合わせる場合はPHP 8.1.0以降が必要です。

Q. プロモートしたプロパティの可視性を後から変更できますか?
A. 通常のプロパティ宣言と同様に、public/protected/privateのいずれか1つを引数に付ける形で指定します。実行時に変更することはできず、変えたい場合はコード側の修飾子を書き換えて再デプロイする必要があります。

Q. すべてのプロパティをプロモートすべきですか?
A. 単純な受け渡しだけならプロモートで問題ありませんが、コンストラクタ内でバリデーションや加工が必要なプロパティは、従来通り本体で代入する書き方の方が見通しが良いです。

類似投稿

コメントを残す

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