개요
이전 글에서 제네레이터의 기본적인 의미와 작동을 추상적인 레벨에서 알아봤습니다.
이번 포스팅에서는 보다 하드하게 스펙 상 정의되어있는 기저의 작동과 의미를 살펴봅니다.
GeneratorFunction 클래스
우선 다음의 코드를 잠시보죠.
const gene1 = function*(){}; const gene2 = function*(){}; gene2 instanceof gene1.__proto__.constructor //true
gene1, gene2는 모두 제네레이터 함수입니다. 이 함수도 객체이므로 프로토타입을 확인하여 생성자를 추출할 수 있죠.
헌데 gene1와 gene2가 모두 동일한 생성자로부터 태어난 인스턴스라는 점을 위의 코드로 알 수 있습니다.
바로 이 클래스가 GeneratorFunction 클래스로 제네레이터 함수를 만드는 행위는 내부적으로 봤을 때, GeneratorFunction 클래스의 인스턴스를 만드는 것입니다.
스펙 문서에서는 GeneratorFunction 클래스에 몇 가지 기본적인 사항을 정의해두고 있습니다.
- class GeneratorFunction extends Function 형태로 Function을 상속한 클래스다.
- 함수를 상속했으므로 기본적으로 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을 갖고 있는 이유를 생각해봐야겠죠. 보통 클래스 생성자와 프로토타입의 관계는 다음과 같습니다.
- 클래스를 정의하면 생성자 함수의 prototype속성에 오브젝트가 설정됩니다.
- 따라서 GeneratorFunction이 생성자(함수)임을 고려하면 GeneratorFunction.prototype은 오브젝트겠죠.
- 간단히 일반화하면 XXX.prototype에서 XXX는 생성자 함수인 셈입니다.
마찬가지로 논리로 생각하면
- GeneratorFunction.prototype.prototype 입장에서 (GeneratorFunction.prototype).prototype이라고 보면 (XXX).prototype 꼴이 모양이므로 XXX에 해당되는 GeneratorFunction.prototype은 생성자 함수여야 합니다.
- 즉 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*(){})가 사실은..
- 임의로 생성된 GeneratorFunction를 상속한 자식 클래스라 생성자로서 인식되고 제네레이터 함수를 호출하여 얻게 되는 개별 제네레이터는 제네레이터 함수의 프로토타입을 물려받게 될 것입니다. 코드로 표현하면 아래와 같습니다.
//1. 제네레이터 함수를 만든다는 건 일종의 클래스 선언이다. const BaseGenerator = function*(){}; //2. 제네레이터 함수를 호출하면 BaseGenerator클래스의 인스턴스를 얻는 셈 const generator = BaseGenerator(); //3. 따라서 instanceof가 성립하고 generator instanceof BaseGenerator //true //4. 프로토타입을 조사해봐도 일치한다. generator.__proto__ == BaseGenerator.prototype //true
그럼 깊이 살펴봐야 할 것은 4번의 제네레이터 함수의 prototype입니다.
- 앞 장에서 생성된 generator인스턴스는 Generator를 상속한다고 했는데,
- 더 정확한 표현은 제네레이터 함수가 Generator를 상속한 클래스이기 때문입니다.
결국 class BaseGenerator extends Generator 상황입니다.
이는 기존 js지식으로 생각하면 굉장히 혼란한 상황을 야기합니다.
- 상속은 생성자 함수를 부모로 둬야 하는데 Generator는 오브젝트입니다.
- 이미 앞 장에서 제네레이터 함수가 상속받는건 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번 상황과도 연결되는데 공식 문서의 다이어그램을 봐도 프로토타입기반에서는 절대로 흉내낼 수 없는 다중 상속을 구현하고 있습니다. 아래 일부 발췌된 그림의 빨간 선을 집중해보죠.
- 이 그림을 보면 g1이 두 개의 클래스를 동시에 상속하고 있다는 것을 알 수 있습니다.
- 제네레이터 함수는 결국 GeneratorFunction의 자식이자 Generator의 자식인 셈입니다.
- 하지만 그림의 오른쪽으로 연결된 실질적인 g1.prototype은 GeneratorFunction쪽으로 상속되지 않고 Generator쪽을 상속받은 객체임을 알 수 있습니다.
- 그림 가장 우하단에 있는 g1()을 통해 만들어진 객체는 결국 Generator의 인스턴스가 되고 동시에 g1.prototype에 연결되어
- 최종적으로 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도 알아보겠습니다.
recent comment