PHPのreadonlyプロパティで防ぐ不変性の崩壊

readonlyプロパティとは、コンストラクタなどの初期化時に一度だけ値を代入でき、それ以降は変更を禁止するPHP 8.1以降の機能です。値オブジェクトやDTOの不変性をコード側で強制したいときに使います。

「初期化したら変えない」というルールは、コメントやドキュメントで書いても守られないことがよくあります。誰かが後から便利メソッドを追加して、うっかりプロパティを書き換えてしまう。そういう事故をreadonlyは言語レベルで防いでくれます。

readonlyは何を保証してくれるのか

基本の書き方はこうです。

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

$price = new Money(1000, "JPY");
$price->amount = 2000; // Error: Cannot modify readonly property Money::$amount

コンストラクタプロモーションと組み合わせると、上のように1行のコンストラクタでほぼ完結します。値オブジェクトを作るたびに毎回setterを封印していた人には、書く量が減るだけでもありがたい変化だと思います。

ここで嬉しいのは「クラスの外からの再代入」だけでなく「クラス内部からの再代入」も禁止される点です。うっかり内部のヘルパーメソッドで$this->amount = $this->amount + $tax;のようなコードを書いてしまっても、実行時にすぐ気づけます。バグを実行前に見つけられないPHPにしては、珍しく厳しめのチェックです。

なぜ「一度だけ」なのか、初期化前は代入できる理由

readonlyは「未初期化の状態から1回だけ代入を許す」という仕組みです。なので、コンストラクタの中で条件分岐して値を決めるようなコードは問題なく書けます。

final class Order
{
    public readonly string $status;

    public function __construct(bool $isPaid)
    {
        $this->status = $isPaid ? "paid" : "pending";
    }
}

複数箇所で分岐して代入していても、実行経路として1回しか通らなければエラーにはなりません。「宣言時に必ず初期化しろ」ではなく「後から書き換えるな」がreadonlyの本質だと理解しておくと、ハマりにくくなります。

クローンで値を変えたいときはどうするか

実務で最初につまずくのがここです。イミュータブルなオブジェクトは「値を変えた新しいインスタンスを作る」設計になるのが普通ですが、readonlyのままだとcloneしてもプロパティは書き換えられません。

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

    public function withAmount(int $amount): self
    {
        return new self($amount, $this->currency);
    }
}

$price = new Money(1000, "JPY");
$discounted = $price->withAmount(800);

「withXxxメソッドで新しいインスタンスを返す」というのが定番のパターンです。PHP 8.3からは__clone内に限りreadonlyプロパティの再代入が許されるようになりましたが、対応バージョンを気にしなくていいという意味でも、withメソッドを素直に用意しておくほうが個人的には安心感があります。

継承先で再宣言するとどうなるか

readonlyプロパティは親クラスで宣言済みのものを子クラスで再宣言できません。フレームワークの基底クラスにreadonlyプロパティを置いて、サブクラスで型を変えたくなるようなケースでは設計をやり直すことになります。

また配列やオブジェクトをreadonlyプロパティに持たせた場合、プロパティ自体の再代入は禁止されても、中身の要素を書き換えることはできてしまいます。

final class Cart
{
    public function __construct(
        public readonly array $items,
    ) {
    }
}

$cart = new Cart(["apple", "banana"]);
$cart->items[] = "orange"; // Error になる(配列プロパティの追加も再代入扱い)

配列のプロパティに対する追加代入はPHP側でエラーになるので安心なのですが、これがオブジェクトの配列だった場合、要素であるオブジェクト自身のプロパティは普通に書き換えられてしまいます。「readonlyだから中身も含めて完全に不変」というのは誤解で、あくまでプロパティのスロット自体が固定されるだけだと覚えておくといいです。

まとめ

readonlyは「代入は1回まで」というシンプルなルールですが、値オブジェクトやDTOを書くときの安心感はかなり大きいです。書き換えたくなったらwithメソッドで新しいインスタンスを返す、配列やオブジェクトを持たせるときは中身の可変性まで意識する、というあたりを押さえておけば実務で困る場面は少ないと思います。

よくある質問

Q. readonlyプロパティにデフォルト値は設定できますか。
A. できません。readonlyプロパティはコンストラクタなどの中で明示的に代入する必要があり、プロパティ宣言時のデフォルト値指定はエラーになります。

Q. readonlyとfinalクラスは一緒に使うべきですか。
A. 必須ではありませんが、サブクラスでの意図しない挙動変更を防ぎたい値オブジェクトでは、finalと組み合わせるのが定石になっています。

Q. PHP 8.0以前でreadonly相当のことをするにはどうすればいいですか。
A. privateプロパティにしてgetterのみを公開し、setterを用意しない書き方が代替になります。ただし言語レベルの強制ではないため、内部からの書き換えは防げません。

類似投稿

コメントを残す

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