16 липня 2023 р.

Ітеративні об’єкти

Ітеративні об’єкти є узагальненням масивів. Це концепція, яка дозволяє нам зробити будь-який об’єкт придатним для використання в циклі 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 (спеціальний вбудований символ саме для цього).

  1. Коли for..of запускається, він викликає цей метод один раз (або викидає помилку, якщо цей метод не знайдено). Метод повинен повернути iterator – об’єкт з методом next.
  2. Далі for..of працює лише з поверненим об’єктом.
  3. Коли for..of хоче отримати наступне значення, він викликає next() на цьому об’єкті.
  4. Результат 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 це рідкісний випадок, навіть у асинхронізованих сценаріях.

Infinite iterators

Також можливі нескінченні ітератори. Наприклад, 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 дозволяють нам застосовувати функції до кожного елемента.

Навчальна карта