Протоколи ітерування

Протоколи ітерування – це не нові вбудовані значення чи синтаксис, а протоколи. Ці протоколи можуть бути реалізовані будь-яким об'єктом, шляхом виконання певних умов.

Є два протоколи: Протокол ітерованого об'єкта та протокол ітератора.

Протокол ітерованого об'єкта

Протокол ітерованого об'єкта дає об'єктам JavaScript змогу означити або налаштувати власну логіку ітерування, наприклад, те, які значення обробляються в циклі for...of. Частина вбудованих типів є вбудованими ітерованими об'єктами з усталеною логікою ітерування, як то Array і Map, коли інші (як то Object) – ні.

Аби бути ітерованим, об'єкт мусить реалізувати метод @@iterator, що означає, що об'єкт (або один з об'єктів у його ланцюжку прототипів) мусить мати властивість з ключем @@iterator, доступним через сталу Symbol.iterator:

[Symbol.iterator]

Функція без аргументів, що повертає об'єкт, котрий виконує протокол ітератора.

Щоразу, коли об'єкт готується до ітерування (наприклад, на початку циклу for...of), викликається без аргументів його метод @@iterator, і повернений ітератор використовується для отримання значень ітерування.

Зверніть увагу, що коли викликається ця функція без аргументів, вона закликається як метод ітерованого об'єкта. Таким чином, всередині цієї функції для звертання до властивостей ітерованого об'єкта може використовуватися ключове слово this, аби з'ясувати, що повинно видаватися під час ітерації.

Ця функція може бути звичайною функцією, а може бути генераторною функцією, щоб при її заклику повертався об'єкт-ітератор. Всередині такої генераторної функції кожен запис надається за допомогою yield.

Протокол ітератора

Протокол ітератора задає стандартний спосіб вироблення послідовності (або скінченної, або нескінченної) значень, і, потенційно, повернене значення, коли всі елементи послідовності були згенеровані.

Об'єкт є ітератором, коли має реалізацію метода next() з наступною семантикою:

next()

Функція, що приймає нуль або один аргумент і повертає об'єкт, що відповідає інтерфейсові IteratorResult (див. нижче). Якщо повертається необ'єктне значення (наприклад, false або undefined), коли ітератор використовується вбудованою можливістю мови (як то for...of), то викидається TypeError ("iterator.next() returned a non-object value").

Від усіх методів протоколу ітератора (next(), return() і throw()) очікується повернення об'єкта, що реалізує інтерфейс IteratorResult. Такий об'єкт повинен мати наступні властивості:

done Необов'язкове

Булеве значення, котре дорівнює false, якщо ітератор зміг виробити наступне значення послідовності. (Це рівносильно тому, щоб не задати властивість done узагалі.)

Має значення true, якщо ітератор завершив свою послідовність. У такому випадку value є необов'язковим завершальним значенням ітератора.

value Необов'язкове

Будь-яке значення JavaScript, повернене ітератором. Може бути опущено, коли значення done дорівнює true.

На практиці жодна з цих властивостей не є суворо обов'язковою; якщо повертається об'єкт без якої-небудь властивості, то це фактично рівносильно поверненню { done: false, value: undefined }.

Коли ітератор повертає результат із done: true, то очікується, що всі наступні виклики next() так само повернуть done: true, хоч це і не вимагається на рівні мови.

Метод next може прийняти значення, котре буде доступним тілу метода. Жодна вбудована можливість мови ніякого значення передавати не буде. Значення, передане в метод next генераторів, стане значенням відповідного виразу yield.

Крім цього, ітератор може, необов'язково, реалізувати методи return(value) і throw(exception), котрі, бувши викликаними, кажуть ітераторові, що викликач закінчив ітерування і можна виконати яке-небудь важливе очищення (як то закриття з'єднання з базою даних).

return(value) Необов'язкове

Функція, котра приймає нуль або один аргумент і повертає об'єкт, котрий відповідає інтерфейсові IteratorResult, зазвичай із полем value, рівним переданому в нього value, і полем done зі значенням true. Виклик цього метода каже ітераторові, що викликач не має наміру більше викликати next() і що можна зайнятися очищенням.

throw(exception) Необов'язкове

Функція, котра приймає нуль або один аргумент і повертає об'єкт, що відповідає інтерфейсові IteratorResult, зазвичай із полем done, рівним true. Виклик цього метода каже ітераторові, що викликач увійшов у помилковий стан, а exception зазвичай є примірником Error.

Примітка: Неможливо рефлективно дізнатися (тобто без фактичного виклику next() і перевірки поверненого результату), чи реалізує певний об'єкт протокол ітератора.

Дуже легко зробити ітератор також ітерованим об'єктом: достатньо реалізувати метод [@@iterator](), котрий повертає this.

// Відповідає і протоколові ітератора, і протоколові ітерованого об'єкта
const myIterator = {
  next() {
    // ...
  },
  [Symbol.iterator]() {
    return this;
  },
};

Такий об'єкт зветься ітерованим ітератором. Це дає змогу використати ітератор в певних синтаксичних структурах, що розраховують на ітеровані об'єкти – таким чином, нечасто є корисною реалізація протоколу ітератора без реалізації водночас протоколу ітерованого об'єкта. (Насправді майже всі мовні структури й API очікують на ітеровані об'єкти, а не ітератори.) Генераторний об'єкт є прикладом цього:

const aGeneratorObject = (function* () {
  yield 1;
  yield 2;
  yield 3;
})();

console.log(typeof aGeneratorObject.next);
// "function": є метод next (котрий повертає правильний результат), тож це ітератор

console.log(typeof aGeneratorObject[Symbol.iterator]);
// "function": є метод @@iterator (котрий повертає правильний ітератор), тож це ітерований об'єкт

console.log(aGeneratorObject[Symbol.iterator]() === aGeneratorObject);
// true: метод @@iterator повертає сам об'єкт (сам ітератор), тож це ітерований ітератор

Усі вбудовані ітератори мають в ланцюжку прототипів об'єкт Iterator.prototype, котрий має реалізацію методу [@@iterator](), що повертає this, тож вбудовані ітератори також є ітерованими об'єктами.

А проте, коли це можливо, краще, щоб iterable[Symbol.iterator] повертав різні ітератори, що завжди починаються спочатку, як це робить Set.prototype[@@iterator]().

Асинхронний ітератор і протокол асинхронного ітерованого об'єкта

Є іще одна пара протоколів, яка використовується для асинхронного ітерування – вони звуться протоколами асинхронного ітератора й асинхронного ітерованого об'єкта. Їх інтерфейси дуже схожі на інтерфейси протоколів ітерованого об'єкта та ітератора, окрім того, що кожне повернене при викликах методів ітератора значення – загорнуте в проміс.

Об'єкт реалізує протокол асинхронного ітерованого об'єкта, коли має реалізацію наступних методів:

[Symbol.asyncIterator]

Функція без аргументів, що повертає об'єкт, котрий відповідає протоколові асинхронного ітератора.

Об'єкт реалізує протокол асинхронного ітератора, коли має реалізацію наступних методів:

next()

Функція, котра приймає нуль або один аргумент і повертає проміс. Цей проміс сповнюється об'єктом, що відповідає інтерфейсові IteratorResult, а його властивості мають таку ж семантику, як в синхронного ітератора.

return(value) Необов'язкове

Функція, котра приймає нуль або один аргумент і повертає проміс. Цей проміс сповнюється об'єктом, що відповідає інтерфейсові IteratorResult, а його властивості мають таку ж семантику, як в синхронного ітератора.

throw(exception) Необов'язкове

Функція, котра приймає нуль або один аргумент і повертає проміс. Цей проміс сповнюється об'єктом, що відповідає інтерфейсові IteratorResult, а його властивості мають таку ж семантику, як в синхронного ітератора.

Взаємодія між мовою та протоколами ітерування

Мова задає API, котрі або виробляють, або приймають ітеровані об'єкти й ітератори.

Вбудовані ітеровані об'єкти

String, Array, TypedArray, Map, Set і Segments (повернений з Intl.Segmenter.prototype.segment()) є вбудованими ітерованими об'єктами, адже кожний з їхніх об'єктів prototype має реалізацію метода @@iterator. На додачу, об'єкт arguments і частина типів колекцій DOM, як то NodeList, так само є ітерованими об'єктами. У ядрі мови JavaScript немає жодного об'єкта, що був би асинхронним ітерованим. Деякі API Вебу, як то ReadableStream, усталено мають метод Symbol.asyncIterator.

Генераторні функції повертають генераторні об'єкти, котрі є ітерованими ітераторами. Асинхронні генераторні функції повертають асинхронні генераторні об'єкти, котрі є асинхронними ітерованими ітераторами.

Ітератори, повернені зі вбудованих ітерованих об'єктів, успадковують від спільного класу Iterator (наразі прихованого), котрий має реалізацію вищезгаданого методу [Symbol.iterator]() { return this; }, що робить їх всіх ітерованими ітераторами. У майбутньому ці вбудовані ітератори можуть отримати додаткові допоміжні методи, на додачу до методу next(), котрий вимагається протоколом ітератора. Ланцюжок прототипів ітератора можна дослідити шляхом виведення його в графічну консоль.

console.log([][Symbol.iterator]());

Array Iterator {}
  [[Prototype]]: Array Iterator     ==> Цей прототип — спільний для всіх ітераторів-масивів
    next: ƒ next()
    Symbol(Symbol.toStringTag): "Array Iterator"
    [[Prototype]]: Object           ==> Цей прототип — спільний для всіх вбудованих ітераторів
      Symbol(Symbol.iterator): ƒ [Symbol.iterator]()
      [[Prototype]]: Object         ==> Це — Object.prototype

Вбудовані API, що приймають ітератори

Є чимало API, що приймають ітеровані об'єкти. Серед прикладів:

const myObj = {};

new WeakSet(
  (function* () {
    yield {};
    yield myObj;
    yield {};
  })(),
).has(myObj); // true

Синтаксичні конструкції, що очікують на ітеровані об'єкти

Частина інструкцій і виразів очікує на ітеровані об'єкти, наприклад, цикли for...of, розгортання масивів і параметрів, yield* і деструктурування масивів:

for (const value of ["a", "b", "c"]) {
  console.log(value);
}
// "a"
// "b"
// "c"

console.log([..."abc"]); // ["a", "b", "c"]

function* gen() {
  yield* ["a", "b", "c"];
}

console.log(gen().next()); // { value: "a", done: false }

[a, b, c] = new Set(["a", "b", "c"]);
console.log(a); // "a"

Коли вбудовані синтаксичні конструкції ітерують ітератор, і поле done останнього результату дорівнює false (тобто ітератор може виробити більше значень), але більше значень не потрібно, то буде викликано метод return, якщо такий метод є. Це може статися, наприклад, якщо в циклі for...of зустрілася інструкція break або return, або коли при деструктуруванні масиву усі ідентифікатори вже отримали значення.

const obj = {
  [Symbol.iterator]() {
    let i = 0;
    return {
      next() {
        i++;
        console.log("Повернення", i);
        if (i === 3) return { done: true, value: i };
        return { done: false, value: i };
      },
      return() {
        console.log("Закривання");
        return { done: true };
      },
    };
  },
};

const [a] = obj;
// Повернення 1
// Закривання

const [b, c, d] = obj;
// Повернення 1
// Повернення 2
// Повернення 3
// Уже досягнутий кінець (останній виклик повернув `done: true`),
// тож `return` не викликається

for (const b of obj) {
  break;
}
// Повернення 1
// Закривання

Цикл for await...of і yield* в асинхронних генераторних функціях (але не синхронних генераторних функціях) – єдині способи взаємодіяти з асинхронними ітерованими об'єктами. Використання for...of, розгортання масиву тощо на асинхронному ітерованому об'єкті, що не є водночас синхронним ітерованим об'єктом (тобто має [@@asyncIterator](), але не має [@@iterator]()) викине TypeError: x is not iterable.

Погано сформовані ітеровані об'єкти

Якщо метод @@iterator ітерованого об'єкта не повертає об'єкт-ітератор, то такий ітерований об'єкт вважається погано сформованим.

Його використання, ймовірно, призведе до помилок під час виконання або проблемної логіки:

const nonWellFormedIterable = {};
nonWellFormedIterable[Symbol.iterator] = () => 1;
[...nonWellFormedIterable]; // TypeError: [Symbol.iterator]() returned a non-object value

Приклади

Користувацькі ітеровані об'єкти

Так можна створювати власні ітеровані об'єкти:

const myIterable = {
  *[Symbol.iterator]() {
    yield 1;
    yield 2;
    yield 3;
  },
};

console.log([...myIterable]); // [1, 2, 3]

Простий ітератор

Ітератори за своєю природою мають стан. Якщо не означити ітератор як генераторну функцію (як це показано в прикладі вище), то, ймовірно, доведеться інкапсулювати цей стан в замиканні.

function makeIterator(array) {
  let nextIndex = 0;
  return {
    next() {
      return nextIndex < array.length
        ? {
            value: array[nextIndex++],
            done: false,
          }
        : {
            done: true,
          };
    },
  };
}

const it = makeIterator(["yo", "ya"]);

console.log(it.next().value); // 'yo'
console.log(it.next().value); // 'ya'
console.log(it.next().done); // true

Нескінченний ітератор

function idMaker() {
  let index = 0;
  return {
    next() {
      return {
        value: index++,
        done: false,
      };
    },
  };
}

const it = idMaker();

console.log(it.next().value); // 0
console.log(it.next().value); // 1
console.log(it.next().value); // 2
// ...

Означення ітерованого об'єкта за допомогою генератора

function* makeSimpleGenerator(array) {
  let nextIndex = 0;
  while (nextIndex < array.length) {
    yield array[nextIndex++];
  }
}

const gen = makeSimpleGenerator(["yo", "ya"]);

console.log(gen.next().value); // 'yo'
console.log(gen.next().value); // 'ya'
console.log(gen.next().done); // true

function* idMaker() {
  let index = 0;
  while (true) {
    yield index++;
  }
}

const it = idMaker();

console.log(it.next().value); // 0
console.log(it.next().value); // 1
console.log(it.next().value); // 2
// ...

Означення ітерованого об'єкта за допомогою класу

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

class SimpleClass {
  #data;

  constructor(data) {
    this.#data = data;
  }

  [Symbol.iterator]() {
    // Використання нового індексу для кожного ітератора. Це робить кілька
    // ітерувань одного ітерованого безпечними для нетривіальних випадків,
    // як то використання break або вкладеного ітерування одного ітерованого.
    let index = 0;

    return {
      // Примітка: використання стрілкової функції дає `this` змогу вказувати на
      // `this` методу `[@@iterator]()`, а не `this` методу `next()`
      next: () => {
        if (index < this.#data.length) {
          return { value: this.#data[index++], done: false };
        } else {
          return { done: true };
        }
      },
    };
  }
}

const simple = new SimpleClass([1, 2, 3, 4, 5]);

for (const val of simple) {
  console.log(val); // 1 2 3 4 5
}

Заміщення вбудованих ітерованих об'єктів

Наприклад, String – це вбудований ітерований об'єкт:

const someString = "hi";
console.log(typeof someString[Symbol.iterator]); // "function"

Усталений ітератор String повертає одну за одною кодові точки рядка:

const iterator = someString[Symbol.iterator]();
console.log(`${iterator}`); // "[object String Iterator]"

console.log(iterator.next()); // { value: "h", done: false }
console.log(iterator.next()); // { value: "i", done: false }
console.log(iterator.next()); // { value: undefined, done: true }

Можна перевизначити логіку ітерування, задавши власний @@iterator:

// необхідно сконструювати об'єкт String явно, аби уникнути автоматичного пакування
const someString = new String("hi");

someString[Symbol.iterator] = function () {
  return {
    // це об'єкт-ітератор, котрий повертає один-єдиний елемент(рядок "bye")
    next() {
      return this._first
        ? { value: "bye", done: (this._first = false) }
        : { done: true };
    },
    _first: true,
  };
};

Зверніть увагу, як заміщення @@iterator впливає на поведінку вбудованих конструкцій, котрі користуються протоколом ітерування:

console.log([...someString]); // ["bye"]
console.log(`${someString}`); // "hi"

Специфікації

Дивіться також