Skip to main content

Що таке symbol.iterator і навіщо він потрібен

Symbol.iterator - вбудований символ у JavaScript, який визначає, як об'єкт генерує послідовність значень для for...of, spread (...), Array.from() і деструктуризації.

Теорія

TL;DR

  • Symbol.iterator - як слот для монет у вендинговому автоматі: визначає інтерфейс для отримання значень по одному
  • Масиви, рядки, Set і Map мають його вбудованим; звичайні об'єкти - ні
  • Будь-який об'єкт з [Symbol.iterator]() працює в for...of, spread і деструктуризації
  • Метод повинен повертати ітератор (iterator): об'єкт з next(), що повертає { value, done }
  • Потрібний кастомний порядок або ліниве завантаження? Реалізуй. Інакше використовуй Array або Set

Швидкий приклад

javascript
// Звичайний об'єкт - немає Symbol.iterator, кидає TypeError const obj = { a: 1, b: 2 }; for (const v of obj) console.log(v); // TypeError: obj is not iterable // Додаємо Symbol.iterator - тепер працює скрізь const iterable = { data: [10, 20, 30], *[Symbol.iterator]() { // генератор бере next() на себе for (const v of this.data) yield v; } }; for (const v of iterable) console.log(v); // 10, 20, 30 console.log([...iterable]); // [10, 20, 30]

Синтаксис генератора (function*) автоматично реалізує next(). Результат такий самий, як якби ти писав { next: () => ({ value, done }) } вручну.

Як працює протокол ітерації

for...of виконує три кроки. Один раз викликає obj[Symbol.iterator](), щоб отримати ітератор. Потім на кожному кроці циклу викликає iterator.next(). Зупиняється, коли next() повертає { done: true }.

Тут є дві окремі ролі. Ітерабельний (iterable) - об'єкт з методом [Symbol.iterator](). Ітератор (iterator) - те, що цей метод повертає: об'єкт зі станом і методом next(). Масиви є обома одночасно, бо arr[Symbol.iterator]() повертає сам масив з прикріпленим next().

Ця різниця важлива на практиці. Ітератор має стан і вичерпується після одного проходу. Ітерабельний об'єкт кожного разу створює свіжий ітератор.

Ітерабельний vs ітератор

javascript
const arr = [1, 2, 3]; const it = arr[Symbol.iterator](); // отримуємо свіжий ітератор console.log(it.next()); // { value: 1, done: false } console.log(it.next()); // { value: 2, done: false } console.log(it.next()); // { value: 3, done: false } console.log(it.next()); // { value: undefined, done: true }

Коли реалізовувати Symbol.iterator

Реалізовуй, коли є структура даних, що має поводитись як послідовність: діапазон чисел, посторінковий результат запиту, обхід дерева або обгортка DOM-елементів. Якщо Array або Set вже підходять - немає сенсу додавати.

Практичний сигнал: якщо ти постійно пишеш методи getAll(), що повертають масиви, і всі споживачі одразу ітерують результат - [Symbol.iterator] безпосередньо на об'єкті виглядатиме чистіше.

Як рушій обробляє це

V8 перевіряє obj[Symbol.iterator] на початку for...of. Якщо властивість відсутня або не є функцією - кидає TypeError: obj is not iterable ще до того, як виконається тіло циклу. Далі в циклі викликає next(), зчитуючи value і done з кожного результату. Генератори компілюються у машини станів у байткоді V8: кожен yield зберігає позицію виконання і локальні змінні, а next() продовжує з того місця.

Лінивість ітераторів має практичне значення. Генератор, що підтягує дані зі сторінки API, не робить запит до мережі, поки не викличуть next(). Ніякого попереднього завантаження, ніякого масиву в пам'яті.

Типові помилки

Повторне використання вичерпаного ітератора:

javascript
const it = myIterable[Symbol.iterator](); // збережений ітератор, не ітерабельний for (const v of it) console.log(v); // 1, 2, 3 for (const v of it) console.log(v); // нічого - вже вичерпаний

for...of на ітерабельному об'єкті щоразу викликає [Symbol.iterator]() заново. Але it вже є ітератором. Вичерпаний - означає вичерпаний. Виправлення: ітеруй оригінальний об'єкт, а не збережений ітератор.

Мутація джерела під час ітерації:

javascript
const obj = { data: [1, 2, 3], *[Symbol.iterator]() { yield* this.data; } }; for (const v of obj) { obj.data.shift(); // мутуємо живий масив у циклі console.log(v); // виводить 1, 3 - пропускає 2 }

Генератор читає з живого посилання. Якщо потрібна мутація - спочатку клонуй дані.

Синхронна ітерація по async-генератору:

javascript
async function* fetchItems() { yield await Promise.resolve(1); } for (const v of fetchItems()) {} // TypeError for await (const v of fetchItems()) {} // правильно

for...of очікує синхронний next(). Async-генератори потребують for await...of і використовують [Symbol.asyncIterator] всередині.

Нескінченний ітератор без break:

javascript
const counter = { *[Symbol.iterator]() { let i = 0; while (true) yield i++; } }; for (const n of counter) { if (n > 10) break; // break викликає iterator.return() всередині console.log(n); } // Без break - цикл ніколи не завершиться

Де зустрічається

  • Node.js streams: Readable.from(iterable) (Node 12+) перетворює будь-який ітератор на readable stream
  • Array.from: використовує [Symbol.iterator] всередині, так само як spread
  • Деструктуризація: const [a, b] = myIterable теж викликає [Symbol.iterator]
  • Promise.all / Promise.race: обидва приймають будь-який ітерабельний об'єкт, не тільки масиви
  • Кастомні діапазони: for (const i of range(1, 100)) читається краще за звичайний for з індексом

Найбільше я використовував [Symbol.iterator] на обгортках для курсорів баз даних: коли клієнт БД повертає cursor, додавання ітераторного протоколу дозволяє коду-споживачу залишатись чистим без жодних змін в інтерфейсі.

Follow-up питання

Q: Яка різниця між ітерабельним і ітератором?
A: Ітерабельний (iterable) має [Symbol.iterator](), який щоразу повертає свіжий ітератор. Ітератор (iterator) - це об'єкт зі станом і методом next(). Масиви є обома: arr[Symbol.iterator]() повертає сам масив з прикріпленим next().

Q: Як реалізувати скінченний range без генераторів?
A: const range = (end) => ({ [Symbol.iterator]() { let i = 0; return { next: () => i < end ? { value: i++, done: false } : { done: true } }; } });

Q: Чому for...of кидає помилку на звичайних об'єктах, а for...in працює?
A: for...in використовує внутрішній механізм перебору властивостей, окремий від протоколу ітерації. for...of суворо вимагає [Symbol.iterator]. Два різні механізми зі своїми специфікаціями.

Q: Що відбувається при break у for...of над генератором?
A: Рушій викликає iterator.return(), якщо він є, що запускає блок finally генератора. Це дозволяє генераторам звільняти ресурси при ранньому виході: закривати файлові дескриптори, відміняти запити.

Q: (Senior) Як V8 компілює генераторну функцію?
A: Як машину станів. Кожен yield розбиває тіло функції на пронумеровані стани. next() переходить до поточного стану через bytecode dispatch, виконує до наступного yield, зберігає локальні змінні і номер стану в замиканні GeneratorObject, потім призупиняється.

Приклади

Range-ітератор з ручним next()

javascript
// Явна реалізація next() - без генераторів function range(start, end) { return { [Symbol.iterator]() { let current = start; return { next() { return current <= end ? { value: current++, done: false } : { value: undefined, done: true }; } }; } }; } for (const n of range(1, 5)) console.log(n); // 1 2 3 4 5 console.log([...range(1, 5)]); // [1, 2, 3, 4, 5] const [first, second] = range(10, 20); console.log(first, second); // 10 11

Всі три варіанти - for...of, spread і деструктуризація - викликають [Symbol.iterator]() всередині. Один і той самий об'єкт працює в усіх трьох контекстах без додаткового коду.

Посторінковий fetch з async-генератором

javascript
// Кожен yield - один user; пагінація прихована від споживача async function* fetchAllUsers(baseUrl) { let page = 1; while (true) { const res = await fetch(`${baseUrl}?page=${page++}&limit=10`); const data = await res.json(); if (!data.length) return; // більше сторінок немає yield* data; // стрімимо елементи сторінки по одному } } // Обробляємо users без завантаження всіх сторінок for await (const user of fetchAllUsers('/api/users')) { console.log(user.name); if (user.role === 'admin') break; // ранній вихід, зайвих запитів немає }

Цей патерн підходить для SSE-роутів в Express і data-шарів Next.js, де повне попереднє завантаження неприйнятне. Зверни увагу: async-генератори використовують Symbol.asyncIterator, а не Symbol.iterator. for await...of обробляє це автоматично.

Обхід дерева як ітерабельний клас

javascript
class TreeNode { constructor(value, children = []) { this.value = value; this.children = children; } // Обхід у глибину через делегування генераторів *[Symbol.iterator]() { yield this.value; for (const child of this.children) { yield* child; // делегуємо Symbol.iterator дочірнього вузла } } } const tree = new TreeNode(1, [ new TreeNode(2, [new TreeNode(4), new TreeNode(5)]), new TreeNode(3) ]); console.log([...tree]); // [1, 2, 4, 5, 3]

yield* делегує іншому ітерабельному об'єкту: викликає child[Symbol.iterator]() і вичерпує його до кінця перш ніж продовжити. Це делегування генераторів (generator delegation) - воно дозволяє складати обходи дерев без ручного керування стеком.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?