PHPのWeakMapでオブジェクトに紐づくキャッシュを安全に持つ
WeakMapとは、オブジェクトをキーにできる連想配列のような構造で、キーにしたオブジェクトが他から参照されなくなった時点で自動的にエントリごと消えるものです。PHP 8.0で追加されました。
「オブジェクトに紐づく計算結果をキャッシュしたいけど、そのオブジェクトが破棄されたらキャッシュも一緒に消えてほしい」という場面、実務だとたまにあります。普通の配列やSplObjectStorageでキャッシュを作ると、オブジェクトへの参照がキャッシュ側にも残ってしまい、本体が要らなくなってもメモリ上に居座り続けてしまうんですね。WeakMapはそこを解決するために作られた仕組みです。
SplObjectStorageと何が違うのか
見た目はSplObjectStorageに似ています。オブジェクトをキーにできて、値を自由に持てる点は同じです。違うのは参照カウントへの影響で、SplObjectStorageのキーはオブジェクトの参照カウントを1つ増やしますが、WeakMapのキーは増やしません。つまりWeakMapに入れているだけでは、そのオブジェクトの寿命を延ばさないということです。
公式マニュアルの例がわかりやすいので、そのまま挙げます。
<?php
$wm = new WeakMap();
$o = new stdClass;
class A {
public function __destruct() {
echo "Dead!\n";
}
}
$wm[$o] = new A;
var_dump(count($wm)); // int(1)
unset($o);
// この時点で "Dead!" が出力される(オブジェクトが破棄された)
var_dump(count($wm)); // int(0)
$oを指しているのはWeakMapのキーだけになった瞬間、そのオブジェクトはガベージコレクションの対象になり、WeakMap側のエントリごと消えます。unsetした側で明示的に何もしなくても、キャッシュのほうが勝手に片付いてくれるということです。
どういう場面で使うのか
典型は「あるオブジェクトについての付加情報を、そのオブジェクトの寿命と一致させたい」ケースです。例えば重い計算結果をオブジェクト単位でメモ化したいときに使えます。
<?php
final class ExpensiveCalculator
{
private WeakMap $cache;
public function __construct()
{
$this->cache = new WeakMap();
}
public function calculate(SomeEntity $entity): int
{
if (isset($this->cache[$entity])) {
return $this->cache[$entity];
}
$result = $this->heavyComputation($entity);
$this->cache[$entity] = $result;
return $result;
}
private function heavyComputation(SomeEntity $entity): int
{
// 実際は重い処理
return array_sum(str_split((string) $entity->id()));
}
}
$entityがどこからも参照されなくなれば、キャッシュ側のエントリも自動で消えます。手動でunsetやexpireの仕組みを書かなくていいのが嬉しいところです。長時間動くバッチやリクエスト処理の中で、同じオブジェクトに対して何度も同じ計算をする場合に向いています。
WeakMapに入れても消えないケースがある
ここは実際にハマりやすい点です。Enumのケース(インスタンス)をWeakMapのキーにすると、想定通りには消えません。Enumのケースは内部的に常に参照が残っている扱いになるため、unsetしても件数は減らないという報告がPHPの公式マニュアルのユーザーコメント欄にも上がっています。
<?php
enum Status
{
case Active;
case Inactive;
}
$weakMap = new WeakMap();
$object = Status::Active;
$weakMap[$object] = 123;
unset($object);
var_dump($weakMap->count()); // 1(消えない)
Enumのケースを弱参照キーにして「使われなくなったら自動で消える」ことを期待すると、その前提が崩れます。消したいなら明示的にunset($weakMap[$object])するしかありません。WeakMapを使う対象は「普通のオブジェクト」に限定して考えたほうが安全です。
foreachでの挙動もSplObjectStorageと違う
もう一点、地味に差があるのがforeachした時のキーです。SplObjectStorageは連番の整数インデックスがキーとして出てきますが、WeakMapはオブジェクト自体がキーとして返ってきます。
<?php
class A {}
$a = new A;
$objectStorage = new SplObjectStorage();
$weakMap = new WeakMap();
$objectStorage[$a] = 1;
$weakMap[$a] = 1;
foreach ($objectStorage as $key => $value) {
var_dump($key); // int(0)
}
foreach ($weakMap as $key => $value) {
var_dump($key); // object(A)#1 (0) {}
}
SplObjectStorageからWeakMapに書き換えるときは、この違いでforeach内のロジックが壊れることがあるので、単純な置き換えのつもりで直すと足元をすくわれます。
ArrayAccessとして使えるので配列っぽく書ける
WeakMapはArrayAccess、Countable、IteratorAggregate(Traversable経由)を実装しているので、$map[$obj]のような添字アクセスやcount($map)、foreachがそのまま使えます。専用のメソッドを覚える必要がほぼないのは扱いやすい点だと思います。
まとめ
WeakMapは、オブジェクトの寿命に紐づけて何かを保持したいときに、参照カウントを増やさずに済む仕組みです。オブジェクト単位のメモ化キャッシュやメタデータの保持に向いていて、手動での後片付けが要らなくなるのが利点です。ただしEnumケースのように内部的に参照が残り続けるものをキーにすると期待通りに消えない、foreachのキーの挙動がSplObjectStorageと違う、といった細部の癖があるので、そこだけ押さえておけば実務で十分使える機能だという気がしています。
よくある質問
Q. WeakMapとSplObjectStorageはどちらを使うべきですか?
A. キーにしたオブジェクトの寿命が尽きたら一緒に消えてほしいならWeakMap、オブジェクトの寿命をキャッシュ側で延ばしたい、または明示的に管理したいならSplObjectStorageが向いています。
Q. WeakMapのキーに配列や文字列は使えますか?
A. 使えません。キーにできるのはオブジェクトのみです。
Q. WeakMapはPHPの何のバージョンから使えますか?
A. PHP 8.0から利用できます。