PHPのコンストラクタプロパティ昇格、readonlyと組み合わせるときの落とし穴
コンストラクタプロパティ昇格(constructor property promotion)とは、コンストラクタの引数にpublicなどの可視性修飾子を付けるだけで、プロパティ宣言と代入処理をまとめて省略できるPHP 8.0の機能です。
値オブジェクトやDTOを書く機会が増えると、この書き方を使わない理由がなくなってきます。ただ、readonlyや可変長引数と組み合わせたときに「あれ、これできないんだっけ」となる場面がいくつかあるので、そのあたりを実務目線でまとめておきます。
書き方はこれだけ
PHP 7時代は、プロパティ宣言・コンストラクタ引数・代入文の3箇所に同じ名前を書く必要がありました。8.0以降は引数に可視性修飾子を付けるだけで済みます。
// PHP 7風の書き方
class Money
{
private int $amount;
private string $currency;
public function __construct(int $amount, string $currency)
{
$this->amount = $amount;
$this->currency = $currency;
}
}
// PHP 8のコンストラクタプロパティ昇格
class Money
{
public function __construct(
private int $amount,
private string $currency,
) {
}
}
3箇所に分散していた情報が1箇所にまとまるので、DTOのように「受け取ってそのまま保持するだけ」のクラスでは記述量がかなり減ります。
省略できるのはなぜか、何が起きているのか
昇格される条件は「引数にpublic・protected・private(またはreadonly)のいずれかが付いていること」です。修飾子が一つも付いていない引数は、これまで通りのただの引数として扱われ、プロパティにはなりません。内部的には、PHPがコンパイル時にプロパティ宣言と代入文を自動生成していると考えるとイメージしやすいです。
型宣言(intやstring)は省略しても文法エラーにはなりません。ただし型を付けなかったプロパティは型チェックが効かなくなるので、実務ではほぼ必ず型を書くことになると思います。
readonlyと組み合わせるとどうなる?
PHP 8.1で追加されたreadonlyは、昇格されたプロパティにもそのまま付けられます。「一度初期化したら二度と書き換えられない」という性質と、DTOや値オブジェクトの「作ったら不変」という要件がぴったり合うので、この2つはセットで使われることが多いです。
final class Money
{
public function __construct(
public readonly int $amount,
public readonly string $currency = 'JPY',
) {
}
}
$money = new Money(1000);
echo $money->amount; // 1000
$money->amount = 2000; // Error: Cannot modify readonly property Money::$amount
ここで注意したいのは、デフォルト値(= 'JPY')を書けているのはあくまで「コンストラクタ引数のデフォルト値」であって、プロパティ自体に初期値を持たせているわけではないという点です。readonlyプロパティは「宣言時に値を持たない」ことが前提になっているので、プロパティ宣言そのものに直接デフォルト値を書くこと(昇格を使わない書き方で public readonly int $amount = 0; のように書くこと)は、長らくエラーになっていました。昇格構文を使う場合は、引数側のデフォルト値としてなら問題なく書けます。バージョンによって挙動が変わりうる部分なので、手元の環境で一度動作確認しておくと安心です。
可変長引数はなぜ昇格できないのか
コンストラクタ引数に...を付けた可変長引数は、昇格の対象にできません。これをやろうとすると「Cannot declare variadic promoted property」というエラーになります。
class Config
{
public function __construct(
private array $items = [],
) {
}
}
// これはできない
class Config
{
public function __construct(
private array ...$items // Fatal error
) {
}
}
可変長引数は「残り全部の実引数を1つの配列にまとめる」ための仕組みで、プロパティ1つに1つの値を対応させる昇格の考え方と噛み合わないためです。可変個の値を保持したいクラスでは、配列型のプロパティを1つ用意して、呼び出し側で配列やスプレッドを使ってもらう形にするのが素直だと思います。
プロパティ宣言と二重に書くとどうなるか
昇格した引数と同じ名前のプロパティを、クラス本体側にも書いてしまうと「Cannot redeclare property」で落ちます。IDEの自動補完やコピペで、うっかり両方に書いてしまうことがあるので、エラーが出たら真っ先にここを疑うとよいです。
まとめ
コンストラクタプロパティ昇格は、値をそのまま保持するだけのクラスを書くときの定型コードを減らしてくれる機能です。readonlyと組み合わせれば、不変な値オブジェクトを驚くほど短く書けます。一方で、可変長引数は昇格できない、プロパティ宣言と二重に書けない、readonlyのデフォルト値の扱いにはバージョン差があるといった細かい制約があるので、「なぜか動かない」と思ったらまずこのあたりを確認してみてください。
よくある質問
Q. 可視性修飾子を付けない引数はどうなりますか?
A. 昇格されず、これまで通りのただの引数として扱われます。プロパティとして保持したい場合は必ずpublic/protected/privateのいずれかを付ける必要があります。
Q. 昇格したプロパティとしていないプロパティを1つのコンストラクタに混在させられますか?
A. できます。修飾子が付いた引数だけが昇格され、付いていない引数はコンストラクタ内でこれまで通り処理を書くただの引数として扱われます。
Q. readonlyプロパティはコンストラクタ以外でも初期化できますか?
A. できます。宣言したクラスのスコープ内であれば、コンストラクタ以外のメソッドから初期化することも可能です。ただし一度値がセットされた後は、同じスコープ内であっても再代入するとエラーになります。