JavaScriptには、キーと値を組にして保存するMapと、似た名前のWeakMapがあります。どちらもset()で登録して、get()で取り出せます。ただ、WeakMapにはsizeもkeys()もありません。

WeakMapは、オブジェクトに情報を対応付けつつ、その登録だけを理由にオブジェクトをメモリへ残し続けないための仕組みです。「弱い」という名前が指しているのは、データの信頼性や検索の精度ではなく、キーを生かす参照の扱いです。

まずは使う側から見ていきます。GC内部での処理と実際の不具合は、続きのWeakMapと世代別GCで扱います。

オブジェクトに情報を付ける

たとえば、あるオブジェクトが何回使われたかを、オブジェクト本体へプロパティを追加せずに記録したいとします。

const counts = new WeakMap();
const item = { name: "button" };

counts.set(item, 0);
counts.set(item, counts.get(item) + 1);

console.log(counts.get(item)); // 1
console.log(item); // { name: "button" }

itemをキーにして、回数を別の場所に保存しています。オブジェクト自体を書き換えないので、元からあるプロパティとの名前の衝突を避けられます。凍結されたオブジェクトにも、外側から情報を対応付けられます。

const item = Object.freeze({ name: "button" });
const descriptions = new WeakMap();

descriptions.set(item, "画面の送信ボタン");
console.log(descriptions.get(item)); // 画面の送信ボタン

ここで凍結しているのはitemです。WeakMapへの登録はitemのプロパティを変更していません。

基本操作はset()、get()、has()、delete()です。登録していないキーのget()はundefinedを返します。値としてundefinedを登録することもできるので、その場合に登録の有無を判定するにはhas()を使います。

const data = new WeakMap();
const key = {};

data.set(key, undefined);
console.log(data.get(key)); // undefined
console.log(data.has(key)); // true

console.log(data.delete(key)); // true
console.log(data.has(key)); // false

これらのメソッドの振る舞いは、ECMAScriptのWeakMapの定義で確認できます。

同じ内容でも別のキー

WeakMapはオブジェクトの内容を比較して検索するものではありません。

const data = new WeakMap();
const a = { id: 1 };
const b = { id: 1 };

data.set(a, "登録済み");

console.log(data.get(a)); // 登録済み
console.log(data.get(b)); // undefined
console.log(data.get({ id: 1 })); // undefined

aとbは、どちらもid: 1という内容ですが、別のオブジェクトです。コピーを作っても元のキーにはなりません。逆に、同じオブジェクトのプロパティを変更しても、そのオブジェクトをキーにした登録は引き続き取り出せます。

IDの数値や名前の文字列で検索したい場合は、Mapを使う方が自然です。WeakMapのキーに文字列や数値をset()するとTypeErrorになります。

現在の仕様では、オブジェクトに加えて、Symbol.for()で共有登録されていないSymbolもキーにできます。Symbol("id")は使えますが、Symbol.for("id")は使えません。この区別はCanBeHeldWeaklyで定義されています。この記事の例では、用途が分かりやすいオブジェクトを使います。

Mapに残る参照

通常のMapにも、オブジェクトをキーとして登録できます。

const data = new Map();
let item = { name: "button" };

data.set(item, { clicks: 0 });
item = null;

console.log(data.size); // 1

item = nullは、変数itemから元のオブジェクトへの参照を外す操作です。オブジェクトそのものを削除する命令ではありません。Mapはまだキーを保持しているので、Mapがプログラムから到達可能な間、キーも値も残ります。

flowchart LR
    R[プログラムから使えるMap] --> M[Mapのエントリ]
    M --> K[キーのオブジェクト]
    M --> V[値]

GCは、プログラムが今後使える場所から参照をたどって、必要なオブジェクトを調べます。この走査の出発点をrootと呼びます。変数への参照を一つ外しても、別の経路でrootから到達できるなら、回収する対象にはなりません。

このMapの例では、keys()でキーを取り出すこともできます。変数itemから使えなくなっても、Mapから使える状態です。

オブジェクトの補助情報を長寿命のMapへ入れ、不要になった登録を消さずにいると、Mapがキーを保持し続けます。画面の部品が何度も作り直されるような処理では、使わなくなった部品が補助情報のMapを通じて残ることがあります。

WeakMapへの登録だけでは残らない

同じ対応付けをWeakMapにすると、登録だけを理由にキーを保持しません。

const data = new WeakMap();
let item = { name: "button" };

data.set(item, { clicks: 0 });
item = null;

このコードのほかにキーへの参照がなければ、キーは回収可能になります。WeakMapのために、使い終わったキーを取り出してdelete()する必要はありません。オブジェクトを使える期間に合わせて、補助情報を持たせられます。MDNのWeakMap解説でも、外部からオブジェクトへ情報を対応付ける用途が説明されています。

ただし、WeakMapに入れたら必ず短時間でメモリが減る、ということではありません。ほかの配列や変数、実行予定の処理などがキーを保持していれば、キーはまだ使えます。GCが実際に走る時期も、JavaScriptのこのコードからは決まりません。

また、WeakMapの値を取り出して別の場所に保存すれば、その値には新しい参照ができます。WeakMapのエントリが不要になったからといって、外で使っている値まで消えるわけではありません。

キーを一覧にできない

WeakMapにはkeys()、values()、entries()やsizeがありません。for...ofでエントリを列挙することもできません。

列挙できると、すでに使わなくなったキーがGCによって残っているか、消えているかを観測できてしまいます。プログラムの結果が、GCを実行した時期に左右されます。WeakMapは、手元にあるキーを渡してその対応付けを調べるAPIにしています。仕様の説明でも、列挙を提供しない理由としてこの点を挙げています。

has(key)を呼ぶには、呼び出す側がそのキーを持っている必要があります。列挙との違いはここです。WeakMapだけが知っているキーを、外から探し出すことはできません。

件数を表示したい、登録内容を全部処理したい、古いエントリから順番に捨てたい、といった用途ではMapを選び、削除の条件を自分で管理します。WeakMapへ入れたキーを別の配列に保存して一覧を作ると、その配列がキーを強く保持します。

オブジェクトごとの計算結果

オブジェクトを入力にした計算の結果を、そのオブジェクトが使われる間だけ再利用する用途にも使えます。

const labels = new WeakMap();

function labelFor(user) {
  if (labels.has(user)) {
    return labels.get(user);
  }

  const label = `${user.familyName} ${user.givenName}`;
  labels.set(user, label);
  return label;
}

同じuserを渡せば保存した結果を返します。どこからも使われなくなったuserを、キャッシュへの登録だけで残し続けることはありません。

一方、user.familyNameを書き換えても、WeakMapは自動で再計算してくれません。キーは同じオブジェクトのままなので、古い結果を返します。入力を変更しない設計にするか、変更時にlabels.delete(user)で無効化する必要があります。

WeakMapはキャッシュの寿命をキーに対応させられますが、計算結果の正しさやメモリ使用量の上限までは管理しません。キーを多数使い続けるプログラムでは、その分の登録も残ります。

値がキーを参照する場合

ここまでの例では、値に数値や文字列を入れていました。値にオブジェクトを入れ、その中からキーを参照することもできます。

const data = new WeakMap();
let item = { name: "button" };

data.set(item, { owner: item });
item = null;

もしWeakMapが値を無条件に保持すると、値のownerを通じてキーに戻れてしまいます。これでは、使い終わったキーをエントリ自身が残し続けます。

そのため、WeakMapの参照関係を実現するGCでは、キーの到達が確認できた場合に値をたどる、という条件付きの扱いが必要になります。この関係を表す仕組みがephemeronです。Racketのephemeronの説明にも、値からキーへ戻る参照がある場合の扱いが記されています。

値を通じた参照だけで、そのエントリ自身の生存条件を満たすことはできません。反対に、値を別の変数で使い続けていれば、その変数からownerを経由してキーにも到達できます。この場合、キーが残る理由はWeakMapへの登録ではなく、外側からの通常の参照です。

ここではGCによる生存関係を説明しています。不要になったオブジェクトが、その場で必ず解放されることを示す実行例ではありません。

続きのWeakMapと世代別GCでは、この条件付き参照を解決する順番と、古いオブジェクトから若いオブジェクトへの参照を記録する仕組みを扱います。Omoikaneに組み込まれたBoaで、到達可能なオブジェクトが誤回収された修正を、実装と回帰テストから見ていきます。