[es6for3] generator 대체 #1/2

es6for3 시리즈

ES6는 새로운 언어철학을 제시하고 편리한 기능을 제공합니다. 하지만 크롬(52버전까지)조차 별도로 플래그를 활성화해야 만 쓸 수 있는 상황입니다.
이에 업계는 바벨 등의 번역기를 이용하여 ES5로 번역시키는 수를 사용하고 있습니다. 결국 바벨이 번역한 코드는 바벨라이브러리를 사용하는 ES5코드로 번역됩니다. ES6에 대한 깊은 이해는 ES6의 코드 방식을 하위 ES3.1로 번역하는걸 가능하게 하죠. ES6 기능에 대한 정확한 이해와 나만의 바벨을 만들기 위한 첫 단계로 ES6의 각 기능을 ES3.1로 번역해보죠.

generator의 suspend작동

이 글은 제네레이터를 설명하는 글은 아닙니다. 기본 개념은 여기를 참고하세요. 우선 1~10까지의 이터레이터를 직접 생성하는 코드를 작성해보죠.

const iter = {//귀찮으니 짧게 쓰자..^^;;
  curr:0, end:10,
  next(){ 
    return {value:this.curr++ < this.end ? this.curr : undefined, done:this.curr == this.end};
  }
};
console.log(iter.next().value); //1
console.log(iter.next().value); //2
.
.
console.log(iter.next().value); //10
console.log(iter.next().value); //undefined

저렇게 짧게 써도 제네레이터는 더 짧게 쓸 수 있습니다.

const generator = function*(curr = 0, end = 10){
  if(curr++ < end) yield curr;
};
const iter = generator();
console.log(iter.next().value); //1
.
.

위의 두 가지 코드를 비교해보면 굉장히 중요한 시사점이 있습니다. 이터레이터는 객체컨텍스트(this)의 의존하여 상태를 기억하고 이를 처리해가지만 제네레이터는 지역변수와 인자..즉 EC에 의존하여 상태를 기억하고 처리해갑니다.

이를 위해서는 제네레이터 내부의 코드가 동기화 명령일때도 yield를 통해 정지시킬 수 있는 강력한 코루틴기능을 요구합니다.ES6는 내부적으로 running EC의 개념을 통해 suspend가 발생하면 이전 EC에게 실행권한을 이양하고 정지해둘 수 있는 기능을 추가했습니다(참고) 본래 싱글쓰레드 특성상 EC는 스택으로 중첩될 뿐 한 번에 하나만 실행된다는 점을 생각하면 코루틴을 구현하기엔 가장 좋은 전략입니다.
이런 기저 시스템은 제네레이터 내부의 yield문으로 사용할 수 있습니다.
문제는 이렇게 실행 중인 EC를 suspend로 특정 명령실행 중에 중지시키는 능력은 오직 엔진에게만 부여된 권능이란 것이죠 ^^;

동기화명령에 대한 suspend 전략

ES3.1까지 동기화 명령을 도중에 멈출 수 있는 방법은 마땅히 없습니다. 해서 어쩔 수 없이 카운트를 이용하거나 닫힌목록 열린목록 등을 이용해 동기화 실행 구역을 나눠서 next에 대응하게 할 수 밖에 없습니다. 또한 매번 호출될 때마다 상태를 유지할 전략이 필요합니다. ES6야 EC자체를 중지했다가 실행하니 그 당시 어휘환경을 그대로 저장하고 있어 문제없습니다만 구현시에는 상태를 저장하려면 객체를 이용할 수 밖에 없을 것입니다.

우선 다음의 코드를 생각해보죠.

var result = 0;
var generator = Gene(function(v){
	Yield(1);
	Yield(2);
	Yield(3);
});
For.Of(generator(), function(v){
	result += v;
});

console.log(result); //6

이런 코드를 짜서 6을 기대하려면 Gene이란 함수가 인자로 받은 함수를 next때마다 호출하고 각 줄의 Yield는 매번 한번만 실행되었음을 보장해야합니다.
하지만 return문이 없는데 어떻게 중간에 멈춘 것처럼 보이는가?

그것은 멈춘게 아니라 나머지 Yield문이 작동하지 않은 것으로 이해할 수 있습니다. 실은 각각의 Yield문은 고유한 id를 갖고 있어 호출 시마다 자신이 이미 사용되었는지를 체크하는 기능이 있다고 보는 편이 맞겠죠.

이것을 실현하기 위해 외곽에서 감싸는 Gene함수 껍데기를 가볍게 만듭니다.

var Gene = function(f){
	if(typeof f != 'function') throw new TypeError();
	return function(ec){
		return new Generator(f, ec);
	};
};

이 정도면 제법 ES6의 사용법과 닮아 있습니다. 실질적인 작동은 매번 호출시마다 반환되는 Generator객체의 next가 담당하겠죠.
반환되는 함수의 인자가 자유롭지 못하고 ec라는 인자 하나만 받는 이유는 지역변수를 유지할 방법이 없기 때문에 이 제네레이터가 유지해야할 ec를 오브젝트로 받는다는 사실을 명확히 하기 위함입니다.
그럼 본격적으로 Genenrator클래스를 생각해보죠. 가장 중심이 되는 것은 Yield입니다. Yield는 제네레이터마다 고유하기 때문에 생성자에서 만들어줘야할 것입니다.

var Generator = function(f, ec){
  var self = this;
  this.f = f;
  this.ec = ec || {}; //어쨌든 ec는 필요.

  //Yield함수를 호출하면 본인의 result가 바뀜
  this.Yield = function(v){
    self.result = v;
  };
  
  //기본적으로 제네레이터는 이터레이터니까..
  this.value = undefined;
  this.done = false;
};

우선은 이정도로 하고 next를 구현해보죠. 결국 next를 호출할 때마다 f를 호출해주되 f내부에서 Yield를 호출한 적이 있으면 그 값을 value에 넣어주는 식이겠죠. Yield를 호출한 적이 있는지 알 수 있으려면 this.result를 특수한 값으로 초기화해야합니다. 이 특수한 값을 noYield 로 정의해둡니다.

var noYield = {};
Generator.prototype.next = function(){
  //1. done이면 이미 아무것도 할 필요가 없다.
  if(this.done) return this;

  //2. 호출하기 전 result를 yield한 적 없음으로 초기화
  this.result = noYield;

  //3. 한시적으로 Yield를 접수한다. 스택구조를 위해 prev패치
  var preYield = window.Yield;
  window.Yield = this.Yield;

  //4. 실제 실행
  this.f(this.ec);

  //5. Yield해제
  window.Yield = prevYield;

  //6. this.result로 이터레이션 처리
  if(this.result === noYield){ //반환값없으니 종료
    this.value = undefined;
    this.done = true;
  }else{
    this.value = this.result;
  }
  return this;
};

주석의 단계에 따라 진행됩니다. 간단히 말해 Yield함수를 호출하면 this.result에 값이 들어올테고 그렇다면 계속 done은 false인 상태로 유지된다는 아이디어입니다. 문제는 이렇게 되면 무한 루프에 빠진다는 거죠. 이 문제를 해결하려면

  1. 매번 next가 호출될 때
  2. 함수 내에서 각각 Yield에 대응할 id를 초기화하고
  3. Yield가 호출될 때마다 id를 증가시켜가면서 해당 id가 호출된 적이 있는 확인하는

방법으로 극복할 수 있습니다. 예를들어 다음과 같은 거죠.

var gene = Gene(function(){
  Yield(1); //내부 id = 1, 호출 0
  Yield(2); //내부 id = 2, 호출 0
  Yield(3); //내부 id = 3, 호출 0
});
var iterator = gene();

위의 상태에서 호출이 일어나면 첫번째 next에서는

iterator.next();
/*
function(){
  Yield(1); //내부 id = 1, 호출 1 - 실행됨. 일단 실행되면 다른 Yield는 작동안함.
  Yield(2); //내부 id = 2, 호출 0
  Yield(3); //내부 id = 3, 호출 0
}
*/

이렇게 마크되어 두 번째 next 시점에는 id = 1 은 더 이상 작동하지 않는 거죠.

iterator.next();
/*
function(){
  Yield(1); //내부 id = 1, 호출 1 - 호출되지만 작동안함
  Yield(2); //내부 id = 2, 호출 1 - 실행되고 다른 Yield는 작동안함.
  Yield(3); //내부 id = 3, 호출 0
}
*/

위와 같은 구조를 구현하려면 이번 next의 함수 호출시 활성화된 Yield의 id를 관리해야하고, 각각의 Yield별 아이디와 호출횟수를 관리해야합니다.
생성자에 다음과 같은 필드를 추가합니다.

var Generator = function(f, ec){
  //상동
  var self = this;
  this.f = f;
  this.ec = ec || {};
  this.value = undefined;
  this.done = false;

  //1. Yield의 id를 관리할 구조체
  this.yieldIDs = {}

  //2. 이번 호출시 활성화된 yield여부
  this.isYieldActive= false;

  //3. 고유하게 부여한 id의 seed값
  this.yieldID = 1;

  //4. Yield함수는 실행시마다 id를 확인하고 해당 id의 카운터와 이번 호출시 활성화된 id가 있는지 검사한다.
  this.Yield = function(v){
    //4-1. 이미 활성화된 yield가 있으면 패스한다.
    if(self.isYieldActive) return true;

    //4-2. 자신의 id를 얻는다.
    var id = self.yieldID++;

    //4-3. yieldIDs 에 없으면 등록한다.
    if(!self.yieldIDs.hasOwnProperty(id)) self.yieldIDs[id] = 1; //1번만 실행하도록 카운트설정
    
    //4-4. 카운트를 다 소진했으면 패스한다.
    if(!self.yieldIDs[id]) return true;

    //4-5. 카운트를 감소시킨다.
    self.yieldIDs[id]--;

    //4-6. Yield를 활성화시키고 참을 반환한다.
    self.isYieldActive = true;
    
    //4-7. 원래 Yield가 하던걸 한다.
    self.result = v;
    return false;
  };
};

이 내부구조에서 각각의 Yield는 호출시마다 고유한 id를 부여받게 되고 본인이 호출될 때마다 카운트를 까게 되니까 한번이상 실행되지 않으면서 동시에 isYieldActive로 인해 하나의 Yield가 활성화되면 나머지는 자동으로 실행되지 않게 됩니다. 이를 next 호출시마다 반영해주면 됩니다.

Generator.prototype.next = function(){
  //상동
  if(this.done) return this;
  this.result = noYield;
  var preYield = window.Yield;
  window.Yield = this.Yield;

  //아이디초기화 및 비활성상태
  this.yieldID = 0;
  this.isYieldActive = false;

  //상동
  this.f(this.ec);
  window.Yield = prevYield;
  if(this.result === noYield) this.value = undefined, this.done = true;
  else this.value = this.result;
  return this;
};

이제 실제로 그럴듯하게 돌아가게 되었습니다.

var generator = Gene(function(){
  Yield(1);
  Yield(2);
  Yield(3);
});
For.Of(generator(), function(v){
  console.log(v); //1, 2, 3
});

문제는 각 Yield는 한 번만 호출되지 않을 수도 있다는 것입니다.

반복문에 대응하는 Yield문제

EC를 중지시킬 수 있다는 초유의 기능은 다음과 같은 코드를 가능하게 만듭니다.

const generator = function*(v){
  var i = 0;
  while(i < v) yield i++;
};
const iterator = generator(3);

위의 코드에서 v값에 다다를때까지 yield를 반복합니다. 즉 while 도중에 함수밖으로 탈출했다가 다시 돌아와서 이어갈 수도 있는 무적의 코루틴이죠.
이게 성립하려면 두 가지가 필요합니다.

  1. yield로 탈출한 지점으로 다시 돌아올 수 있는 능력
  2. 지역변수의 상태를 다음 호출시까지 기억할 수 있는 능력

ES3에서는 둘다 불가능합니다. 따라서 편리한 지역변수를 포기하고 EC를 대체할 오브젝트가 주어져 이 오브젝트를 유지하는 식으로 2번을 해결해야하고 1번은 불가능하므로 처음부터 반복문을 짜지 않거나 짠다하더라도 그 위치에 자동복원은 불가능하니 적절히 나눠서 해결해야합니다.

일단 간단히 EC의 개념을 구현해보죠.

var generator = Gene(function(ec){
  while(ec.i < ec.v) return Yield(ec.i++);
});
var iterator = generator({i:0, v:3});

이 예제에서는 다행히 루프 안에 문장이 하나라 복귀지점이 필요없어 제법 그럴듯하게 보입니다. 헌데 그렇다고 해도 Yield는 한 번만 실행되니 소용없습니다. 위에서 this.Yield를 구현할 때 미리 다양한 카운터에 대응하도록 작성했으므로 인자로 카운트만 보내주면 될 것입니다.

//2번째 인자로 카운터도 받는다
this.Yield = function(v, cnt){
  //상동
  if(self.isYieldActive) return true;
  var id = self.yieldID++;

  //카운트를 반영해준다.
  if(!self.yieldIDs.hasOwnProperty(id)) self.yieldIDs[id] = cnt || 1;

  //상동
  if(!self.yieldIDs[id]) return true;
  self.yieldIDs[id]--;
  self.isYieldActive = true;
  self.result = v;
  return false;
};

따라서 최종적인 샘플 코드는 다음과 같은 모양이 됩니다.

var generator = Gene(function(ec){
  while(ec.i < ec.v) return Yield(ec.i++, ec.v); //카운트를 보내주자!
});
var iterator = generator({i:0, v:3});

이미 위의 코드에서 ec를 처리하는 코드는 포함되어있으므로 큰 문제없이 실행됩니다.

결론

제네레이터에 익숙하지 않은 분들은 좀 어려운 내용일 수도 있겠다 싶습니다. 이번 포스팅에서는 기초가 되는 구현을 진행했습니다. Generator 클래스의 경우 다쓰고 나면 재활용이 가능하기 때문에 풀링하는 편이 유리합니다. 풀링을 포함하여 정리된 전체 코드는 아래와 같습니다.

var Gene = (function(){
	var done = Object.freeze({done:true});
	var noYield = {};
	var SELF; //Yield함수의 컨텍스트
	var pool = [], Generator = function(){}, fn = Generator.prototype;
	fn.init = function(f, ec){
		this.f = f;
		this.ec = ec || {};
		this.ids = {};
		this.value = undefined, this.done = false;
	};
	fn.next = (function(){
		var prevSelf;
		if(this.done) return this;
		//1. 실행준비
		this.isYieldActive = false;
		this.seed = 1;
		this.result = noYield;
		//2. Yield의 컨텍스트를 자신으로
		prevSelf = SELF;
		SELF = this;
		//2. 실행
		this.f(this.ec);
		//3. 스택처리
		SELF = prevSelf;
		//4. 결과처리
		if(this.result === noYield){
			//Yield가 호출된적 없으면 풀에 회수하고 정리
			return pool[pool.length] = this, done;
		}
		return this.value = this.y, this;
	};
	window.Yield =  function(v, cnt){
		if(SELF.isYieldActive) return true;
		var id = SELF.seed++;
		if(SELF.ids[id] === undefined) SELF.ids[id] = cnt || 1;
		if(!SELF.ids[id]) return true;
		SELF.ids[id]--;
		SELF.isYieldActive = true;
		SELF.result = v;
		return false;
	};
	return function(f){
		return function(ec){
			//풀링반영
			var g = pool.length ? pool.pop() : new Generator();
			g.init(f, ec);
			return g;
		};
	};
})();

하지만 아직도 갈 길이 멉니다.

  1. yield*의 처리
  2. class내에서 사용할 경우의 super와 this의 처리
  3. 사용한 문인지 아닌지에 대한 커스텀 문분기처리기

등을 추가로 구현해야합니다. 복잡한 내용이 많으므로 2/2로 넘깁니다. 완성된 구현 예제는 여기에 있습니다.