PHPのreadonlyプロパティ、なんとなくimmutableにしていませんか

PHPのreadonlyプロパティとは、コンストラクタなどで一度だけ値を設定したらそれ以降は変更できなくなるプロパティ宣言のことです。PHP 8.1から使えます。

値オブジェクトを作るときに final class にしてコンストラクタで全部set、みたいな書き方をずっとやってきた人は多いと思います。readonlyはそこにPHP側から鍵をかけてくれる機能なんですが、「一度だけ」の範囲がどこまでなのか、意外とちゃんと理解しないまま使っている人が多い気がしています。今日はそのあたりを整理しておきます。

readonlyはどう書く?

プロパティ宣言に readonly を付けるだけです。コンストラクタプロパティ昇格(constructor promotion)と組み合わせるのが一番よく見る形だと思います。

final class Money
{
    public function __construct(
        public readonly int $amount,
        public readonly string $currency,
    ) {
        if ($amount < 0) {
            throw new InvalidArgumentException('amount must not be negative');
        }
    }
}

$price = new Money(1000, 'JPY');
echo $price->amount; // 1000

$price->amount = 2000; // Error: Cannot modify readonly property Money::$amount

読み取り専用にすることで、生成したあとに誰かが値をこっそり書き換える、という事故をPHP自体が防いでくれます。値オブジェクトを書くたびにgetterだけ生やしてsetterを封じる、という手作業から解放されるのが素直に嬉しいところです。

「一度だけ」の一度はいつなのか

ここが一番誤解されやすいところだと思うのですが、readonlyは「宣言したクラスのスコープ内で、未初期化の状態から一度だけ」代入できる、という制約です。コンストラクタの中でなければいけない、という意味ではありません。

class Article
{
    public readonly ?DateTimeImmutable $publishedAt;

    public function __construct()
    {
        // コンストラクタの中でも、書くのは初期化の一回だけ
        $this->publishedAt = null;
    }

    public function publish(): void
    {
        // すでに一度代入済みなのでこれはエラーになる
        $this->publishedAt = new DateTimeImmutable();
    }
}

コンストラクタの外だから書けない、コンストラクタの中だから書ける、という単純な話ではなく、あくまで「そのプロパティに対する最初の一回の代入かどうか」で判定されます。逆に言えば、コンストラクタの中であっても2回目の代入はできません。この感覚を持っておくと、readonlyプロパティを条件分岐で遅延初期化しようとして詰まる、みたいなハマり方を避けられると思います。

cloneするとどうなるのか

immutableなオブジェクトを扱っていると、「値を1個だけ変えた新しいインスタンスが欲しい」という場面がよく出てきます。DateTimeImmutableのwithメソッド群と同じ発想ですね。ここでPHP 8.1のreadonlyは少し不便で、__clone()の中であっても、すでに値が入っているreadonlyプロパティを上書きすることはできません。

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

    public function withAmount(int $amount): self
    {
        // PHP 8.1〜8.2ではこれはエラーになる(cloneでもreadonlyへの再代入は不可)
        $clone = clone $this;
        $clone->amount = $amount;
        return $clone;
    }
}

PHP 8.1〜8.2の間は、この「1つだけ値を変えた複製」を作りたい場合、リフレクションを使うか、素直にコンストラクタを呼び直すしかありませんでした。この制約はPHP 8.3で緩和され、コンストラクタと同様にcloneの中でも未初期化状態からの再代入という扱いで書き換えができるようになっています。バージョンによって挙動が変わる部分なので、自分の実行環境がどのバージョンなのかは意識しておいた方がいい箇所だと思います。

クラスまるごとreadonlyにする(PHP 8.2)

プロパティが多い値オブジェクトだと、全部のプロパティにreadonlyを書くのは正直だるいです。PHP 8.2からは、クラス宣言自体にreadonlyを付けることで、インスタンスプロパティ全部を一括でreadonly扱いにできます。

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

これで各プロパティに個別に書かなくても済みます。ただし制約も増えていて、readonlyクラスは非readonlyなプロパティを持つトレイトを使えませんし、動的プロパティ(#[AllowDynamicProperties])も許可されません。継承についても、readonlyクラスは非readonlyクラスを継承できず、逆に非readonlyクラスがreadonlyクラスを継承することもできません。継承関係のreadonly/非readonlyは揃えておく必要がある、と覚えておくといいと思います。

まとめ

readonlyは「コンストラクタでしか代入できない」ではなく「未初期化のプロパティに対する最初の一回しか代入できない」というのが正確な理解です。この違いを分かっていないと、cloneでの複製や遅延初期化まわりで思わぬエラーに当たることになります。値オブジェクトやDTOをPHPで書くなら、まずreadonly、必要ならPHP 8.2以降でクラスごとreadonly、という選択肢が素直に有効な場面が多いんじゃないかと思います。

よくある質問

Q. readonlyプロパティにデフォルト値は設定できますか?
A. できません。readonlyプロパティにデフォルト値を書くとコンパイルエラーになります。値は必ずコンストラクタなど初期化時のコードで設定する必要があります。

Q. readonlyプロパティをprivateにできますか?
A. できます。readonlyはpublic/protected/privateいずれの可視性とも組み合わせられます。外部からは読み取りだけ許可したいならpublic readonly、クラス内部からしか読ませたくないならprivate readonlyという使い分けが可能です。

Q. PHP 8.1でreadonlyプロパティの値を1個だけ変えた複製を作るにはどうすればいいですか?
A. cloneの中での再代入ができないため、新しいインスタンスをコンストラクタ経由で作り直すか、ReflectionPropertyのsetValue()で直接書き換える方法があります。素直さで言えばコンストラクタを呼び直す方法をおすすめします。

類似投稿

コメントを残す

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