В этом небольшом посте я хочу рассказать об одном интересном предложении (англ. proposal) в стандарт EcmaScript. Речь пойдёт об асинхронных итераторах, о том, что это такое, как ими пользоваться и зачем они вообще нужны простому разработчику.
Асинхронные итераторы, это расширение возможностей обычных итераторов, которые с помощью цикла for-of
/for-await-of
позволяют пробежать по всем элементам коллекции.
Для начала стоит объяснить, что я подразумеваю под генераторами, а что под итераторами, т.к. я часто буду использовать эти термины. Генератор — функция, которая возвращает итератор, а итератор — объект, содержащий метод next()
, который в свою очередь возвращает следующее значение.
function* generator () { // функция генератор
yield 1
}
const iterator = generator() // при вызове возвращается итератор
console.log(iterator.next()) /// значение { value: 1, done: false }
Хотелось бы несколько подробнее остановиться на итераторах и объяснить их смысл в настоящее время. Современный JavaScript (стандарт ES6/ES7) позволяет перебрать значения коллекции (например Array
, Set
, Map
и т.д.) поочерёдно, без лишней возни с индексами. Для этого был принят протокол итераторов, определяемый в прототипе коллекции с помощью символа (Symbol
) Symbol.iterator
:
// как пример, генератор диапазонов чисел
// конструктор типа Range
function Range (start, stop) {
this.start = start
this.stop = stop
}
// объявляем метод, который будет возвращать генератор
// мы не будем вызывать его явно, он будет вызван автоматически в цикле for-of
Range.prototype[Symbol.iterator] = function *values () {
for (let i = this.start; i < this.stop; i++) {
yield i
}
}
// создаём новый диапазон
const range = new Range(1, 5)
// а вот здесь уже из диапазона вызывается [Symbol.iterator]()
// и итерируется по созданному генератору
for (let number of range) {
console.log(number) // 1, 2, 3, 4
}
Каждый итератор (в нашем случае это range[Symbol.iterator]()
) имеет метод next()
, который возвращает объект, содержащий 2 поля: value
и done
, содержащие текущее значение и флаг, обозначающий конец генератора, соответственно. Этот объект можно описать таким интерфейсом:
interface IteratorResult<T> {
value: T;
done: Boolean;
}
Более подробно о генераторах можно почитать на MDN.
К слову, если у нас уже есть итератор и мы хотим пройтись по нему с помощью for-of
, то нам не нужно приводить его обратно к нашему (или любому другому итерируемому) типу, т.к. каждый итератор имеет такой же метод [Symbol.iterator]
, который возвращает this
:
const iter = range[Symbol.iterator]()
assert.strictEqual(iter, iter[Symbol.iterator]())
Надеюсь, здесь всё понятно. Теперь ещё немного нужно сказать про асинхронные функции.
В ES7 был предложен async
/await
синтаксис. По сути, это сахар позволяющий в псевдосинхронном стиле работать с промисами (Promise):
async function request (url) {
const response = await fetch(url)
return await response.json()
}
// против
function request (url) {
return fetch(url)
.then(response => response.json())
}
Отличие от обычной функции в том, что async
-функция всегда возвращает Promise, даже, если мы делаем обычный return 1
, то получим Promise
, который при разрешении вернёт 1
.
Отлично, теперь наконец-то переходим к асинхронным итераторам.
Вслед за асинхронными фнкциями (async function () { ... }
) были предложены асинхронные итераторы, которые можно использовать внутри этих самых функций:
async function* createQueue () {
yield 1
yield 2
// ...
}
async function handle (queue) {
for await (let value of queue) {
console.log(value) // 1, 2, ...
}
}
В данный момент асинхронные итераторы находятся в предложениях, в 3-й стадии (кандидат), что означает, что синтаксис стабилизирован и ожидает включения в стандарт. Это предложение пока не реализовано ни в одном JavaScript-движке, но попробовать и поиграть с ним всё же можно — с помощью Babel плагина babel-plugin-transform-async-generator-functions:
{
"dependencies": {
"babel-preset-es2015-node": "···",
"babel-preset-es2016": "···",
"babel-preset-es2017": "···",
"babel-plugin-transform-async-generator-functions": "···"
// ···
},
"babel": {
"presets": [
"es2015-node",
"es2016",
"es2017"
],
"plugins": [
"transform-async-generator-functions"
]
},
// ···
}
взято из блога 2ality, полный код с примерами использования можно посмотреть в rauschma/async-iter-demo
Итак, чем же асинхронные итераторы отличаются от обычных? Как говорилось выше, итератор возвращает значение IteratorResult
. Асинхронный же итератор всегда возвращает Promise<IteratorResult>
. Это значит, что для того, чтобы получить значение и понять нужно продолжать выполнение цикла или нет, нужно дождаться разрешения (resolve) промиса, который вернёт IteratorResult
. Именно поэтому был введён новый синтаксис for-await-of
, который и делает всю эту работу.
Возникает закономерный вопрос: зачем было вводить новый синтаксис, почему нельзя вернуть IteratorResult<Promise>
, а не Promise<IteratorResult>
и подождать (await ...
) его руками (прошу прощения за это странное выражение)? Это сделано для тех случаев, когда мы изнутри синхронного генератора не можем определить есть ли следующее значение или нет. Например нужно сходить в некую удалённую очередь по сети и забрать следующее значение, если очередь опустела, то выйти из цикла.
Хорошо, с этим разобрались, остался последний вопрос — использование асинхронных генераторов и итераторов. Здесь всё достаточно просто: добавляем к генератору ключевое слово async
и у нас получается асинхронный генератор:
// некая очередь задач
async function* queue () {
// бесконечно выбираем новые задачи из очереди
while (true) {
// дожидаемся результат
const task = await redis.lpop('tasks-queue')
if (task === null) {
// если задачи кончились, то прекращаем выполнение и выходим
// как раз тот случай, когда нужен именно Promise<IteratorResult>
return
} else {
// возвращаем задачу
yield task
}
}
}
// обработчик задач из очереди
async function handle () {
// получаем итератор по задачам
const tasks = queue()
// дожидаемся каждую задачу из очереди
for await (const task of tasks) {
// обрабатываем её
console.log(task)
}
}
Если мы хотим чтобы наша собственная структура могла быть асинхронно проитерирована с помощью for-await-of
, то нужно реализовать метод [Symbol.asyncIterator]
:
function MyQueue (name) {
this.name = name
}
MyQueue.prototype[Symbol.asyncIterator] = async function* values () {
// тот же код, что и в примере выше
while (true) {
const task = await redis.lpop(this.name)
if (task === null) {
return
} else {
yield task
}
}
}
async function handle () {
const tasks = new MyQueue('tasks-queue')
for await (const task of tasks) {
console.log(task)
}
}
На этом всё. Надеюсь эта статья была интересна и хоть в какой-то мере полезна. Спасибо за внимание.
Ссылки
Комментарии (18)
popov654
15.03.2017 17:21Вы не могли бы ещё раз рассказать, почему альтернативный подход с IteratorResult не годится? Немного непонятно получилось в статье.
Mingun
15.03.2017 18:48+1Ну вот разгружаете вы вагоны с
дерьмомзерном. Подставляете спину, туда прилетает мешок, вы его быстренько-быстренько в закромаотсыпаете там, конечно, и за следующим, пока мешки летают. Но вот рабочий день закончился, вагон опустел, но хотя последний рабочий, уходя, похлопал вас по спине,аки ломовую лошадь, вы разогнуться уже не можете, так и стоите в интересной позе тылом к рельсам. А ведь могли бы домой пойти, если бы на последнем мешке была этикетка с надписью "все, баста!"...popov654
15.03.2017 20:27Всё равно не понял)) Если без метафор, то в чём загвоздка?
Это сделано для тех случаев, когда мы изнутри синхронного генератора не можем определить есть ли следующее значение или нет. Например нужно сходить в некую удалённую очередь по сети и забрать следующее значение, если очередь опустела, то выйти из цикла.
Судя по коду, мы внутри генератора делаем await (дальнейший код исполнится когда "фоновый" Promise заресолвится, если я правильно понимаю), и потом проверяем на переменную на null как условие, что данных больше нет. Так может это условие и использовать как знак того, что "баста"?
mayorovp
15.03.2017 21:37А снаружи-то как вовремя узнать что больше элементов не будет?
Чему будет равно свойство done итератора во время выполнения оператора await в генераторе?
asdf404
16.03.2017 03:32-1А снаружи-то как вовремя узнать что больше элементов не будет?
Снаружи "вовремя" будет тогда, когда зарезолвится промис, т.к. пока это не произойдёт ваш цикл не сможет пойти дальше. А когда промис резолвится, то он возвращает состояние итератора, где указано завершился итератор или нет.
Чему будет равно свойство done итератора во время выполнения оператора await в генераторе?
Оно будет равно
false
. Станет равнымtrue
лишь при выходе из функции-генератора явно (поreturn
) или неявно (кончилось тело функции).mayorovp
16.03.2017 07:04Вот смотрите, проверили мы done. Оно равно false. Мы получили очередной промиз и начали его ждать.
А следующего элемента-то и нет! Как теперь закончить ожидание?
PS блин, да вы ниже сами все расписали с кодом! Зачем тут чушь пишите?
asdf404
16.03.2017 07:57Когда вызывается
return
или мы выходим из генератора, то всё равно возвращаетсяPromise
, содержащийIteratorResult
. Вот пример с кодом:
async function* values () { yield 1 yield 2 } function handle () { const iter = values() console.log(iter.next()) // Promise { value: 1, done: false } console.log(iter.next()) // Promise { value: 2, done: false } // следующего элемента нет console.log(iter.next()) // Promise { value: undefined, done: true } } handle()
Т.е. последний промис разрешается сразу и возвращает
done = true
. Вы можете запустить этот код и проверить самостоятельно.
Зачем тут чушь пишите?
В каком месте я написал чушь?
mayorovp
16.03.2017 08:30Это вы сейчас написали как
Promise<IteratorResult>
работает.
А я отвечал на вот этот вопрос:
Вы не могли бы ещё раз рассказать, почему альтернативный подход с IteratorResult не годится? Немного непонятно получилось в статье.
Альтернативный — это тот, при котором метод next() возвращает
IteratorResult<Promise>
.
Large
25.03.2017 12:43Загвоздки нет, просто код будет выглядеть менее уродски. Вот аналог с обычным итератором:
for(const taskPromise of queue) { const task = await taskPromise; task ? resolve(task) : break; }
Просто итератор будет бесконечный и проверку на выход прийдется делать руками каждый раз. В случае же асинхронного итератора у вас проверка будет спрятана в сам итератор и он не будет бесконечным.
asdf404
16.03.2017 02:25+3Давайте избавимся от
for-await-of
и посмотрим как это можно обработать вручную. Допустим, у нас есть удалённая очередьqueue
с несколькими элементами.
Пример дляIteratorResult<Promise>
:
const queue = ... // queue это итератор while (true) { const { value, done } = queue.next() // done всегда будет false, т.к. из генератора синхронно(!) мы не можем узнать закончилась ли очередь const result = await value // здесь мы дожидаемся разрешения value if (result === null) { break } // вот здесь нужно проверить, что нам вернулось пустое значение. Но дело в том, что null может быть вполне валидным значением, а не индикатором пустой очереди // обрабатываем result; следующая итерация }
Как видите, этот подход имеет недостаток — у нас нет четкого понимания, что очередь пуста. Мы не можем трактовать null как конец, если только не приняли некое соглашение, что null — это всегда конец очереди.
В случае с
Promise<IteratorResult>
всё несколько иначе:
const queue = ... while (true) { const { value, done } = await queue.next() // здесь у нас есть Promise, который возвращает текущее состояние итератора if (done) { break } // и есть четкое понимание когда стоит прекратить цикл // обрабатываем value; следующая итерация }
Т.е. при подходе
Promise<IteratorResult>
у нас есть возможность без всяких соглашений четко дать понять, что очередь пуста, можно выходить из цикла.queue
, например, может при каждом вызовеnext()
помимо получения элемента спрашивать у очереди сколько элементов осталось и при значении0
вернутьdone = true
, чтобы прервать цикл и не создать последующих запросов.popov654
16.03.2017 04:01Спасибо, почти понятно. А если сильно извратиться — можно ли чисто теоретически снаружи повлиять на наш генератор, основываясь на информации, которую вернёт Promise? Например, опять же, передавать помимо значения некий флаг конца в поле объекта.
Хотя понимаю, что это сильный костыль.
asdf404
16.03.2017 04:23А если сильно извратиться — можно ли чисто теоретически снаружи повлиять на наш генератор
Да, вы можете в
next()
передать какое-нибудь значение:
function* generator () { let value = yield 'Hi!' console.log('Hello %s!', value) } const iterator = generator() console.log(iterator.next().value) // выведет "Hi!" iterator.next('World') // выведет "Hello World!"
Таким образом можно передать генератору что угодно.
0x1000000
15.03.2017 21:02А можно ли будет применять функции map reduce filter и пр. над асинхронными итераторами? Получилась бы хорошая замена observable.
asdf404
16.03.2017 02:47Сейчас и для обычных итераторов их нельзя применить. Для этого сначала нужно преобразовать в массив (с помощью
Array.from
или spread оператора[ ...iterable ]
), а потом над ним уже совершать операции.
В качестве эксперимента я пишу библиотеку, которая добавляет эти методы прямо к итератору (изменяет его прототип, как делает SugarJS), но она далека от завершения, к тому же есть множество более качественных альтернатив: Wu, Lazy.JS и т.д.
Large
25.03.2017 13:05Можно написать простенький декоратор который будет это делать для синхронного или для асинхронного итератора. Пример map:
function functor(Target) { if(!Reflect.has(Target, Symbol.asycIterator)) throw new Error(`${Target} should be async iterable`); if(!Reflect.defineProperty(Target.prototype, 'map', { value: async function(transform) { const res = []; for await(const el of this) { res.push(transform(el)); } return res; } }) throw new Error(`${Target} already has a map method`); return Target; } @functor // или после объявления класса functor(Queue) если не хочется включать бабель для декоратора class Queue {...}
В данном примере map просто асинхронно выгребет коллекцию, применит к ней заданную трансформацию и вернет промис с результатом, но можно поведение усложнять и генерировать события на каждый приход элемента.
ObsSpace
А что по поддержке Chrome Canary?
asdf404
Судя по всему, поддержку недавно добавили в V8. Не использую Chrome, так что не могу проверить.