[es2015+] Generator #2 / 3

개요

이전 글에서 제네레이터의 기본적인 의미와 작동을 추상적인 레벨에서 알아봤습니다.
이번 포스팅에서는 보다 하드하게 스펙 상 정의되어있는 기저의 작동과 의미를 살펴봅니다.

GeneratorFunction 클래스

우선 다음의 코드를 잠시보죠.

const gene1 = function*(){};
const gene2 = function*(){};

gene2 instanceof gene1.__proto__.constructor //true

gene1, gene2는 모두 제네레이터 함수입니다. 이 함수도 객체이므로 프로토타입을 확인하여 생성자를 추출할 수 있죠.
헌데 gene1와 gene2가 모두 동일한 생성자로부터 태어난 인스턴스라는 점을 위의 코드로 알 수 있습니다.

바로 이 클래스가 GeneratorFunction 클래스로 제네레이터 함수를 만드는 행위는 내부적으로 봤을 때, GeneratorFunction 클래스의 인스턴스를 만드는 것입니다.

스펙 문서에서는 GeneratorFunction 클래스에 몇 가지 기본적인 사항을 정의해두고 있습니다.

  1. class GeneratorFunction extends Function 형태로 Function을 상속한 클래스다.
  2. 함수를 상속했으므로 기본적으로 callable 객체로서 인스턴스는 호출 가능한 타입이다.

즉 GeneratorFunction의 인스턴스는 일종의 호출할 수 있는 함수 같은 녀석이 된다는 것입니다.
es2015에서는 빌트인 객체를 상속할 수 있으므로 callable 인스턴스를 만드는 건 어려운 일이 아닙니다.

이를 이해하기 위해 Function을 상속한 클래스를 만들고 프록시의 apply트랩을 사용하여 this도 바인딩 시켜보죠.

const Repeater = class extends Function{
  constructor(v){

    //1. 함수 몸체에서 this를 사용했지만 
    //2. Function생성자는 전역스코프로 바인딩되어버린다
    super('count', `return Array.from(' '.repeat(count)).map(_=>this.v)`);
    this.v = v;

    //3. 프록시를 이용해 호출 시 항상 자신이 바인딩되도록 변경하자!
    return new Proxy(this, {apply:(f, t, a)=>f.apply(this, a)})
  }
};
(new Repeater('abc'))(3); //["abc", "abc", "abc"]

그럼 마찬가지로 GeneratorFunction도 비슷하게 생성할 수 있을 것입니다. 스펙문서에 기술된 GeneratorFunction 생성자의 시그니쳐는 함수 생성자 인자와 대동소이하므로 비슷하게 자식클래스를 만들 수 있습니다.
GeneratorFunction클래스는 기본적으로 전역에 직접 부여된 이름이 없도록 규정되어있습니다만, 이미 위의 예에서 생성자를 얻는데 성공했으므로 문제없습니다.
이제 상속 받은 클래스로 제네레이터를 만들어보죠.

//생성자 강제 탈취
const GeneratorFunction = (function*(){}).__proto__.constructor;

//자식클래스 생성
const SubGene = class extends GeneratorFunction{
  constructor(){

    //super를 통해 제네레이터 정의
    super('', `yield* [1,2,3,4];`);
  }
};

//인스턴스 생성
const gene = new SubGene();
console.log([...gene()]); //[1,2,3,4]

근데 제네레이터도 callable이므로 함수 쪽에서 사용했던 Proxy기법이 다 먹힙니다. 실제로 사용해보죠.

const SubGene = class extends (function*(){}).__proto__.constructor{
  constructor(data){

    //this.data로 부터 가져옴
    super('', `yield* this.data;`);

    this.data = data;

    //callable이므로 apply트랩을 동일하게 적용
    return new Proxy(this, {apply:(f, t, a)=>f.apply(this, a)})
  }
};

const gene = new SubGene([1,2,3,4]);
console.log([...gene()]); //[1,2,3,4]

이제 어느 정도 GeneratorFunction의 실체와 function*(){} 구문이 무엇을 대체하는지 알게 되었습니다.

GeneratorFunction클래스의 prototype

이제 GeneratorFunction클래스의 prototype을 살펴보면 다음과 같은 속성이 발견됩니다.

(function*(){}).__proto__ == {
  prototype:Generator,
  [Symbol.toStringTag]:'GeneratorFunction',
  constructor:GeneratorFunction
};

일단 prototype이라는 속성을 갖고 있다는 점이 특이합니다. 보통은 생성자가 소유하고 인스턴스에게는 __proto__로 전파될 속성 명이 prototype인데 특이하게 __proto__ 안에 prototype이 또 정의되어 있는 셈입니다. 표현하자면 다음과 같은 상황입니다.

const GeneratorFunction = function(){};

GeneratorFunction.prototype.prototype = {...};

보통의 클래스들과 달리 prototype안에 다시 prototype을 갖고 있는 이유를 생각해봐야겠죠. 보통 클래스 생성자와 프로토타입의 관계는 다음과 같습니다.

  1. 클래스를 정의하면 생성자 함수의 prototype속성에 오브젝트가 설정됩니다.
  2. 따라서 GeneratorFunction이 생성자(함수)임을 고려하면 GeneratorFunction.prototype은 오브젝트겠죠.
  3. 간단히 일반화하면 XXX.prototype에서 XXX는 생성자 함수인 셈입니다.

마찬가지로 논리로 생각하면

  1. GeneratorFunction.prototype.prototype 입장에서 (GeneratorFunction.prototype).prototype이라고 보면 (XXX).prototype 꼴이 모양이므로 XXX에 해당되는 GeneratorFunction.prototype은 생성자 함수여야 합니다.
  2. 즉 GeneratorFunction.prototype은 특이하게도 오브젝트인데 클래스 생성자인 셈입니다.

여기까지 설명한 GeneratorFunction.prototype를 생성자로 하는 클래스의 이름이 바로 “Generator”입니다.

  • GeneratorFunction.prototype == Generator
  • GeneratorFunction.prototype.prototype == Generator.prototype

그래도 생성자가 함수가 아니라 객체라니 이상하죠? 이를 스펙문서에서는 다음과 같이 표현하고 있습니다.

an ordinary object that serves as the abstract constructor of Generator instance

즉 보통의 객체지만 Generator의 인스턴트들에게 추상생성자로 제공된다라는 겁니다. 이를 코드로 확인해보죠.

//GeneratorFunction의 인스턴스
const generatorFunction = function*(){};

//Generator의 인스턴스
const generator = generatorFunction();

//Generator의 생성자가 GeneratorFunction의 prototype과 일치하는가
generator.__proto__.constructor == generatorFunction.__proto__); //true

//위에게 잘된다고 instanceof가 통과되는 건 아니다!
generator instanceof generatorFunction.__proto__); //false

생성자가 객체라는 건 확인했지만 instanceof는 작동하지 않습니다. 그건 instanceof 스펙을 보면 이해가 됩니다. 4번 항목에 target항에 들어갈 객체가 callable이 아니면 즉시 false가 되기 때문에 오브젝트인 GeneratorFunction.prototype으로는 true가 될 수 없는 것입니다.

어쨌든 스펙대로 굉장히 신기한 클래스를 보게 된 것입니다. 생성자가 함수가 아니라 객체라니!
제네레이터 함수는 결국 내부의 복잡한 작동을 통해 Generator의 인스턴스를 만들어 내는 역할을 수행합니다.
헌데… 아직도 어지러운 얘기는 끝나지 않았습니다.

Generator 클래스와 prototype

어지러우니 다시 정리해보죠.

//1. GeneratorFunction의 생성자
const GeneratorFunction = (function*(){}).__proto__.constructor

//2. GeneratorFunction의 prototype
GeneratorFunction.prototype

//3. Generator인스턴스
const generator = (function*(){})();

//4. Generator의 생성자
const Generator = generator.__proto__.constructor;

//5. 4은 특이하게 객체로 2번과 일치
Generator === GeneratorFunction.prototype

//6. Generator의 prototype
Generator.prototype === GeneratorFunction.prototype.prototype

한 마디로 GeneratorFunction.prototype은 Generator다라고 정리할 수 있습니다. 어떤 생성자의 프로토타입이 다른 클래스의 생성자가 되다니 굉장히 희안하지만 그렇게 된 셈이죠. 일단 생성되는 인스턴스의 위상 층에 대한 정리가 끝났다면, 실제 정의되는 클래스에 대한 보다 상세한 정리를 할 차례입니다.

제네레이터 함수(function*(){})가 사실은..

  1. 임의로 생성된 GeneratorFunction를 상속한 자식 클래스라 생성자로서 인식되고 제네레이터 함수를 호출하여 얻게 되는 개별 제네레이터는 제네레이터 함수의 프로토타입을 물려받게 될 것입니다. 코드로 표현하면 아래와 같습니다.
//1. 제네레이터 함수를 만든다는 건 일종의 클래스 선언이다.
const BaseGenerator = function*(){};

//2. 제네레이터 함수를 호출하면 BaseGenerator클래스의 인스턴스를 얻는 셈
const generator = BaseGenerator();

//3. 따라서 instanceof가 성립하고
generator instanceof BaseGenerator //true

//4. 프로토타입을 조사해봐도 일치한다.
generator.__proto__ == BaseGenerator.prototype //true

그럼 깊이 살펴봐야 할 것은 4번의 제네레이터 함수의 prototype입니다.

  1. 앞 장에서 생성된 generator인스턴스는 Generator를 상속한다고 했는데,
  2. 더 정확한 표현은 제네레이터 함수가 Generator를 상속한 클래스이기 때문입니다.

결국 class BaseGenerator extends Generator 상황입니다.

이는 기존 js지식으로 생각하면 굉장히 혼란한 상황을 야기합니다.

  1. 상속은 생성자 함수를 부모로 둬야 하는데 Generator는 오브젝트입니다.
  2. 이미 앞 장에서 제네레이터 함수가 상속받는건 GeneratorFunction 이라고 했습니다. 자바스크립트는 다중상속을 허용하지 않습니다.

우선 1번을 코드로 표현하자면

const Generator = (function*(){}).__proto__; //오브젝트임

//오브젝트를 부모로 상속할 수 없다!
const SubGeneratorFunction = class extends Generator{} //Error!

이런 상황이 되어버립니다. 이는 js코드 상으로는 성립할 수 없는 구조입니다.
반대로 처음에 다뤘던 GeneratorFunction를 상속받는 건 차라리 가능하죠.

const GeneratorFunction = (function*(){}).__proto__.constructor;
const SubGene = class extends GeneratorFunction{} //ok!

하지만 이렇게 상속 받은 SubGene는 prototype이 GeneratorFunction을 상속받은 객체입니다.
실제 엔진 상에서 SubGene.prototype은 Generator를 상속받은 객체가 되어야 합니다. 결국 스펙문서의 내용을 js코드로 개발자가 흉내낼 수 있는 방법은 없습니다.

2번 상황과도 연결되는데 공식 문서의 다이어그램을 봐도 프로토타입기반에서는 절대로 흉내낼 수 없는 다중 상속을 구현하고 있습니다. 아래 일부 발췌된 그림의 빨간 선을 집중해보죠.

  1. 이 그림을 보면 g1이 두 개의 클래스를 동시에 상속하고 있다는 것을 알 수 있습니다.
  2. 제네레이터 함수는 결국 GeneratorFunction의 자식이자 Generator의 자식인 셈입니다.
  3. 하지만 그림의 오른쪽으로 연결된 실질적인 g1.prototype은 GeneratorFunction쪽으로 상속되지 않고 Generator쪽을 상속받은 객체임을 알 수 있습니다.
  4. 그림 가장 우하단에 있는 g1()을 통해 만들어진 객체는 결국 Generator의 인스턴스가 되고 동시에 g1.prototype에 연결되어
  5. 최종적으로 Generator.prototype의 능력을 쓸 수 있게 되는 셈입니다.

강제로 GeneratorFunction을 얻어 자식 클래스를 만든다고 해도 function*(){} 구문처럼 Generator를 상속한 클래스는 될 수는 없다는 사실을 알 수 있습니다.

다행히 이 그림은 그저 실현 불가능한 교훈만 있는 것은 아닙니다 ^^

어떤 제네레이터 함수에서 만들어진 Generator의 인스턴스들은 해당 제네레이터 함수의 prototype을 물려받는 사실을 알 수 있습니다. 이를 간단히 코드로 이용해보면 아래와 같습니다.

const g1 = function*(){};
g1.prototype.log =_=>console.log('test');

//이제 모든 g1()으로 생성되는 제네레이터인스턴스들은 test메소드를 쓸 수 있다.
g1().log();//test
g1().log();//test

이 경우 이미 클래스문을 벗어나 생성되는 메소드라 HomeObject 바인딩을 쓸 수는 없으니, super를 사용할 수는 없을 것입니다. 하지만 자신의 상속 받은 메소드를 사용하는 건 문제없죠. 이를 통해 next()가 반환하는 복잡한 IteratorResult({value,done}형태) 대신 간단히 값을 반환하는 전용 메소드를 하나 제작해보죠.

const Gene = function*(arr){
  yield* arr;
};
Gene.prototype.take = function(count){
  const ret = [], v;
  while(count--){
    const v = this.next().value;
    if(v === undefined) break;
    ret.push(v);
  }
  return ret;
};

Gene([1,2,3,4]).take(2); //[1,2]
Gene(['a','b','c']).take(1); //['a']

실은 next도 오버라이드할 수 있습니다. 기존 프로토타입 패턴처럼 미리 잡아두면 그만이기 때문입니다.
하지만 이제 학습을 통해 확실한 계층구조를 익혔으므로(정말?) GeneratorFunction으로부터 역으로 추적해 Generator.prototype을 잡아두고 시작해보죠. 이를 이용해 값을 두 배로 만들어서 반환하는 next를 만들어보죠.

//Generator의 프로토타입을 미리 잡아둔다.
const GeneratorPrototype = (function*(){}).__proto__.prototype;

const Gene = function*(arr){
  yield* arr;
};

//next를 재정의 하자!
Gene.prototype.next = function(){
  const ret = GeneratorPrototype.next.call(this);
  if(ret.value !== undefined) ret.value *= 2;
  return ret;
};

//이제 모든 이터레이터에 반응!
[...Gene([1,2,3])] //[2,4,6]

메소드로서의 제네레이터 함수

function*(){} 은 그 자체로 클래스 같은 녀석입니다. 헌데 이걸 다른 클래스의 메소드로 지정하면 어떻게 되는 것일까요? 코드로는 아래와 같은 상황입니다.

//오브젝트의 속성으로 할당됨
const test = {
  *gene(){}
};

//클래스의 메소드가 됨
const Test = class{
  *gene(){}
};

일단 기본은 이게 함수처럼 보이지만 일단 클래스이기 때문에 만들어진 클래스를 속성으로 추가한다는 개념으로 바라보면 쉽습니다.

//일단 만들어진 제네레이터 함수
const generator = function*(){};

//오브젝트의 속성으로 할당
const test = {
  gene: generator
};

//클래스의 속성으로 할당
const Test = class{};
Test.prototype.gene = generator;

헌데 클래스 내부에서의 메소드 선언은 super바인딩을 정적으로 확정 짓습니다. 따라서 위와 같은 코드로는 재현할 수 없는 super의 사용을 허용합니다. 이를 이용한 제네레이터를 구현해보죠.

const Parent = class{

  //생성자에서 배열을 잡아둠
  constructor(arr){
    this.arr = arr;
  }

  //해당 배열을 반환함
  *gene(){
    yield* this.arr;
  }
};

const Child = class extends Parent{
  constructor(arr){
    super(arr);
  }
  *gene(){
    //우선 부모측을 해소하고
    yield* super.gene();
    //추가로 진행
    yield* "abc";
  }
};

const test = new Child([1,2,3]);

[...test.gene()] //[1, 2, 3, 'a', 'b', 'c']

이런 점에서 클래스의 메소드로 지정된 제네레이터 함수는 훨씬 다양한 형태로 활용해볼 수 있습니다.

결론

이번 포스팅에서는 스펙 정의에 따라 제네레이터의 상세한 구동 원리와 그 응용된 코드를 살펴봤습니다.
마지막 시리즈에서는 Generator의 상태 변화와 현업에서 주로 활용되는 패턴을 살펴보고 차기 es2018에서 유력하게 표준이 될 것으로 보이는 asyncGenerator도 알아보겠습니다.

%d 블로거가 이것을 좋아합니다: