PHPでオブジェクトをキーにする — SplObjectStorageとWeakMapの使い分け

SplObjectStorage と WeakMap は、どちらも「オブジェクトそのものをキーにして値を持つ」ためのPHP標準のデータ構造です。違いは参照の強さで、WeakMap はキーのオブジェクトが不要になれば自動でエントリごと消えます。

PHPの配列のキーは int か string しか使えません。だから「このオブジェクトに紐づくメタ情報を覚えておきたい」という場面で、多くの人は spl_object_id() を取って配列のキーにします。私も昔はそう書いていました。でもこれ、地味に事故ります。

spl_object_id を配列キーにすると何が起きるのか

IDは「今このオブジェクトが生きている間だけ」有効な番号です。オブジェクトが破棄されると、その番号は次に作られた別のオブジェクトに再利用されます。

<?php
$cache = [];

$a = new stdClass();
$cache[spl_object_id($a)] = 'Aのメタ情報';

unset($a); // ここでIDが空く

$b = new stdClass();
var_dump(spl_object_id($b));
var_dump($cache[spl_object_id($b)] ?? 'なし');
// → 'Aのメタ情報' が出てくることがある

まったく無関係な $b が、消したはずの $a のデータを拾ってしまう。しかもこれ、負荷の高い環境や長時間動くワーカーでだけ再現するので、原因にたどり着くまでが長いです。私が実際にこれを踏んだときは、キャッシュの内容がたまに混線するという報告から二日ほど溶かしました。

SplObjectStorage はどう書く?

オブジェクトを直接キーにできる、ハッシュマップ的なクラスです。配列アクセス構文がそのまま使えます。

<?php
$storage = new SplObjectStorage();

$user = new stdClass();
$storage[$user] = ['role' => 'admin', 'checked_at' => time()];

var_dump(isset($storage[$user])); // true
var_dump($storage[$user]['role']); // 'admin'
var_dump(count($storage)); // 1

// 走査もできる
foreach ($storage as $obj) {
    $meta = $storage[$obj]; // ループ中は現在のオブジェクトで引ける
}

IDの再利用を気にしなくてよくなるのが一番の収穫です。SplObjectStorage はキーのオブジェクトへの強参照を持つので、格納した時点でそのオブジェクトは絶対に破棄されなくなります。これは「意図的に生かしておきたい」ときには利点ですが、そうでないときは次の問題になります。

なぜ WeakMap が必要なのか

PHP 8.0 で入った WeakMap は、キーを弱参照で持ちます。つまり、外部からそのオブジェクトへの参照がなくなった瞬間に、マップ側のエントリも一緒に消えます。

<?php
$weak = new WeakMap();
$strong = new SplObjectStorage();

$obj = new stdClass();
$weak[$obj] = 'メタ情報';
$strong[$obj] = 'メタ情報';

var_dump(count($weak), count($strong)); // 1, 1

unset($obj);

var_dump(count($weak));   // 0 ← 自動で消えた
var_dump(count($strong)); // 1 ← 居座っている

この差が効いてくるのが、エンティティに付随する計算結果を覚えておく用途です。たとえばORMのエンティティごとに重い集計結果をメモ化するとき、SplObjectStorage でやるとリクエストが終わってもエンティティが解放されず、長時間動くプロセス(キューワーカーやSwoole/RoadRunner系)ではじわじわメモリが増えます。WeakMap ならエンティティの寿命に自動で追従します。

<?php
final class ScoreCalculator
{
    private WeakMap $memo;

    public function __construct()
    {
        $this->memo = new WeakMap();
    }

    public function calculate(User $user): int
    {
        // すでに計算済みならそれを返す
        return $this->memo[$user] ??= $this->heavyCalculation($user);
    }

    private function heavyCalculation(User $user): int
    {
        // 重い処理
        return 42;
    }
}

??= がそのまま使えるので、メモ化のコードがかなり短く書けます。しかも $user がGCされればメモも消えるため、明示的なクリア処理を書かなくてよくなります。キャッシュの寿命管理を自分で書かなくていい、というのが実務での一番の旨味だと思います。

使い分けの基準

私は「そのオブジェクトの生存を、このコレクションが決めるべきか」で切り分けています。決めるべきならSplObjectStorage、決めるべきでないならWeakMapです。

SplObjectStorage が向くのは、オブザーバの登録リスト、処理待ちキュー、訪問済みノードの集合など、コレクション自体が「持ち主」である場合。attach() / detach() / contains() があるので、値を持たない単なる集合(Set)としても使えます。

<?php
// 循環参照のあるグラフを辿るときの訪問済み管理
function walk(Node $node, SplObjectStorage $visited): void
{
    if ($visited->contains($node)) {
        return; // 無限ループ回避
    }
    $visited->attach($node);

    foreach ($node->children() as $child) {
        walk($child, $visited);
    }
}

一方 WeakMap が向くのは、メモ化、オブジェクトへの後付けメタデータ、イベントリスナの紐づけなど、「本体が消えたら一緒に消えてほしい」もの全般です。

ハマりどころ

WeakMap のキーはオブジェクトのみです。int や string を渡すと TypeError になります。当たり前ですが、汎用キャッシュの置き換えとしては使えません。

もう一つ、値の側にキーのオブジェクトを入れると弱参照の意味がなくなります。これは静かに効いてくるので注意したいところです。

<?php
$weak = new WeakMap();
$obj = new stdClass();

// これはダメ:値からキー自身を強参照している
$weak[$obj] = ['owner' => $obj, 'score' => 10];

unset($obj);
var_dump(count($weak)); // 1 のまま。解放されない

値の中に、キーを間接的に参照するオブジェクト(親を辿れるエンティティなど)を入れてしまうケースも同様です。WeakMap を入れたのにメモリが減らない、というときはまずここを疑うといいと思います。

あとは count() のタイミング。WeakMap の要素数はGCの動作に依存するので、テストで厳密な件数を検証するときは gc_collect_cycles() を挟まないと不安定になることがあります。

まとめ

オブジェクトをキーにしたくなったら、まず spl_object_id() を配列キーにする書き方はやめる。コレクションがオブジェクトの持ち主なら SplObjectStorage、そうでなく本体に寄生するだけのデータなら WeakMap。この二択で考えると、だいたい迷わなくなります。

特に WeakMap はメモ化との相性が良くて、$this->memo[$obj] ??= ... の一行で済むのが気持ちいいです。長時間動くPHPプロセスが増えてきた今のほうが、出番は多いんじゃないかという気がしています。

よくある質問

Q. SplObjectStorage と WeakMap、どちらが速いですか?
A. 実測では大きな差は出にくく、WeakMap のほうがやや軽い場面が多いです。ただ選択基準は速度ではなく参照の強さなので、そちらで決めるのが安全です。

Q. WeakReference との違いは何ですか?
A. WeakReference は単一オブジェクトへの弱い参照を1つ保持するもので、WeakMap はそれをキーにしたマップです。1対1の紐づけだけなら WeakReference、複数を管理するなら WeakMap が向きます。

Q. 通常のリクエスト単位のPHPでも WeakMap を使う意味はありますか?
A. リクエスト終了で全部解放されるため、メモリ面の恩恵は限定的です。それでもIDの再利用事故を防げる点と、キャッシュのクリア処理を書かずに済む点で使う価値はあると思います。

類似投稿

コメントを残す

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