なぜ JavaScript の [1, 2, 3] + [4, 5, 6] は '1,2,34,5,6' なのか
先日次のツイートを見かけた。
I have been writing Javascript since roughly 1997 but it still manages to occasionally do something that absolutely shocks me pic.twitter.com/JyYOo4wGOu
— mcc (@mcclure111) January 11, 2022
JavaScript では [1, 2, 3] + [4, 5, 6]
の結果が "1,2,34,5,6"
であり、この挙動が直感に反しているというツイートである。
実際のところ筆者も直感に反していると思う。しかしこの挙動は至って ECMAScript の仕様通りである。
この記事では、なぜこの挙動が ECMAScript の仕様に従っていると言えるのか仕様を引用して説明する。
大雑把な答え
まず大雑把な答えを示しておこう。
JavaScript で [1, 2, 3] + [4, 5, 6]
が "1,2,34,5,6"
になるのは、オペランドの配列の Array.prototype.toString
が呼び出され、それらが文字列として結合されるからだ。
しかしこれではつまらないのでちゃんと仕様をたどっていく。
+ 演算子
まずは +
演算子の挙動がどのように定められているか見ていこう。
+
演算子は The Addition Operator として https://tc39.es/ecma262/#sec-addition-operator-plus に定義されている。
構文については多くの人の想像する通りだと思うし今回の本題ではないので無視するとして、セマンティクスを見てみよう。ここでは次のようにセマンティクスが定義されている。
1. Return ? EvaluateStringOrNumericBinaryExpression(AdditiveExpression, +, MultiplicativeExpression).
AdditiveExpression
と MultiplicativeExpression
とはここではそれぞれ左辺と右辺のことである。
つまり +
演算子は EvaluateStringOrNumericBinaryExpression
という Abstract Operation に左辺と+
、そして右辺を渡した結果を返す。
EvaluateStringOrNumericBinaryExpression
EvaluateStringOrNumericBinaryExpression
は https://tc39.es/ecma262/#sec-evaluatestringornumericbinaryexpression に定義されている。
この Abstract Operation は leftOperand
、opText
、rightOperand
という次の3つの引数を取る。
そしてその3つの引数を次のステップに従って実行する。
1. Let lref be the result of evaluating leftOperand.
2. Let lval be ? GetValue(lref).
3. Let rref be the result of evaluating rightOperand.
4. Let rval be ? GetValue(rref).
5. Return ? ApplyStringOrNumericBinaryOperator(lval, opText, rval).
これらのステップを大雑把に説明する。まず leftOperand
を評価した結果を lref
とする。そして GetValue(lref)
の結果を lval
とする(GetValue
は https://tc39.es/ecma262/#sec-getvalue で定義されている。今回考えている場合のように [1, 2, 3]
のような単純な配列を渡す場合はそのままの配列が返ってくると考えてよい。)。次にleftOperand
に対しての処理と同じことを rightOperand
に対しても行う。
そうして lval
と rval
が得られる。
最後に ApplyStringOrNumericBinaryOperator
という Abstract Operation に対して lval
と、引数として受け取っていた opText
、そして rval
を渡し、その結果を返す。
つまり EvaluateStringOrNumericBinaryExpression
はオペランドと演算子の種類を引数として受け取り、オペランドを評価し GetValue
した上で、そのまま ApplyStringOrNumericBinaryOperator
に渡すだけの Abstract Operation である。
ApplyStringOrNumericBinaryOperator
ApplyStringOrNumericBinaryOperator
は https://tc39.es/ecma262/#sec-applystringornumericbinaryoperator に定義されている。
ApplyStringOrNumbericBinaryOperator
は lval
、opText
、rval
という3つの引数を受け取る。
ApplyStringOrNumbericBinaryOperator
のステップをすべて掲載すると長いの関連するステップのみ説明していく。
まずは最初のステップでは引数で受け取った opText
のバリデーションを行う。
1. Assert: opText is present in the table in step 7.
step 7 に掲載されている表によると opText
は次のいずれかである必要がある。
**
*
/
%
+
-
<<
>>
>>>
&
^
|
もちろんこの記事の対象である +
もここに含まれており妥当な opText
である。
次のステップは opText
が +
であるときのみ実行される。
2. If opText is +, then
a. Let lprim be ? ToPrimitive(lval).
b. Let rprim be ? ToPrimitive(rval).
c. If Type(lprim) is String or Type(rprim) is String, then
i. Let lstr be ? ToString(lprim).
ii. Let rstr be ? ToString(rprim).
iii. Return the string-concatenation of lstr and rstr.
d. Set lval to lprim.
e. Set rval to rprim.
a
と b
で lval
と rval
をそれぞれ ToPrimitive
に渡してその結果を lprim
、rprim
とする。
c
では、lprim
と rprim
の少なくともどちらかの型が String
であればもう片方も String
に変換し、それぞれ lstr
、rstr
とする。そして lstr
と rstr
を文字列として結合した結果を ApplyStringOrNumericBinaryOperator
全体の結果とする。この場合には後続の d
と e
は実行されない。
結論からいえば [1, 2, 3] + [3, 4, 5]
では、このステップ c
が実行されることで文字列の結合が行われ "1,2,34,5,6"
が出来上がるというわけだ。
c
が実行されるのは、 lprim
と rprim
の少なくともどちらかの型が String
であるときだけだ。そして lprim
と rprim
は ToPrimitive
によって返された値である。すなわち、配列に対する ToPrimitive
の結果が String
であるために、冒頭で紹介した直感的でない挙動が引き起こされているのだ。
ToPrimitive
ToPrimitive
は https://tc39.es/ecma262/#sec-toprimitive に定義されている。
ToPrimitive
は input
という1つの必須の引数と preferedType
という1つのオプショナルの引数を受け取る。
ToPrimitive
は input
が Object 型だったときにそれを非 Object 型(つまりプリミティブ型)に変換する Abstract Operation である。
ToPrimitive
は次のステップに従って実行される。
1. If Type(input) is Object, then
a. Let exoticToPrim be ? GetMethod(input, @@toPrimitive).
b. If exoticToPrim is not undefined, then
i. If preferredType is not present, let hint be "default".
ii. Else if preferredType is string, let hint be "string".
iii. Else,
1. Assert: preferredType is number.
2. Let hint be "number".
iv. Let result be ? Call(exoticToPrim, input, « hint »).
v. If Type(result) is not Object, return result.
vi. Throw a TypeError exception.
c. If preferredType is not present, let preferredType be number.
d. Return ? OrdinaryToPrimitive(input, preferredType).
2. Return input.
1
は input
が Object 型のときのみ実行される(input
が最初から非 Object 型のときは 2
に進みそのまま input
を返す)。
次に 1
の各ステップ a
、b
、c
について説明する。
ステップ a
では GetMethod
を使って input
の@@ToPrimitive
を取得し exoticToPrim
とする(GetMethod
は https://tc39.es/ecma262/#sec-getmethod に定義されている。名前の通りオブジェクトからメソッドを取得するための Abstract Operation である。)。
@@ToPrimitive
は Well-known Symbols の1つで、Object 型の値がプリミティブに変換されるときの挙動を制御できる。MDN にドキュメントがあるので詳しくはそちらを参照してほしい。
配列にはデフォルトの @@ToPrimitive
は存在しないので今回の場合は exoticToPrim
は undefined
になる。
そしてステップ b
は If exoticToPrim is not undefined, then
という条件付きで実行されるので、exoticToPrim
が undefined
である今回は b
は実行されない。
次にステップ c
では preferedType
が存在しないときに preferedType
を number
とする。今回 ToPrimitive
は ApplyStringOrNumbericBinaryOperator
から呼び出されているが、preferedType
は指定されていないためこの c
により preferedType
は number
になる。
最後のステップ d
では input
と preferedType
を OrdinaryToPrimitive
という別の Abstract Operation に渡し、その結果を返す。
つまり ToPrimitive
は input
に @@ToPrimitive
が存在すればそれに基づいて input
を非 Object 型に変換するが、@@ToPrimitive
が存在しない場合は OrdinaryToPrimitive
を呼び出し、その結果を返す Abstract Operation である。
OrdinaryToPrimitive
OrdinaryToPrimitive
は https://tc39.es/ecma262/#sec-ordinarytoprimitive に定義されている。
OrdinaryToPrimitive
は O
と hint
という2つの引数を受け取る。O
は Object であり、hint
は string
もしくは number
である。ToPrimitive
から渡された input
が O
で、preferedType
が hint
だ。
OrdinaryToPrimitive
は次のステップに従って実行される。
1. If hint is string, then
a. Let methodNames be « "toString", "valueOf" ».
2. Else,
a. Let methodNames be « "valueOf", "toString" ».
3. For each element name of methodNames, do
a. Let method be ? Get(O, name).
b. If IsCallable(method) is true, then
i. Let result be ? Call(method, O).
ii. If Type(result) is not Object, return result.
4. Throw a TypeError exception.
まずステップ 1
と 2
によって methodNames
が決定する。hint
が string
のときは methodNames
は "toString", "valueOf"
になり number
のときは "valueOf", "toString"
になる。今回は ToPrimitive
の 1
の c
によって preferedType
が number
になっているので、methodNames
は "valueOf", "toString"
である。
ステップ 3
では methodNames
の各要素(今回の場合 "valueOf"
と "toString"
)に対して順にステップ a
と b
を実行する。各ループごとに methodNames
の要素は name
という名前に格納される。
まず a
では Get
を使って O
から name
に対応するメソッドを取得し method
とする(Get
は https://tc39.es/ecma262/#sec-get-o-p に定義されている)。
次に b
では IsCallable
を使い method
が呼び出し可能かどうかを調べる(IsCallable
は https://tc39.es/ecma262/#sec-iscallable に定義されている)。もし呼び出し可能であれば method
を呼び出しその結果を result
とする。そしてその結果が非 Object 型であれば result
を返す。
そしてステップ 1
、2
、3
を実行しての何も返すことができなかった場合、ステップ 4
で TypeError
をスローする。
OrdinaryToPrimitive
を JavaScript で簡単に表現すると次のようになる。当然厳密ではないので疑似コードだと考えてほしい。
function OrdinaryToPrimitive(O, hint) {
const methodNames =
// 1.
hint === "string" ? ["toString", "valueOf"]
// 2.
: ["valueOf", "toString"];
// 3.
for (const name of methodNames) {
// a.
const method = Get(O, name);
// b.
if (IsCallable(method)) {
// i.
const result = method(O);
// ii.
if (typeof result !== "object") {
return result;
}
}
}
// 4.
throw TypeError();
}
今回の場合は methodNames
は "valueOf", "toString"
なので、その順番でループが実行される。
1回目のループでは Get
を使って O
(今回は配列)から valueOf
を取得し method
とする。method
には配列の valueOf
が格納され、IsCallable(method)
をパスする。しかし配列の valueOf
はその配列を返す。つまり配列の valueOf
は Object 型の値を返すのだ。そのため ii
の If Type(result) is not Object
という条件はパスできない。したがって値を何も返さず次のループへ進む。
2回目のループでは、Get
を使って O
から toString
を取得し method
とする。配列には toString
が定義されているので、method
はその配列の toString
になる。今回のループでは method
に 配列の toString
が格納されているので IsCallable(method)
は true
になる。次に i
でその method
を呼び出した結果を result
とする。配列のデフォルトの toString
は String
を返す。String
は非 Object 型なので If Type(result) is not Object
という条件をパスし result
が OrdinaryToPrimitive
の返り値となる。
つまりなんだっけ?
ApplyStringOrNumericBinaryOperator
を思い出してほしい。
ApplyStringOrNumericBinaryOperator
では ToPrimitive
によって左辺と右辺をプリミティブ化した値(lprim
と rprim
)が String
であれば、それを結合して返すのだった。
配列に対する ToPrimitive
は結局のところ配列の toString
を呼び出したものを返す。配列の toString
の挙動は簡単に確認できる。(仕様では https://tc39.es/ecma262/#sec-array.prototype.tostring で定義されている)
const arrStr = [1, 2, 3].toString();
console.log(arrStr); // "1,2,3"
つまり、単純にそれを結合したものが ApplyStringOrNumericBinaryOperator
の返り値になり、それはそのまま +
演算子の返り値になるのだ。
[1, 2, 3] + [4, 5, 6]
の場合は、ToPrimitive([1, 2, 3])
が "1,2,3"
であり ToPrimitive([4, 5, 6])
が "4,5,6"
なので ApplyStringOrNumericBinaryOperator
によってその2つが文字列として結合され +
演算子全体の結果が "1,2,34,5,6"
になったということである。
ここからハック
さて、この仕様がわかればいくつかのハックが思いつくだろう。
まずは ToPrimitive
によって OrdinaryToPrimitive
よりも先に実行される @@ToPrimitive
を上書きすればその挙動を変更できる。
const arr = [1, 2, 3];
arr[Symbol.toPrimitive] = () => "hello!!";
console.log(arr + [4, 5, 6]); // "hello!!4,5,6"
また、配列の場合 toString
よりも valueOf
の方が優先される。なので valueOf
が非 Object 型を返すように上書きしてもその挙動を変更できる。
const arr = [1, 2, 3];
arr.valueOf = () => "hello!!";
console.log(arr + [4, 5, 6]); // "hello!!4,5,6"
もしくは toString
自体を上書きしてもその挙動を変更できる。
const arr = [1, 2, 3];
arr.toString = () => "hello!!";
console.log(arr + [4, 5, 6]); // "hello!!4,5,6"
まとめ
The Addition Operator の仕様には次のような記述がある。
Note: The addition operator either performs string concatenation or numeric addition.
つまり +
演算子というのは数値の加算もしくは文字列の結合を行う演算子なのだ。したがって直感的でない挙動を避けるためにはそれ以外の用途では使わない方がよいだろう。
当然だが TypeScript では [1, 2, 3] + [4, 5, 6]
のような式はコンパイルエラーになる。TypeScript を使おう。