0w0

Js에서 [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) 2022/01/11

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에 정의되어 있다.

구문에 대해서는 다들 알 테니 글의 주제에 벗어나니 무시하고, 세멘틱을 보자. 여기에서 다음과 같은 세멘틱이 정의되어 있다.

1. Return ? EvaluateStringOrNumericBinaryExpression(AdditiveExpression, +, MultiplicativeExpression).

AdditiveExpressionMultiplicativeExpression은 좌, 우측에 있다.

그리고 + 연산자는 EvaluateStringOrNumericBinaryExpression라는 Abstract Operation에 좌측 + 우측을 전달 이런 식으로 전달한다.

EvaluateStringOrNumericBinaryExpression

EvaluateStringOrNumericBinaryExpressionhttps://tc39.es/ecma262/#sec-evaluatestringornumericbinaryexpression에 정의되어 있다.

Abstract Operation은 leftOperand, opText, rightOperand 인수를 받는다. 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 정의 이번 경우처럼 [1, 2, 3] 같이 단순한 배열을 넘기면 그대로 배열을 반환해준다 생각하면 좋다.)

leftOperand 에서 한 처리를 rightOperand에서도 한다.

그러면 lval, rval를 얻는다

마지막으로 ApplyStringOrNumericBinaryOperator Abstract Operation에 lval이랑 인수로 받는 opText, rval를 전달하고 결과를 반환한다.

EvaluateStringOrNumericBinaryExpression은 피연산자와 연산자를 인수로 받아, 비연산자를 평가해 GetValue 한 후에, 그대로 ApplyStringOrNumericBinaryOperator에 전달할 뿐인 Abstract Opertaion 한 것이다

ApplyStringOrNumericBinaryOperator

ApplyStringOrNumericBinaryOperator 정의

ApplyStringOrNumbericBinaryOperatorlval, opText, rval를 받는다

ApplyStringOrNumbericBinaryOperator의 단계를 전부 서술하기에는 공백이 부족하기에 관련되는 단계만 설명한다.

우선 첫 단계로 인수로 받은 opText의 validation 한다

1. Assert: opText is present in the table in step 7.

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, blval, rval을 각각 ToPrimitve에 전달해 결과를 lprim, rprim이라 한다.

c에서는 lprim, rprim에서 어딘가 하나라도 String이면 남은 쪽도 String으로 변환해서 그것을 lstr, rstr이라 한다. 그리고 lstr, rstr을 문자열로 결합한 결과를 ApplyStringOrNumericBinaryOperator 전체 결과로 한다

이 때 후속 d, e는 실행되지 않는다.

결론적으로 [1, 2, 3] + [4, 5, 6]c 단계가 실행되어 문자열 결합하는 "1,2,34,5,6가 된다는 소리다.

c가 실행되는 것은 lprim, rpirm이 적어도 하나가 String일 때 뿐이다.

그리고 lprim, rprimToPrimitive에 의해 변환된 값이다.

즉, 배열에 ToPrimitive 결과가 String이므로 직관에 반하는 행동이 일어난다.

ToPrimitive

ToPrimitive 정의

ToPrimitiveinput 필수 인수와 preferedType 옵션 인수를 받는다

ToPrimitiveinput이 Object 타입일 때 Object가 아닌 타입(primitive type)으로 변환하는 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.

1input이 Object 타입일 때만 실행된다 (input이 Object 타입이아니라 때는 2로 직행해서 input를 반환한다)

1a, b, c에 설명하면

aGetMethod를 사용해 input@@ToPrimitve를 얻어 exoticToPrim이라 한다.

(GetMethod 정의, 이름처럼 객체에서 메소드를 받기 위한 Abstract Operation)

@@ToPrimitve는 Well-know Symbols의 하나로 Object 타입값이 primitive으로 변환될 때 행동을 제어한다.

상세는 MDN 참조

배열에는 기본적으로 @@ToPrimitive가 없으므로 이번에는 exoticToPrimundefined이다

bIf exoticToPrim is not undefined, then 조건 실행하고 exoticToPrimundefined라면 b는 넘어간다.

cpreferedType가 존재하지 않으면 preferedTypenumber로 한다.

이번 ToPrimitiveApplyStringOrNumbericBinaryOperator에서 호출했지만, preferedType가 지정되어 있지 않아 c에 의해 preferedTypenumber가 된다

OrdinaryToPrimitive

OrdinaryToPrimitive 정의

OrdinaryToPrimitiveOhint를 받는다.

O은 Object, hintstring, number이다.

ToPrimitive에서 전달된 inputO, preferedTypehint

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이 결정된다.

hintstring이면 methodNames"toString", "valueOf"가 된다.

number"valueOf", "toString"이 된다.

이번에는 ToPrimitive 1c에 의해서 preferedTypenumber가 되었으므로, methodNames"valueOf", "toString"이 된다.

3 단계에서는 methodNames 각 요소(이번에는 valueOf, toString)에 a, b 단계를 실행한다.

각 루프마다 methodNames 요소는 name으로 한다.

a에서 Get를 사용해 O에서 name에 대한 메서드를 취득하고 method라 한다.(Get 정의)

b에서 IsCallable를 사용해 method 호출 가능을 확인한다(IsCallable 정의).

만약 호출가능하면 method 호출, 결과를 result라 한다. 결과가 Object형이 아니라면 result를 반환한다.

1, 2, 3 단계를 실행해 아무 것도 반환할 것이 없으면 4TypeError를 던진다.

OrdinaryToPrimitive를 JS로 간단히 표현하면 이렇게 된다. 의사코드로 봐달라.

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"이므로 순서대로 루프 실행한다.

첫 번째 루프에서 Get를 사용해 O(이번엔 배열)에서 valueOf를 받아 method로 한다. method에는 배열 valueOf가 담긴다.

IsCallable(method)는 넘어간다. 하지만 배열 valueOf는 배열을 반환한다.

즉, 배열 valueOf는 객체 값을 반환한다.

그렇기에 iiIf Type(result) is not Object 조건은 넘어가지 않는다. 값을 아무것도 반환하지 않으면 다음 루프로 진입한다.

두 번째 루프에서 Get를 사용해 O에서 toString를 받아 method라 한다.

배열에는 toString이 정의되어 있으므로, method는 그 배열을 toString한다.

이번 루프에서는 method에 배열 toString이 담기므로 IsCallable(method)true이다.

다음 i에서는 method를 호출한 결과를 result로 한다.

배열의 기본 toStringString을 반환한다. String은 객체가 아니므로, If Type(result) is not Object 조건을 넘어가서 resultOrdinaryToPrimitive 값이 된다.

결국에 뭐지?

ApplyStringOrNumericBinaryOperator를 떠올려보자. ApplyStringOrNumericBinaryOperator에는 ToPrimitive에 의해 좌변, 우변을 Primitive화 한 값(lprim, rprim)이 String이라면 결합해서 반환했다.

ToPrimitive는 배열을 toString 호출해서 반환한다는 소리다.

배열의 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"이 된다는 소리다.

hack

그럼 이 사양이 보았으니, 뭔가 hack이 떠올랐을 것이다.

우선 ToPrimtive에 의해 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.

+ 연산자는 수치를 더하거나 문자열을 결합하는 연산자이다.

그래서 직관적이지 않은 행동을 피하기 위해서는 그것 이외의 용오로는 사용하지 않는 것이 좋다.

당연하지만 TS에서는 [1, 2, 3] + [4, 5, 6]은 컴파일러에서 에러가난다. TS를 쓰자