Map/Set

JavaScriptでデータの集まりを扱うコレクションは配列だけではありません。 この章では、マップ型のコレクションであるMapと、セット型のコレクションであるSetについて学びます。

Map

Mapはマップ型のコレクションを扱うためのビルトインオブジェクトです。 マップとは、キーと値の組み合わせからなる抽象データ型です。 他のプログラミング言語の文脈では辞書やハッシュマップ、連想配列などと呼ばれることもあります。

マップの作成と初期化

Mapオブジェクトをnewすることで、新しいマップを作ることができます。 作成されたばかりのマップは何ももっていません。 そのため、マップのサイズを返すsizeプロパティは0を返します。

const map = new Map();
console.log(map.size); // => 0

Mapオブジェクトをnewで初期化するときに、コンストラクタに初期値を渡すことができます。 コンストラクタ引数として渡すことができるのはエントリーの配列です。 エントリーとは、ひとつのキーと値の組み合わせを[キー, 値]という形式の配列で表現したものです。 そのため、次の例のように配列の配列を渡すことになります。

const map = new Map([["key1", "value1"], ["key2", "value2"]]);
// 2つのエントリーで初期化されている
console.log(map.size); // => 2

要素の追加と取り出し

Mapには新しい要素を追加したり、追加した要素を取り出したりするためのメソッドがあります。 setメソッドは特定のキーと値をもつ要素をマップに追加します。 ただし、同じキーで複数回setメソッドを呼び出した際は後から追加された値で上書きされます。

getメソッドは特定のキーに紐付いた値を取り出します。 また、特定のキーに紐付いた値をもっているかどうかを確認するhasメソッドがあります。

const map = new Map();
// 新しい要素の追加
map.set("key", "value1");
console.log(map.size); // => 1
console.log(map.get("key")); // => "value1"
// 要素の上書き
map.set("key", "value2");
console.log(map.get("key")); // => "value2"
// キーの存在確認
console.log(map.has("key")); // => true
console.log(map.has("foo")); // => false

deleteメソッドは追加した要素を削除します。 deleteメソッドに渡されたキーと、そのキーに紐付いた値がマップから削除されます。 また、マップがもつすべての要素を削除するためのclearメソッドがあります。

const map = new Map();
map.set("key1", "value1");
map.set("key2", "value2");
console.log(map.size); // => 2
map.delete("key1");
console.log(map.size); // => 1
map.clear();
console.log(map.size); // => 0

マップの反復処理

マップがもつ要素を列挙するメソッドとして、forEachkeysvaluesentriesがあります。

forEachメソッドはマップがもつすべての要素を、マップへの追加順に反復します。 コールバック関数には引数として値、キー、マップの3つが渡されます。 配列のforEachメソッドと似ていますが、インデックスの代わりにキーが渡されます。 配列は順序により要素を特定しますが、マップはキーにより要素を特定するためです。

const map = new Map([["key1", "value1"], ["key2", "value2"]]);
const results = [];
map.forEach((value, key) => {
    results.push(`${key}:${value}`);
});
console.log(results); // => ["key1:value1","key2:value2"]

keysメソッドはマップがもつすべての要素のキーを挿入順に並べたIteratorオブジェクトを返します。 同様に、valuesメソッドはマップがもつすべての要素の値を挿入順に並べたIteratorオブジェクトを返します。 これらの戻り値はIteratorオブジェクトであって配列ではありません。 そのため次の例のように、for...of文で反復処理をおこなったり、Array.fromメソッドに渡して配列に変換して使ったりします。

const map = new Map([["key1", "value1"], ["key2", "value2"]]);
const keys = [];
// keysメソッドの戻り値を反復する
for (const key of map.keys()) {
    keys.push(key);
}
console.log(keys); // => ["key1","key2"]
// keysメソッドの戻り値から配列を作る
const keysArray = Array.from(map.keys());
console.log(keysArray); // => ["key1","key2"]

entriesメソッドはマップがもつすべての要素をエントリーとして挿入順に並べたIteratorオブジェクトを返します。 先述のとおりエントリーは[キー, 値]のような配列です。 そのため、配列の分割代入を使うとエントリーからキーと値を簡潔に取り出せます。

const map = new Map([["key1", "value1"], ["key2", "value2"]]);
const entries = [];
for (const [key, value] of map.entries()) {
    entries.push(`${key}:${value}`);
}
console.log(entries); // => ["key1:value1","key2:value2"]

また、マップ自身もiterableなオブジェクトなので、for...of文で反復できます。 マップをfor...of文で反復したときは、すべての要素をエントリーとして挿入順に反復します。 つまり、entriesメソッドの戻り値を反復するときと同じ結果が得られます。

const map = new Map([["key1", "value1"], ["key2", "value2"]]);
const results = [];
for (const [key, value] of map) {
    results.push(`${key}:${value}`);
}
console.log(results); // => ["key1:value1","key2:value2"]

マップとしてのObjectとMap

ES2015でMapが導入されるまで、JavaScriptにおいてマップ型を実現するためにObjectが利用されてきました。 何かをキーにして値にアクセスするという点で、MapObjectはよく似ています。 ただし、マップとしてのObjectにはいくつかの問題があります。

  • Objectのプロトタイプから継承されたプロパティによって、意図しないマッピングを生じる危険性があります
  • また、プロパティとしてデータをもつため、キーとして使えるのは文字列かSymbolに限られます

Objectにはプロトタイプがあるため、いくつかのプロパティは初期化されたときから存在します。 Objectをマップとして使うと、そのプロパティと同じ名前のキーを使おうとしたときに問題があります。 たとえばconstructorという文字列はObject.prototype.constructorプロパティと衝突してしまうため、 オブジェクトのキーに使うことで意図しないマッピングを生じる危険性があります。 この問題はマップとして使うObjectのインスタンスをObject.create(null)のように初期化して作ることで回避されてきました。

const map = {};
// マップがキーをもつことを確認する
function has(key) {
    return typeof map[key] !== "undefined";
}
console.log(has("foo")); // => false
// Objectのプロパティが存在する
console.log(has("constructor")); // => true

これらの問題を解決するためにMapが導入されました。 Mapはプロパティとは異なる仕組みでデータを格納します。 そのため、Mapのプロトタイプがもつメソッドやプロパティとキーが衝突することはありません。 また、Mapはマップのキーとしてあらゆるオブジェクトを使うことができます。

他にもMapには次のような利点があります。

  • マップのサイズを簡単に知ることができる
  • マップがもつ要素を簡単に列挙できる
  • オブジェクトをキーにすると参照ごとに違うマッピングができる

たとえばショッピングカートのような仕組みを作るとき、次のようにMapを使って商品のオブジェクトと注文数をマッピングできます。

// ショッピングカートを表現するクラス
class ShoppingCart {
    constructor() {
        // 商品とその数をもつマップ
        this.items = new Map();
    }
    // カートに商品を追加する
    addItem(item) {
        const count = this.items.get(item) || 0;
        this.items.set(item, count + 1);
    }
    // カート内の合計金額を返す
    getTotalPrice() {
        return Array.from(this.items).reduce((total, [item, count]) => {
            return total + item.price * count;
        }, 0);
    }
    // カートの中身を文字列にして返す
    toString() {
        return Array.from(this.items).map(([item, count]) => {
            return `${item.name}:${count}`;
        }).join(",");
    }
}
const shoppingCart = new ShoppingCart();
// 商品一覧
const shopItems = [
    { name: "みかん", price: 100 },
    { name: "りんご", price: 200 },
];

// カートに商品を追加する
shoppingCart.addItem(shopItems[0]);
shoppingCart.addItem(shopItems[0]);
shoppingCart.addItem(shopItems[1]);

// 合計金額を表示する
console.log(shoppingCart.getTotalPrice()); // => 400
// カートの中身を表示する
console.log(shoppingCart.toString()); // => "みかん:2,りんご:1"

Objectをマップとして使うときに起きる多くの問題は、Mapオブジェクトを使うことで解決しますが、 常にMapObjectの代わりになるわけではありません。 マップとしてのObjectには次のような利点があります。

  • リテラル表現があるため作成しやすい
  • 規定のJSON表現があるため、JSON.stringify関数を使ってJSONに変換するのが簡単である
  • ネイティブAPI・外部ライブラリを問わず、多くの関数がマップとしてObjectを渡される設計になっている

次の例では、ログインフォームのsubmitイベントを受け取ったあと、サーバーにPOSTリクエストを送信しています。 サーバーにJSON文字列を送るために、JSON.stringify関数を使います。 そのため、Objectのマップを作ってフォームの入力内容をもたせています。 このような簡易なマップにおいては、Objectを使うほうが適切でしょう。

// URLとObjectのマップを受け取ってPOSTリクエストを送る関数
function sendPOSTRequest(url, data) {
    // XMLHttpRequestを使ってPOSTリクエストを送る
    const httpRequest = new XMLHttpRequest();
    httpRequest.setRequestHeader("Content-Type", "application/json");
    httpRequest.send(JSON.stringify(data));
    httpRequest.open("POST", "/api/login");
}

// formのsubmitイベントを受け取る関数
function onLoginFormSubmit(event) {
    const form = event.target;
    const data = {
        userName: form.elements.userName,
        password: form.elements.password,
    };
    sendPOSTRequest("/api/login", data);
}

WeakMap

WeakMapは、Mapと同じくマップを扱うためのビルトインオブジェクトです。 Mapと違う点は、キーを弱い参照(Weak Reference)でもつことです。

弱い参照とは、ガベージコレクタによるオブジェクトの解放を妨げないための特殊な参照です。 あるオブジェクトへの参照がすべて弱い参照のとき、そのオブジェクトはいつでもガベージコレクタによって解放できます。 弱い参照は、不要になったオブジェクトを参照し続けて発生するメモリリークを防ぐために使われます。 WeakMapでは不要になったキーとそれに紐付いた値が自動的に削除されるため、メモリリークを引き起こす心配がありません。

WeakMapMapと似ていますがiterableではありません。 そのため、キーを列挙するkeysメソッドや、データの数を返すsizeプロパティなどは存在しません。 また、キーを弱い参照でもつ特性上、キーとして使えるのは参照型のオブジェクトだけです。

WeakMapの主な使い方のひとつは、あるオブジェクトに紐付くオブジェクトを管理することです。 たとえば次の例では、オブジェクトが発火するイベントのリスナー関数(イベントリスナー)をマップで管理しています。 イベントリスナーとは、イベントが発火したときに呼び出される関数のことです。 このマップをMapで実装してしまうと、targetObjがマップから削除されるまでイベントリスナーはメモリ上に残り続けます。 ここでWeakMapを使うと、addListener関数に渡されたlistenertargetObjが解放された際、自動的に解放されます。

// イベントリスナーを管理するマップ
const listenersMap = new WeakMap();

// 渡されたオブジェクトに紐付くイベントリスナーを追加する
function addListener(targetObj, listener) {
    const listeners = listenersMap.get(targetObj) || [];
    listenersMap.set(targetObj, listeners.concat(listener));
}
// 渡されたオブジェクトに紐付くイベントリスナーを呼び出す
function triggerListeners(targetObj) {
    if (listenersMap.has(targetObj)) {
        listenersMap.get(targetObj)
            .forEach((listener) => listener());
    }
}

// 上記関数の実行例

let eventTarget = {};
// イベントリスナーを追加する
addListener(eventTarget, () => {
    console.log("イベントが発火しました");
});
// eventTargetに紐付いたイベントリスナーが呼び出される
triggerListeners(eventTarget);
// eventTargetの参照が変われば自動的にイベントリスナーが解放される
eventTarget = null;

また、あるオブジェクトから計算した結果を保存する用途でもよく使われます。 次の例ではDOM要素の高さを計算した結果を保存して、2回目以降に同じ計算をしないようにしています。

const cache = new WeakMap();

function getHeight(element) {
    if (cache.has(element)) {
        return cache.get(element);
    }
    const height = element.getBoundingClientRect().height;
    // elementオブジェクトに対して高さを紐付けて保存している
    cache.set(element, height);
    return height;
}

[コラム] キーの等価性とNaNオブジェクト

Mapに値をセットする際のキーにはあらゆるオブジェクトが使えますが、一部のオブジェクトについては扱いに注意が必要です。

マップが特定のキーをすでにもっているか、つまり挿入と上書きの判定は基本的に===演算子と同じです。 ただしNaNオブジェクトの扱いだけが例外的に違います。Mapにおけるキーの比較では、NaN同士は常に等価であるとみなされます。 この挙動はSame-value-zeroアルゴリズムと呼ばれます。

const map = new Map();
map.set(NaN, "value");
// NaNは===で比較した場合は常にfalse
console.log(NaN === NaN); // => false
// MapはNaN同士を比較できる
console.log(map.get(NaN)); // => "value"

Set

Setはセット型のコレクションを扱うためのビルトインオブジェクトです。 セットとは、重複する値がないことを保証したコレクションのことをいいます。 Setは追加した値を列挙できるので、値が重複しないことを保証する配列のようなものとしてよく使われます。 ただし、配列と違って要素は順序をもたず、インデックスによるアクセスはできません。

セットの作成と初期化

Setオブジェクトをnewすることで、新しいセットを作ることができます。 作成されたばかりのセットは何ももっていません。 そのため、セットのサイズを返すsizeプロパティは0を返します。

const set = new Set();
console.log(set.size); // => 0

Setオブジェクトをnewで初期化するときに、コンストラクタに初期値を渡すことができます。 コンストラクタ引数として渡すことができるのはiterableオブジェクトです。 次の例ではiterableオブジェクトである配列を初期値として渡しています。

const set = new Set(["value1", "value2", "value2"]);
// "value2"が重複しているので、セットのサイズは2になる
console.log(set.size); // => 2

値の追加と取り出し

作成したセットに値を追加するにはaddメソッドを使います。 先述のとおり、セットは重複する値をもたないことが保証されます。 そのため、すでにセットがもっている値をaddメソッドに渡した際は無視されます。

また、セットが特定の値をもっているかどうかを確認するhasメソッドがあります。

const set = new Set();
// 値の追加
set.add("a");
console.log(set.size); // => 1
// 重複する値は追加されない
set.add("a");
console.log(set.size); // => 1
// 値の存在確認
console.log(set.has("a")); // => true
console.log(set.has("b")); // => false

セットから値を削除するには、deleteメソッドを使います。 deleteメソッドに渡された値がセットから削除されます。 また、セットがもつすべての値を削除するためのclearメソッドがあります。

const set = new Set();
set.add("a");
set.add("b");
console.log(set.size); // => 2
set.delete("a");
console.log(set.size); // => 1
set.clear();
console.log(set.size); // => 0

セットの反復処理

セットがもつすべての値を反復するにはfor...of文を使います。 for...of文でセットを反復したときは、セットへの追加順に値が取り出されます。

const set = new Set();
set.add("a");
set.add("b");
const results = [];
for (const value of set) {
    results.push(value);
}
console.log(results); // => ["a","b"]

セットがもつ要素を列挙するメソッドとして、forEachkeysvaluesentriesがあります。 これらはMapとの類似性のために存在しますが、セットにはマップにおけるキー相当のものがありません。 そのため、keysメソッドはvaluesメソッドのエイリアスになっており、セットがもつすべての値を挿入順に列挙するIteratorオブジェクトを返します。 また、entriesメソッドは[値, 値]という形のエントリーを挿入順に列挙するIteratorオブジェクトを返します。 ただし、Set自身がiterableであるため、これらのメソッドが必要になることはないでしょう。

WeakSet

WeakSetは弱い参照で値をもつセットです。 WeakSetSetと似ていますが、iterableではないので追加した値を反復できません。 つまり、WeakSetは値の追加と削除、存在確認以外のことができません。 データの格納ではなく、データの一意性を確認することに特化したセットといえるでしょう。

また、弱い参照で値をもつ特性上、値として使えるのは参照型のオブジェクトだけです。

results matching ""

    No results matching ""