Новинки JavaScript: Асинхронные итераторы

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

    На этом всё. Надеюсь эта статья была интересна и хоть в какой-то мере полезна. Спасибо за внимание.


    Ссылки


    Поделиться публикацией

    Похожие публикации

    Комментарии 22

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

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

        0

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

          +1

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

            0

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


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

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

              0

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


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

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

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


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

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

                  0

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


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


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

                    0

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


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

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

                      0

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


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


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

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

                0
                Загвоздки нет, просто код будет выглядеть менее уродски. Вот аналог с обычным итератором:
                for(const taskPromise of queue) {
                  const task = await taskPromise;
                  task ? resolve(task) : break;
                }
                

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

                0

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


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

                  0
                  А если сильно извратиться — можно ли чисто теоретически снаружи повлиять на наш генератор

                  Да, вы можете в 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!"

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

              0

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

                0

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

                  0

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

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

                      Лучше уж прототип заменить, чем патчить каждый объект...

                        –1
                        Что в коде и делается. Там одна описка при проверке на итератор тоже нужно проверять прототип. А так как раз изменяется прототип класса, а не каждый объект.
                          +1

                          А, так вы там класс патчите, а не метод. Да, тогда все правильно.


                          Хотя можно было бы обойтись просто базовым классом в таком случае.

                            –1
                            ну посмотрите, там ведь так и написано декоратор применяется к классу.

                            нет нельзя, класс может наследоваться от чего-то другого, так что нужен именно декоратор-миксин.

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

                  Самое читаемое