PHP 8.3の型付きクラス定数で、子クラスの上書きを型で縛る

型付きクラス定数とは、PHP 8.3から使える const string FOO = 'bar'; のような書き方で、クラス定数に型を宣言し、子クラスが違う型で上書きするのを防ぐ機能です。

定数に型なんて要るのか、と最初は思いました。定数は値が決まっているのだから、型は見れば分かる。実際、自分のクラスの中だけで完結しているならその通りだと思います。

問題は継承とインターフェースが絡んだときですね。親が「これは文字列のつもり」で置いた定数を、子が平気で配列に変える。そういうコードを、私は何度か踏んでいます。

なぜ定数に型が必要なのか

PHP 8.2までのクラス定数は、子クラスで完全に自由に上書きできました。型どころか、まったく別物にできてしまう。

<?php
class Formatter
{
    // 区切り文字。文字列のつもりで置いた
    public const SEPARATOR = ', ';

    public function join(array $items): string
    {
        return implode(static::SEPARATOR, $items);
    }
}

class BrokenFormatter extends Formatter
{
    // うっかり配列にしてしまった。PHP 8.2まではこれが通る
    public const SEPARATOR = [', '];
}

(new BrokenFormatter())->join(['a', 'b']);
// 実行して初めて TypeError(implode() の第1引数が string ではない)

宣言の時点では何も起きず、join() を呼んだ瞬間に落ちます。原因の行と壊れた行が離れているので、追いかけるのが地味に面倒でした。

型を書くと、いつ落ちるかが変わる

PHP 8.3以降は const の直後に型を書けます。狙いは「実行時ではなくコンパイル時に落とす」ことです。

<?php
class Formatter
{
    public const string SEPARATOR = ', ';
}

class BrokenFormatter extends Formatter
{
    public const string SEPARATOR = [', '];
}
// Fatal error: Cannot use array as value for class constant
// BrokenFormatter::SEPARATOR of type string

クラスを読み込んだ時点で止まります。メソッドを呼ぶ前に分かるので、原因の特定が一気に楽になりますね。public/protected/final と併用でき、インターフェース・トレイト・enum の定数にも書けます。

型は狭められるが、広げられない

戻り値型と同じ共変のルールが効きます。親の型を子が狭めるのはOK、広げるのはNGです。

<?php
class ParentClass
{
    public const string|int VALUE = 'default';
}

class NarrowChild extends ParentClass
{
    public const string VALUE = 'ok';   // 狭めるのはOK
}

class WideChild extends ParentClass
{
    public const string|int|float VALUE = 1.0;  // 広げるのはNG
}
// Fatal error: Type of WideChild::VALUE must be compatible with
// ParentClass::VALUE of type string|int

「親が許した範囲より外に出ない」ことが保証されるので、親側のコードが static::VALUE を使っていても安心して書けます。

いちばんのハマりどころは「型を省略すると落ちる」

ここが実務で効いてくるところです。親が型付きで宣言したら、子は型を省略できません。省略は「型なし」ではなく「非互換」と判定されます。

<?php
class ParentClass
{
    public const string|int VALUE = 'default';
}

class ChildClass extends ParentClass
{
    public const VALUE = 'ok';  // 型を書かなかっただけ
}
// Fatal error: Type of ChildClass::VALUE must be compatible with
// ParentClass::VALUE of type string|int

つまり、既存のライブラリやフレームワークの基底クラスに後から定数の型を足すのは、後方互換を壊す変更になります。継承される前提のクラスを公開している場合は、マイナーバージョンで気軽に入れないほうがいいと思います。逆に、アプリ内の閉じたクラス階層なら遠慮なく入れていい類のものですね。

strict_typesは関係ない

もうひとつ、地味に間違えやすい点があります。定数の型チェックは declare(strict_types=1) の有無に関係なく、常に厳格です。

<?php
declare(strict_types=0);

class Test
{
    const string LABEL = 1;  // 1 は '1' に変換されない
}
// Fatal error: Cannot use int as value for class constant
// Test::LABEL of type string

引数や戻り値の感覚で「非strictなら通るだろう」と考えると外します。定数の値は暗黙変換されない、と覚えておくのが安全です。

書けない型と、Reflection

指定できるのは、通常の型・null許容型・ユニオン型・交差型・DNF型です。使えないのは voidnevercallable の3つ。前2つは戻り値専用の型で、callable は文脈依存のため型付きプロパティでも使えないのと同じ理由です。なお Closure 型は書けます。

Reflection側にも追加があり、定数の型を後から取り出せます。属性ベースの設定を読むようなコードで、たまに使いどころがあります。

<?php
class Config
{
    const string DRIVER = 'mysql';
    const TIMEOUT = 30;  // 型なし
}

$typed = new ReflectionClassConstant(Config::class, 'DRIVER');
var_dump($typed->hasType());          // bool(true)
echo (string) $typed->getType(), "\n"; // string

$plain = new ReflectionClassConstant(Config::class, 'TIMEOUT');
var_dump($plain->hasType());          // bool(false)
var_dump($plain->getType());          // NULL

hasType() で型宣言の有無、getType() は型がなければ null を返します。型があるかどうかを見てから getType() を触る、という順番になりますね。

どこまで入れるか

個人的には、継承やインターフェース実装が絡む定数だけに絞って付けています。単独クラスの中で完結する定数にまで全部書くと、記述量の割に得るものが少ない気がしています。

逆に、テンプレートメソッド的に static::SOMETHING を親が参照している設計なら、これは付けておく価値があります。子クラスの実装者に「ここは文字列で埋めてください」と、コメントではなく型で伝えられるので。

まとめ

型付きクラス定数は、子クラスの上書きが型を壊さないことをコンパイル時に保証してくれる機能です。型は狭められても広げられず、子で型を省略すると非互換エラーになる。この「省略できない」性質のせいで、公開ライブラリの基底クラスに後付けするのは破壊的変更になります。strict_typesとは無関係に常に厳格、という点も含めて、入れる場所を選べば素直に効く機能だと思います。

よくある質問

Q. 型付きクラス定数はどのバージョンから使えますか?
A. PHP 8.3以降です。8.2以前で書くとパースエラーになるため、実行環境のバージョンを確認してから導入してください。

Q. 親クラスの定数に型を付けたら、子クラスも直す必要がありますか?
A. 必要です。子で型を省略すると「非互換」と判定されて致命的エラーになります。継承ツリー全体をまとめて直せる範囲かどうかが、導入判断の分かれ目になります。

Q. enumやインターフェースの定数にも型を書けますか?
A. 書けます。クラス、インターフェース、トレイト、enumのいずれの定数でも型宣言が可能です。

類似投稿

コメントを残す

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