Map과 Set을 자주 쓰면서 spread syntax와 Array.from 을 쓰다가 iterator가 반복이 끝난, 일명 iterator consumption이라는 현상을 겪게 되었는데, 그 원인을 잘 파악하지 못했다고 생각하여 iterable과 iterator에 대해서 공부 후 기록해둡니다.
이 글에서는 iterator consumption이 왜 발생했는지 풀어나가는 과정에서 알아야 할 것들을 간단히 정리했으며 이터러블(iterable)에 대한 자세한 개념과 원리는 🔗이 글을 참고하면 됩니다.
1. Iteration protocol
먼저 protocol 에 대해서 정리해봅시다.
protocol 은컴퓨터와 컴퓨터 혹은 컴퓨터와 다른 장비 사이에서 데이터 통신을 원활하게 하기 위해 필요한 통신 규약입니다. 즉, 송신자와 수신자가 서로 약속을 해두고 약속된 규칙에 맞춰 통신을 할 수 있는 것이죠.
(예를 들어 신호 송신 순서, 데이터 표현법, 오류 검출법 등을 정할 수 있다.)
즉, Iteration protocol은 어떠한 객체든 특정 조건을 만족하면 Iterable 또는 Iterator로 평가 받을 수 있도록 하는 규약입니다.
2. The Iterable Protocol
iterable protocol은 Javascript 객체가 반복 가능한 작업을 할 수 있도록 정의하거나 customize 하는 것을 허용합니다.
예를 들어서 for...of 구조에서 value를 반복하는 동작을 수행할 수 있는 것이죠.
Array와 Map 등의 built-in type들은 default iteration이 built-in-iterables입니다.(iterable 하다는 뜻입니다.)
iterable object 로 평가되면 할 수 있는 것
반복 가능한(iterable) 객체는 배열을 일반화한 객체입니다. iterable 이라는 개념을 사용하면 어떤 객체에든 for...of 문, 전개 문법(spread syntax), 구조 분해 할당(destructuring) 등에 사용할 수 있습니다.
iterable 하기 위해서 준수해야 하는 조건
1. 객체 내에 [Symbol.iterator] (=@@iterator )메서드가 존재해야 한다. (`Symbol.iterator`를 특수 내장 심볼이라고 부릅니다.)
2. [Symbol.iterator] 메서드는 `Iterator` 객체를 반환해야 한다.
즉, The iterable protocol은 객체가 iterable 하기 위해 따라야 하는 규약인 것이고, iterable은 '반복 가능한 객체'가 되는 것입니다.
const iterable = {
[Symbol.iterator]() {
return someIteratorObject
}
...
}
for(item of iterable) {
console.log(item) // work
}
3. The Iterator Protocol
iterator protocol은 값의 sequence를 생성하는 표준 방법과, 값이 생성되었을 때 잠재적인 반환 값을 정의한 규약입니다.
iterator 가 되기 위해서 준수해야 하는 조건
1. 객체 내에 next 메서드가 존재해야 한다.
2. next 메서드는 IteratorResult 객체를 반환해야 한다.
3. IteratorResult 객체는 `done: boolean` 과 `value: any` 프로퍼티를 가진다.
4. 이전 next 메서드 호출의 결과로 done 값이 true를 리턴했다면, 이후 호출에 대한 done값도 true여야 한다.
`done`은 Iterator의 반복이 모두 완료되었는지를 판별하는 프로퍼티입니다.
Iterator는 done 값이 true가 될 때까지 반복을 수행합니다. `value`는 각 반복 수행을 하면서 반환하는 값입니다.
즉, The iterator protocol은 iterator가 되기 위해 따라야하는 규약이고, iterator는 "next 메서드를 사용해 객체를 순환할 수 있는 객체"입니다. 참고로 모든 iterator object(Symbol.iterator이 return하는 것)는 iterable object이지만, 모든 iterable object가 iterator object는 아닙니다.
const iterable = {
/**
* TIL: Iterable Object 의 조건
* * 1. [Symbol.iterator] 메서드가 존재한다.
* * 2. [Symbol.iterator] 메서드는 Iterator 객체를 반환해야 한다.
*
*/
/**
* TIL: Iterator 의 조건
* 객체 내에 next 메서드가 존재해야 함.
* next 메서드는 IteratorResult 객체를 반환해야 함.
* IteratorResult 객체는 done:boolean 과 valu: any 프로퍼티를 가짐.
* 이전 next 메서드 호출의 결과로 done:true 를 return 했다면, 이후 호출에 대한 done 값도 true 여야 한다.
*/
[Symbol.iterator]: () => {
let i = 0;
/**
* iterator 객체를 반환하는 부분
*/
return {
/**
* next 메서드는 IteratorResult 객체를 반환해야 한다.
* @returns {@type IteratorResult}
*/
next: () => {
while (i < 10) {
return { value: i++, done: false };
}
return { value: undefined, done: true };
// return { done: true }; // i 가 10이 되면 반복 종료(value 값 생략해도 된다. 근데 보통 undefined를 할당해서 return 하는 게 정석이다.);
},
};
},
};
const SymbolIterator1 = iterable[Symbol.iterator]()
const SymbolIterator2 = iterable[Symbol.iterator]()
console.log(SymbolIterator1.next()); // { value: 0, done: false }
console.log(SymbolIterator1.next()); // { value: 1, done: false }
console.log(SymbolIterator2.next()); // { value: 0, done: false } // i 가 [Symbol.iterator] 메서드 스코프의 안에 있기 때문에 새로 호출하면 i가 또 새로 만들어짐. => 반복 상태 공유하지 않음.
for (const item of iterable) console.log(item); // 0, 1, 2, 3, 4, ... 9
for (const item of iterable) console.log(item); // 0, 1, 2, 3, 4, ... 9
위에서 iterable 객체가 iterator를 반환한다고 하였는데, iterable object 의 핵심은 '관심사의 분리(Seperation of concern, SoC)'에 있습니다.
- 위 예시에서 iterable 객체에는 메서드 next() 가 없습니다.
- 대신 iterable[Symbol.iterator]() 를 호출해서 만든 'iterator'객체와 이 객체의 메서드 next() 에서 반복에 사용될 값을 만들어냅니다.
이렇게 하면 iterator 객체와 반복 대상인 객체를 분리할 수 있습니다.
4. well-formed iterable
iterator 이면서 iterable인 객체를 well-formed iterable 이라고 합니다.(찾아보니 와전된 개념이라고 합니다. @@iterator method가 iterator protocol(iterator가 되기 위해 준수해야 하는 약속)이 구현된 object를 리턴하기만 하면 iterable한 iterator가 아니더라도 well-formed iterable 이라고 합니다.) 쉽게 말하면 somethingiter[Symbol.iterable]() === somethingiter인 경우를 well-formed iterable 이라고 합니다.
iterator 객체와 반복 대상 객체를 합쳐서 iterable object 자체를 iterator로 바꿔보겠습니다.
const wellFormedIterable = { // Iterator 객체
next() {
return someIteratorResultObject
}
// Iterator 객체에 Symbol.iterator 메서드가 존재하며,
// 해당 메서드가 자기 자신(iterator)을 반환한다.
[Symbol.iterator]() {
return this
}
...
}
여기서부터는 헷갈릴 수 있으니 집중해서 이해해야 합니다. 위 코드에서 [Symbol.iterator]가 자기 자신 this를 호출하고 있는 모습을 볼 수 있습니다. 이는 wellFormedIterable이 iterable인 동시에 자기 자신 wellFormedIterable(this)로 return 이후 또 접근해서 내부의 next 메서드에 접근할 수 있기 때문에 iterator가 될 수 있음을 보여줍니다. well-formed iterable의 장점은 [Symbol.iterator] 메서드가 this를 반환하기 때문에 자기 자신의 상태를 기억할 수 있다는 점입니다. 이로 인해서 iterator는 이전까지 얼마나 반복을 진행했는지 기억할 수 있습니다.
예시 코드를 만들어보겠습니다.
const wellFormedIterable = {
i: 0,
next() {
while (this.i < 10) {
return { value: this.i++, done: false };
}
return { value: undefined, done: true };
},
[Symbol.iterator]() {
return this;
},
};
const iter = wellFormedIterable[Symbol.iterator]();
const iter2 = wellFormedIterable[Symbol.iterator]();
console.log(iter.next()); // { value: 0, done: false }
console.log(iter.next()); // { value: 1, done: false }
console.log(iter2.next()); // { value: 2, done: false } // 반복 상태 공유됨.
// 앞에서 next() 를 호출했기 때문에 3부터 시작한다.
for (const num of iter) console.log(num); // 3, 4, 5 ... 9
for (const num of iter) console.log(num); // 빈값
// 초기 파라미터에 접근이 가능한 방식
const objSpecial = {
next: ((i = 0) =>
function () {
return { done: i < 5, value: i < 5 ? i++ : undefined };
})(),
[Symbol.iterator]() {
return this;
},
};
console.log(objSpecial[Symbol.iterator]() === objSpecial); // true
const cast = objSpecial[Symbol.iterator]();
console.log(cast.next());
console.log(cast.next());
console.log(cast.next());
이전까지 얼마나 반복을 진행했는지 기억할 수 있다는 장점이 반대로 단점[주의해야 할 점]이 될 수도 있겠습니다. 하나의 객체에 for...of 반복문은 동시에 사용할 수 없다는 점입니다. 반복문이 반복 상태를 공유하기 때문입니다. 이미 지나간 것은 지나간 것이 되는 것(주의해야 할 점)입니다.
5. 나의 궁금증 해결
위에서 얘기한 주의해야 할 점과 이어지는 내용입니다. `Map`을 공부를 하다 해결이 되지 않았던 궁금증을 여기서 해결할 수 있었습니다. 저는 애초에 반복 상태를 공유한다는 점을 몰랐던 것입니다.
코드를 보도록 하겠습니다.
const targetMap = new Map([[1, "one"], [2, "two"], [3, "three"], [4, "four"]]);
/**
* values(): IterableIterator<string>
* Returns an iterable of values in the map
*/
const checkValues = targetMap.values() // [Map Iterator] { 'one', 'two', 'three', 'four' }
checkValues[Symbol.iterator]() // [Map Iterator] { 'one', 'two', 'three', 'four' }
const spreadedValues = [...checkValues]
const arrayFromValues = Array.from(checkValues);
console.log(spreadedValues); // [ 'one', 'two', 'three', 'four' ]
console.log(checkValues) // [Map Iterator] { }
console.log(checkValues.next()) // { value: undefined, done: true }
console.log(arrayFromValues) // []
- targetMap.values() 메서드가 Map iterator를 반환합니다. 그런데 그 iterator 안에 내장 심볼이 있습니다.
targetMap.values()와 checkValues[Symbol.iterator]() 의 값이 같은 것을 확인할 수 있죠. 자기 자신을 return 하는 것입니다. - spread operator 를 통해 spreadedValues에 값을 담을 때 이미 한 번 반복이 끝난 것이었습니다.
이 점을 몰랐습니다. 그래서 iterable object를 복사해서 배열로 바꿔주는 Array.from 을 통해서 checkValues 를 배열로 바꾸려고 했을 때 [] 빈 배열을 반환하게 된 것입니다. - spreadedValues에 이미 spread operator로 이미 값을 반복하고 복사하였기 때문에 checkValues를 로그를 찍었을 때 Map Iterator { } 가 출력되었습니다. 더 정확하게 next 메서드를 통해 반환된 값을 보면 done이 true 인 것을 확인할 수 있습니다. 결과적으로 이미 반복이 끝난 값을 복사하여 배열로 변환하려 했으니 빈 배열이 나온 것이죠.
6. iterator를 명시적으로 호출하기
이터레이터의 명시적 사용 예시입니다.(🔗출처)
let str = "Hello";
// for..of를 사용한 것과 동일한 작업을 합니다.
// for (let char of str) alert(char);
let iterator = str[Symbol.iterator]();
while (true) {
let result = iterator.next();
if (result.done) break;
alert(result.value); // 글자가 하나씩 출력됩니다.
}
문자열은 iterable 입니다. 다음과 반복 과정을 더 잘 통제할 수 있습니다. 혹은 반복을 시작했다가 멈추고 다른 작업을 하다가 다시 반복을 시작하는 것과 같이 반복 과정을 여러 개로 쪼개는 것이 가능합니다.
7. iterable과 유사 배열
비슷해 보이지만 아주 다른 용어 두 가지가 있습니다.
- 이터러블(iterable) 은 위에서 설명한 바와 같이 메서드 Symbol.iterator가 구현된 객체입니다.
- 유사 배열(array-like) 은 인덱스와 length 프로퍼티가 있어서 배열처럼 보이는 객체입니다.
브라우저 등의 호스트 환경에서 자바스크립트를 사용해 문제를 해결할 때 `iterable object`나 `array-like object` 혹은 둘 다인 객체를 만날 수 있습니다.
이터러블 객체(for...of 사용가능)이면서 유사배열 객체(숫자 인덱스와 length 프로퍼티가 있음)인 문자열(well-formed iterable은 아닙니다.)이 대표적인 예입니다.
이터러블 객체라고 해서 유사 배열 객체는 아닙니다. 유사 배열 객체라고 해서 이터러블 객체인 것도 아닙니다.
그 이유는 이터러블 객체는 인덱스도 없고 length 프로퍼티도 없기 때문이고, 유사 배열 객체는 Symbol.iterator가 없기 때문입니다. 이터러블과 유사배열은 대게 배열이 아니기 때문에 push, pop 등의 메서드를 지원하지 않습니다. 이를 배열로 변환하고 싶을 때 아까 위에서 얘기한 Array.from 을 쓰는 것입니다.