「この値はコンストラクターで決めたら、あとは絶対に変えてほしくない」——注文IDや設定値のように、途中で書き換わると困るプロパティがあります。これまではプロパティを private にしてゲッターを用意する、という書き方で守っていましたが、PHP 8.1 で追加された readonly を使えば、public のまま「読み取り専用」にできます。この記事では readonly プロパティの書き方と制約、値を変えたいときの書き換えパターン、PHP 8.2 の readonly クラスまでを解説します。
目次
readonly は「一度だけ代入できる」プロパティ
プロパティ宣言に readonly を付けると、そのプロパティはクラスの内部から一度だけ初期化でき、その後は誰も変更できなくなります。定数と違うのは、値をインスタンスごとに決められる点です。
class Order
{
public readonly string $id;
public readonly int $amount;
public function __construct( string $id, int $amount )
{
// コンストラクターの中では代入できる
$this->id = $id;
$this->amount = $amount;
}
}
$order = new Order( 'A-1024', 3800 );
echo $order->id; // A-1024 (読み取りは自由)
echo $order->amount; // 3800
$order->amount = 0; // Error: Cannot modify readonly property Order::$amount
外から代入しようとすると Error が投げられます。警告ではなく例外なので、握りつぶさないかぎりそこで処理が止まります。「変わらないはずの値が、どこかで書き換えられていた」という種類のバグが起こりえなくなるのが最大の利点です。
従来のように private $amount + getAmount() でも同じ保護はできますが、プロパティごとにゲッターを書く手間がかかり、呼び出し側も $order->getAmount() と書くことになります。readonly なら public のまま $order->amount で読めて、書き換えだけが防がれます。
コンストラクターのプロモーションと組み合わせる
PHP 8.0 のコンストラクタープロモーション(引数にアクセス修飾子を書くとプロパティが自動で作られる記法)と組み合わせると、かなり短く書けます。実際にはこちらの形をよく見かけます。
class Order
{
public function __construct(
public readonly string $id,
public readonly int $amount,
public readonly ?string $note = null,
) {
}
}
$order = new Order( 'A-1024', 3800 );
プロパティ宣言と代入の記述が消え、クラスの中身が「何を持つか」の一覧だけになりました。設定値や API のレスポンスを表す値オブジェクトは、この形で書くのが定番です。
readonly の制約
readonly にはいくつか決まりごとがあります。知らないと意図しない Fatal error に出くわすので、先に押さえておきます。
| 制約 | 内容 |
|---|---|
| 型の指定が必須 | 型を書いていないプロパティには付けられない |
| 初期値が書けない | public readonly int $x = 5; はエラー |
| 初期化はクラス内部から | 宣言したクラスのスコープ内でしか代入できない |
| static には付けられない | public static readonly は不可 |
| 再代入は不可 | 初期化済みの値は、クラス内部からも変更できない |
型が無いと使えない
class Config
{
// Fatal error: Readonly property Config::$name must have type
public readonly $name;
}
型を決められないときは mixed を指定すれば通ります。ただし mixed を使うくらいなら、そのプロパティの設計自体を見直したほうがよい場面が多いはずです。
初期値を書けない
宣言と同時に値を書くとエラーになります。これは「すべてのインスタンスで同じ値なら、それは定数であるべき」という考え方によるものです。既定値を持たせたいなら、コンストラクターの引数のほうにデフォルト値を書きます。
class Config
{
// NG: public readonly int $timeout = 30;
public function __construct(
// 引数側にデフォルト値を書く
public readonly int $timeout = 30,
) {
}
}
外から初期化することもできない
まだ値が入っていない状態でも、クラスの外から代入することはできません。「初期化はまだだから1回目の代入は通るだろう」と考えてしまいがちですが、宣言したクラスのスコープ内であることが条件です。
class Order
{
public readonly string $id;
}
$order = new Order();
// Error: Cannot initialize readonly property Order::$id from global scope
$order->id = 'A-1024';
なお、初期化されないまま読み取ろうとすると Error: Typed property Order::$id must not be accessed before initialization になります。readonly かどうかに関係なく、型付きプロパティ共通の挙動です。
値を変えたいときは新しいインスタンスを作る
readonly は再代入を許さないので、「金額だけ変えた注文」が欲しいときはセッターを書けません。代わりに、変更後の値を持った新しいインスタンスを返すメソッドを用意します。慣習として with で始まる名前を付けます。
class Order
{
public function __construct(
public readonly string $id,
public readonly int $amount,
) {
}
public function withAmount( int $amount ): self
{
// 自分は変えず、新しいインスタンスを返す
return new self( $this->id, $amount );
}
}
$order = new Order( 'A-1024', 3800 );
$discount = $order->withAmount( 3400 );
echo $order->amount; // 3800(元のオブジェクトはそのまま)
echo $discount->amount; // 3400
元のオブジェクトが変わらないので、どこかに渡した $order が知らないうちに書き換わる心配がありません。PSR-7 の HTTP メッセージなど、標準的なライブラリでも採用されている書き方です。
PHP 8.3 なら __clone() の中で再初期化できる
PHP 8.2 以前は、clone したオブジェクトの readonly プロパティを __clone() の中でも変更できませんでした。そのため、内部に可変なオブジェクトを持っているクラスでディープコピーが書けないという問題がありました。PHP 8.3 で、__clone() の中でだけ再初期化が許されるようになり、これが解決しています。
class Schedule
{
public function __construct(
public readonly DateTime $startAt,
) {
}
public function __clone(): void
{
// PHP 8.3 以降は __clone() の中でだけ再代入できる。
// これをしないと、複製元と同じ DateTime を共有してしまう
$this->startAt = clone $this->startAt;
}
}
許されているのは __clone()(とそこから呼ばれた処理)の中だけです。withAmount() のような通常のメソッドで clone $this してから値を差し替えることはできません。値を差し替えたいときは、これまでどおり new self() で作り直すのが基本と覚えておいてください。
PHP 8.2 の readonly クラス
すべてのプロパティを readonly にしたい場合、PHP 8.2 以降ならクラス自体に readonly を付けられます。
// すべてのプロパティが自動的に readonly になる
readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {
}
}
各プロパティに readonly を書かなくてよくなるうえ、「このクラスは不変である」という意図がクラス宣言の一行で伝わります。ただし readonly クラスには追加の制約があり、型の無いプロパティと静的プロパティを持てません。また、動的プロパティ(宣言していないプロパティへの代入)も禁止されます。
readonly は「深い不変」ではない
いちばん誤解されやすい点です。readonly が禁止するのはプロパティへの再代入だけで、そのプロパティが指しているオブジェクトの中身までは守りません。
class Basket
{
public function __construct(
public readonly ArrayObject $items,
) {
}
}
$basket = new Basket( new ArrayObject( [ 'コーヒー' ] ) );
// プロパティの差し替えはできない
// $basket->items = new ArrayObject(); // Error
// でも、中身の変更は通ってしまう
$basket->items[] = 'サンドイッチ';
echo count( $basket->items ); // 2
オブジェクトを保持するプロパティでは、そのクラス自身も readonly にするか、そもそも変更用のメソッドを持たない設計にする必要があります。
一方、配列は値として扱われるので安心です。public readonly array $items に対して $obj->items[] = 'x' と書くと、これはプロパティの変更とみなされて Error になります。配列を持たせるぶんには、期待どおりに守られます。
エラーが出たときに見るところ
Cannot modify readonly property
初期化済みのプロパティに代入しています。クラスの内部からでも同じエラーになるので、「メソッドの中だから大丈夫」とはなりません。値を変えたいなら、前述の with メソッドで新しいインスタンスを返す形に書き換えてください。
見落としがちなのが unset() です。初期化済みの readonly プロパティに unset() を使うと Cannot unset readonly property になります。「未初期化に戻してから代入し直す」という抜け道は用意されていません。
子クラスで再定義できない
親クラスで宣言した readonly プロパティを、子クラスで宣言し直すことはできません。Cannot redeclare readonly property というエラーになります。また、子クラスのコンストラクターから親の readonly プロパティを初期化することもできません。初期化できるのは、宣言したクラス自身のスコープだけだからです。親側にコンストラクターを用意し、子からは parent::__construct() を呼んで初期化させてください。
マジックメソッドとの相性
readonly プロパティが初期化済みの場合、そこへの代入で __set() が呼ばれることはなく、そのまま Error になります。未初期化かつクラス外からのアクセスであれば __set() が呼ばれますが、混乱のもとなので readonly を使うクラスではマジックメソッドを避けるのが無難です。
まとめ
readonly は、プロパティを「クラス内部から一度だけ初期化でき、あとは変更できない」状態にする修飾子です。public のまま読み取りの利便性を保ちつつ書き換えだけを禁止できるので、ゲッターを量産する必要がなくなります。コンストラクタープロモーションと組み合わせれば、値オブジェクトは数行で書けます。
制約として、型の指定が必須、宣言時の初期値が書けない、初期化は宣言したクラスのスコープ内から、という3点を覚えておいてください。値を変えたいときはセッターではなく、新しいインスタンスを返す withXxx() メソッドを用意します。PHP 8.2 以降ならクラスごと readonly にもできます。
注意したいのは、守られるのはプロパティへの再代入だけで、保持しているオブジェクトの中身までは不変にならないことです。配列なら問題ありませんが、可変なオブジェクトを持たせるときは相手側の設計も見直す必要があります。まずは設定値や ID を表すクラスから readonly を付けてみると、その効果を実感しやすいはずです。