こんにちは!コドモンプロダクト開発部で認証/認可のリプレイスを主に担当しております、エンジニアの関口です。 今回はPHP8.0からの新機能「constructor promotion」について「アツい!」と感じたので、その紹介がてら、クラスのコンストラクタを書く際の私のプラクティスを書いていきます。
弊社では以前からPHPのバージョンアッププロジェクトが進行しており、PHP8.1への移行がおおむね完了するところまで来ています。8.0で出た多くの改善を開発に活かせる環境が整い、実際に利用することができるようになってきたのでこのテーマを取り上げました。移行を進めてくれた弊社PHPバージョンアップチームに感謝です。
- constructor promotionとは?
- 可能な限りすべてのプロパティにreadonly属性を付与する
- 複数箇所でインスタンス化される場合、コンストラクタを非公開にして代わりにstatic factoryメソッドを公開する
- コンストラクタの処理部に何も書かない
- 参照の逸出に注意する
- まとめ
- おまけ
constructor promotionとは?
__constructorの引数がそのままクラス変数の定義に格上げされる仕組みです。今までは
- プロパティを定義する
- コンストラクタの引数を定義する
- 代入式を書く
と3ステップ必要だったのが、最短で2のコンストラクタの引数を定義するだけになります。
<これまで>
<?php final class MyClass { private string $strProp; private int $intProp; public function __construct(string $strProp, int $intProp) { $this->strProp = $strProp; $this->intProp = $intProp; } }
<これから>
<?php final class MyClass { public function __construct( private string $strProp, private int $intProp ) { } }
記述が簡潔になり、クラスを作成する手間がグッと減ります。読むほうの目にも優しいですね。これを利用しない手はありません。PHP8.0以後はクラスを作成したり修正したりする場合はデフォルトで使用しましょう。
これとの組み合わせで、私が心がけているコンストラクタ定義のTipsをご紹介します。
可能な限りすべてのプロパティにreadonly属性を付与する
私がPHP7.xでコードを書き始めてもっとも困惑していた問題がこれで解決しました。クラス定義にはfinalを宣言できるのになぜプロパティには宣言できないのかと……。readonlyは事実上プロパティにfinalを宣言するのと同じです。よかった。
readonlyを宣言したプロパティはインスタンスが生成されてから値が変わりません(一部例外はあります。後述します)。実装でそれを担保する必要がないですし、おかしなことをすればエディタやPHPが即座にエラーで知らせてくれます。
<readonlyを宣言したプロパティの例>
<?php final class MyClass { public function __construct( private readonly string $strProp, ) { } public function setProp(string $strProp): void { // my phpstorm tells "Cannot modify 'readonly' property" with a red waved line $this->strProp = $strProp; } } $mc = new MyClass('hello'); // Error : Cannot modify readonly property MyClass::$strProp $mc->setProp('hoge');
また、readonly宣言されたプロパティに関わる振る舞いを追加/変更/削除する際には状態の変更を追って確認する必要がありません。付けておくだけでコーダーとレビュアー双方が楽になりますね。
そしてすべてのプロパティにreadonly属性を付与したクラスは宣言的に不変なクラスです。実装の影響を受けて不変性が破れることはありません。PHPではあまりメリットにならないかもしれませんが、不変なクラスはスレッドセーフについて一切考える必要がなくなります。
readonly属性をデフォルトでつける最大のメリットは、readonlyにできないプロパティがあった場合にそれが正当化できるかどうかを考える契機になることです。私の経験上、BuilderとEntity以外のクラスでreadonlyでないプロパティが必要になる場合はほとんど例外なくクラスの設計に改善の余地があります。DDDやクリーンアーキテクチャの設計パターンと合わせて、readonlyの有無はよいクラス設計を促すマーカーとして役立ちます。
複数箇所でインスタンス化される場合、コンストラクタを非公開にして代わりにstatic factoryメソッドを公開する
コンストラクタは一度公開するともっとも振る舞いを変えるのが難しい部分になります。特にPHPではJavaのようにオーバーロードもできず、一個しか定義できません。
UseCase/Service/Repositoryなどの処理系のクラスであれば生成元が少ないのであまり問題にならないと思いますが、ValueObjectなど生成元が多いような場合では特にこの問題が顕著です。この場合はコンストラクタを非公開にして代わりにstatic factoryメソッドを公開することで直接コンストラクタに変更が波及しないようにする手があります。
例えば、クラスのプロパティが増えた場合でもコンストラクタの定義を変えつつstatic factoryメソッド内でデフォルトの値を入れておくようにし、deprecatedして新しいstatic factoryメソッドの利用を促すなど柔軟な対応ができます。以下は二次元だった座標クラスを三次元に変更する例です。
<二次元だった座標クラスを三次元に変更する例>
<?php final class Coordinate { private function __construct( public readonly int $x, public readonly int $y, // woops… now we need another axis after this code released public readonly int $z ) { } /** * @deprecated use edgeOfFirstOctant instead * @return Coordinate */ public static function edgeOfFirstQuadrant(): Coordinate { // return new Coordinate(255, 255); return new Coordinate(255, 255, 255); } public static function edgeOfFirstOctant(): Coordinate { return new Coordinate(255, 255, 255); } public static function origin(): Coordinate { // return new Coordinate(0, 0); return new Coordinate(0, 0, 0); } public function moveX(int $to): Coordinate { return new Coordinate($to, $this->y, $this->z); } public function __toString(): string { return sprintf('x:%d, y:%d, z:%d', $this->x, $this->y, $this->z); } } // Error : Call to private … // $c = new Coordinate(1,1,1); // move 100 points to x direction from origin $c = Coordinate::origin()->moveX(100); // x:100, y:0, z:0 echo $c;
もし実際に私がこのリファクタを入れるなら、クラス名をCoordinate2dに変えてそれをラップしたCoordinate3dを作りますが、クライアントに破壊的変更を加えることなく修正できていることがおわかりいただけると思います。
また、変更が容易という観点から以下のようなユースケースでもstatic factoryメソッドはうってつけのソリューションになります。
- コンストラクタの引数の一部にデフォルト値を設定したい
- ボイラープレートなクライアントの変換処理を肩代わりしたい
- 不正な引数が来ないようにチェックを入れたい
余談ですが、このパターンは生成済みのインスタンスのコンストラクタを呼ぶ(呼べてしまいます)とメソッドがミューテーターとして機能する問題への解決にもなります。私はこの問題を@t_wadaさんのこの動画で初めて知りました(15:20あたりです)。PHPは奥が深い……。
コンストラクタの処理部に何も書かない
若干本題からそれますが、そもそも私がコンストラクタに処理を書くことがよくないと考える理由を書いておきます。
まず第一は、処理がある以上コンストラクタにもテストを書く必要が生じることです。コンストラクタはクラスの振る舞いではありませんので、テストが必要になるのはおかしな話です。
第二は、インスタンスを生成するとその時点で必ずその中身が実行されてしまうことです。生成したインスタンスを使うかどうかはクライアントのコードひとつですが、有無を言わさずに実行してしまいます。必要なことは必要になるまでしないのがベストです。どうしても避けられない初期化処理があるのならば、最初に必要になるタイミングまで遅延するか、static factoryメソッドや外側にfactoryクラスを定義します。こうしておけば、それが重い処理ならばそれとわかる名前をつけることもできます。クラスの利用者はコンストラクタの処理が重いかもしれないとは考えもしないでしょう。
最後に、コンストラクタの中からインスタンスメソッドを呼べてしまう危険性も指摘しておきます。コンストラクタの中は、インスタンス生成が完了する前の状態で実行される処理です。そのインスタンスメソッドに必要なプロパティがすべて事前に設定されている場合にのみコンストラクタの中から呼ばれるインスタンスメソッドは正しい振る舞いをし、そうでない場合にどうなるかは予期しにくいものになります。下記は下手な例ですが、コンストラクタの中からインスタンスメソッドを呼ぶことの嫌な感じがご理解いただければと思います(この例のようにある処理の結果をキャッシュしたくなった場合について、最後の「おまけ」で言及します)。
<コンストラクタの中からインスタンスメソッドを呼ぶよくないクラスの例>
<?php final class ComplexConstructor { private int $prop1; private int $prop2; private int $sum; public function __construct( int $prop1, int $prop2, ) { $this->prop1 = $prop1; // prop2をセットする前にsetSumしてはいけないという暗黙知が生じている // 当然何の警告も出ない $this->setSum(); $this->prop2 = $prop2; } private function setSum(): void { $this->sum = $this->prop1 + $this->prop2; } public function sum(): int { return $this->sum; } } // Error : Typed property ComplexConstructor::$prop2 must not be accessed before initialization $cc = new ComplexConstructor(1,1);
参照の逸出に注意する
readonlyを宣言したプロパティはインスタンスが生成されてから値が変わりません
と先に書きましたが、例外があります。不変でないオブジェクトの参照が逸出した場合です。以下が例です。
<参照が逸出してしまいreadonly属性が無意味になる例>
<?php final class Mutable { public function __construct( private readonly \DateTimeInterface $date ) { } public function date(): \DateTimeInterface { return $this->date; } public function __toString(): string { return $this->date->format('Y-m-d'); } } // input reference is held by client and mutated. $dateIn = new \DateTime('2023-01-01'); $m = new Mutable($dateIn); // 2023-01-01 echo $m; // change the input. $dateIn->setDate(2023, 2, 1); // 2023-02-01 echo $m; // output reference is held by client and mutated. $dateOut = $m->date(); // 2023-02-01 echo $m; // change the output. $dateOut->setDate(2023, 3, 1); // 2023-03-01 echo $m;
この問題は、2つの解決があります。クラスのすべてのプロパティを不変クラスにするか、入りと出の両方で防御的にコピーするか、です。
<プロパティの型を不変クラスにする例>
<?php final class Immutable2 { public function __construct( // use DateTimeImmutable instead of DateTimeInterface private readonly \DateTimeImmutable $date ) { } ……(以下同様)
<入りと出の両方で防御的にコピーする例>
<?php public function __construct( private \DateTimeInterface $date ) { // clone $this->date = clone $date; } public function date(): \DateTimeInterface { // clone return clone $this->date; } ……(以下同様)
どちらも先のスクリプトの出力がすべて「2023-01-01」で固定されるようになり、不変性が確認できます。
前者の変更は破壊的変更になるので、既にリリース後だったり、影響範囲がとても広かったりする場合は取りにくい対策になります。後者は破壊的変更はありませんが、再代入が必要になるのでdateをconstructor promotionで定義する場合、readonly属性が付けられなくなります。
なお、このケースでもコンストラクタをprivateにし、DateTimeInterfaceを引数にしたstatic factoryメソッドを定義していた場合の良さが確認できます。この場合は前者と後者のいいとこ取りのソリューションが可能です。dateをreadonly属性付きのままDateTimeImmutable型に定義し直し、static factoryメソッド内でDateTimeInterfaceをDateTimeImmutableにするだけです。もちろん、先のスクリプトの出力がすべて「2023-01-01」で固定されるようになります。
<?php final class Immutable3 { private function __construct( // use DateTimeImmutable instead of DateTimeInterface private readonly \DateTimeImmutable $date ) { } public static function of(\DateTimeInterface $date): Immutable3 { return new Immutable3(\DateTimeImmutable::createFromInterface($date)); } public function date(): \DateTimeInterface { // no need to clone return $this->date; } ……(以下同様)
ちなみに、DateTimeImmutableクラスは先に紹介した@t_wadaさんの動画でも言及されているとおり、コンストラクタがミューテーターとして機能する問題をはらんでいます。あまりないとは思いますが、もしそこが問題になるケースでは、「入りと出で防御的にコピーする」が唯一の選択肢になります。
まとめ
クラスのコンストラクタは相当な理由がない限り以下のように定義します。
- constructor promotionを利用し、引数部分にすべてのプロパティを定義する
- すべてのプロパティをreadonlyにする
- 複数の場所でインスタンスが生成される場合には、コンストラクタをprivateにしてstatic factoryメソッドを定義する
- コンストラクタの処理部を空白に保つ
これが私のTipsです。これだけでクラスはより安全で変更に強く保守性に優れたものになります。このクラスを提供する側も、このクラスのクライアントもレビュアーもハッピーです。この指針はクラスに余計な複雑さが持ち込まれることを事前にチェックしてくれるでしょう。この習慣を破らざるを得ないときが来たら、コンストラクタのDocにその理由やトレードオフになったものを書いてください。数日後のあなた、あるいは他の誰かがよりよい改善案をくれるかもしれません。
なお、constructor promotionを除く今回ご紹介した私のTipsの内容のほとんどは「Effective Java」という書籍に書いてあります。特にバックエンドを実装するエンジニアにはどの言語でも(もちろんPHPにも)通用する内容が多くありますので、機会があればご一読をお勧めします。
おまけ
今回ご紹介した中でconstructor promotionでreadonlyな引数を定義すると厳しいケースは以下です。
- 処理の結果を再利用できるようにキャッシュしておきたい
- コンストラクタの引数を防御的にコピーしたい
言及しなかった1.のほうで、私がどうしているかを書いておきます。私がこの問題によく遭遇するのは以下のようなユースケースです。
Entityの集合を表すクラスで、IDによる検索を実装するが、1リクエスト内で同じインスタンスを頻繁に検索するため、IDとEntityのmapをキャッシュしておきたい
いつも悩むのですが、私はこう実装します。
<キャッシュのためにreadonlyにできないプロパティを定義する例>
<?php final class SomeEntityID { public function __construct( private readonly string $value ) { } public function asString(): string { return $this->value; } } final class SomeEntity { public function __construct( private readonly SomeEntityID $id ) { } public function id(): SomeEntityID { return $this->id; } } final class SomeEntities { /** * cached map of SomeEntityID and SomeEntity.<br> * do not access this property directly, use #idMap.<br><br> * this field is for avoidance of O(n^2) computation for #findByID, <br> * it brings some complexity into this class, but once the cache created * after O(n) computation, #findByID will be done in O(1).<br> * also this cache does not prevent the thread safeness of this class * because the data and process to fill this cache is idempotent. * @var array<string, SomeEntity> */ private array $idMapCached = []; /** * @param SomeEntity[] $someEntities */ public function __construct( private readonly array $someEntities ) { } public function findByID(SomeEntityID $id): SomeEntity|false { return $this->idMap()[$id->asString()] ?? false; } private function idMap(): array { if (!$this->idMapCached) { $this->idMapCached = array_reduce( $this->someEntities, function (array $carry, SomeEntity $someEntity): array { $carry[$someEntity->id()->asString()] = $someEntity; return $carry; }, [] ); } return $this->idMapCached; } }
ご覧のとおり基本は今まで書いてきた内容のとおりです。ただし、cacheする部分だけを可変プロパティとしてコンストラクタの外に定義してコメントを書き、遅延初期化を行うprivateなアクセッサメソッドを用意します。こうすることで、idMapCachedプロパティがSomeEntitiesクラスにおけるアタッチメントに過ぎないことが明確にできると思います。他によい実装があればぜひ、ご意見をいただけると幸いです。