Углублённое руководство по JavaScript: генераторы. Часть 1, основы

в 11:18, , рубрики: javascript, Блог компании Mail.Ru Group, генераторы, никто не читает теги, Программирование, Разработка веб-сайтов
Углублённое руководство по JavaScript: генераторы. Часть 1, основы - 1

В этой серии статей я расскажу почти всё, что нужно знать о генераторах в JavaScript: что это такое, как их использовать и какие тонкости с ними связаны. И, как всегда, начнём мы с основ — общего представления о том, что такое генераторы.

Я не исхожу из того, что вы хоть что-то знаете о генераторах. Но вам требуется хорошо разбираться в итераторах и итерируемых объектах в JavaScript. Если вы с ними не знакомы или «плаваете в теме», то сначала углублённо изучите их. Если же вы владеете этими знаниями, то можно погружаться в мир генераторов. Это очень странный мир, в котором многое совершенно не похоже на то, что вы используете в обычном JS-коде. При этом сам механизм очень прост, и даже после прочтения этой статьи вы сможете уверенно использовать генераторы. Приступим!

Мотивация

«А зачем мне вообще учиться использовать генераторы?» — спросите вы. Очень честный вопрос. В самом деле, генераторы пока ещё довольно экзотическая фича, во многих кодовых базах они применяются редко. Но есть проблемы, которые с помощью генераторов решаются на удивление элегантно. В следующей статье я покажу подобный пример. И после того, как мы освоим генераторы, попробуем объединить их с React, чтобы получить код, который значительно превосходит тот, что основан на хуках. Надеюсь, это вдохновит вас на поиск своих сценариев применения генераторов.

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

Полагаю, в мире React самым популярным является пакет redux-saga, это промежуточное ПО для Redux, позволяющее писать код с побочными эффектами, который к тому же очень удобочитаем и прекрасно тестируется (а это редкость!).

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

Введение

Если бы мне пришлось объяснять суть генераторов одним предложением, я бы написал так: «Это синтаксический сахар для создания итераторов». Конечно, такое описание и вовсе не охватывает природу и возможности генераторов. Но всё же близко к правде.

Давайте возьмём простую функцию, возвращающую число:

function getNumber() {
    return 5;
}

Если её типизировать с помощью TypeScript, то мы бы сказали, что она возвращает числовой тип:

function getNumber(): number {
    return 5;
}

Чтобы превратить функцию в генератор, после ключевого слова function нужно добавить знак *:

function* getNumber(): number {
    return 5;
}

Но если бы мы и правда делали это на TypeScript, то компилятор начал бы жаловаться, потому что функция-генератор возвращает не просто значение, которое было возвращено в её теле.

Она возвращает итератор!

Если мы изменим типизацию так:

function* getNumber(): Iterator<number> {
    return 5;
}

то компилятор TypeScript проглотит код без вопросов. Но это TypeScript. А теперь давайте посмотрим, вернёт ли function* итератор в чистом JavaScript. Например, применительно к тому, что вернул генератор, попробуем вызвать такой метод:

const probablyIterator = getNumber();

console.log(probablyIterator.next());

Не только работает, но и выводит в консоль { value: 5, done: true }. На самом деле очень разумное поведение. В некотором смысле функция является итерабельной, возвращает всего одно значение и завершается.

А можно ли вернуть из генератора несколько значений? Вероятно, первым делом вы подумали о нескольких возвращениях:

function* getNumber() {
    return 1;
    return 2;
    return 3;
}

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

Однако… этот вариант не работает. Выполним код:

const iterator = getNumber();

console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());

И получим результат:

{ value: 1, done: true }
{ value: undefined, done: true }
{ value: undefined, done: true }

Получили только первое значение, а затем итератор застрял в состоянии «done». Любопытно, что мы можем лишь однократно обратиться к возвращённому значению, потому что последующие вызовы next возвращают лишь undefined.

И такое поведение тоже совершенно верное. Оно подчиняется основному правилу для всех функций: return всегда останавливает исполнение тела функции, даже если после return ещё есть какой-нибудь код. Это верно и для функций-генераторов.

Но всё же есть способ «вернуть» из нашего генератора несколько значений. Для этого предназначено ключевое слово yield:

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

Снова выполним код:

const iterator = getNumber();

console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());

Получилось!

{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }

То есть извлечение значений из генератора позволяет создать итератор, который возвращает несколько значений.

А что будет, если после этого ещё несколько раз вызвать next? Функция поведёт себя как обычный итератор, постоянно возвращая объект { value: undefined, done: true }.

Теперь обратите внимание, что последней строкой генератора тоже является yield. Изменится ли что-то, если поменять её на return?

function* getNumber() {
    yield 1;
    yield 2;
    return 3; // note that we used a `return` here!
}

const iterator = getNumber();

console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());

Получаем:

{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: true }  // now done is true here!

Любопытно. Делает всё то же самое, но свойство done становится true на один шаг раньше. Быть может, вы помните, что свойство done возвращаемого объекта определяет, должен ли продолжаться цикл for ... of.

Посмотрим, как ведут себя обе версии генератора getNumber с циклами for ... of.

Сначала запустим версию с тремя извлечениями:

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

const iterator = getNumber();

for (let element of iterator) {
    console.log(element);
}

Получаем:

1
2
3

Так и должен себя вести итератор.

Теперь запустим генератор с двумя извлечениями и одним возвращением:

function* getNumber() {
    yield 1;
    yield 2;
    return 3; // only this line changed
}

const iterator = getNumber();

for (let element of iterator) {
    console.log(element);
}

Получаем:

1
2

Очень интересно. Если подумать, то именно так ведут себя итераторы с циклом for ... of. Свойство done решает, должен ли выполняться следующий этап итерации.

Посмотрите, как в статье об итерируемых объектах мы эмулировали цикл for ... of с while:

let result = iterator.next();

while (!result.done) {
    const element = result.value;

    console.log(element);

    result = iterator.next();
}

В этом коде если вы при вызове iterator.next() получите объект { value: 3, done: true }, то число 3 тоже не появится в консоли. Причина в том, что перед вызовом console.log(element) идёт условие !result.done. А поскольку для объекта { value: 3, done: true } это условие имеет значение false, тело while не будет выполнено для числа 3.

И циклы for ... of работают точно так же.

То есть правило простое: хотите, чтобы появилось значение из цикла for ... of? Применяйте yield! Хотите вернуть значение из генератора, но не включать его в итерацию for ... of? Применяйте return!

Поток управления в генераторах

В генераторах можно использовать все обычные конструкции потока управления. Например, можно выбрать число для извлечения в зависимости от переданного в генератор аргумента:

function* getNumber(beWeird) {
    yield 1;

    if(beWeird) {
        yield -100;
    } else {
        yield 2;
    }

    yield 3;
}

Вызов getNumber(false) создаст итератор, возвращающий числа 1, 2, 3. А вызов getNumber(true) создаст итератор, возвращающий числа 1, -100, 3.

Кроме того, в генераторах можно даже использовать циклы! Именно в этом проявляется их сила.

В статье об итерируемых объектах мы создали бесконечный итератор, который генерировал числа 0, 1, 2, 3,… и вплоть до бесконечности. Это было не слишком сложно, но и код получился не самым удобочитаемым. Теперь же мы можем сделать генератор всего в несколько простых строк:

function* counterGenerator() {
    let index = 0;

    while(true) {
        yield index;
        index++;
    }
}

Сначала задаём index значение 0, а затем бесконечно исполняем цикл while(true). В нём мы извлекаем текущий index, а потом просто увеличиваем его на единицу. И следующим шагом извлекаем новое значение.

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

Посмотрите, как далеко мы ушли: мы все привыкли к функциям, которые возвращают только одно значение, а теперь пишем функцию, которая «возвращает» практически… вечно!

Отправка значений в генератор

Выше мы узнали, что можно с помощью генераторов создавать обычные итераторы. Но возвращаемый итератор выглядит странно. Он позволяет нам… передавать значения обратно в генератор!

Расширим наш предыдущий пример:

function* getNumber() {
    const first = yield 1;
    const second = yield 2;
    const third = yield 3;
}

Здесь мы по прежнему просто извлекаем из генератора числа, а также присваиваем переменным то, что вычисляют их выражения yield <numbеr>. Очевидно, что сейчас эти переменные никак не используются. Ради иллюстрации мы будем их просто журналировать, но вы можете делать с ними что угодно.

Добавим в начало функции дополнительный журнал:

function* getNumber() {
    console.log('start');

    const first = yield 1;
    console.log(first);

    const second = yield 2;
    console.log(second);

    const third = yield 3;
    console.log(third);
}

А затем будем исполнять этот генератор несколько раз. Рекомендую скопировать этот код куда-нибудь или открыть статью в другой вкладке. Вам будет гораздо проще понять, что происходит, если по мере запуска примеров вы сможете как можно чаще возвращаться к этому генератору!

Запустим новый генератор:

for (let element of getNumber()) {
    console.log(element);
}
What we get is:
start
1
undefined
2
undefined
3
undefined

Надеюсь, вам понятно, какой журнал относится к генератору, а какой к циклу for ... of. Вот ответы:

start          <- generator
1              <- loop
undefined      <- generator
2              <- loop
undefined      <- generator
3              <- loop
undefined      <- generator

Очевидно, что результатами выражений yield <numbеr> являются просто undefined. Но можно это изменить! Для этого придётся убрать цикл for ... of и использовать итератор вручную.

Вызовем четыре раза метод next из итератора, чтобы получить три числа и последний объект с переменной done в значении true. И будем журналировать все результаты вызовов next.

const iterator = getNumber();

console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());

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

start
{ value: 1, done: false }
undefined
{ value: 2, done: false }
undefined
{ value: 3, done: false }
undefined
{ value: undefined, done: true }

Здесь мало что поменялось: значения undefined никуда не делись. Мы лишь заменили числа из цикла for ... of на журналирование всех объектов из вызовов next.

Генераторы разумно используют гибкость интерфейса итератора. Ведь у него должен быть метод next, возвращающий объект вида { done, value }. Но никто не говорил, что этот метод не может принимать какие-нибудь аргументы! Он будет по прежнему удовлетворять интерфейсу, пока возвращает объект ожидаемого вида!

Давайте передадим несколько строк в вызовы next:

const iterator = getNumber();

console.log(iterator.next('a'));
console.log(iterator.next('b'));
console.log(iterator.next('c'));
console.log(iterator.next('d'));

После исполнения мы видим в консоли ещё что-то кроме undefined:

start
{ value: 1, done: false }
b                                <- no more undefined
{ value: 2, done: false }
c                                <- no more undefined
{ value: 3, done: false }
d                                <- no more undefined
{ value: undefined, done: true }

Возможно, результат вас удивил. Ведь первой переданной в next буквой была a, а здесь мы видим только b, c и d. Но если разобрать выполнение пошагово, то всё станет понятно.

Вызов next заставляет генератор выполняться, пока он не дойдёт до вызова yield <some vаlue>. Тогда будет возвращена часть <sоme value> из вызова next (в качестве значения объекта { value, done }). С этого момента генератор просто ждёт следующего вызова next. Значение, переданное в этот другой вызов next, станет тем значением, которое вычислит выражение yield <sоmething>.

Разберём всё по шагам.

Когда вы в первый раз вызвали next, он просто начал исполнять функцию-генератор. В нашем случае это означает, что будет исполнено console.log('start').

const iterator = getNumber();

iterator.next('a');
results in the following:
start

В генераторе после console.log('start') мы доходим до выражения yield 1. Число 1 будет возвращено из первого вызова next, который мы только что сделали. Чтобы проверить это, можете обернуть вызов next в console.log:

const iterator = getNumber();
console.log(iterator.next('a'));

Вот что мы получили:

start
{ value: 1, done: false }

Как раз единицу мы и извлекли из генератора.

Сейчас генератор приостановлен. Даже выражение, в котором мы дошли до yieldconst first = yield 1; — не было выполнено целиком. Ведь генератор пока не знает, какое значение должно быть у yield 1. Дадим ему это значение с помощью следующего вызова next:

const iterator = getNumber();

console.log(iterator.next('a'));
iterator.next('b');

Получили:

start
{ value: 1, done: false }
b

То есть генератор продолжил исполнение и заменил yield 1 на значение, которое мы передали в вызов next — строку b.

Чтобы закрепить понимание, можете здесь передать какие нибудь другие значения:

const iterator = getNumber();

console.log(iterator.next('a'));
iterator.next('this is some other string, which we created for tutorial purposes');

Это даст такой результат (надеюсь, теперь вам понятно, почему):

start
{ value: 1, done: false }
this is some other string, which we created for tutorial purposes

Именно вы здесь решаете, что должно вычислить выражение yield 1.

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

Дойдя до yield <sоme value>, генератор говорит: «я верну <sоme value> в текущем вызове next, а в следующем вызове next дай мне в качестве аргумента то, что я должен заменить на yield <sоme value>». И это означает, что переданный в первый вызов next аргумент никогда не будет использован генератором. Его просто некуда предоставить, так что уберём его из примера:

const iterator = getNumber();

console.log(iterator.next()); // no need to pass anything on the first `next` call
iterator.next('b');

После второго вызова next генератор продолжил исполнение кода, пока не дошёл до другого выражения yieldyield 2. Поэтому число 2 возвращено в качестве значения из этого вызова next.

То есть этот код:

const iterator = getNumber();

console.log(iterator.next());
console.log(iterator.next('b'));

выводит:

start
{ value: 1, done: false }
b
{ value: 2, done: false }

Что тут происходит? Генератор не знает, какое значение нужно получить при вычислении yield 2 в выражении const second = yield 2;. Поэтому он просто ждёт, пока вы не передадите новое значение в вызов next:

const iterator = getNumber();

console.log(iterator.next());
console.log(iterator.next('b'));
iterator.next('c');

Теперь мы получили:

start
{ value: 1, done: false }
b
{ value: 2, done: false }
c

То есть после третьего вызова next код генератора возобновляет исполнение, пока не доходит до yield 3. И число 3 возвращается в качестве значения из этого вызова:

const iterator = getNumber();

console.log(iterator.next());
console.log(iterator.next('b'));
console.log(iterator.next('c')); // we've added console.log here

Получили:

start
{ value: 1, done: false }
b
{ value: 2, done: false }
c
{ value: 3, done: false }

Теперь генератор приостановлен на выражении const third = yield 3;. Мы знаем, как снова его запустить:

const iterator = getNumber();

console.log(iterator.next());
console.log(iterator.next('b'));
console.log(iterator.next('c'));
iterator.next('d'); // we've added another next call

Получили:

start
{ value: 1, done: false }
b
{ value: 2, done: false }
c
{ value: 3, done: false }
d

И поскольку генератор не содержит других выражений yield, то и не возвращает других значений. Он выполняется вплоть до своего завершения. Поэтому последний объект { done, value } из вызова next не содержит значения и уведомляет о завершении итератора.

Этот код:

const iterator = getNumber();

console.log(iterator.next());
console.log(iterator.next('b'));
console.log(iterator.next('c'));
console.log(iterator.next('d')); // we've added console.log here

выводит:

start
{ value: 1, done: false }
b
{ value: 2, done: false }
c
{ value: 3, done: false }
d
{ value: undefined, done: true }

И всё! Если вы запутались, то прогоните примеры самостоятельно. Можете помочь себе, пошагово добавляя успешные вызовы next и console.log. Постарайтесь также всегда контролировать, в какой строке генератора вы сейчас находитесь. Помните! Нужно разбирать генератор пошагово, чтобы точно разобраться в ситуации! Не ограничивайтесь чтением статьи, прогоните пример самостоятельно столько раз, сколько потребуется для полного понимания происходящего!

Заключение

Мы изучили основы работы генераторов. Узнали, как их создавать, как использовать ключевое слово yield и генераторы.

Надеюсь, первые упражнения и примеры вдохновили вас узнать больше. Нам предстоит ещё многое рассмотреть в будущих статьях.

Автор: Макс

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js