LoginSignup
35
24

More than 5 years have passed since last update.

JavaScriptでObjectじゃなくMapを使う場面の一例

Last updated at Posted at 2018-11-21

例題

与えられた文章に対し、単語の数を数え上げる関数を作ってみましょう。
ある一つの単語ではなく、出てくる単語全部について数えます。

function countWords (text) {

  // ???

}

const text = 'base base kick kick base kick kick'
const counts = countWords(text)
console.log(counts)
// -> 'base'が3個!'kick'が4個!

さあ、countWordsはどんなデータを返せば良いでしょうか。

解法1. 配列を返す

ソースを追うのがだるかったら最後の方の出力結果部分だけ見て下さい。

配列を返す.js
function countWords (text) {
  const words = text.split(' ')

  // 単語と数のペアを並べた配列をこれから作っていきます。
  // 例:[['base', 3], ['kick', 4]]
  const wordAndCountPairs = []

  for (const word of words) {
    // 配列の中から、単語に対応するペアを探します
    const wordAndCountPair = findWordAndCountPair(wordAndCountPairs, word)
    if (wordAndCountPair == null) {
      // ペアを未作成の場合 -> 新しいペアを作って配列に追加
      const newPair = [word, 1]
      wordAndCountPairs.push(newPair)
    } else {
      // ペアを作成済の場合 -> ペアのカウント部分をインクリメント
      wordAndCountPair[1]++
    }
  }

  return wordAndCountPairs
}

function findWordAndCountPair (wordAndCountPairs, searchWord) {
  for (const wordAndCountPair of wordAndCountPairs) {
    const word = wordAndCountPair[0]
    if (word === searchWord) {
      return wordAndCountPair
    }
  }
  return null
}

const text = 'base base kick kick base kick kick'
const counts = countWords(text)
console.log(counts)
// -> [ [ 'base', 3 ], [ 'kick', 4 ] ]

問題点

取得した配列を実際に使ってみましょう。

// (「配列を返す.js」の続き)

function showCount (counts, targetWord) {
  for (const [word, count] of counts) {
    if (word === targetWord) {
      console.log(`'${word}'は${count}個!`)
      return
    }
  }
  console.log(`'${targetWord}'なんてもんは無え。忘れろ。`)
}
showCount(counts, 'base') // -> 'base'は3個!
showCount(counts, 'kick') // -> 'kick'は4個!
showCount(counts, 'clap') // -> 'clap'なんてもんは無え。忘れろ。

もしも単語が何百万語もあった場合、for文でいちいち探すのはパフォーマンスがいまいちです。1
オブジェクトを使う時みたいに、どの単語についても一瞬で取れたら良いですよね。2
というわけで次はオブジェクトを使う例を示します。
ES2015より前だとオブジェクトを使うのが主流だったんじゃないでしょうか。

解法2. オブジェクトを返す

オブジェクトを返す.js
function countWords (text) {
  const words = text.split(' ')

  const counts = {}

  for (const word of words) {
    if (counts[word] == null) {
      counts[word] = 1
    } else {
      counts[word]++
    }
  }

  return counts
}

const text = 'base base kick kick base kick kick'
const counts = countWords(text)
console.log(counts)
// -> { base: 3, kick: 4 }

問題点

オブジェクトって、普通こういうやつですよね。

const user = {
  id: 1,
  name: 'nisioisin',
  createdDate: '2000/01/01',
  saySomething: () => {
    console.log('『僕は悪くない』')
  }
}

属性名(メソッド名)と属性値(メソッド)のペアを並べたものです。
Objectは本来こういうオブジェクト指向っぽいことをするためのものです。
なので、機能もそういった用途に特化しています。

先ほどの解法みたいな使い方は、Objectの本来的な使い方ではありません。
この不整合が原因で、微妙な問題がちょこちょこあります。

問題点1. キーはStringとは限らない

user.nameuser['name']とも書けます。
これを踏まえ、「'name'という文字列を"キー"にして、userオブジェクトから'nisioisin'という"値"を取得する。」というような言い方をすることがあります。

オブジェクトはキーと値のペアが並んだものと見ることができます。
先程の例で言えば、単語をキー、カウントを値として保持していたことになります。

さて、オブジェクトのキーがString以外になるとどうなるでしょうか。
ここでは先程の例題を拡張して、「配列の中身を種類毎に数え上げる関数」を作る時のことを考えてみます。

function count (iterable) {
  const counts = {}

  for (const item of iterable) {
    if (counts[item] == null) {
      counts[item] = 1
    } else {
      counts[item]++
    }
  }

  return counts
}

// キーがBooleanの場合
const bools = [true, true, false, false, true, false, false]
console.log(count(bools))
// -> { true: 3, false: 4 }

// キーがObjectの場合
const persons = [
  { name: 'kato ai' },
  { name: 'ato kai' },
  { name: 'kato ai' },
  { name: 'ato kai' }
]
console.log(count(persons))
// -> { '[object Object]': 4 }

実のところ、ObjectのキーにString以外を使うことはできません。3
なんせObjectのキーっていうのは属性名やメソッド名を入れるところですからね、本来。
「文字列しか入らない」というのは自然な仕様です。

String以外を無理矢理キーにぶち込むと、JavaScriptはtoString()を使って勝手にStringに変えてしまいます。

上のコードで言えば、「キーがObjectの場合」がとても分かりやすいでしょう。
{ name: 'kato ai' }{ name: 'ato kai' }も、toString()すればどちらも'[object Object]'なので、同じものとして数え上げられてしまっているのが分かると思います。

「キーがBooleanの場合」もちょっと分かりづらいですが、キーはtrueではなく'true'という文字列になっています。
こちらは当面は問題にはならないかもしれませんが、ちょっと変わったことをしようとした時にはやはりバグの元になります。

問題点2. 微妙に列挙しづらい

以下の2つのコードを比べてみてください。

// (「配列を返す.js」の続き)

for (const [word, count] of counts) {
  console.log(`'${word}'は${count}個!`)
}
// -> 'base'は3個!
// -> 'kick'は4個!
// (「オブジェクトを返す.js」の続き)

for (const word in counts) {
  const count = counts[word]
  console.log(`'${word}'は${count}個!`)
}
// -> 'base'は3個!
// -> 'kick'は4個!

後者はfor...in文というちょっと特殊な構文を使っています。
オブジェクトはiterableじゃないので、普通のfor文が使えないのです。
他にもiterableでないことで困る場面はちょこちょこあります。

問題点3. Objectが既に使っているキーがある

Objectにはprototypeオブジェクトがデフォルトで設定されており、ここから様々なメソッドやプロパティが継承されています。
例えばtoStringなんかは身近なんじゃないでしょうか。

function countWords (text) {
  const words = text.split(' ')

  const counts = {}

  for (const word of words) {
    if (counts[word] == null) {
      counts[word] = 1
    } else {
      counts[word]++
    }
  }

  return counts
}

const text = 'fromString toString fromString toString'
const counts = countWords(text)
console.log(counts)
// -> { fromString: 2, toString: NaN }

バグりました。
ちょっとテストしただけじゃ見つからなさそうなバグなのが嫌らしいですね。

修正案は色々あります4が、まあそもそもObjectを使わずにバグを避けるのが一番です。

解法3. Mapを返す

というわけでMapの出番です。
詳しい使い方は省きますが、下記のコードは「オブジェクトで返す.js」とほとんど同じ流れで書いてあるので、比較すれば感覚で分かるんじゃないでしょうか。

Mapを返す.js
function countWords (text) {
  const words = text.split(' ')

  const counts = new Map()

  for (const word of words) {
    if (!counts.has(word)) {
      counts.set(word, 1)
    } else {
      const previousCount = counts.get(word)
      counts.set(word, previousCount + 1)
    }
  }

  return counts
}

const text = 'base base kick kick base kick kick'
const counts = countWords(text)
console.log(counts)
// -> Map { 'base' => 3, 'kick' => 4 }

メリット

MapはObjectと同じくキーと値を並べたものです。
ですが、オブジェクト指向の為に独自進化したObjectとは違い、配列のようにシンプルにキーと値を並べることに特化しています。
具体的に見てみましょう。

「配列を返す」の問題点を解消

MapはObjectと同様に、キーを元にして値を一瞬で取得することができます。
取得の書き方も簡単で、map.get(key)の1行で済みます。
これもObjectでobject[key]と書けるのと同じですね。

// (「Mapを返す.js」の続き)

function showCount (counts, targetWord) {
  if (counts.has(targetWord)) {
    console.log(`'${targetWord}'は${counts.get(targetWord)}個!`)
  } else {
    console.log(`'${targetWord}'なんてもんは無え。忘れろ。`)
  }
}
showCount(counts, 'base') // -> 'base'は3個!
showCount(counts, 'kick') // -> 'kick'は4個!
showCount(counts, 'clap') // -> 'clap'なんてもんは無え。忘れろ。

配列の場合と比べ、わざわざfor文を書くことがなくなり、とても簡潔になっています。

「オブジェクトを返す」の問題点を解消

MDNより抜粋。

Map の使用を望ましくする Object と Map 間の重要な違いが存在します。

  • Object のキーは Strings と Symbols ですが、Map では任意の値がキーとなり得ます。
  • Map の大きさは size プロパティで簡単に得ることができます。一方、Object の大きさは手動で保つ必要があります。
  • Map は iterable で ダイレクトに反復処理できる一方、オブジェクトを反復処理するには、何らかの方法でキーを取得し、それらのキーを元に反復処理する必要があります。
  • Object はプロトタイプを持つため、既定のキーがマップ中に存在します。ES5ではこれを map = Object.create(null) を使うことで回避することができますが、推奨しません。
  • Map は、頻繁に要素を追加したり削除したりするシナリオでは、パフォーマンスがObject に比べて良い場合があります。

上で挙げた問題点が全部解消されるのが分かるでしょうか。
具体的に見てみましょう。

1. 「キーはStringとは限らない」問題

Mapを使って、再度「配列の中身を種類毎に数え上げる関数」を作ってみます。

function count (iterable) {
  const counts = new Map()

  for (const item of iterable) {
    if (!counts.has(item)) {
      counts.set(item, 1)
    } else {
      const previousCount = counts.get(item)
      counts.set(item, previousCount + 1)
    }
  }

  return counts
}

// キーがBooleanの場合
const bools = [true, true, false, false, true, false, false]
console.log(count(bools))
// -> Map { true => 3, false => 4 }

// キーがObjectの場合 ver.1
const persons = [
  { name: 'kato ai' },
  { name: 'ato kai' },
  { name: 'kato ai' },
  { name: 'ato kai' }
]
console.log(count(persons))
// ->
// Map {
//   { name: 'kato ai' } => 1,
//   { name: 'ato kai' } => 1,
//   { name: 'kato ai' } => 1,
//   { name: 'ato kai' } => 1 }

// キーがObjectの場合 ver.2
const kato = { name: 'kato ai' }
const ato = { name: 'ato kai' }
const persons2 = [kato, ato, kato, ato]
console.log(count(persons2))
// -> Map { { name: 'kato ai' } => 2, { name: 'ato kai' } => 2 }

Objectをキーにしても問題なく数え上げられました。
(ver.1の場合、2つの{ name: 'kato ai' }オブジェクトは見た目が同じなだけで別々のオブジェクトなので、個別に数え上げられています。一方でver.2では、変数katoが2回とも同じオブジェクトを参照しているので、同じものとして数え上げられています。)

2. 「微妙に列挙しづらい」問題

// (「Mapを返す.js」の続き)

for (const [word, count] of counts) {
  console.log(`'${word}'は${count}個!`)
}
// -> 'base'は3個!
// -> 'kick'は4個!

配列の時と全く同じコードで、自然に列挙できます。

3. 「Objectが既に使っているキーがある」問題

当然toStringなどのキーが埋まっていることはありません。

function countWords (text) {
  const words = text.split(' ')

  const counts = new Map()

  for (const word of words) {
    if (!counts.has(word)) {
      counts.set(word, 1)
    } else {
      const previousCount = counts.get(word)
      counts.set(word, previousCount + 1)
    }
  }

  return counts
}

const text = 'fromString toString fromString toString'
const counts = countWords(text)
console.log(counts)
// -> Map { 'fromString' => 2, 'toString' => 2 }

というわけで

この問題に関してはObjectではなくMapを使うのが最適です。
他にも「属性とかメソッドとかじゃなくて、単にキーと値を並べたいだけなんだよなぁ」という時にはMapを使いましょう。
キーが動的な場合は大体Mapが適しています。

ここまで書いておいて気づいたのですが、MDNの「キー付きコレクション」というページに大体同じこと書いてありました。

おわり。


  1. Array.prototype.find()メソッド等を使っても同じことです。今回は計算量をイメージしやすいように、わざとそういったメソッドを使わずに全部for文で書いています。 

  2. Objectは(おそらくMapも)「ハッシュテーブル」というデータ構造であるため、任意のキーに対して一瞬で値を取得することができます。 

  3. 正確にいうとSymbolは使えます。まあこれもだいたい文字列みたいなもんです。 

  4. 一番簡単かつ確実なのは、Objectを{}リテラルで生成せず、Object.create(null)としてprototypeオブジェクトをnullにしながら生成することです。しかし、MDN曰くこれは非推奨らしいです。なんでなんでしょうね。誰か知ってたら教えてください。 

35
24
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
24