7 июня 2022 г.

Генераторы

Материал на этой странице устарел, поэтому скрыт из оглавления сайта.

Более новая информация по этой теме находится на странице https://learn.javascript.ru/generators.

Генераторы – новый вид функций в современном JavaScript. Они отличаются от обычных тем, что могут приостанавливать своё выполнение, возвращать промежуточный результат и далее возобновлять его позже, в произвольный момент времени.

Создание генератора

Для объявления генератора используется новая синтаксическая конструкция: function* (функция со звёздочкой).

Её называют «функция-генератор» (generator function).

Выглядит это так:

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

При запуске generateSequence() код такой функции не выполняется. Вместо этого она возвращает специальный объект, который как раз и называют «генератором».

// generator function создаёт generator
let generator = generateSequence();

Правильнее всего будет воспринимать генератор как «замороженный вызов функции»:

При создании генератора код находится в начале своего выполнения.

Основным методом генератора является next(). При вызове он возобновляет выполнение кода до ближайшего ключевого слова yield. По достижении yield выполнение приостанавливается, а значение – возвращается во внешний код:

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}.

«Открутить назад» завершившийся генератор нельзя, но можно создать новый ещё одним вызовом generateSequence() и выполнить его.

function* (…) или function *(…)?

Можно ставить звёздочку как сразу после function, так и позже, перед названием. В интернете можно найти обе эти формы записи, они верны:

function* f() {
  // звёздочка после function
}

function *f() {
  // звёздочка перед названием
}

Технически, нет разницы, но писать то так то эдак – довольно странно, надо остановиться на чём-то одном.

Автор этого текста полагает, что правильнее использовать первый вариант function*, так как звёздочка относится к типу объявляемой сущности (function* – «функция-генератор»), а не к её названию. Конечно, это всего лишь рекомендация-мнение, не обязательное к выполнению, работать будет в любом случае.

Генератор – итератор

Как вы, наверно, уже догадались по наличию метода next(), генератор связан с итераторами. В частности, он является итерируемым объектом.

Его можно перебирать и через for..of:

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

let generator = generateSequence();

for(let value of generator) {
  alert(value); // 1, затем 2
}

Заметим, однако, существенную особенность такого перебора!

При запуске примера выше будет выведено значение 1, затем 2. Значение 3 выведено не будет. Это потому что стандартный перебор итератора игнорирует value на последнем значении, при done: true. Так что результат return в цикле for..of не выводится.

Соответственно, если мы хотим, чтобы все значения возвращались при переборе через for..of, то надо возвращать их через yield:

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

let generator = generateSequence();

for(let value of generator) {
  alert(value); // 1, затем 2, затем 3
}

…А зачем вообще return при таком раскладе, если его результат игнорируется? Он тоже нужен, но в других ситуациях. Перебор через for..of – в некотором смысле «исключение». Как мы увидим дальше, в других контекстах return очень даже востребован.

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

Один генератор может включать в себя другие. Это называется композицией.

Разберём композицию на примере.

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

function* generateSequence(start, end) {

  for (let i = start; i <= end; i++) {
    yield i;
  }

}

// Используем оператор … для преобразования итерируемого объекта в массив
let sequence = [...generateSequence(2,5)];

alert(sequence); // 2, 3, 4, 5

Мы хотим на её основе сделать другую функцию generateAlphaNum(), которая будет генерировать коды для буквенно-цифровых символов латинского алфавита:

  • 48..57 – для 0..9
  • 65..90 – для A..Z
  • 97..122 – для a..z

Далее этот набор кодов можно превратить в строку и использовать, к примеру, для выбора из него случайного пароля. Только символы пунктуации ещё хорошо бы добавить для надёжности, но в этом примере мы будем без них.

Естественно, раз в нашем распоряжении есть готовый генератор generateSequence, то хорошо бы его использовать.

Конечно, можно внутри generateAlphaNum запустить несколько раз generateSequence, объединить результаты и вернуть. Так мы бы сделали с обычными функциями. Но композиция – это кое-что получше.

Она выглядит так:

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

function* generateAlphaNum() {

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

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

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

}

let str = '';

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

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

Здесь использована специальная форма yield*. Она применима только к другому генератору и делегирует ему выполнение.

То есть, при yield* интерпретатор переходит внутрь генератора-аргумента, к примеру, generateSequence(48, 57), выполняет его, и все yield, которые он делает, выходят из внешнего генератора.

Получается – как будто мы вставили код внутреннего генератора во внешний напрямую, вот так:

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

function* generateAlphaNum() {

  // yield* generateSequence(48, 57);
  for (let i = 48; i <= 57; i++) yield i;

  // yield* generateSequence(65, 90);
  for (let i = 65; i <= 90; i++) yield i;

  // yield* generateSequence(97, 122);
  for (let i = 97; i <= 122; i++) yield i;

}

let str = '';

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

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

Код выше по поведению полностью идентичен варианту с yield*. При этом, конечно, переменные вложенного генератора не попадают во внешний, «делегирование» только выводит результаты yield во внешний поток.

Композиция – это естественное встраивание одного генератора в поток другого. При композиции значения из вложенного генератора выдаются «по мере готовности». Поэтому она будет работать даже если поток данных из вложенного генератора оказался бесконечным или ожидает какого-либо условия для завершения.

yield – дорога в обе стороны

До этого генераторы наиболее напоминали «итераторы на стероидах». Но, как мы сейчас увидим, это не так, есть фундаментальное различие, генераторы гораздо мощнее и гибче.

Всё дело в том, что yield – дорога в обе стороны: он не только возвращает результат наружу, но и может передавать значение извне в генератор.

Вызов let result = yield value делает следующее:

  • Возвращает value во внешний код, приостанавливая выполнение генератора.
  • Внешний код может обработать значение, и затем вызвать next с аргументом: generator.next(arg).
  • Генератор продолжит выполнение, аргумент next будет возвращён как результат yield (и записан в result).

Продемонстрируем это на примере:

function* gen() {
  // Передать вопрос во внешний код и подождать ответа
  let result = yield "2 + 2?";

  alert(result);
}

let generator = gen();

let question = generator.next().value;
// "2 + 2?"

setTimeout(() => generator.next(4), 2000);

На рисунке ниже прямоугольником изображён генератор, а вокруг него – «внешний код», который с ним взаимодействует:

На этой иллюстрации показано то, что происходит в генераторе:

  1. Первый вызов generator.next() – всегда без аргумента, он начинает выполнение и возвращает результат первого yield («2+2?»). На этой точке генератор приостанавливает выполнение.
  2. Результат yield переходит во внешний код (в question). Внешний код может выполнять любые асинхронные задачи, генератор стоит «на паузе».
  3. Когда асинхронные задачи готовы, внешний код вызывает generator.next(4) с аргументом. Выполнение генератора возобновляется, а 4 выходит из присваивания как результат let result = yield ....

В примере выше – только два next.

Увеличим их количество, чтобы стал более понятен общий поток выполнения:

function* gen() {
  let ask1 = yield "2 + 2?";

  alert(ask1); // 4

  let ask2 = yield "3 * 3?"

  alert(ask2); // 9
}

let generator = gen();

alert( generator.next().value ); // "2 + 2?"

alert( generator.next(4).value ); // "3 * 3?"

alert( generator.next(9).done ); // true

Взаимодействие с внешним кодом:

  1. Первый .next() начинает выполнение… Оно доходит до первого yield.
  2. Результат возвращается во внешний код.
  3. Второй .next(4) передаёт 4 обратно в генератор как результат первого yield и возобновляет выполнение.
  4. …Оно доходит до второго yield, который станет результатом .next(4).
  5. Третий next(9) передаёт 9 в генератор как результат второго yield и возобновляет выполнение, которое завершается окончанием функции, так что done: true.

Получается «пинг-понг»: каждый next(value) передаёт в генератор значение, которое становится результатом текущего yield, возобновляет выполнение и получает выражение из следующего yield. Исключением является первый вызов next, который не может передать значение в генератор, т.к. ещё не было ни одного yield.

generator.throw

Как мы видели в примерах выше, внешний код может вернуть генератору в качестве результата yield любое значение.

…Но «вернуть» можно не только результат, но и ошибку!

Для того, чтобы передать в yield ошибку, используется вызов generator.throw(err). При этом на строке с yield возникает исключение.

Например, в коде ниже обращение к внешнему коду yield "Сколько будет 2 + 2" завершится с ошибкой:

function* gen() {
  try {
    // в этой строке возникнет ошибка
    let result = yield "Сколько будет 2 + 2?"; // (**)

    alert("выше будет исключение ^^^");
  } catch(e) {
    alert(e); // выведет ошибку
  }
}

let generator = gen();

let question = generator.next().value;

generator.throw(new Error("ответ не найден в моей базе данных")); // (*)

«Вброшенная» в строке (*) ошибка возникает в строке с yield (**). Далее она обрабатывается как обычно. В примере выше она перехватывается try..catch и выводится.

Если ошибку не перехватить, то она «выпадет» из генератора. По стеку ближайший вызов, который инициировал выполнение – это строка с .throw. Можно перехватить её там, как и продемонстрировано в примере ниже:

function* gen() {
  // В этой строке возникнет ошибка
  let result = yield "Сколько будет 2 + 2?";
}

let generator = gen();

let question = generator.next().value;

try {
  generator.throw(new Error("ответ не найден в моей базе данных"));
} catch(e) {
  alert(e); // выведет ошибку
}

Если же ошибка и там не перехвачена, то дальше – как обычно, либо try..catch снаружи, либо она «повалит» скрипт.

Плоский асинхронный код

Одна из основных областей применения генераторов – написание «плоского» асинхронного кода.

Общий принцип такой:

  • Генератор yield'ит не просто значения, а промисы.
  • Есть специальная «функция-чернорабочий» execute(generator) которая запускает генератор, последовательными вызовами next получает из него промисы – один за другим, и, когда очередной промис выполнится, возвращает его результат в генератор следующим next.
  • Последнее значение генератора (done:true) execute уже обрабатывает как окончательный результат – например, возвращает через промис куда-то ещё, во внешний код или просто использует, как в примере ниже.

Напишем такой код для получения аватара пользователя с github и его вывода, аналогичный рассмотренному в статье про промисы.

Для AJAX-запросов будем использовать метод fetch, он как раз возвращает промисы.

// генератор для получения и показа аватара
// он yield'ит промисы
function* showUserAvatar() {

  let userFetch = yield fetch('/article/generator/user.json');
  let userInfo = yield userFetch.json();

  let githubFetch = yield fetch(`https://api.github.com/users/${userInfo.name}`);
  let githubUserInfo = yield githubFetch.json();

  let img = new Image();
  img.src = githubUserInfo.avatar_url;
  img.className = "promise-avatar-example";
  document.body.appendChild(img);

  yield new Promise(resolve => setTimeout(resolve, 3000));

  img.remove();

  return img.src;
}

// вспомогательная функция-чернорабочий
// для выполнения промисов из generator
function execute(generator, yieldValue) {

  let next = generator.next(yieldValue);

  if (!next.done) {
    next.value.then(
      result => execute(generator, result),
      err => generator.throw(err)
    );
  } else {
    // обработаем результат return из генератора
    // обычно здесь вызов callback или что-то в этом духе
    alert(next.value);
  }

}

execute( showUserAvatar() );

Функция execute в примере выше – универсальная, она может работать с любым генератором, который yield'ит промисы.

Вместе с тем, это – всего лишь набросок, чтобы было понятно, как такая функция в принципе работает. Есть уже готовые реализации, обладающие большим количеством возможностей.

Одна из самых известных – это библиотека co, которую мы рассмотрим далее.

Библиотека «co»

Библиотека co, как и execute в примере выше, получает генератор и выполняет его.

Начнём сразу с примера, а потом – детали и полезные возможности:

co(function*() {

  let result = yield new Promise(
    resolve => setTimeout(resolve, 1000, 1)
  );

  alert(result); // 1

})

Предполагается, что библиотека co подключена к странице , например, отсюда: https://cdnjs.com/libraries/co/. В примере выше function*() делает yield промиса с setTimeout, который через секунду возвращает 1.

Вызов co(…) возвращает промис с результатом генератора. Если в примере выше function*() что-то возвратит, то это можно будет получить через .then в результате co:

co(function*() {

  let result = yield new Promise(
    resolve => setTimeout(resolve, 1000, 1)
  );

  return result; // return 1

}).then(alert); // 1
Обязательно нужен catch

Частая ошибка начинающих – вообще забывать про обработку результата co. Даже если результата нет, ошибки нужно обработать через catch, иначе они «подвиснут» в промисе.

Такой код ничего не выведет:

co(function*() {
  throw new Error("Sorry that happened");
})

Программист даже не узнает об ошибке. Особенно обидно, когда это опечатка или другая программная ошибка, которую обязательно нужно поправить.

Правильный вариант:

co(function*() {
  throw new Error("Sorry that happened");
}).catch(alert); // обработать ошибку как-либо

Большинство примеров этого catch не содержат, но это лишь потому, что в примерах ошибок нет. А в реальном коде обязательно нужен catch.

Библиотека co умеет выполнять не только промисы. Есть несколько видов значений, которые можно yieldить, и их обработает co:

  • Промис.
  • Объект-генератор.
  • Функция-генератор function*()co её выполнит, затем выполнит полученный генератор.
  • Функция с единственным аргументом вида function(callback) – библиотека co её запустит со своей функцией-callback и будет ожидать, что при ошибке она вызовет callback(err), а при успешном выполнении – callback(null, result). То есть, в первом аргументе – будет ошибка (если есть), а втором – результат (если нет ошибки). После чего результат будет передан в генератор.
  • Массив или объект из вышеперечисленного. При этом все задачи будут выполнены параллельно, и результат, в той же структуре, будет выдан наружу.

В примере ниже происходит yield всех этих видов значений. Библиотека co обеспечивает их выполнение и возврат результата в генератор:

Object.defineProperty(window, 'result', {
  // присвоение result=… будет выводить значение
  set: value => alert(JSON.stringify(value))
});

co(function*() {
  result = yield function*() { // генератор
    return 1;
  }();

  result = yield function*() { // функция-генератор
    return 2;
  };

  result = yield Promise.resolve(3); // промис

  result = yield function(callback) { // function(callback)
    setTimeout(() => callback(null, 4), 1000);
  };

  result = yield { // две задачи выполнит параллельно, как Promise.all
    one: Promise.resolve(1),
    two: function*() { return 2; }
  };

  result = yield [ // две задачи выполнит параллельно, как Promise.all
    Promise.resolve(1),
    function*() { return 2 }
  ];

});
Устаревший yield function(callback)

Отдельно заметим вариант с yield function(callback). Такие функции, с единственным-аргументом callback’ом, в англоязычной литературе называют «thunk».

Функция обязана выполниться и вызвать (асинхронно) либо callback(err) с ошибкой, либо callback(null, result) с результатом.

Использование таких функций в yield является устаревшим подходом, так как там, где можно использовать yield function(callback), можно использовать и промисы. При этом промисы мощнее. Но в старом коде его ещё можно встретить.

Посмотрим пример посложнее, с композицией генераторов:

co(function*() {
  let result = yield* gen();
  alert(result); // hello
});

function* gen() {
  return yield* gen2();
}

function* gen2() {
  let result = yield new Promise( // (1)
    resolve => setTimeout(resolve, 1000, 'hello')
  );
  return result;
}

Это – отличный вариант для библиотеки co. Композиция yield* gen() вызывает gen() в потоке внешнего генератора. Аналогично делает и yield* gen2().

Поэтому yield new Promise из строки (1) в gen2() попадает напрямую в библиотеку co, как если бы он был сделан так:

co(function*() {
  // gen() и затем gen2() встраиваются во внешний генератор
  let result = yield new Promise(
    resolve => setTimeout(resolve, 1000, 'hello')
  );
  alert(result); // hello
});

Пример showUserAvatar() можно переписать с использованием co вот так:

// Загрузить данные пользователя с нашего сервера
function* fetchUser(url) {
  let userFetch = yield fetch(url);
  let user = yield userFetch.json();

  return user;
}

// Загрузить профиль пользователя с github
function* fetchGithubUser(user) {
  let githubFetch = yield fetch(`https://api.github.com/users/${user.name}`);
  let githubUser = yield githubFetch.json();

  return githubUser;
}

// Подождать ms миллисекунд
function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// Использовать функции выше для получения аватара пользователя
function* fetchAvatar(url) {
  let user = yield* fetchUser(url);
  let githubUser = yield* fetchGithubUser(user);

  return githubUser.avatar_url;
}

// Использовать функции выше для получения и показа аватара
function* showUserAvatar() {
  let avatarUrl;

  try {
    avatarUrl = yield* fetchAvatar('/article/generator/user.json');
  } catch(e) {
    avatarUrl = '/article/generator/anon.png';
  }

  let img = new Image();
  img.src = avatarUrl;
  img.className = "promise-avatar-example";
  document.body.appendChild(img);

  yield sleep(2000);

  img.remove();

  return img.src;
}

co(showUserAvatar);

Заметим, что для перехвата ошибок при получении аватара используется try..catch вокруг yield* fetchAvatar:

try {
  avatarUrl = yield* fetchAvatar('/article/generator/user.json');
} catch(e) {
  avatarUrl = '/article/generator/anon.png';
}

Это – одно из главных удобств использования генераторов. Несмотря на то, что операции fetch – асинхронные, мы можем использовать обычный try..catch для обработки ошибок в них.

Для генераторов – только yield*

Библиотека co технически позволяет писать код так:

let user = yield fetchUser(url);
// вместо
// let user = yield* fetchUser(url);

То есть, можно сделать yield генератора, co() его выполнит и передаст значение обратно. Как мы видели выше, библиотека co – довольно всеядна. Однако, рекомендуется использовать для вызова функций-генераторов именно yield*.

Причин для этого несколько:

  1. Делегирование генераторов yield* – это встроенный механизм JavaScript. Вместо возвращения значения обратно в co, выполнения кода библиотеки… Мы просто используем возможности языка. Это правильнее.
  2. Поскольку не происходит лишних вызовов, это быстрее по производительности.
  3. И, наконец, пожалуй, самое приятное – делегирование генераторов сохраняет стек.

Проиллюстрируем последнее на примере:

co(function*() {

  // при запуске в стеке не будет видно этой строки
  yield g(); // (*)

}).catch(function(err) {
  alert(err.stack);
});

function* g() {
  throw new Error("my error");
}

При запуске этого кода стек может выглядеть примерно так:

at g (eval at runJS …, <anonymous>:13:9)
  at GeneratorFunctionPrototype.next (native)
  at onFulfilled (…/co/…/index.min.js:1:1136)
  at …/co/…/index.min.js:1:1076
  at co (…/co/…/index.min.js:1:1039)
  at toPromise (…/co/…/index.min.js:1:1740)
  at next (…/co/…/index.min.js:1:1351)
  at onFulfilled (…/co/…/index.min.js:1:1172)
  at …/co/…/index.min.js:1:1076
  at co (…/co/…/index.min.js:1:1039)

Детали здесь не имеют значения, самое важное – почти весь стек находится внутри библиотеки co.

Из оригинального скрипта там только одна строка (первая):

at g (eval at runJS …, <anonymous>:13:9)

То есть, стек говорит, что ошибка возникла в строке 13:

// строка 13 из кода выше
throw new Error("my error");

Что ж, спасибо. Но как мы оказались на этой строке? Об этом в стеке нет ни слова!

Заменим в строке (*) вызов yield на yield*:

co(function*() {

  // заменили yield на yield*
  yield* g(); // (*)

}).catch(function(err) {
  alert(err.stack);
});

function* g() {
  throw new Error("my error");
}

Пример стека теперь:

at g (eval at runJS …, <anonymous>:13:9)
  at GeneratorFunctionPrototype.next (native)
  at eval (eval at runJS …, <anonymous>:6:10)
  at GeneratorFunctionPrototype.next (native)
  at onFulfilled (…/co/…/index.min.js:1:1136)
  at …/co/…/index.min.js:1:1076
  at co (…/co/…/index.min.js:1:1039)
  at eval (eval at runJS …, <anonymous>:3:1)
  at eval (native)
  at runJS (…)

Если очистить от вспомогательных вызовов, то эти строки – как раз то, что нам надо:

at g (eval at runJS …, <anonymous>:13:9)
  at eval (eval at runJS …, <anonymous>:6:10)
  at eval (eval at runJS …, <anonymous>:3:1)

Теперь видно, что (читаем снизу) исходный вызов был в строке 3, далее – вложенный в строке 6, и затем произошла ошибка в строке 13.

Почему вариант с простым yield не работает – достаточно очевидно, если внимательно посмотреть на код и воспроизвести в уме, как он функционирует. Оставляем это упражнение вдумчивому читателю.

Итого, рекомендация уже достаточно обоснована – при запуске вложенных генераторов используем yield*.

Итого

  • Генераторы создаются при помощи функций-генераторов function*(…) {…}.
  • Внутри генераторов и только внутри них разрешён оператор yield. Это иногда создаёт неудобства, поскольку в колбэках .map/.forEach сделать yield нельзя. Впрочем, можно сделать yield массива (при использовании co).
  • Внешний код и генератор обмениваются промежуточными результатами посредством вызовов next/yield.
  • Генераторы позволяют писать плоский асинхронный код, при помощи библиотеки co.

Что касается кросс-браузерной поддержки – она стремительно приближается. Пока же можно использовать генераторы вместе с Babel.

Карта учебника