🥴

オブジェクトを連想配列として使うと痛い目にあいますよ

2 min read 6

2億回くらい口をすっぱくして言ったような気がします。(嘘)

が、過去に何度も語られて語りつくされた話題なので、雑にまとめておきます。

JavaScript でオブジェクトを連想配列として使っちゃだめです。
だめ。全然だめ。絶対だめ。

こういう、『なんか工夫して使ったら使えちゃったから便利。』だけどあとからひどい目にあうという変な構文やテクニックがやたらおおくてカオスなのがJavaScriptの難しさです。気をつけましょう。

const inputValue = 'a';

const object1 = {
  a: 'Aです',
  b: 'Bです',
  c: 'Cです',
}

const isUndefined = value => typeof value === 'undefined';

if (isUndefined(object1[inputValue])) {
  console.log('処理がみつかりません');
} else {
  console.log(object1[inputValue]);
}

これで、inputValueがa,b,c,d など【さまざまな値】であっても、出力結果は次のどれか【のみ】なる。そのようにプログラムは読めますよね。

  Aです / Bです / Cです / 処理がみつかりません

ちがいます。全然ちがいます。絶対ちがいます。

inputValue に、'toString' や、 'constructor' を代入して調べてみてください。

  function toString() { [native code] }

とか

  function Object() { [native code] }

と出力されます。

object1 は、自分で定義してなくても、constructor や toString というプロパティを最初から持っているのです。

なので、このようなオブジェクトを連想配列扱いするようなプロパティに対する文字アクセスで分岐処理をするような使い方はしてはいけません。

そんな使い方は想定していないから大丈夫。

大丈夫なわけねーだろ。

という実例を教えていただいたので引用させていただきます。

https://twitter.com/kymn_/status/1297171791962546178

「以下では、バグの原因について報告します。 パフォーマンスをを計算する部分のコードについて、以下のようなコードがありました。(単純化しています。) rate = rates[user] ? rates[user] : default; ratesは内部レートを格納するJSONをパースしたObject型、userは参加者のIDです。」

ratesには「一度以上参加した参加者のレート」が格納されています。そのため、userが初参加ならばrates[user]は偽として評価され、rateにはdefaultが格納されるはずでした。
しかし、JavaScriptのオブジェクトはデフォルトで複数のメンバを持っています。例えば、"toString"や"constructor"です。

ratesには「一度以上参加した参加者のレート」が格納されています。そのため、userが初参加ならばrates[user]は偽として評価され、rateにはdefaultが格納されるはずでした。
しかし、JavaScriptのオブジェクトはデフォルトで複数のメンバを持っています。例えば、"toString"や"constructor"です。

ここで、このような名前を持つユーザーが参加した場合を考えます。
当然rates["constructor"]はundefinedではないため、真として評価されます。結果として、rateに数値でない値が入ってしまったためにその後の計算でNaNが発生し、連鎖的に様々なところが破壊されました。
これが今回起きたことです。

こわっ、、、こわすぎる。。。地獄や。

参考

それぞれリンク先のコメントまで十分に読みましょう。

https://qiita.com/impl_chamuji/items/277d225784e11a5a7937#comment-faa14506b0a83860c305

https://qiita.com/impl_s/items/3ab29783daf4f160bd1f#comment-54032c1bdb1f10e06c49

https://qiita.com/impl_s/items/3ab29783daf4f160bd1f#comment-c3827f4b3bd953d710c8

なるべくバグが発生しないプログラミングコードを書きましょう的な現場からは以上です。

追記

t12uさんから知識を増やしてくださる良いコメントを頂きましたのでコメント欄も読まれることをおすすめします。

念の為なのですが、この記事でいいたいのはキーと値のペアなデータ構造としてオブジェクトを使わないように、という事ではなく、特定文字列がトラブルの原因になる場合があるから、使い方に気をつけていきましょう、という意図で書いています。

より安全なプログラムを目指してがんばっていきましょう!

Discussion

JavaScript の擁護をすると笑、Object.prototype.hasOwnProperty でオブジェクト自身が持つプロパティかどうかを検証できるので、オブジェクトを安全に連想配列として使うことができます。

const object1 = {
  a: 'Aです',
  b: 'Bです',
  c: 'Cです',
}

Object.prototype.hasOwnProperty.call(object1, 'a') // true
Object.prototype.hasOwnProperty.call(object1, 'toString') // false

オブジェクトを扱うときは必須ですよね。hasOwnProperty。

ただ、constructorとかtoStringという文字列を扱えない時点で連想配列とは呼べないと思います。

例にあげた、ユーザー名にconstructorという文字列を使った時の不具合を防止できるものでもないでしょうし、

また、IT用語辞典サイトなんて作ってみて、constructorだけは登録できません、とか、あるいは登録はできてDBに書き込めたけど、それが読込時に落ちてサイトが動かないとかなると、アジャパーとなります。

hasOwnPropertyで防御するくらいならMap使いましょう、というか、Mapってそういう用途にも使える用に作られたんだと思います。

他には愚直に

const array1 = [
  [文字,],
  [文字,],
  [文字,],
]

こんなデータ構造を自作しても文字列をキーにした連想配列は作れるのでオブジェクト連想配列として使っちゃいかんかなと。🥴

constructorとかtoStringという文字列を扱えない時点で連想配列とは呼べない

constructor や toString はオブジェクト自身ではなく Object.prototype のプロパティなので、key が constructor や toString であっても以下のように使えますが、そういう意味ではありませんか?
この記事で提起されている問題であればこれで十分対応可能だと思います。

const o1 = {
  constructor: "hey",
  toString: "yo"
}

Object.prototype.hasOwnProperty.call(o1, "constructor") // true
Object.prototype.hasOwnProperty.call(o1, "toString") // true

const o2 = {}

Object.prototype.hasOwnProperty.call(o2, "constructor") // false
Object.prototype.hasOwnProperty.call(o2, "toString") // false

Map を使うべきというのは全く同意です。

追記:もしかしたら in 演算子の挙動と勘違いされているかもしれません。
in 演算子はプロトタイプチェーンをさかのぼってプロパティをチェックします。

"toString" in {} // true

なるほどー!!!上書きすると、そんな動きするんですね。知らなかったです。
すいません、理解が不足していました。
理解できました。ありがとうございます。

これもうまく動きますね。

const o2 = {}
o2.constructor = 'test'
o2.toString = 'test'

console.log(
  Object.prototype.hasOwnProperty.call(o2, "constructor") // true
)
console.log(
  Object.prototype.hasOwnProperty.call(o2, "toString") // true
)

toStringをウワがいてしまうのは、MDNのObject.create(null)したら、デバッグで困ったりするよ問題(下記リンク)にぶつかりそうなので、こわいもんですが、勉強になりました。ありがとうございます。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/create

constructorとかtoStringとか、どんな文字列がアウトなのか疑わしくていろいろドキドキしそうですが、hasOwnでチェックすればまあ、なんとかなるんですね。


in についてありがとうございます。in は理解していたのですが、constructor や toString を上書きできるということがすっかり抜けていました。

よかったです!

実際、どんな文字列がアウトなのか疑わしくていろいろ怖い

Object.prototype に存在するようなプロパティは暗黙に呼び出されることがあるので混乱を呼ぶという話ですね。

これはプロトタイプチェーン上に存在するものでなければ問題ないと思います。
プロトタイプチェーン上のプロパティを取得する関数を書いてみたので、使ってみてください。

const getAllProperties = (o = Object.create(null), result = []) => {
  const prototype = Object.getPrototypeOf(o)
  if (!prototype) {
    return result
  }
  const keys = Object.getOwnPropertyNames(prototype)
  return getAllProperties(prototype, [...result, ...keys])
}

console.log(getAllProperties([]))
`*
["length", "constructor", "concat", "copyWithin", "fill", "find", "findIndex", "lastIndexOf", "pop", "push", "reverse", "shift", "unshift", "slice", "sort", "splice", "includes", "indexOf", "join", "keys", "entries", "values", "forEach", "filter", "flat", "flatMap", "map", "every", "some", "reduce", "reduceRight", "toLocaleString", "toString", "constructor", "__defineGetter__", "__defineSetter__", "hasOwnProperty", "__lookupGetter__", "__lookupSetter__", "isPrototypeOf", "propertyIsEnumerable", "toString", "valueOf", "__proto__", "toLocaleString"]
*`

ありがとうございます。すごい。
非常に勉強になります!

ログインするとコメントできます