Недавно мне пришлось изучить основы генераторов 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
, это возвращаемое значение итератора.
Следовательно, итераторы по сути:
- Объекты, определяющие последовательности
- Есть
next()
метод… - … Который возвращает объект с двумя свойствами:
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 действия:
- Он возобновляет выполнение; а также
- Он передает
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 против понимания списков. В то время как эти два функционально идентичны, выражения генератора предлагают преимущества памяти, поскольку значения вычисляются лениво, в то время как составные части списков быстро оценивают значения и создают сразу весь список.
Заключение
Эту статью было действительно интересно писать, и я многому научился за это время. Однако, вероятно, есть еще масса безумных, удивительных вещей, которые вы можете выполнить с помощью генераторов, о которых эта статья даже не касается. Однако это не предназначено - оно предназначено только как рабочее руководство для начинающих. Так что вперед и ныряйте глубже!