プロパティフック

プロパティフックは、他の言語では「プロパティアクセサー」とも呼ばれる仕組みで、 プロパティの読み書き動作へ干渉し、それを上書きする機能を提供します。 この機能には、次の2つの目的があります:

  1. get- メソッドや set- メソッドを使わずプロパティを参照しつつ、 将来的に追加の機能を追加する余地を残す。 フックを使わない場合の定型の get/set メソッドのほとんどは 不要になる。
  2. 実際に値を保持することなく、 オブジェクトを説明するプロパティを実装する。

非静的なプロパティには getset の2種類のフックがあります。 それぞれ、プロパティの読み取りと書き込みの動作を上書きします。 フックは、型付きプロパティ、型のないプロパティ、いずれにも利用可能です。

プロパティはバックドプロパティか仮想プロパティのどちらかになります。 バックドプロパティは実体の値を持つプロパティです。 フックを持たないプロパティは、全てバックドプロパティになります。 仮想プロパティは、実体の値を必要としないフックだけを持つプロパティです。 この場合、フックは実質的にメソッドと等価であり、 オブジェクトはこのプロパティ用のメモリを必要としません。

プロパティフックは readonly プロパティと同時に使えません。 getset の動作の変更だけでなく アクセス自体の制限も行いたい場合、 非対称可視性プロパティ を使ってください。

基本的なフック構文

フックを宣言する一般的な構文は次のとおりです。

例1 プロパティフック (フルバージョン)

<?php
class Example
{
    private bool $modified = false;

    public string $foo = 'default value' {
        get {
            if ($this->modified) {
                return $this->foo . ' (modified)';
            }
            return $this->foo;
        }
        set(string $value) {
            $this->foo = strtolower($value);
            $this->modified = true;
        }
    }
}

$example = new Example();
$example->foo = 'changed';
print $example->foo;
?>

$foo プロパティの末尾はセミコロンではなく {} です。 これがフックの存在を表します。 ここでは getset 両方のフックを定義していますが、 一方だけ定義することも可能です。 どちらのフックも {} の中に任意のコードを書くことができます。

set フックは、渡される値の型と名前を メソッドと同じ書式で指定できます。 この型はプロパティの型と同じか、 反変 (より広い型) でなければなりません。 たとえば string 型のプロパティに対しては、 stringStringable を受け取る set フックを定義できますが、 array のみを受け取るものは定義できません。

少なくとも一方のフックが $this->foo(プロパティ自体)を参照しているため、 このプロパティはバックドプロパティになります。 $example->foo = 'changed' が呼び出されると、 渡された文字列は小文字に変換され、それが値として保存されます。 プロパティを読み取る際には、保存された値に対し、条件に応じて追加の文字列が 付与されます。

よくあるケースに対応するために、いくつかの短縮構文が用意されています。

get フックが単独の式である場合、 {} を省略してアロー式で置き換えることができます。

例2 プロパティの get 式

この例は前の例と等価です。

<?php
class Example
{
    private bool $modified = false;

    public string $foo = 'default value' {
        get => $this->foo . ($this->modified ? ' (modified)' : '');

        set(string $value) {
            $this->foo = strtolower($value);
            $this->modified = true;
        }
    }
}
?>

set フックのパラメータの型がプロパティの型と同じ場合 (典型的にはそうなります)、 その型は省略可能です。この場合、渡される値には自動的に $value という名前が付きます。

例3 プロパティ set のデフォルト

この例は前の例と等価です。

<?php
class Example
{
    private bool $modified = false;

    public string $foo = 'default value' {
        get => $this->foo . ($this->modified ? ' (modified)' : '');

        set {
            $this->foo = strtolower($value);
            $this->modified = true;
        }
    }
}
?>

set フックが、渡された値を変形して保存するだけの場合には、 アロー式でより簡略にできます。 式の評価結果が値として保存されます。

例4 プロパティの set 式

<?php
class Example
{
    public string $foo = 'default value' {
        get => $this->foo . ($this->modified ? ' (modified)' : '');
        set => strtolower($value);
    }
}
?>

この例は $this->modified の値を更新していないため 前の例と等価ではありません。 set フック内で複数の文が必要な場合、中括弧付きの構文を使用してください。

プロパティは状況に応じて、一方または双方のフックを実装するか、どちらも実装しないことができます。 それぞれの短縮構文は独立しており、 たとえば「短縮構文の get と 通常の set」 「型を明示した短縮構文の set」など、いずれも有効です。

バックドプロパティで getset のフックを省略した場合、 それはデフォルトの読み書き動作になります。

注意: フックは コンストラクタのプロモーション にも定義できます。ただし、コンストラクタで受け取る値は set フックが受け入れる型ではなく、 プロパティの本来の型に一致しなければならない点に注意してください。 次のような例を考えます:

class Example
{
    public function __construct(
        public private(set) DateTimeInterface $created {
            set (string|DateTimeInterface $value) {
                if (is_string($value)) {
                    $value = new DateTimeImmutable($value);
                }
                $this->created = $value;
            }
        },
    ) {
    }
}
内部では次のような形で処理されます:
class Example
{
    public private(set) DateTimeInterface $created {
        set (string|DateTimeInterface $value) {
            if (is_string($value)) {
                $value = new DateTimeImmutable($value);
            }
            $this->created = $value;
        }
    }

    public function __construct(
        DateTimeInterface $created,
    ) {
        $this->created = $created;
    }
}
コンストラクタ以外からプロパティに値を書き込む時は stringDateTimeInterface を受け入れますが、 コンストラクタからは DateTimeInterface のみ受け入れます。 プロパティの型 (DateTimeInterface) が コンストラクタのシグネチャのパラメータ型として使われ、 set フックが受け入れる型は参照されないためです。 このような振る舞いがコンストラクタでも必要な場合、 コンストラクタのプロモーションは使用できません。

仮想プロパティ

仮想プロパティは、値を保持しないプロパティです。 getset いずれのフックも プロパティ自体を正確に参照していない場合、それは仮想プロパティになります。 例えば、$foo という名前のプロパティのフックに $this->foo というコード含まれれば、それはバックドプロパティです。 次のプロパティはバックドプロパティではなく、エラーが発生します:

例5 無効な仮想プロパティ

<?php
class Example
{
    public string $foo {
        get {
            $temp = __PROPERTY__;
            return $this->$temp; // $this->foo を正確に参照していません
        }
    }
}
?>

仮想プロパティでは、フックを省略するとその操作は存在しないとみなされ、 使おうとするとエラーが発生します。 仮想プロパティはオブジェクト内でメモリを消費しません。 仮想プロパティは、例えば他の2つのプロパティを組み合わせて値を作るような 「派生」プロパティに向いています。

例6 仮想プロパティ

<?php
readonly class Rectangle
{
    // 仮想プロパティ
    public int $area {
        get => $this->h * $this->w;
    }

    public function __construct(public int $h, public int $w) {}
}

$s = new Rectangle(4, 5);
print $s->area; // 20 と表示
$s->area = 30; // set が定義されていないためエラー
?>

仮想プロパティに対して getset 両方のフックを定義することもできます。

スコープ

全てのフックは、オブジェクトスコープで動作します。 すなわち、全ての public、private、protected な メソッドやプロパティに対してアクセスできます。 フックを持つプロパティへアクセスする場合も同様です。 フック内で別のプロパティにアクセスしても、そのフックがバイパスされることはありません。

重要なのは、フック内で必要に応じて、 任意の複雑なメソッドを呼び出すこともできるという点です。

例7 フックからメソッド呼び出し

<?php
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;
    }
}
?>

リファレンス

フックはプロパティの読み書きの操作へ干渉するため、 プロパティへのリファレンスを取得したり、 $this->arrayProp['key'] = 'value'; のような 間接的な変更操作を行う場合に問題が発生します。 リファレンスによる値の変更が set フックをバイパスしてしまうからです。

プロパティからフックを経由してリファレンスを取得する必要がある場合、 get フックの先頭に & を付与します。 同じプロパティで get&get を両方定義すると 構文エラーになります。

バックドプロパティでは &get フックと set フックは同時に定義できません。 前述のとおり、リファレンスへの書き込みは set フックをバイパスしてしまうためです。 仮想プロパティでは、両フックから共有される実体の値がないため、同時に定義しても問題ありません。

配列プロパティのインデックスへ書き込みを行う場合も、暗黙的にリファレンスが使われます。 従って、フックを伴う配列のバックドプロパティの要素の書き換えができるのは、 &get フックだけが定義されている場合に限ります。 仮想プロパティの場合、 get&get から返された配列の書き換え自体は可能ですが、 それが実際にオブジェクトに反映されるかはフックの実装次第です。

配列全体を上書きする場合は問題ありません。他のプロパティと同様に扱われます。 配列の個々の要素を操作する場合のみ注意が必要です。

継承

final フック

フックは final としても宣言することもできます。 その場合はオーバーライドできなくなります。

例8 final フックの例

<?php
class User
{
    public string $username {
        final set => strtolower($value);
    }
}

class Manager extends User
{
    public string $username {
        // これは許可される
        get => strtoupper($this->username);

        // parentのsetがfinalのため許可されない。
        set => strtoupper($value);
    }
}
?>

プロパティ自体を final と宣言することもできます。 final で宣言されたプロパティは子クラスで再宣言できず、 フックを変更したりアクセス権を広げることもできません。

final と宣言したプロパティに対しフックも final とするのは 単に冗長であり無視されます。 これは final メソッドと同じ動作です。

子クラスでは、オーバーライドしたいフックを再定義することで、 フックを個別に上書きできます。 フックを持たないプロパティにフックを追加することもできます。 フックがメソッドのように振る舞うという点で、一貫した動作です。

例9 フックの継承

<?php
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;
        }
    }
}
?>

それぞれのフックは親の実装を個別にオーバーライドします。 子クラスがフックを追加する場合、親クラスのプロパティで設定されたデフォルト値は削除され、再宣言が必要です。 これはフックのないプロパティを継承する場合と同じ動作です。

親フックへのアクセス

子クラスのフックから parent::$prop に続き目的のフックを指定することで、 親クラスのプロパティにアクセスできます。 例えば parent::$propName::get() は、 「親クラスに定義された prop の get 操作を実行する」 という意味になります。同様に set 操作も実行できます。

これらの方法でアクセスしない限り、親クラスのフックは無視されます。 これはメソッドの動作と同じです。 この方法で親クラスの記憶領域にアクセスできます。 親クラスのプロパティにフックが存在しない場合、 デフォルトの get/set 動作が使われます。 フックは、自分自身のプロパティにおける親フック以外は呼び出せません。

上記の例をより効率的に書くと、以下のようになります。

例10 親フックへのアクセス (set)

<?php
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;
        }
    }
}
?>

get フックだけをオーバーライドする場合、次の例のようになります:

例11 親フックへのアクセス (get)

<?php
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());
    }
}
?>

シリアライズ

PHP には、オブジェクトを外部で利用したり デバッグしたりするための、いくつかのシリアライズ手段があります。 その際のフックの挙動は、用途によって異なります。 あるケースでは、プロパティに保存された生の値が使われ、 フックはバイパスされます。 別のケースでは、通常の読み書きと同じように フックを通して処理されます。

  • var_dump: 生の値を使用
  • serialize: 生の値を使用
  • unserialize: 生の値を使用
  • __serialize()/__unserialize(): カスタムロジックと get/set フック
  • 配列キャスト: 生の値を使用
  • var_export: get フックを使用
  • json_encode: get フックを使用
  • JsonSerializable: カスタムロジックと get フック
  • get_object_vars: get フックを使用
  • get_mangled_object_vars: 生の値を使用