JavaScriptのMapで値を取得するときmap.get('key')と冗長です。でもObjectならmap.keyと短く書けます。ここに夢を見た人々は、Objectを辞書として使い、沼に沈み死にます。
成果物
Proxy辞書オブジェクトを返すDictクラスを作成しました。
明らかに間違った狂気の所業です。しかもオブジェクト辞書ではありません。タイトル詐欺だと騒ぐのは待ってください。こうなった経緯があるのです。悪いのはJavaScriptなんです。
結論
結論は「辞書ならMapを使うべき」です。
しかし世の中には、どうしてもオブジェクトを辞書として使おうとする異常性癖の持ち主が一定数います。なにせmap.keyと短く書けますからね。この記事では、それが幻想に過ぎなかったことを証明します。
さあ、無駄な時間を過ごす覚悟はできましたね? ではご照覧あれ。
辞書
辞書とは文字列をキーにして値を取得するデータ構造です。
現代なら辞書はMapで次のように書きます。
const map = new Map([['key', 'value']]) console.assert(map.has('key')) console.assert('value'===map.get('key'))
ここで気になるのがmap.get('key')です。これをmap.keyで参照し'value'を取得したいです。いざやってみるとundefinedを返されてしまいます。
const map = new Map([['key', 'value']]) console.assert(map.has('key')) console.assert(undefined===map.key) // map.keyは未定義です console.assert('value'===map.key) // エラーです。期待は裏切られました
Objectなら達成できます。
const map = {key:'value'} console.assert(map.hasOwnProperty('key')) console.assert('value'===map.key) // OK! 期待通り
やりました。map.keyで'value'を取得できましたね。
じゃあ辞書にはObject使えば良くね? Mapとか要らなくね? と思うわけです。
ですが待ってください。ここには恐ろしい罠が潜んでいます。
Object辞書の罠
いや罠なんて無いって。ほら見て、こんな風にサクッとキーを追加できるんだぜ? ぬわんて楽チンなんだ。最高だぜ!
const map = {} map.key = 'value' console.assert(map.hasOwnProperty('key')) console.assert('value'===map.key) // OK! 期待通り
と思うでしょう? 残念ながら次のような問題があります。
map.keyで参照できるキー名には制限がある(キーの型や値によってはmap[key]と参照せねばならない)map.typoのようにタイポしたキーが追加されてしまう- プロトタイプにあるキーも参照できてしまう
1. map.keyで参照できるキー名には制限がある(キーの型や値によってはmap[key]と参照せねばならない)
キーを整数型の値0にするとエラーになります。
const map = {} map.0 = 'value' // SyntaxError: Unexpected number
const map = {0:'value'} console.log(map.0) // SyntaxError: missing ) after argument list
キーに整数値をセットできる記述もあれば、例外発生する記述もあります。いずれにせよmap.keyで参照すると例外発生します。
ですが、次のようにmap[key]と書けば参照できます。
const map = {0:'value'} console.assert(map[0]) // OK console.assert(map['0']) // OK
これはつまり、Mapにおけるmap.get('key')と同じことをやるためには、オブジェクト辞書ではmap.key、map[key]の二つの記法があり、キーの型によって使い分けねばならないことを意味しています。
困ります。map.keyで参照したいのです。キーの型が何であれ、map.keyで参照したいのです。map[key]と参照せねば例外発生したり参照できなかったりするのは困ります。
そもそも辞書のキーは文字列型だけに制限したいのです。残念ながらオブジェクトのプロパティは文字列型以外にも受け付けてしまいます。よってオブジェクトを辞書として使うと、こうした罠にかかってmap.keyと短く書くことができず実行時エラーを食らうハメになります。
また、たとえ文字列型であってもmap[key]でないと参照できない場合もあります。JavaScriptのメタ文字が該当します。たとえば半角スペースをキーにしてみましょう。
const map = {' ':'value'} console.assert(map[' ']) // OK console.assert(map. ) // SyntaxError: Unexpected token ')'
おそらく、スペースを辞書のキーにすることは皆無でしょう。でも、こうしたコードが許容されているため、起こりうる事態なのです。また、=や(などの記号を使用しても同じく構文エラーになります。
map.keyで参照できるキーは限られている、ということです。
オブジェクト辞書は、map.key書式では参照できない値でもキーとして定義できてしまいます。よって、場合によっては例外発生するという発見困難なバグが潜んだコードになってしまうのです。あるいは将来の拡張によってバグ発生する可能性を孕んだコードとも言えるでしょう。
これを回避するにはmap['key']の書式を使うことになります。するとmap.keyの時より長くなり、Mapのmap.get('key')に近づき、オブジェクト辞書を使う意義が薄れてしまいます。
いずれにせよ、目標であるmap.keyと書いたら例外発生する場合があるため使えません。
もしMapなら、必ずmap.get('key')という形式で参照するため、参照書式による例外発生は起きません。
MapとObjectにおける取得・参照の書式には次の違いがあります。
| 内容 | Map | Object |
|---|---|---|
| 取得 | map.get('key') |
map.key/map[key] |
| 代入 | map.set('key','value') |
map.key = 'value' |
Mapは統一性があるため書式差によるバグは起きません。オブジェクト辞書は書式差によるバグが起き得ます。バグる可能性を内包しているのにオブジェクト辞書を使おうと言うのですか? 考え直すべきです。
2. map.typoのようにタイポしたキーが追加されてしまう
もし代入時にキーをタイポすると、そのタイポしたキーが辞書に追加されてしまいます。
const map = {yamada:12} map.yamaDa = 13 // タイポ。yamadaがyamaDaになっちゃった! console.assert('yamaDa' in map) // タイポしたキーが追加されちゃった! console.assert(13===map.yamada) // エラー。これが期待値だった…
オブジェクト辞書にあるyamadaというキーに、別の値を代入したかった場面です。ここでタイポしてしまい、yamaDaになってしまいました。すると、このyamaDaが新しいキーとして追加されてしまうのです。
Mapでもmap.set('yamaDa', 13)と書けば同じ結果になります。よってオブジェクト辞書固有の問題とは言えません。ただしMapなら事前にmap.has('yamaDa')で存在確認できます。次のように。
const map = new Map([['yamada',12]]) function set(key, value) {if (map.has(key)) {map.set(key, value)}} set('yamaDa', 13) // タイポ。yamadaがyamaDaになっちゃった! console.assert(!('yamaDa' in map)) // でも追加されずセーフ console.assert(13===map.yamada) // エラー。これが期待値だった…
もしMapにadd()メソッドがあり、次のように区別されていれば、上手いこと使い分けることができました。
| 処理 | メソッド | 特徴 |
|---|---|---|
| 新規追加 | add(key, value) |
既存キーなら例外発生する |
| 既存更新 | set(key, value) |
未存キーなら例外発生する |
| 追加更新 | addset(key, value) |
キーが既存なら更新、未存なら追加する |
残念ながらadd()メソッドはありません。また、実際のMapが持つset()は、次のような挙動です。
| キー | Map.set(key,value) |
|---|---|
| 既存 | 既存キーの値を更新する |
| 未存 | 新規キーと値を追加する |
キーの既存や未存によって例外発生させるには、自分でメソッドを用意する必要があります。
const map = new Map([['yamada',12]]) function add(key, value) {if(map.has(key)){throw new TypeError(`キーは既存です。:${key}`)}else{map.set(key,value)}} function set(key, value) {if(map.has(key)){map.set(key, value)}else{throw new TypeError(`キーは未存です。:${key}`)}} function addset(key, value) {map.set(key, value)} set('yamaDa', 13) // タイポ。yamadaがyamaDaになっちゃった!例外発生! console.assert(!('yamaDa' in map)) // でも追加されずセーフ console.assert(13===map.yamada) // エラー。これが期待値だった…
set('yamaDa', 13)の時点で例外発生してくれます。キーは未存です。:yamaDaと怒られるので、ここでタイポしたことに気づけます。
もしadd()でタイポしたら、追加されてしまいます。もう諦めるしかありません。でも、取得する時は既存キーを参照するだけなので、渡されたキーが既存でなければ例外発生させることでタイポであると判断できます。set()と使い分ければいいのです。
これにて意図せぬバグを早い段階で防げます。デバッグの時点で例外発生してくれる可能性が高まったので後戻り作業が最小限になるでしょう。かくして効率よく品質を担保した安全なコードが製造できます。
これと同じことをオブジェクト辞書でやると、次の通りです。
const map = {yamada:12} function has(key){return [...Object.keys(key)].includes(key)} function add(key, value) {if(has(key)){throw new TypeError(`キーは既存です。:${key}`)}else{map[key]=value}} function set(key, value) {if(has(key)){map[key]=value}else{throw new TypeError(`キーは未存です。:${key}`)}} function addset(key, value) {map[key]=value} set('yamaDa', 13) // タイポ。yamadaがyamaDaになっちゃった! 例外発生! console.assert(!('yamaDa' in map)) // 追加されずセーフ console.assert(13===map.yamada) // エラー。これが期待値だった…
オブジェクト辞書のキーでタイポが起きた時、例外発生させることができました。
でも、思い出してください。map.keyと参照し、map.key = 'value'と代入したかったのです。なのに上記のコードはset('yamaDa', 13)で代入しています。その中身はmap[key]で参照しています。
また、map.yamaDa = 13と書くこともできてしまいます。よってタイポによるキー追加を抑制することができず、何の問題解決にもなりません。
これなら素直にMapのget(),set()を使ったほうがマシでしょう。
3. プロトタイプにあるキーも参照できてしまう
Objectのプロトタイプには標準でいくつかのメソッドが備わっています。
const map = {} console.assert(!('constructor' in map)) // 追加してないのに存在しちゃってる!
Objectのプロトタイプに標準装備されているメソッド一覧は、以下で確認できます。
console.log({})
次の名前がキーに使われています。
constructor hasOwnProperty isPrototypeOf propertyIsEnumerable toLocaleString toString valueOf __defineGetter__ __defineSetter__ __lookupGetter__ __lookupSetter__ __proto__
inはプロトタイプまで遡ってキー存在確認します。よってプロトタイプにそのキーがあれば真を返してしまいます。
const map = {} Object.setPrototypeOf(map, {key:'value'}) console.assert(!('key' in map)) // 自身には無いがプロトタイプには有るので真を返しちゃう!
普通Object.setPrototypeOf()は使いません。ましてや辞書オブジェクトに使うのは想定外でしょう。なので問題ないと考えてしまうかもしれません。でも言語仕様上は可能なので、いつか誰かが何らかの拡張を施されることも想定しておくべきでしょう。つまり、このコードはバグが生じる余地のあるコードと言えます。
残念ながらこれはJavaScriptの仕様です。いわゆるプロトタイプ汚染と呼ばれ、誤動作のオンパレードを生み出す死の構造です。
map自身とプロトタイプ、両者に同名キーがあった場合の挙動を確認します。
const map = {} Object.setPrototypeOf(map, {key:'value'}) delete map.key // 自身には存在しないのに例外発生せず、プロトタイプも遡らず。 console.assert('key' in map) // 自身には存在しないが、プロトタイプには存在するので真 console.assert(0===[...Object.keys(map)].length) // 自身には存在しないので0 map.key = 'X' // 自身にキー追加(プロトタイプの'value'を持つ同名キーより優先して参照される) console.assert('value'===Object.getPrototypeOf(map).key) console.assert('X'===map.key)
map.keyでkeyを参照するとき、このkeyがどこに存在するかで結果が変わります。
map自身 |
プロトタイプ | 参照値 |
|---|---|---|
| 無 | 無 | undefined |
| 有 | 無 | map自身 |
| 無 | 有 | プロトタイプ |
| 有 | 有 | map自身 |
このため、プロトタイプの状態を意識しておかないと想定外のバグが発生し得ます。
いくつかの対処法が思いつくでしょう。
Object.freeze()でプロトタイプごと凍結するObject.create(null)でプロトタイプを消す
残念ながら、どちらも根本解決しません。詳しく見ていきましょう。
3-1. Object.freeze()でプロトタイプごと凍結する
Object.freeze()はたしかにObject.setPrototypeOf()を抑制できます。
const map = Object.freeze({}) Object.setPrototypeOf(map, {key:'value'}) // TypeError: #<Object> is not extensible
ただし、辞書としてキー追加・削除、値変更まで不可能になってしまいます。
const map = Object.freeze({}) map.key = 'value' console.assert('key' in map) // エラー。追加できてない!
const map = Object.freeze({key:'value'}) delete map.key console.assert(!('key' in map)) // エラー。削除できてない!
const map = Object.freeze({key:'value'}) map.key = 'X' console.assert('X'===map.key) // エラー。変更できてない!
これでは辞書として機能しません。よってObject.freeze()でプロトタイプごと凍結する方法は使えません。
3.2 Object.create(null)でプロトタイプを消す
Object.create(null)なら、プロトタイプごと標準実装されたメソッド達を消せます。
const map = Object.create(null) console.assert(!('constructor' in map)) console.log(map)
ですが、結局あとから追加できます。Object.setPrototypeOf()を使って。
const map = Object.create(null) Object.setPrototypeOf(map, {key:'value'}) console.assert('key' in map) // 自身には無いがプロトタイプには有るため真を返しちゃう console.assert('value'===map.key) // 自身には無いがプロトタイプには有るため値を返しちゃう console.log(map)
いくらmapの原作者がObject.setPrototypeOf()を使わずとも、いつか誰かがコードを改修してObject.setPrototypeOf()を使ってしまう可能性があります。このとき、map.keyはプロトタイプまで遡ってしまうせいで、map自身に存在しないキーであってもプロトタイプにあればその値を返してしまいます。これが想定外の事態を起こします。
Object.setPrototypeOf()を使うことはないと思っていても無駄です。JavaScriptはプロトタイプベースのオブジェクト指向です。このプロトタイプは言語仕様なので、JavaScriptを使っている時点で避けることはできません。いわゆるプロトタイプ汚染です。
かといって、Object.freeze()でプロトタイプごと凍結すれば、先述の通り、辞書オブジェクトにキーの追加・削除や、値の変更ができなくなります。
詰みました。Objectでは、どう足掻いてもバグが混入する危険コードを書くハメになります。それでもmap.keyで参照したいのです。何とかならないでしょうか。
Proxyで強引に解決する
Proxyを使えば解決できます。取得、代入、削除、プロトタイプ代入といった処理に対して、任意の処理を作り込むことで、思い通りに動くオブジェクトを作ります。
main.js
Dict.new()で辞書オブジェクト(Proxy)を生成します。Dictの詳細は後述します。今はその使い方だけを書きます。
const dict = Dict.new({key:'value'}) console.assert(Dict.has(dict, 'key')) console.assert('key' in dict) console.assert('value'===dict.key) dict.key = 'X' console.assert('X'===dict.key)
期待通りdict.keyという書式で参照や代入ができています。
Objectでは問題があった事態には、きちんと例外発生させます。順に見ていきましょう。
標準オブジェクトのプロトタイプに実装されているメソッドはありません。むしろプロトタイプはnullです。
const dict = Dict.new() console.assert(null===Object.getPrototypeOf(dict)) console.assert(!Dict.has(dict, 'constructor')) console.assert(!Dict.has(dict, 'toString')) console.assert(!('constructor' in dict)) console.assert(!('toString' in dict))
もしObject.setPrototypeOf()したら例外発生し、プロトタイプ汚染を抑制します。
const dict = Dict.new() Object.setPrototypeOf(dict, {key:'value'}) // TypeError: 辞書オブジェクトにsetPrototypeOf()することを禁じます。
もし未存キーを参照したら例外発生します。Dict.new()かDict.add()した時のキーのみ有効です。
const dict = Dict.new() dict.noneKey // TypeError: 指定されたキーは辞書に存在しません。:noneKey
もし不正なキー名を指定したら例外発生します。
const dict = Dict.new({0:'value'}) // TypeError: 指定されたキーは無効値です。文字列型かつ正規表現/^[a-zA-Z][_a-zA-Z0-9]$/に一致させてください。:0
const dict = Dict.new({' ':'value'}) // TypeError: 指定されたキーは無効値です。文字列型かつ正規表現/^[a-zA-Z][_a-zA-Z0-9]$/に一致させてください。:
キー名に関してはプログラミング言語の変数名とほぼ同じ名前だけを有効にしています。すなわちmap.keyという書式で参照できるものだけです。これはmap[key]という書式で参照せずに済むような文字列のみを許可した形です。
また、_を先頭に付与してはいけない形にしたのは、将来、この辞書オブジェクトにhas()などのメソッドを付与する時、map._has()やmap._.has()のように_をプレフィクスにすることができるようにするためです。尚、メソッドを辞書オブジェクト自身またはプロトタイプに付与してしまった場合、inによって存在判定されてしまう点に注意が必要です。
Dictは他にもkeys()などのメソッドを持っています。ただし辞書オブジェクトmap自身はメソッドを何一つ持っていません。プロトタイプもnullでセットもできません。よってinにより存在確認できるのは、Dict.new()かDict.add()で追加されたキーのみです。これにて判りやすい辞書オブジェクトになったと考えます。
dict.js
辞書オブジェクトを作るクラスDictを定義します。それを補助するクラスが2つあります。
| クラス | 概要 |
|---|---|
Dict |
辞書オブジェクト(Proxy)を生成したりキーや値の追加・削除・変更・存在確認等をするヘルパー |
KeyMap |
Dictで追加されたキーをSetで管理する |
BlankObject |
Object.create(null)を生成する(オブジェクト、配列、二次元配列、文字列、null,undefinedから変換する) |
;(function(){ class KeyMap { // DictのProxyのキー一覧を保持する(ProxyとTargetの両インスタンスをキーにしたい) constructor(){this._list=[]} clear(){this._list.length=0} delete(proxyOrTarget){this._list.length=0} // proxyかtargetのいずれかが一致すれば存在すると判定する add(proxy, target, keys) {this._list.push({proxy:proxy, target:target, keys:keys})} get(proxyOrTarget) {return this._list.find(item=>item.proxy===proxyOrTarget || item.target===proxyOrTarget)} has(proxyOrTarget) {return !!this.get(proxyOrTarget)} getKeys(proxyOrTarget) {return this.get(proxyOrTarget)?.keys} setKeys(proxyOrTarget, keys) { const i = this.#fi(proxyOrTarget) if (-1<i){this._list[i].keys = keys} } deleteKey(proxyOrTarget, key) { const i = this.#fi(proxyOrTarget) this._list[i].keys.delete(key) } addKey(proxyOrTarget, key) { const i = this.#fi(proxyOrTarget) this._list[i].keys.add(key) } #fi(proxyOrTarget){return this._list.findIndex(item=>item.proxy===proxyOrTarget || item.target===proxyOrTarget)} } class Dict { static KEY_MAP = new KeyMap(); static new(obj){ const keys = this.#makeKeyList(obj) Dict.throwValidKeys(keys) const target = this.#makeTarget(obj) const proxy = new Proxy(target, this.#makeHandler()) this.KEY_MAP.add(proxy, target, new Set(keys)) return proxy } static #makeKeyList(obj) { if (Array.isArray(obj) && obj.every(o=>Array.isArray(o) && 2===o.length)) {return obj.map(o=>o[0])} else if (this.#isStrs(obj)) {return obj} else if (this.#isObj(obj)){return [...Object.keys(obj)]} else if (this.#isStr(obj)){return obj.split(' ')} else if (undefined===obj || null===obj){return []} else {throw new TypeError(`Dict.newの第一引数はundefined,null,オブジェクト,配列,二次元配列,文字列のみ有効です。{key:'value',...},[key1,key2,...],[[key,value],...],'key1 key2'`)} } static #makeTarget(obj) {return BlankObject.new(this.#makeTargetObject(obj))} static #makeTargetObject(obj) {// 配列,二次元配列,文字列などを辞書風オブジェクトに変換する if (Array.isArray(obj) && obj.every(o=>Array.isArray(o) && 2===o.length)) {return obj.reduce((o,[k,v])=>Object.assign(o,{[k]:v}),({}))} else if (this.#isStrs(obj)) {return obj.reduce((o,k,i)=>Object.assign(o,{[k]:null}),({}))} else if (undefined===obj || null===obj){return ({})} else if (this.#isStr(obj)){return obj.split(' ').reduce((o,k,i)=>Object.assign(o,{[k]:null}),({}))} else if (this.#isObj(obj)){return obj} else {throw new TypeError(`Dict.newの第一引数はundefined,null,オブジェクト,配列,二次元配列,文字列のみ有効です。{key:'value',...},[key1,key2,...],[[key,value],...],'key1 key2'`)} } static #makeHandler() { return { get(dict, key, receiver) { Dict.throwHas(dict, key) Dict.throwValidKey(key) return dict[key] }, set(dict, key, value, receiver) { Dict.throwHas(dict, key) Dict.throwValidKey(key) dict[key] = value return true }, deleteProperty(target, key) { Dict.throwHas(target, key) Dict.throwValidKey(key) delete target[key] Dict.KEY_MAP.deleteKey(target, key) return true }, // 将来setPrototypeOf()でhas()等のメソッドを実装するかもしれないが現時点では禁止してin句によりキー存在確認できるようにする setPrototypeOf(target, prototype) { throw new TypeError(`辞書オブジェクトにsetPrototypeOf()することを禁じます。`); }, } } static #isObj(obj) {return null!==obj && undefined!==obj && 'object'===typeof obj && '[object Object]'===Object.prototype.toString.call(obj)} static #isStr(str) {return 'string'===typeof str || str instanceof String;} static #isStrs(keys){return Array.isArray(keys) ? keys.every(k=>this.#isStr(k)) : false} // Keyの先頭に数字が使えない:プログラミング言語における制約。数値と区別するため。 // Keyの先頭に_が使えない:プログラミング言語では使えるが、将来オブジェクトにメソッドを追加するとき_has(),_keys()のようにしたい static isValidKey(key) {return this.#isStr(key) && /^[a-zA-Z][_a-zA-Z0-9]*$/.test(key)} static throwValidKey(key) {if(!this.isValidKey(key)){throw new TypeError(`指定されたキーは無効値です。文字列型かつ正規表現/^[a-zA-Z][_a-zA-Z0-9]$/に一致させてください。:${key}`)}} static throwValidKeys(keys){ if (!Array.isArray(keys)){throw new TypeError(`第一引数keysは文字列配列であるべきです。:${keys}:${typeof keys}`)} keys.every(k=>this.throwValidKey(k)) } static isExist(dict) {// 辞書存在確認 if (!this.KEY_MAP.has(dict)){throw new TypeError(`指定されたオブジェクトはDict.newされた辞書ではありません。`)} return this.KEY_MAP.getKeys(dict) } // キー存在確認 static has(dict, key) { if (!this.#isStr(key)){throw new TypeError(`キーは文字列であるべきです。:${key}:${typeof key}`)} return this.isExist(dict).has(key) } static throwHas(dict, key) {if (!this.has(dict, key)) {throw new TypeError(`指定されたキーは辞書に存在しません。:${key}`)}} static add(dict, key, value) {// キー追加 if (this.has(dict, key)){throw new TypeError(`Dict.addの第二引数で指定されたキーは既存です。`)} Reflect.set(this.KEY_MAP.get(dict).target, key, value) this.KEY_MAP.addKey(dict, key) } static keys(dict) {return this.keysItr(dict)} static keysItr(dict) {return this.isExist(dict).keys()} static keysAry(dict) {return [...this.isExist(dict).keys()]} static keysSet(dict) {return this.isExist(dict)} static values(dict) {return [...this.isExist(dict).keys()].map(k=>dict[k])} static entries(dict) {return [...this.isExist(dict).keys()].map(k=>[k,dict[k]])} // 拡張 static hasEvery(dict, keys) { if (Array.isArray(keys)){throw new TypeError(`Dict.hasEvery()の第二引数keysは配列であるべきです。:${keys}:${typeof keys}`)} return keys.every(key=>Dict.has(dict, key)) } static hasSome(dict, keys) { if (Array.isArray(keys)){throw new TypeError(`Dict.hasSome()の第二引数keysは配列であるべきです。:${keys}:${typeof keys}`)} return keys.some(key=>Dict.has(dict, key)) } // new Mapには無いが、あったほうが便利かも? static hasEvery(dict, keys){return this.#hasES(dict, keys, 'every')} static hasSome(dict, keys){return this.#hasES(dict, keys, 'some')} static #hasES(dict, keys, ES) {this.throwValidKeys(keys);return ['every','some'].some(es=>es===ES) && 0<keys.length ? keys[ES](k=>this.isExist(dict).has(k)) : false} // 不要だがnew Map()には存在するメンバー(clear(),size()以外は実装内容をそのまま書いたほうが短く済む) static delete(dict, key) {delete dict[key]} static clear(dict){this.keysAry(dict).map(k=>delete dict[k])} static get(dict, key){return dict[key]} static set(dict, key, value){dict[key]=value} static size(dict) {return this.isExist(dict).size} static forEach(dict, fn) { const ents = this.entries(dict); for (let i=0; i<ents.length; i++) { fn(ents[i][0],ents[i][1],i) } } // 実装する意義がない(Object.keys()が配列を返してしまうため、それをベースにyieldしてもメモリ消費が増えるだけで意味がない) // Map.prototype[Symbol.iterator]() // そもそも対象とする型が違う(groupBy()は表(table)型に対して行うものであってMap(Dict/Hash/連想配列)に対するものではない) // groupBy() // 欲しいけどnew Map()には未実装 static isEmpty(dict){return 0===this.size(dict)} static isSame(...dicts) {return this.#isSame(false, ...dicts)} static isSameKey(...dicts) {return this.#isSame(true, ...dicts)} static #isSame(isKeyOnly=false, ...dicts) { if (dicts.length < 2){throw new TypeError(`Dict.isSame${isKeyOnly ? 'Key' : ''}は比較する二つ以上のDictが必要です。`)} const keys = this.keysAry(dicts[0]) for (let i=1; i<dicts.length; i++) { if (keys.length !== this.keysAry(dicts[i]).length) {return false} if (!keys.every(k=>this.has(dicts[i], k))) {return false} if (!isKeyOnly && !keys.every(k=>dicts[i][k]===dicts[0][k])) {return false} } return true } } class BlankObject { static new(obj) {// {key:'value'}, [['key','value']], ['key','value','key2','val2'] return Object.create(null, this.#getPropObj(obj)) } static #getPropObj(obj) { if (Array.isArray(obj) && obj.every(o=>Array.isArray(o))) {return this.#makePropObjFromArys(obj)} else if (Array.isArray(obj) && 0===(obj.length % 2)){return this.#makePropObjFromAry(obj)} else if (this.#isObj(obj)){return this.#makePropObjFromObj(obj)} else if (undefined===obj || null===obj){return undefined} else {throw new TypeError(`Dict.new()の第一引数はundefined、null、オブジェクト、配列、二次元配列のみ有効です。{key:value,...}, [key,value,...], [[key,value],...]`)} } static #makePropObjFromArys(arys) {return arys.reduce((obj,[k,v])=>{this.#throwKey(k);return Object.assign(obj,{[k]:this.#getDescriptor(v)})}, Object.create(null))} static #makePropObjFromAry(ary) { const obj = Object.create(null); for (let i=0; i<ary.length; i+=2) { this.#throwKey(ary[i]); obj[ary[i]] = this.#getDescriptor(ary[i+1]); } return obj; } static #makePropObjFromObj(inObj) { const keys = Object.getOwnPropertyNames(inObj) keys.every(k=>this.#throwKey(k)) return keys.reduce((obj,k)=>Object.assign(obj,{[k]:this.#getDescriptor(inObj[k])}), Object.create(null)) } static #getDescriptor(v){return {value:v, enumerable:true, configurable:true, writable:true}} // 列挙,削除,書換 可能 static #isObj(obj) {return null!==obj && undefined!==obj && 'object'===typeof obj && '[object Object]'===Object.prototype.toString.call(obj)} static #isStr(key){return 'string'===typeof key || key instanceof String;} static #throwKey(key){if(!this.#isStr(key)){throw new TypeError(`キーは文字列であるべきです。:${key}:${typeof key}`)}} } window.Dict = Dict; })();
お気づきでしょうが、Proxyを使った時点で、もうオブジェクト辞書ではありません。タイトル詐欺です。でも仕方ありません。先述の通り、Objectには多くの欠陥があるため、辞書として使うわけにはいきませんから。
Proxy辞書なら、たしかにmap.keyでの参照や、未存キー例外発生、プロトタイプ汚染回避はできました。でもだからと言って、Mapを使わず自作クラスDictを使うべき理由になるとは到底思えません。Dictには次のような欠点があるからです。
Dictクラスの実装やテストが面倒DictをインポートするときのHTTP通信などによるパフォーマンス低下Dict.has(dict)のような冗長化したメソッド呼出
1は当然です。そもそも、このDictを実装するのが面倒です。また、一応テストはしましたが、果たして本当に問題ないのか、テスト漏れはないのか判りません。どう考えてもプロの仕事である標準APIのMapを使ったほうがいいです。
2も当然です。自作クラスはすべて外部ファイル化されており、インポートが必須です。これによる処理が発生してパフォーマンスが落ちます。もし標準APIのMapならインポート不要なので、比べるまでもなくMapのほうが良いです。アルゴリズムとしてのパフォーマンスもMapのほうが良いでしょう。さらにDictはProxyによる代理処理でパフォーマンス低下するはずです。
3は悲しい結果です。当初の目標だったmap.keyでの参照・代入ができる代わりに、それ以外のメソッド呼出がMapのそれと比較して冗長になりました。Dict.メソッド名(dict, 引数)のようにDict.とdictが必要になってしまいます。たとえば次のように。
| Map | Dict |
|---|---|
map.has('key') |
Dict.has(dict, 'key') |
map.keys() |
Dict.keys(dict) |
map.values() |
Dict.values(dict) |
map.entrie() |
Dict.entries(dict) |
map.size() |
Dict.size(dict) |
| Map | Dict |
|---|---|
map.get('key','value') |
dict.key |
map.set('key','value') |
dict.key = 'value' |
参照・代入に関してはDictのほうが短く書けます。が、それ以外のメソッドはすべて冗長化します。もちろん、それ以外のメソッドのほうが多いです。つまり、参照・代入の短縮効果は、それ以外のメソッドを利用するほど帳消しになります。むしろ逆に総合的には冗長化する可能性のほうが高いでしょう。
それでもmap.keyの書式にこだわってMapの利用を避けるのですか? もはやその意義は薄いと言わざるを得ません。
辞書はMapしかありえない
一応、Proxy辞書のプロトタイプにhas(),keys()などのメソッドを作る方法も考えられます。これならメソッド呼出の冗長化はほぼ解決するでしょう。ただし、辞書キーとの名前重複を避けるためmap._has()かmap._.has()のような形で実装することになります。やや冗長なのは避けられません。また、inによる存在確認もされてしまいます。そして、実装やテストの負担、パフォーマンス低下については、相変わらず解決できません。
辞書でありながらキーの存在確認や一覧取得ができないのは欠陥です。よって方法を提供せねばなりません。かといって自身やそのプロトタイプにそれらのメソッドを追加すると、map.key書式で参照する場合、メソッドなのか辞書キーなのか区別がつけられません。よって外部に実装し、呼び出す他ありません。すると冗長化します。Dict.has(dict,'key')のように。
ならばMapのように取得や代入がmap.get('key')と冗長化しても、他のメソッドもmap.has('key')と短く書けるほうがマシではないでしょうか?
悲しい答え
つまり、オブジェクト辞書によるmap.keyで参照できることは、最初から魅力には成り得ず、罠だったのでは?
はい、そうです。プロトタイプによって仕組まれた罠であり幻想です。
ついに真理に至りました。もっと早く教えてほしかったです。でも、それを理解するにはJavaScriptの基礎であるObjectやプロトタイプについて知っておらねばならず、込み入った話になります。なので、人はオブジェクト辞書に夢を見て、裏切られ、傷つくことで、仕組みを理解し、JavaScriptがクソ言語であることを悟って絶望するのです。
それでも意地になってmap.key参照にこだわった結果、Proxyまで使い出す始末。もはや本末転倒も甚だしい異常行動としか言えない変態行為です。
目を覚ましてください。夢から覚めてください。オブジェクト辞書は罠です。map.key参照はそれ以外に無数の被害をもたらします。奴らは甘い顔をして私達を沼へと誘う悪魔です。それを理解するに至ったこの時間を返してくれと言った所で無駄です。私達は未だ、JavaScriptの沼と付き合っていかねばならないのです。これ以外にWebクライアント言語がないのだから。
オブジェクト辞書にこだわった結果、JavaScriptの深淵を垣間見てしまいました。悪いことは言いません、辞書ならMapを使いましょう。
プロトタイプ汚染ヤバすぎ問題
プロトタイプ汚染の影響範囲と驚異度が大きすぎます。
JavaScriptにおいて、すべての非プリミティブ型であるObject型は、プロトタイプ汚染し得ます。
今回のようなトラブルが常に付きまといます。そのため、実装が終わったら全オブジェクト型に対してObject.freeze()すべきではないか? と思いました。
あるいはProxyにてプロトタイプ汚染を抑制するよう実装すべきでは? と。
これは言語の致命的な欠陥であり、使う気が失せるレベルです。特にセキュリティにも関わりそうなのが恐ろしい。Webクライアント言語としても致命的にアウトでは? なぜ世界中で未だに使われているのか理解できないくらいヤバイ気がしてきました。
もう辞書とかどうでもいい。誰か今すぐ何とかしてくれ。