JavaScript Map vs Object(実装編)

JavaScript, Map, Object
2022-03-23

JavaScriptでは単純な連想配列(ハッシュマップ)を実装する場合、MapまたはObjectを利用することが多いと思う、使い方がよく似ているためどちらを選択していいか迷うときがある、今回はその違い、パフォーマンス、使い所をまとめてみた。

そもそも...

JavaScriptは元来(ES5までは)、プリミティブな型以外の”データの集合”を表す場合、すべてObjectとなっており、ArrayやFunctionも実はObjectである。

ES6において、classやSetなどデータの集合を表すための表現がいくつか追加され、その中の一つにKey-Valueのデータを扱うための新しいアプローチとしてMapも追加された。

とはいえ、JSON.parseのように、純粋なObject型を使うケースはまだまだ存在し、どのようなタイミングでMapを利用するかが不明瞭だったので、それを少し調べてみた。

Map VS Object

Key

Mapの一番の特徴はKeyにString以外の値を設定できるところかと思う。

const m = new Map;
// こういったケースが可能
m.set(true, false);
m.set([0], '1');
m.set({}, '2');
m.set(() => {}, '3');

また、Object型のインスタンスはObjectのprototypeを参照することになるので、valueOfやtoStringなどのキーをデフォルトで持つことになる。また、最近は見なくなったけど、Object.prototypeを拡張している場合そのプロパティも参照できてしまうことになる。(そのため、hasOwnPropertyを挟むとか昔はよくあった)

const x = {};
x['toString'] => Function

Object.prototype.y = 'yyy';
x.yyy => 'yyy'

for(const key in x){ console.log(key) } // yが取得できる

Mapではこのようなことが起きない。

デフォルトの値

Mapではコンストラクタの引数にkey-valeu配列の配列を渡すことでデフォルトの値を定義することができる。

const m = new Map([['key', 1], ['key2', 2]]);
m.get('key'); // 1

Objectでは宣言時のオブジェクトリテラル {} 内に値を定義する

const o = {key: 1, key2: 2};
o.key // 1

値の登録

Mapでは set メソッドを利用して値を登録する。keyが重複した場合は当然値が上書きされる。

const m = new Map();
m.set('key', 1);
m.set('key', 2); // keyの値を上書き

Objectでは . でkeyを直接指定、またはハイフンを含むキーや全角文字の場合クォートを付けた文字列リテラルまたは文字列の変数を利用して、設定する方法がある。

const o = {};
o.key = 1;
o['key-2'] = 2;

値の取得

Mapにおける値の取得は get メソッドを利用する。keyが存在しない場合は undefinde が返る。

const m = new Map();
m.set('key', 1);
m.get('key');    // 1
m.get('key123'); // undefined

Objectは設定時と同様で . でkeyを直接指定、または [] 内に文字列を入れて呼び出す方法がある。キーが存在しない場合は undefined が返る。

const o = {};
o.key = 1;
o.key // 1
o.key123 // undefined

値の削除

Mapでは delete メソッドを利用して指定のキー及び値を削除することができる。削除が成功すると、 true を返し、keyが存在しない場合は、 false を返す。

const m = new Map();
m.set('key', 1);
m.delete('key');    // true
m.delete('key123'); // false;

また、map内のすべての値を削除する clear メソッドも用意されている。

m.clear();

Objectでは、 delete 演算子を用いてキー及び値を削除する。キーが自分自身のnon-configurableのプロパティであった場合、 strict モードでなければ false を返し、それ以外はtrueを返す。つまりキーが存在しない場合でもtrueを返す。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cant_delete

const o = {key: 1};
delete o.key // true
delete o.y   // true

キーの存在確認

Mapでキーの存在を確認する場合は has メソッドを利用します。キーが存在する場合はtrue、存在しない場合はfalseを返す。

const m = new Map();
m.set('key', 1);
m.has('key');    // true
m.has('key123'); // false

Objectでは hasOwnProperty メソッドで自身のオブジェクトにあるキーかをチェックすることができる。

const o = {key: 1};
o.hasOwnProperty('key'); // true
// もしくは
Object.prototype.hasOwnProperty(o, 'key'); // true 
💡
自分の所感ではget等で呼び出して、 undefined かどうかでチェックする構文をよく利用する。この場合のコーナーケースはキーはあるけど値が undefined である場合と、そもそもキーがない場合とで見分けがつかないことである。 とはいえ、できれば正しく判定メソッドを利用すべきである。

要素数の確認 / 空チェック

Mapでは size メソッドが用意されており、簡単に要素数を取得できる。値が0であるかを判定する事によってemptyかのチェックもできる。

const m = new Map([['key', 1]]);
m.size // 1

Objectでは Object.entriesObject.keysObject.values にて配列に変換し、その要素数を取得するというのが一般的なやり方である。一度配列に変換するので、コストがかかるという印象。

余談だが、 Object.keys よりも Object.valuesの方がChrome,Firefoxではパフォーマンスが良い

const o = {key: 1};
Object.values(o).length // 1

走査

Map, Objectともに走査の方法はいくつか存在する。

Mapはイテレータブルなオブジェクトなので、 for .. of が利用できる。この場合、[ key, value ]のタプルような形で値を取得される。

const m = new Map([['key1', 1],['key2', 2]]);

for(const p of m){
  console.log('key', p[0], 'value', p[1]);
}
// こちらと同等の処理である
for(const p of map.entiers()){
  console.log('key', p[0], 'value', p[1]);
}

keys , values メソッドでキーのみ、値のみのイテレータブルなオブジェクトを取得することができる。

mapオブジェクトに実装されている forEach を利用しても同様に走査ができる。(ArrayのforEachとは引数が異なるので注意)

m.forEach((v, k) => console.log(k, v));

イテレータブルなオブジェクトは Array.from または [...x] を利用して配列に変換できるため、従来の配列のメソッドを利用したい場合はこちらのパターンを利用する。

[...m].forEach(([k, v]) => console.log(k,v));
// または [...m.entiers()],[...m.keys()],[...m.values()]

Objectの場合は for..in を利用して走査ができる。

ただし、この場合、前述の通りObject.prototypeに拡張されているプロパティまで参照されてしまう。なので、hasOwnProperty等でチェックする必要がある。

const o = {key1: 1, key2: 2};
Object.prototype.x = 3
for (const p in o) {
  console.log('key', p, 'value', o[p]); 
  // key key1 value 1
  // key key2 value 2
  // key x value 3  <- !!
}

for (const p in o) {
  if(o.hasOwnProperty(p){
    console.log('key', p, 'value', o[p]);
  }
  // key key1 value 1
  // key key2 value 2
 }

結論から言うと、for..inを使うケースは今後ほぼないと思います。 代わりに Object.entries() Object.key() Object.values() のいずれかを使い、配列に変換し、Arrayのメソッドもしくは for..of を利用して走査するのが一般的です。

Object.entries(o).forEach(([k, v]) => console.log(k,v));

// または

for(const p of Object.entries(o)){ console.log(p[0], p[1]) }

Mapのオブジェクト、配列への変換

ES2019から加わった Object.fromEntries を使うことにより、オブジェクトへの変換が可能、

また、 Array.from で、配列に変換できます。

const m = new Map([[1,2], [3,true]])

Object.fromEntries(m)
// {1: 2, 3: true}

Array.from(m)
// [[1, 2], [3, true]]

JSONへのシリアライズ

Mapの場合、キーに文字列以外を指定できるため、原則的には直接変換できないものと思われる。

上記の Object.fromEntries を利用し、オブジェクトに変換し、それをシリアライズすることは可能。ただし、キーに文字列以外が指定されていると意図しない結果になるので注意。

const m = new Map([[1,1]]);
const o = { obj: '123'};
m.set(o, 100);
JSON.stringify(Object.fromEntries(m));
// '{"1":1,"[object Object]":100}'  🤔

また、配列に変換しそれをシリアライズすることが可能。こちらもキーに関数やクラスのインスタンスなど設定すると意図しないものとなるので注意。

const m = new Map([[1,1]]);
const o = { obj: '123'};
m.set(o, 100);

JSON.stringify([...m]); // もしくは Array.from(m.entries())など
// => [[1,1],[{"obj":"123"},100]]

// キーに関数やクラスのインスタンスなど設定すると意図しないものとなるので注意
const m2 = new Map();
const f = x => x + x; 
class A { get(){return 'get'} }
const aa = new A();
m2.set(f, 'func');
m2.set(aa, aa);

JSON.stringify([...m2]);
// => [[null, 'func'],[{}, {}]] 🤔

まとめ

MapObject
constructorconst m = new Map()const o = {}
値の登録m.set(key, value)o.key = value o[key] = value
値の取得m.get(key)o.key o[key]
値の削除m.delete(key) m.clear()delete o.key delete o[key]
プロパティの存在確認m.has(key)o.hasOwnProperty(key)
プロパティ数の確認 空かどうかのチェックm.size m.size === 0Object.values(o).length Object.values(o).length === 0
走査for(const p of m){...} m.forEach((v, k) => {...}); [...m].forEach((p) => {...})Object.entries(o).forEach((p) => {...}) for(const p of Object.entries(o)){...}
オブジェクト化、配列化Object.fromEntries(m) Array.from(m)Object.entries(o)

同等のことができとわかったので、次回はパフォーマンスを見てみたいと思います。

次回↓

JavaScript Map vs Object (パフォーマンス編) | kyohei's blog
前記事の続き 結局、どちらも同じようなことができることはわかったので、パフォーマンス的にどちらを利用するかを検証してみます。 Perflikを利用して、各処理のブラウザでのパフォーマンスを図ってみた。 (macOS Monterey) Chrome Version 99 Safari Version 15.4 Firefox Version 98 (windows 10) Edge Version 99 いずれのブラウザでも、Objectの方が速い。 こちらもいずれのブラウザでもObjectの方がやや速い。 (このテストはランダム性があるので何回行ってみた) これは各ブラウザでばらつきがでた。Chrome,Edgeだと delete メソッドの方が速いが、Safariだとdeleteオペレータの方が速い。Firefoxはほぼ同じくらいだがmapの方がやや速い。 これはいくつかの操作のパターンを試してみた。 ChormeとFirefox,Edgeはほぼ同じような結果になり、Safariは結構違う結果になった。 valueのみを利用したい場合は 9 の Object.values で変換し、昔ながらのfor文を使って走査するのが速い。 次点で for(const v of Object.values(o)) mapではネイティブのメソッドである forEach がどのブラウザでも速かった。 Safariでは mapのforEachが圧倒的に早く、他のmap, objectの走査はどれも遅かった。 mapのforEach以外だと、Objectの for..in が次点で速かった。 結論としては、走査の書き方によって処理速度が顕著に変わる、mapの場合は forEach 一択である。Objectの場合はvalue限定であれば、通常のfor文を利用するが一番速いが、バランスを取って for(const v of Object.values(o)){} でもよいと思われる。 Perflinkリンク ObjectとMapは同じ使い方ができるが、Mapを使う場合の指標としては以下の点が上げられる キーに文字列(またはSymbol)以外を指定したい場合はMap一択となる プロパティの追加、削除が頻発する場合は、Mapの方がパフォーマンスが上がる。 走査する場合、 Object.keys や Object.values を使わずに走査できるので、可読性が上がり、処理速度も速い。 Objectの要素数や空の判定も、もともと効率的な書き方ができなかったので、 sizeメソッドによる恩恵も大きいと思う JavaScriptはもともと辞書型を定義するものがObjectしかなかったので、仕方なくそれを使っていた面もあるので、機会(と要件)が合うのであれば積極的にMapも使って行きたい所である。
Written by Kyohei Tsukuda who lives and works in Tokyo 🇯🇵 , building useful things 🔧.