Недавно мне пришлось изучить основы генераторов JavaScript, чтобы лучше понять, как работают Redux Sagas (тема для другого дня). Я решил собрать воедино кусочки информации, которую я мог собрать с разных веб-сайтов, и сжать их в одну статью, которая, я надеюсь, будет доступной, но достаточно строгой, чтобы служить рабочим руководством для начинающих по генераторам.

Вступление

Генераторы были введены в JavaScript в ES6. Функции генератора аналогичны обычным функциям, за исключением того, что их можно приостанавливать и возобновлять. Генераторы также очень тесно связаны с итераторами в том смысле, что объект-генератор является итератором.

В JavaScript функции обычно не могут быть приостановлены или остановлены после вызова. (Да, асинхронная функция приостанавливается во время ожидания оператора await, но асинхронные функции были введены только в ES7. Кроме того, асинхронные функции в любом случае построены на генераторах.) Обычная функция завершается только после возврата или выдает ошибку.

function foo() {
  console.log('Starting');
  const x = 42;
  console.log(x);
  console.log('Stop me if you can');
  console.log('But you cannot');
}

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

Генераторы и итераторы

От MDN:

В JavaScript итератор - это объект, который определяет последовательность и потенциально возвращаемое значение после ее завершения. В частности, итератор - это любой объект, который реализует протокол итератора, имея метод next(), который возвращает объект с двумя свойствами: value, следующее значение в последовательности; и done, что равно true, если последнее значение в последовательности уже было использовано. Если value присутствует рядом с done, это возвращаемое значение итератора.

Следовательно, итераторы по сути:

  1. Объекты, определяющие последовательности
  2. Есть next() метод…
  3. … Который возвращает объект с двумя свойствами: value и done

Вам требуются генераторы для создания итераторов? Неа. Фактически, вы уже могли создать бесконечную последовательность Фибоначчи, используя замыкания до ES6, как показано в этом примере:

var fibonacci = {
  next: (function () {
    var pre = 0, cur = 1;
    return function () {
      tmp = pre;
      pre = cur;
      cur += tmp;
      return cur;
    };
  })()
};
fibonacci.next(); // 1
fibonacci.next(); // 2
fibonacci.next(); // 3
fibonacci.next(); // 5
fibonacci.next(); // 8

Еще раз процитирую MDN о преимуществах генераторов:

Хотя настраиваемые итераторы - полезный инструмент, их создание требует тщательного программирования из-за необходимости явно поддерживать их внутреннее состояние. Функции генератора предоставляют мощную альтернативу: они позволяют вам определять итерационный алгоритм, написав единственную функцию, выполнение которой не является непрерывным.

Другими словами, проще создавать итераторы с помощью генераторов (замыкания не требуются!), Что означает меньшую вероятность ошибок.

Связь между генераторами и итераторами проста: функции генератора возвращают объекты-генераторы, которые являются итераторами.

Синтаксис

Функции генератора создаются с использованием синтаксиса function * и приостанавливаются с помощью ключевого слова yield.

Первоначальный вызов функции генератора не выполняет никакого ее кода; вместо этого он возвращает объект-генератор. Значения потребляются путем вызова метода next() генератора, который выполняет код до тех пор, пока не встретит ключевое слово yield, после чего приостанавливается до следующего вызова next().

function * makeGen() {
  yield 'Hello';
  yield 'World';
}
const g = makeGen(); // g is a generator
g.next(); // { value: 'Hello', done: false }
g.next(); // { value: 'World', done: false }
g.next(); // { value: undefined, done: true }
...

Повторный вызов g.next() после нашего последнего оператора выше вернет (или, точнее, yield) тот же объект возврата: { value: undefined, done: true }.

yield приостанавливает выполнение

Вы можете заметить что-то особенное в приведенном выше фрагменте кода. Второй вызов next() возвращает объект со свойством done: false, а не done: true.

Разве свойство done не должно быть true, поскольку мы выполняем последний оператор в функции генератора? Ну нет. Когда встречается оператор yield, возвращается значение после него (в данном случае «World»), и выполнение приостанавливается. Следовательно, второй next() вызов приостанавливает второй yield оператор, и, таким образом, выполнение еще не завершено - выполнение завершается (и done: true) только тогда, когда выполнение возобновляется после второго yield оператор, и больше нет кода для запуска.

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

доход против доходности

В приведенном выше примере мы используем yield для передачи значений за пределы генератора. Мы также можем использовать return (как в обычной функции); однако использование return завершает выполнение и устанавливает done: true.

function * makeGen() {
  yield 'Hello';
  return 'Bye';
  yield 'World';
}
const g = makeGen(); // g is a generator
g.next(); // { value: 'Hello', done: false }
g.next(); // { value: 'Bye', done: true }
g.next(); // { value: undefined, done: true }
...

Поскольку выполнение не приостанавливается на операторе return и по определению не может быть никакого дальнейшего выполнения кода после оператора return, done устанавливается в true.

yield: двусторонняя связь

До сих пор мы использовали yield для передачи значений за пределы генератора (а также для приостановки его выполнения).

Однако yield на самом деле является улицей с двусторонним движением и позволяет также передавать значения в функцию генератора.

function * makeGen() {
  const foo = yield 'Hello world';
  console.log(foo);
}
const g = makeGen();
g.next(1); // { value: 'Hello world', done: false }
g.next(2); // logs 2, yields { value: undefined, done: true }

Подожди секунду. Разве нельзя 1 записывать в консоль, а не 2? Сначала я нашел эту часть концептуально противоречащей интуиции, как я и ожидал foo = 1. В конце концов, мы передали «1» в вызов next() метода, который дал Hello world, верно?

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

Мне нравится рационализировать выполнение программы следующим образом:

  • При первом вызове next() он выполняется до тех пор, пока не встретит yield 'Hello world', после чего выдает { value: 'Hello world', done: false } и делает паузу. Это все. Как видите, любое значение, переданное в первый вызов next(), не используется (и, следовательно, отбрасывается).
  • Когда next(...) вызывается снова, выполнение возобновляется. В этом случае выполнение влечет за собой присвоение некоторого значения (определяемого оператором yield) константе foo. Следовательно, наш второй вызов next(2) назначает foo = 2. Однако программа не останавливается на достигнутом - она ​​выполняется до тех пор, пока не встретит следующий yield или return оператор. В этом случае доходности больше нет, поэтому он регистрирует 2 и возвращает undefined с done: true.

Асинхронный режим с генераторами

Поскольку yield - это двусторонний канал, который позволяет информации течь в обоих направлениях, это позволяет нам очень круто использовать генераторы. До сих пор мы в основном использовали yield для передачи значений вне генератора. Но мы также можем воспользоваться двусторонним характером yield для синхронного написания асинхронных функций.

Используя приведенные выше концепции, мы могли бы создать элементарную функцию, которая напоминает синхронный код, но действительно выполняет асинхронные функции:

function request(url) {
  fetch(url).then(res => {
    it.next(res); // Resume iterator execution
  });
}
function * main() {
  const rawResponse = yield request('https://some-url.com');
  const returnValue = synchronouslyProcess(rawResponse);
  console.log(returnValue);
}
const it = main();
it.next(); // Remember, the first next() call doesn't accept input

Вот как это работает. Сначала мы объявляем функцию request и функцию генератора main. Затем мы создаем итератор it, вызывая main(). Затем мы вызываем it.next(), чтобы начать работу.

В первой строке function * main() выполнение приостанавливается после yield request('https://some-url.com'). request() неявно возвращает undefined, поэтому мы фактически получаем undefined, но это не имеет значения - мы все равно не используем это значение доходности.

Когда вызов fetch() в функции request() завершается, он вызывает it.next(res), который выполняет 2 действия:

  1. Он возобновляет выполнение; а также
  2. Он передает res в функцию генератора, которая назначается rawResponse

Наконец, остальная часть main() завершается синхронно.

Это очень элементарная настройка, которая должна иметь некоторое сходство с обещаниями. Более подробное описание доходности и асинхронности можно найти в этой статье.

Генераторы одноразовые

Вы не можете повторно использовать генераторы, но вы можете создавать новые генераторы из своей функции генератора.

function * makeGen() {
  yield 42;
}
const g1 = makeGen();
const g2 = makeGen();
g1.next(); // { value: 42, done: false }
g1.next(); // { value: undefined, done: true }
g1.next(); // No way to reset this!
g2.next(); // { value: 42, done: false }
...
const g3 = makeGen(); // Create a new generator
g3.next(); // { value: 42, done: false }

Бесконечные последовательности

Итераторы представляют собой последовательности, вроде того, как это делают массивы. Итак, мы должны иметь возможность выражать все итераторы в виде массивов, верно?

Ну нет. Массивы требуют активного выделения при создании, в то время как итераторы используются лениво. Массивы нетерпеливы, потому что создание массива из n элементов требует, чтобы все n элементов были сначала созданы / рассчитаны, чтобы их можно было сохранить в массиве. Напротив, итераторы ленивы, потому что следующее значение в последовательности создается / вычисляется только тогда, когда оно потребляется.

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

Давайте создадим бесконечную последовательность чисел от 1 до положительной бесконечности. В отличие от массива, для этого не требуется бесконечная память, потому что каждое значение в последовательности вычисляется лениво, когда оно используется.

function * makeInfiniteSequence() {
  var curr = 1;
  while (true) {
    yield curr;
    curr += 1;
  }
}
const is = makeInfiniteSequence();
is.next(); { value: 1, done: false }
is.next(); { value: 2, done: false }
is.next(); { value: 3, done: false }
... // It will never end

Интересный факт: это похоже на выражения генератора Python против понимания списков. В то время как эти два функционально идентичны, выражения генератора предлагают преимущества памяти, поскольку значения вычисляются лениво, в то время как составные части списков быстро оценивают значения и создают сразу весь список.

Заключение

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