Введение

Долгое время генераторы оставались для меня такой странной фичей JS, которая, казалось мне, нужна только для того, чтобы спрашивать о них на собеседованиях (я так не делал, но, когда интервьюировали меня, о генераторах спрашивали довольно часто). Но не так давно я понял, насколько же это важная и интересная штука.

Немного о том, как работать с генераторами

Генераторы похожи на функции, но значительно от них отличаются. Обычные функции возвращают либо что-то одно, либо ничего. Генераторы же могут, приостанавливая свое выполнение, вернуть неограниченное количество значений. Это очень пересекается с понятием “монада”, о котором вы можете почитать в моей статье о монадах. Более того, генераторы решают кучу проблем, связанных с монадами — об этом я расскажу чуть позже.

А пока — давайте посмотрим на пример:

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

Функцию-генератор мы объявляем именно так, function*, со звездочкой. Когда мы выполняем такую функцию, мы получаем сам генератор:

const generator = generateNumberSequence();

У генератора есть метод next(), который выполняет все, пока не встретит ключевое слово yield. Тогда он вернет значение из yield (удобно представлять себе yield как неокончательный return) или undefined, если такого значения нет. После этого генератор приостановит выполнение и будет ждать следующего вызова next().

Результатом вызова next() всегда становится такой объект:

{
  value, // значение из yield
  done // true, если генератор полностью выполнен, в ином случае false
}

Давайте поглядим на пример целиком:

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

const generator = generateNumberSequence();

const one = generator.next(); // { value: 1, done: false }
const two = generator.next(); // { value: 2, done: false }

// и так далее, до:
const ten = generator.next() // { value: 10, done: true }

// по значению done: true мы можем понять, что генератор полностью выполнен.
// мы можем и дальше запускать next() неограниченное количество раз, но это бесполезно:

const oneMoreTime = generator.next() // { done: true }
const andLastTime = generator.next() // { done: true }

Генератор как Iterable (перебор генераторов)

Достаточно очевидно, что из-за наличия метода next() генераторы — это перебираемые объекты. Это значит, что мы можем делать так:

const generator = generateNumberSequence();

for (const value of generator) {
  console.log(value)
}

/*
1
2
3
4
5
6
7
8
9
*/

Выглядит приятнее, да? Но есть момент: return в таком случае не выполнится, и мы не увидим значения 10. Поэтому можно просто не использовать return в генераторах, ограничившись yield.

Еще момент: генераторы не обязательно конечны. Мы можем написать и бесконечный генератор:

function* infiniteGenerator() {
  for (let i = 0; i < 10; i--) {
    yield i;
  }
}
// если и использовать такие генераторы, то нужно не забывать про break / return

Композиция генераторов

В случае с обычными функциями, чтобы их объединить, мы отдельно исполняем их, сохраняя промежуточные результаты и объединяя их в конце. А у генераторов есть очень интересная возможность, в отличие от обычных функций: мы можем встраивать генераторы друг в друга, используя синтаксис yield*.

Смотрите, что можно сделать:

// генератор последовательностей от start до end
function* generateNumbers(start, end) {
  for (let i = start; i <= end; i++) {
    yield i;
  }
}

function* sequenceGenerator() {
  yield* generateNumbers(1, 100);
  yield* generateNumbers(200, 300);
}

for (const num of sequenceGenerator()) {
  console.log(num);
}
// 1 .. 100 200 .. 300

Таким образом, не используя дополнительную память для сохранения промежуточных результатов и не разрабатывая огромный генератор на очень много строк, мы пишем гораздо более оптимальный и читаемый код.

И еще немного интересного об yield

До этого момента мы использовали yield только для того, чтобы что-то отдать из генератора наружу. Хоть это само по себе очень полезно в том смысле, что мы можем собирать очень кастомные Iterable-объекты, но это не все, что yield умеет — еще с помощью него мы можем снаружи передать что-то в генератор, ведь в next() можно передавать аргументы, которые становятся результатом yield. Смотрите:

function* inputGen() {
  let result = yield 'awaiting something from the outside'
  yield result;
  yield result.toUpperCase();
}

const gen = inputGen();

console.log(gen.next().value); // awaiting something from the outside
console.log(gen.next('something').value); // something
console.log(gen.next().value); // SOMETHING

Видите? Мы передали что-то в next, в генераторе сохранили это в result и применили к нему .toUpperCase(). Таким образом, генератор — это не просто прокачанный перебираемый объект, это нечто гораздо более функциональное.

Еще один интересный метод генераторов — throw, с помощью которого мы можем выкидывать ошибки из генераторов. Смотрите:

function* inputGen() {
  let result = yield 'awaiting something from the outside'
}

const gen = inputGen();

const question = gen.next().value;

try {
  gen.throw(new Error('Ответ не найден'));
} catch (e) {
  console.log(e); // Ответ не найден
}

Итоги

Вообще, генераторы в реальном коде используются редко. Но как способ работы с разными монадическими структурами (о монадах — здесь) они крайне полезны. Да и вообще, создавать перебираемые объекты, которые во время выполнения могут обмениваться данными с внешним миром — крутая возможность. В общем, учим генераторы :)