Повний посібник з розуміння та реалізації протоколу ітератора JavaScript, що дозволить вам створювати власні ітератори для покращеної обробки даних.
Розкриваємо протокол ітератора JavaScript та власні ітератори
Протокол ітератора в JavaScript надає стандартизований спосіб обходу структур даних. Розуміння цього протоколу дозволяє розробникам ефективно працювати з вбудованими ітерованими об'єктами, такими як масиви та рядки, а також створювати власні ітеровані об'єкти, пристосовані до конкретних структур даних та вимог додатку. Цей посібник пропонує всебічне дослідження протоколу ітератора та способів реалізації власних ітераторів.
Що таке протокол ітератора?
Протокол ітератора визначає, як об'єкт може бути ітерований, тобто як можна послідовно отримати доступ до його елементів. Він складається з двох частин: протоколу Iterable (ітерований об'єкт) та протоколу Iterator (ітератор).
Протокол Iterable (ітерований об'єкт)
Об'єкт вважається ітерованим (Iterable), якщо він має метод з ключем Symbol.iterator
. Цей метод повинен повертати об'єкт, що відповідає протоколу Iterator (ітератор).
По суті, ітерований об'єкт знає, як створити для себе ітератор.
Протокол Iterator (ітератор)
Протокол Iterator визначає, як отримувати значення з послідовності. Об'єкт вважається ітератором, якщо він має метод next()
, який повертає об'єкт з двома властивостями:
value
: Наступне значення в послідовності.done
: Булеве значення, що вказує, чи досяг ітератор кінця послідовності. Якщоdone
має значенняtrue
, властивістьvalue
може бути пропущена.
Метод next()
є робочою конячкою протоколу ітератора. Кожен виклик next()
просуває ітератор і повертає наступне значення в послідовності. Коли всі значення повернуті, next()
повертає об'єкт з `done`, встановленим у `true`.
Вбудовані ітеровані об'єкти
JavaScript надає кілька вбудованих структур даних, які є ітерованими за своєю природою. До них належать:
- Масиви
- Рядки
- Maps
- Sets
- Об'єкт Arguments функції
- TypedArrays
Ці ітеровані об'єкти можна безпосередньо використовувати з циклом for...of
, синтаксисом розповсюдження (...
) та іншими конструкціями, що покладаються на протокол ітератора.
Приклад з масивами:
const myArray = ["apple", "banana", "cherry"];
for (const item of myArray) {
console.log(item); // Вивід: apple, banana, cherry
}
Приклад з рядками:
const myString = "Hello";
for (const char of myString) {
console.log(char); // Вивід: H, e, l, l, o
}
Цикл for...of
Цикл for...of
є потужною конструкцією для ітерації по ітерованих об'єктах. Він автоматично обробляє складнощі протоколу ітератора, що полегшує доступ до значень у послідовності.
Синтаксис циклу for...of
:
for (const element of iterable) {
// Код, що виконується для кожного елемента
}
Цикл for...of
отримує ітератор з ітерованого об'єкта (використовуючи Symbol.iterator
) і багаторазово викликає метод next()
ітератора, доки `done` не стане `true`. На кожній ітерації змінній `element` присвоюється значення властивості `value`, повернуте методом `next()`.
Створення власних ітераторів
Хоча JavaScript надає вбудовані ітеровані об'єкти, справжня сила протоколу ітератора полягає в можливості визначати власні ітератори для ваших структур даних. Це дозволяє вам контролювати, як дані обходяться та як до них отримується доступ.
Ось як створити власний ітератор:
- Визначте клас або об'єкт, що представляє вашу власну структуру даних.
- Реалізуйте метод
Symbol.iterator
у вашому класі або об'єкті. Цей метод повинен повертати об'єкт ітератора. - Об'єкт ітератора повинен мати метод
next()
, який повертає об'єкт з властивостямиvalue
таdone
.
Приклад: Створення ітератора для простого діапазону
Створимо клас під назвою Range
, який представляє діапазон чисел. Ми реалізуємо протокол ітератора, щоб дозволити ітерацію по числах у цьому діапазоні.
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
[Symbol.iterator]() {
let currentValue = this.start;
const that = this; // Зберігаємо 'this' для використання всередині об'єкта ітератора
return {
next() {
if (currentValue <= that.end) {
return {
value: currentValue++,
done: false,
};
} else {
return {
value: undefined,
done: true,
};
}
},
};
}
}
const myRange = new Range(1, 5);
for (const number of myRange) {
console.log(number); // Вивід: 1, 2, 3, 4, 5
}
Пояснення:
- Клас
Range
приймає значенняstart
таend
у своєму конструкторі. - Метод
Symbol.iterator
повертає об'єкт ітератора. Цей об'єкт ітератора має власний стан (currentValue
) та методnext()
. - Метод
next()
перевіряє, чи знаходитьсяcurrentValue
в межах діапазону. Якщо так, він повертає об'єкт з поточним значенням та `done` встановленим у `false`. Він також збільшуєcurrentValue
для наступної ітерації. - Коли
currentValue
перевищує значенняend
, методnext()
повертає об'єкт з `done`, встановленим у `true`. - Зверніть увагу на використання
that = this
. Оскільки метод `next()` викликається в іншому контексті (циклом `for...of`), `this` всередині `next()` не посилався б на екземпляр `Range`. Щоб вирішити цю проблему, ми зберігаємо значення `this` (екземпляр `Range`) у змінній `that` поза контекстом `next()` і потім використовуємо `that` всередині `next()`.
Приклад: Створення ітератора для зв'язного списку
Розглянемо ще один приклад: створення ітератора для структури даних зв'язного списку. Зв'язний список — це послідовність вузлів, де кожен вузол містить значення та посилання (вказівник) на наступний вузол у списку. Останній вузол у списку має посилання на null (або undefined).
class LinkedListNode {
constructor(value, next = null) {
this.value = value;
this.next = next;
}
}
class LinkedList {
constructor() {
this.head = null;
}
append(value) {
const newNode = new LinkedListNode(value);
if (!this.head) {
this.head = newNode;
return;
}
let current = this.head;
while (current.next) {
current = current.next;
}
current.next = newNode;
}
[Symbol.iterator]() {
let current = this.head;
return {
next() {
if (current) {
const value = current.value;
current = current.next;
return {
value: value,
done: false
};
} else {
return {
value: undefined,
done: true
};
}
}
};
}
}
// Приклад використання:
const myList = new LinkedList();
myList.append("London");
myList.append("Paris");
myList.append("Tokyo");
for (const city of myList) {
console.log(city); // Вивід: London, Paris, Tokyo
}
Пояснення:
- Клас
LinkedListNode
представляє один вузол у зв'язному списку, зберігаючиvalue
та посилання (next
) на наступний вузол. - Клас
LinkedList
представляє сам зв'язний список. Він містить властивістьhead
, яка вказує на перший вузол у списку. Методappend()
додає нові вузли в кінець списку. - Метод
Symbol.iterator
створює та повертає об'єкт ітератора. Цей ітератор відстежує поточний вузол, що відвідується (current
). - Метод
next()
перевіряє, чи існує поточний вузол (current
не є null). Якщо так, він отримує значення з поточного вузла, переміщує вказівникcurrent
на наступний вузол і повертає об'єкт зі значенням таdone: false
. - Коли
current
стає null (це означає, що ми досягли кінця списку), методnext()
повертає об'єкт зdone: true
.
Генераторні функції
Генераторні функції надають більш стислий та елегантний спосіб створення ітераторів. Вони використовують ключове слово yield
для генерації значень за вимогою.
Генераторна функція визначається за допомогою синтаксису function*
.
Приклад: Створення ітератора за допомогою генераторної функції
Перепишемо ітератор Range
, використовуючи генераторну функцію:
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
*[Symbol.iterator]() {
for (let i = this.start; i <= this.end; i++) {
yield i;
}
}
}
const myRange = new Range(1, 5);
for (const number of myRange) {
console.log(number); // Вивід: 1, 2, 3, 4, 5
}
Пояснення:
- Метод
Symbol.iterator
тепер є генераторною функцією (зверніть увагу на*
). - Всередині генераторної функції ми використовуємо цикл
for
для ітерації по діапазону чисел. - Ключове слово
yield
призупиняє виконання генераторної функції та повертає поточне значення (i
). Наступного разу, коли буде викликано методnext()
ітератора, виконання відновиться з місця, де воно зупинилося (після оператораyield
). - Коли цикл завершується, генераторна функція неявно повертає
{ value: undefined, done: true }
, сигналізуючи про кінець ітерації.
Генераторні функції спрощують створення ітераторів, автоматично обробляючи метод next()
та прапорець done
.
Приклад: Генератор послідовності Фібоначчі
Ще один чудовий приклад використання генераторних функцій — генерація послідовності Фібоначчі:
function* fibonacciSequence() {
let a = 0;
let b = 1;
while (true) {
yield a;
[a, b] = [b, a + b]; // Деструктуруюче присвоєння для одночасного оновлення
}
}
const fibonacci = fibonacciSequence();
for (let i = 0; i < 10; i++) {
console.log(fibonacci.next().value); // Вивід: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34
}
Пояснення:
- Функція
fibonacciSequence
є генераторною функцією. - Вона ініціалізує дві змінні,
a
таb
, першими двома числами послідовності Фібоначчі (0 та 1). - Цикл
while (true)
створює нескінченну послідовність. - Оператор
yield a
генерує поточне значенняa
. - Оператор
[a, b] = [b, a + b]
одночасно оновлюєa
таb
до наступних двох чисел у послідовності за допомогою деструктуруючого присвоєння. - Вираз
fibonacci.next().value
отримує наступне значення з генератора. Оскільки генератор нескінченний, вам потрібно контролювати, скільки значень ви з нього витягуєте. У цьому прикладі ми витягуємо перші 10 значень.
Переваги використання протоколу ітератора
- Стандартизація: Протокол ітератора надає узгоджений спосіб ітерації по різних структурах даних.
- Гнучкість: Ви можете визначати власні ітератори, пристосовані до ваших конкретних потреб.
- Читабельність: Цикл
for...of
робить код ітерації більш читабельним та стислим. - Ефективність: Ітератори можуть бути "лінивими", тобто вони генерують значення лише за потреби, що може покращити продуктивність для великих наборів даних. Наприклад, наведений вище генератор послідовності Фібоначчі обчислює наступне значення лише при виклику `next()`.
- Сумісність: Ітератори бездоганно працюють з іншими можливостями JavaScript, такими як синтаксис розповсюдження та деструктуризація.
Просунуті техніки роботи з ітераторами
Комбінування ітераторів
Ви можете комбінувати кілька ітераторів в один. Це корисно, коли вам потрібно обробити дані з кількох джерел у єдиний спосіб.
function* combineIterators(...iterables) {
for (const iterable of iterables) {
for (const item of iterable) {
yield item;
}
}
}
const array1 = [1, 2, 3];
const array2 = ["a", "b", "c"];
const string1 = "XYZ";
const combined = combineIterators(array1, array2, string1);
for (const value of combined) {
console.log(value); // Вивід: 1, 2, 3, a, b, c, X, Y, Z
}
У цьому прикладі функція `combineIterators` приймає будь-яку кількість ітерованих об'єктів як аргументи. Вона ітерує по кожному ітерованому об'єкту та повертає кожен елемент за допомогою `yield`. Результатом є єдиний ітератор, який генерує всі значення з усіх вхідних ітерованих об'єктів.
Фільтрація та трансформація ітераторів
Ви також можете створювати ітератори, які фільтрують або трансформують значення, що генеруються іншим ітератором. Це дозволяє обробляти дані конвеєрним способом, застосовуючи різні операції до кожного значення в міру його генерації.
function* filterIterator(iterable, predicate) {
for (const item of iterable) {
if (predicate(item)) {
yield item;
}
}
}
function* mapIterator(iterable, transform) {
for (const item of iterable) {
yield transform(item);
}
}
const numbers = [1, 2, 3, 4, 5, 6];
const evenNumbers = filterIterator(numbers, (x) => x % 2 === 0);
const squaredEvenNumbers = mapIterator(evenNumbers, (x) => x * x);
for (const value of squaredEvenNumbers) {
console.log(value); // Вивід: 4, 16, 36
}
Тут `filterIterator` приймає ітерований об'єкт та функцію-предикат. Він повертає за допомогою `yield` лише ті елементи, для яких предикат повертає `true`. `mapIterator` приймає ітерований об'єкт та функцію-трансформатор. Він повертає за допомогою `yield` результат застосування функції-трансформатора до кожного елемента.
Застосування в реальному світі
Протокол ітератора широко використовується в бібліотеках та фреймворках JavaScript, і він є цінним у різноманітних реальних додатках, особливо при роботі з великими наборами даних або асинхронними операціями.
- Обробка даних: Ітератори корисні для ефективної обробки великих наборів даних, оскільки вони дозволяють працювати з даними частинами, не завантажуючи весь набір даних у пам'ять. Уявіть собі розбір великого CSV-файлу з даними клієнтів. Ітератор дозволить вам обробляти кожен рядок, не завантажуючи весь файл у пам'ять одночасно.
- Асинхронні операції: Ітератори можна використовувати для обробки асинхронних операцій, таких як отримання даних з API. Ви можете використовувати генераторні функції, щоб призупинити виконання доти, доки дані не будуть доступні, а потім відновити роботу з наступним значенням.
- Власні структури даних: Ітератори є незамінними для створення власних структур даних з певними вимогами до обходу. Розглянемо структуру даних "дерево". Ви можете реалізувати власний ітератор для обходу дерева в певному порядку (наприклад, у глибину або в ширину).
- Розробка ігор: У розробці ігор ітератори можна використовувати для керування ігровими об'єктами, ефектами частинок та іншими динамічними елементами.
- Бібліотеки для інтерфейсу користувача: Багато UI-бібліотек використовують ітератори для ефективного оновлення та рендерингу компонентів на основі змін у базових даних.
Найкращі практики
- Правильно реалізуйте
Symbol.iterator
: Переконайтеся, що ваш методSymbol.iterator
повертає об'єкт ітератора, який відповідає протоколу ітератора. - Точно обробляйте прапорець
done
: Прапорецьdone
є вирішальним для сигналізації про закінчення ітерації. Переконайтеся, що ви правильно встановлюєте його у своєму методіnext()
. - Розгляньте можливість використання генераторних функцій: Генераторні функції надають більш стислий та читабельний спосіб створення ітераторів.
- Уникайте побічних ефектів у
next()
: Методnext()
повинен в основному зосереджуватися на отриманні наступного значення та оновленні стану ітератора. Уникайте виконання складних операцій або побічних ефектів усерединіnext()
. - Ретельно тестуйте свої ітератори: Тестуйте свої власні ітератори з різними наборами даних та сценаріями, щоб переконатися, що вони працюють правильно.
Висновок
Протокол ітератора JavaScript надає потужний та гнучкий спосіб обходу структур даних. Розуміючи протоколи Iterable та Iterator, а також використовуючи генераторні функції, ви можете створювати власні ітератори, пристосовані до ваших конкретних потреб. Це дозволяє ефективно працювати з даними, покращувати читабельність коду та підвищувати продуктивність ваших додатків. Оволодіння ітераторами відкриває глибше розуміння можливостей JavaScript та дає змогу писати більш елегантний та ефективний код.