16 липня 2023 р.

Генератори

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

Генератори можуть повертати (“yield”) кілька значень, одне за одним, на вимогу. Вони чудово працюють з об’єктами, що перебираються, дозволяючи легко створювати потоки даних.

Функції-генератори

Щоб створити генератор, нам потрібна спеціальна синтаксична конструкція: function*, так звана “функція-генератор”.

Це виглядає ось так:

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

Функції-генератори поводяться інакше, ніж звичайні. Коли така функція викликається, вона не запускає свій код. Замість цього вона повертає спеціальний об’єкт, який називається «об’єкт-генератор», щоб керувати її виконанням.

Ось подивіться:

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

// "функція-генератор" створює "об’єкт-генератор"
let generator = generateSequence();
alert(generator); // [object Generator]

Виконання коду функції ще не розпочато:

Основним методом генератора є next(). При виклику він запускає виконання коду до найближчого оператора yield <value> (value можна опустити, тоді воно є undefined). Потім виконання функції призупиняється, а отримане value повертається до зовнішнього коду.

Результатом next() завжди є об’єкт з двома властивостями:

  • value: отримане значення.
  • done: true, якщо код функції закінчився, інакше false.

Наприклад, тут ми створюємо генератор і отримуємо його перше значення, що повертається:

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

let generator = generateSequence();

let one = generator.next();

alert(JSON.stringify(one)); // {value: 1, done: false}

На даний момент ми отримали лише перше значення, а виконання функції відбувається на другому рядку:

Давайте знову викличемо generator.next(). Він відновлює виконання коду і повертає наступний yield:

let two = generator.next();

alert(JSON.stringify(two)); // {value: 2, done: false}

І якщо ми викликаємо його втретє, виконання досягає оператора return, який завершує виконання функції:

let three = generator.next();

alert(JSON.stringify(three)); // {value: 3, done: true}

Тепер генератор виконався. Ми можемо побачити це за допомогою done:true і обробити value:3 як кінцевий результат.

Нові виклики generator.next() більше не мають сенсу. Якщо ми їх робимо, вони повертають той самий об’єкт: {done: true}.

function* f(…) чи function *f(…)?

Обидва синтаксиси правильні.

Але зазвичай перевага віддається першому синтаксису, оскільки зірочка * означає, що це функція-генератор, вона описує вид, а не ім’я, тому її слід розташовувати разом із ключовим словом function.

Перебір генераторів

Як ви, напевно, вже здогадалися, дивлячись на метод next(), генератори є об’єктами, що перебираються.

Ми можемо перебирати їх значення за допомогою for..of:

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

let generator = generateSequence();

for(let value of generator) {
  alert(value); // 1, потім 2
}

Виглядає набагато приємніше, ніж виклик .next().value, чи не так?

…Але зверніть увагу: у прикладі вище показано 1, потім 2, і це все. Значення 3 не показується!

Це тому, що перебір через for..of ігнорує останнє value, коли done: true. Отже, якщо ми хочемо, щоб усі результати відображалися через for..of, то повинні повертати їх через yield:

function* generateSequence() {
  yield 1;
  yield 2;
  yield 3;
}

let generator = generateSequence();

for(let value of generator) {
  alert(value); // 1, потім 2, потім 3
}

Оскільки генератори є об’єктами, що перебираються, ми можемо використовувати всю пов’язану з ними функціональність, наприклад синтаксис розширення ...:

function* generateSequence() {
  yield 1;
  yield 2;
  yield 3;
}

let sequence = [0, ...generateSequence()];

alert(sequence); // 0, 1, 2, 3

У наведеному вище коді ...generateSequence() перетворює об’єкт-генератор, що перебирається, в масив елементів (докладніше про синтаксис розширення читайте у главі Залишкові параметри та синтаксис поширення)

Використання генераторів для об’єктів, що перебираються

Деякий час тому в главі Ітеративні об’єкти ми створили об’єкт range, що перебирається та повертає значення from..to.

Ось, давайте згадаємо код:

let range = {
  from: 1,
  to: 5,

  // for..of range викликає цей метод один раз на самому початку
  [Symbol.iterator]() {
    // ...він повертає об’єкт, що перебирається:
    // далі for..of працює лише з цим об’єктом, запитуючи в нього наступні значення
    return {
      current: this.from,
      last: this.to,

      // next() викликається при кожній ітерації цикла for..of
      next() {
        // повинно повернути значення як об’єкт {done:.., value :...}
        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

// при переборі об’єкта range повертаються числа від range.from до range.to
alert([...range]); // 1,2,3,4,5

Ми можемо використовувати функцію-генератор для перебору об’єкта, вказавши її як Symbol.iterator.

Ось той самий range, але набагато компактніший:

let range = {
  from: 1,
  to: 5,

  *[Symbol.iterator]() { // скорочення для [Symbol.iterator]: function*()
    for(let value = this.from; value <= this.to; value++) {
      yield value;
    }
  }
};

alert( [...range] ); // 1,2,3,4,5

Це працює, тому що range[Symbol.iterator]() тепер повертає генератор, а методи генератора – це саме те, що очікує for..of:

  • він має метод .next()
  • повертає значення у вигляді {value: ..., done: true/false}

Це, звичайно, не випадковість. Генератори були додані до мови JavaScript з урахуванням об’єктів, що перебираються, щоб їх було легше реалізувати.

Варіант з генератором набагато лаконічніший, ніж оригінальний код range, що перебирається, і зберігає ту саму функціональність.

Генератори можуть генерувати значення нескінченно

У вищенаведених прикладах ми створили кінцеві послідовності, але ми також можемо створити генератор, який видає значення нескінченно. Наприклад, нескінченна послідовність псевдовипадкових чисел.

Безсумнівно, для цього буде потрібно break (або return) у циклі for..of в такому генераторі. Інакше цикл повторюватиметься нескінченно та зависне.

Композиція генераторів

Композиція генераторів – це особливість генераторів, що дозволяє прозоро “вбудовувати” генератори один в одного.

Наприклад, у нас є функція, яка генерує послідовність чисел:

function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) yield i;
}

Тепер ми хотіли б повторно використати його для створення складнішої послідовності:

  • спочатку цифри 0..9 (з кодами символів 48…57),
  • за якими йдуть великі літери алфавіту A..Z (коди символів 65…90)
  • за якими йдуть малі літери алфавіту a..z (коди символів 97…122)

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

У звичайній функції, щоб об’єднати результати кількох інших функцій, ми викликаємо їх, зберігаємо результати, а потім об’єднуємо в кінці.

Для генераторів існує спеціальний синтаксис yield* для “вбудовування” (компонування) одного генератора в інший.

Ось композиція генераторів:

function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) yield i;
}

function* generatePasswordCodes() {

  // 0..9
  yield* generateSequence(48, 57);

  // A..Z
  yield* generateSequence(65, 90);

  // a..z
  yield* generateSequence(97, 122);

}

let str = '';

for(let code of generatePasswordCodes()) {
  str += String.fromCharCode(code);
}

alert(str); // 0..9A..Za..z

Директива yield* делегує виконання іншому генератору. Цей термін означає, що yield* gen виконує ітерацію над генератором gen і прозоро передає його вихід назовні. Ніби значення були отримані зовнішнім генератором.

Результат такий самий, як якби ми вставили код із вкладених генераторів: