タイトルだけで中身を99%表していますが、PHP7.4でのプロパティ型指定に続き、クラス定数にも型指定が可能となります。
PHPの型システム、どんどん隙がなくなりつつありますね。
class C {
const string TEST = "Test";
}
これだけだと一見何の役にも立たないように見えますが、役に立つのはextendsやimplementsが混ざってきたときです。
ということで以下は該当のRFC、Typed class constantsの紹介です。
PHP RFC: Typed class constants
Introduction
PHPは型システムの改良に多大な努力が払われ続けていますが、いまだ定数に型宣言することができません。
グローバル定数については型宣言はさほど必要ありません。
しかしクラス定数については、バグや混乱の元となることがありえます。
親クラスや定数がfinalで定義されていないときは、子クラスで親クラスの定数を上書きすることができるため、クラス定数の型がぱっとわからない場合があります。
interface I {
const TEST = "Test"; // 常に文字列を想定してる
}
class Foo implements I {
const TEST = []; // でも配列で上書きできてしまう
}
class Bar extends Foo {
const TEST = null; // nullにもできる
}
クラス定数をfinalにすることなく、型だけを制限することは有用でしょう。
Proposal
本RFCでは、class・interface・trait・enumなどのクラス定数に型を宣言できるようにします。
enum E {
const string TEST = "Test1"; // E::TEST はstring型
}
trait T {
const string TEST = E::TEST; // T::TEST はstring型
}
interface I {
const string TEST = E::TEST; // I::TEST はstring型
}
class Foo implements I {
use T;
const string TEST = E::TEST; // Foo::TEST はstring型以外にできない
}
class Bar extends Foo {
const string TEST = "Test2"; // Bar::TEST はstring型以外にできないが、中身の変更はできる
}
Supported types
クラス定数の型には、PHPがサポートする全ての型を指定可能です。
ただしvoid
・callable
・never
は除きます。
void
・callable
は、プロパティ型指定と同じ理由で対象外になりました。
never
は適用可能な型ではありません。
Strict and coercive typing modes
型チェックは常に行われるため、strict_types
の設定値は動作に影響を与えません。
これは型付きプロパティと同じ挙動です。
Inheritance and variance
クラス定数は共変です。
すなわち、継承するときに広げることはできません。
ただし親のクラス定数がprivateである場合は、自由な型を指定可能です。
クラス定数ではUNION型、交差型、DNF型などもサポートされます。
以下に例を挙げます。
trait T {
public const ?array E = [];
}
class Test {
use T;
private const int A = 1;
public const mixed B = 1;
public const int C = 1;
public const Foo|Stringable|null D = null;
public const array E = []; // NG This is illegal since the type cannot change when T::E is redefined
}
class Test2 extends Test {
public const string A = 'a'; // OK 親がprivateなのでなんでも指定可能
public const int B = 0; // OK intはmixedのサブタイプ
public const mixed C = 0; // NG 広げることはできない
public const (Foo&Stringable)|null D = null; // OK 親より狭い
}
enum E {
public const static A = E::Foo; // OK This is legal since constants provide a covariant context
case Foo;
}
class Foo implements Stringable {
public function __toString() {
return "";
}
}
クラス定数が共変である理由は、読み取り専用だからです。
Constant values
定数値は、クラス定数の型と一致しなければなりません。
唯一の例外は、float型が整数を受け入れることです。
class Test {
// OK
public const string A = 'a';
public const int B = 1;
public const float C = 1.1;
public const bool D = true;
public const array E = ['a', 'b'];
// これもOK
public const iterable F = ['a', 'b'];
public const mixed G = 1;
public const string|array H = 'a';
public const int|null I = null;
// 例外としてOK
public const float J = 1;
// これはNG
public const string K = 1;
public const bool L = "";
public const int M = null;
}
定数値がコンパイル時に評価されない式である場合は、コンパイル時にチェックされません。
定数を更新する際にチェックされます。
このチェックは、クラスをインスタンス化したときか、クラス定数を読み出した際に行われます。
そのため、以下のコードは許されます。
class Test {
public const int TEST1 = C;
}
define('C', 1);
echo Test::TEST; // 1
この場合、正しくない型の値をdefineすると、new Test()
したときかTest::TEST
を読み込んだときに初めてTypeErrorが発生します。
Reflection
ReflectionClassConstantクラスに2メソッドが追加されます。
class ReflectionClassConstant implements Reflector {
public function getType(): ?ReflectionType {}
public function hasType(): bool {}
}
getType()
は、クラス定数に型がある場合はReflectionTypeを、なければnullを返します。
hasType()
は、クラス定数に型がある場合はtrueを、なければfalseを返します。
この挙動は、引数・プロパティに対するgetType()/hasType()
、返り値に対するgetReturnType()/hasReturnType()
と同じです。
Backwards incompatible changes
後方互換性のない変更点はありません。
Impact on extensions
拡張機能への影響はありません。
互換性を保つため元々のzend_declare_class_constant_ex()
はそのままで、新しくzend_declare_typed_class_constant()
関数が追加されました。
Future scope
この項目は将来の展望であり、本RFCには含まれません。
現在、クラス定数の値を自身のインスタンスにすることはできません。
class A {
public const CONST1 = C;
}
const C = new A(); // Error: Undefined constant "C"
これはNew in initializersの知られざる制限です。
いまのところ、selfやstatic、そしてクラス名そのものをクラス定数に使うことができません。
いっぽうENUMでは使用することができます。
enum E {
public const E CONST1 = E::Foo;
public const self CONST2 = E::Foo;
public const static CONST3 = E::Foo;
case Foo;
}
Vote
投票期間は2023/02/27から2023/03/13であり、2/3の賛成で受理されます。
本RFCは賛成24反対0の全会一致で可決されました。
感想
RFCを読む限りではこうなると思うんだけど、
trait T {
public const ?array E = [];
}
class C {
public const ?array F = [];
}
class Test extends C{
use T;
public const array E = []; // NG
public const array F = []; // OK
}
何故extendsはOKでtraitはNGなのかよくわからない。
traitの場合、メソッドは定義を完全に無視して自由に上書きすることができます。
trait T {
public function E():?array{};
}
class C {
public function E():?array{};
}
class Test extends C{
use T;
public function E():string{}; // OK
public function F():string{}; // NG
}
メソッドは自由なのに定数は変更不可なのはなぜに?
ということでこのへんよくわかりませんでした。
とはいえ基本的にはメソッドの継承と同じ挙動になるみたいなので、便利になる使いやすい変更ではないかと思います。
ただなんというかこう、そもそも定数値を継承先で変更するという運用自体が気持ち悪いというか、個人的に気に入らないんだよな。
おまえは定数なんだからそんな簡単に相手に影響されたりせず、堂々といつまでも変わらずにいろって思うわけですよ。