Ітеративні об’єкти є узагальненням масивів. Це концепція, яка дозволяє нам зробити будь-який об’єкт придатним для використання в циклі for..of
.
Звичайно, по масивах можна ітеруватися. Але є багато інших вбудованих об’єктів, які також можна ітерувати. Наприклад, рядки також можна ітерувати.
Якщо об’єкт технічно не є масивом, а представляє колекцію (list, set) чогось, то for..of
– чудовий синтаксис для його обходу, тому подивімось, як змусити його працювати.
Symbol.iterator
Ми можемо легко зрозуміти концепцію ітеративних об’єктів, зробивши її власноруч.
Наприклад, у нас є об’єкт, який не є масивом, але виглядає придатним для for..of
.
Як, наприклад, об’єкт range
, який представляє інтервал чисел:
let range = {
from: 1,
to: 5
};
// Ми хочемо, щоб for..of працював:
// for(let num of range) ... num=1,2,3,4,5
Щоб зробити об’єкт range
ітерабельним (і таким чином дозволити for..of
працювати), нам потрібно додати метод до об’єкта з назвою Symbol.iterator
(спеціальний вбудований символ саме для цього).
- Коли
for..of
запускається, він викликає цей метод один раз (або викидає помилку, якщо цей метод не знайдено). Метод повинен повернути iterator – об’єкт з методомnext
. - Далі
for..of
працює лише з поверненим об’єктом. - Коли
for..of
хоче отримати наступне значення, він викликаєnext()
на цьому об’єкті. - Результат
next()
повинен мати вигляд{done: Boolean, value: any}
, деdone=true
означає, що ітерація завершена, інакшеvalue
– це наступне значення.
Ось повна реалізація об’єкту range
із зауваженнями:
let range = {
from: 1,
to: 5
};
// 1. виклик for..of спочатку викликає цю функцію
range[Symbol.iterator] = function() {
// 2. Далі, for..of працює тільки з цим ітератором, запитуючи у нього наступні значення
return {
current: this.from,
last: this.to,
next() {
// 4. він повинен повертати значення як об’єкт {done:.., value :...}
if (this.current <= this.last) {
return { done: false, value: this.current++ };
} else {
return { done: true };
}
}
};
};
// тепер це працює!
for (let num of range) {
alert(num); // 1, потім 2, 3, 4, 5
}
Будь ласка, зверніть увагу на основну особливість ітеративних об’єктів: розділення проблем.
- Сам
range
не має методуnext()
. - Натомість інший об’єкт, так званий “ітератор”, створюється за допомогою виклику
range[Symbol.iterator]()
, а йогоnext()
генерує значення для ітерації.
Отже, об’єкт, що ітерує відокремлений від об’єкта, який він ітерує.
Технічно, ми можемо об’єднати їх і використовувати range
в якості ітератора, щоб зробити код простішим.
Подібно до цього:
let range = {
from: 1,
to: 5,
[Symbol.iterator]() {
this.current = this.from;
return this;
},
next() {
if (this.current <= this.to) {
return { done: false, value: this.current++ };
} else {
return { done: true };
}
}
};
for (let num of range) {
alert(num); // 1, тоді 2, 3, 4, 5
}
Тепер range[Symbol.iterator]()
повертає сам об’єкт range
: він має необхідний next()
метод і пам’ятає поточну ітерацію прогресу в this.current
. Коротше? Так. А іноді це також добре.
Недоліком є те, що тепер неможливо мати два for..of
цикли паралельно для проходження через об’єкт: вони будуть ділити ітераційний стан, тому що є тільки один ітератор – сам об’єкт. Але два паралельних for-of це рідкісний випадок, навіть у асинхронізованих сценаріях.
Також можливі нескінченні ітератори. Наприклад, range
стає нескінченним для range.to = Infinity
. Або ми можемо зробити ітерований об’єкт, який генерує нескінченну послідовність псевдорандомних чисел. Це також може бути корисним.
Немає обмежень на next
, він може повертати все більше і більше значень, це нормально.
Звичайно, for..of
цикли через такий об’єкт буде нескінченним. Але ми завжди можемо зупинити його за допомогою break
.
Рядок є ітерованим
Масиви та рядки найбільш широко використовуються вбудовані ітератори.
Для рядка, for..of
цикл проходить по символам:
for (let char of "test") {
// викликається 4 рази: один раз для кожного символу
alert( char ); // t, потім e, потім s, потім t
}
І це правильно працює з сурогатними парами!
let str = '𝒳😂';
for (let char of str) {
alert( char ); // 𝒳, і потім 😂
}
Виклик ітератора явно
Для глибшого розуміння, подивімось, як явно використовувати ітератор.
Ми будемо ітерувати рядок точно так само, як для for..of
, але з прямими викликами. Цей код створює ітератор рядка і отримує значення від нього “вручну”:
let str = "Привіт";
// робить те ж саме, як
// for (let char of str) alert(char);
let iterator = str[Symbol.iterator]();
while (true) {
let result = iterator.next();
if (result.done) break;
alert(result.value); // виводить символи один за одним
}
Це рідко потрібно, але дає нам більше контролю над процесом, ніж for ..of
. Наприклад, ми можемо розділити процес ітерації: трохи ітерувати, а потім зупинитися, зробити щось інше, а потім відновити пізніше.
Ітеровані об’єкти та псевдомасиви
Ці два офіційних терміни виглядають подібними, але дуже різні. Будь ласка, переконайтеся, що ви добре розумієте їх, щоб уникнути плутанини.
- Ітеровані – це об’єкти, які реалізують метод
Symbol.iterator
, як описано вище. - Псевдомасиви – це об’єкти, які мають індекси та
length
, тому вони виглядають як масиви.
Коли ми використовуємо JavaScript для практичних завдань у браузері або будь-якому іншому середовищі, ми можемо зустріти об’єкти, які є ітерованими або масивами, або обома.
Наприклад, рядки є ітерованими об’єктами (for..of
працює на них) та псевдомасивами (у них є числові індекси та length
).
Але ітерований об’єкт може не бути масивом. І навпаки, псевдомасив може бути не ітерованим об’єктом.
Наприклад, range
у прикладі вище є ітерованим об’єктом, але не масивом, тому що він не має індексованих властивостей та length
.
І ось об’єкт, який є псевдомасивом, але не ітерованим об’єктом:
let arrayLike = { // має індекси та length => псевдомасив
0: "Hello",
1: "World",
length: 2
};
// Помилка (немає Symbol.iterator)
for (let item of arrayLike) {}
Обидва, ітерований об’єкт та псевдомасив, як правило є не масивами, вони не мають push
,pop
та ін. Це досить незручно, якщо у нас є такий об’єкт і ми хочемо працювати з ним як з масивом. Наприклад, ми хотіли б працювати з angy
за допомогою методів масиву. Як цього досягти?
Array.from
Існує універсальний метод Array.from, який приймає ітерований об’єкт або псевдомасив і робить з нього “справжній” масив. Тоді ми можемо викликати на ньому методи масиву.
Наприклад:
let arrayLike = {
0: "Привіт",
1: "Світ",
length: 2
};
let arr = Array.from(arrayLike); // (*)
alert(arr.pop()); // Світ (метод працює)
Array.from
у рядку (*)
бере об’єкт, перевіряє його на ітерабельність або те, що це псевдомасив, потім створює новий масив і копіює до нього всі елементи.
Те ж саме відбувається і з ітерованим об’єктом:
// припустимо, що діапазон взятий з наведеного вище прикладу
let arr = Array.from(range);
alert(arr); // 1,2,3,4,5 (array toString conversion works)
Повний синтаксис для Array.from
також дозволяє нам надати додаткову функцію “трансформації”:
Array.from(obj[, mapFn, thisArg])
mapFn
є необов’язоковим(опціональним) аргументом-функцією, яка буде застосовуватись до кожного елемента перед його додаванням до масиву, а thisArg
– також необов’язковий аргумент, який дозволяє встановити this
для виклику mapFn
.
Наприклад:
// припустимо, що діапазон взятий з наведеного вище прикладу
// порахуємо квадрат кожного числа
let arr = Array.from(range, num => num * num);
alert(arr); // 1,4,9,16,25
Тут ми використовуємо Array.from
, щоб перетворити рядок у масив символів:
let str = '𝒳😂';
// розіб’ємо рядок на масив символів
let chars = Array.from(str);
alert(chars[0]); // 𝒳
alert(chars[1]); // 😂
alert(chars.length); // 2
На відміну від str.split
, він спирається на ітерабельний характер рядка і тому, так само, як for..of
, коректно працює з сурогатними парами.
Технічно тут це відбувається так само, як:
let str = '𝒳😂';
let chars = []; // Array.from внутрішньо робить цей самий цикл
for (let char of str) {
chars.push(char);
}
alert(chars);
… Але значно коротше.
До речі, ми можемо створити slice
, якій підтримує сурогатні пари:
function slice(str, start, end) {
return Array.from(str).slice(start, end).join('');
}
let str = '𝒳😂𩷶';
alert( slice(str, 1, 3) ); // 😂𩷶
// ось нативний метод який не підтримує сурогатні пари
alert( str.slice(1, 3) ); // сміття (дві частини різних сурогатних пар)
Підсумки
Об’єкти, які можна використовуватися у for..of
, називаються ітерованими.
- Технічно ітеровані об’єкти повинні реалізовувати метод з назвою
Symbol.iterator
.- Результат
obj[Symbol.iterator]()
називається ітератором. Він забезпечує подальший процес ітерації. - Ітератор повинен мати метод з назвою
next()
, який повертає об’єкт{done: Boolean, value: any}
, деdone: true
означає кінець процесу ітерації, інакшеvalue
є наступним значенням.
- Результат
- Метод
Symbol.iterator
автоматично викликаєтьсяfor..of
, але ми також можемо це зробити безпосередньо. - Вбудовані ітеровані об’єкти, такі як рядки або масиви, також реалізують
Symbol.iterator
. - Рядковий ітератор знає про сурогатні пари.
Об’єкти, які мають індексовані властивості та length
, називаються псевдомасивами. Такі об’єкти також можуть мати інші властивості та методи, але не мають вбудованих методів масивів.
Якщо ми заглянемо в специфікацію – ми побачимо, що більшість вбудованих методів припускають, що вони працюють з ітерованими об’єктами або псевдомасивами замість “реальних” масивів, тому що це більш абстрактно.
Array.from(obj[, mapFn, thisArg])
створює справжній Array
з ітерованого об’єкту або псевдомасиву obj
, і тоді ми можемо використовувати на ньому методи масиву. Необов’язкові аргументи mapFn
таthisArg
дозволяють нам застосовувати функції до кожного елемента.