JavaScript で下記4点を連想配列として用いる場合の使い分け方を解説します。
- オブジェクト初期化子
Object.create(null)
- Map
- WeakMap
オブジェクトは連想配列に例えられる事が多いですが、その理由は [key, value] をセットにして格納できる性質にあります。
実際に、オブジェクト初期化子で連想配列を作ってみましょう。
({}
の正式名称は「オブジェクト初期化子」ですが、「オブジェクトリテラル」と呼ばれる事もあります)
var obj = {a: 1, b: 2, c: 3}; // オブジェクト初期化子({}) で [key, value] を定義する
console.log(JSON.stringify(obj)); // {"a":1,"b":2,"c":3}
オブジェクト初期化子には、連想配列と違い、定義された key を数える機能がない為、次の代替手段が使われます。
Object.keys()
で直属のプロパティを列挙 (列挙可能な文字列を key とするプロパティに限定される)Object.getOwnPropertyNames
で直属の全てのプロパティを列挙 (列挙可能なプロパティ、列挙不可能なプロパティのいずれも列挙するが、文字列をkeyとするプロパティのみ)Object.getOwnPropertySymbols()
で直属の全てのSymbol
プロパティを列挙
var obj = {a: 1, b: 2, c: 3};
obj[Symbol(4)] = 4;
Object.defineProperty(obj, 'e', {value: 5});
console.log(Object.keys(obj)); // ["a", "b", "c"]
console.log(Object.getOwnPropertyNames(obj)); // ["a", "b", "c", "e"]
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(4)]
console.log(Object.keys(obj).length); // 3
console.log(Object.getOwnPropertyNames(obj).length); // 4
console.log(Object.getOwnPropertySymbols(obj).length); // 1
key の列挙は前述の通りですが、実は列挙順がランダムという性質があります。 つまり、ブラウザによって列挙順が異なる可能性があります。
JavaScriptにはオブジェクトのプロパティ参照時、直属のプロパティ名が存在しなければ、[[Prototype]]
上のプロパティ名を探して検索するという性質があります。
この性質をプロトタイプチェーンと呼びますが、説明すると長くなる為、詳細は下記リンク先を参照して下さい。
var obj = {};
console.log(Object.prototype === Object.getPrototypeOf(obj)); // true
console.log(obj.__proto__ === Object.getPrototypeOf(obj)); // true
console.log(JSON.stringify(Object.getOwnPropertyNames(Object.prototype))); // ["constructor","__defineGetter__","__defineSetter__","hasOwnProperty","__lookupGetter__","__lookupSetter__","isPrototypeOf","propertyIsEnumerable","toString","valueOf","__proto__","toLocaleString"]
console.log(JSON.stringify(Object.getOwnPropertyNames(obj))); // []
obj.__proto__ = 1;
console.log(obj.__proto__); // {constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, hasOwnProperty: ƒ, __lookupGetter__: ƒ, …}
obj.__proto__ = null;
console.log(obj.__proto__); // undefined
obj.__proto__ = [];
console.log(obj.__proto__); // []
obj.__proto === Object.prototype.__proto__
であり、このプロパティは Object 型以外が代入された場合に undefined
を返す性質があります。
Object.prototype
上に存在する他のプロパティも影響を受けるはずですが、実際にどうなるか検証してみましょう。
var obj = {},
prototype = Object.getPrototypeOf(obj), // [[Prototype]] を参照する
keys = Object.getOwnPropertyNames(prototype); // [[Prototype]] 上の全てのプロパティを列挙
for (var i = 0, len = keys.length; i < len; ++i) {
obj[keys[i]] = 1;
}
console.log(JSON.stringify(keys)); // ["constructor","__defineGetter__","__defineSetter__","hasOwnProperty","__lookupGetter__","__lookupSetter__","isPrototypeOf","propertyIsEnumerable","toString","valueOf","__proto__","toLocaleString"]
console.log(keys.length); // 12
console.log(JSON.stringify(obj)); // {"constructor":1,"__defineGetter__":1,"__defineSetter__":1,"hasOwnProperty":1,"__lookupGetter__":1,"__lookupSetter__":1,"isPrototypeOf":1,"propertyIsEnumerable":1,"toString":1,"valueOf":1,"toLocaleString":1}
console.log(Object.keys(obj).length); // 11
__proto__
以外は問題なく、使えそうですね。
前節(プロトタイプチェーン)のコードについて、もう一度、考えてみましょう。
hasOwnProperty
等は使わないようにすればいいとしても、toString
(ToString
、String型に変換する時に使用される ) やvalueOf
(ToPrimitive
、Primitive型に変換する時に使用される ) は内部的に使われる際にエラーを誘発させてしまうのではないでしょうか。
String(obj); // TypeError: Cannot convert object to primitive value
Number(obj); // TypeError: Cannot convert object to primitive value
new Date(obj); // TypeError: Cannot convert object to primitive value
やはり、TypeError
となりました。
オブジェクト初期化子を連想配列として使う場合は、内部動作で型変換処理が働かないように注意する必要がありそうです。
オブジェクト初期化子は Object.prototype
を [[Prototype]]
に持つ故の問題がいくつかありました。
では、[[Prototype]]
が存在しないオブジェクトを生成してみてはどうでしょうか。
Object.create()
は第一引数に [[Prototype]]
を指定でき、null
を指定すれば [[Prototype]]
が存在しない状態となります。
var obj = Object.create(null);
obj.__proto__ = 1;
console.log(Object.getPrototypeOf(obj)); // null
console.log(Object.keys(obj)); // ["__proto__"]
console.log(JSON.stringify(obj)); // {"__proto__":1}
[[Prototype]] === null
となり、オブジェクト初期化子では代入不可能だった "_proto__"
プロパティに代入する事が出来ました。
前節([[Prototype]]
が存在しないオブジェクト)のコードで型変換を試してみましょう。
String(obj); // TypeError: Cannot convert object to primitive value
Number(obj); // TypeError: Cannot convert object to primitive value
new Date(obj); // TypeError: Cannot convert object to primitive value
やはり、toString
, valueOf
プロパティが未定義な為にエラーになってしまいました。
この問題は Object 型全体の問題の為、[[Prototype]]
に null
を代入しても回避できません。
Map はオブジェクトの上位互換と呼べる機能で次の性質があります。
- Map は定義順で [key, value] を列挙できます
- Map は列挙可能な key を定義します。Map に列挙不可能な key は存在しません。
- Map は全ての型を key に指定できます。(例: "1" (String型)と 1 (Number型)は別の key として扱われます)
- Map に指定できる key に予約語は存在しません。(例:
"__proto__"
も問題なく、指定できます) Map.prototype.size
によって、要素の数を返すことが出来ます
基本的には、オブジェクト初期化子と同じように連想配列として使う事になります。 Map は第一引数にイテレータを指定することで [key, value] のセットとして扱います(配列はイテレータの一種)。
const map = new Map([[1, 'Hello'], [2, 'World!']]);
map.set('1', 'JavaScript'); // key="1", value="JavaScript" をセットする
console.log(map.get(1)); // "Hello"
console.log(map.get('1')); // "JavaScript" (key=1 と key="1" は別物)
console.log(map.has(2)); // true (key=2 は存在する)
console.log(map.size); // 3
Map.prototype.forEach
や for-of
で要素を定義順に列挙できます。
const map = new Map([[1, 'a'], ['__proto__', 'b'], [Symbol(3), 'c']]);
map.forEach((value, key, map) => console.log([key, value])); // [1, "a"] -> ["__proto__", "b"] -> [Symbol(3), "c"]
for (let entry of map) { // entry === [key, value]
console.log(entry); // [1, "a"] -> ["__proto__", "b"] -> [Symbol(3), "c"]
}
for (let entry of map.entries()) { // entry === [key, value]
console.log(entry); // [1, "a"] -> ["__proto__", "b"] -> [Symbol(3), "c"]
}
for (let key of map.keys()) {
console.log(key); // 1 -> "__proto__" -> Symbol(3)
}
for (let value of map.values()) {
console.log(value); // "a" -> "b" -> "c"
}
new Map
もイテレータなので、Spread要素(SpreadElement)で展開できます。
const map = new Map([[NaN, 1], [+0, 2], [-0, 3]]);
console.log([...map]); // [[NaN, 1], [0, 3]] (+0 と -0 は同値扱いの為、後で定義した値に上書きされてしまう)
console.log(map.get(NaN)); // 1 (NaN は同値扱いとして get できる)
WeakMap は Map と比べて機能が限定されています。
- key に指定可能な型は Object 型のみ
- 要素の数を確認できない (
WeakMap.prototype.size
がない) - 要素を列挙できない (
WeakMap.prototype.forEach
がなく、iterable
でもない) - 全ての要素を削除する機能がない (
WeakMap.prototype.clear
がない) - 弱参照である (key に指定されたオブジェクトがどこからも参照されなくなった時、自動的にメモリから解放される)
WeakMap (弱いMap) はその名の通り、弱参照の性質を持ちます。
/**
* WeakMap
*/
var wm = new WeakMap();
function sample1 () {
var obj = {};
wm.set(obj, 1);
console.log(wm.get(obj)); // 1
}
sample1();
// sample1 内の obj はどこからも参照されなくなった為、ガベージコレクション(Garbage Collection)によって wm の中から解放(削除)される
new WeakMap
は、どこからも参照されなくなったオブジェクトがメモリから解放される事をECMAScript 仕様が保証しています。
対して、Map やオブジェクトは、そうではありません。
/**
* オブジェクト
*/
var object = {};
function sample2 () {
object.a = 1;
console.log(object.a); // 1
}
sample2();
// sample2 内のプロパティ a は object から参照されている為、メモリから解放されない
/**
* Map
*/
var map = new Map();
function sample3 () {
var obj = {};
map.set(obj, 1);
console.log(map.get(obj)); // 1
}
sample3();
// sample3 内の obj は map から参照されている為、メモリから解放されない
Map やオブジェクトは弱参照ではないので、参照を保持し続けます。
WeakMap は機能限定版だからこそ、出来る事があります。
var wm = new WeakMap;
function foo () {
var bar = {};
wm.set(bar, 1);
console.log(wm.get(bar)); // 1
}
function hello () {
var world = {};
wm.set(world, 2);
console.log(wm.get(world)); // 2
}
foo();
hello();
set して get するという無意味なコードになっていますが、注目してほしいのは次の事実です。
wm.get(bar)
を参照出来るのは関数 foo の中だけであるwm.get(world)
を参照出来るのは関数 hello の中だけである
変数 wm
は広いスコープとなっていますが、実際には key となるオブジェクトによってスコープが制限されているのです。
WeakMap は Map と違い、要素を列挙する事ができず、全ての要素を削除する機能が用意されていませんので、set された [key, value] を他のスコープから操作される心配もありません。
前節では key でスコープを分割しましたが、WeakMap 自身でスコープを分割する方法もあります。
const object = {};
function foo () {
const wm1 = new WeakMap([[object, 1]]);
console.log(wm1.get(object)); // 1
}
function bar () {
const wm2 = new WeakMap([[object, 2]]);
console.log(wm2.get(object)); // 2
}
foo();
bar();
前節と比較し、オブジェクトと new WeakMap
の位置が入れ替わっている事が分かります。
wm1.get(object)
を参照できるのは関数 foo 内だけであるwm2.get(object)
を参照できるのは関数 bar 内だけである
この性質をうまく活用したものに、class 構文でプライベートプロパティを定義するコードがあります。
その昔、DOM 3 Core で Node#setUserData
というメソッドが仕様化された事がありました。
これは任意のDOMオブジェクトにオブジェクトを埋め込む関数で、DOMオブジェクトとJavaScriptオブジェクトを結びつけるAPIとして期待されていましたが、何か問題があったようで廃止されました。今では、HTML Standard 規定の data-*
属性が存在しますが、オブジェクトを埋め込み可能なAPIは用意されていません。
WeakMapは逆転の発想で DOM オブジェクトにオブジェクトを結びつけることが出来ます。
var wm = new WeakMap([[document.getElementById('sample'), 'Hello, World!']]);
console.log(wm.get(document.getElementById('sample'))); // "Hello, World!"
このコードは考え方次第では、#sample
の要素ノードの隠されたプロパティに "Hello, World!"
を埋め込んでいる状態と似ています。オブジェクトの参照が切れれば、対応するプロパティも切れますが、WeakMap も弱参照ですから、プロパティと同様にメモリから解放されるわけで動作的にはそっくりですね。
Node#setUserData
との違いは、次の2点で WeakMap の方がより安全に実装できる事が分かります。
- WeakMap 単位でスコープを分割できる
- DOMノード側からは WeakMap のデータを参照できない
ECMAScript 2017 準拠の機能比較表。
プロパティ/機能 | オブジェクト初期化子 | Object.create (null) | Map | 機能説明 |
---|---|---|---|---|
*.prototype.constructor |
〇 | × | 〇 | インスタンス生成元のコンストラクタ(書き換え可能) |
*.prototype.size |
× | × | 〇 | 格納されている要素数(Array#length のようなもの) |
*.prototype.delete() |
〇 (※1) | 〇 (※1) | 〇 | 対象の key を持つ要素を削除 (※1 delete 演算子で代替可能) |
*.prototype.has() |
〇 (※2) | △ (※2) | 〇 | 対象の key を持つ要素の存在判定をBoolean値で返す (※2 Object.prototype.hasOwnProperty で代替可能) |
*.prototype.entries() |
△ (※3) | △ (※3) | 〇 | 全ての要素の [key,value] を持つ Itelator オブジェクトを返す (※3 ES2017 規定の Object.entries で代替可能だが、返り値は Iterator ではなく、配列である) |
*.prototype.keys() |
△ (※4) | △ (※4) | 〇 | 全ての要素の key を持つ Itelator オブジェクトを返す (※4 ES5 規定の Object.keys で代替可能だが、返り値は Iterator ではなく、配列である) |
*.prototype.values() |
△ (※5) | △ (※5) | 〇 | 全ての要素の value を持つ Itelator オブジェクトを返す (※5 ES2017 規定の Object.keys で代替可能だが、返り値は Iterator ではなく、配列である) |
*.prototype[@@iterator]() |
× | × | 〇 | 要素列挙時に実行される Iterator 関数 |
*.prototype[@@toStringTag]() |
〇 | △ (※6) | 〇 | インスタンスに与えられるオブジェクトの種類を表す名前 (ES5 では [[Class]] だった) (※6 [[Prototype]] が存在しない為、明示的に初期化する必要有) |
定義順で [key, value] を列挙 | × (※7) | × (※7) | 〇 | 定義順で要素を列挙する (※7 定義順が仕様定義されていない為、列挙される順序は実装依存) |
全ての型の key を指定できる | × (※8) | × (※8) | 〇 | 全ての型のkeyを指定できる (※8 オブジェクトは String, Symbol 型の key のみ指定できる) |
"__proto__" キーに値をセット |
× (※9) | 〇 | 〇 | ※9 Object 型のみセット可能だが、プロトタイプ上のプロパティとして扱われる |
"valueOf" キーに値をセット |
△ (※10) | 〇 | 〇 | ※10 セット可能だが、ToPrimitive (Primitive型への型変換)実行時に TypeError を誘発させる |
"toString" キーに値をセット |
△ (※11) | 〇 | 〇 | ※11 セット可能だが、ToString (String型への型変換)実行時に TypeError を誘発させる |
"hasOwnProperty" キーに値をセット |
〇 (※12) | 〇 (※12) | 〇 | ※12 当該メソッドを使用する場合は、Object.prototype.hasOwnProperty.call(object, key) を指定する必要有 |
"isPrototypeOf" キーに値をセット |
〇 (※13) | 〇 (※13) | 〇 | ※13 当該メソッドを使用する場合は、Object.prototype.isPrototypeOf.call(prototypeObject, object) を指定する必要有 |
ECMAScript 2017 準拠の機能比較表。
プロパティ/機能 | Map | WeakMap | 機能説明 |
---|---|---|---|
*.prototype.constructor |
〇 | 〇 | インスタンス生成元のコンストラクタ(書き換え可能) |
*.prototype.size |
〇 | × | 格納されている要素数(Array#length のようなもの) |
*.prototype.delete() |
〇 | 〇 | 対象の key を持つ要素を削除 |
*.prototype.get() |
〇 | 〇 | 対象の key を持つ要素を返す |
*.prototype.has() |
〇 | 〇 | 対象の key を持つ要素の存在判定をBoolean値で返す |
*.prototype.set() |
〇 | 〇 | 指定した [key, value] を要素に格納する |
*.prototype.clear() |
〇 | × | 全ての要素を削除する |
*.prototype.entries() |
〇 | × | 全ての要素の [key,value] を持つ Itelator オブジェクトを返す |
*.prototype.forEach() |
〇 | × | 定義順で要素を列挙し、[key, value, map] を引数に持つコールバック関数を実行する(Array#forEach と同じ動作) |
*.prototype.keys() |
〇 | × | 全ての要素の key を持つ Itelator オブジェクトを返す |
*.prototype.values() |
〇 | × | 全ての要素の value を持つ Itelator オブジェクトを返す |
*.prototype[@@iterator]() |
〇 | × | 要素列挙時に実行される Iterator 関数 |
*.prototype[@@toStringTag]() |
〇 | 〇 | インスタンスに与えられるオブジェクトの種類を表す名前 (ES5 では [[Class]] だった) |
弱参照 | × | 〇 | key に指定されたオブジェクトの参照が切れた時、WeakMap 上からも要素を削除する |
※SpreadElement は「Array Initializer」の節を参照。
- 12.2.5 Array Initializer - ECMAScript® 2017 Language Specification
- 12.2.6 Object Initializer - ECMAScript® 2017 Language Specification
- 19.1.2.2 Object.create - ECMAScript® 2017 Language Specification
- 23.1 Map Objects - ECMAScript® 2017 Language Specification
- 23.3 WeakMap Objects - ECMAScript® 2017 Language Specification