プロパティフックとは何なのかというと、これです。
class HOGE{
public string $tel{
set{
if(!ctype_digit($value)){
throw new ValueError("電話番号は数値のみ");
}
if(strlen($value) < 10){
throw new ValueError("電話番号は10文字以上");
}
$this->tel = $value;
}
get{
return '電話番号は' . $this->tel;
}
}
}
$hoge = new HOGE();
$hoge->tel = '123456789012'; // OK
$hoge->tel = 'abcdefghijkl'; // Uncaught ValueError: 電話番号は数値のみ
$hoge->tel = '123'; // Uncaught ValueError: 電話番号は10文字以上
echo $hoge->tel; // "電話番号は123456789012"
プロパティにsetter/getterを直接記述できるという構文です。
これは、かつてNikita Popovが提案していたもののあまりに複雑すぎて諦めたプロパティアクセサ構文のリベンジです。
ということで以下は該当のRFC、Property hooksの紹介です。
PHP RFC: Property hooks
Introduction
大抵の開発者は、オブジェクトプロパティへのアクセスをラップするメソッドを書きます。
このロジックには汎用的なパターンが幾つか存在しますが、いずれも冗長になりがちです。
またマジックメソッド__get
・__set
を使うこともできますが、これは未定義のプロパティ全てに反応してしまうので大鉈になりすぎます。
プロパティフックは、よりターゲットを絞ったプロパティアクセスの方法を提供します。
このRFCは、かつての非対称可視性のRFCとプロパティアクセサのRFCを効果的に置き換えます。
また本実装の多くは、NikitaによるプロパティアクセサのRFCから来ています。
設計と構文はKotlinに最も似ていますが、C#とSwiftからも影響を受けています。
フックの主な使用例は、最初は使わないけど将来必要になったときに対応できる余地を確保しておくことです。
開発者はプロパティにとりあえずgetFoo/setFoo
を実装しますが、これはそれらが今すぐ必要だからではなく、今後必要になるからであり、必要になったあとでプロパティからメソッドに変更すると互換が壊れるからです。
そのため、最もよくあるgetFoo/setFoo
の使い方を、メソッドではなくプロパティでアクセスできるようにすることで、意味のない定型文を念のためで書いておく手間を省きます。
もちろん意味のないgetFoo/setFoo
の使い方だけではなく、他の利用法にとってもメリットがあります。
PHP7ではよく見かけていたクラス宣言を考えてみましょう。
class User
{
private $name;
public function __construct(string $name) {
$this->name = $name;
}
public function getName(): string {
return $this->name;
}
public function setName(string $name): void {
$this->name = $name;
}
}
PHP8.3では、同じ構文を次のように書けます。
class User
{
public function __construct(public string $name) {}
}
これはたいへん優れた記法ですが、代償として拡張性が犠牲になりました。
たとえば後から引数の検証を行いたくなっても、それを書く場所がありません。
いまのところ、以下2つの選択肢があります。
・getName()/setName()を再度追加する。BCブレークになる。
・__set()/__get()でがんばる。わかりにくく間違いやすく、静的解析ツールも見逃しやすい。
class User
{
private string $_name;
public function __construct(string $name) {
$this->_name = $name;
}
public function __get(string $propName): mixed {
return match ($propName) {
'name' => $this->_name,
default => throw new Error("Attempt to read undefined property $propName"),
};
}
public function __set(string $propName, $value): void {
switch ($propName) {
case 'name':
if (!is_string($value)) {
throw new TypeError("Name must be a string");
}
if (strlen($value) === 0) {
throw new ValueError("Name must be non-empty");
}
$this->_name = $value;
break;
default:
throw new Error("Attempt to write undefined property $propName");
}
}
public function __isset(string $propName): bool {
return $propName === 'name';
}
}
プロパティフックを使うことで、開発者にも外部ツールにもやさしいプロパティへの追加動作を導入することができます。
class User
{
public string $name {
set {
if (strlen($value) === 0) {
throw new ValueError("Name must be non-empty");
}
$this->name = $value;
}
}
public function __construct(string $name) {
$this->name = $name;
}
}
この新しい構文により、$name
の可視性を変更する必要はなく、静的解析も容易になり、複数のプロパティをひとつのメソッドでまとめて扱う必要もなくなります。
また、setter/getterメソッドを使っていると読み取りと更新に余計な記述をしなければならないことがよくあります。
class Foo
{
private int $runs = 0;
public function getRuns(): int { return $this->runs; }
public function setRuns(int $runs): void
{
if ($runs <= 0) throw new Exception();
$this->runs = $runs;
}
}
$f = new Foo();
$f->setRuns($f->getRuns() + 1);
プロパティフックでは以下のように容易に書けます。
class Foo
{
public int $runs = 0 {
set {
if ($value <= 0) throw new Exception();
$this->runs = $value;
}
}
}
$f = new Foo();
$f->runs++;
もちろんincrementRuns()
メソッドを追加して対応してもいいかもしれませんが、それは機能が限定的すぎるでしょう。
A note on the approach
このRFCは、できるかぎり堅牢さを保ったうえで機能が充実するように設計されています。
同様の機能を持つ5言語(Swift・C#・Kotlin・Javascript・Python)を分析し、またPHPのエッジケースを考慮しています。
結果としてRFCは非常に長くなりましたが、これは採用したアプローチと採用した理由を全て明示するためです。
このRFCは最小限であり、RFCを分割して一部の機能だけを先に実装するなどの余地がほとんどありません。
このRFCの設計目標は、プロパティへのフックをできるかぎり透過的にすることで、利用者がプロパティフックを意識する必要がないようにすることです。
完全に透過的にすることができない場合は、既存のマジックメソッド__get・__set
構文に従うことにしました。
マジックメソッドはプロパティフックとほぼ同じ機能を提供しますが、堅牢性や使いやすさははるかに劣ります。
このRFCの決め事はいずれも恣意的なものではありません。
このRFCは、できるだけエッジケースを生まないように設計しています。
Proposal Summary
このRFCでは、プロパティのget
・set
動作をオーバーライドするふたつのフックが導入されます。
本RFCでは対象外ですが、もしかしたら今後その他のフックもサポートされるかもしれません。
このRFCにより、これまで念のため
でわざわざ作っていたsetter/getterメソッドを作る必要がなくなり、コードも短くなり、追加要求をBCブレークなしで満たすことができるようになります。
フル構文と短縮構文の2種類の構文がサポートされます。
class User implements Named
{
private bool $isModified = false;
public function __construct(private string $first, private string $last) {}
public string $fullName {
// 読み込みをオーバーライド
get => $this->first . " " . $this->last;
// 書き込みをオーバーライド
set {
[$this->first, $this->last] = explode(' ', $value, 2);
$this->isModified = true;
}
}
}
この機能を使うことでプロパティはpublicにすることが自然になるため、interfaceでのプロパティ可視性宣言も自然になります。
interface Named
{
// implementsするクラスには読み取り可能な$fullNameプロパティが必要。
public string $fullName { get; }
}
// ↑↑の例にあるUserクラスはNamedインターフェイスを満たす。
// ↓このように書いてもいい。
class SimpleUser implements Named
{
public function __construct(public readonly string $fullName) {}
}
これらを組み合わせることで、より短く堅牢なコードを書けるようになります。
Detailed Proposal
本RFCはオブジェクトプロパティにのみ適用され、静的プロパティには適用されません。
静的プロパティは本RFCの影響を受けません。
プロパティでフックを使用する場合、末尾の;
をコードブロック{}
に書き替えます。
コードブロック内にはひとつ以上のフックを実装する必要があり、空だとコンパイルエラーになります。
フックの記述順は任意です。
get
およびset
フックは、PHPデフォルトの読み取り・書き込み動作をオーバーライドします。
get
とset
を両方とも実装することも、いずれか片方だけ実装することもできます。
フック内では、$this->[propertyName]
はフックを通過せずに直接値を参照します。
フック以外の場所では、$this->[propertyName]
は該当するフックを通過します。
たとえばget
フックからプロパティ書き込みを行っても、set
フックは呼び出されずに直接値が書き込まれます。
通常のプロパティは、オブジェクト内に同名のバッキング値として保存されています。
ただし、プロパティに少なくともひとつのフックがあり、さらにフック内で$this->[propertyName]
を使っていない場合は、オブジェクト内にバッキング値は生成されません。
このプロパティは実際の値を持たないため、仮想プロパティと呼ばれます。
get
get
フックは、PHPデフォルトの読み込みをオーバーライドします。
class User
{
public function __construct(private string $first, private string $last) {}
public string $fullName {
get {
return $this->first . " " . $this->last;
}
}
}
$u = new User('Larry', 'Garfield');
print $u->fullName; // "Larry Garfield"
get
フックはメソッドであり、プロパティと互換性のある型を返す必要があります。
この例では、$this->[propertyName]
を使っていないため、$fullName
は仮想プロパティとなります。
仮想プロパティであるためset
は未定義であり、書き込みしようとするとエラーになります。
いっぽう次の例では$this->[propertyName]
を使っているので$name
プロパティが生成され、書き込もうとすると通常どおりの書き込み動作が行われます。
class Loud
{
public string $name {
get {
return strtoupper($this->name);
}
}
}
$l = new Loud();
$l->name = 'larry';
print $l->name; // "LARRY"
この例では$name
は実在するプロパティなので、可視性があれば書き込むことができます。
読み込みはもちろんget
フックを通るので、返り値は大文字になります。
set
set
フックは、PHPデフォルトの書き込みをオーバーライドします。
class User
{
public function __construct(private string $first, private string $last) {}
public string $fullName {
set (string $value) {
[$this->first, $this->last] = explode(' ', $value, 2);
}
}
public function getFirst(): string {
return $this->first;
}
}
u = new User('Larry', 'Garfield');
$u->fullName = 'Ilija Tovilo';
print $u->getFirst(); // "Ilija"
set
フックはメソッドであり、引数をひとつ受け取ります。
上記例では、$fullName
は仮想プロパティです。
get
フックはないので、$fullName
を読み込もうとするとエラーになります。
このような使い方は一般的ではありませんが、文法としては有効です。
より一般的な使い方は以下のようになるでしょう。
class User
{
public function __construct(public string $first, public string $last) {}
public string $fullName {
get {
return "$this->first $this->last";
}
set (string $value) {
[$this->first, $this->last] = explode(' ', $value, 2);
}
}
}
u = new User('Larry', 'Garfield');
$u->fullName = 'Ilija Tovilo';
print $u->first; // "Ilija"
さらに次の例では、バッキング値$username
が作成されるため、直接読み取りが可能です。
class User {
public string $username {
set(string $value) {
if (strlen($value) > 10) throw new \InvalidArgumentException('Too long');
$this->username = strtolower($value);
}
}
}
$u = new User();
$u->username = "Crell"; // setフックが呼ばれる
print $u->username; // "crell"
$u->username = "something_very_long"; // Too longの\InvalidArgumentExceptionが出る
プロパティフックはset
の検証としての使い方が最も頻度が高くなると思われます。
set
フックは、プロパティの型が反変です。
つまり、より広くて寛容な引数を受け入れることができます。
ただし出力される値は宣言された型に準拠していなければなりません。
これによって、たとえば以下のような書式は有効です。
use Symfony\Component\String\UnicodeString;
class Person
{
public UnicodeString $name {
set(string|UnicodeString $value) {
$this->name = $value instanceof UnicodeString ? $value : new UnicodeString($value);
}
}
}
$name
は引数としてUnicodeString型とstring型を受け取ることができますが、実際に$name
に入るのはUnicodeString型です。
これによって読み取りに一貫性を確保することができます。
set
フックの返り値の型は未指定であり、暗黙的にvoidとして扱われます。
ところでほとんど意識されませんが、=
代入演算子は値を返す式です。
しかし、返される値には既に若干の矛盾があります。
型付きプロパティの場合、返り値は代入後にプロパティに入る値となりますが、これは型強制される場合があります。
マジックメソッド__set
の場合、型強制するプロパティの型というものが存在しないので、常に代入式の右側の値になります。
set
フックは__set
と同じ動作になります。
class C {
public array $_names;
public string $names {
set {
$this->_names = explode(',', $value, 2);
}
}
}
$c = new C();
var_dump($c->names = 'Ilija,Larry'); // 'Ilija,Larry'
var_dump($c->_names); // ['Ilija', 'Larry']
strictモード下においては、代入演算子の結果が変動する唯一のケースは、float型にintを代入する場合です。
strictでない場合は、暗黙的な型キャストによって型が変わる場合が他にもいくつかあります。
この現象は、現在でも__set
を使った場合に起きているのですが、実際に=
の返り値を使用することはほとんどありません。
従って、これは許容できるエッジケースだと考えています。
Abbreviated syntax
上記構文はフル構文です。
よくあるケースを簡潔に記載できるように、幾つかの短縮構文が利用できます。
Short-get
get
フックが1つの式だけであれば、アロー関数と同じく{}
とreturn
を省略することができます。
つまり、以下の$fullName
は同じです。
class User
{
public function __construct(private string $first, private string $last) {}
public string $fullName {
get {
return $this->first . " " . $this->last;
}
}
public string $fullName {
get => $this->first . " " . $this->last;
}
}
Implicit ''set'' parameter
プロパティの型と、set
の引数の型が同じであれば、引数を省略することができます。
つまり、以下の$fullName
は同じです。
public string $fullName {
set (string $value) {
[$this->first, $this->last] = explode(' ', $value, 2);
}
}
public string $fullName {
set {
[$this->first, $this->last] = explode(' ', $value, 2);
}
}
引数が指定されていない場合、値として$value
が使われます。
これはKotlin・C#と同じ変数名です。
set
フックは1つの式であれば=>
に短縮できます。
値の格納先はプロパティ名です。
つまり、以下の$fullName
は同じです。
class User {
public string $username {
set(string $value) {
$this->username = strtolower($value);
}
}
public string $username {
set => strtolower($value);
}
}
Scoping
全てのフックは、オブジェクトスコープで動作します。
すなわち、全てのpublic・private・protectedメソッドおよびプロパティにアクセスすることができます。
プロパティフックを持つプロパティへのアクセスも同様です。
フック内から別のプロパティにアクセスした場合、そのプロパティのフックがバイパスされたりすることはありません。
class Person {
public string $phone {
set => $this->sanitizePhone($value);
}
private function sanitizePhone(string $value): string {
$value = ltrim($value, '+');
$value = ltrim($value, '1');
if (!preg_match('/\d\d\d\-\d\d\d\-\d\d\d\d/', $value)) {
throw new \InvalidArgumentException();
}
return $value;
}
}
注意すべき点のひとつとして、フックが別のメソッドを呼び出し、そのメソッドがプロパティから読み込もうとすると、無限ループになります。
これを防ぐため、プロパティフックから呼び出されたメソッドからプロパティにアクセスしようとすると、単にエラーになります。
これは、フックされたメソッドからのプロパティアクセスがマジックメソッドをバイパスする__get・__set
とは異なる動きになります。
References
プロパティの読み書きプロセスはフックによって横取りされるため、プロパティへのリファレンスを使ったりすると問題が生じます。
リファレンスによる値の変更はset
フックがバイパスされてしまうためです。
そのため、フックが存在するプロパティへのリファレンス取得や、プロパティの間接的な変更は禁止されます。
リファレンスによるプロパティの読み書きは非常に稀であるため、大抵は問題になりません。
class Foo
{
public string $bar;
public string $baz {
get => $this->baz;
set => strtoupper($value);
}
}
$x = 'beep';
$foo = new Foo();
$foo->bar = &$x; // OK $barにはフックがない
$foo->baz = &$x; // NG エラーになる
set
フックがない場合はリファレンスがあっても問題になりません。
従って、読み込み専用プロパティのリファレンスは許可されます。
この場合、フックに&
を付けます。
class Foo
{
public string $baz {
&get {
if ((!isset($this->baz)) {
$this->baz = $this->computeBaz();
}
return $this->baz;
}
}
}
$foo = new Foo();
print $foo->baz; // getが遅延実行されて返る
$temp =& $foo->baz; // bazのリファレンス。
$temp = 'update'; // $foo->bazが"update"になる。
呼び出し元では、&get
指定されたプロパティのみリファレンスで参照することができるようになります。
get
プロパティをリファレンスしようとするとエラーになります。
get
と&get
は、フックされたプロパティがリファレンスによってどこか知らない場所で書き替えられることを許すかどうかを明示します。
get
フックは書き替えから保護されます。
&get
は、他所で書き替えられることをバグではなく仕様であると表明します。
get
と&get
を両方実装するとコンパイルエラーになります。
なお、例外がひとつ存在します。
プロパティが仮想プロパティしか持っていない場合は、set
とget
は直接的な関係が存在しないので、仮想プロパティへのget
フックのみオプトインでリファレンスを許可できるようにしました。
&get
とすることで、値ではなく値が示すリファレンスを返すことができるようになります。
リファレンスへの書き込みではset
フックはトリガーされません。
高度な下位互換性を実現するために、この仕様を残しています。
class Foo
{
private string $_baz;
public string $baz {
&get => $this->_baz;
set => $this->_baz = strtoupper($value);
}
}
$foo = new Foo();
// setフックが呼ばれる
$foo->baz = 'beep';
// `$this->_baz`を直接参照する
$x =& $foo->baz;
// setフックは呼ばれない
$x = 'boop';
リファレンスによる&set
はサポートされていません。
この動作は既存のマジックメソッド__get()
・__set()
がリファレンスを処理する方法に合わせています。
また、フックが存在するプロパティをリファレンスで反復処理しようとするとエラーになります。
まとめ
・バッキング値
get
:OK、リファレンス不可
get/set
:OK、リファレンス不可
&get
:OK、リファレンス可能
&get/set
:NG、コンパイルエラー
set
:OK、リファレンス不可
・仮想プロパティ
get
:OK、リファレンス不可
get/set
:OK、リファレンス不可
&get
:OK、リファレンス可能
&get/set
:OK、リファレンス可能
set
:OKだが無意味
&get
フックは、必ずしもオブジェクトのプロパティを返さないことがあります。
これはバッキング値と仮想プロパティ両方の場合に当てはまります。
リファレンスに書き込んだつもりでそうならないことがあることに気をつけましょう。
class C {
public string $a {
&get {
$b = $this->a;
return $b;
}
}
$c = new C();
$c->a = 'beep'; // $cに変化はない
もっともこれは&getA()
メソッドでも同じことであり、フックに特有の問題ではありません。
オブジェクトのプロパティをリファレンスでループすると、フックのあるプロパティまで辿り着いたときにエラーが出ます。
foreach ($someObjectWithHooks as $key => $value) {
// getフックを通して値を取得する
}
foreach ($someObjectWithHooks as $key => &$value) {
// フックがあるとエラー
}
Arrays
配列プロパティについては追加で気を付けることがあります。
プロパティの配列の変更は、値のコピーを発生させずにインプレースで行うことができますが、通常のsetter/getterメソッドではこれを行うことができません。
class Test {
public $array = [];
public function getArray() {
echo "getArray()\n";
return $this->array;
}
public function setArray($array) {
echo "setArray()\n";
$this->array = $array;
}
}
$test = new Test();
// プロパティを直接書き換える。パフォーマンス上のオーバーヘッドなし
$test->array[] = 'foo';
// getArray()は値を返すので、それを変更しても意味がない
$test->getArray()[] = 'foo';
// 期待通りの動作をするが、2行目で値のコピーが発生するのでパフォーマンスに劣る
$array = $test->getArray();
$array[] = 'foo';
$test->setArray($array);
この問題へのわかりやすい対策は、getArrayをリファレンスにすることです。
class Test {
// ...
public function &getArray() {
echo "getArray()\n";
return $this->array;
}
// ...
}
// 思ったとおりに動く
$test->getArray()[] = 'foo';
しかし、これには問題がひとつあります。
setArray()
が呼び出されなくなるのです。
フックにも同じことが起こります。
class Test {
public $array {
&get {
echo "getArray()\n";
return $this->array;
}
set {
echo "setArray()\n";
$this->array = $value;
}
}
}
$test = new Test();
// &getフックは呼ばれるが、setフックは呼ばれない
$test->array[] = 'foo';
考えられる対策案はいずれもいまいちです。
配列の場合は、専用のミューテータメソッドを用意してもらうのが最もよいだろう、というのが我々の意見です。
class Test {
private $_array;
public $array {
get => $this->_array;
}
public function addElement($value) {
// setフックのかわり
$this->_array[] = $value;
}
}
$test = new Test();
$test->addElement('foo');
以下が、フックの組み合わせとサポートされている操作の完全なリストです。
プロパティ | フック | 読み込み | 書き込み | 入れ替え |
---|---|---|---|---|
例 | $a->arr[1]; | $a->arr[1] = 2 | $a->arr = $arr2 | |
バッキング値 | get | 〇 | × | 〇 |
バッキング値 | &get | 〇 | 〇 | 〇 |
バッキング値 | get/set | 〇 | × | 〇 |
バッキング値 | &get/set | × | × | × |
バッキング値 | set | 〇 | × | 〇 |
仮想プロパティ | get | 〇 | × | × |
仮想プロパティ | &get | 〇 | 〇 | × |
仮想プロパティ | get/set | 〇 | 〇 | 〇 |
仮想プロパティ | &get/set | 〇 | 〇 | 〇 |
仮想プロパティ | set† | × | × | 〇 |
†:指定は可能だけど意味はない
注目すべきはバッキング値への&get
であり、遅延初期化が許可されます。
遅延初期化された後の配列は、完全にpublicになります。
class C
{
public array $list {
&get {
$this->list ??= $this->defaultListValue();
return $this->list;
}
}
private function defaultListValue() {
return ['a', 'b', 'c'];
}
}
$c = new C();
print $c->list[1]; // "b"
// &getフックで返したリファレンスに書き込む。setフックがないので許される
$c->list[] = 'd';
print count($c->list); // 4
Default values
デフォルト値は、バッキング値を持つプロパティのみサポートされます。
仮想プロパティにはデフォルト値を割り当てるべき場所がないので、コンパイルエラーになります。
注意点として、デフォルト値はsetフックを経由せずに直接割り当てられます。
それ以降の書き込みは全てsetフックを通ります。
これはオブジェクトの初期化順による混乱を避けるためであり、またKotlinと同じ実装となっています。
デフォルト値がある場合、フックの前に記述します。
class User
{
public string $role = 'anonymous' {
set => strlen($value) <= 10 ? $value : throw new \Exception('Too long');
}
}
Inheritance
子クラスでは、オーバーライドしたいフックのみを再定義することで上書きが可能です。
プロパティの型や可視性は、このRFCとは関係なく既存のルールに従います。
子クラスでは、フックのないプロパティにフックを追加することもできます。
class Point
{
public int $x;
public int $y;
}
class PositivePoint extends Point
{
public int $x {
set {
if ($value < 0) {
throw new \InvalidArgumentException('Too small');
}
$this->x = $value;
}
}
}
子クラスでフックを追加すると、親プロパティで指定されているデフォルト値は削除されます。
これは既存の継承と同じ動作です。
Accessing parent hooks
子クラスのフックは、parent::$prop
から親のフックにアクセスすることができます。
たとえばparent::$propName::get()
であり、これは親クラスの$props
のget()
を実行します。
この方法を使わない場合はフックを使わないアクセスとなります。
親クラスにフックがない場合は、デフォルトの動作になります。
上記サンプルを書きなおした例です。
class Point
{
public int $x;
public int $y;
}
class PositivePoint extends Point
{
public int $x {
set($x) {
if ($x < 0) {
throw new \InvalidArgumentException('Too small');
}
parent::$x::set($x);
}
}
}
以下はsetフックだけをオーバーライドする例です。
class Strings
{
public string $val;
}
class CaseFoldingStrings extends Strings
{
public bool $uppercase = true;
public string $val {
get => $this->uppercase
? strtoupper(parent::$val::get())
: strtolower(parent::$val::get());
}
}
getフックだけが指定されていて、親にはフックがない即ちバッキング値であるため、プロパティへの書き込みは普通のプロパティと同じです。
フックは、自身の親プロパティ以外のフックにアクセスすることはできません。
このような構文が選ばれた理由はFAQセクションを参照してください。
Final hooks
フックをfinal宣言した場合、そのフックはオーバーライドできません。
class User
{
public string $username {
final set => strtolower($value);
}
}
class Manager extends User
{
public string $username {
// こちらはOK
get => strtoupper($this->username);
// エラーになる
set => strtoupper($value);
}
}
プロパティ自体をfinal宣言した場合は、フックを含めいかなる方法でも変更はできません。
finalプロパティ内でフックをfinal宣言するのは単に冗長であり、無視されます。
これはfinalメソッドと同じ動作です。
class User
{
// 子クラスでは一切変更できない
public final string $name;
// 子クラスでは一切変更できない
public final string $username {
set => strtolower($value);
}
}
Interfaces
プロパティフックの目的は、getter/setterメソッドを不要にすることです。
クラスの場合はこれまで見てきたとおりですが、多くの場合、オブジェクトはインターフェイスも継承しています。
従って、このRFCはインターフェイスにも対応します。
実装には、通常のプロパティもしくはフックのいずれを使ってもかまいません。
interface I
{
// publicに読み取り可能である。書き込みについては未定義
public string $readable { get; }
// publicに書き込み可能である。読み取りについては未定義
public string $writeable { set; }
// publicに読み取り・書き込みが両方とも可能である。
public string $both { get; set; }
}
// 従来のプロパティ。これも完全に有効。
class C1 implements I
{
public string $readable;
public string $writeable;
public string $both;
}
// フックを使った実装。これも完全に有効。
class C2 implements I
{
private string $written = '';
private string $all = '';
// $readableを読み込みできる。書き込みはできてもできなくてもいい。
public string $readable { get => strtoupper($this->writeable); }
// $writeableに書き込みできる。読み取りはできてもできなくてもいい。
public string $writeable {
get => $this->written;
set => $value;
}
// $bothは読み書き両方が必要。
public string $both {
get => $this->all;
set => strtoupper($value);
}
}
interfaceはpublicアクセスのみに影響するので、publicではないプロパティについては関知しません。
これはメソッドと同じです。
interfaceのget
フックは、継承クラスでget
もしくは&get
いずれかで実装される必要があります。
interfaceに&get
フックと書くと、継承クラスは必ず&get
で実装する必要があります。
フックを明示しないpublic string $foo
形式のプロパティはサポートしないことを意図的に選択しました。
フックの最も一般的な使用ケースはget
のみ可能というものだと思われますが、明示しない場合がget
のみなのかget/set
なのか明確ではなくなってしまうためです。
曖昧さを避けるために、期待される動作を明示することにしました。
なおgetのみを必要とするプロパティは、public readonly
で代替することができます。
しかしsetのみのプロパティは代替できません。
Abstract properties
抽象クラスでは、インターフェイス同様に抽象プロパティを宣言できます。
また抽象プロパティはprotectedも宣言が可能です。
抽象privateプロパティは禁止であり、privateメソッド同様コンパイルエラーになります。
abstract class A
{
// publicに読み込み可能。
abstract public string $readable { get; }
// publicもしくはprotectedに書き込み可能。
abstract protected string $writeable { set; }
// publicもしくはprotectedに読み書き可能。
abstract protected string $both { get; set; }
}
class C extends A
{
// publicに読み込み可能なのでOK。
public string $readable;
// エラー。publicに読み込めないのでNG。
protected string $readable;
// protectedに書き込み可能なのでOK。
protected string $writeable {
set => $value;
}
// publicに読み書き可能なのでOK。
public string $both;
}
抽象プロパティは、フックを実装することもできます。
インターフェイスでは実装を行うことができません。
abstract class A
{
// 子クラスでgetの実装が必要 setはなくてもいいしオーバーライドしてもいい
abstract public string $foo {
get;
set { $this->foo = $value };
}
}
Abstract property types
通常のプロパティは、サブクラスでその型を変えることはできません。
なぜならば、get操作は共変でなければならず、set操作は反変でなければならないからです。
両方を満たすことのできる唯一の方法が、不変です。
ところが抽象プロパティや仮想プロパティを使うと、set操作とget操作の片方のみが可能なプロパティを宣言することができるようになります。
その結果、get操作のみが可能なプロパティは共変になり、set操作のみが可能なプロパティは反変になります。
class Animal {}
class Dog extends Animal {}
class Poodle extends Dog {}
interface PetOwner
{
// getだけなので共変
public Animal $pet { get; }
}
class DogOwner implements PetOwner
{
// 許可
// ただしこれはネイティブプロパティなので、この子クラスではもう変更できない
public Dog $pet;
}
class PoodleOwner extends DogOwner
{
// エラーになる
public Poodle $pet;
}
Property magic constant
プロパティフック内では、マジック定数__PROPERTY__
が定義され、値はプロパティ名自身となります。
使用例についてはサンプルを参照ください。
Interaction with traits
トレイトのプロパティにもフックを宣言できます。
ただし、通常のプロパティ同様に名称の競合を自動的に解決はしません。
トレイトとuseするクラスが同名のプロパティを宣言するとエラーになります。
Interaction with readonly
readonlyプロパティは、バッキング値が初期化されているかどうかをチェックすることで機能しています。
しかし、仮想プロパティには確認すべきバッキング値が存在しません。
フックとオーバーライドが重なると、初期化の概念が非常に複雑なことになります。
そのため、単純にget
・set
フックとreadonlyは同時に指定できないことにします。
両方を指定するとコンパイルエラーになります。
readonlyプロパティに子クラスでgetフックを宣言することもできません。
Interaction with magic methods
未定義のプロパティにアクセスした場合、もしくは呼び出し元のスコープから見れないプロパティにアクセスした場合は、__get()
・__set()
・__isset()
・__unset()
の各マジックメソッドが呼び出されます。
これはプロパティフックがある場合でも変わりません。
プロパティフックがあるということはつまりプロパティがあるということですが、呼び出し元のスコープから見えない可視性だった場合は、フックが存在しない場合と同様にマジックメソッドが呼び出されます。
マジックメソッドからは、プロパティが見える場合はアクセスできます。
class C
{
private string $name {
get => $this->name;
set => ucfirst($value);
}
public function __set($var, $val)
{
print "In __set\n";
$this->$var = $val;
}
}
$c = new C();
$c->name = 'picard';
// "In __set"と表示される。$c->nameの値は"Picard"
Interaction with isset() and unset()
getフックが見える場合、関数isset()
はgetフックを呼び出し、値がnullでなければtrueを返します。
つまりisset($o->foo)
は、!is_null($o->foo)
と同じです。
未定義プロパティへのisset()
は、__isset()
マジックメソッドが定義されていてtrueを返し、__get()
マジックメソッドがnull以外を返す場合にのみtrueを返します。
フックの存在するプロパティは必ず定義されているので、__isset()
をチェックする必要はありません。
まとめると、getフックへのisset()
の動作は通常プロパティのisset()
と同じです。
プロパティにバッキング値が存在し、getフックがない場合は、プロパティ値を直接確認します。
プロパティが仮想プロパティであり、getフックがない場合は、isset()
はエラーになります。
setフックのみ定義された仮想プロパティで発生しますが、稀な現象であると思われます。
プロパティフックが存在する場合、プロパティのunset()
は許可されず、エラーになります。
unset()
は非常に限定された目的の書き込み操作ですが、これをサポートするとsetフックをバイパスすることになるので望ましくありません。
Interaction with constructor property promotion
PHP8.0以降、コンストラクタ引数でインラインにプロパティを宣言可能です。
ここで複雑なプロパティフックを指定した場合、コンストラクタのシグネチャが数十行になってしまう可能性があります。
しかしコンストラクタプロパティ宣言の値の検証を行うためにsetフックを使用したい需要は高いだろうと思われるので、互換させない場合はプロパティフックの価値が大きく損なわれることでしょう。
検討の結果、コンストラクタプロパティ宣言においてもフックに対応することにしました。
たしかに病的なコードを記述できる可能性はありますが、実際にそのようなことになる場合は少ないと思われます。
フックとコンストラクタプロパティ宣言の組み合わせは、大抵は次のような形になるでしょう。
class User
{
public function __construct(
public string $username { set => strtolower($value); }
) {}
}
Interaction with serialization
フックのあるプロパティのシリアライズは、フックのないプロパティのシリアライズと可能なかぎり同じになるよう設計されています。
考慮すべきシリアライズコンテキストを以下に解説します。
・var_dump(): 生の値
・serialize(): 生の値
・unserialize(): 生の値
・__serialize()/__unserialize(): フックを使用、独自ロジック
・Array casting: 生の値
・var_export(): フックを使用
・json_encode(): フックを使用
・JsonSerializable: フックを使用、独自ロジック
・get_object_vars(): フックを使用
・get_mangled_object_vars(): 生の値
serialize()
やvar_dump()
はオブジェクトの内部状態を表すことを目的としているので、フックを使わずプロパティの生の値をそのまま出力します。
バッキング値を持たない仮想プロパティは省略されます。
同様にunserialize()
は、setフックを使わず生の値をバッキング値に直接書き込みます。
マジックメソッド__serialize()
・__serialize()
が存在する場合は、これらは他メソッドと同様単純に実行されるため、プロパティもフックを通ることになります。
オブジェクトを配列にキャストする$arr = (array) $obj
場合は、いまもプロパティの可視性は無視されます。
getフックも無視されます。
get_mangled_object_vars()
は配列キャストの置き換えを目的としているため、動作も同じです。
可視性もgetフックも無視されます。
JsonSerializable
は通常のメソッドとして呼び出されるだけであり、通常のメソッドと同じくgetフックを通ります。
JsonSerializable
を実装していないオブジェクトでjson_encode()
を使うと、スコープに関係なくpublicプロパティのみが返ります。
json_encode()
の目的はオブジェクトの表の顔をシリアル化することであり、従ってgetフックが呼び出されます。
get_object_vars()
もスコープを意識しており、内部状態へのアクセスを想定したものではありません。
動作的にはオブジェクトにforeachを使ったのと同じ挙動になります。
従って、フックが呼び出されます。
var_export()
は特殊です。
この目的はオブジェクトの内部状態を出力することであり、可視性は無視しますが、しかし__set_state()
マジックメソッドを使うとユーザが挙動を操作することが可能です。
__set_state()
が実装されている場合、プロパティアクセスはフックを通ります。
従って非対称性を抑えるため、var_export()
のプロパティアクセスは常にgetフックを呼び出すことを選びました。
Reflection
新しく列挙型PropertyHookType
が追加されます。
enum PropertyHookType: string
{
case Get = 'get';
case Set = 'set';
}
ReflectionProperty
クラスにフックを操作するメソッドが幾つか追加されます。
getHooks()
は、フックがあればReflectionMethod
オブジェクトとして返します。
getHook(PropertyHookType $hook)
は、対応するフックがあればReflectionMethod
オブジェクトを、なければnullを返します。
isVirtual()
は、プロパティにバッキング値があればfalseを、なければtrueを返します。
従って、フックのない通常のプロパティは常にfalseになります。
getSettableType()
は、setフックの型定義を返します。
型が定義されていない場合はgetType()
と同じ値が返ります。
getRawValue(object $object)
は、getフックを通さずプロパティのバッキング値を返します。
フックがない場合はgetValue()
と同じです。
プロパティが仮想プロパティである場合はエラーになります。
setRawValue(object $object, mixed $value)
は、同様にsetフックを通さずプロパティのバッキング値を直接設定します。
Attributes
フックは内部的にはメソッドであり、すなわちメソッドと同じアトリビュートを指定可能です。
#[Attribute(Attribute::TARGET_METHOD)]
class A {}
#[Attribute(Attribute::TARGET_METHOD)]
class B {}
class C {
public $prop {
#[A] get {}
#[B] set {}
}
}
$getAttr = (new ReflectionProperty(C::class, 'prop'))
->getHook(PropertyHookType::Get)
->getAttributes()[0];
$aAttrib = $getAttr->getInstance();
// $aAttribはAのインスタンス
フックの引数には、引数のアトリビュートを設定できます。
class C {
public int $prop {
set(#[SensitiveParameter] int $value) {
throw new Exception('Exception from $prop');
}
}
}
$c = new C();
$c->prop = 'secret';
// Exceptionが出るが、スタックトレース内の$valueはSensitiveParameterValueでマスクされる
// Stack trace:
// #0 example.php(4): C->$prop::set(Object(SensitiveParameterValue))
フックに#[\Override]
アトリビュートを適用可能です。
親クラスにフックがない場合もオーバーライドできます。
Frequently Asked Questions
Why not Python/JavaScript-style accessor methods?
どうしてPython・JavaScriptの構文ではないの?
我々が調査したプロパティアクセサを持つ5言語のうち、C#・Swift・KotlinはPHPと同様プロパティ宣言にフックやロジックを持たせる形式です。
PythonとJavaScriptではプロパティ宣言ではなく、getter/setterメソッド形式です。
ためしにJavaScript形式でアクセサプロパティを実装すると、次のような構文になるでしょう。
class Person
{
public string $firstName;
public function __construct(private string $first, private string $last) {}
public function get firstName(): string
{
return $this->first . " " . $this->last;
}
public function set firstName(string $value): void
{
$this->first = $value;
}
}
この構文も一見よさそうですが、様々な理由であまりよくありません。
プロパティ$firstName
の型は何ですか?
おそらくstringですが、しかしget firstName()
の返り値の型とset firstName()
の引数の型と$firstName
の型を合わせる強制力はありません。
コンパイルエラーで検出することは可能ですが、3か所で同じ型を書くという追加の労力が開発者にふりかかることを意味します。
無効な状態はそもそも書けないようにするべきですが、この構文ではこれができません。
可視性についても同じことが考えられます。
get
・set
・プロパティは全て同じ可視性を持つべきでしょうか?
なお、PythonやJavaScriptには可視性修飾子がないので問題が発生しません。
アクセサメソッドはクラス内のどこにでも、数百行離れたところにも書くことができます。
すなわち、プロパティ宣言を見ただけではアクセサメソッドが存在するかがわかりません。
getter/setterメソッド形式の構文は、プロパティに明示的な型指定を行わない言語ではうまくいきます。
明示的な型指定を行う言語では非常に煩雑でわかりにくくなります。
実際に、明示的な型指定のある言語C#・Swift・Kotlinは全てプロパティアクセサ形式です。
PHPも明示的な型指定のある言語であるため、そちらの構文を選ぶほうが合理的です。
Why isn't asymmetric visibility included, like in C#?
C#の非対称可視性が含まれないのはどうして?
C#・Swift・Kotlinは非対称可視性をサポートしていますが、それぞれが異なる構文を採用しています。
非対称可視性をサポートしたいというユースケースは十分にありますが、C#のフックバインド構文を使用すると配列の非対称可視性が不可能になってしまったり、それを回避しようとすると構文がさらに複雑になって今います。
そのため、このRFCには非対称可視性は含まれません。
将来的に非対称可視性が望ましいと判断された場合は、C#とは別の構文で導入されるかもしれません。
What's with the weird syntax for accessing a parent property?
親プロパティにアクセスする構文はどうしてこんなに変なのですか?
フックを介して親プロパティにアクセスする構文は、他の構文との混乱を最小限に抑えるよう設計されています。
一見明白なparent::$x
のような代替案には、実は問題があります。
まず"親フックにアクセスする"と"親の静的プロパティを取得する"が区別できません。
さらに大きな問題としては、parent::$x
が必ずしもバッキング値のかわりにはならないことです。
演算子=
のサポートは可能ですが、++
・--
・<=
その他の演算子にはアクセスできません。
それらの実装には大きなコストが必要であり、そのうえでほとんど使い道がありません。
現在の少し長い構文$a = parent::$prop::get()
は、解釈を間違える余地がないため混乱がありません。
Why no explicit "virtual" flag?
明示的なvirtualフラグを作らなかったのはどうして?
提案のひとつに、プロパティが実在するかどうかを自動検出ではなくキーワードでマークするものがありました。
しかしこのアプローチを検討した結果、継承を考えると実現不可能であると判断されました。
具体的には、バッキングされたプロパティを持つ親クラスについて、子クラスで拡張してバッキング値を使わないフックを実装した場合、子クラスには隠されたプロパティが残っています。
逆に、親クラスに仮想プロパティしかなくても、子クラスでそれをオーバーライドしてバッキング値を持たせることができます。
よって、親クラスによって異なる挙動になるため、virtualキーワードが信用に値しないこととなります。
class P {
public virtual $prop { get => ...; set { ... }; }
}
class C extends P {
public virtual $prop { get => strtoupper(parent::$prop::get()); }
}
C::$prop
は自身のバッキング値を使わないため、virtual
キーワードを使うことは適切です。
しかし、もしP::$prop
が実プロパティだった場合はC::$prop
も実プロパティであるため、virtual
キーワードは間違いということになります。
Usage examples
フックの典型的な利用例を以下に集めました。
仕様を網羅しているわけではありませんが、フックをどのように利用できるかの参考になるでしょう。
Backward Incompatible Changes
互換性のない変更点。
親プロパティのアクセス構文に伴い、わずかなBCがひとつ存在します。
class A {
public static $prop = 'C';
}
class B extends A {
public function test() {
return parent::$prop::get();
}
}
class C {
public static function get() {
return 'Hello from C::get';
}
}
現在、parent::$prop
はC
となり、従ってC::get()
が呼び出されます。
今回、このコードはエラーとなります。
このBCに対応するには、以下のようにします。
class B extends A {
public function test() {
$class = parent::$prop;
return $class::get();
}
}
Proposed PHP Version(s)
PHP8.4
Future Scope
この節は将来の展望であり、本RFCには含まれません。
isset and unset hooks
マジックメソッド__isset
・__unset
もフックしたいと考える人がいるかもしれません。
開発者としては、この有用な利用法は少ないと考えているため、このRFCには含まれていません。
有効な使用例が示されれば、今後導入される可能性はあります。
Reusable hooks
Swiftには、複数のプロパティに一括適用できるフックpackageが存在します。
PHPにおける、メソッドに対するトレイトのようなものです。
これは便利な機能であるかもしれませんが、大規模になると思われるため今回のRFCでは対象外としています。
有効な使用例が示されれば、今後導入される可能性はあります。
Return to assign for long-set
set
フックに実装を書く場合、現在のシグネチャはreturn void
です。
明示的に値を書き込む必要があるため、値をreturnするだけでいい短縮構文より少しばかり面倒です。
こうなっている理由は、return;
・return null;
・"returnしない"を呼び出し側で区別できないからです。
バッキング値にnullを入れたいのか、処理を中断したので何もしないのか、を判定することができません。
これに対応するためにはPHPエンジンを改修する必要がありますが、これは大きな影響を伴うため、本RFCでは対象外としています。
Assignment by reference
このRFCでは、フックされたプロパティのリファレンスをサポートしていません。
非常に稀なエッジケースであり、マジックメソッド__set
もサポートしていないので、本RFCでは対象外です。
Accessing sibling hooks
get
・set
フック内での$this->[propertyName]
は、get
・set
いずれのフックもスキップする仕様です。
ほとんどの用途はこれで賄えます。
しかし、get
フックからset
フックにアクセスしたいという用途もあるかもしれません。
その場合、self::$foo::get()
のような構文を導入するのが最も自然でしょう。
ただし、注意して利用しないと無限ループになる可能性があります。
ほとんど使い道がないと思われるため、本RFCには含まれません。
Proposed Voting Choices
投票期間は2024/04/15から2024/04/29、投票者の2/3の賛成で受理されます。
本RFCは賛成42反対2の賛成多数で受理されました。
プロパティフックはPHP8.4から利用可能です。
Implementation
感想
めっちゃ長い!
とにかくあらゆるエッジケースを調べて回り、それぞれに適切な解決策を取り付け、見事にフックを実装しきりました。
これではNikitaたったひとりでの実装が無謀極まりなかったのも頷けるというものです。
逆に言うと、Nikita Popovというあまりに高すぎる山でも、みんなの力を合わせることで乗り越えられるという事実を示したといえるでしょう。
このRFCは、PHP Foundationの開発チームとスポンサーみんなが力を結集して取り組んできました。
さらにev4lでは既にProperty hooksを試すことができます。
冒頭のプロパティフック構文も実際に動作します。
そしてこれほど多くの人々を巻き込んでいるRFCということもあり、ほぼ全会一致の賛成多数で受理されることになりました。
ただまあ個人的には未定義変数$value
が脈絡なくいきなり生えるのがやはり気持ち悪く感じますね。
$http_response_header等の謎変数もやめようぜとなりつつある今になって、汎用過ぎる名前の$value
を新設するのはどうなんですかね。
構文自体が新機能なので既存の動作に影響するようなことはないのですが、深く考えずに処理を書いて「あれ?なんか動きがおかしいぞ?」と思ったら$value
って書いてたみたいなことは起きてしまいそうです。
といって、ではどうすればいいかと言われたら困りますが。