PHP
rfc
PHP7
PHP7.4

ついにPHPにプロパティ型指定がやってくる

Typed Properties 2.0というRFCが投票フェーズに入ったのですが、2018/09/13時点で賛成48反対0となっていて、ほぼ決まりの状態です。

Typed Properties 2.0

どういうRFCなのかというと、これです。

class User {
    public int $id;
    public string $name;

    public function __construct(int $id, string $name) {
        $this->id = $id;
        $this->name = $name;
    }
}

プロパティに型を強制することができるようになります。

導入

PHP7.4で導入される予定。

構文

class Example {
    // 対象型はvoidとcallable以外全て
    public int $scalarType;
    protected ClassName $classType;
    private ?ClassName $nullableClassType;

    // staticにも指定可能
    public static iterable $staticProp;

    // varにも使える
    var bool $flag;

    // デフォルト値を指定
    public string $str = "foo";
    public ?string $nullableStr = null;

    // 複数のプロパティを指定
    public float $x, $y;
    // ↑は↓と同じ
    public float $x;
    public float $y;
}

型指定したプロパティは、宣言した型を満たす値が入っているか、もしくはTypeErrorを発するかのいずれかになります。

使用可能な型

voidとcallable以外の全てが指定可能です。

bool, int, float, string, array, object
iterable
self, parent
任意のクラスおよびインターフェイス名
?type // null許容型

voidは意味がないうえ、PHPからvoid型の値を設定・取得する方法がありません(取得するとnullになる)。
そのため対象外となりました。

callableは実行可能かどうかがコンテキストによって決まるため、対象外とされました。

class Test {
    public callable $cb;

    public function __construct() {
        // ここからは呼べる
        $this->cb = [$this, 'method'];
    }

    private function method() {}
}

$obj = new Test;
// ここからは呼べない
($obj->cb)();

ちなみに、このあたりをどうにかしようというRFCが別途提出されています

strict_typesの影響

strict
declare(strict_types=1);

class Test {
    public int $val;
}

$test = new Test;
$test->val = "42"; // TypeError
not_strict
declare(strict_types=0);

$test = new Test;
$test->val = "42";
var_dump($test->val); // int(42)

せっかくの型宣言ですが、値は暗黙の型変換の影響を受けます。
つまり、intなフィールドに"42"を突っ込んでもTypeErrorが起こらず値は42になってしまいます。
厳密な型チェックを行いたい場合はstrict_typesを指定しましょう。

継承と分散

もちろん子クラスで型の変更はできません。

class A {
    private bool $a;
    public int $b;
    public ?int $c;
}
class B extends A {
    public string $a; // parent::$aはprivateなので別プロパティになる
    public ?int $b;   // NG
    public int $c;    // NG
}

親クラスのプロパティがprivateである場合は、単に別のプロパティになるだけなので異なる型を設定可能です。
とはいえ同じ名前で異なる型というのはわかりにくくなるだけなので、やめておいたほうが無難でしょう。

またstaticプロパティについては異なる型は禁止されます。

class A {
    public static bool $a;
}
class B extends A {
    public static int $a; // NG
}

本来これは別の値となるはずなのですが、遅延静的束縛などを使うと簡単に乗り越えられてしまうので禁止となりました。

trait T1 {
    public int $prop;
}
trait T2 {
    public string $prop;
}
class C {
    use T1, T2; // NG
}

トレイトも同名プロパティの異なる型指定はNGです。
insteadofやasを使えばいいということもなく、同時にuseしようとする時点で駄目なようです。

デフォルト値

通常のプロパティと同じくデフォルト値を指定できますが、当然ながら型に合わせないといけません。

class Test {
    // OK
    public bool     $a = true;
    public int      $b = 42;
    public float    $c = 42.42;
    public float    $d = 42; // 特別にOK
    public string   $e = "str";
    public array    $f = [1, 2, 3];
    public iterable $g = [1, 2, 3];
    public ?int     $h = null;
    public ?object  $i = null;
    public ?Test    $j = null;

    // デフォルト値を設定できない
    public object   $k;
    public Test     $l;

    // NG
    public bool     $m = 1;
    public int      $n = null;
    public Test     $o = null;
}

メソッドの型引数と同様、NULL許容型でないかぎりNULLは禁止されます。

object型はデフォルト値を設定する方法がないので、常に未定義となります。
Javaなどでは定義と同時にnewとか書けますが、PHPでは値や定数しか置くことができません。

デフォルト値をコンパイル時に評価できない場合、実行時に初めてチェックされます。
つまり以下のコードは許可されます。

class Test {
    public int $prop = FOO;
}

define('FOO', 42);
new Test;

define('FOO', 'hoge')などとした場合は実行時にTypeErrorが発生します。

初期値、unset()

デフォルト値を設定しない場合の初期値はnullではなく、未初期化という状態です。

class Test {
    public int $val;
}

$test = new Test();
var_dump($test->val); // TypeError

未初期化プロパティを参照するとTypeErrorが発生します。
安全な運用のためには、デフォルト値を書いておくか、コンストラクタで必ず設定するようにしておくべきでしょう。

またvar_dumpすると特別なuninitialized値になります。

object(Test)#1 (0) {
  ["val"]=>
  uninitialized(int)
}

print_rやvar_exportでどうなるかは書かれていませんでした。

オーバーロード

型指定プロパティをunset()すると、未定義ではなく未初期化状態に戻ります。
ちなみに通常のプロパティはunsetすると消滅します。

class Test {
    public int $typed;

    public function __construct() {
        unset($this->typed); // uninitializedになる
    }

    public function __get($name) {
        if ($name === 'typed') {
            return $this->typed = $this->computeValue(); // なんか値を入れる
        }
    }
}

$test = new Test;
var_dump($test->typed); // __getが呼ばれる
var_dump($test->typed); // 呼ばれない

未初期化プロパティはアクセス不能とみなされるので、いきなり参照しようとすると__get()が呼び出されます。
値を入れるとアクセスできるようになるので__getは呼ばれなくなります。

なお、プロパティに値を入れなかったとしても、__getは正しい型の値を返さなければTypeErrorになります。

class Test {
    public int $val;

    public function __get($name) {
        return "not an int";
    }
}

$test = new Test;
var_dump($test->val); // TypeError

これはわりと意外ですね。

間接的な型変更

PHPの暗黙型変換が悪さをすることもあります。

class Test {
    public int $x;
}

$test = new Test;
$test->x = PHP_INT_MAX;
$test->x++; // TypeError

PHP_INT_MAX+1はfloat型に自動型変換されるため、TypeErrorが発生します。

リファレンス

リファレンスは使うな!!!!

残念ながら型指定プロパティはリファレンスに対応しています。
RFCでは以下のようなコードが例示されています。

class Test {
    public array $ary = [3, 2, 1];
}
$test = new Test;
sort($test->ary);

sortの引数はリファレンス渡しなので、リファレンスが効かないとこのような書き方ができません。
ということなのだけど、そもそもこんな使い方するなって話だな。

リファレンスを使うと型合成みたいなことができます。

class Test {
    public ?int $i;
    public ?string $s;
}

$r = null;
$test->i =& $r;
$test->s =& $r;

ここで$rには?int型かつ?string型の値しか代入できないことになります。
?int型かつ?string型を満たす値はnullしかないのでこの例は役に立たないのですが、うまく使えば菱形継承みたいなこともできるかもしれません。
そんな奇天烈なことするなよ、絶対するなよ。

自動初期化

PHPでは未定義プロパティを参照すると該当プロパティが自動的に作成され、値がnullになります。

$test = new stdClass();
$b =& $test->a; // null 
var_dump($test); // $test->aが勝手に作られる

同様に、未初期化プロパティを参照すると値がnullになります。

class Test {
    public ?int $x;
    public int $y;
}

$test = new Test;
$x =& $test->x; // $text->x = nullになる
$y =& $test->y; // TypeError

$xはnullableなので未初期化参照可能ですが、$yはnull禁止なので参照した時点でTypeErrorが発生します。
ややこしいので、未定義プロパティは参照できないようにするべきでしょう。

リフレクション

ReflectionPropertyに3メソッドが追加されます。

class ReflectionProperty {
    public function getType(): ?ReflectionType;
    public function hasType(): bool;
    public function isInitialized([object $object]): bool;
}

getTypeは型指定のある場合ReflectionTypeを、なければnullを返します。
hasTypeは型指定があればtrue、なければfalseを返します。

isInitializedはプロパティが初期化されていればtrueです。
未初期化、およびunsetされたプロパティについてはfalseになります。
publicでない場合は先にsetAccessible(true)しないとReflectionExceptionが出ます。

文法について

2種類の文法が提案されました。

class Example {
    public int $num;
    public $num: int;
}

どちらの形式がよいか検討した結果前者になりました。
前者はCやJavaなどの古い言語に多く、後者はTypeScriptやRustといったモダンな言語に多い文法です。
ならば後者がいいのではないかと思いますが、PHPでは複数の変数を同時に宣言できます。

class Example {
    public int $x=1, $y=2, $z=3;
    public $x=1: int, $y=2: int, $z=3: int;
}

前者の方が楽ですね。

下位互換性

既存のPHPコードに影響が生じることはありません。

エクステンションへの影響

エクステンション開発側は以下の2点について対応が必要となります。

・write_propertyシグニチャの変更
・参照割り当ての修正

詳細も書かれていますが、使用する側にとっては特に関係ないので省略。
マクロが用意されているので使うと楽になるでしょう。

パフォーマンス

各フレームワークでの実測値が出ていますが、平均して1.7%程度遅くなるようです。
これは型指定を使用しなくても発生します。
むしろ型指定導入後に高速化しているqdigやdrupalはなんなんだ。
というか勝手に-を遅くなると考えていたのだが、これ単位がないからどっちが早いのかわからないな。

投票

2/3の賛成が必要になりますが、2018/09/13時点では賛成48反対0です。
余程重大な問題でも発覚しないかぎり、逆転されることはまずないでしょう。

感想

ようやく来たか、というかんじですね。

以前却下されたTyped Propertiesで考えが足りてなかったところを悉く潰して、周到に用意されてきたという印象です。
特にcallableや参照などのややこしいところについては、非常に細かいところまで動作とその理由が説明されています。
こんなに長いRFCは初めて見た気がします。
おかげで満場一致での採択となりました。

とはいえ使う側としては、初期化とリファレンス禁止だけ徹底していれば、奇妙な動作に惑わされることはなさそうです。

ここの解説は詳細をだいぶ端折っているので、細かい挙動が知りたい場合はRFCを直接見てください。
リファレンスの連鎖とか見るのも実装するのも嫌になるレベル。