Menu
Русский
Попробовать в Playground

Итераторы и генераторы в JavaScript: function*, yield

Разбираемся, как устроен протокол итераторов в JavaScript, как сделать свой объект итерируемым и почему с генераторами вся эта история становится проще.

Протокол итератора

Многие возможности JavaScript — for...of, spread (...), деструктуризация, Array.from, Promise.all — работают на одном и том же механизме: протоколе итератора. Когда въезжаешь в эту идею, всё перечисленное начинает выглядеть как вариации одного и того же.

Итератор в javascript — это любой объект с методом next(), который возвращает { value, done }:

index.js
Output
Click Run to see the output here.

Вызывай next() сколько нужно. Каждый вызов возвращает очередное значение и флаг done. Когда done становится true — последовательность закончилась. Вот и весь протокол: четыре символа и булев флаг.

Iterable против Iterator

Рядом живёт ещё одно понятие. Iterable (итерируемый объект) — это всё, что умеет отдать итератор. Делает он это через метод с особым ключом: Symbol.iterator.

index.js
Output
Click Run to see the output here.

Массивы — это iterable-объекты. Вызов numbers[Symbol.iterator]() возвращает свежий итератор. Строки, Map, Set и arguments — тоже iterable, и именно поэтому for...of работает со всеми ними.

Важно чувствовать разницу: iterable — это сама коллекция, а iterator — это курсор по ней. У одного iterable можно запросить сколько угодно независимых курсоров.

Почему работает цикл for...of

Цикл for...of в javascript — это просто синтаксический сахар над протоколом итератора. Под капотом он вызывает Symbol.iterator, а затем дёргает next() до тех пор, пока done не станет true:

index.js
Output
Click Run to see the output here.

Spread и деструктуризация работают по одному и тому же принципу — они проходят по итератору до конца:

index.js
Output
Click Run to see the output here.

Любой объект, в котором реализован Symbol.iterator, автоматически получает доступ ко всем этим возможностям.

Свой итератор в javascript: пишем iterable-объект

Давайте сделаем объект range, который выдаёт числа от start до end:

index.js
Output
Click Run to see the output here.

На что здесь стоит обратить внимание:

  • [Symbol.iterator]() — это вычисляемое имя метода. Ключом выступает сам символ, а не строка "Symbol.iterator".
  • Каждый вызов [Symbol.iterator]() возвращает новый итератор со своим собственным current. Именно поэтому по range можно пройтись дважды — он не «израсходуется» после первого обхода.
  • Итератору достаточно одного метода next(). И всё.

Работает, но многословно. Есть способ куда приятнее.

Знакомьтесь — генераторы javascript

Функция-генератор объявляется через function* (обратите внимание на звёздочку). Вместо того чтобы выполниться целиком, она умеет приостанавливаться на выражении yield и продолжать работу позже. Её вызов не запускает тело функции — вместо этого возвращается объект-генератор, который одновременно является и итератором, и итерируемым объектом:

index.js
Output
Click Run to see the output here.

Каждый вызов next() выполняет тело функции до ближайшего yield, замораживает его и возвращает { value, done: false }. Когда функция отработала до конца, получаем { value: undefined, done: true }.

А раз генераторы — это iterable, они отлично дружат со всем, о чём шла речь в предыдущем разделе:

index.js
Output
Click Run to see the output here.

Переписываем range с помощью генератора

Сравните многословный вариант выше с этим:

index.js
Output
Click Run to see the output here.

Вот и всё. Звёздочка * перед [Symbol.iterator] превращает метод в генератор. А yield i заменяет весь самописный объект-итератор. Никаких next, никаких done, никаких рисков с off-by-one — просто обычный цикл, где вместо push стоит yield.

Ради этого генераторы и придуманы. Вместо «напиши итератор» получается «напиши функцию, которая yield-ит».

yield против return

yield приостанавливает выполнение, а return завершает его. Вызывать yield можно сколько угодно раз — генератор каждый раз продолжает работу с того места, где остановился:

index.js
Output
Click Run to see the output here.

return внутри генератора возвращается как { value: "done", done: true } в том вызове, который его завершает. Важный момент: for...of и спред игнорируют это возвращаемое значение — они забирают только элементы, у которых done равен false. Так что не пытайтесь протащить финальный элемент в цикл через return value — его просто пропустят.

Ленивые вычисления и бесконечные последовательности в JavaScript

Генераторы выдают значения по запросу, по одному за раз. Благодаря этому можно описывать такие последовательности, которые в виде массива существовать в принципе не могут:

index.js
Output
Click Run to see the output here.

Цикл буквально while (true), но программа всё равно завершается — генератор продвигается лишь тогда, когда у него запрашивают очередное значение. Можно взять первые N элементов и остановиться, а всё остальное просто не выполнится:

index.js
Output
Click Run to see the output here.

take сам по себе — тоже генератор, только обёрнутый вокруг другого. Именно в такой композиции и раскрывается сила генераторов javascript: маленькие кирпичики, каждый из которых делает что-то одно.

Делегирование через yield*

Если генератору нужно отдать наружу всё, что выдаёт другой итерируемый объект, на помощь приходит yield* — он делегирует выдачу:

index.js
Output
Click Run to see the output here.

yield* умеет работать с любым iterable — массивами, множествами, другими генераторами — и пробрасывает каждый элемент по одному. По сути, это тот же spread, только для итераторов.

Асинхронные генераторы, если коротко

Генератор, объявленный как async function*, может делать yield значений, которые приходят не сразу — удобно, когда нужно стримить данные из API или читать файл по кускам. Перебирать такой генератор нужно через for await...of:

async function* paginate(url) {
  let next = url;
  while (next) {
    const res = await fetch(next);
    const page = await res.json();
    for (const item of page.items) yield item;
    next = page.nextUrl;
  }
}

for await (const item of paginate("/api/users")) {
  console.log(item);
}

Этот пример здесь не запустить — ему нужен настоящий эндпоинт, — но полезно знать, что такая форма существует. Когда разберётесь с обычными генераторами, асинхронные — это ровно та же идея, только с вкраплениями await.

Когда стоит брать генератор

Генератор уместен, когда:

  • Последовательность бесконечна или потенциально бесконечна — идентификаторы, временные метки, задержки между повторными попытками.
  • Вычислить все значения дорого, а потребитель вполне может остановиться на полпути.
  • Вы реализуете Symbol.iterator на своём объекте. Так почти всегда получается короче, чем вручную собирать объект с методом next().
  • Нужно собрать цепочку потоковых преобразований (take, filter, map) без промежуточных массивов.

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

Дальше: символы

Symbol.iterator — первый символ, с которым обычно сталкиваются, но далеко не единственный. Символы — это примитивный тип, придуманный как раз для подобных точек расширения: уникальные ключи, через которые язык и ваш собственный код цепляются к объектам, не конфликтуя с обычными именами свойств. Об этом — на следующей странице.

Часто задаваемые вопросы

Чем итерируемый объект отличается от итератора в JavaScript?

Итерируемый объект (iterable) — это любой объект, у которого есть метод Symbol.iterator, возвращающий итератор. А итератор — это уже та штука, которая реально выдаёт значения: у неё есть метод next(), возвращающий { value, done }. Массивы, строки, Map и Set — это iterable; если вызвать у них Symbol.iterator, получите итератор, по которому можно шагать.

Что такое функция-генератор в JavaScript?

Это функция, объявленная через function*, которая лениво выдаёт значения с помощью yield. Важный момент: сам вызов такой функции не запускает её тело — он возвращает объект-генератор, который одновременно является и итератором, и итерируемым. Каждый вызов next() прогоняет код до следующего yield, ставит его на паузу и возвращает полученное значение.

В чём разница между yield и return внутри генератора?

yield ставит генератор на паузу и отдаёт значение наружу, но функция может продолжить работу с того же места при следующем next(). А return завершает генератор окончательно — выставляет done: true, и больше значений из него не вытянешь. yield можно делать сколько угодно раз, а осмысленный return — только один.

Когда выбирать генератор вместо обычного массива?

Когда последовательность бесконечна, её дорого вычислять целиком или вам нужна лишь часть значений. Генератор выдаёт элементы по одному по запросу — так можно описать бесконечный поток ID или постраничные ответы API, не материализуя всё сразу в памяти. Если же у вас и так небольшой фиксированный массив — просто используйте массив, не усложняйте.

Учитесь программировать с Coddy

НАЧАТЬ