자바스크립트 완벽가이드 12장에 해당하는 부분이고, 읽으면서 자바스크립트에 대해 새롭게 알게된 부분만 정리한 내용입니다.
이 장에서는 이터레이터가 어떻게 동작하는지 설명하고 이터러블 데이터 구조를 직접 만드는 방법을 설명한다.
JS의 순회를 이해하려면 다음 3가지를 이해해야 한다.
Symbol.iterator
라는 이터레이터 메서드를 가진 객체
next()
메서드가 있는 객체
value
와done
프로퍼티가 있는 객체
즉 다시 말하면, 이터러블 객체란 이터레이터 객체를 반환하는 특별한 이터레이터 메서드(Symbol.iterator)를 가진 객체이다. 또한 이터레이터 객체는 순회 결과 객체를 반환하는 next() 메서드가 있는 객체이다.
이터러블 객체 iterable을 순회하는 단순한 for/of 루프 예시
// 객체 생성
const iterable = [99];
// 이터레이터 메서드를 호출하여 이터레이터 객체 생성
const iterator = iterable[Symbol.iterator]();
for (let result = iterator.next(); !result.done; result = iterator.next()) {
console.log(result.value); // 99
}
이터레이터 객체 그 자체가 이터러블인 경우
const list = [1, 2, 3, 4, 5];
const iter = list[Symbol.iterator]();
const head = iter.next().value;
const tail = [...iter];
console.log(head); // 1
console.log(tail); // [2, 3, 4, 5]
내장된 이터러블 데이터 타입의 이터레이터 객체는 그 자체가 이터러블이다. 이런 특징이 유용할 때가 간혹 있다.
클래스를 이터러블로 만들기 위해서는 반드시 이름이 Symbol.iterator
인 메서드를 만들어야 한다. 이 메서드는 반드시 next()
메서드가 있는 이터레이터 객체를 반환해야한다. next()
메서드는 반드시 순회 결과 객체를 반환해야 하며 순회 결과 객체에는 value 프로퍼티와 불 done 프로퍼티 중 하나는 반드시 존재해야 한다.
class Range {
constructor(from, to) {
this.from = from;
this.to = to;
}
has(x) {
return typeof x === 'number' && this.from <= x && x <= this.to;
}
toString() {
return `{x | ${this.from} <= x ${this.to}}`;
}
// 이터레이터 객체 반환 -> 이터러블을 만들기 위해서
[Symbol.iterator]() {
let next = Math.ceil(this.from);
const last = this.to;
return {
// next() 메서드가 이터레이터 객체의 핵심
next() {
return next <= last ? { value: next++ } : { done: true };
},
[Symbol.iterator]() {
return this;
}
};
}
}
for (const x of new Range(1, 10)) console.log(x); // 1부터 10까지 숫자
console.log(...new Range(-2, 2)); // -2 -1 0 1 2
이터러블 객체와 이터레이터 핵심 특징 중 하나는 이들이 본질적으로 느긋하다(lazy)는 것이다. 따라서 그 값이 실제 필요할 때까지 계산을 늦추어 메모리를 아낄 수 있다.
이터레이터 객체에 종료를 위해 return()
메서드가 사용되기도 한다.
next()가 done 프로퍼티가 true인 순회 결과를 반환하기 전에 순회를 마쳐야 한다면 인터프리터는 이터레이터 객체에
return()
메서드가 있는지 확인한다.
function*
키워드를 사용하여 정의한다. 이 함수를 호출하면 제너레이터 객체를 반환한다.
function* oneDigitPrimes() {
yield 2;
yield 3;
yield 5;
yield 7;
}
// 제너레이터 함수를 호출하여 제너레이터를 생성한다.
const primes = oneDigitPrimes();
console.log(primes.next().value); // 2
console.log(primes.next().value); // 3
console.log(primes.next().value); // 5
console.log(primes.next().value); // 7
console.log(primes.next().done); // true
// 제너레이터는 다른 이터러블 타입처럼 사용할 수 있다.
console.log([...oneDigitPrimes()]); // [2, 3, 5, 7]
표현식으로 제너레이터 정의는 가능하지만 화살표 함수 문법은 불가능하다. 또한 제너레이터를 사용하면 아래와 같이 이터러블 클래스를 만들기 쉽다.
// Range 클래스에서 제너레이터를 사용하지 않은 경우
// 이터레이터 객체 반환 -> 이터러블을 만들기 위해서
[Symbol.iterator]() {
let next = Math.ceil(this.from);
const last = this.to;
return {
// next() 메서드가 이터레이터 객체의 핵심
next() {
return next <= last ? { value: next++ } : { done: true };
},
[Symbol.iterator]() {
return this;
}
};
}
// Range 클래스에서 제너레이터를 시용한 경우
*[Symbol.iterator]() {
for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
}
제너레이터를 활요한 피보나치 수열
function* fibonacciSequence() {
let x = 0;
let y = 1;
for (;;) {
yield y;
[x, y] = [y, x + y];
}
}
function fibonacci(n) {
for (const f of fibonacciSequence()) {
if (n-- <= 0) return f;
}
}
console.log(fibonacci(20)); // 10946
// 무한한 제너레이터를 take() 제너레이터와 함께 사용한 경우
function* take(n, iterable) {
// 이터레이터 객체 생성
const it = iterable[Symbol.iterator]();
while (n-- > 0) {
const next = it.next();
if (next.done) return;
yield next.value;
}
}
console.log([...take(5, fibonacciSequence())]); // 1 1 2 3 5
yield*
키워드는 yield와 비슷하지만 값 하나를 전달하는 것이 아니라 이터러블 객체를 순회하면서 각각의 값을 전달한다.
function* oneDigitPrimes() {
yield 2;
yield 3;
yield 5;
yield 7;
}
function* sequence(...iterables) {
for (const iterable of iterables) {
yield* iterable;
}
}
console.log([...sequence('abc', oneDigitPrimes())]); // ['a', 'b', 'c', 2, 3, 5, 7]
하지만 아래처럼배열요소를 순회하기 위해 forEach()
메서드를 사용하는 경우 정상적으로 작동이 되지 않는다.
function* sequence(...iterables) {
iterables.forEach(iterable => yield* iterable); // 에러
}
위 예제의 중첩된 화살표 함수는 일반적인 함수이므로
yield
yield*
는 제너레이터 함수 안에서만 사용할 수 있으므로 허용되지 않는다.
yield*
을 사용해 재귀 제너레이터를 만들수 있고, 재귀적으로 정의된 트리구조에 비재귀적 순회를 수행할 수 있다.
제너레이터 함수도 다른 함수와 마찬가지로 값을 반환할 수 있다.
function* oneAndDone() {
yield 1;
return 'done';
}
console.log([...oneAndDone()]);
const generator = oneAndDone();
console.log(generator.next()); // { value: 1, done: false }
console.log(generator.next()); // { value: 'done', done: true }
console.log(generator.next()); // { value: undefined, done: true }
next()를 마지막으로 호출했을 때 반환하는 객체에는 value와 done이 모두 존재한다.
yield는 표현식이라서 값을 가질 수 있다.
function* smallNumbers() {
console.log('next()가 처음 호출되었으며 인자는 무시됩니다.');
const y1 = yield 1; // y1 === 'b'
console.log(`next()가 두 번째로 호출됐으며 인자는 ${y1}입니다.`);
const y2 = yield 2; // y2 === 'c'
console.log(`next()가 두 번째로 호출됐으며 인자는 ${y2}입니다.`);
const y3 = yield 3; // y3 === 'd'
console.log(`next()가 두 번째로 호출됐으며 인자는 ${y3}입니다.`);
return 4;
}
const g = smallNumbers();
console.log('제너레이터가 생성됐습니다. 아직 실행된 코드는 없습니다.');
const n1 = g.next('a'); // n1.value = 1;
console.log(`제너레이터가 전달한 값은 ${n1.value}입니다.`);
const n2 = g.next('b'); // n2.value = 2;
console.log(`제너레이터가 전달한 값은 ${n2.value}입니다.`);
const n3 = g.next('c'); // n3.value = 3;
console.log(`제너레이터가 전달한 값은 ${n3.value}입니다.`);
const n4 = g.next('d'); // n4 === {value: 4, done: true}
console.log(`제너레이터는 ${n4.value}를 넘기고 종료됐습니다.`);
제너레이터의
next()
메서드를 다음에 호출할 때next()
에 전달된 인자는 멈췄던 yield 표현식의 값이 된다.
즉, 호출자는next()
를 통해 제너레이터에 값을 전달한다. 첫 번째 전달 값은 무시된다.
next()
뿐만 아니라 return()
과 throw()
메서드를 호출해서 제너레이터의 실행 흐름을 제어할 수 있다.
제너레이터에서는 try/finally
문을 통해 제너레이터가 종료될 때(finally 블록에서) return()
을 사용하여 필요한 정리 작업을 수행하게 만들 수 있다. throw()
도 마찬가지로 임의의 신호를 예외의 형태로 제너레이터에 보내 예외 처리를 할 수 있다.
제너레이터가 yield*
를 통해 다른 이터러블 객체에 값을 전달하면 제너레이터의 next()
메서드를 호출할 때 이터러블 객체의 next()
메서드가 호출된다. return()
과 throw()
메서드도 마찬가지이다. 제너레이터가 return()
과 throw()
메서드가 정의된 이터러블 객체에 yield*
를 사용하면, 제너레이터에서 return()
이나 throw()
를 호출할 때 이터레이터의 return()
이나 throw()
메서드가 이어서 호출된다.