ES6 в деталях: генераторы, продолжение

ES6 в деталях — это цикл статей о новых возможностях языка программирования JavaScript, появившихся в 6 редакции стандарта ECMAScript, кратко — ES6.

Добро пожаловать обратно к ES6 в деталях! Я надеюсь, время нашего летнего перерыва вы провели так же весело, как и я. Но жизнь программиста не может состоять из одних только фейерверков и лимонада. Пришло время вернуться к тому, где мы остановились. И у меня заготовлена тема, которая для этого идеально подходит.

Ещё в мае я писал про генераторы, новый вид функций, появившихся в ES6. Я назвал их самой волшебной функциональностью ES6. Я говорил о том, как они могут изменить будущее асинхронного программирования. И затем я написал это:

О генераторах ещё можно многое рассказать… Но я считаю, что эта статья уже достаточно длинная, и из неё и так можно узнать много нового. Как и генераторы, мы пока приостановимся и закончим позднее.

Вот и пришло время.

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

Краткий обзор

В прошлый раз мы обратили внимание на базовое поведение генераторов. Оно немного странное, наверное, но не сложное для понимания. Функция-генератор во многом похожа на обычную функцию. Основное отличие в том, что тело функции-генератора не выполняется всё сразу. Она работает понемногу раз за разом, приостанавливая выполнение всякий раз, как достигнет выражения yield.

В первой части было подробное объяснение, но мы так и не рассмотрели ни одного примера того, как все эти части взаимодействуют. Давайте этим и займёмся.

function* someWords() {
  yield "hello";
  yield "world";
}

for (var word of someWords()) {
  alert(word);
}

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

СЦЕНА - ВНУТРЕННОСТИ КОМПЬЮТЕРА, ДЕНЬ

ЦИКЛ FOR стоит в одиночестве на сцене, на голове каска, в руках
доска с зажимом, очень деловитый.

                          ЦИКЛ FOR
                         (вызывает)
                        someWords()!

Входит ГЕНЕРАТОР: высокий латунный механический джентльмен.
Выглядит дружелюбно, но неподвижен будто статуя.

                          ЦИКЛ FOR
                       (потирая руки)
                      Итак, приступим!
                        (генератору)
                          .next()!

ГЕНЕРАТОР оживает.

                         ГЕНЕРАТОР
               {value: "hello", done: false}

Генератор застывает в дурацкой позе.

                          ЦИКЛ FOR
                           alert!

Сломя голову вбегает ALERT, с широко распахнутыми глазами,
запыхавшийся. Складывается ощущение, что он такой всегда.

                          ЦИКЛ FOR
                  Скажи пользователю "hello".

ALERT разворачивается и рывком убегает за сцену.

                           ALERT
                   (из-за кулис, кричит)
                       Всё остановить!
          Страница на hacks.mozilla.org сообщает:
                          "hello"!

Пауза несколько секунд, затем ALERT мчится обратно, подбегает
к ЦИКЛУ FOR и с юзом останавливается.

                           ALERT
                  Пользователь говорит, OK

                          ЦИКЛ FOR
                       (потирая руки)
                      Итак, приступим!
                (поворачивается к ГЕНЕРАТОРУ)
                          .next()!

ГЕНЕРАТОР снова оживает.

                         ГЕНЕРАТОР
               {value: "world", done: false}

Он застывает в другой дурацкой позе.

                          ЦИКЛ FOR
                           alert!

                           ALERT
                        (уже бежит)
                          Я мигом!
                   (из-за кулис, кричит)
                       Всё остановить!
          Страница на hacks.mozilla.org сообщает:
                          "world"!

Снова пауза, затем ALERT плетётся обратно на сцену, неожиданно
расстроенный.

                           ALERT
             Пользователь снова говорит, OK, но...
            но пожалуйста, запрети этой странице
                 показывать диалоговые окна.

Уходит с недовольным видом.

                          ЦИКЛ FOR
                       (потирая руки)
                      Итак, приступим!
                (поворачивается к ГЕНЕРАТОРУ)
                          .next()!

ГЕНЕРАТОР оживает в третий раз.

                          ГЕНЕРАТОР
                      (с достоинством)
               {value: undefined, done: true}

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

                          ЦИКЛ FOR
                Время для обеденного перерыва.

Уходит.

Через какое-то время входит СБОРЩИК МУСОРА, поднимает
безжизненного ГЕНЕРАТОРА и уносит за кулисы.

Ну да, не то чтобы прямо Гамлет. Но вы поняли.

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

Это действие синхронное и однопоточное. Заметьте, в любой момент времени что-то делает только какой-то один из персонажей. Персонажи никогда не прерывают и не перекрикивают друг друга. Они говорят по очереди, и кто бы ни говорил, он может продолжать столько, сколько хочет. (Точно как у Шекспира!)

И похожая разновидность этой драмы разворачивается всегда, когда генератор передаётся в цикл for-of. Всегда есть последовательность вызовов метода .next(), которая нигде в вашем коде не указана. Здесь я их сделал явными, но для вас и ваших программ, всё это будет происходить за кулисами, потому что генераторы и циклы for-of были разработаны чтобы взаимодействовать друг с другом через интерфейс итераторов.

Итак, подведём промежуточные итоги:

Как выключить генератор

У генераторов есть несколько интересных особенностей, о которых я не рассказывал в первой части:

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

Например, в определённый момент вам, возможно, захочется использовать что-то подобное:

function doThings() {
  setup();
  try {
    // ... что-то делаем ...
  } finally {
    cleanup(); // убираем за собой
  }
}

doThings();

Уборка может включать в себя закрытие соединений или файлов, освобождение системных ресурсов, или просто обращение к DOM с целью выключить спиннер «мы ещё в процессе». Мы хотим, чтобы это происходило в независимости от того, завершилась ли наша работа успешно, или нет, и для этого используем блок finally.

Как это будет выглядеть в генераторе?

function* produceValues() {
  setup();
  try {
    // ... отдаём какие-то значения ...
  } finally {
    cleanup(); // убираем за собой
  }
}

for (var value of produceValues()) {
  work(value);
}

Выглядит нормально. Но есть нюанс: вызов work(value) не находится внутри блока try. Если он бросит исключение, то что произойдёт с этапом уборки?

Или предположим, что цикл for-of содержит инструкцию break или return. Что тогда произойдёт с уборкой?

Она всё равно выполнится. ES6 вас прикроет.

Когда мы впервые говорили об итераторах и цикле for-of, мы упомянули, что интерфейс итератора содержит опциональный метод .return(), который автоматически вызывается языком когда итерация заканчивается, перед тем, как итератор сказал, что он закончил. Генераторы поддерживают этот метод. Вызов myGenerator.return() заставляет генератор выполнить все блоки finally и выйти, как если бы текущий yield таинственным образом превратился в инструкцию return.

Обратите внимание, .return() вызывается языком автоматически не во всех контекстах, а только когда используется протокол итераторов. Поэтому возможна ситуация, когда генератор уйдёт сборщику мусора, а его блок finally так и не отработает.

Как это поведение выглядело бы на сцене? Генератор заморожен посреди задачи, которая требует какой-то подготовки, например, строительство небоскрёба. Вдруг кто-то бросает ошибку! Цикл for ловит её и откладывает в сторону. Он говорит генератору: return(). Генератор молча разбирает все свои строительные леса и выключается. Затем цикл for достаёт отложенную ошибку, и продолжается обычный процесс обработки ошибки.

Ответственность на генераторах

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

(Поддельный скриншот сообщений iPhone между генератором и его пользователем, где пользователь лишь говорит раз за разом 'next', а генератор отвечает значениями.)

Ответственность лежит на коде, который использует генератор. Генератор лишь работает по запросу. Но это не единственный подход к программированию генераторов.

В первой части я говорил, что генераторы могут использоваться для асинхронного программирования. То, что вы делаете при помощи асинхронных коллбеков или цепочек промисов, можно сделать при помощи генераторов. У вас, наверное, возникнет вопрос, как это должно работать. Почему возможности отдавать значения (в конце концов, это единственная суперспособность генераторов) придаётся такое значение? Ведь асинхронный код не только отдаёт значения. Он заставляет всякие штуки происходить. Он запрашивает данные из файлов и баз данных. Он отправляет серверам запросы. И затем он возвращается к событийному циклу и ждёт, пока эти асинхронные процессы не завершатся. Как именно генераторы должны это делать? И каким образом без коллбеков генератор получит данные из всех этих файлов, баз данных и серверов, когда они прийдут?

Давайте разбираться. Представьте, что бы было, если бы у кода, вызывающего .next(), был способ передать значение обратно в генератор. Одного этого изменения хватило бы для того, чтобы общение качественно изменилось:

(Поддельный скриншот сообщений iPhone между генератором и вызывающим кодом. Каждое значение, отдаваемое генератором,— это требование. А вызывающий код отдаёт требуемое в виде аргумена в следующий раз, как вызовет .next().)

И метод генераторов .next() в самом деле принимает опциональный аргумент, и есть одна хитрость: этот аргумент для генератора выглядит как значение, возвращаемое выражением yield. То есть, yield — это не инструкция вроде return, это выражение, которое получает значение как только генератор возобновляет работу.

var results = yield getDataAndLatte(request.areaCode);

Для одной строчки кода тут происходит много всего:

Добавим контекста, вот код для всего разговора с картинки выше:

function* handle(request) {
  var results = yield getDataAndLatte(request.areaCode);
  results.coffee.drink();
  var target = mostUrgentRecord(results.data);
  yield updateStatus(target.id, "ready");
}

Заметьте, yield всё ещё обозначает то же самое, что обозначал раньше: поставить генератор на паузу и передать значение обратно вызывающему коду. Но теперь всё изменилось! Этот генератор ожидает вполне определённого поведения от вызывающего кода — чтобы тот оказывал ему поддержку. Будто бы он ожидает, что вызывающий код примет на себя обязанности секретаря.

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

Как может выглядеть этот секретарь-вызыватель генераторов? Совсем не обязательно, что прямо уж так сложно. Например, он может выглядеть так:

function runGeneratorOnce(g, result) {
  var status = g.next(result);
  if (status.done) {
    return;  // фу-уф!
  }

  // Генератор попросил нас раздобыть что-то и
  // вызвать его, когда всё будет готово
  doAsynchronousWorkIncludingEspressoMachineOperations(
    status.value,
    (error, nextResult) => runGeneratorOnce(g, nextResult));
}

Чтобы запустить процесс, мы создаём генератор и один раз его вызываем, вот так:

runGeneratorOnce(handle(request), undefined);

В мае я упоминал Q.async() в качестве примера библиотеки, которая рассматривает генераторы как асинхронные процессы и автоматически запускает их по необходимости. runGeneratorOnce примерно такая же штука. На практике генераторы не будут отдавать строки с указаниями вызывающему коду, что ему делать. Скорее они будут отдавать объекты-промисы.

Если вы уже умеете работать с промисами и поняли, как работают генераторы, возможно вы захотите попробовать изменить runGeneratorOnce и добавить поддержку промисов. Это трудная задача, но когда вы с ней справитесь, вы сможете писать сложные асинхронные алгоритмы используя промисы как прямолинейный код, без единого намёка на .then() или коллбеки.

Как сломать генератор

Вы заметили, как функция runGeneratorOnce обрабатывает ошибки? Она их игнорирует!

Так дело не пойдёт. Нам бы очень хотелось каким-то образом сообщать генератору об ошибках. И генераторы имеют и такую возможность тоже: можно вызывать generator.throw(ошибка) вместо generator.next(результат). Это приведёт к тому, что выражение yield бросит ошибку. Как и в случае с .return(), обычно это убьёт генератор. Но если текущая точка yield находится внутри блока try, то сработают блоки catch и finally, и генератор может вернуться в строй.

Изменить runGeneratorOnce так, чтобы в случае необходимости вызывался метод .throw() — это ещё одно отличное упражнение. Имейте в виду, исключения, брошенные внутри генератора, всегда передаются вызывающему коду. Так что generator.throw(ошибка) кинется ошибкой прямо в вас, если генератор её не перехватит!

Это завершает список возможных вещей, которые могут произойти после того, как генератор достигнет выражения yield и приостановится:

Всё это ненамного сложнее старых обычных вызовов функций. Действительно новая возможность — это только .return().

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

Совместная работа генераторов

Давайте я покажу вам ещё одну возможность. Предположим, мы пишем простую функцию-генератор для конкатенации двух итерируемых объектов:

function* concat(iter1, iter2) {
  for (var value of iter1) {
    yield value;
  }
  for (var value of iter2) {
    yield value;
  }
}

В ES6 для этого есть краткая запись:

function* concat(iter1, iter2) {
  yield* iter1;
  yield* iter2;
}

Обычное выражение yield отдаёт одно значение, а выражение yield* принимает итератор целиком и отдаёт всё значения.

Этот же синтаксис решает другую занятную проблему: как вызвать генератор из генератора. С обычными функциями мы можем сгрести в кучу код и вынести его в отдельную функцию без изменения поведения. Очевидно, нам также захочется рефакторить и генераторы тоже. Но нам придётся как-то вызывать вынесенную процедуру и убедиться, что все значения, которые мы отдавали ранее, всё ещё отдаются, несмотря на то, что теперь их производит другая процедура. yield* — это способ добиться этого.

function* factoredOutChunkOfCode() { ... }

function* refactoredFunction() {
  ...
  yield* factoredOutChunkOfCode();
  ...
}

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

Занавес

Что ж, про генераторы это всё! Надеюсь, вам понравилось так же, как и мне. Я рад, что вернулся.

На следующей неделе мы поговорим об ещё одной ошеломительной возможности, которая также появилась в ES6. О новом виде объектов, настолько неявном и настолько хитроумном, что может оказаться, что вы используете его, даже не зная об этом. Присоединяйтесь к нам на следующей неделе и мы рассмотрим прокси-объекты из ES6 в деталях.