[es6for3] Iterable 대체

es6for3 시리즈

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

이터러블과 이터레이터의 관계

이터러블은 스펙문서에 기술된 인터페이스에 의하면 @@iterator키에 정의된 메소드가 이터레이터 객체를 반환하는 것을 의미합니다.
이터레이터는 이보다는 약간 복잡한 인터페이스인데 ‘next’키에 정의된 메소드가 {value:undefined|값, done:true|false} 형식으로 객체를 반환할 것을 요구합니다.
이터러블과 이터레이터는 이후 다루게 될 제네레이터와 for of 루프, 해체 등과 복잡하게 관련되어 있습니다.

어떤 값이 단순한 스칼라 값이 아니라 집합을 이루는 경우 정적집합인 경우가 있고 수학적 혹은 상태적인 집합인 경우가 있습니다. 기존의 JS는 이중 정적 집합만 지원하기 때문에 집합의 원소를 사용하기 전에 반드시 정의하여 포함시켜야만 사용할 수 있었습니다. 예를 들어 1~n까지의 원소가 있다면 이 원소를 이용해 두 배를 한 값을 얻고 싶다면 n까지의 원소를 먼저 반드시 채워야한다는 것입니다.

var arr = [1,2,3];
for(var i = 0, j = arr.length; i < j; i++){
  console.log(arr[i] * 2);
}

너무 오랫동안 써와서 이게 굉장히 자연스러워 보일지 모르지만 n이 10이면 반드시

var arr = [1,2,3,4,5,6,7,8,9,10];

으로 배열원소가 확정되어야만 실현할 수 있다는 것입니다. 집합인데 정적으로 정의하지 않고 원소가 탄생하는 원리를 적용한 알고리즘으로 집합을 표현하는 기능이 있다면 n에 따라 탄력적으로 반응할 수 있을 것입니다. 이를 정의하는 간단한 이터레이터를 생각해보죠.

var iter = {
  start:1,
  end:10,
  curr:null,
  next:function(){
    if(this.curr === null) this.curr = this.start;
    if(this.curr > this.end){
      return {value:undefined, done:true};
    }else{
      return {value:this.curr++, done:false};
    }
  }
};

이 경우 iter.next()를 최초로 호출하면 {value:1, done:false}가 나오고 이후 value가 10까지 1씩 증가하다가 마지막에는 {value:undefined, done:true} 가 반환될 것입니다. 실제 메모리 상에서는 알고리즘계산을 위한 변수는 존재하지만 그 원소가 되는 데이터는 존재하지 않고 매 next 호출시 연산을 통해 만들어지죠. f(x) = y 를 일반적인 수학적집합이라하면 이를 실현하는 언어적인 장치라할 수 있습니다.

이터러블은 매번 초기화된 새로운 이터레이터를 얻기 위한 게터용 메소드로 인터페이스도 단순히 ‘@@iterator’ 키에 정의된 메소드가 앞서 설명한 이터레이터객체를 반환하기만 하면 됩니다.

이터레이터 구현

위의 예에서 보듯 이터레이터의 개념과 인터페이스의 정의는 사실 ES6와 무관하므로 이를 약간 래핑하여 구현할 수 있습니다. arguments나 HTMLElementList처럼 length가 있는 리스트형태나 Array를 생성자의 인자로 받아들여 간단히 next로 순회해주는 역할을 수행하는 정도로 구성해볼텐데 최대한 객체 생성을 억제하는 정도로 컨셉을 잡아보죠. 이터레이터는 기저층에서 굉장히 많이 호출당하기 때문에 객체를 양산하면 여파가 너무 커서 감당하기 힘듭니다.

var Iter = function(v){

  //생성자 인자로 배열과 리스트형태만 지원하자.
  if(!(v instanceof Array) && !v.hasOwnProperty('length')) throw 'Array & List only';

  this.origin = v; //원본저장
  this.cursor = 0; //커서의 위치초기화

  //반환용 객체생성을 억제하고 인스턴스 그 자체를 재활용함.
  this.done = !v.length; //길이가 0이면 이미 끝임.
  this.value = v[0] || undefined;
};

Iter.prototype.next = function(){

  //원소가 남았을때
  if(this.cursor < this.origin.length){
    this.value = this.origin[this.cursor++]; //커서전진
  }else{
    //종료
    this.value = undefined;
    this.done = true;
  }
  return this;
};

var iter = new Iter([1,2]);

console.log(iter.next()); //{value:1, done:false}
console.log(iter.next()); //{value:2, done:false}
console.log(iter.next()); //{value:undefined, done:true}

손쉽게 정적 배열을 이터레이터로 바꿔줍니다. 이제 이터러블을 구현해보죠. 이터러블은 그저 이터레이터를 반환하기만 하면 됩니다. 예를 들어 배열과 문자열에 폴리필을 시도해보죠. 아래 코드는 이전 포스팅인 유사 심볼구축 에서 구현한 Symb을 사용합니다.

if(!Symbol)(function(){
  
  Array.prototype[Symb.iterator] = function(){
    return new Iter(this);
  };

  String.prototype[Symb.iterator] = function(){
    return new Iter(this);
  };

})();

머 고작 이 정도면 모든 문자열과 배열이 이터러블이 될 것입니다.

풀링 구현

사실 이터레이터의 사용이 1회성이고 done:true로 끝나면 그 시점에 사실 회수해도 큰 문제는 발생하지 않기 때문에(싱글쓰레드이므로 ^^) Iter클래스에 풀링을 추가하여 더욱 객체 재활용을 높일 수 있습니다.

var Iter = (function(){
  //객체 풀생성
  var pool = [];

  //완료전용 객체
  var done = {value:undefined, done:true};

  var Iter = function(){};
  Iter.prototype.next = function(){
    if(this.cursor < this.origin.length){
      this.value = this.origin[this.cursor++];
      return this;
    }else{

      //완료시 풀에 본인은 회수하고 완료객체를 반환한다.
      pool.push(this);
      return done;
    }
  };
  return function(v){
    if(!(v instanceof Array) && !v.hasOwnProperty('length')) throw 'Array & List only';

    //풀에서 인스턴스를 얻거나 새로 생성
    var iter; = pool.length ? pool.pop() : new Iter();

    //초기화는 여기서    
    iter.origin = v;
    iter.cursor = 0;
    iter.done = !v.length; //길이가 0이면 이미 끝임.
    iter.value = v[0] || undefined;
    return iter;
  };
})();

클래스가 직접 노출되지 않고 팩토리가 노출되므로 위의 폴리필 코드도 약간 변화합니다.

Array.prototype[Symb.iterator] = function(){
  return Iter(this);
};
String.prototype[Symb.iterator] = function(){
  return Iter(this);
};

동적 이터러블바인딩과 String인덱스의 문제

문자열과 배열에 적용되어있는 이터러블을 통해 얻은 이터레이터객체는 원본의 복제본이 아닙니다. 즉 다음과 같은 코드로 설명할 수 있습니다.

const arr = [1,2,3];
for(const v of arr){
  if(v == 1) arr.push(4);
  console.log(v);
}
//1,2,3,4까지 출력

즉 for of하는 시점에 내부적으로 분명
1. arr의 Symbol.iterator가 호출되어 적절한 이터레이터객체가 반환되고
2. 그 이터레이터 객체의 next를 호출해가면서 실행

되고 있을텐데도 그 이터레이터 객체는 arr의 복제본으로 만들어진게 아니라 원본 arr과의 연결을 유지하고 있는 반증이죠. 이는 좀 귀찮지만 스펙문서에 기술되어있긴 합니다.
1. 22.1.3.29 Array.prototype.values() 에서의 작동을 살펴보면
2. 결국 자신을 CreateArrayIterator에게 보냅니다.
3. 여기서 생성되는 이터레이터 객체가 원본을 [[IteratedObject]]속성에 잡아두게 되고
4. next 메소드의 실행 절차를 보면 원본에 의지하여 전진되는 것을 알 수 있습니다.

String쪽도 대동소이합니다. 위에서 구현한 Iter클래스와 그 작동도 완벽히 이를 지원하는 구조이므로 문제는 없습니다만 문자열인 경우는 꽤나 귀찮습니다.
왜냐면 구형 브라우저는 charAt() 메소드만 지원하고 대괄호 인덱스로 문자를 얻을 수 없기 때문입니다.
아예 이 레벨의 하위 브라우저를 노리고 코드를 짜두는 편이 더 낫겠죠. 코드에 대한 설명은 위에서 충분히 했으므로 정리하여 팩토리에서 next를 결정하게 하는 식으로 변경해보죠.

var Iter = (function(){
	var done = Object.freeze({value:undefined, done:true}), pool = [];
	var strNext = function(){ //문자열용 charAt버전
		if(this.c < this.v.length) return this.value = this.v.charAt(this.c++), this;
		return pool.push(this), done;
	};
	var idxNext = function(){ //인덱스 타는 버전
		if(this.c < this.v.length) return this.value = this.v[this.c++], this;
		return pool.push(this), done;
	};
	var Iter = function(){};
	return function(v){
		var iter = pool.length ? pool.pop() : new Iter();
		iter.v = v, iter.done = !v.length, iter.c = 0;

		//여기서 next 선택
		if(typeof v == 'string'){
			iter.next = strNext;
			iter.value = v.charAt(0);
		}else{
			iter.next = idxNext;
			iter.value = v[0];
		}
		return iter;
	};
})();

결론

위에 제작한 Iter클래스는 결국 이터레이터의 구현 예제로 실목적은 String과 Array에 대해 기본 폴리필을 수행하기 위해서였습니다. 사실 외부에서 Iter함수를 사용할 필요가 없다면 간단히 폴리필하고 끝내면 될 것입니다.

(function(){ //Iterator
	var pool = [], done = Object.freeze({value:undefined, done:true}), 
		next = {
			string:function(){
				if(this.c < this.v.length) return this.value = this.v.charAt(this.c++), this;
				return pool.push(this), done;
			},
			object:function(){
				if(this.c < this.v.length) return this.value = this.v[this.c++], this;
				return pool.push(this), done;
			}
		};
	String.prototype[Symb.iterator] = Array.prototype[Symb.iterator] = function(){
		var iter = pool.length ? pool.pop() : {}, t = typeof this;
		iter.c = 0, iter.v = this, iter.next = next[t], 
		iter.done = !this.length, 
		iter.value = t == 'string' ? this.charAt(0) : this[0];
		return iter;
	};
})();

이 정도면 충분할 것입니다 ^^; 여기에 간단한 테스트 페이지가 있습니다.

지금까지 시리즈에서 다음과 같은 주제를 다뤘습니다.

이 중 템플릿문자열이야 어쨌든 아래 세가지 요소를 결합하면 for of 를 폴리필 할 수 있게 됩니다. 따라서 다음 편에서는 for of를 구현할 예정입니다.

P.S
사실 이제와서 얘기지만 해체2편 마지막에 등장한 완성 코드에는 오늘 설명한 이터러블과 이터레이터를 처리하는 코드가 이미 포함되어있습니다.

//이터러블해소
if(iter = v){
  while(typeof iter[Symb.iterator] == 'function') iter = iter[Symb.iterator]();
    if(typeof iter.next == 'function') iterR = [];
  }
...

이런 식으로 해체단계에 포함되어있죠 ^^;

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