2024年9月24日

WeakMap と WeakSet

チャプター ガベージコレクション で学んだ通り、JavaScriptエンジンは、それが到達可能な(そして潜在的に利用される可能性がある)間、メモリ上に値を保持しています。

例:

let john = { name: "John" };

// オブジェクトへはアクセス可能です。john がその参照を持っています

// 参照を上書きします
john = null;

// オブジェクトはメモリから削除されるでしょう

通常、オブジェクトまたは配列の要素、もしくは別のデータ構造のプロパティは到達可能と考えられ、そのデータ構造がメモリにいる間は保持され続けます。

例えば、あるオブジェクトを配列に入れた場合、その配列が生きている間は、他の参照がなくてもそのオブジェクトは生きていることになります。

例:

let john = { name: "John" };

let array = [ john ];

john = null; // 参照を上書きします

// 以前 john で参照されていたオブジェクトは配列内に格納されています
// そのため、ガベージコレクションされません。
// array[0] で取得することが可能です

また、通常の Map のキーとしてオブジェクトを使うと、Map が存在している間はそのオブジェクトも存在します。これはメモリを占め、ガベージコレクションされないかもしれません。

例:

let john = { name: "John" };

let map = new Map();
map.set(john, "...");

john = null; // 参照を上書きします

// john は map の中に保持されています
// map.keys() で取得することができます

WeakMap はこの点で根本的に異なります。これらはキーオブジェクトのガベージコレクションを妨げることはありません。

例でその意味するところを見ていきましょう。

WeakMap

Map との最初の違いは、WeakMap のキーはプリミティブな値ではなくオブジェクトでなければならないことです:

let weakMap = new WeakMap();

let obj = {};

weakMap.set(obj, "ok"); // 正常に動作します (オブジェクトのキー)

// キーに文字列は使えません
weakMap.set("test", "Whoops"); // エラー, "test" はオブジェクトではないため

いま、オブジェクトをキーとして使用し、そのオブジェクトへの参照が他にない場合、自動的にメモリ(と map)から削除されます。

let john = { name: "John" };

let weakMap = new WeakMap();
weakMap.set(john, "...");

john = null; // 参照を上書きします

// john はメモリから削除されます!

上の例を通常の Map の場合と比べて見てください。WeakMap のキーとしてのみ john が存在する場合、自動的に削除されます。

WeakMap は繰り返しと、メソッド keys(), values(), entries() をサポートしません。そのため、すべてのキーや値を取得する方法はありません。

WeakMap は次のメソッドのみを持っています:

  • weakMap.get(key)
  • weakMap.set(key, value)
  • weakMap.delete(key, value)
  • weakMap.has(key)

なぜこのような制限があるのでしょうか?これは技術的な理由です。もしオブジェクトがすべての他の参照を失った場合(上のコードの john のように)、自動的に削除されます。しかし、技術的には いつクリーンアップが発生するか は正確には指定されていません。

それはJavaScriptエンジンが決定します。エンジンはすぐにメモリのクリーンアップを実行するか、待ってより多くの削除が発生した後にクリーンアップするかを選択できます。従って、技術的にはWeakMap の現在の要素数はわかりません。エンジンがクリーンアップしている/していない、または部分的にそれをしているかもしれません。このような理由から、WeakMap 全体にアクセスするメソッドはサポートされていません。

さて、どこでこのようなものが必要なのでしょう?

ユースケース: additional data

WeakMap のアプリケーションの主な領域は、 追加のデータ格納 です。

別のコード、おそらくサードパーティライブラリに “属する” オブジェクトを操作していて、それに関連付けられたデータをいくつか保存したい場合、それは元のオブジェクトが生きている間だけ存在している必要があります。このとき、WeakMap はまさに必要とされるものです。

キーとしてオブジェクトを使用して、WeakMap にデータを格納し、オブジェクトがガベージコレクションされたとき、データも同様自動的に消えます。

weakMap.set(john, "secret documents");
// もし john がなくなった場合、秘密のドキュメントは破壊されるでしょう

例を見てみましょう。

例えば、ユーザの訪問カウントを保持するコードがあるとします。情報は map に保持されています。ユーザオブジェクトがキーであり、訪問カウントがその値です。ユーザが離れたとき(そのオブジェクトがガベージコレクションされる)、もうそのユーザの訪問カウントは保持する必要はありません。

これは、 Map を使用したカウント関数の例です:

// 📁 visitsCount.js
let visitsCountMap = new Map(); // map: user => visits count

// 訪問カウントを増やす
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

また、これはコードの別の部分で、おそらく上の関数を使用する別のファイルです:

// 📁 main.js
let john = { name: "John" };

countUser(john); // 訪問をカウント

// あとで john が離脱したとき
john = null;

このとき、john オブジェクトはガベージコレクションされるべきですが、visitsCountMap のキーなので、メモリに残ったままです。

ユーザが削除されたとき、visitsCountMap をクリーンアップする必要があります。そうしないと、メモリ内で無限に大きくなります。このようなクリーニングは複雑なアーキテクチャでは面倒な作業になりえます。

代わりに WeakMap に切り替えることで回避できます:

// 📁 visitsCount.js
let visitsCountMap = new WeakMap(); // weakmap: user => visits count

// 訪問数を増加
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

これで、visitsCountMap をクリーンアップする必要はありません。WeakMap のキーを除いたすべての手段で john オブジェクトが到達不可能になった後、WeakMap からそのキーによる情報とともに、メモリからは削除されます。

ユースケース: キャッシュ

もう一つの一般的な例はキャッシュです。関数からの結果を保持(“キャッシュ”)できるので、同じオブジェクトに対する将来の呼び出しで再利用することができます。

これを実現するために、Map(最適ではないシナリオ)が利用できます:

// 📁 cache.js
let cache = new Map();

// 計算し結果を覚える
function process(obj) {
  if (!cache.has(obj)) {
    let result = obj /* に対する計算結果 */;

    cache.set(obj, result);
  }

  return cache.get(obj);
}

// ここで、別のファイルで process() を使用します。

// 📁 main.js
let obj = {/* オブジェクトがあるとします */};

let result1 = process(obj); // 計算します

// ...その後、別の場所で呼ばれるとします...
let result2 = process(obj); // キャッシュから取得した、記憶された結果が使われます

// ...後ほど、オブジェクトがこれ以上は不要になったとき
obj = null;

alert(cache.size); // 1 (なんと! オブジェクトは依然としてキャッシュされており、メモリを食っています!)

同じオブジェクトので process(obj) の複数回の呼び出しに対して、初回だけ結果の計算を行い、その後は cache から値を取ります。デメリットは、オブジェクトがこれ以上不要になったとき、cache のクリーンアップが必要なことです。

MapWeakMap に置き換えた場合、この問題は消えます。キャッシュされた結果はオブジェクトのガベージコレクト後、自動的にメモリから削除されます。

// 📁 cache.js
let cache = new WeakMap();

// 計算し結果を覚える
function process(obj) {
  if (!cache.has(obj)) {
    let result = obj /* に対する計算結果 */;

    cache.set(obj, result);
  }

  return cache.get(obj);
}

// 📁 main.js
let obj = {/* some object */};

let result1 = process(obj);
let result2 = process(obj);

// ...後ほど、オブジェクトがこれ以上は不要になったとき
obj = null;

// WeakMap なので cache.size は取得できません
// が、0 あるいはすぐに 0 になります
// オブジェクトがガベージコレクトされると、キャッシュされたデータも同様に削除されます。

WeakSet

WeakSet も同様に動作します:

  • Set に似ていますが、WeakSet へはオブジェクトのみ追加できます(プリミティブではありません)
  • オブジェクトは、別の場所から到達可能である間、Set に存在します。
  • Set 同様、add, has, delete をサポートしますが、size, keys() とイテレーションはサポートしません。

“弱い” ので、追加の格納場所としても使えます。ですが、任意のデータではなく、むしろ “はい/いいえ” という boolean ライクな情報を記憶するためのものとしてです。WeakSet のメンバーはオブジェクトについてなにかを意味する場合があります。

例えば、ユーザを WeakSet に追加して、サイトにアクセスしたユーザを追跡できます。:

let visitedSet = new WeakSet();

let john = { name: "John" };
let pete = { name: "Pete" };
let mary = { name: "Mary" };

visitedSet.add(john); // John が訪問
visitedSet.add(pete); // 次に Pete
visitedSet.add(john); // John 再び

// visitedSet は 2 ユーザいます

// John が訪問したかどうかをチェック
alert(visitedSet.has(john)); // true

// Mary が訪問したかをチェック
alert(visitedSet.has(mary)); // false

john = null;

// visitedSet は自動的にクリーンアップされます。

最も注目すべき WeakMapWeakSet の制限は、イテレーションの欠如と現在のすべてのコンテンツを取得することができないことです。これは不便に見えるかもしれませんが、WeakMap/WeakSet がこれらの主要なジョブ – 別の場所に保存/管理されているオブジェクトのデータの “追加の” 保管場所になること – をするのを妨げることはありません。

サマリ

WeakMapMap ライクなコレクションであり、オブジェクトのみがキーとして許可され、他の手段でそのオブジェクトが到達不可能になると、関連付けされた値と一緒に削除されます。

WeakSetSet ライクなコレクションであり、オブジェクトのみが保管でき、他の手段でそのオブジェクトが到達不可能になると、それらも削除されます。

これらの主なアドバンテージは、オブジェクトに対して弱い参照を持っていることです。なので、ガベージコレクションで容易に削除できます。

なお、これには clear, size, keys, values などのサポートがないという代償が伴います。

WeakMapWeakSet は “主要な” オブジェクト保管場所に加え、“2つ目の” データ構造として使用されます。一旦オブジェクトが主要な保管場所から削除されると、それが WeakMap のキーまたは WeakSet でのみ見つかった場合、オブジェクトは自動的にクリーンアップされます。

タスク

重要性: 5

メッセージの配列があります:

let messages = [
    {text: "Hello", from: "John"},
    {text: "How goes?", from: "John"},
    {text: "See you soon", from: "Alice"}
];

あなたのコードでそれにアクセスできますが、メッセージは他の誰かのコードで管理されています。そのコードによって新しいメッセージが追加され、古いものが定期的に削除されます。そして、あなたはそれが発生する正確なタイミングを知りません。

今、メッセージを “読んだ” かの情報を格納するために使えるのはどのデータ構造でしょうか?その構造は与えられたメッセージオブジェクトに対して、“読んだか?” という解答を与えるのに適したものでなければなりません。

P.S メッセージが messages から削除されたとき、あなたの構造からも同様に消える必要があります。

P.P.S. 私たちはメッセージオブジェクトを直接変更すべきではありません。ほかの誰かのコードによって管理されている場合、余分なプロパティの追加は悪い結果になる可能性があります。

ここでの良い選択肢は WeakSet です:

let messages = [
    {text: "Hello", from: "John"},
    {text: "How goes?", from: "John"},
    {text: "See you soon", from: "Alice"}
];

let readMessages = new WeakSet();

// 2つのメッセージは読まれました
readMessages.add(messages[0]);
readMessages.add(messages[1]);
// readMessages は2つの要素を持っています

// ...再び最初のメッセージを読みましょう!
readMessages.add(messages[0]);
// readMessages は依然として2つのユニークな要素をもっています

// 答え: message[0] は読んだ?
alert("Read message 0: " + readMessages.has(messages[0])); // true

messages.shift();
// これで readMessages の要素は1つです(技術的にはメモリは後ほどクリーンされるかもしれません)

WeakSet でメッセージのセットを格納し、その中でメッセージの存在を簡単に確認することができます。

それは自動的に自身をクリーンアップします。トレードオフはそれをイテレートすることができないことです。私たちは直接 “すべての既読メッセージ” を取得することができません。しかし、すべての messages をイテレートし、set 中のものをフィルタリングすることでそれを実現できます。

P.S メッセージが誰か他の人のコードで管理されている場合、各メッセージに同時のプロパティを追加することは危険です。が、衝突を回避するためにシンボルでそれをすることができます。

このようになります:

// シンボリックプロパティは我々のコードだけが知っています
let isRead = Symbol("isRead");
messages[0][isRead] = true;

これで、たとえ他のコードがメッセージプロパティのために for..in ループを使っても、隠しフラグは表示されません。

重要性: 5

前のタスクのメッセージ配列があります。状況は似ています。

let messages = [
    {text: "Hello", from: "John"},
    {text: "How goes?", from: "John"},
    {text: "See you soon", from: "Alice"}
];

この問題: “いつメッセージが読まれたか” という情報を格納するのにあなたが提案するのはどのようなデータ構造ですか?

前のタスクでは、“既読/未読” の事実の保持だけが必要でした。今度は日付を保存する必要があります。なお、前と同じようにメッセージが削除されたら現れないでください。

WeakMap を使って日付を格納することができます。:

let messages = [
    {text: "Hello", from: "John"},
    {text: "How goes?", from: "John"},
    {text: "See you soon", from: "Alice"}
];

let readMap = new WeakMap();

readMap.set(messages[0], new Date(2017, 1, 1));
// Date オブジェクトについては後ほど学びます
チュートリアルマップ