Пошук уроків, статей та іншого контенту
Дізнаєтеся, як працюють ітерабельні об’єкти, Symbol.iterator та ручне проходження колекцій.
У JavaScript ітерація — це стандартизований спосіб послідовно отримувати значення з об’єкта.
Завдяки протоколу ітерації однаковий синтаксис працює з різними структурами:
for (const value of collection) {
console.log(value);
}Цей код може перебирати:
масиви;
рядки;
Set;
Map;
генератори;
об’єкти, які реалізують власний протокол ітерації.
Важливо розрізняти два поняття:
ітерабельний об’єкт — об’єкт, який може надати ітератор;
ітератор — об’єкт, який повертає наступне значення під час проходження колекції.
Ітератор — це об’єкт із методом next().
Кожен виклик next() повертає об’єкт такого формату:
{
value: any,
done: boolean
}value — поточне значення;
done — ознака завершення ітерації.
Розглянемо простий ітератор:
function createCounter(max) {
let current = 0;
return {
next() {
if (current < max) {
return {
value: current++,
done: false
};
}
return {
value: undefined,
done: true
};
}
};
}
const iterator = createCounter(3);
console.log(iterator.next()); // { value: 0, done: false }
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: undefined, done: true }
console.log(iterator.next()); // { value: undefined, done: true }Після завершення ітератор зазвичай залишається завершеним. Повторний виклик next() не починає проходження спочатку.
Ітерабельний об’єкт повинен мати метод із ключем Symbol.iterator.
Цей метод без аргументів повертає ітератор:
const iterable = {
[Symbol.iterator]() {
return {
next() {
return {
value: "значення",
done: true
};
}
};
}
};Об’єкт із методом Symbol.iterator можна використовувати з for...of:
const numbers = {
values: [10, 20, 30],
[Symbol.iterator]() {
let index = 0;
const values = this.values;
return {
next() {
if (index < values.length) {
return {
value: values[index++],
done: false
};
}
return {
value: undefined,
done: true
};
}
};
}
};
for (const number of numbers) {
console.log(number);
}
// 10
// 20
// 30У цьому прикладі:
numbers є ітерабельним об’єктом;
numbers[Symbol.iterator]() повертає ітератор;
for...of викликає next() доти, доки done не стане true.
Ітератор і ітерабельний об’єкт — не обов’язково одне й те саме.
Наприклад, масив є ітерабельним:
const array = [1, 2, 3];
console.log(typeof array[Symbol.iterator]); // "function"А результат виклику array[Symbol.iterator]() є ітератором:
const iterator = array[Symbol.iterator]();
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }Вбудовані ітератори масивів також самі є ітерабельними. Це можна перевірити так:
const iterator = [1, 2, 3][Symbol.iterator]();
console.log(iterator[Symbol.iterator]() === iterator); // trueЦе означає, що ітератор можна передавати в конструкції, які очікують ітерабельний об’єкт:
const iterator = [1, 2, 3][Symbol.iterator]();
console.log([...iterator]); // [1, 2, 3]Однак після проходження ітератора він буде вичерпаний:
const iterator = [1, 2, 3][Symbol.iterator]();
console.log([...iterator]); // [1, 2, 3]
console.log([...iterator]); // []Symbol.iteratorSymbol.iterator — це вбудований символ, який позначає метод отримання синхронного ітератора.
Він використовується багатьма мовними конструкціями:
for...of;
оператором розпилення ...;
деструктуризацією масивів;
конструктором Array.from;
конструкторами new Map() і new Set();
методом yield* у генераторах.
Наприклад, оператор розпилення фактично проходить ітерабельний об’єкт:
const values = new Set([1, 2, 3]);
const array = [...values];
console.log(array); // [1, 2, 3]Якщо об’єкт не є ітерабельним, така операція завершиться помилкою:
const object = {
first: 1,
second: 2
};
// TypeError: object is not iterable
const values = [...object];Звичайний об’єкт не стає ітерабельним лише через наявність властивостей. Для цього потрібно явно реалізувати Symbol.iterator.
Іноді потрібно отримувати значення вручну, а не використовувати for...of.
const values = ["a", "b", "c"];
const iterator = values[Symbol.iterator]();
let step = iterator.next();
while (!step.done) {
console.log(step.value);
step = iterator.next();
}Цей код еквівалентний простому for...of:
for (const value of values) {
console.log(value);
}Ручна робота з ітератором потрібна, коли:
потрібно зупинити проходження за складною умовою;
потрібно комбінувати кілька ітераторів;
потрібно явно контролювати стан обходу;
створюється власна абстракція над ітерацією;
потрібно обробляти return() або throw() ітератора.
Під час ручного проходження слід пам’ятати, що next() може повертати довільні значення, але властивості done та value повинні відповідати протоколу ітератора.
Розглянемо клас Range, який генерує числа в заданому діапазоні:
class Range {
constructor(start, end, step = 1) {
if (!Number.isInteger(start) || !Number.isInteger(end)) {
throw new TypeError("Початок і кінець мають бути цілими числами");
}
if (!Number.isInteger(step) || step === 0) {
throw new TypeError("Крок має бути ненульовим цілим числом");
}
if (step > 0 && start > end) {
throw new RangeError("Для додатного кроку start має бути не більшим за end");
}
if (step < 0 && start < end) {
throw new RangeError("Для від’ємного кроку start має бути не меншим за end");
}
this.start = start;
this.end = end;
this.step = step;
}
*[Symbol.iterator]() {
if (this.step > 0) {
for (let value = this.start; value <= this.end; value += this.step) {
yield value;
}
} else {
for (let value = this.start; value >= this.end; value += this.step) {
yield value;
}
}
}
}
const ascending = new Range(1, 5, 2);
console.log([...ascending]); // [1, 3, 5]
const descending = new Range(5, 1, -2);
console.log([...descending]); // [5, 3, 1]
for (const value of ascending) {
console.log(value);
}Метод із синтаксисом *[Symbol.iterator]() є генератором. Він автоматично створює ітератор і реалізує необхідну поведінку next().
Генератор — це спеціальна функція, оголошена за допомогою function*.
Виклик генератора не виконує його тіло одразу. Замість цього він повертає об’єкт-генератор, який є ітератором:
function* colors() {
yield "red";
yield "green";
yield "blue";
}
const iterator = colors();
console.log(iterator.next()); // { value: "red", done: false }
console.log(iterator.next()); // { value: "green", done: false }
console.log(iterator.next()); // { value: "blue", done: false }
console.log(iterator.next()); // { value: undefined, done: true }Ключове слово yield:
повертає значення назовні;
призупиняє виконання генератора;
дозволяє продовжити виконання з того самого місця під час наступного next().
Генератори значно спрощують створення складних ітераторів.
Результат next(value) стає значенням попереднього виразу yield:
function* conversation() {
const name = yield "Як вас звати?";
const language = yield `Приємно познайомитися, ${name}`;
return `Ви вивчаєте ${language}`;
}
const generator = conversation();
console.log(generator.next());
// { value: "Як вас звати?", done: false }
console.log(generator.next("Олена"));
// { value: "Приємно познайомитися, Олена", done: false }
console.log(generator.next("JavaScript"));
// { value: "Ви вивчаєте JavaScript", done: true }Перший виклик next() не може передати корисне значення, оскільки виконання генератора ще не призупинене на yield.
return генератораЗначення, повернуте через return, буде присутнім у результаті останнього ручного виклику next():
function* example() {
yield 1;
return 2;
}
const iterator = example();
console.log(iterator.next());
// { value: 1, done: false }
console.log(iterator.next());
// { value: 2, done: true }Проте for...of ігнорує фінальне значення, коли done дорівнює true:
function* example() {
yield 1;
return 2;
}
for (const value of example()) {
console.log(value);
}
// 1yield* і делегування ітераціїОператор yield* передає керування іншому ітерабельному об’єкту:
function* numbers() {
yield* [1, 2, 3];
yield* new Set([4, 5]);
yield 6;
}
console.log([...numbers()]); // [1, 2, 3, 4, 5, 6]Це корисно для об’єднання кількох джерел даних:
function* chain(...iterables) {
for (const iterable of iterables) {
yield* iterable;
}
}
const result = chain(
[1, 2],
new Set([3, 4]),
"56"
);
console.log([...result]); // [1, 2, 3, 4, "5", "6"]yield* працює не лише з генераторами, а з будь-якими ітерабельними об’єктами.
Ітерабельний об’єкт може:
повертати новий ітератор під час кожного виклику Symbol.iterator;
повертати один і той самий ітератор;
бути нескінченним;
мати стан, спільний для всіх проходжень.
Зазвичай колекції на кшталт масивів є повторно ітерабельними:
const values = [1, 2, 3];
console.log([...values]); // [1, 2, 3]
console.log([...values]); // [1, 2, 3]Кожен виклик values[Symbol.iterator]() створює новий ітератор.
Генератор, навпаки, є одноразовим ітератором:
function* values() {
yield 1;
yield 2;
yield 3;
}
const generator = values();
console.log([...generator]); // [1, 2, 3]
console.log([...generator]); // []Якщо об’єкт має незалежний стан для кожного проходження, його можна обходити повторно. Якщо стан зберігається в самому ітераторі, після завершення проходження він стає порожнім.
Ітератор не зобов’язаний коли-небудь повертати done: true.
Наприклад, генератор може створювати нескінченну послідовність:
function* integers(start = 0) {
let value = start;
while (true) {
yield value++;
}
}
const iterator = integers(10);
for (const value of iterator) {
console.log(value);
if (value === 14) {
break;
}
}Без break цей цикл ніколи не завершиться.
Нескінченні ітератори потрібно використовувати обережно з:
Array.from;
оператором ...;
деструктуризацією без обмеження;
функціями, які очікують завершення ітерації.
Наприклад, цей код ніколи не завершиться:
// Не запускайте цей код без обмеження кількості значень
// const array = [...integers()];Для роботи з нескінченними джерелами зазвичай створюють обмежувальний ітератор:
function* take(iterable, count) {
if (count < 0) {
throw new RangeError("Кількість значень не може бути від’ємною");
}
let taken = 0;
for (const value of iterable) {
if (taken >= count) {
return;
}
yield value;
taken++;
}
}
console.log([...take(integers(100), 5)]);
// [100, 101, 102, 103, 104]return()Ітератор може мати метод return(). Він викликається, коли ітерація завершується достроково, наприклад через break, return або виняток у for...of.
Це дає змогу звільнити ресурси або виконати очищення:
const resource = {
[Symbol.iterator]() {
let current = 1;
return {
next() {
if (current <= 5) {
return {
value: current++,
done: false
};
}
return {
value: undefined,
done: true
};
},
return() {
console.log("Ітерацію завершено достроково");
return {
value: undefined,
done: true
};
}
};
}
};
for (const value of resource) {
console.log(value);
if (value === 2) {
break;
}
}
// 1
// 2
// Ітерацію завершено достроковоfor...of намагається коректно завершити ітератор. Це особливо важливо для ітераторів, які пов’язані з ресурсами або мають внутрішній стан.
Під час звичайного завершення через done: true метод return() не потрібен.
throw() у генераторівГенератор має метод throw(), який дозволяє передати виняток у точку, де генератор був призупинений:
function* process() {
try {
yield "Початок";
yield "Продовження";
} catch (error) {
console.log(`Помилка оброблена: ${error.message}`);
}
yield "Завершення";
}
const generator = process();
console.log(generator.next());
// { value: "Початок", done: false }
console.log(generator.throw(new Error("Некоректні дані")));
// Помилка оброблена: Некоректні дані
// { value: "Завершення", done: false }
console.log(generator.next());
// { value: undefined, done: true }Цей механізм корисний для складних генераторів, але в прикладному коді часто достатньо обробляти помилки навколо самої операції ітерації.
Map і SetЗа замовчуванням масив ітерується за значеннями:
const values = ["a", "b", "c"];
for (const value of values) {
console.log(value);
}Для індексів і значень можна використати entries():
const values = ["a", "b", "c"];
for (const [index, value] of values.entries()) {
console.log(index, value);
}Масив також має ітератори:
values() — значення;
keys() — індекси;
entries() — пари [індекс, значення].
const values = ["x", "y"];
console.log([...values.keys()]); // [0, 1]
console.log([...values.values()]); // ["x", "y"]
console.log([...values.entries()]); // [[0, "x"], [1, "y"]]SetSet ітерується своїми значеннями:
const uniqueValues = new Set([10, 20, 10, 30]);
for (const value of uniqueValues) {
console.log(value);
}
// 10
// 20
// 30У Set методи values() і keys() поводяться однаково, оскільки структура зберігає лише значення:
const set = new Set(["a", "b"]);
console.log([...set.keys()]); // ["a", "b"]
console.log([...set.values()]); // ["a", "b"]MapMap за замовчуванням ітерується парами [ключ, значення]:
const users = new Map([
[1, "Олена"],
[2, "Тарас"]
]);
for (const [id, name] of users) {
console.log(`${id}: ${name}`);
}Map має такі основні ітератори:
keys() — ключі;
values() — значення;
entries() — пари.
console.log([...users.keys()]); // [1, 2]
console.log([...users.values()]); // ["Олена", "Тарас"]
console.log([...users.entries()]); // [[1, "Олена"], [2, "Тарас"]]Рядки є ітерабельними. Ітерація рядка відбувається за Unicode-кодпоінтами, а не просто за UTF-16-кодами.
const text = "A😀B";
console.log([...text]);
// ["A", "😀", "B"]
for (const character of text) {
console.log(character);
}Це відрізняється від безпосереднього доступу за індексом:
const text = "😀";
console.log(text.length); // 2
console.log([...text].length); // 1Емодзі 😀 займає дві UTF-16 кодові одиниці, але під час ітерації сприймається як один Unicode-кодпойнт.
Деструктуризація масиву також використовує протокол ітерації:
const values = new Set([10, 20, 30]);
const [first, second] = values;
console.log(first); // 10
console.log(second); // 20Оператор залишку отримує всі наступні значення ітератора:
const [first, ...rest] = "hello";
console.log(first); // "h"
console.log(rest); // ["e", "l", "l", "o"]Для об’єктів це не працює автоматично:
const user = {
name: "Олена",
age: 28
};
// TypeError: user is not iterable
// const [name, age] = user;Деструктуризація властивостей об’єкта має інший протокол і не пов’язана з Symbol.iterator:
const { name, age } = user;
console.log(name, age);Array.from та ітерабельні об’єктиArray.from може створити масив з ітерабельного об’єкта:
const values = new Set([1, 2, 3]);
const array = Array.from(values);
console.log(array); // [1, 2, 3]Він також приймає функцію перетворення:
const range = new Range(1, 5);
const squares = Array.from(range, value => value ** 2);
console.log(squares); // [1, 4, 9, 16, 25]На відміну від for...of, Array.from намагається зібрати всі значення в пам’ять. Тому його не можна безпечно застосовувати до нескінченних ітераторів.
Перед ручною роботою можна перевірити, чи є значення ітерабельним:
function isIterable(value) {
return value != null &&
typeof value[Symbol.iterator] === "function";
}
console.log(isIterable([1, 2, 3])); // true
console.log(isIterable("text")); // true
console.log(isIterable(new Set())); // true
console.log(isIterable({})); // false
console.log(isIterable(null)); // falseОднак перевірка лише наявності властивості не гарантує, що сам метод коректно реалізує протокол. Метод може повернути некоректний об’єкт або викинути виняток.
Для публічних API краще дозволити стандартній операції ітерації повідомити про помилку, якщо це відповідає контракту функції.
Генератори зручні, але іноді потрібно явно контролювати ітератор. Наприклад, можна реалізувати обхід дерева в ширину:
class TreeNode {
constructor(value, children = []) {
this.value = value;
this.children = children;
}
}
class Tree {
constructor(root) {
this.root = root;
}
*[Symbol.iterator]() {
if (!this.root) {
return;
}
const queue = [this.root];
while (queue.length > 0) {
const node = queue.shift();
yield node.value;
for (const child of node.children) {
queue.push(child);
}
}
}
}
const tree = new Tree(
new TreeNode("root", [
new TreeNode("a", [
new TreeNode("a1"),
new TreeNode("a2")
]),
new TreeNode("b")
])
);
console.log([...tree]);
// ["root", "a", "b", "a1", "a2"]Це приклад ітерабельної структури, яка не зберігає всі значення наперед. Наступний вузол визначається лише під час ітерації.
for...in замість for...offor...in перебирає ключі або індекси, а for...of — значення ітерабельного об’єкта:
const values = ["a", "b"];
for (const key in values) {
console.log(key);
}
// 0
// 1
for (const value of values) {
console.log(value);
}
// a
// bfor...in призначений переважно для перелічуваних властивостей об’єкта. Для проходження значень колекції використовуйте for...of.
const object = {
a: 1,
b: 2
};
// Помилка: об’єкт не має Symbol.iterator
// for (const value of object) {}Якщо потрібно перебрати властивості, використовуйте:
for (const [key, value] of Object.entries(object)) {
console.log(key, value);
}const iterator = [1, 2, 3][Symbol.iterator]();
console.log([...iterator]); // [1, 2, 3]
console.log([...iterator]); // []Якщо потрібен новий прохід, створіть новий ітератор:
const values = [1, 2, 3];
console.log([...values[Symbol.iterator]()]);
console.log([...values[Symbol.iterator]()]);Оператор ... намагається отримати всі значення:
function* infinite() {
let value = 0;
while (true) {
yield value++;
}
}
// Така операція ніколи не завершиться
// const values = [...infinite()];Для нескінченних джерел завжди потрібне обмеження кількості елементів або умова завершення.
next()Результат next() повинен бути об’єктом із коректним прапорцем done:
const invalidIterator = {
next() {
return 42;
}
};
// for...of не зможе коректно працювати з таким ітераторомПравильний результат:
const validIterator = {
next() {
return {
value: 42,
done: false
};
}
};Зміна структури під час обходу може призвести до пропущених елементів, повторного проходження або іншої поведінки, залежно від конкретної колекції.
const values = [1, 2, 3, 4];
for (const value of values) {
if (value % 2 === 0) {
values.splice(values.indexOf(value), 1);
}
}
console.log(values);Такий код важко аналізувати. Безпечніші підходи:
створити копію перед змінами;
зібрати елементи для видалення окремо;
використати filter;
чітко документувати правила зміни колекції.
Ітератори добре підходять для лінивих обчислень. Значення обчислюються лише тоді, коли їх запитують:
function* map(iterable, transform) {
for (const value of iterable) {
yield transform(value);
}
}
function* filter(iterable, predicate) {
for (const value of iterable) {
if (predicate(value)) {
yield value;
}
}
}
const numbers = new Range(1, 10);
const result = filter(
map(numbers, value => value ** 2),
value => value % 2 === 0
);
for (const value of result) {
console.log(value);
}
// 4
// 16
// 36
// 64
// 100У цьому прикладі:
Range генерує числа;
map обчислює квадрат лише для запитаного числа;
filter пропускає лише парні результати;
значення не зберігаються в проміжному масиві.
Цей підхід може зменшити споживання пам’яті під час обробки великих або потенційно нескінченних послідовностей.
Синхронний ітератор використовує:
Symbol.iteratorі метод:
next()Асинхронний ітератор використовує:
Symbol.asyncIteratorа його next() повертає проміс.
Для асинхронної ітерації застосовується for await...of:
async function* delayedValues() {
for (const value of [1, 2, 3]) {
await new Promise(resolve => setTimeout(resolve, 100));
yield value;
}
}
async function main() {
for await (const value of delayedValues()) {
console.log(value);
}
}
main();Асинхронні ітератори потрібні для потоків даних, посторінкового отримання ресурсів, читання частин файлу або інших операцій, де наступне значення доступне асинхронно.
Не слід плутати:
Symbol.iterator з Symbol.asyncIterator;
for...of з for await...of;
синхронні генератори з асинхронними генераторами.
Масив зручний, коли:
усі значення вже доступні;
потрібен випадковий доступ за індексом;
потрібно багаторазово проходити дані;
потрібні методи map, filter, reduce та інші операції масивів.
Ітератор зручний, коли:
значення потрібно обчислювати ліниво;
даних дуже багато;
джерело може бути нескінченним;
потрібне потокове оброблення;
не потрібно зберігати всі значення одночасно.
Ітератор не є автоматично швидшим за масив. Його головні переваги — контроль моменту обчислення та економія пам’яті.
Ітератор — це об’єкт із методом next().
next() повертає об’єкт із властивостями value і done.
Ітерабельний об’єкт має метод Symbol.iterator.
for...of, ..., деструктуризація та Array.from використовують протокол ітерації.
Масиви, рядки, Set, Map і генератори є ітерабельними.
Ітератор часто є одноразовим, тоді як колекція може створювати новий ітератор для кожного проходження.
Генератори спрощують створення ітераторів за допомогою yield.
yield* делегує ітерацію іншому ітерабельному об’єкту.
Метод return() дає змогу коректно завершити ітератор достроково.
Нескінченні ітератори потрібно завжди обмежувати перед перетворенням у масив.
Для асинхронних джерел використовуються Symbol.asyncIterator та for await...of.