В этом небольшом посте я хочу рассказать об одном интересном предложении (англ. 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:


package.json
{
  "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)


  1. ObsSpace
    15.03.2017 15:30

    А что по поддержке Chrome Canary?


    1. asdf404
      15.03.2017 15:37

      Судя по всему, поддержку недавно добавили в V8. Не использую Chrome, так что не могу проверить.


  1. popov654
    15.03.2017 17:21

    Вы не могли бы ещё раз рассказать, почему альтернативный подход с IteratorResult не годится? Немного непонятно получилось в статье.


    1. Mingun
      15.03.2017 18:48
      +1

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


      1. popov654
        15.03.2017 20:27

        Всё равно не понял)) Если без метафор, то в чём загвоздка?


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

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


        1. mayorovp
          15.03.2017 21:37

          А снаружи-то как вовремя узнать что больше элементов не будет?


          Чему будет равно свойство done итератора во время выполнения оператора await в генераторе?


          1. asdf404
            16.03.2017 03:32
            -1

            А снаружи-то как вовремя узнать что больше элементов не будет?

            Снаружи "вовремя" будет тогда, когда зарезолвится промис, т.к. пока это не произойдёт ваш цикл не сможет пойти дальше. А когда промис резолвится, то он возвращает состояние итератора, где указано завершился итератор или нет.


            Чему будет равно свойство done итератора во время выполнения оператора await в генераторе?

            Оно будет равно false. Станет равным true лишь при выходе из функции-генератора явно (по return) или неявно (кончилось тело функции).


            1. mayorovp
              16.03.2017 07:04

              Вот смотрите, проверили мы done. Оно равно false. Мы получили очередной промиз и начали его ждать.


              А следующего элемента-то и нет! Как теперь закончить ожидание?


              PS блин, да вы ниже сами все расписали с кодом! Зачем тут чушь пишите?


              1. 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. Вы можете запустить этот код и проверить самостоятельно.


                Зачем тут чушь пишите?

                В каком месте я написал чушь?


                1. mayorovp
                  16.03.2017 08:30

                  Это вы сейчас написали как Promise<IteratorResult> работает.


                  А я отвечал на вот этот вопрос:


                  Вы не могли бы ещё раз рассказать, почему альтернативный подход с IteratorResult не годится? Немного непонятно получилось в статье.

                  Альтернативный — это тот, при котором метод next() возвращает IteratorResult<Promise>.


        1. Large
          25.03.2017 12:43

          Загвоздки нет, просто код будет выглядеть менее уродски. Вот аналог с обычным итератором:

          for(const taskPromise of queue) {
            const task = await taskPromise;
            task ? resolve(task) : break;
          }
          

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


    1. 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, чтобы прервать цикл и не создать последующих запросов.


      1. popov654
        16.03.2017 04:01

        Спасибо, почти понятно. А если сильно извратиться — можно ли чисто теоретически снаружи повлиять на наш генератор, основываясь на информации, которую вернёт Promise? Например, опять же, передавать помимо значения некий флаг конца в поле объекта.


        Хотя понимаю, что это сильный костыль.


        1. 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!"

          Таким образом можно передать генератору что угодно.


  1. 0x1000000
    15.03.2017 21:02

    А можно ли будет применять функции map reduce filter и пр. над асинхронными итераторами? Получилась бы хорошая замена observable.


    1. mayorovp
      15.03.2017 21:39

      Нет. Но наверняка там появятся асинхронные версии этих функций.


    1. asdf404
      16.03.2017 02:47

      Сейчас и для обычных итераторов их нельзя применить. Для этого сначала нужно преобразовать в массив (с помощью Array.from или spread оператора [ ...iterable ]), а потом над ним уже совершать операции.
      В качестве эксперимента я пишу библиотеку, которая добавляет эти методы прямо к итератору (изменяет его прототип, как делает SugarJS), но она далека от завершения, к тому же есть множество более качественных альтернатив: Wu, Lazy.JS и т.д.


    1. 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 просто асинхронно выгребет коллекцию, применит к ней заданную трансформацию и вернет промис с результатом, но можно поведение усложнять и генерировать события на каждый приход элемента.