JavaScript итераторы и генераторы: Полное руководство

Источник: «JavaScript iterators and generators: A complete guide»
С появлением ES6 итераторы и генераторы были официально добавлены в JavaScript.

Итераторы позволяют выполнять итерацию над любым объектом, соответствующим спецификации. В первом разделе мы рассмотрим, как использовать итераторы и сделать любой объект итерируемым.

Вторая часть этой статьи полностью посвящена генераторам: что это такое, как их использовать и в каких ситуациях они могут быть полезны.

Мне всегда нравится смотреть на то, как все работает под капотом: в предыдущей серии статей я рассказал о том, как JavaScript работает в браузере. В продолжение этой темы в данной статье я хочу рассказать о том, как работают итераторы и генераторы JavaScript.

Что такое итераторы

Прежде чем разбираться в генераторах, необходимо хорошо понять итераторы в JavaScript, поскольку эти два понятия идут рука об руку. После этого раздела станет ясно, что генераторы — это просто способ более безопасного написания итераторов.

Как видно из названия, итераторы позволяют выполнять итерацию по объекту (массивы также являются объектами).

Скорее всего, вы уже использовали итераторы в JavaScript. Например, каждый раз, когда вы выполняли итерацию по массиву, вы использовали итераторы, но вы также можете выполнять итерацию по объектам Map и даже по строкам.

for (let i of 'abc') {
console.log(i);
}

// Вывод
// "a"
// "b"
// "c"

Любой объект, реализующий протокол iterable, может быть итерирован с помощью for...of.

Если копнуть ещё глубже, то любой объект можно сделать итерируемым, реализовав функцию @@iterator, которая возвращает объект-итератор.

Делаем любой объект итерируемым

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

Начнём с объекта, содержащего имена пользователей, сгруппированные по городам:

const userNamesGroupedByLocation = {
Tokio: [
'Aiko',
'Chizu',
'Fushigi',
],
'Buenos Aires': [
'Santiago',
'Valentina',
'Lola',
],
'Saint Petersburg': [
'Sonja',
'Dunja',
'Iwan',
'Tanja',
],
};

Я привёл этот пример потому, что при такой структуре данных нелегко перебрать всех пользователей; для этого нам потребуется несколько циклов, чтобы получить всех пользователей.

Если мы попытаемся выполнить итерацию над этим объектом в его нынешнем виде, то получим следующее сообщение об ошибке:

Uncaught ReferenceError: iterator is not defined

Чтобы сделать этот объект итерируемым, сначала нужно добавить функцию @@iterator. Доступ к этому символу мы можем получить через Symbol.iterator.

userNamesGroupedByLocation[Symbol.iterator] = function() {
// ...
}

Как я уже говорил, функция итератора возвращает объект итератора. Этот объект содержит функцию next, которая также возвращает объект с двумя атрибутами: done и value.

userNamesGroupedByLocation[Symbol.iterator] = function() {
return {
next: () => {
return {
done: true,
value: 'hi',
};
},
};
}

value содержит текущее значение итерации, а done — логическое значение, указывающее, завершилось ли выполнение.

При реализации этой функции нужно быть особенно внимательным к значению done, так как постоянный возврат false приведёт к бесконечному циклу.

Приведённый пример кода уже представляет собой корректную реализацию протокола iterable. Мы можем проверить его, вызвав функцию next объекта итератора.

// Вызов функции итератора возвращает объект итератора
const iterator = userNamesGroupedByLocation[Symbol.iterator]();
console.log(iterator.next().value);
// "hi"

Итерация над объектом с помощью "for...of" использует функцию next под капотом.

Использование "for...of" в данном случае ничего не вернёт, поскольку мы сразу установим значение done в false. Кроме того, при такой реализации мы не получим никаких имён пользователей, а ведь именно поэтому мы хотели сделать этот объект итерируемым.

Реализация функции итератора

Прежде всего, нам необходимо получить доступ к ключам объекта, представляющим города. Мы можем получить его, вызвав Object.keys по ключевому слову this, ссылающемуся на родителя функции, которым в данном случае является объект userNamesGroupedByLocation.

Доступ к ключам через this возможен только в том случае, если мы определили итерируемую функцию с помощью ключевого слова function. Если бы мы использовали стрелочную функцию, это бы не сработало, поскольку они наследуют область видимости своего родителя.

const cityKeys = Object.keys(this);

Также необходимы две переменные, отслеживающие количество итераций.

let cityIndex = 0;
let userIndex = 0;

Мы определяем эти переменные в функции итератора, но вне функции next, что позволяет нам сохранять данные между итерациями.

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

Теперь мы можем использовать эти данные для изменения возвращаемого значения.

return {
next: () => {
const users = this[cityKeys[cityIndex]];
const user = users[userIndex];

return {
done: false,
value: user,
};
},
};

Далее необходимо увеличивать индексы при каждой итерации.

Мы каждый раз увеличиваем индекс пользователя, если только мы не пришли к последнему пользователю данного города, в этом случае мы установим userIndex в 0 и вместо этого увеличим индекс города.

return {
next: () => {
const users = this[cityKeys[cityIndex]];
const user = users[userIndex];

const isLastUser = userIndex >= users.length - 1;
if (isLastUser) {
// Сброс индекса пользователя
userIndex = 0;
// Переход к следующему городу
cityIndex++
} else {
userIndex++;
}

return {
done: false,
value: user,
};
},
};

Будьте осторожны и не выполняйте итерации по этому объекту с помощью "for...of". Учитывая, что значение done всегда равно false, это приведёт к бесконечному циклу.

Последнее, что нам нужно добавить, — это условие выхода, которое устанавливает значение done в true. Мы выйдем из цикла после того, как выполним итерацию по всем городам.

if (cityIndex > cityKeys.length - 1) {
return {
value: undefined,
done: true,
};
}

После того как все собрано воедино, наша функция выглядит следующим образом:

userNamesGroupedByLocation[Symbol.iterator] = function() {
const cityKeys = Object.keys(this);
let cityIndex = 0;
let userIndex = 0;

return {
next: () => {
// Мы уже выполнили итерацию по всем городам
if (cityIndex > cityKeys.length - 1) {
return {
value: undefined,
done: true,
};
}

const users = this[cityKeys[cityIndex]];
const user = users[userIndex];

const isLastUser = userIndex >= users.length - 1;

userIndex++;
if (isLastUser) {
// Сброс индекса пользователя
userIndex = 0;
// Переход к следующему городу
cityIndex++
}

return {
done: false,
value: user,
};
},
};
};

Это позволяет нам быстро получить все имена из нашего объекта с помощью цикла "for...of".

for (let name of userNamesGroupedByLocation) {
console.log('name', name);
}

// Вывод:
// name Aiko
// name Chizu
// name Fushigi
// name Santiago
// name Valentina
// name Lola
// name Sonja
// name Dunja
// name Iwan
// name Tanja

Как видите, сделать объект итерируемым — это не магия. Однако делать это нужно очень аккуратно, так как ошибки в функции next могут легко привести к бесконечному циклу.

Если вы хотите узнать больше об этом поведении, я рекомендую вам попробовать сделать итерируемым какой-либо объект по вашему выбору. Исполняемую версию кода из этого руководства вы можете найти на этом codepen.

Подводя итог тому, что мы сделали для создания итерируемого объекта, можно привести следующие шаги:

Что такое генераторы

Мы узнали, как сделать любой объект итерируемым, но как это связано с генераторами?

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

Генераторы — это полезный инструмент, позволяющий создавать итераторы путём определения функции.

Такой подход менее подвержен ошибкам и позволяет создавать итераторы более эффективно.

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

Объявление функции генератора

Создание функции генератора очень похоже на создание обычных функций. Все, что нам нужно сделать, это добавить звёздочку (*) перед именем.

function *generator() {
// ...
}

Если мы хотим создать анонимную функцию генератор, то эта звёздочка перемещается в конец ключевого слова function.

function* () {
// ...
}

Использование ключевого слова yield

Объявление функции генератора — это только половина работы и само по себе не очень полезно.

Как уже говорилось, генераторы — это более простой способ создания итераторов. Но как итератор узнает, над какой частью функции ему следует выполнить итерацию? Должен ли он выполнять итерацию над каждой строкой?

Именно в этом случае в дело вступает ключевое слово yield. Его можно представить как ключевое слово await, знакомое вам по JavaScript Promises, но для генераторов.

Мы можем добавить это ключевое слово в каждую строку, где хотим остановить итерацию. Тогда функция next будет возвращать результат утверждения этой строки как часть объекта итератора ({ done: false, value: 'something' }).

function* stringGenerator() {
yield 'hi';
yield 'hi';
yield 'hi';
}

const strings = stringGenerator();

console.log(strings.next());
console.log(strings.next());
console.log(strings.next());
console.log(strings.next());

Вывод этого кода будет следующим:

{value: "hi", done: false}
{value: "hi", done: false}
{value: "hi", done: false}
{value: undefined, done: true}

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

Когда функция доходит до конца, value становится равной undefined, а done автоматически устанавливается в true.

Использование yield*

Если к ключевому слову yield добавить звёздочку, то мы делегируем выполнение другому объекту итератора.

Например, мы можем использовать его для делегирования другой функции или массива:

function* nameGenerator() {
yield 'Iwan';
yield 'Aiko';
}

function* stringGenerator() {
yield* nameGenerator();
yield* ['one', 'two'];
yield 'hi';
yield 'hi';
yield 'hi';
}

const strings = stringGenerator();

for (let value of strings) {
console.log(value);
}

Код выдаёт следующий результат:

Iwan
Aiko
one
two
hi
hi
hi

Передача значений в генераторы

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

Используя предыдущий пример, мы можем переопределить значение, которое yield вернул бы в противном случае.

function* overrideValue() {
const result = yield 'hi';
console.log(result);
}

const overrideIterator = overrideValue();
overrideIterator.next();
overrideIterator.next('bye');

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

Методы генератора

Помимо метода "next", который необходим любому итератору, генераторы также предоставляют функции return и throw.

Функция return

Вызов return вместо next в итераторе приведёт к выходу из цикла на следующей итерации.

Каждая итерация, следующая за вызовом return, будет устанавливать значение done в true, а значение — в undefined.

Если передать в эту функцию значение, то оно заменит атрибут value на объекте итератора.

Этот пример из документации Web MDN прекрасно это иллюстрирует:

function* gen() {
yield 1;
yield 2;
yield 3;
}

const g = gen();

g.next(); // { value: 1, done: false }
g.return('foo'); // { value: "foo", done: true }
g.next(); // { value: undefined, done: true }

Функция throw

Генераторы также реализуют функцию throw, которая вместо продолжения цикла выдаёт ошибку и завершает выполнение:

function* errorGenerator() {
try {
yield 'one';
yield 'two';
} catch(e) {
console.error(e);
}
}

const errorIterator = errorGenerator();

console.log(errorIterator.next());
console.log(errorIterator.throw('Bam!'));

Вывод приведённого выше кода выглядит следующим образом:

{value: 'one', done: false}
Bam!
{value: undefined, done: true}

Если мы попытаемся продолжить итерацию после выброса ошибки, то возвращаемое значение будет undefined, а значение done будет установлено в true.

Зачем нужны генераторы

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

Однако в некоторых случаях эта возможность очень полезна. Как правило, в таких случаях используется возможность приостанавливать и возобновлять выполнение генераторов.

Генератор уникальных ID

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

Генерирование уникальных и инкрементных идентификаторов требует отслеживания сгенерированных идентификаторов.

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

Каждый раз, когда вам нужен новый идентификатор, вы можете вызвать следующую функцию, а генератор позаботится обо всем остальном:

function* idGenerator() {
let i = 0;
while (true) {
yield i++;
}
}

const ids = idGenerator();

console.log(ids.next().value); // 0
console.log(ids.next().value); // 1
console.log(ids.next().value); // 2
console.log(ids.next().value); // 3
console.log(ids.next().value); // 4

Спасибо, Nick, за идею.

Другие варианты применения генераторов

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

Достаточно много библиотек также используют генераторы, например, Mobx-State-Tree или Redux-Saga.

Заключение

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

В этой статье мы узнали об итераторах и о том, как сделать любой объект итерируемым. Во втором разделе мы узнали, что такое генераторы, как их использовать и в каких ситуациях они могут пригодиться.

Если вы хотите узнать больше о том, как JavaScript работает под капотом, вы можете ознакомиться с серией моих статей о работе JavaScript в браузере, в которых рассказывается о цикле событий и управлении памятью JavaScript.

Дополнительная информация:

Дополнительные материалы

Предыдущая Статья

Написание CSS в 2023 году: Отличается ли он от того, что было раньше?

Следующая Статья

6 лучших практик Laravel RESTful APIs