PHPのreadonlyプロパティ、実務でどこまで頼れるか

PHPのreadonlyプロパティとは、宣言したクラスのスコープ内で一度だけ値を設定でき、以降は再代入するとErrorになるプロパティ宣言のことです。

PHP 8.1で追加された機能ですが、「constで良くない?」「そもそも何が嬉しいの?」という反応を割とよく見ます。今日は値オブジェクトやDTOを書くときに、readonlyがどこまで頼りになって、どこでハマるかを整理します。

readonlyは何をしてくれるのか

普通のプロパティは、getter/setterを用意しない限りどこからでも書き換えられます。readonlyを付けると、そのプロパティは宣言したクラスのスコープ内で一度だけ初期化でき、初期化後はそのクラス内であっても再代入できなくなります。

final class Money
{
    public readonly int $amountInYen;

    public function __construct(int $amountInYen)
    {
        if ($amountInYen < 0) {
            throw new InvalidArgumentException('金額は0以上にしてください');
        }
        $this->amountInYen = $amountInYen;
    }
}

$money = new Money(1000);
$money->amountInYen = 2000; // Error: Cannot modify readonly property Money::$amountInYen

コンストラクタでバリデーション込みで一度だけセットし、以降は誰も触れない状態を型システムのレベルで保証できます。値オブジェクトやDTOのように「作った後は変わらない」ことを前提にしたクラスとは相性がいいです。

constとの違いは何か

constは値がクラス定義の時点で固定される定数で、インスタンスごとに違う値を持てません。readonlyはインスタンスごとに異なる値をコンストラクタで受け取りつつ、代入後は不変にできる点が違います。「インスタンスによって値は変わるが、一度決まったら変わらない」というケースはreadonlyの領分です。

なぜ再代入がErrorになるのか、握りつぶせないのか

readonlyプロパティへの再代入はTypeErrorではなくErrorがthrowされます。これはtry/catchで捕まえること自体は可能ですが、実務的には「バグとして気づけること」の方が重要です。setterを自作して「変更禁止のつもりだったが実は変更できてしまう」状態を防げるのが、readonlyを使う一番の理由だと考えています。

配列やオブジェクトを持たせるときの注意

readonlyが保証するのは「プロパティが指すもの自体を差し替えられない」ことであって、中身の不変性ではありません。配列を持たせた場合、要素の追加や書き換えもプロパティへの書き込み操作とみなされるためErrorになりますが、オブジェクトを持たせている場合はプロパティ自体は変わらず、オブジェクトの中身だけが変わってしまいます。

final class Tags
{
    public function __construct(
        public readonly array $names,
    ) {}
}

$tags = new Tags(['php', 'web']);
$tags->names[] = 'test'; // Error: Cannot modify readonly property Tags::$names

// オブジェクトを持たせた場合は要注意
final class Box
{
    public function __construct(
        public readonly \ArrayObject $items,
    ) {}
}

$box = new Box(new \ArrayObject(['a']));
$box->items[] = 'b'; // これはエラーにならない。中身は書き換わる

「readonlyにしたから安心」と思い込まず、中に入れるものがミュータブルなオブジェクトなら、そちらの不変性も別途考える必要があります。

クローンするとどうなるか

readonlyプロパティは、通常はclone後もそのままの値を引き継ぎ、__clone()内であっても再代入はできませんでした。ただしPHP 8.3以降は、__clone()メソッドの実行中に限り、readonlyプロパティを一度だけ再初期化できるようになっています。日付を持つ値オブジェクトを複製しつつ一部だけ差し替える、といった処理が書きやすくなりました。

final class Snapshot
{
    public function __construct(
        public readonly \DateTimeImmutable $takenAt,
    ) {}

    public function __clone(): void
    {
        // PHP 8.3以降、__clone内でのみ再初期化できる
        $this->takenAt = new \DateTimeImmutable();
    }
}

PHP 8.1・8.2の環境で同じことをやろうとすると、__clone()内でもError: Cannot modify readonly propertyになるので、動かしている実行環境のバージョンは意識しておいたほうがいいです。

クラス全体をreadonlyにする書き方

PHP 8.2からは、プロパティ一つひとつにreadonlyを書く代わりに、クラス宣言自体にreadonlyを付けられるようになりました。クラス内の全プロパティが自動的にreadonly扱いになり、動的プロパティの生成も禁止されます。

readonly class Coordinate
{
    public function __construct(
        public float $lat,
        public float $lng,
    ) {}
}

値オブジェクトを量産するタイプのプロジェクトでは、プロパティごとにreadonlyを書き忘れる心配がなくなるので、こちらの書き方に統一しておくと安全だと思います。

まとめ

readonlyは「一度セットしたら変えられない」ことをコード上で保証してくれる便利な機能ですが、保証してくれるのはあくまでプロパティ自体の差し替え禁止までです。中に持たせたオブジェクトのミュータビリティまでは面倒を見てくれないので、値オブジェクトを設計するときは中身の型まで含めて不変にできているか、そこは自分の目で確認する必要があると思います。

よくある質問

Q. readonlyプロパティにデフォルト値は設定できますか?
A. PHP 8.1〜8.5の時点では、型付きのreadonlyプロパティにデフォルト値を書くことはできません。宣言時ではなく、コンストラクタなどのスコープ内で明示的に代入する必要があります。

Q. readonlyプロパティをunsetできますか?
A. 初期化前であればunsetできますが、一度値を代入した後にunsetしようとするとErrorになります。__clone()内での再初期化の一環としてunsetする用途はPHP 8.3で許容されています。

Q. privateなreadonlyプロパティは継承先のクラスから再代入できますか?
A. できません。再初期化が許されるのは、そのプロパティを宣言したクラスのスコープ内だけです。親クラスでprivate readonlyとして宣言した場合、子クラスから触ることもできません。

類似投稿

コメントを残す

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