ES6 в деталях: генераторы

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

Мне не терпится вам всё рассказать. Сегодня мы будем обсуждать самую волшебную функциональность в ES6.

Что я имел в виду под словом «волшебную»? Во-первых, эта функциональность настолько отличается от всего того, что уже есть в JS, что поначалу может показаться колдовством. В том смысле, что она выворачивает обычное поведение языка наизнанку! Если это не магия, то я не знаю, что это.

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

Я излишне нахваливаю? Давайте углубимся, и вы сами рассудите.

Знакомьтесь, генераторы ES6

Что такое генераторы?

Начнём с рассмотрения одного генератора:

function* quips(name) {
  yield "привет, " + name + "!";
  yield "я надеюсь, вам нравятся статьи";
  if (name.startsWith("X")) {
    yield "как круто, что ваше имя начинается с X, " + name;
  }
  yield "увидимся!";
}

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

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

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

Что делают генераторы

Что произойдёт, если запустить функцию-генератор quips()?

> var iter = quips("jorendorff");
  [object Generator]
> iter.next()
  { value: "привет, jorendorff!", done: false }
> iter.next()
  { value: "я надеюсь, вам нравятся статьи", done: false }
> iter.next()
  { value: "увидимся!", done: false }
> iter.next()
  { value: undefined, done: true }

Возможно, вы очень привыкли к обычным функциям и тому, как они себя ведут. Когда их вызывают, они сразу же начинают выполняться и выполняются до тех пор, пока не вернут значение или не бросят исключение. Такое поведение само собой разумеется для любого JS-программиста.

Вызов генератора выглядит так же: quips("jorendorff"). Но после того, как вы вызовете генератор, он ещё не начнёт выполняться. Вместо этого он вернёт приостановленный объект Generator (в примере выше он под именем iter). Вы можете считать, что объект Generator — это вызов функции, замороженный во времени. Если точнее, он заморожен прямо в самом начале функции-генератора, перед первой строчкой кода.

Каждый раз, как вы вызываете метод .next() у объекта Generator, вызов функции оттаивает и выполняется, пока не достигнет следующего выражения yield.

Вот почему в примере выше после вызовов iter.next() мы всякий раз получали новое строковое значение. Эти значения производятся выражениями yield в теле quips().

При последнем вызове iter.next() мы, наконец, достигли конца функции-генератора, так что поле .done результата стало равно true. Добраться до конца функции — это всё равно что вернуть undefined, и именно поэтому поле .value результата равно undefined.

Похоже, сейчас самое время вернуться к странице с говорящей кошкой и как следует поиграться с кодом. Попробуйте добавить yield внутрь цикла. Что произойдёт?

Говоря техническим языком, каждый раз, когда генератор отдаёт значение, его стековый кадр: локальные переменные, аргументы, временные значения и текущая позиция точки выполнения внутри тела генератора — удаляется из стека. Однако, объект Generator хранит ссылку на этот стековый кадр (или его копию), так что последующий вызов .next() возобновит его и продолжит выполнение.

Стоит отметить, что генераторы не являются потоками выполнения. В языках с потоками различные куски кода могут выполняться одновременно, обычно приводя к состояниям гонки, недетерминированности и страстно желанному приросту производительности. Генераторы вообще на это не похожи. Когда генератор выполняется, он работает в том же потоке, что и код его вызвавший. Порядок выполнения последователен и строго определён, и нет никакой параллельности. В отличие от системных потоков, генератор останавливается только на тех местах, где в коде есть yield.

Хорошо. Теперь мы знаем, что такое, генераторы. Мы видели, как генераторы выполняются, приостанавливают и возобновляют свое выполнение. Теперь хороший вопрос: как эти странные возможности могут нам пригодиться?

Генераторы — итераторы

На прошлой неделе мы увидели, что в ES6 итераторы не просто один встроенный класс. Они — точка расширения языка. Вы можете создавать собственные итераторы, и для этого нужно лишь реализовать два метода: [Symbol.iterator]() и .next().

Но реализация интерфейса — это всегда работа, по меньшей мере, небольшая. Взглянем, как реализация итератора выглядит на практике. В качестве примера возьмём простой итератор range, который всего-навсего считает от одного числа до другого, как в старомодном цикле for (;;) из C.

// Должно "прозвенеть" трижды
for (var value of range(0, 3)) {
  alert("Динь! на этаже № " + value);
}

Вот одно решение, с использованием класса ES6. (Если синтаксис class вам не до конца ясен, не волнуйтесь, мы разберём его в одной из будущих статей.)

class RangeIterator {
  constructor(start, stop) {
    this.value = start;
    this.stop = stop;
  }

  [Symbol.iterator]() { return this; }

  next() {
    var value = this.value;
    if (value < this.stop) {
      this.value++;
      return {done: false, value: value};
    } else {
      return {done: true, value: undefined};
    }
  }
}

// Возвращает новый итератор, который считает от 'start' до 'stop'.
function range(start, stop) {
  return new RangeIterator(start, stop);
}

Посмотрите на код в действии.

Так реализация итератора выглядит в Java или Swift. Неплохо. Но вместе с тем и нетривиально. Есть ли ошибки в этом коде? Трудно сказать. Это выглядит совершенно непохоже на изначальный цикл for (;;), который мы пытаемся эмулировать: протокол итераторов заставляет нас разобрать этот цикл на части.

В этом месте вы можете слегка охладеть к итераторам. Может, ими и здорово пользоваться, но вот реализовывать их трудно.

Вам, возможно, не пришло бы в голову предлагать добавить новую, пугающую и мозголомную структуру потока выполнения в язык JS просто чтобы стало легче писать итераторы. Но раз уж у нас уже есть генераторы, можем ли мы их тут применить? Давайте попробуем:

function* range(start, stop) {
  for (var i = start; i < stop; i++)
    yield i;
}

Посмотрите на код в действии.

Вот этот генератор из 4 строчек полностью заменяет предыдущую 23-строчную реализацию range(), включая весь класс RangeIterator целиком. Это возможно потому что генераторы — это итераторы. У всех генераторов есть встроенная реализация .next() и [Symbol.iterator](). Всё, что вам нужно — это описать поведение цикла.

Реализация итераторов без генераторов похожа на случай, когда нужно написать длинное электронное письмо используя только пассивный залог. Когда нельзя просто сказать то, что имеется в виду, речь в итоге получается весьма запутанной. RangeIterator длинный и странный потому что он должен описывать функциональность цикла не используя синтаксис циклов. Генераторы — ответ на это.

Для чего ещё можно применить возможность генераторов вести себя как итераторы?

Итак, генераторы полезны? Разумеется. Это удивительно лёгкий способ реализации собственных итераторов, а итераторы — это новый стандарт для данных и циклов во всём ES6.

Но это ещё не всё, что генераторы могут делать. Может даже выясниться, что это не самое важное из того, что они делают.

Генераторы и асинхронный код

Вот такой код JS я писал раньше:

          };
        })
      });
    });
  });
});

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

Вот ещё кое-что из того JS-кода, что я писал:

}).on('close', function () {
  done(undefined, undefined);
}).on('error', function (error) {
  done(error);
});

В асинхронных API используются соглашения об обработке ошибок вместо исключений. У разных API могут быть разные соглашения. В большинстве из них ошибки просто игнорируются по умолчанию. В некоторых из них игнорируется по умолчанию даже обычное успешное выполнение.

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

Генераторы дают нам новую надежду, что это так не останется.

Q.async() — это экспериментальная попытка сделать асинхронный код похожим на синхронный при помощи генераторов и промисов. К примеру:

// Синхронный код, производящий шум
function makeNoise() {
  shake();
  rattle();
  roll();
}

// Асинхронный код, производящий шум
// Возвращает объект Promise, который разрешится,
// когда мы закончим шуметь
function makeNoise_async() {
  return Q.async(function* () {
    yield shake_async();
    yield rattle_async();
    yield roll_async();
  });
}

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

Если добавить конструкции вроде if или try/catch в версию Q.async, то всё будет работать точно так же, как если бы их добавили в синхронный код. По сравнению с другими способами написания асинхронного кода этот меньше всего ощущается как изучение нового языка.

Если вы дочитали до этого места, возможно, вам понравится очень подробная статья по этой теме от Джеймса Лонга (James Long).

Итак, генераторы освещают наш путь к новой модели асинхронного программирования, которая, кажется, лучше подходит для человеческого мозга. Эта работа ещё не окончена. Среди всего прочего, может помочь синтаксис получше. Предложение асинхронных функций, работающих на промисах и генераторах и вдохновлённых похожими возможностями в C#, уже внесено в таблицу на включение в ES7.

Когда можно воспользоваться этими безумными вещами?

На сервере вы можете применять генераторы уже сегодня в io.js (или в Node с параметром командной строки --harmony).

Из браузеров пока что генераторы поддерживают только Firefox 27+ и Chrome 39+. Чтобы применять генераторы в вебе, придётся воспользоваться Babel или Traceur и транслировать код ES6 в понятный всем браузерам ES5.

Ещё кое-что, что нельзя не упомянуть: Генераторы впервые были реализованы в JS Бренданом Айком (Brendan Eich), и его подход очень напоминал генераторы в Python, которые в свою очередь были вдохновлены Icon. Они появились в Firefox в далёком 2006. Путь к стандартизации был непростым, синтаксис и поведение за это время немного поменялись. Генераторы ES6 были реализованы как в Firefox, так и в Chrome мастером компиляции Энди Винго (Andy Wingo). Его работа спонсировалась Bloomberg.

yield;

О генераторах ещё можно многое рассказать. Мы не рассмотрели методы .throw() и .return(), необязательные аргументы .next() и синтаксис выражения yield*. Но я считаю, что эта статья уже достаточно длинная, и из неё и так можно узнать много нового. Как и генераторы, мы пока приостановимся и закончим позднее.

Но на следующей неделе давайте немного сменим тему. Мы охватили две сложные темы подряд. Разве не было бы здорово в следующий раз поговорить о функциональности ES6, которая не изменит вашу жизнь? О чем-нибудь простом и очевидно полезном? О чём-то, что вызовет у вас улыбку? В ES6 и такое есть.

В следующей статье: фича, которая прекрасно подойдёт к любому коду, который вы пишете каждый день. Присоединяйтесь на следующей неделе и мы рассмотрим шаблоны строк в деталях.