137
83

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【PHP8.4】ついにPHPにプロパティフックが導入される

Last updated at Posted at 2024-05-20

プロパティフックとは何なのかというと、これです。

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では、プロパティのgetset動作をオーバーライドするふたつのフックが導入されます。
本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デフォルトの読み取り・書き込み動作をオーバーライドします。
getsetを両方とも実装することも、いずれか片方だけ実装することもできます。

フック内では、$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を両方実装するとコンパイルエラーになります。

なお、例外がひとつ存在します。
プロパティが仮想プロパティしか持っていない場合は、setgetは直接的な関係が存在しないので、仮想プロパティへの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()であり、これは親クラスの$propsget()を実行します。
この方法を使わない場合はフックを使わないアクセスとなります。
親クラスにフックがない場合は、デフォルトの動作になります。

上記サンプルを書きなおした例です。

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プロパティは、バッキング値が初期化されているかどうかをチェックすることで機能しています。
しかし、仮想プロパティには確認すべきバッキング値が存在しません。
フックとオーバーライドが重なると、初期化の概念が非常に複雑なことになります。

そのため、単純にgetsetフックと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か所で同じ型を書くという追加の労力が開発者にふりかかることを意味します。
無効な状態はそもそも書けないようにするべきですが、この構文ではこれができません。

可視性についても同じことが考えられます。
getset・プロパティは全て同じ可視性を持つべきでしょうか?
なお、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::$propCとなり、従って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

getsetフック内での$this->[propertyName]は、getsetいずれのフックもスキップする仕様です。
ほとんどの用途はこれで賄えます。
しかし、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って書いてたみたいなことは起きてしまいそうです。
といって、ではどうすればいいかと言われたら困りますが。

137
83
0

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
137
83

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?