Главная » Статьи » Итераторы и генераторы JavaScript: полное руководство

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

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

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

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

Прежде чем мы сможем понять генераторы, нам необходимо доскональное понимание итераторов в JavaScript, поскольку эти две концепции тесно связаны между собой. После этого раздела станет ясно, что генераторы — это просто способ более безопасного написания итераторов. Как уже понятно из названия, итераторы позволяют выполнять итерацию по объекту (массивы также являются объектами). Скорее всего, вы уже использовали итераторы JavaScript. Каждый раз, когда вы выполняете итерацию по массиву, например, вы использовали итераторы, но вы также можете выполнять итерацию по объектам Map и даже по строкам.

for (let i of 'abc') { console.log(i);
} // Output
// "a"
// "b"
// "c"

Для любого объекта, реализующего итеративный протокол, можно выполнить итерацию, используя «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, что приведет к бесконечному циклу. В приведенном выше примере кода уже представлена правильная реализация итеративного протокола. Мы можем проверить это, вызвав функцию next объекта-итератора.

// Calling the iterator function returns the iterator object
const iterator = userNamesGroupedByLocation[Symbol.iterator]();
console.log(iterator.next().value);
// "hi"

Обход объекта с помощью «for… of» неявно использует функцию next. Использование «for… of» в этом случае ничего не вернет, потому что мы сразу установили для done значение false. Мы также не получаем никаких имен пользователей, реализуя его таким образом, поэтому мы изначально хотели сделать этот объект iterable.

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

Прежде всего, нам нужно получить доступ к ключам объекта, представляющего города. Мы можем получить это, вызвав 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) { // Reset user index userIndex = 0; // Jump to next city 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: () => { // We already iterated over all cities 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) { // Reset user index userIndex = 0; // Jump to next city cityIndex++ } return { done: false, value: user, }; }, };
};

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

for (let name of userNamesGroupedByLocation) { console.log('name', name);
} // Output:
// name Aiko
// name Chizu
// name Fushigi
// name Santiago
// name Valentina
// name Lola
// name Sonja
// name Dunja
// name Iwan
// name Tanja

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

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

Добавьте к объекту функцию итератора с помощью ключа @@iterator (доступного через Symbol.iterator)

Эта функция возвращает объект, который включает функцию next

Функция next возвращает объект с атрибутами done и value.

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

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

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

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

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

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

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

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

function* () { // ...
}

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

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

Вот где в игру вступает ключевое слово yield. Мы можем добавить это ключевое слово в каждую строку, где мы хотим, чтобы итерация остановилась. Функция 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 вместо next на итераторе приведет к завершению цикла на следующей итерации. Каждая итерация, которая происходит после вызова return, установит для done значение true, а для value значение 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}

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

Зачем использовать генераторы?

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

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

Генератор уникальных идентификаторов

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

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

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

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

Другие варианты использования генераторов

Есть много других вариантов использования. Многие библиотеки используют генераторы, например, Mobx-State-Tree или Redux-Saga.

Заключение

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

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

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

Автор: Felix Gerschau

Источник: blog.logrocket.com

Редакция: Команда webformyself.

Читайте нас в Telegram, VK, Яндекс.Дзен