23
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【PHP8.3】クラス定数に型が書けるようになるよ

Last updated at Posted at 2023-03-20

タイトルだけで中身を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がサポートする全ての型を指定可能です。
ただしvoidcallableneverは除きます。

voidcallableは、プロパティ型指定と同じ理由で対象外になりました。
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
}

メソッドは自由なのに定数は変更不可なのはなぜに?

ということでこのへんよくわかりませんでした。

とはいえ基本的にはメソッドの継承と同じ挙動になるみたいなので、便利になる使いやすい変更ではないかと思います。

ただなんというかこう、そもそも定数値を継承先で変更するという運用自体が気持ち悪いというか、個人的に気に入らないんだよな。
おまえは定数なんだからそんな簡単に相手に影響されたりせず、堂々といつまでも変わらずにいろって思うわけですよ。

23
9
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?