Генераторы в JS — что это, зачем это и почему это красиво
Введение
Долгое время генераторы оставались для меня такой странной фичей JS, которая, казалось мне, нужна только для того, чтобы спрашивать о них на собеседованиях (я так не делал, но, когда интервьюировали меня, о генераторах спрашивали довольно часто). Но не так давно я понял, насколько же это важная и интересная штука.
Немного о том, как работать с генераторами
Генераторы похожи на функции, но значительно от них отличаются. Обычные функции возвращают либо что-то одно, либо ничего. Генераторы же могут, приостанавливая свое выполнение, вернуть неограниченное количество значений. Это очень пересекается с понятием “монада”, о котором вы можете почитать в моей статье о монадах. Более того, генераторы решают кучу проблем, связанных с монадами — об этом я расскажу чуть позже.
А пока — давайте посмотрим на пример:
function* generateNumberSequence() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
yield 6;
yield 7;
yield 8;
yield 9;
return 10;
}
Функцию-генератор мы объявляем именно так, function*
, со звездочкой. Когда мы выполняем такую функцию, мы получаем сам генератор:
const generator = generateNumberSequence();
У генератора есть метод next()
, который выполняет все, пока не встретит ключевое слово yield
. Тогда он вернет значение из yield
(удобно представлять себе yield
как неокончательный return
) или undefined
, если такого значения нет. После этого генератор приостановит выполнение и будет ждать следующего вызова next()
.
Результатом вызова next()
всегда становится такой объект:
{
value, // значение из yield
done // true, если генератор полностью выполнен, в ином случае false
}
Давайте поглядим на пример целиком:
function* generateNumberSequence() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
yield 6;
yield 7;
yield 8;
yield 9;
return 10;
}
const generator = generateNumberSequence();
const one = generator.next(); // { value: 1, done: false }
const two = generator.next(); // { value: 2, done: false }
// и так далее, до:
const ten = generator.next() // { value: 10, done: true }
// по значению done: true мы можем понять, что генератор полностью выполнен.
// мы можем и дальше запускать next() неограниченное количество раз, но это бесполезно:
const oneMoreTime = generator.next() // { done: true }
const andLastTime = generator.next() // { done: true }
Генератор как Iterable (перебор генераторов)
Достаточно очевидно, что из-за наличия метода next()
генераторы — это перебираемые объекты. Это значит, что мы можем делать так:
const generator = generateNumberSequence();
for (const value of generator) {
console.log(value)
}
/*
1
2
3
4
5
6
7
8
9
*/
Выглядит приятнее, да? Но есть момент: return
в таком случае не выполнится, и мы не увидим значения 10
. Поэтому можно просто не использовать return
в генераторах, ограничившись yield
.
Еще момент: генераторы не обязательно конечны. Мы можем написать и бесконечный генератор:
function* infiniteGenerator() {
for (let i = 0; i < 10; i--) {
yield i;
}
}
// если и использовать такие генераторы, то нужно не забывать про break / return
Композиция генераторов
В случае с обычными функциями, чтобы их объединить, мы отдельно исполняем их, сохраняя промежуточные результаты и объединяя их в конце. А у генераторов есть очень интересная возможность, в отличие от обычных функций: мы можем встраивать генераторы друг в друга, используя синтаксис yield*
.
Смотрите, что можно сделать:
// генератор последовательностей от start до end
function* generateNumbers(start, end) {
for (let i = start; i <= end; i++) {
yield i;
}
}
function* sequenceGenerator() {
yield* generateNumbers(1, 100);
yield* generateNumbers(200, 300);
}
for (const num of sequenceGenerator()) {
console.log(num);
}
// 1 .. 100 200 .. 300
Таким образом, не используя дополнительную память для сохранения промежуточных результатов и не разрабатывая огромный генератор на очень много строк, мы пишем гораздо более оптимальный и читаемый код.
И еще немного интересного об yield
До этого момента мы использовали yield
только для того, чтобы что-то отдать из генератора наружу. Хоть это само по себе очень полезно в том смысле, что мы можем собирать очень кастомные Iterable
-объекты, но это не все, что yield
умеет — еще с помощью него мы можем снаружи передать что-то в генератор, ведь в next()
можно передавать аргументы, которые становятся результатом yield
. Смотрите:
function* inputGen() {
let result = yield 'awaiting something from the outside'
yield result;
yield result.toUpperCase();
}
const gen = inputGen();
console.log(gen.next().value); // awaiting something from the outside
console.log(gen.next('something').value); // something
console.log(gen.next().value); // SOMETHING
Видите? Мы передали что-то в next, в генераторе сохранили это в result и применили к нему .toUpperCase()
. Таким образом, генератор — это не просто прокачанный перебираемый объект, это нечто гораздо более функциональное.
Еще один интересный метод генераторов — throw
, с помощью которого мы можем выкидывать ошибки из генераторов. Смотрите:
function* inputGen() {
let result = yield 'awaiting something from the outside'
}
const gen = inputGen();
const question = gen.next().value;
try {
gen.throw(new Error('Ответ не найден'));
} catch (e) {
console.log(e); // Ответ не найден
}
Итоги
Вообще, генераторы в реальном коде используются редко. Но как способ работы с разными монадическими структурами (о монадах — здесь) они крайне полезны. Да и вообще, создавать перебираемые объекты, которые во время выполнения могут обмениваться данными с внешним миром — крутая возможность. В общем, учим генераторы :)
Интересный пост?
Вот еще похожие:
- Событийно-ориентированная архитектура и Node.js Events
- Реактивное программирование: теория и практика
- Как и зачем писать тесты?
- Функциональное программирование. Что это и зачем?
- Профилирование Node.js-приложений