2022年11月13日

WeakMapとWeakSet

ガベージコレクションの章で学んだように、JavaScriptエンジンは値が「到達可能」で、潜在的に使用できる間はメモリに保持します。

例えば

let john = { name: "John" };

// the object can be accessed, john is the reference to it

// overwrite the reference
john = null;

// the object will be removed from memory

通常、オブジェクトのプロパティや配列または他のデータ構造の要素は、そのデータ構造がメモリにある間、到達可能と見なされ、メモリに保持されます。

例えば、オブジェクトを配列に入れた場合、配列が存続する限り、たとえ他の参照がなくても、オブジェクトも存続します。

このように

let john = { name: "John" };

let array = [ john ];

john = null; // overwrite the reference

// the object previously referenced by john is stored inside the array
// therefore it won't be garbage-collected
// we can get it as array[0]

同様に、通常のMapでオブジェクトをキーとして使用した場合、Mapが存在する間、そのオブジェクトも存在します。メモリを占有し、ガベージコレクションされない可能性があります。

例えば

let john = { name: "John" };

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

john = null; // overwrite the reference

// john is stored inside the map,
// we can get it by using map.keys()

WeakMapはこの点で根本的に異なります。キーオブジェクトのガベージコレクションを防ぎません。

例で見てみましょう。

WeakMap

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

let weakMap = new WeakMap();

let obj = {};

weakMap.set(obj, "ok"); // works fine (object key)

// can't use a string as the key
weakMap.set("test", "Whoops"); // Error, because "test" is not an object

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

let john = { name: "John" };

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

john = null; // overwrite the reference

// john is removed from memory!

上記の通常のMapの例と比較してください。johnWeakMapのキーとしてのみ存在する場合、マップ(およびメモリ)から自動的に削除されます。

WeakMapは反復処理とkeys()values()entries()メソッドをサポートしていないため、そこからすべてのキーまたは値を取得する方法はありません。

WeakMapには次のメソッドのみがあります。

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

JavaScriptエンジンがそれを決定します。メモリクリーンアップをすぐに実行するか、後でより多くの削除が行われたときにクリーンアップを行うかを決定します。したがって、技術的には、WeakMapの現在の要素数は不明です。エンジンがそれをクリーンアップしたかどうか、または部分的にクリーンアップしたかどうかはわかりません。そのため、すべてのキー/値にアクセスするメソッドはサポートされていません。

では、そのようなデータ構造はどこで必要になるのでしょうか?

ユースケース:追加データ

WeakMapの主な適用分野は、追加データストレージです。

別のコード(サードパーティライブラリの場合もある)に「属する」オブジェクトを操作していて、それに関連付けられたデータを保存したい場合、そのデータはオブジェクトが存続する間のみ存在する必要があります。そのような場合、WeakMapがまさに必要なものです。

オブジェクトをキーとして使用してデータをWeakMapに配置すると、オブジェクトがガベージコレクションされると、そのデータも自動的に消えます。

weakMap.set(john, "secret documents");
// if john dies, secret documents will be destroyed automatically

例を見てみましょう。

例えば、ユーザーの訪問回数を保持するコードがあるとします。情報はマップに保存されます。ユーザーオブジェクトがキーで、訪問回数が値です。ユーザーが離れると(そのオブジェクトがガベージコレクションされると)、その訪問回数を保存したくありません。

Mapを使用したカウント関数の例を次に示します。

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

// increase the visits count
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

そして、おそらくそれを使用する別のファイルのコードです。

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

countUser(john); // count his visits

// later john leaves us
john = null;

これで、johnオブジェクトはガベージコレクションされるはずですが、visitsCountMapのキーであるため、メモリに残ります。

ユーザーを削除する際にはvisitsCountMapをクリーンアップする必要があります。そうでないと、メモリが無期限に増大します。このようなクリーンアップは、複雑なアーキテクチャでは面倒な作業になる可能性があります。

代わりにWeakMapを使用することで、それを回避できます。

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

// increase the visits count
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

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

ユースケース:キャッシング

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

そのためには、Mapを使用できます(最適なシナリオではありません)。

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

// calculate and remember the result
function process(obj) {
  if (!cache.has(obj)) {
    let result = /* calculations of the result for */ obj;

    cache.set(obj, result);
    return result;
  }

  return cache.get(obj);
}

// Now we use process() in another file:

// 📁 main.js
let obj = {/* let's say we have an object */};

let result1 = process(obj); // calculated

// ...later, from another place of the code...
let result2 = process(obj); // remembered result taken from cache

// ...later, when the object is not needed any more:
obj = null;

alert(cache.size); // 1 (Ouch! The object is still in cache, taking memory!)

同じオブジェクトでprocess(obj)を複数回呼び出すと、最初に結果を計算し、その後cacheから取得します。欠点は、オブジェクトが不要になったときにcacheをクリーンアップする必要があることです。

MapWeakMapに置き換えると、この問題はなくなります。オブジェクトがガベージコレクションされると、キャッシュされた結果は自動的にメモリから削除されます。

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

// calculate and remember the result
function process(obj) {
  if (!cache.has(obj)) {
    let result = /* calculate the result for */ obj;

    cache.set(obj, result);
    return result;
  }

  return cache.get(obj);
}

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

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

// ...later, when the object is not needed any more:
obj = null;

// Can't get cache.size, as it's a WeakMap,
// but it's 0 or soon be 0
// When obj gets garbage collected, cached data will be removed as well

WeakSet

WeakSetは同様に動作します。

  • Setに似ていますが、WeakSetにはオブジェクトのみを追加できます(プリミティブは追加できません)。
  • オブジェクトは、他の場所から到達可能である間、セットに存在します。
  • Setと同様に、addhasdeleteをサポートしますが、sizekeys()、反復処理はサポートしません。

「弱い」ものであるため、追加ストレージとしても機能します。ただし、任意のデータではなく、「yes/no」の事実を保存するために使用されます。WeakSetへのメンバーシップは、オブジェクトに関する何かを意味する可能性があります。

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

let visitedSet = new WeakSet();

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

visitedSet.add(john); // John visited us
visitedSet.add(pete); // Then Pete
visitedSet.add(john); // John again

// visitedSet has 2 users now

// check if John visited?
alert(visitedSet.has(john)); // true

// check if Mary visited?
alert(visitedSet.has(mary)); // false

john = null;

// visitedSet will be cleaned automatically

WeakMapWeakSetの最も注目すべき制限は、反復処理がないこと、および現在のすべてのコンテンツを取得できないことです。これは不便に思えるかもしれませんが、WeakMap/WeakSetの主な役割、つまり別の場所で保存/管理されているオブジェクトの「追加」データストレージとしての役割を果たすことを妨げません。

まとめ

WeakMapは、キーとしてオブジェクトのみを許可し、他の手段ではアクセスできなくなったときに関連付けられた値とともにキーを削除する、Mapのようなコレクションです。

WeakSetは、オブジェクトのみを保存し、他の手段ではアクセスできなくなったときにオブジェクトを削除する、Setのようなコレクションです。

それらの主な利点は、オブジェクトへの弱い参照を持つため、ガベージコレクタによって簡単に削除できることです。

これは、clearsizekeysvaluesなどがサポートされないという犠牲を払って実現されます。

WeakMapWeakSetは、「プライマリ」オブジェクトストレージに加えて「セカンダリ」データ構造として使用されます。オブジェクトがプライマリストレージから削除されると、WeakMapのキーまたはWeakSet内にある場合のみ、自動的にクリーンアップされます。

課題

重要度:5

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

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

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

では、メッセージが「既読かどうか」に関する情報を保存するために、どのようなデータ構造を使用できますか?この構造は、指定されたメッセージオブジェクトに対して「既読でしたか?」という質問に答えるのに適している必要があります。

補足:メッセージがmessagesから削除されると、あなたの構造からも削除される必要があります。

補足2:メッセージオブジェクトを変更したり、プロパティを追加したりすべきではありません。他の誰かのコードによって管理されているため、予期せぬ結果につながる可能性があります。

既読メッセージをWeakSetに保存しましょう。

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

let readMessages = new WeakSet();

// two messages have been read
readMessages.add(messages[0]);
readMessages.add(messages[1]);
// readMessages has 2 elements

// ...let's read the first message again!
readMessages.add(messages[0]);
// readMessages still has 2 unique elements

// answer: was the message[0] read?
alert("Read message 0: " + readMessages.has(messages[0])); // true

messages.shift();
// now readMessages has 1 element (technically memory may be cleaned later)

WeakSetを使用すると、メッセージのセットを保存し、メッセージが存在するかどうかを簡単に確認できます。

自動的にクリーンアップされます。トレードオフは、反復処理できないこと、そこから直接「すべての既読メッセージ」を取得できないことです。ただし、すべてのメッセージを反復処理し、セット内にあるメッセージをフィルタリングすることで、これを行うことができます。

別の解決策としては、既読後にメッセージにmessage.isRead=trueのようなプロパティを追加する方法があります。メッセージオブジェクトは他のコードによって管理されているため、一般的には推奨されませんが、競合を回避するためにシンボルプロパティを使用できます。

このように

// the symbolic property is only known to our code
let isRead = Symbol("isRead");
messages[0][isRead] = true;

サードパーティのコードは、おそらく私たちの追加プロパティを見ることができません。

シンボルを使用すると問題発生の可能性を低減できますが、アーキテクチャの観点からはWeakSetを使用する方が優れています。

重要度:5

メッセージの配列があります。前の課題と同様です。

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

問題は、「メッセージがいつ読まれたか」という情報を保存するために、どのようなデータ構造を提案するかです。

前の課題では、「はい/いいえ」の事実だけを保存する必要がありました。今度は日付を保存する必要があり、メッセージがガベージコレクションされるまでメモリに保持される必要があります。

追伸:日付は、後で説明する組み込みのDateクラスのオブジェクトとして保存できます。

日付を保存するには、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 object we'll study later
チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善点の提案がある場合は、コメントする代わりにGitHub issueまたはプルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • 数行のコードを挿入するには<code>タグを使用し、複数行の場合は<pre>タグで囲み、10行を超える場合は、サンドボックス(plnkrjsbincodepenなど)を使用してください。