[es6+] Object Rest/Spread와 Iterable

개요top

ES2018부터 적용되는 Object Rest/Spread Properties는 미묘한 특징이 있습니다.

Array로 Object Rest/Spread를 할 때top

우선 아래 코드를 보죠.

//Rest로 해체될 때
const {...a} = [1,2,3];
console.log(a); //{"0":1, "1":2, "2":3}

//Spread로 사용될 때
const b = {...[1,2,3]};
console.log(b); //{"0":1, "1":2, "2":3}

이 결과만 보면 배열의 경우 Object Rest와 Spread가 잘 작동합니다. 따라서 Iterable인터페이스가 수용되는 것처럼 보일 수 있습니다.

Iterable로 Object Rest/Spread를 할 때top

위에서 배열이 작동하는 걸 확인했으니 간단히 iterable을 만들어서 실험해보죠.

const iterable = {
  [Symbol.iterator](){
    let cursor = 0;
    return {next(){
      return {done:cursor++ > 2, value:cursor};
    }};
  }
};

const {...a} = iterable;
//{[Symbol.iterator]:f}

const b = {...iterable};
//{[Symbol.iterator]:f}

배열일 때는 Object Rest와 Spread가 작동하던 게 직접 작성한 iterable은 적용되지 않는다는 사실을 알 수 있습니다. 말 그대로 객체로 해체되어 갖고 있던 [Symbol.iterator]키만 복사되었습니다. 그럼 배열인척 하는 iterable을 만들어볼까요.

const iterableArr = new (class extends Array{
  [Symbol.iterator](){
    let cursor = 0;
    return {next(){
      return {done:cursor++ > 2, value:cursor};
    }};
  }
})();

const {...a} = iterableArr;
//{}

const b = {...iterableArr};
//{}

클래스 문이 되어 hasOwnProperty에 [Symbol.iterator]가 안걸리게 되었을 뿐 증상은 같습니다. 혹시 new.target 탓일 수도 있으니 [Symbol.species]로 구상해버리죠.

const iterableArr = new (class A extends Array{
  static get [Symbol.species](){
    return A;
  }
  [Symbol.iterator](){
    let cursor = 0;
    return {next(){
      return {done:cursor++ > 2, value:cursor};
    }};
  }
})();

const {...a} = iterableArr;
//{}

const b = {...iterableArr};
//{}

소용없습니다. 즉 리터럴로 생성된 배열의 경우는 이터러블과 완전히 무관하게 인덱스가 자신의 키로 잡히기 때문에 Object Rest나 Spread에 반응한다는 사실을 알 수 있습니다.

사실 이는 이미 스펙문서에 자세히 기술되어있습니다 ^^(괜히 브라우저 구현체를 확인하고 싶어서 뻘 짓을 해봤습니다)
Object Rest나 Spread는 내부적으로 CopyDataProperties라는 함수를 사용하는데 그 정의는 다음과 같습니다.

CopyDataProperties ( target, source, excludedItems )

  1. target은 객체다
  2. excludedItems은 속성키의 리스트다.
  3. source가 undefined나 null이면 target을 반환한다.
  4. 속성키리스트를 돌면서
    1. 그 키가 source의 hasOwnProperty인지 체크한다.
    2. 맞으면 target에 CreateDataProperty로 그 속성과 값을 셋팅해준다.
  5. 다했으면 target을 반환한다.

결국 이 작동에 iterable을 인식하는 동작은 없는 셈입니다.

iterable을 배열처럼 Object Rest/Spread에 쓰려면?top

결국 방법은 Array Rest/Spread를 한 뒤에 다시 Object Rest/Spread를 할 수 밖에 없습니다.
처음 코드로 돌아와서

const iterable = {
  [Symbol.iterator](){
    let cursor = 0;
    return {next(){
      return {done:cursor++ > 2, value:cursor};
    }};
  }
};

요런 간단한 iterable을 대상으로 처리한다면 아래와 같은 구문이 될 것입니다.

const {...[...a]} = iterable;
const b = {...[...iterable]};

이게 잘되면 좋겠지만 미묘하게 Rest와 Spread는 문법이 다릅니다.

우선 위에서 b에 해당되는 Spread의 문법은 정확하게는 Object literal에서 지원하는 assignment 중 destructuring assignment라는 사양에 의존합니다.

근데 이 사양을 잘 보고 있으면
1. AssignmentRestProperty 부분이 DestructuringAssignmentTarget을 가리키고
2. DestructuringAssignmentTarget은 다시 LeftHandSideExpression을 가리킵니다.
3. LeftHandSideExpression은 다시 destructuring assignment을 포함하기 때문에 재귀가 성립됩니다.

그런 이유로 문법 상 const b = {…[…iterable]}; 가 성립하게 되어 문제 없이 처리됩니다.
즉 b는 const b = {…[1,2,3]} 처럼 {“0”:1, “1”:2, “2”:3}이 잘 출력됩니다.

하지만 a는 Uncaught SyntaxError: ... must be followed by an identifier in declaration contexts 라는 에러를 내고 죽어버립니다.
에러메시지는 Rest표현 뒤에는 반드시 식별자가 와야한다는 거죠. destructuring은 문법적인 변수와 그 할당에 대한 내용이라 별도로 정해져 있습니다(스펙문서에는 너무 많은 참조로 타고 내려가야해서 생략했습니다)

따라서 a가 정상이 되려면 const {…a} = […iterable] 이 될 수 밖에 없습니다. 이렇게 하면 a도 {“0”:1, “1”:2, “2”:3}이 출력됩니다.

const iterable = {
  [Symbol.iterator](){
    let cursor = 0;
    return {next(){
      return {done:cursor++ > 2, value:cursor};
    }};
  }
};

const {...a} = [...iterable]
const b = {...[...iterable]};

이렇게 귀결지을 수 있습니다.

결론top

코드스피츠 77에서 해당 내용이 나왔는데 간단히 배열로 작동하는 것을 확인한 후 Iterable도 된다고 생각해 내용을 포함시켰는데 완전히 틀렸네요.
재확인할 겸 차근차근 살펴보았습니다. 역시 ECMAScript는 계속 스펙이 추가되어가고 있고 거기에 따라 더욱 공부하고 확인 해봐야하는 내용도 많아지네요.