nmi.jp Twitter → @tkihira
ExGameの製作で感じたこと JavaScript イディオム集

JavaScriptの+演算子の謎挙動に迫る


2013-06-28
Takuo Kihira

JavaScriptで、手っ取り早く文字列を数字に変換するときに、+演算子(正確には単項+演算子)を使います。

var str = "3";
console.log(str + 1); // 31 because of ("3" + 1)
var num = +str;
console.log(num + 1); // 4 because of (3 + 1)


JavaScriptの仕様として、単項+演算子を使うと、内部的にToNumberと呼ばれる処理を呼び出します。

console.log(+"5"); // 5
console.log(+""); // 0
console.log(+"a"); // NaN
console.log(+"5 a"); // NaN
console.log(+true); // 1 falseは0
console.log(+{}); // NaN
console.log(+null); // 0
console.log(+undefined); // NaN

必ず数値型になるので、数値に変換できない場合はundefinedではなくNaNとなります。nullやundefinedの数値変換に違和感があるかもしれませんが、まあここまではよしとしましょう。

ところが、これを、配列に対して適用すると、直感に反した挙動になります。

console.log(+[]); // 0
console.log(+[3]); // 3
console.log(+[null]); // 0
console.log(+[undefined]); // 0
console.log(+["3"]); // 3
console.log(+[1,2]); // NaN
console.log(+[true]); // NaN
console.log(+[false]); // NaN

なぜこのような処理になるのか、JavaScriptの仕様書(ECMA Script Specification 5th)から説明してみましょう。

まず最初、単項+演算子(Unary + Operator)を評価する際に、ToNumberを呼び出すのは前述したとおりです。仕様書の(11.4.6)に書かれています。ではToNumberとはどのような処理でしょうか。仕様書の(9.3)セクションを読んでみると、

  • undefined → NaN
  • Null → +0
  • false → +0、true → 1
  • Number → そのままの値
  • String → 仕様書参考
  • Object → 独自ルール

となっています。上記で+undefined==NaN、+null==0、になっているのは仕様で定められていることがわかりますね。さて、今回問題にしているのは配列です。配列は内部的にはObjectになりますので、Objectの独自ルールを紐解いてみましょう。仕様書(9.3)によると、Objectに対してToNumberが呼ばれた場合は、

  • ToPrimitiveを、数値になることをヒントとして呼び出す
  • その返値を、再度ToNumberにかける

という、自分自身を再帰で呼び出す大変複雑な構造になっています。ToPrimitiveも内部的な特殊関数で、primitive(undefined、null、string、number、boolean)の値に変換することを保証する処理になります。

では次に、ToPrimitiveの動作を追ってみましょう。仕様書の(9.1)になります。Object以外の場合は、そのまま受け取った値を返す関数になりますが、Objectの場合は、ヒントを利用して(今回は数値)、これまた内部の特殊メソッドである[[DefaultValue]]の値を返すことになっています。

というわけで、[[DefaultValue]]の処理を見てみましょう。仕様書の(8.12.8)になります。今回はヒントが数値であることがわかっているので、数値ヒントの場合の処理を追ってみると、

  • まずvalueOfという関数があることを確認し、もしあった場合はvalueOfの値を返す
  • もしvalueOfがない場合は、toStringという関数があることを確認し、あった場合はtoStringの値を返す
  • 両方なかった場合は例外を返す

という処理になっていることがわかります。

今扱っているのは配列です。配列には、デフォルトでvalueOf関数がありませんので、必然toStringが呼ばれることになります。そうすると次に追うのはArray.prototype.toStringメソッドになるわけですが、そちらの定義は仕様書の(15.4.4.2)となります。そちらを読むと、

  • もしjoinメソッドがあれば、それを呼ぶ
  • なければObject.prototype.toStringと同等の処理を呼ぶ

ということがわかります。普通、Arrayはjoinメソッドを持っているので、最終的にjoinメソッドが呼ばれることがわかりました。

というわけで、長々と追っていきましたが、やっと配列の数値化のメカニズムを理解することが出来ました!上の例でいうと、

  • +[] → +([].join()) → +("") → 0
  • +[3] → +([3].join()) → +("3") → 3
  • +null → +([null].join()) → +("") → 0
  • +undefined → +([undefined].join()) → +("") → 0
  • +["3"] → +(["3"].join()) → +("3") → 3
  • +[1, 2] → +([1, 2].join()) → +("1,2") → NaN
  • +[true] → +([true].join()) → +("true") → NaN
  • +[false] → +([false].join()) → +("false") → NaN

という処理になっていることがお分かりになるかと思います。複雑すぎて直感とは大幅に変わってしまっていますね。

ちなみに、上記は正確に仕様が守られておりまして、例えば

console.log([].toString, +[]); // "", 0
delete Array.prototype.toString;
console.log([].toString, +[]); // "[object Array]", NaN
delete Object.prototype.toString;
console.log(+[]); // Exception: Type Error

という風に仕様通りに動いていますし、例えば

Array.prototype.valueOf = function() { return 999; };
console.log(+["中に", "何を", "入れようとも"]); // 999

という風に遊ぶことも出来ます。

配列を数値化するなんてセンスのないコードを書く人はいないとは思いますが、仮にそのようなセンスのないコードを書いたとした場合に言語仕様的にどのように処理をされているのかを追っていくのは、言語を深く知るためにもとてもよいトレーニングだと思います。よくわからない挙動に当たった場合は、ぜひ仕様を追っていってみてください。面白いですよ!