PHPのArrayAccessインターフェースで配列っぽいオブジェクトを作る

ArrayAccessは、オブジェクトを$obj['key']のように配列の添字構文で読み書きできるようにするPHPの組み込みインターフェースです。

設定値を保持するクラスやDTOっぽいものを作るとき、「配列でも受け取れるしオブジェクトでもアクセスできる」ものが欲しくなる場面が実務では時々あります。ArrayAccessはそういうときの選択肢の一つです。ただ、便利さの割に落とし穴も多いインターフェースだと感じています。

ArrayAccessは何を実装させるのか

実装すべきメソッドは4つだけです。offsetExistsoffsetGetoffsetSetoffsetUnset。これらを定義すると、そのクラスのインスタンスに対してisset($obj['x'])$obj['x'] = 1unset($obj['x'])が使えるようになります。

final class Config implements ArrayAccess
{
    private array $data;

    public function __construct(array $data = [])
    {
        $this->data = $data;
    }

    public function offsetExists(mixed $offset): bool
    {
        return isset($this->data[$offset]);
    }

    public function offsetGet(mixed $offset): mixed
    {
        return $this->data[$offset] ?? null;
    }

    public function offsetSet(mixed $offset, mixed $value): void
    {
        if ($offset === null) {
            $this->data[] = $value;
        } else {
            $this->data[$offset] = $value;
        }
    }

    public function offsetUnset(mixed $offset): void
    {
        unset($this->data[$offset]);
    }
}

内部的には結局ただの配列をラップしているだけです。ArrayAccessは「配列っぽく見せる皮」であって、中身の実装まで面倒を見てくれるわけではありません。

なぜ生の配列ではなくこれを使うのか

一番の理由は、読み書きのタイミングでバリデーションやログを差し込めることだと思います。生の配列だと$config['timeout'] = 'abc'のような代入を止める術がありませんが、offsetSetの中で型チェックを入れれば防げます。

public function offsetSet(mixed $offset, mixed $value): void
{
    if ($offset === 'timeout' && !is_int($value)) {
        throw new InvalidArgumentException('timeout must be int');
    }
    $this->data[$offset] = $value;
}

もう一つは、既存コードが配列前提で書かれているレガシー資産に対して、後からオブジェクトの振る舞いを混ぜたいケースです。既存の呼び出し側を$config['db_host']のまま維持しつつ、裏側だけ差し替えるような移行に使えます。個人的には、新規設計でここまでするならバリューオブジェクトかDTOを素直に作った方が見通しは良いと思っていて、ArrayAccessは「配列との互換性を切りたくない事情がある」ときの妥協策という位置づけで使っています。

参照渡しでハマるところ

ArrayAccessを実装したオブジェクトは、配列の多次元アクセスや参照渡しで期待通りに動かないことがあります。特に$obj['a']['b'] = 1のようなネストした代入は、offsetGet('a')が値のコピーを返す実装だと反映されません。

// offsetGetがコピーを返す実装だと、これは無言で失敗する
$config['nested']['key'] = 'value';

これを動かすにはoffsetGetの戻り値を参照で返す必要があり、public function &offsetGet(...)のように書き換えることになります。ただし参照を返す設計は途端にコードが読みにくくなるので、私はネスト構造が必要になった時点でArrayAccessをやめて、専用のメソッドを生やす方に倒すことが多いです。「配列っぽく振る舞わせる」のは1階層までに留めておくのが無難だという気がしています。

foreachで回したいならCountableとIteratorAggregateも要る

ArrayAccessだけではforeachで回すことはできません。count()を使いたければCountable、foreachで回したければIteratorAggregateかIteratorを別途実装する必要があります。「配列みたいなオブジェクト」を本気で作るなら、この3〜4個をセットで実装することになりがちです。

final class Config implements ArrayAccess, Countable, IteratorAggregate
{
    // ...offsetGet等は省略

    public function count(): int
    {
        return count($this->data);
    }

    public function getIterator(): Iterator
    {
        return new ArrayIterator($this->data);
    }
}

ここまで来ると、SPLにArrayObjectという既製品があることを思い出します。単純に配列をオブジェクトとして扱いたいだけなら、自前でArrayAccessを実装するよりArrayObjectを継承するか、それをラップする方が早いです。自作するのは、バリデーションや不変条件などArrayObjectにはない振る舞いを差し込みたいときに限るべきだと思います。

まとめ

ArrayAccessは配列の見た目を保ったままオブジェクトの振る舞いを混ぜられる便利なインターフェースですが、ネストした代入や参照渡りの挙動はふつうの配列と完全には一致しません。既存の配列ベースのコードとの互換性を維持しながら少しずつ値オブジェクトへ寄せていく、という限定的な用途で使うのが個人的にはしっくりきています。何でもかんでも配列っぽくするより、素直にオブジェクトとして設計した方が結局は読みやすいコードになることの方が多い気がしています。

よくある質問

Q. ArrayAccessを実装したオブジェクトはjson_encodeできますか?
A. ArrayAccess自体はJSON変換に関与しません。json_encodeで配列やオブジェクトとして出力したい場合は、別途JsonSerializableを実装してjsonSerializeメソッドを定義する必要があります。

Q. ArrayAccessとstdClassの動的プロパティはどう違いますか?
A. stdClassの動的プロパティは$obj->key形式のアクセスで、任意のプロパティを自由に追加できます。ArrayAccessは$obj['key']形式で、offsetGet/offsetSetの中に自分でロジックを書ける点が異なります。制御したい処理があるならArrayAccess、単なるデータの入れ物ならstdClassやオブジェクトリテラル的な使い方が向いています。

Q. 配列とArrayAccessオブジェクトはis_arrayで区別できますか?
A. できます。ArrayAccessを実装したオブジェクトはis_array()ではfalseになります。配列かどうかを判定したい既存コードにArrayAccessオブジェクトを渡すと、型チェックで弾かれることがあるので注意が必要です。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です