CSS-live.ru

ES6: генераторы изнутри

Перевод статьи ES6 Generators in Depth с сайта ponyfoo.com, опубликовано на css-live.ru с разрешения автора — Николаса Беваквы.

Это «ES6 изнутри», самая продолжительная серия статей в истории Pony Foo! ES5 уже надоел? Тогда приветствую! Для начала давайте поговорим про деструктирование, литералы шаблона, стрелочные функции, оператор расширения и оставшиеся параметры, улучшения в литералах объекта, новый «сахарок» — классы поверх прототипов, let, const и «Временную мёртвую зону», а также итераторы.

Как и в прошлых статьях, рекомендую вам установить Babel и повторять за мной, копируя примеры с помощью интерактивной оболочки REPL, либо командной строки babel-node и файла. Это поможет гораздо лучше усвоить идеи, обсуждаемые в серии. Если вы не из тех, кто любит устанавливать что-либо на свой компьютер, то вам есть смысл залезть на CodePen и кликнуть иконку с шестерёнкой для JavaScript — у него есть препроцессор Babel, который с лёгкостью позволяет опробовать ES6. Ещё одна довольно полезная альтернатива, это использовать онлайновый REPL для Babel — он показывает скомпилированный ES5-код справа от ES6-кода, чтобы быстро сравнить.

Пока мы не начали, позвольте беззастенчиво попросить вашей поддержки, если вы наслаждаетесь моей серией «ES6 изнутри». Ваши взносы пойдут на хостинг сайту, мне на еду, на то, чтоб я не отставал от графика, и на то, чтобы Pony Foo оставался поистине источником джаваскриптовых вкусняшек.

Спасибо, что выслушали, а теперь перейдём к генераторам! Если вы ещё не прочитали вчерашнюю статью про итераторы, то следует сделать это, поскольку сегодняшняя статья во многом на нее опирается.

Функции-генераторы и Объекты-генераторы

Генераторы — новая фича в ES6. Вы объявляете функцию-генератор, возвращающую объекты-генераторы g, которые затем можно перебирать с помощью Array.from(g), [...g], или циклов for значение of g. Функции-генераторы позволяют объявлять особый вид итератора. Эти итераторы могут приостанавливать исполнение, сохраняя при этом свой контекст. Недавно мы уже обсуждали итераторы и их метод .next(), который вызывается, чтобы извлекать значения из последовательности по одному.

Вот пример функции-генератора. Обратите внимание на * после function. Это не опечатка, именно так обозначается, что функция-генератор — это генератор.

function* generator () {
  yield 'f'
  yield 'o'
  yield 'o'
}

Объекты-генераторы соответствуют протоколам «Итерируемый» и «Итератор». Это значит…

var g = generator()
// объект-генератор g строится с помощью функции-генератора
typeof g[Symbol.iterator] === 'function'
// он итерируемый, поскольку у него есть @@iterator
typeof g.next === 'function'
// и также итератор, поскольку у него есть метод .next
g[Symbol.iterator]() === g
// итератор для объекта-генератора — это сам объект-генератор
console.log([...g])
// <- ['f', 'o', 'o']
console.log(Array.from(g))
// <- ['f', 'o', 'o']

(Эта статья начинает опасно напоминать лекцию по матану… )

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

Возьмём другой пример, в этот раз с рядом других операторов, идущих «вперемешку» с выражениями yield. Это простой генератор, но он ведёт себя необычным образом,

function* generator () {
  yield 'p'
  console.log('o')
  yield 'n'
  console.log('y')
  yield 'f'
  console.log('o')
  yield 'o'
  console.log('!')
}

Если использовать цикл for..of, то он выведет ponyfoo! По одному символу за раз, как и ожидалось.

var foo = generator()
for (let pony of foo) {
  console.log(pony)
  // <- 'p'
  // <- 'o'
  // <- 'n'
  // <- 'y'
  // <- 'f'
  // <- 'o'
  // <- 'o'
  // <- '!'
}

А как насчёт синтаксиса расширения [...foo]? Здесь всё немного иначе. Это может покажется неожиданным, но так работают генераторы, всё, что не попало в результат, становится побочным эффектом. При создании последовательности операторы console.log (между вызовами yield) выполняются и выводят символы в консоли до того, как foo заполнит собой массив. Предыдущий пример работал, поскольку мы выводили символы, как только они извлекались из последовательности, а не ждали, пока создастся диапазон для всей последовательности.

var foo = generator()
console.log([...foo])
// <- 'o'
// <- 'y'
// <- 'o'
// <- '!'
// <- ['p', 'n', 'f', 'o']

Приятный аспект функций-генераторов — можно также использовать yield*, чтобы делегировать другой функции-генератору. Как насчет извратного способа разбить строку 'ponyfoo' на символы? Поскольку строки в ES6 придерживаются протокола «Итерируемый», можно делать следующее.

function* generator () {
  yield* 'ponyfoo'
}
console.log([...generator()])
// <- ['p', 'o', 'n', 'y', 'f', 'o', 'o']

Конечно, в реальном мире мы могли бы просто делать [...'ponyfoo'], поскольку синтаксис расширения прекрасно поддерживает итерируемые объекты. Так же, как можно вызвать yield* со строкой, можно вызвать yield* со всем, что придерживается протокола «Итерируемый». А это другие генераторы, массивы, а с приходом ES6 — вообще практически всё.

var foo = {
  [Symbol.iterator]: () => ({
    items: ['p', 'o', 'n', 'y', 'f', 'o', 'o'],
    next: function next () {
      return {
        done: this.items.length === 0,
        value: this.items.shift()
      }
    }
  })
}
function* multiplier (value) {
  yield value * 2
  yield value * 3
  yield value * 4
  yield value * 5
}
function* trailmix () {
  yield 0
  yield* [1, 2]
  yield* [...multiplier(2)]
  yield* multiplier(3)
  yield* foo
}
console.log([...trailmix()])
// <- [0, 1, 2, 4, 6, 8, 10, 6, 9, 12, 15, 'p', 'o', 'n', 'y', 'f', 'o', 'o']

Можете также перебирать последовательность вручную, вызывая метод .next(). Этот подход даёт максимальный контроль над перебором, но также требует и максимального участия. Здесь можно использовать ещё несколько возможностей, которые позволят контролировать итерацию ещё лучше.

Перебор генераторов вручную

Помимо описанного выше перебора trailmix с помощью [...trailmix()], for value of trailmix() и Array.from(trailmix()), можно использовать напрямую генератор, полученный при вызове trailmix(), и перебрать его. Но trailmix уже сделал своё дело как пример для yield*, он и так вышел слишком сложным, давайте лучше рассмотрим это на старом примере генератора с побочными эффектами.

function* generator () {
  yield 'p'
  console.log('o')
  yield 'n'
  console.log('y')
  yield 'f'
  console.log('o')
  yield 'o'
  console.log('!')
}
var g = generator()
while (true) {
  let item = g.next()
  if (item.done) {
    break
  }
  console.log(item.value)
}

Как мы вчера узнали, у любых элементов, которые вернул итератор, будет два свойства: done, указывающее, закончилась ли последовательность, и value, указывающее текущее значение в последовательности.

Если вас удивляет, почему печатается "!", хотя за ним и нет никаких выражений yield — разгадка в том, что g.next() не знает об этом. Его суть в том, что при каждом вызове он выполняет метод до тех пор, пока скрипт не дойдёт до выражения yield, выкидывает своё значение и выполнение приостанавливается. При следующем вызове выполнение возобновляется с места приостановки (с последнего выражения yield) до тех пор, пока скрипт не дойдёт до следующего выражения yield. Когда скрипт не встречает ни одного выражения yield, генератор возвращает { done: true }, сообщающее о завершении последовательности. Однако, на этом этапе оператор console.log('!') уже выполнен.

Также стоит отметить, что во время приостановки и возобновления контекст сохраняется. Это значит, что генераторы могут сохранять своё состояние. По сути, как раз на генераторах будет реализована семантика async/await в грядущем ES7.

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

  • Выражение yield возвращает следующее значение в последовательности
  • Оператор return возвращает последнее значение в последовательности
  • Оператор throw останавливает выполнение в генераторе полностью
  • Функция-генератор дошла до конца, о чем сообщает { done: true }

По окончании перебора последовательности генератором g, последующие вызовы не повлияют на результат и просто вернут { done: true }.

function* generator () {
  yield 'only'
}
var g = generator()
console.log(g.next())
// <- { done: false, value: 'only' }
console.log(g.next())
// <- { done: true }
console.log(g.next())
// <- { done: true }

Генераторы: странные потрясающие моменты

Помимо .next у объектов-генераторов есть ещё пара методов — .return и .throw. Мы уже основательно обсудили .next, но не до конца. Можно использовать .next(value), чтобы передать значение в генератор.

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

var answers = [
  `Бесспорно`, `Предрешено `, `Никаких сомнений`,
  `Определённо да`, `Можешь быть уверен в этом`, `Мне кажется — «да»`,
  `Вероятнее всего`, `Хорошие перспективы`, `Да`, `Знаки говорят — «да»`,
  `Пока не ясно, попробуй снова`, `Спроси позже`, `Лучше не рассказывать`,
  `Сейчас нельзя предсказать`, `Сконцентрируйся и спроси опять`,
  `Даже не думай`, `Мой ответ — «нет»`, `По моим данным — «нет»)`,
  `Перспективы не очень хорошие`, `Весьма сомнительно`
]
function answer () {
  return answers[Math.floor(Math.random() * answers.length)]
}

Следующая функция-генератор может выступать в качестве «джинна», который ответит на любые возникшие вопросы. Заметьте, что мы отбрасываем первый результат из g.next(). Это потому, что первый вызов .nextзапускает генератор, но выражение yield еще не готово захватить value из g.next(value).

function* chat () {
  while (true) {
    let question = yield '[Джинн] ' + answer()
    console.log(question)
  }
}
var g = chat()
g.next()
console.log(g.next('[Я] ES6 умрёт мучительной смертью? ').value)
// <- '[Я] ES6 умрёт мучительной смертью?'
// <- '[Джинн] По моим данным — «нет»'
console.log(g.next('[Я] Как делааа?').value)
// <- '[Я] Как деелааа?'
// <- '[Джинн] Сконцентрируйся и спроси опять '

И всё-таки, втыкать g.next() где попало — как-то неаккуратненько. Нельзя ли как-нибудь иначе? Можно поменять участников действа ролями.

Инверсия управления

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

function* chat () {
  yield '[Я] ES6 умрёт мучительной смертью?'
  yield '[Я] Как делаааа?'
}
var g = chat()
while (true) {
  let question = g.next()
  if (question.done) {
    break
  }
  console.log(question.value)
  console.log('[Джинн] ' + answer())
  // <- '[Я] ES6 умрёт мучительной смертью?'
  // <- '[Джинн] Очень сомнительно'
  // <- '[Я] Как делаааа?'
  // <- '[Джинн] Мой ответ — «нет» '
}

Можно подумать, что в процессе перебора генератору придется изрядно «попотеть», но на деле генераторы облегчают перебор, приостанавливая собственное выполнение — и откладывая тяжелую работу на потом. Это один из самых мощных аспектов генераторов. Допустим, что итератор — метод genie в библиотеке, например:

function genie (questions) {
  var g = questions()
  while (true) {
    let question = g.next()
    if (question.done) {
      break
    }
    console.log(question.value)
    console.log('[Genie] ' + answer())
  }
}

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

genie(function* questions () {
  yield '[Я] ES6 умрёт мучительной смертью?'
  yield '[Я] Как делаааа?'
})

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

Работа с асинхронными потоками

А теперь представьте, что теперь библиотека genie получает свои ответы магического шара от API. На что это теперь будет похоже? Видимо, на что-то вроде кода ниже. Предположим, что вызов псевдокода xhr всегда даёт ответы в формате JSON типа { answer: 'No' }. Учтите, что это простой пример, который всего лишь последовательно обрабатывает вопрос за вопросом. Можно объединить различные, в том числе более сложные, алгоритмы управления потоком в зависимости от предмета поиска.

Это просто демонстрация абсолютной мощи генераторов.

function genie (questions) {
  var g = questions()
  pull()
  function pull () {
    let question = g.next()
    if (question.done) {
      return
    }
    ask(question.value, pull)
  }
  function ask (q, next) {
    xhr('https://computer.genie/?q=' + encodeURIComponent(q), got)
    function got (err, res, body) {
      if (err) {
        // todo
      }
      console.log(q)
      console.log('[Джинн] ' + body.answer)
      next()
    }
  }
}

Посмотрите живой пример на Babel-овском REPL

Даже несмотря на то, что наш метод genie стал асинхронным, и что теперь мы используем API для извлечения ответов на вопросы пользователя, то, как получатель использует библиотеку genie, передавая функцию-генератор questions, остается неизменным! Это потрясающе!

Мы ещё не рассматривали ситуацию, когда API выкидывает ошибку err. Это неудобно. Что мы можем здесь предпринять?

Выбрасывание ошибки в генераторе

Теперь, когда мы выяснили, что на самом деле самый важный аспект генераторов — это код потока управления, который решает, когда вызвать g.next(), мы можем взглянуть на два других метода и понять их суть. Не переключившись на подход «генератор определяет, что перебирать, а не как», мы бы вряд ли нашли, где можно было бы применить g.throw. Но теперь это кажется очевидным. Поток управления, который использует генератор, должен уметь сообщить генератору, получающему последовательность для перебора, если что-то пошло не так при обработке элемента в этой последовательности.

В случае нашего потока genie, использующего xhr, могут возникнуть проблемы с сетью и не будет возможности продолжать обработку элементов, и, вероятно, захочется предупредить пользователей о неожиданных ошибках. Здесь мы просто добавляем g.throw(error) в наш код потока управления.

function genie (questions) {
  var g = questions()
  pull()
  function pull () {
    let question = g.next()
    if (question.done) {
      return
    }
    ask(question.value, pull)
  }
  function ask (q, next) {
    xhr('https://computer.genie/?q=' + encodeURIComponent(q), got)
    function got (err, res, body) {
      if (err) {
        g.throw(err)
      }
      console.log(q)
      console.log('[Джинн] ' + body.answer)
      next()
    }
  }
}

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

genie(function* questions () {
  try {
    yield '[Я] ES6 умрёт мучительной смертью?'
  } catch (e) {
    console.error('Error', e.message)
  }
  try {
    yield '[Я] Как делаааа?'
  } catch (e) {
    console.error('Error', e.message)
  }
})

Принудительный возврат из генератора

Не столь заметный на фоне других механизмов асинхронного потока управления, метод g.return() позволяет возобновлять выполнение в функции-генераторе, так же, как пару минут назад делал g.throw(). Основное отличие в том, что g.return() не приведёт к исключению на уровне генератора, хотя и завершит последовательность.

function* numbers () {
  yield 1
  yield 2
  yield 3
}
var g = numbers()
console.log(g.next())
// <- { done: false, value: 1 }
console.log(g.return())
// <- { done: true }
console.log(g.next())
// <- { done: true }, как нам известно

Также можно вернуть value с помощью g.return(value), и в результате в IteratorResult будет находиться указанное value. Это эквивалентно наличию return value где-то в функцции-генераторе. Тем не менее, будьте внимательны, поскольку и for..of, [...generator()], и Array.from(generator()), пропускают value в том IteratorResult, который сообщает { done: true }.

function* numbers () {
  yield 1
  yield 2
  return 3
  yield 4
}
console.log([...numbers()])
// <- [1, 2]
console.log(Array.from(numbers()))
// <- [1, 2]
for (let n of numbers()) {
  console.log(n)
  // <- 1
  // <- 2
}
var g = numbers()
console.log(g.next())
// <- { done: false, value: 1 }
console.log(g.next())
// <- { done: false, value: 2 }
console.log(g.next())
// <- { done: true, value: 3 }
console.log(g.next())
// <- { done: true }

Использование g.return ничем не отличается в этом плане, воспринимайте это как программный эквивалент того, что мы только что сделали.

function* numbers () {
  yield 1
  yield 2
  return 3
  yield 4
}
var g = numbers()
console.log(g.next())
// <- { done: false, value: 1 }
console.log(g.return(5))
// <- { done: true, value: 5 }
console.log(g.next())
// <- { done: true }

Как отметил Аксель, можно избежать предстоящего завершения последовательности, если при вызове g.return() код в функции-генераторе обёрнут в try/finally. Как только выражение yield в блоке finally завершилось, последовательность закончится с value переданным в g.return(value).

function* numbers () {
  yield 1
  try {
    yield 2
  } finally {
    yield 3
    yield 4
  }
  yield 5
}
var g = numbers()
console.log(g.next())
// <- { done: false, value: 1 }
console.log(g.next())
// <- { done: false, value: 2 }
console.log(g.return(6))
// <- { done: false, value: 3 }
console.log(g.next())
// <- { done: false, value: 4 }
console.log(g.next())
// <- { done: true, value: 6 }

Вот и всё, что нужно знать о генераторах в плане функциональности.

Когда нужны генераторы ES6

К этому моменту вы уже должны быть «на ты» с итераторами, итерируемыми объектами и генераторами в ES6. Если есть желание ещё глубже разобраться в этой теме, настоятельно рекомендую заглянуть в статью Акселя про генераторы, поскольку всего несколько месяцев назад он подробно описал, где и как можно их использовать.

P.S. Это тоже может быть интересно:

1 комментарий

Оставить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Получать новые комментарии по электронной почте. Вы можете подписаться без комментирования.