引数のデフォルト値に new を書く – PHP 8.1のnew in initializersの使いどころと制限
new in initializers は、PHP 8.1で入った「引数のデフォルト値・static変数・グローバル定数・属性の引数に new を書けるようにする」機能です。= null で受けて中で差し替える、という定番の書き方を減らせます。
地味な機能なのですが、コンストラクタの見た目がかなり変わるので、私はけっこう気に入っています。ただ「書ける場所」と「いつ評価されるか」を知らないまま使うと、意図しない挙動やコンパイルエラーで足を止められます。実務で引っかかったところを中心にまとめておきます。
= null で受けてから差し替える、をやめられる
PHP 8.0までは、デフォルトの依存を持たせたいとき、こう書くしかありませんでした。型がnullableになってしまうのが気持ち悪いところです。
class Importer
{
private LoggerInterface $logger;
public function __construct(?LoggerInterface $logger = null)
{
$this->logger = $logger ?? new NullLogger();
}
}
8.1以降はデフォルト値に直接 new を書けるので、コンストラクタ本体が消えます。プロモーテッドプロパティ(コンストラクタでの昇格)のデフォルト値も、パラメータのデフォルト値なので使えます。
class Importer
{
public function __construct(
private LoggerInterface $logger = new NullLogger(),
) {}
}
嬉しいのは、シグネチャを読むだけで「省略したら NullLogger が入る」と分かることです。型も ?LoggerInterface ではなく LoggerInterface のままでいられます。引数には名前付き引数も渡せます。
デフォルト値はいつ評価されるのか?
ここが一番の勘所です。パラメータのデフォルト値は、その引数が明示的に渡されなかった呼び出しのたびに、左から順に評価されます。つまり「一度作ったオブジェクトが使い回される」わけではありません。
class Counter
{
public static int $created = 0;
public function __construct()
{
self::$created++;
}
}
function work(Counter $c = new Counter()): void {}
work();
work();
var_dump(Counter::$created); // int(2)
デフォルト値としてのオブジェクトは呼び出しごとに新しく作られる、と覚えておけば事故りません。逆に言うと、コストの重いオブジェクトをデフォルトに置くのは避けたほうがいいです。共有したいなら、static変数の初期化子に置くのが素直です。こちらは制御が宣言に到達したときに一度だけ評価されます。
function defaultLogger(): LoggerInterface
{
static $logger = new NullLogger(); // 初回に一度だけ評価される
return $logger;
}
null を明示的に渡していた呼び出し側が壊れる
既存コードを ?Logger $logger = null から Logger $logger = new NullLogger() に書き換えるとき、私がまず踏んだのがこれでした。デフォルト値は「引数が渡されなかったとき」にしか使われないので、null を明示的に渡している呼び出しは素通りせずTypeErrorになります。
new Importer(); // OK: NullLogger が入る
new Importer(null); // TypeError: LoggerInterface が期待されている
「nullを渡す=デフォルト」という暗黙の慣習でコードを書いていた現場ほど刺さります。移行するなら、呼び出し側で null を渡している箇所を先に洗い出しておいたほうが安全だと思います。
どこには書けないのか
書ける場所は、パラメータのデフォルト値(昇格プロパティ含む)・static変数の初期化子・グローバル定数の初期化子・属性の引数の4つだけです。プロパティの初期化子とクラス定数では、今のところ使えません。
class Service
{
// Fatal error: New expressions are not supported in this context
private LoggerInterface $logger = new NullLogger();
// クラス定数も同様にコンパイル時エラーになる
const DEFAULT_LOGGER = new NullLogger();
}
理由もはっきりしていて、プロパティ初期化子はオブジェクト生成のたびに評価する必要があるため、newInstanceWithoutConstructor() やアンシリアライズのようにコンストラクタを通らない経路で副作用が走ってしまう、という問題があります。クラス定数と静的プロパティのほうは、初期化子が「クラスが最初に使われたとき」に遅延評価される仕様なので、副作用を含む式を許すと評価順が読めなくなる、という話です。将来的に解禁される可能性はありますが、当面はコンストラクタ側に寄せるしかありません。
逆に、名前空間直下のグローバル定数では書けます。こちらは宣言に到達した時点で評価されます。
const DEFAULT_TZ = new DateTimeZone('Asia/Tokyo');
なぜ動的なクラス名は禁止なのか
書ける場所であっても、new の書き方には制限があります。定数式として解決できるものだけ、というのが基準です。
function test(
$a = new (CLASS_NAME)(), // 動的なクラス名: NG
$b = new class {}, // 匿名クラス: NG
$c = new Foo(...$args), // 引数のアンパック: NG
$d = new Bar($value), // 変数を引数に: NG
) {}
いずれもコンパイル時に確定できない、あるいは定数式として扱えない形なので弾かれます。実行時エラーではなくコンパイル時に落ちるので、気づかず本番に出ることはありません。そこは安心していい部分です。
属性のネストという副産物
このRFCの副産物として、属性の引数にも new が書けるようになりました。結果として、属性の中に属性を入れ子にする書き方ができます。バリデーションの定義などで見かける形です。
#[Assert\All(new Assert\NotNull(), new Assert\Length(max: 6))]
private array $codes;
ただし属性の引数は、ReflectionAttribute::getArguments() や newInstance() を呼ぶたびに評価されます。属性からオブジェクトを取り出してキャッシュせず何度も読むと、そのぶん生成が走ります。フレームワークの内側でループから呼んでいるようなときは、一応意識しておくといいかもしれません。
まとめ
new in initializers は、「デフォルトの依存をシグネチャに書く」ためのもの、と割り切ると使いどころがはっきりします。nullableを外せて、コンストラクタ本体が空になり、APIの契約が読みやすくなる。そのかわり、デフォルト値は呼び出しごとに評価されること、明示的なnullは救ってくれないこと、プロパティやクラス定数では使えないこと、この3つだけ頭に入れておけば大きな事故にはなりません。私は、軽量なNullオブジェクトやデフォルト設定のような「作るのが安いもの」に限って使う、という運用に落ち着いています。
よくある質問
Q. デフォルト値のオブジェクトは呼び出し間で共有されますか?
A. されません。引数が省略された呼び出しのたびに、新しくインスタンスが作られます。共有したい場合は static 変数の初期化子に置くか、DIコンテナから注入してください。
Q. プロパティの初期化子でも使えますか?
A. 使えません。プロパティ初期化子とクラス定数は対象外で、書くとコンパイル時にエラーになります。デフォルトの依存はコンストラクタの引数側で表現するのが現状の答えです。
Q. PHP 8.0以下でも同じことはできますか?
A. 言語機能としては使えないので、?Logger $logger = null で受けて $logger ?? new NullLogger() で補う従来の書き方になります。挙動は近いですが、nullを明示的に渡せてしまう点だけが違います。