81
33

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 3 years have passed since last update.

【PHP8.1】読めるけど書けないプロパティが作れるようになる

Posted at

新潟←ちょっとこれを読んでみてください。

読めましたね。

ではちょっと画面から目を離して、niigata←これを漢字で書いてみてください。

8割くらいの人は書けなかったんじゃないかなと思います。

ということで、読めるけど書けない漢字になぞらえて、読めるけど書けないプロパティ新潟アクセス修飾子と呼ばれています。

	echo $object->property; // できる
	$object->property = 1; // できない

もちろんこの呼称はネタですが、でも実際protectedやprivateを使う理由の98%は『読まれてもいいけど書き込ませたくない』だけじゃないですか。
そのためだけにいちいち値を出し入れするだけのsetter/getterを作ったり__set/__getあたりでなんか技巧を凝らしたりとか無駄なことをやっていたわけですが、ついにPHP本体でこの制御が可能になります。

以下はReadonly properties 2.0のRFCの紹介です。

Readonly properties 2.0

Introduction

このRFCでは、初期化した後のプロパティ変更を防ぐreadonlyプロパティ修飾子を導入します。

バリューオブジェクトはしばしばimmutableです。
コンストラクタでいちど初期化され、その後は変更できないようにされます。
PHPでは現在、この制約を強制する方法がありません。
今のところもっとも近い制約は、privateで宣言してpublicなgetterを公開することです。

class User {
    public function __construct(
        private string $name
    ) {}
 
    public function getName(): string {
        return $this->name;
    }
}

実際にプロパティが読み取り専用になるわけではありませんが、修正が必要な範囲を一か所に限定することができます。
しかし残念ながらgetterを定義する必要があるため、人間工学的によろしくない構造となります。

読み取り専用のプロパティをファーストクラスでサポートすることによって、publicかつreadonlyなプロパティを直接公開できるようになります。
外部からの干渉によってimmutableが破られる心配はありません。

class User {
    public function __construct(
        public readonly string $name
    ) {}
}

Proposal

readonlyプロパティ修飾子は、同じスコープから一度だけ初期化することができます。

class Test {
    public readonly string $prop;
 
    public function __construct(string $prop) {
        // OK
        $this->prop = $prop;
    }
}
 
$test = new Test("foobar");
// 読み込みはOK
var_dump($test->prop); // string(6) "foobar"
 
// 書き込みはNG
$test->prop = "foobar"; // Error: Cannot modify readonly property Test::$prop

クラス外からの初期化は許されません。

class Test {
    public readonly string $prop;
}
 
$test = new Test;
$test->prop = "foobar"; // Error: Cannot initialize readonly property Test::$prop from global scope

単純な代入だけではなく、以下のような変更、参照操作も全て禁止されます。

class Test {
    public function __construct(
        public readonly int $i = 0,
        public readonly array $ary = [],
    ) {}
}

$test = new Test; // ↓は全てNG
$test->i += 1;
$test->i++;
++$test->i;
$test->ary[] = 1;
$test->ary[0][] = 1;
$ref =& $test->i;
$test->i =& $ref;
byRef($test->i);
foreach ($test as &$prop);

ただし、オブジェクトの内部値は変更可能です。

class Test {
    public function __construct(public readonly object $obj) {}
}
 
$test = new Test(new stdClass);

$test->obj->foo = 1; // これはOK

$test->obj = new stdClass; // オブジェクト自体の変更はNG

Restrictions

readonly修飾子は、型付きプロパティにのみ適用可能です。
理由として、型付きではないプロパティは暗黙のデフォルト値nullを持っているので、これが初期化と判定されてしまうためです。

型制約を付けたくないプロパティにreadonly修飾子を適用したいときは、PHP8.0で導入されたmixed型を使うことができます。

class Test {
    public readonly mixed $prop;
}

代替案として、readonly修飾子がついた方のついていないプロパティには暗黙のデフォルト値nullを適用しないという案もありました。
しかしこちらは暗黙のデフォルト値に関するルールが複雑になってしまい混乱するため、単純に禁止することとしました。

また、readonly修飾子のついたプロパティにデフォルト値を指定することはできません。

class Test {
    public readonly int $prop = 42; // Fatal error: Readonly property Test::$prop cannot have default value
}

これは単なる定数であり、特に有用なものではありません。
将来的にプロパティデフォルト値として式が許可されるようになれば、readonly修飾子のデフォルト値も有用になるかもしれません。

コンストラクタ引数昇格におけるプロパティデフォルト値は、プロパティではなく引数に適用されます。

class Test {
    public function __construct(
        public readonly int $prop = 0,
    ) {}
}
 
// ↑は↓と同じ
 
class Test {
    public readonly int $prop;
 
    public function __construct(int $prop = 0) {
        $this->prop = $prop;
    }
}

このプロパティはデフォルト値を持っていないため、コンストラクタで初期化することができます。

readonly staticプロパティはサポートされていません。
これが有用であるかは疑問であり、現時点ではこの機能をサポートする価値はないと考えています。

Inheritance

継承時に、readonlyプロパティをついていないプロパティで上書きしたり、その逆を行うことはできません。
以下の例は両方とも禁止されます。

// 通常プロパティからreadonlyに
class A {
    public int $prop;
}
class B extends A {
    public readonly int $prop; // Illegal: readwrite -> readonly
}

// readonlyから通常プロパティに
class A {
    public readonly int $prop;
}
class B extends A {
    public int $prop; // Illegal: readonly -> readwrite
}

子クラスでのreadonly追加を禁止する必要があるのは明らかです。
なぜならば、親クラスで実行した操作がエラーになる可能性があるからです。

子クラスでreadonlyを解除すると、親クラスが想定していた不変性が破壊されます。
従って、readonly修飾子は継承時に追加も削除もできません。

class A {
    public readonly int $prop;
}
class B extends A {
    public readonly int $prop;
}

上記例では、AとB両方でプロパティ宣言されており、どちらのクラスからでも初期化が可能です。

同じプロパティが2つのトレイトからインポートされる場合、readonlyも一致しないといけません。

trait T1 {
    public readonly int $prop;
}
trait T2 {
    public int $prop;
}
class C {
    use T1, T2; // Illegal: Conflicting properties.
}

readonlyプロパティの型は不変です。
共変であると考えることもまあできます。

class A {
    public readonly int|float $prop;
}
class B extends A {
    public readonly int $prop;
}

共変はプロパティの読み込みには有効ですが、初期化の場合はAとB両方から初期化可能可能なため、あまり意味がありません。
将来的に共変セマンティクスの緩和を考慮する可能性はあります。

Unset

一度初期化されると削除はできません。

class Test {
    public readonly int $prop;
 
    public function __construct() {
        $this->prop = 1;
        unset($this->prop); // Error: Cannot unset readonly property Test::$prop
    }
}

ただし、初期化する前であれば削除することが可能です。
その後は通常のプロパティ同様、マジックメソッド__get等が反応するようになり、遅延初期化などを行えるようになります。

class Test {
    public readonly int $prop;
 
    public function __construct() {
        unset($this->prop);
    }
 
    public function __get($name) {
        if ($name === 'prop') {
            $this->prop = $this->lazilyComputeProp();
        }
        return $this->$name;
    }
}

Reflection

ReflectionProperty::isReadOnly()が追加され、プロパティがreadonlyか否かを確認できるようになります。
ReflectionProperty::getModifiers()が、ReflectionProperty::IS_READONLYフラグを返すようになります。

ReflectionProperty::setValue()は、同じスコープからしか初期化できないという制限を回避し、初期化することができます。
ただし、既に初期化されたプロパティを変更することはできません。

Serialization

readonlyはシリアライズに影響を与えません。
__unserialize()はコンストラクタを実行しないため、readonlyプロパティは初期化されていない状態となり、__unserialize()で任意に初期化することができます。

同様にReflectionClass::newInstanceWithoutConstructor()などでコンストラクタを回避した場合は、readonlyプロパティは初期化されません。

Rationale

このRFCで導入されるreadonlyプロパティは、クラスの内部と外部両方に、強力な不変性の保証を提供します。
一度初期化されたプロパティは、いかなる方法を用いても変更することはできません。
間にどのような処理を行おうと、readonlyプロパティは常に同じ値を返します。

class Test {
    public readonly string $prop;
 
    public function method(Closure $fn) {
        $prop = $this->prop;
        $fn(); // ここにどんな中身を書いたとしても
        $prop2 = $this->prop;
        assert($prop === $prop2); // 必ずtrue
    }
}

この保証は、一部のユースケースについては強力すぎるかもしれません。
たとえば、外部からは読み込み専用だが、内部からは書き込み可能なプロパティがほしいといった場合です。
これはオブジェクトの生存期間中にプロパティ値が変更される可能性があるため、readonlyよりは弱い保証となります。
いずれの形式も状況に応じて有用であり、readonlyが追加されたからといって、そのようなプロパティの追加が妨げられるわけではありません。

該当のケースの使用例を挙げておきます。

class Point {
    public function __construct(
        public readonly float $x,
        public readonly float $y,
        public readonly float $z,
    ) {}
 
    public function withX(float $x): static {
        // これは動く
        return new static($x, $this->y, $this->z);
        
        // こっちは動かない
        $clone = clone $this;
        $clone->x = $x;
        return $clone;
    }
}

cloneでの実装は、cloneしたオブジェクトのプロパティ$xは既に初期化済みのため、エラーになります。

Backward Incompatible Changes

readonlyキーワードが予約語になります。

読み込み可能なプロパティは全て書き込み可能でもあるという前提で書かれている既存のコードは動作しなくなる可能性があります。

Vote

投票期間は2021/07/01から2021/07/15まで、投票者の2/3の賛成で受理されます。

2021/07/12時点では賛成36反対11の賛成多数であり、おそらく受理されます。

感想

コンストラクタで一度だけ書き込んで、その後は読み取り専用にしたい、という極めてよくある要望が、ようやく言語レベルでサポートされます。

constだとスカラー式しか書けないのでnew HOGE()とか書けない、privateprotectedは外から見れないので使い勝手が悪い、publicは好き勝手されるといった具合で、ちょうどいいレベルの制限がありませんでした。
今後はreadonlyを使うことで、プロパティが変な値で上書きされてる!とか気にしなくてよくなります。

コンストラクタ引数昇格やデフォルトnewあたりと組み合わせることで、とてもいいかんじのオブジェクト初期化が書けるようになるでしょう。

始まる前に終わったかもしれないプロパティアクセサ構文

実は似たようなRFCとしてもうひとつProperty Accessorsがありまして、どちらかというとこっちのほうに注目していたんですよね。
こちらはreadonlyの完全上位互換であり、さらなる高度な制御をかますことが可能になります。

class Test {
    public int $prop {
        get { return 42; }
        private set
    }
}

C#のプロパティアクセサとだいたい同じ構文です。

しかしまあ実際、プロパティアクセサ構文のユースケースの9割はreadonlyなわけですよ。
従ってreadonlyが実装された後になってから、それほど使われない機能を新たな構文を突っ込んでまでProperty Accessorsを投入するのかと考えると、このRFCの行き先には怪しいものがありますね。

怪しいというか、元々Property AccessorsのRFCが先にあったんだけど、このRFCは色々と問題があったので機能限定版としてreadonlyが作られた、というのが実際の経緯です。
何が問題って、Property Accessorsはエッジケースがすごいことになっていて、相当に巨大で複雑な規模のものになってしまっているのです。
どのくらいかってあのNikitaが弱音を漏らすくらい

このRFCの提案と実装に力を注いできたんだけど、この方向性が正しいのか不安になってきました。
当初の予定よりスコープを縮小したにも関わらず、それでも予想していたよりもはるかに複雑で、さらにまだ気付いていない副作用もあるかもしれません。
その複雑さに見合う価値があるか疑問です。

そんなわけで10割のユースケースを満たす完璧かつ複雑な機能ではなく、9割のユースケースを満たす使いやすく簡潔な機能が実装されることになりました。
妥当な落としどころではないでしょうか。

81
33
1

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
81
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?