Generators, advanced iteration

Generators

regular function은 하나의 결과만 리턴함
generator는 여러 개의 값을 필요에 따라 차례대로 리턴(yield)할 수 있음
iterable과 함께 사용하면 좋음

Generator functions

function*으로 generator function을 생성할 수 있음:

1
2
3
4
5
6
7
8
function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

let generator = generateSequence();
alert(generator); // [object Generator]
  • generator function을 실행하면 내부 코드가 실행되지 않고, generator object를 반환함

generator의 주요 메소드는 next()
next()가 호출되면 가장 가까운 yield <value>까지 코드를 실행함
yield <value>value를 리턴하고, 다시 함수 실행을 멈춤
(<value>가 생략되면 undefined가 리턴됨)

next()의 결과는 항상 두 개의 property를 가지는 객체임:

  • value : yielded value
  • done : 함수 코드가 끝났으면 true, 아니면 false

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

let generator = generateSequence();

let one = generator.next();
alert(JSON.stringify(one)); // {value: 1, done: false}
let two = generator.next();
alert(JSON.stringify(two)); // {value: 2, done: false}
let three = generator.next();
alert(JSON.stringify(three)); // {value: 3, done: true}
  • generator가 종료된 후에는 generator.next()를 실행하면 {done: true} 객체가 계속 반환됨

function* f(...) or function *f(...)?
둘 다 사용 가능하지만, 전자가 많이 사용됨

Generators are iterable

generator가 next() method를 가지기 때문에 iterable임!
따라서 for...of로 반복할 수 있음:

1
2
3
4
5
6
7
8
9
10
11
function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

let generator = generateSequence();

for(let value of generator) {
  alert(value); // 1, then 2
}
  • 1, 2만 출력되고 3은 출력되지 않음에 주의!!!
    for...ofdone: true인 마지막 객체를 무시함
    따라서 for...of로 결과를 모두 출력하고 싶으면 마지막 값도 yield로 리턴해야 함!:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
      function* generateSequence() {
        yield 1;
        yield 2;
        yield 3;
      }
    
      let generator = generateSequence();
    
      for(let value of generator) {
        alert(value); // 1, then 2, then 3
      }
    

generator가 iterable이기 때문에 spread syntax ...을 적용할 수 있음:

1
2
3
4
5
6
7
8
9
function* generateSequence() {
  yield 1;
  yield 2;
  yield 3;
}

let sequence = [0, ...generateSequence()];

alert(sequence); // 0, 1, 2, 3

Using generators for iterables

range object는 from부터 to까지의 값을 반환하는 iterable임:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let range = {
  from: 1,
  to: 5,

  [Symbol.iterator]() {
    return {
      current: this.from,
      last: this.to,

      next() {
        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

alert([...range]); // 1,2,3,4,5
  • [Symbol.iterator]() method는 range가 iteration을 시작할 때 한 번만 호출됨
    e.g. for...of range가 시작될 때

    iterator 객체를 리턴함

    • 이 객체를 통해서 for...of와 같은 반복문이 작동함
    • next()를 가지고 있어야 함
  • next()는 매 반복마다 호출됨
    e.g. for...of의 매 iteration 마다

    {done: .., value: ..}인 객체를 리턴함

Symbol.iterator()를 generator function으로 바꿀 수 있음:

1
2
3
4
5
6
7
8
9
10
11
12
let range = {
  from: 1,
  to: 5,

  *[Symbol.iterator]() {
    for(let value = this.from; value <= this.to; value++) {
      yield value;
    }
  }
};

alert( [...range] ); // 1,2,3,4,5
  • *[Symbol.iterator]()[Symbol.iterator}: function*()의 shorthand임
  • generator function으로 변환된 Symbol.iterator()는 정확히 iterator의 역할을 수행함
    • .next() method가 존재함
    • {done: .., value: ..} 객체를 리턴함
  • generator 자체가 JS에서 iterator를 쉽게 다루기 위해 만들어진 것임
    => iterable code가 더 간결해짐

Generators may generate values forever
위 예시에서는 유한한 수열을 생성했지만, 무한하게 출력하는 것도 가능함
=> generator를 사용하는 반복문에서 종료시켜야 함

Generator composition

generator composition을 사용해서 generator 안에 generator를 embed할 수 있음
아래와 같이 수열을 생성하는 함수가 있다 하자:

1
2
3
function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) yield i;
}

이 함수를 사용해서 아래와 같은 수열을 만들어야 함:

  • 첫 번째로 '0'..'9'(48..57) 출력
  • 그 뒤에 A..Z(65..90) 출력
  • 그 뒤에 a..z(97..122) 출력

regular function에서 위와 같은 작업을 수행하려면 변수 하나에 각각의 함수 호출 결과를 저장하고 그것을 출력해야 함
generator에서는 yield*를 이용해서 generator의 결과를 합칠 수 있음:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) yield i;
}

function* generatePasswordCodes() {
  yield* generateSequence(48, 57);
  yield* generateSequence(65, 90);
  yield* generateSequence(97, 122);
}

let str = '';

for(let code of generatePasswordCodes()) {
  str += String.fromCharCode(code);
}

alert(str); // 0..9A..Za..z
  • yield* generateSequence(48, 57);for (let i = 48; i <= 57; i++) yield i;와 같음
  • yield*는 다른 generator에게 실행을 위임함
    즉, yield* gengen의 반복을 수행하고 그 결과를 바깥의 generator의 yield로 전달함
  • generator composition은 generator를 다른 generator 안으로 넣기 위한 방법임
    이를 이용하면 generator의 값을 옮기기 위해 따로 변수를 선언하지 않아도 되기 때문에 메모리가 절약됨

“yield” is a two-way street

yield는 generator의 결과를 밖으로 리턴할 뿐만 아니라, 밖에서 generator 안으로 값을 가져올 수도 있음
generator.next(arg)를 사용해서 argyield의 결과로 만들 수 있음:

1
2
3
4
5
6
7
8
9
10
11
12
function* gen() {
  // Pass a question to the outer code and wait for an answer
  let result = yield "2 + 2 = ?"; // (*)

  alert(result);
}

let generator = gen();

let question = generator.next().value; // <-- yield returns the value

generator.next(4); // --> pass the result into the generator
  • (*)"2 + 2 = ?"가 바깥의 question으로 들어가고, generator.next(4)에 의해 gen()result4가 대입됨
js-generator1
javascript.info 참고
1
2
3
4
5
1. `generator.next()`에 의해 실행이 시작되고 `yield "2+2=?"`의 결과가 리턴됨  
	이 시점에서 함수 흐름은 `(*)`에 멈춰있음
2. `yield`의 결과가 `gen`을 호출한 코드의 `question`에 저장됨
3. `generator.next(4)`가 실행되면서 generator가 다시 실행되고 `4`가 `result`에 들어감 - 첫 번째 `generator.next()`는 항상 argument 없이 호출되어야 함!  
(있으면 무시됨)

outer code에서 즉시 next(4)를 호출할 필요는 없음!
시간을 두고 실행해도 generator가 입력을 기다리다가 받음:

1
setTimeout(() => generator.next(4), 1000);

보통의 함수들과 다르게, generator와 calling code는 yield/next로 값을 서로 전달할 수 있음:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function* gen() {
  let ask1 = yield "2 + 2 = ?";

  alert(ask1); // 4

  let ask2 = yield "3 * 3 = ?"

  alert(ask2); // 9
}

let generator = gen();

alert( generator.next().value ); // "2 + 2 = ?"
alert( generator.next(4).value ); // "3 * 3 = ?"
alert( generator.next(9).done ); // true
  • 첫 번째를 제외한 나머지 next(value)value를 현재 yield의 결과값이 되도록 전달하고, 다음 yield의 값을 가져옴
js-generator2
javascript.info 참고

generator.throw

outer code도 yield의 결과가 되도록 generator 안으로 값을 전달할 수 있음
에러도 하나의 결과값이기 때문에 에러를 전달할 수도 있음
에러를 yield로 전달하기 위해선 generator.throw(err)를 사용하면 됨
=> 에러가 yield가 있는 줄에서 발생하게 됨:

function* gen() {
  try {
    let result = yield "2 + 2 = ?"; // (1)
    alert("The execution does not reach here, because the exception is thrown above");
  } catch(e) {
    alert(e);
  }
}

let generator = gen();
let question = generator.next().value;

generator.throw(new Error("The answer is not found in my database")); // (2)
  • (2)에서 generator 안으로 던진 에러는 (1)에서 exception이 됨
    generator 안의 try...catch로 에러를 처리함
  • generator 안에서 잡지 않으면 에러는 밖으로 떨어져나옴
    따라서 아래와 같이 (2)에서 에러를 잡을 수도 있음:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
      function* generate() {
        let result = yield "2 + 2 = ?"; // Error in this line
      }
    
      let generator = generate();
      let question = generator.next().value;
    
      try {
        generator.throw(new Error("The answer is not found in my database"));
      } catch(e) {
        alert(e); // shows the error
      }
    
  • 에러를 아예 잡지 않으면 다른 에러와 마찬가지로 스크립트를 멈춤

generator.return

generator.return(value)는 강제로 generator 실행을 끝내고 value를 리턴함:

1
2
3
4
5
6
7
8
9
10
11
function* gen() {
  yield 1;
  yield 2;
  yield 3;
}

const g = gen();

g.next();        // { value: 1, done: false }
g.return('foo'); // { value: "foo", done: true }
g.next();        // { value: undefined, done: true }
  • 이미 return을 사용한 상태에서 한 번 더 사용하면 이전의 값을 다시 리턴함
  • return을 사용한 후에는 generator가 끝난 상태이기 때문에 next를 호출하면 위와 같이 undefind가 리턴됨

Summary

code description
function* f(...) { ... } generator function f를 선언
yield value generator 내부에서 외부와 값을 전달할 때 사용됨
f.next(value) generator f의 현재 yield의 값으로 value를 전달하고 다음 yield의 결과값을 받음
yield* <generator object> <generator object>의 결과값을 outer generator object의 yield의 결과값으로 사용
generator.throw(err) generatorerr를 던짐
generator.return(value) generatorvalue를 가진 객체를 리턴함과 함께 끝냄
  • generator function을 실행하면 내부 코드가 실행되는게 아니라 generator object가 반환됨
  • generator는 iterable임
    next() method를 가짐 => for...of에 generator를 사용해서 반복 가능, spread syntax 적용 가능
  • 아예 rangeSymbol.iterator를 generator function으로 바꿀 수도 있음
  • generator 내부에서 yield, return으로 함수 실행을 조절할 수 있음
  • generator composition
    • 한 generator object의 결과를 다른 generator의 yield의 결과값으로 사용하는 것
    • yield*를 이용
  • generator.return()을 호출하면 가장 최근의 값을 리턴함

Tasks

Pseudo-random generator

“seeded pseudo-random generator”는 시드값과 점화식을 이용해서 랜덤 수열을 생성하는 것을 말함

1
2
3
4
5
6
7
8
9
10
11
12
function* pseudoRandom(seed){
  while(true){
    seed=seed*16807%2147483647;
    yield seed;
  }
}

let generator = pseudoRandom(1);

alert(generator.next().value); // 16807
alert(generator.next().value); // 282475249
alert(generator.next().value); // 1622650073

아래와 같이 함수로도 구현 가능함:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function pseudoRandom(seed) {
  let value = seed;

  return function() {
    value = value * 16807 % 2147483647;
    return value;
  }
}

let generator = pseudoRandom(1);

alert(generator()); // 16807
alert(generator()); // 282475249
alert(generator()); // 1622650073
  • 하지만 이렇게 구현하면 for...of로 반복하거나 generator composition을 사용하지 못함

Async iteration and generators

Recall iterables

아래와 같이 range iterable을 구현 가능함:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let range = {
  from: 1,
  to: 5,

  [Symbol.iterator]() { // called once, in the beginning of for..of
    return {
      current: this.from,
      last: this.to,

      next() { // called every iteration, to get the next value
        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

for(let value of range) {
  alert(value); // 1 then 2, then 3, then 4, then 5
}

Async iterables

시간차를 두는 등의 이유로 값들이 비동기적으로 들어올 때 비동기적 반복이 필요함
가장 흔한 케이스는 네트워크 통신임

iterable을 비동기적으로 만들기 위해서는 아래와 같은 과정을 거쳐야 함:

  1. Symbol.asyncIterator를 사용해야 함(Symbol.iterator 대신)
  2. next()는 promise를 리턴해야 함(다음 값으로 fulfilled)
    => async next()로 비동기적 반복을 처리함
  3. 이러한 iterable은 for await (let item of iterable)를 이용해서 반복을 수행해야 함

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
let range = {
  from: 1,
  to: 5,

  [Symbol.asyncIterator]() {
    return {
      current: this.from,
      last: this.to,

      async next() {
        await new Promise(resolve => setTimeout(resolve, 1000));

        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

(async () => {
  for await (let value of range) {
    alert(value); // 1,2,3,4,5
  }
})()

iterator, async iterator의 차이:

  Iterators Async iterators
Object method to provide iterator Symbol.iterator Symbol.asyncIterator
next() return value is any value Promise
to loop, use for...of for await ...of

The spread syntax ... doesn’t work asynchronously
asynchronous iterator에 대해서는 spread syntax를 사용할 수 없음!

Recall generators

generator는 function*로 선언된 generator function을 통해 생성됨
yield, return으로 값을 리턴함

iterable을 generator로 구현하기 위해선 아래와 같이 Symbol.iterator을 generator function으로 만들어야 함:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let range = {
  from: 1,
  to: 5,

  *[Symbol.iterator]() { // a shorthand for [Symbol.iterator]: function*()
    for(let value = this.from; value <= this.to; value++) {
      yield value;
    }
  }
};

for(let value of range) {
  alert(value); // 1, then 2, then 3, then 4, then 5
}

Async generators (finally)

asynchronous generator를 이용하면 비동기적으로 값을 생성하는 객체를 만들 수 있음
function*async를 적용시켜 generator function이 await를 사용할 수 있게 만들어주면 됨
반복할 때는 for await (...)을 사용함:

1
2
3
4
5
6
7
8
9
10
11
12
13
async function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) {
    await new Promise(resolve => setTimeout(resolve, 1000));
    yield i;
  }
}

(async () => {
  let generator = generateSequence(1, 5);
  for await (let value of generator) {
    alert(value); // 1, then 2, then 3, then 4, then 5 (with delay between)
  }
})();

Under-the-hood difference
regular generator의 경우 result = generator.next()를 사용해서 값을 받을 수 있음
하지만 async generator의 경우 generator.next()가 비동기적이고, promise를 리턴함
따라서 아래와 같이 await를 추가해야 함:

1
result = await generator.next(); // result = {value: ..., done: true/false}

Async iterable range

async iterable range도 Symbol.asyncIteratorawait를 추가해서 구현할 수 있음:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let range = {
  from: 1,
  to: 5,

  async *[Symbol.asyncIterator]() {
    for(let value = this.from; value <= this.to; value++) {
      await new Promise(resolve => setTimeout(resolve, 1000));
      yield value;
    }
  }
};

(async () => {
  for await (let value of range) {
    alert(value); // 1, then 2, then 3, then 4, then 5
  }
})();

Please note:
이론적으로는 Symbol.iterator, Symbol.asyncIterator 둘 다 object에 추가 가능하기 때문에 한 객체가 동기적(for...of), 비동기적(for await...of) iterable이 될 수 있음
하지만 실제로는 그렇게 사용하지 않음

Real-life example: paginated data

한 페이지에 100개의 유저 정보만 출력하는 경우 async generator가 필요함
이 패턴은 유저 정보 뿐만이 아닌 아주 흔한 패턴임
예를 들어 Github에서도 커밋을 페이지 방식으로 조회하게 만듦:

  • http://api.github.com/repos/<repo>/commitsfetch를 요청
  • 30개의 JSON과 Link헤더에 다음 페이지의 링크를 제공함
  • 그 링크를 사용해서 더 많은 커밋 정보를 조회함

이 예제에서는 더 간편한 방법을 사용해서 커밋 정보를 얻을 것임
fetchCommits(repo)는 필요할 때마다 요청해서 커밋을 가져오는 함수라 하자
페이지화된 방법을 사용하기 위해 우리는 for await...of를 사용해보자
그렇다면 아래와 같이 fetchCommits를 사용할 수 있음:

1
2
3
for await (let commit of fetchCommits("username/repository")) {
  // process commit
}

함수의 구현부:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function* fetchCommits(repo) {
  let url = `https://api.github.com/repos/${repo}/commits`;

  while (url) {
    const response = await fetch(url, { // (1)
      headers: {'User-Agent': 'Our script'},
    });

    const body = await response.json(); // (2) response is JSON (array of commits)

    // (3) the URL of the next page is in the headers, extract it
    let nextPage = response.headers.get('Link').match(/<(.*?)>; rel="next"/);
    nextPage = nextPage?.[1];

    url = nextPage;

    for(let commit of body) { // (4) yield commits one by one, until the page ends
      yield commit;
    }
  }
}
  • (1)에서 Github에서는 인증 등을 위해 다른 헤더도 필요하면 추가할 수 있도록 User-Agent를 요구함
  • (2)에서 JSON으로 받은 커밋을 복호화함
  • (3)에서 Link 헤더에서 다음 페이지의 링크를 추출함
  • (4)에서 커밋을 하나씩 yield함

사용 예시:

1
2
3
4
5
6
7
8
9
(async () => {
  let count = 0;
  for await (const commit of fetchCommits('javascript-tutorial/en.javascript.info')) {
    console.log(commit.author.login);
    if (++count == 100) { // let's stop at 100 commits
      break;
    }
  }
})();

Summary

  • 비동기적으로 데이터를 입력받는 것은 async generator를 사용하면 됨
  • async iteration을 사용해서 async generator에 대해 iteration을 수행할 수 있음

regular iterator, async iterator 구현 차이:

  Iterable Async Iterable
Method to provide iterator Symbol.iterator Symbol.asyncIterator
next() return value is {value:..., done:...} Promise that resolves to {value:..., done:...}

regular generator, async generator 구현 차이:

  Generators Async Generators
Declaration function* async function*
next() return value is {value:..., done:...} Promise that resolves to {value:..., done:...}
  • browser 환경에서는 Stream API를 사용하면 이런 paginated data를 쉽게 다룰 수 있음

Leave a comment