신명나게 멀티패러다임 프로그래밍 책을 N회독하면서 이터레이터를 이해하면 책의 내용 및 라이브러리인 FxTs
뿐 아니라 함수를 생성 할 때에도 유용하게 쓸 수 있을 거 같아 한 번 MDN 을 통해 공부해본다.
이 개념들이 왜 유용할까?
우선 기본적으로 이터러블 프로토콜들은 내장된 기능인 for of
문으로 순회 가능하거나 스프레드 연산들을 가능하게 하는 프로토콜이기에 알고 있어야 한다.
다만 더 나아가 이 개념들을 활용하면 다음과 같은 기능들을 할 수 있다.
- 지연 평가를 이용한 메모리 관리
만약 무한하거나 무한에 가깝게 거대한 데이터 스트림을 다뤄야 한다고 생각해보자
이 데이터 스트림들은 매우 크기가 크기 때문에 변수에 저장해두면 메모리에 많은 값을 저장하고 있게 된다.
하지만 이터레이터들을 직접 활용하면 메모리 공간을 크게 사용하지 않고 데이터를 다룰 수 있게 된다.
- 지연평가를 통한 코드의 선언적 처리
해당 코드는 이터레이터를 활용하여 구현된 함수형 라이브러리인 FxTs
의 코드 예시이다.
const iter = take(2, [0, 1, 2, 3, 4, 5, 6]);
iter.next() // {done:false, value: 0}
iter.next() // {done:false, value: 1}
iter.next() // {done:true, value: undefined}
// with pipe
pipe(
[0, 1, 2, 3, 4, 5, 6], // N개의 데이터에 대해
filter((n)=> n % 2), // 홀수에 대하여
take(2), // 2개만 취하여
toArray, // 배열로 변환
); // [1,3]
지연평가를 이용하면 데이터 셋을 모두 순회할 필요 없이 원하는 시점까지만 데이터를 취하는 것이 가능하다.
예를 들어 위 코드에서 이터러블인 [0,1,...6] 에 대해서 배열을 생성하는데 take(2)
는 2개의 값이 찰 때 까지만 filter(n => n%2)([0,1,2,...6])
이 반환하는 이터러블을 순회한다.
즉 0 -> 1 (take) -> 2 -> 3(take) -> 배열로 변경 [1,3]
이렇게만 하고 종료된다.
만일 일반적인 배열의 메소드 체이닝이였다면 모든 값을 순회해야했을 것이다.
이터레이터를 직접 다루는 것의 아름다움은 지연평가를 통해 원하는 시점에 원하는 개수만큼만 코드를 실행 시킬 수 있다는 것에 있다고 생각한다.
이를 통해 선언적으로 작성된 함수들이 명령형 코드로 작성된 코드 만큼 효율적이면서 원하는 호출 시점에 값을 취할 수 있다는 함수로서의 장점도 가져갈 수 있다.
이터러블 프로토콜 (순회가능 프로토콜)
이터러블 프로토콜은 자바스크립트에서 for of
문을 이용하여 순회 가능한 (이터러블) 객체이기 위한 프로토콜을 의미한다.
- [Symbol.iterator] 를 통해 이터레이터를 반환하는 메소드가 있어야 한다.
interface Iterable<T, TReturn = any, TNext = any> {
[Symbol.iterator](): Iterator<T, TReturn, TNext>;
}
우리가 for of
문으로 순회하거나 스프레드 연산이 가능한 값들은 이터러블 프로토콜을 따르는 이터러블 객체에 해당한다.
이런 이터러블 객체는 배열부터 맵,셋 ... 등등 다양한 객체들이 존재한다.
const arr = [1, 2, 3, 4, 5];
const map = new Map([
["a", 1],
["b", 2],
["c", 3],
]);
const set = new Set([1, 2, 3, 4, 5]);
console.log(...arr); // 1 2 3 4 5
console.log(...map); // [ 'a', 1 ] [ 'b', 2 ] [ 'c', 3 ]
console.log(...set); // 1 2 3 4 5
console.log(arr[Symbol.iterator]); // [Function: values]
console.log(map[Symbol.iterator]); // [Function: entries]
console.log(set[Symbol.iterator]); // [Function: values]
아! 그런데 예외적인게 객체는 이터러블이 아님에도 불구하고 스프레드 연산이 가능하다.
ES2018 부터 객체 리터럴에서 이터러블 프로토콜을 따르지 않지만 열거 가능한 속성들에 대한 스프레드 구문이 사용 가능하다.
const obj = {
name: "John",
age: 30,
city: "New York",
};
console.log(typeof obj[Symbol.iterator]); // undefined
console.log({ ...obj }); // { name: 'John', age: 30, city: 'New York' }
이렇게 이터러블 프로토콜을 지키는 객체 (이하 이터러블) 들에 대해서 [Symbol.iterator]
를 통해 이터레이터 객체를 반환받을 수 있다.
const arr = [1, 2, 3];
const iterator = arr[Symbol.iterator]();
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: undefined, done: true }
for (const value of arr) {
// for..of 문은 내부적으로 Symbol.iterator를 사용하여 반복합니다.
console.log(value); // 1, 2, 3
}
for of
문을 통한 순회는 이런 이터러블들에 대해서 [Symbol.iterator]
를 호출하여 이터레이터 객체를 받은 후 이터레이터의 값들이 모두 종료 될 때 까지 값을 호출한다.
이터레이터 프로토콜 (반복자 프로토콜)
interface IteratorYieldResult<TYield> {
done?: false;
value: TYield;
}
interface IteratorReturnResult<TReturn> {
done: true;
value: TReturn;
}
type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>;
interface Iterator<T, TReturn = any, TNext = any> {
// NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
next(...[value]: [] | [TNext]): IteratorResult<T, TReturn>;
return?(value?: TReturn): IteratorResult<T, TReturn>;
throw?(e?: any): IteratorResult<T, TReturn>;
}
이터레이터 프로토콜은 다음과 같은 규약을 지키는 객체를 의미하며 이런 객체들을 이터레이터라 한다.
next()
메소드를 통해IteratorResult
를 반환한다.IteratorResult
는done,value
값을 가지며 다음next()
호출 때value
값을 반환 가능 여부를done
에 표시하고value
는 각 순회마다의 값을 반환한다.IteratorResult
를return , throw
하는 방식 중 원하는 어떤 방식으로 구현 가능하다.
예시를 보는편이 훨씬 편하기에 직접 이터레이터 객체를 구현해보자
const naturalIterator = {
value: 0,
next() : IteratorResult<number> {
return { done: false, value: this.value++ };
},
};
console.log(naturalIterator.next()); // { done: false, value: 0 }
console.log(naturalIterator.next()); // { done: false, value: 1 }
console.log(naturalIterator.next()); // { done: false, value: 2 }
console.log(naturalIterator.next()); // { done: false, value: 3 }
이런식으로 무한한 값을 반환하는 이터레이터를 생성 할 수도 있고 필요에 따라 이후의 순회를 종료하는 것도 가능하다.
const naturalUnder3 = {
value: 0,
next(): IteratorResult<number> {
return this.value < 3
? {
done: false,
value: this.value++,
}
: {
done: true,
value: undefined,
};
},
};
console.log(naturalUnder3.next()); // { done: false, value: 0 }
console.log(naturalUnder3.next()); // { done: false, value: 1 }
console.log(naturalUnder3.next()); // { done: false, value: 2 }
console.log(naturalUnder3.next()); // { done: true, value: undefined }
이터러블을 구현해보자
그럼 위에서 말했던 이터러블, 이터레이터 프로토콜에 맞춰 이터러블을 구현해보자
const naturalIterable = {
value: 0,
// 1. Symbol.iterator 를 통해 이터레이터를 반환받을 수 있으니
// naturalIterable은 iterable이다.
[Symbol.iterator](): Iterator<number> {
// 2. Symbol.iterator 를 통해 이터레이터 프로토콜을 지키는 객체를 반환한다.
return {
next(): IteratorResult<number> {
return {
value: this.value++,
done: this.value > 10, // 예시를 위해 10까지만 반복
};
},
};
},
};
for (const value of naturalIterable) {
console.log(value); // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
}
위 객체는 Symbol.iterator
를 통해 Iterator
를 반환하니 이터러블이라 할 수 있다.
이터러블 이터레이터
이터러블 이터레이터는 이터러블이면서 이터레이터인 객체를 의미한다.
interface IterableIterator<T, TReturn = any, TNext = any> extends Iterator<T, TReturn, TNext> {
[Symbol.iterator](): IterableIterator<T, TReturn, TNext>;
}
이터러블이면서 이터레이터란 것은 Symbol.iterator
를 통해 이터레이터를 반환하면서도 본인이 next
메소드를 가진 이터레이란 것인데 이는 단순히 이처럼 구현 가능하다.
const naturalIterableIterator = {
value: 0,
[Symbol.iterator]() {
return this;
},
next() {
return {
done: this.value > 10,
value: this.value++,
};
},
};
// 직접 이터레이터로서 사용 가능
console.log(naturalIterableIterator.next()); // { done: false, value: 0 }
console.log(naturalIterableIterator.next()); // { done: false, value: 1 }
console.log(naturalIterableIterator.next()); // { done: false, value: 2 }
// for of 문으로 사용 가능
for (const value of naturalIterableIterator) {
console.log(value); // 3, 4, 5, 6, 7, 8, 9, 10
}
이터레이터를 반환 할 때 자기자신을 반환하는 이터러블이라면 이터러블 이터레이터라 할 수 있다.
이터러블 이터레이터를 반환하는 제네레이터 함수
이렇게 매번 이터러블 이터레이터를 만들 때 객체 리터럴과 클로저를 이용해서 구현하는 것은 꽤나 번거로운 일이다.
이에 좀 더 쉽게 이터러블 이터레이터인 제네레이터를 반환하는 제네레이터 함수에 대해 알아보자
제네레이터 함수는 다음과 같은 인터페이스를 가진 제네레이터를 반환한다.
interface Generator<T = unknown, TReturn = any, TNext = any> extends IteratorObject<T, TReturn, TNext> {
// NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
next(...[value]: [] | [TNext]): IteratorResult<T, TReturn>;
return(value: TReturn): IteratorResult<T, TReturn>;
throw(e: any): IteratorResult<T, TReturn>;
[Symbol.iterator](): Generator<T, TReturn, TNext>;
}
인터페이스를 살펴보면 IterableIterator
와 동일한 모습을 볼 수 있다. 인터페이스명만 Generator
란 점을 제외하곤 말이다.
const naturalGenratorFunction = function* () {
let i = 0;
while (i < 10) {
yield i++;
}
};
const iterable = naturalGenratorFunction(); // Genrator {}
console.log(typeof iterable[Symbol.iterator]); // function
console.log(typeof iterable.next); // function
console.log(iterable.next()); // { value: 0, done: false }
for (const value of iterable) {
console.log(value); // 1, 2, 3, 4, 5, 6, 7, 8, 9
}
제네레이터 함수는 function
키워드 앞에 *
를 붙혀 사용하며 return
이 아닌 yield
를 이용해 IteratorYieldResult
를 반환한다.
이렇게 반환되는 값은 이터레이터의 next()
메소드를 호출했던 것과 동일한 인터페이스를 가진다.
비동기 이터러블, 이터레이터, 제네레이터
위에서 설명했던 개념들만 알고 있다면 비동기 이터러블, 이터레이터, 제네레이터에 대한 것도 쉽게 알 수 있다.
비동기 이터러블,이터레이터, 제네레이터들은 ES2018
에 추가된 스펙으로 다음과 같은 인터페이스를 갖는다.
interface AsyncIterator<T, TReturn = any, TNext = any> {
// NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
next(...[value]: [] | [TNext]): Promise<IteratorResult<T, TReturn>>;
return?(value?: TReturn | PromiseLike<TReturn>): Promise<IteratorResult<T, TReturn>>;
throw?(e?: any): Promise<IteratorResult<T, TReturn>>;
}
interface AsyncIterable<T, TReturn = any, TNext = any> {
[Symbol.asyncIterator](): AsyncIterator<T, TReturn, TNext>;
}
/**
* Describes a user-defined {@link AsyncIterator} that is also async iterable.
*/
interface AsyncIterableIterator<T, TReturn = any, TNext = any> extends AsyncIterator<T, TReturn, TNext> {
[Symbol.asyncIterator](): AsyncIterableIterator<T, TReturn, TNext>;
}
// AsyncGenerator
interface AsyncGenerator<T = unknown, TReturn = any, TNext = any> extends AsyncIteratorObject<T, TReturn, TNext> {
// NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
next(...[value]: [] | [TNext]): Promise<IteratorResult<T, TReturn>>;
return(value: TReturn | PromiseLike<TReturn>): Promise<IteratorResult<T, TReturn>>;
throw(e: any): Promise<IteratorResult<T, TReturn>>;
[Symbol.asyncIterator](): AsyncGenerator<T, TReturn, TNext>;
}
비동기 이터러블 이터레이터들 모두 일반적인 것과 동일하지만 값들이 모두 Promise
로 감싸져있다는 점만 다르다.
비동기 이터러블들은 모두 for await (const ...)
로 순회 가능하다.
비동기 제네레이터는 async function*
를 통해 생성 가능하다.
const asyncNaturalGeneratorFunction =
async function* (): AsyncGenerator<number> {
let i = 0;
while (i < 10) {
// 비동기적 딜레이 추가
await new Promise((resolve) => setTimeout(resolve, 100));
yield i++;
}
};
const asyncGenerator = asyncNaturalGeneratorFunction();
(async function () {
for await (const value of asyncGenerator) {
console.log(value); // 2. 100ms 마다 0부터 9까지 출력
}
console.log("비동기적 코드 실행 완료!"); // 3. 비동기적 코드 실행 완료!
})();
console.log("동기적 코드 실행!"); // 1. 동기적 코드 실행!
이터레이터들을 활용하는 방법을 소개하는 FxTs 라이브러리
이렇게 이터러블 이터레이터들을 직접 조작하는 가장 아름다운 방법을 소개하는 라이브러린 FxTs
의 예시를 들어보고 싶다.
여러 함수형 라이브러리에선 pipe
나 compose
를 통해 함수들을 합성하여 사용한다.
마치 우리가 배열을 사용 할 때 [].filter().map().reduce()
처럼 쓰듯이 말이다.
FxTs
라이브러리는 다음과 같은 개념을 통해 함수 합성을 제시한다.
- 라이브러리의 대부분의 메소드들 (
lazy
)은IterableIterator
를 반환한다.
예를 들어 map(num => num*2, [1,2,3])
의 경우 [2,4,6]
을 반환하는 것이 아니라 next()
가 호출 될 때 마다 2,4,6
을 차례로 반환한다.
import { map } from "@fxts/core";
const mapped = map((num) => num + 1, [1, 2, 3]);
console.log(mapped);
/**
{
next: [Function: next],
[Symbol(Symbol.iterator)]: [Function (anonymous)]
}
*/
각 반환되는 값들은 IterableIterator
기 때문에 해당 값들을 이용하여 새로운 이터러블 이터레이터를 생성 할 수 있다. 예를 들어 이처럼 말이다.
import { filter, map } from "@fxts/core";
const mapped = map((num) => num + 1, [1, 2, 3]);
const filtered = filter((num) => num % 2, mapped);
console.log(filtered.next()); // { value: 3, done: false }
이 코드가 실행되면 filtered
의 이터러블 next()가 num %2
를 만족하는 값이 나올 때 까지 mapped
의 next() 를 호출한다.
즉 값이 필요한 시점에 map
의 메소드들을 호출하여 값을 취하는 지연평가를 가능하게 한다.
이런 지연평가는 데이터스트림의 크기가 클 수록 빛을 발한다.
import { filter, map, range, take } from "@fxts/core";
const mapped = map((num) => num + 1, range(Infinity));
const filtered = filter((num) => num % 2, mapped);
for (const value of take(10, filtered)) {
console.log(value); // 1, 3, 5, 7, 9, 11, 13, 15, 17, 19
}
이 코드들은 take
에서 10개의 값이 나올 때 까지 filtered(mapped)
로 생성된 이터레이터들의 next() 를 호출한다.
10개의 값이 나올 때 까지 filtered(mapped) 에서 20번의 연산만 일어났다.
만약 배열의 프로토타입 메소드를 이용했다면 무한한 크기의 데이터스트림에 모드 map , filter 연산을 해야 했기에 무한의 시간만큼이나 걸렸을 것이다.
애초에 무한한 크기의 배열을 저장하는 것 조차 불가능했을 것이다.
심지어 이 코드들은 pipe
를 통해 함수 합성을 아주 우아하게 제공한다.
import { filter, map, range, take, pipe, tap, each } from "@fxts/core";
pipe(
range(Infinity),
map((num) => num + 1),
filter((num) => num % 2),
take(10),
each(console.log) // 1, 3, 5, 7, 9 ...
);
이 라이브러는 지연평가뿐 아니라 비동기처리를 위한 toAsync
, 병렬 처리를 위한 concurrentPool
메소드들도 제공한다.
여러 코드들은 위에서 설명한 이터러블, 이터레이터 개념과 for of
문을 이용해 구현된 아주 단순한 코드들이기에 이해하기 쉽고 효율적이다.
아름다워 아름다워