JavaScriptで連想配列を利用する際にObjectではなくMapを使うメリット

JavaScript

はじめに

JavaScriptで値のユニークな配列を利用する方法(Set) のエントリに関連して。

「ES2015」で新たに用意された便利なコレクションオブジェクトとして Map があります。
他言語だと、例えばJavaのMapのように組み込みの連想配列機能が用意されています。
Map を利用すると、それまでは Object で代用してきた連想配列機能を実現することができます。

とはいえ今まで Object で実現できていたのならそれで良いのではないか?という疑問が浮かびました。
そこで Object ではなく Map を使う際の違いやメリットを調べてみました。

検証環境

$ node --version
v12.5.0

従来のObjectを使った場合の連想配列の作り方

従来のObjectを使っての連想配列の作り方は以下のようなコードになります。

(example1.js)

const obj = {}
obj.string = "hello"
obj.array = []
obj.number = 2
obj.object = {}
console.log(obj)

node コマンドを使ってコマンドラインから実行した結果は以下のようになります。

$ node example.js
{ string: 'hello', array: [], number: 2, object: {} }

Objectを使った場合のキーは暗黙に変換される

Objectを利用した場合には、キーの取扱に注意しなければいけません。
実際にソースコードを見ていきましょう。

以下の例ではObjectに対して、Numberの 2 と Stringの "2" をキーとして値をセットしています。

(example2.js)

const obj = {}
obj["2"] = "二"
obj[2] = "に"
// { '2': 'に' }
console.log(obj)

実行すると以下のようになります。

$ node example2.js
{ '2': 'に' }

2つの値を別のキーでセットしたにもかかわらず、キーは1つしか保存されていません。
Objectのキーは、自動的に文字列に変換されることがわかります。

別の例を見ていきましょう。
Objectのキーとして、Objectを使ってみます。

(example3.js)

const obj = {}
const key1 = { id: "hoge" }
const key2 = { id: "fuga" }
obj[key1] = "ほげ"
obj[key2] = "ふが"
// { '[object Object]': 'ふが' }
console.log(obj)

実行すると先程同様、キーは1つしか保存されていません。

$ node example3.js
{ '[object Object]': 'ふが' }

先の例と同様で、キーとなる key1key2 それぞれに対して toString メソッドを呼び出し文字列変換している事がわかります。
いずれの変換結果も [object Object] ですので、やはり保存されているキーは1つになります。

ちなみに Array をキーにした場合は、 Array#toString が格納されている値を列挙しているためにこのような問題は起きないようです。

const obj = {}
const array1 = [ 1, 2, 3 ]
const array2 = [ 4, 5, 6 ]
obj[array1] = "いちにさん"
obj[array2] = "よんごろく"
// { '1,2,3': 'いちにさん', '4,5,6': 'よんごろく' }
console.log(obj)

Objectを使った場合は要素数を確認できない

Objectを使った場合には、これ自体にキーの数を数えるためのメソッドが用意されていません。

const obj = {}
obj.x = 1
obj.y = 2
obj.z = 3
obj.a = function (){ return 4 }
// { x: 1, y: 2, z: 3, a: [Function] }
console.log(obj)
// undefined
console.log(obj.length)

ただし、 Object.keys を使うというちょっとハックな方法をとることで取得することはできました。
直感的ではないですね。

// *** ちょっとハックな方法で取得できる ***
// 4
console.log(Object.keys(obj).length)

Objectを使った場合は値の一覧を用意に取得できない

やろうと思ったらループ処理を記述する必要があります。

const obj = {}
obj.x = 1
obj.y = 2
obj.z = 3
obj.a = function (){ return 4 }
// ループでkeyを一つずつ処理していく
let values = []
for (let k of Object.keys(obj)) {
  values.push(obj[k])
}
// [ 1, 2, 3, [Function] ]
console.log(values)

Objectを使った場合はキーの削除方法が「キモチワルイ」

delete キーワードを利用すれば削除可能ですが、Javaなど他言語の Map オブジェクトに慣れていると、メソッドで削除するのが直感的ですよね。

const obj = {}
obj.x = 1
obj.y = 2
obj.z = 3
// { x: 1, y: 2, z: 3 }
console.log(obj)
// キーを削除(キモチワルイ)
delete obj.y
// { x: 1, z: 3 }
console.log(obj)
// キーを削除(キモチワルイ)
delete obj['x']
// { z: 3 }
console.log(obj)

Mapを使った場合

ということでMapを使った場合のコードを見ていきます。

// Mapオブジェクトを生成
const map = new Map()
// 値を設定
map.set('2', '二')
map.set(2, 'に')
const key1 = { id: "hoge" }
const key2 = { id: "fuga" }
map.set(key1, "ほげ")
map.set(key2, "ふが")
const array1 = [ 1, 2, 3 ]
const array2 = [ 4, 5, 6 ]
map.set(array1, "いちにさん")
map.set(array2, "よんごろく")
// 【値を取得】
// => ほげ
console.log(map.get(key1))
// => 二
console.log(map.get("2"))
// 【値を表示】
// => Map {
//   '2' => '二',
//   2 => 'に',
//   { id: 'hoge' } => 'ほげ',
//   { id: 'fuga' } => 'ふが',
//   [ 1, 2, 3 ] => 'いちにさん',
//   [ 4, 5, 6 ] => 'よんごろく'
// }
console.log(map)
// 【要素数を取得】
// => 6
console.log(map.size)
// 【キーの一覧を取得】
// => [Map Iterator] {
//   '2',
//   2,
//   { id: 'hoge' },
//   { id: 'fuga' },
//   [ 1, 2, 3 ],
//   [ 4, 5, 6 ]
// }
console.log(map.keys())
// 【値の一覧を取得】
// [Map Iterator] { '二', 'に', 'ほげ', 'ふが', 'いちにさん', 'よんごろく' }
console.log(map.values())
// 【要素を削除】
map.delete(2)
map.delete(key1)
map.delete(array2)
// => Map { '2' => '二', { id: 'fuga' } => 'ふが', [ 1, 2, 3 ] => 'いちにさん' }
console.log(map)
// 【キーが存在しているかを確認】
// => false
console.log(map.has(0))
// => true
console.log(map.has(key2))

MapのキーにNaNを指定した場合の挙動

JavaScriptでは NaN === NaNfalse となりますが、 NaN がキーの場合には複数の Nan キーが発生することはありません。

let x = NaN
let y = NaN
let map = new Map([
  [x, 'hello']
])
// NaN と NaN は等しくない
// => false
console.log(x === y)
// ※等しくはないが、キーは1つのみとなる
// => Map { NaN => 'hello' }
console.log(map)
// => hello
console.log(map.get(x))
// => hello
console.log(map.get(y))

MapをArrayに変換

ArrayからMap,MapからArrayに変換する方法は以下のとおりです。

// コンストラクタに二次元のArrayを渡して初期化
const map = new Map([
  [ "1", "いち" ],
  [ { id: "hoge" }, "ほげ" ],
  [ [ 1, 2, 3 ], "いちにさん" ],
])
// Mapの値を確認
// => Map { '1' => 'いち', { id: 'hoge' } => 'ほげ', [ 1, 2, 3 ] => 'いちにさん' }
console.log(map)
// Map => Arrayに変換
// => [ [ '1', 'いち' ], [ { id: 'hoge' }, 'ほげ' ], [ [ 1, 2, 3 ], 'いちにさん' ] ]
console.log(Array.from(map))

Mapでループ処理を行う

Map自体がイテレータなので、 for of 構文でループさせることができます。

const map = new Map()
map.set(1, "いち")
map.set(2, "に")
map.set(3, "さん")
for (let [key, value] of map) {
  console.log("key =", key, "/ value =", value)
}

実行結果は以下のようになります。

key = 1 / value = いち
key = 2 / value = に
key = 3 / value = さん

Map オブジェクト自身が持つ forEach メソッドを利用することも可能です。

const map = new Map([
  [1, 'one'],
  [2, 'two'],
  [3, 'three'],
])
// キーと値のセットでループを行う
map.forEach((k, v) => {
  console.log(`${k} = ${v}`)
})

実行結果は以下のようになります。

one = 1
two = 2
three = 3

Mapを使った場合の注意点

注意点があります。

今まで暗黙の文字列変換を行っていたことにより、「数値」でキーを指定しようが「文字列」でキーを指定しようが同じでしたが、これらを明確に区別するようになります。

意識しておかないとバグのもととなります。

const map = new Map()
// 上書き想定でNumberの値をキーとして利用
map.set('2', 'に')
map.set(2, '二')
// Stringの'2'とNumberの2が別キーで保持される
// Map { '2' => 'に', 2 => '二' }
console.log(map)

ひとこと

特に明確な理由がない場合には Map オブジェクトを利用して連想配列を実現するのが良いかと思います。

JavaScript