Звичайні функції повертають лише одне, єдине значення (або нічого).
Генератори можуть повертати (“yield”) кілька значень, одне за одним, на вимогу. Вони чудово працюють з об’єктами, що перебираються, дозволяючи легко створювати потоки даних.
Функції-генератори
Щоб створити генератор, нам потрібна спеціальна синтаксична конструкція: function*
, так звана “функція-генератор”.
Це виглядає ось так:
function
*
generateSequence
(
)
{
yield
1
;
yield
2
;
return
3
;
}
Функції-генератори поводяться інакше, ніж звичайні. Коли така функція викликається, вона не запускає свій код. Замість цього вона повертає спеціальний об’єкт, який називається «об’єкт-генератор», щоб керувати її виконанням.
Ось подивіться:
function
*
generateSequence
(
)
{
yield
1
;
yield
2
;
return
3
;
}
// "функція-генератор" створює "об’єкт-генератор"
let
generator =
generateSequence
(
)
;
alert
(
generator)
;
// [object Generator]
Виконання коду функції ще не розпочато:
Основним методом генератора є next()
. При виклику він запускає виконання коду до найближчого оператора yield <value>
(value
можна опустити, тоді воно є undefined
). Потім виконання функції призупиняється, а отримане value
повертається до зовнішнього коду.
Результатом next()
завжди є об’єкт з двома властивостями:
value
: отримане значення.done
:true
, якщо код функції закінчився, інакшеfalse
.
Наприклад, тут ми створюємо генератор і отримуємо його перше значення, що повертається:
function
*
generateSequence
(
)
{
yield
1
;
yield
2
;
return
3
;
}
let
generator =
generateSequence
(
)
;
let
one =
generator.
next
(
)
;
alert
(
JSON
.
stringify
(
one)
)
;
// {value: 1, done: false}
На даний момент ми отримали лише перше значення, а виконання функції відбувається на другому рядку:
Давайте знову викличемо generator.next()
. Він відновлює виконання коду і повертає наступний yield
:
let
two =
generator.
next
(
)
;
alert
(
JSON
.
stringify
(
two)
)
;
// {value: 2, done: false}
І якщо ми викликаємо його втретє, виконання досягає оператора return
, який завершує виконання функції:
let
three =
generator.
next
(
)
;
alert
(
JSON
.
stringify
(
three)
)
;
// {value: 3, done: true}
Тепер генератор виконався. Ми можемо побачити це за допомогою done:true
і обробити value:3
як кінцевий результат.
Нові виклики generator.next()
більше не мають сенсу. Якщо ми їх робимо, вони повертають той самий об’єкт: {done: true}
.
function* f(…)
чи function *f(…)
?Обидва синтаксиси правильні.
Але зазвичай перевага віддається першому синтаксису, оскільки зірочка *
означає, що це функція-генератор, вона описує вид, а не ім’я, тому її слід розташовувати разом із ключовим словом function
.
Перебір генераторів
Як ви, напевно, вже здогадалися, дивлячись на метод next()
, генератори є об’єктами, що перебираються.
Ми можемо перебирати їх значення за допомогою for..of
:
function
*
generateSequence
(
)
{
yield
1
;
yield
2
;
return
3
;
}
let
generator =
generateSequence
(
)
;
for
(
let
value of
generator)
{
alert
(
value)
;
// 1, потім 2
}
Виглядає набагато приємніше, ніж виклик .next().value
, чи не так?
…Але зверніть увагу: у прикладі вище показано 1
, потім 2
, і це все. Значення 3
не показується!
Це тому, що перебір через for..of
ігнорує останнє value
, коли done: true
. Отже, якщо ми хочемо, щоб усі результати відображалися через for..of
, то повинні повертати їх через yield
:
function
*
generateSequence
(
)
{
yield
1
;
yield
2
;
yield
3
;
}
let
generator =
generateSequence
(
)
;
for
(
let
value of
generator)
{
alert
(
value)
;
// 1, потім 2, потім 3
}
Оскільки генератори є об’єктами, що перебираються, ми можемо використовувати всю пов’язану з ними функціональність, наприклад синтаксис розширення ...
:
function
*
generateSequence
(
)
{
yield
1
;
yield
2
;
yield
3
;
}
let
sequence =
[
0
,
...
generateSequence
(
)
]
;
alert
(
sequence)
;
// 0, 1, 2, 3
У наведеному вище коді ...generateSequence()
перетворює об’єкт-генератор, що перебирається, в масив елементів (докладніше про синтаксис розширення читайте у главі Залишкові параметри та синтаксис поширення)
Використання генераторів для об’єктів, що перебираються
Деякий час тому в главі Ітеративні об’єкти ми створили об’єкт range
, що перебирається та повертає значення from..to
.
Ось, давайте згадаємо код:
let
range =
{
from
:
1
,
to
:
5
,
// for..of range викликає цей метод один раз на самому початку
[
Symbol.
iterator]
(
)
{
// ...він повертає об’єкт, що перебирається:
// далі for..of працює лише з цим об’єктом, запитуючи в нього наступні значення
return
{
current
:
this
.
from,
last
:
this
.
to,
// next() викликається при кожній ітерації цикла for..of
next
(
)
{
// повинно повернути значення як об’єкт {done:.., value :...}
if
(
this
.
current <=
this
.
last)
{
return
{
done
:
false
,
value
:
this
.
current++
}
;
}
else
{
return
{
done
:
true
}
;
}
}
}
;
}
}
;
// при переборі об’єкта range повертаються числа від range.from до range.to
alert
(
[
...
range]
)
;
// 1,2,3,4,5
Ми можемо використовувати функцію-генератор для перебору об’єкта, вказавши її як Symbol.iterator
.
Ось той самий range
, але набагато компактніший:
let
range =
{
from
:
1
,
to
:
5
,
*
[
Symbol.
iterator]
(
)
{
// скорочення для [Symbol.iterator]: function*()
for
(
let
value =
this
.
from;
value <=
this
.
to;
value++
)
{
yield
value;
}
}
}
;
alert
(
[
...
range]
)
;
// 1,2,3,4,5
Це працює, тому що range[Symbol.iterator]()
тепер повертає генератор, а методи генератора – це саме те, що очікує for..of
:
- він має метод
.next()
- повертає значення у вигляді
{value: ..., done: true/false}
Це, звичайно, не випадковість. Генератори були додані до мови JavaScript з урахуванням об’єктів, що перебираються, щоб їх було легше реалізувати.
Варіант з генератором набагато лаконічніший, ніж оригінальний код range
, що перебирається, і зберігає ту саму функціональність.
У вищенаведених прикладах ми створили кінцеві послідовності, але ми також можемо створити генератор, який видає значення нескінченно. Наприклад, нескінченна послідовність псевдовипадкових чисел.
Безсумнівно, для цього буде потрібно break
(або return
) у циклі for..of
в такому генераторі. Інакше цикл повторюватиметься нескінченно та зависне.
Композиція генераторів
Композиція генераторів – це особливість генераторів, що дозволяє прозоро “вбудовувати” генератори один в одного.
Наприклад, у нас є функція, яка генерує послідовність чисел:
function
*
generateSequence
(
start,
end
)
{
for
(
let
i =
start;
i <=
end;
i++
)
yield
i;
}
Тепер ми хотіли б повторно використати його для створення складнішої послідовності:
- спочатку цифри
0..9
(з кодами символів 48…57), - за якими йдуть великі літери алфавіту
A..Z
(коди символів 65…90) - за якими йдуть малі літери алфавіту
a..z
(коди символів 97…122)
Ми можемо використовувати цю послідовність, наприклад створювати паролі, вибираючи з неї символи (можна також додати символи пунктуації), але давайте спочатку згенеруємо її.
У звичайній функції, щоб об’єднати результати кількох інших функцій, ми викликаємо їх, зберігаємо результати, а потім об’єднуємо в кінці.
Для генераторів існує спеціальний синтаксис yield*
для “вбудовування” (компонування) одного генератора в інший.
Ось композиція генераторів:
function
*
generateSequence
(
start,
end
)
{
for
(
let
i =
start;
i <=
end;
i++
)
yield
i;
}
function
*
generatePasswordCodes
(
)
{
// 0..9
yield
*
generateSequence
(
48
,
57
)
;
// A..Z
yield
*
generateSequence
(
65
,
90
)
;
// a..z
yield
*
generateSequence
(
97
,
122
)
;
}
let
str =
''
;
for
(
let
code of
generatePasswordCodes
(
)
)
{
str +=
String.
fromCharCode
(
code)
;
}
alert
(
str)
;
// 0..9A..Za..z
Директива yield*
делегує виконання іншому генератору. Цей термін означає, що yield* gen
виконує ітерацію над генератором gen
і прозоро передає його вихід назовні. Ніби значення були отримані зовнішнім генератором.
Результат такий самий, як якби ми вставили код із вкладених генераторів:
function
*
generateSequence
(
start,
end
)
{
for
(
let
i =
start;
i <=
end;
i++
)
yield
i;
}
function
*
generateAlphaNum
(
)
{
// yield* generateSequence(48, 57);
for
(
let
i =
48
;
i <=
57
;
i++
)
yield
i;
// yield* generateSequence(65, 90);
for
(
let
i =
65
;
i <=
90
;
i++
)
yield
i;
// yield* generateSequence(97, 122);
for
(
let
i =
97
;
i <=
122
;
i++
)
yield
i;
}
let
str =
''
;
for
(
let
code of
generateAlphaNum
(
)
)
{
str +=
String.
fromCharCode
(
code)
;
}
alert
(
str)
;
// 0..9A..Za..z
Композиція генераторів – це природний спосіб вставити потік одного генератора в інший. Вона не використовує додаткову пам’ять для зберігання проміжних результатів.
“yield” — дорога з двостороннім рухом
До цього моменту генератори були схожі на об’єкти, що перебираються, зі спеціальним синтаксисом для генерування значень. Але насправді вони набагато потужніші й гнучкіші.
Це тому, що yield
є дорогою з двостороннім рухом: він не лише повертає результат назовні, але також може передати значення всередину генератора.
Для цього ми повинні викликати generator.next(arg)
з аргументом. Цей аргумент стає результатом yield
.
Подивімося на прикладі:
function
*
gen
(
)
{
// Передаємо запитання зовнішньому коду та чекаємо відповіді
let
result =
yield
"2 + 2 = ?"
;
// (*)
alert
(
result)
;
}
let
generator =
gen
(
)
;
let
question =
generator.
next
(
)
.
value;
// <-- yield повертає значення
generator.
next
(
4
)
;
// --> передає результат в генератор
- Перший виклик
generator.next()
завжди має здійснюватися без аргументу (аргумент ігнорується, якщо він переданий). Він розпочинає виконання та повертає результат першогоyield "2+2=?"
. У цей момент генератор зупиняє виконання, залишаючись на рядку(*)
. - Потім, як показано на зображенні вище, результат
yield
потрапляє до змінноїquestion
у коді, що викликає. - На
generator.next(4)
генератор відновлюється, і4
потрапляє як результат:let result = 4
.
Зауважте, що зовнішній код не повинен негайно викликати next(4)
. Це може зайняти час. Це не проблема: генератор зачекає.
Наприклад:
// відновити роботу генератора через деякий час
setTimeout
(
(
)
=>
generator.
next
(
4
)
,
1000
)
;
Як бачимо, на відміну від звичайних функцій, генератор і код, що його викликає, можуть обмінюватися результатами, передаючи значення в next/yield
.
Щоб зробити речі більш очевидними, ось інший приклад із більшою кількістю викликів:
function
*
gen
(
)
{
let
ask1 =
yield
"2 + 2 = ?"
;
alert
(
ask1)
;
// 4
let
ask2 =
yield
"3 * 3 = ?"
alert
(
ask2)
;
// 9
}
let
generator =
gen
(
)
;
alert
(
generator.
next
(
)
.
value )
;
// "2 + 2 = ?"
alert
(
generator.
next
(
4
)
.
value )
;
// "3 * 3 = ?"
alert
(
generator.
next
(
9
)
.
done )
;
// true
Зображення виконання:
- Перший
.next()
починає виконання… Він досягає першогоyield
. - Результат повертається до зовнішнього коду.
- Другий
.next(4)
передає4
назад у генератор як результат першогоyield
і відновлює виконання. - …Воно досягає другого
yield
, який стає результатом виклику генератора. - Третій
next(9)
передає9
у генератор як результат другогоyield
і відновлює виконання, яке досягає кінця функції, томуdone: true
.
Це як гра в “пінг-понг”. Кожне next(value)
(за винятком першого) передає значення в генератор, яке стає результатом поточного yield
, а потім повертає результат наступного yield
.
generator.throw
Як ми помітили у прикладах вище, зовнішній код може передати значення в генератор, як результат yield
.
…Але він також може ініціювати (викинути) там помилку. Це природно, оскільки помилка – це свого роду результат.
Щоб передати помилку в yield
, ми повинні викликати generator.throw(err)
. У цьому випадку err
викидається в рядок із цим yield
.
Наприклад, тут "2 + 2 = ?"
призводить до помилки:
function
*
gen
(
)
{
try
{
let
result =
yield
"2 + 2 = ?"
;
// (1)
alert
(
"Виконання не доходить сюди, тому що вище викинуто виняток"
)
;
}
catch
(
e)
{
alert
(
e)
;
// покаже помилку
}
}
let
generator =
gen
(
)
;
let
question =
generator.
next
(
)
.
value;
generator.
throw
(
new
Error
(
"Відповідь не знайдено в моїй базі даних"
)
)
;
// (2)
Помилка, яка прокидається в генератор на рядку (2)
, призводить до винятку на рядку (1)
з yield
. У вищенаведеному прикладі try..catch
ловить та показує його.
Якщо ми його не зловимо, то, як і будь-який виняток, він “випадає” з генератора у код, що його викликав.
Поточний рядок коду, що викликає – це рядок із generator.throw
, позначений як (2)
. Тож ми можемо зловити її тут, наприклад:
function
*
generate
(
)
{
let
result =
yield
"2 + 2 = ?"
;
// Помилка в цьому рядку
}
let
generator =
generate
(
)
;
let
question =
generator.
next
(
)
.
value;
try
{
generator.
throw
(
new
Error
(
"Відповідь не знайдено в моїй базі даних"
)
)
;
}
catch
(
e)
{
alert
(
e)
;
// покаже помилку
}
Якщо ми не перехопимо помилку там, то далі, як зазвичай, вона потрапляє до зовнішнього коду (якщо є) і, якщо не перехоплена, вбиває скрипт.
generator.return
generator.return(value)
завершує виконання генератора та повертає задане value
.
function
*
gen
(
)
{
yield
1
;
yield
2
;
yield
3
;
}
const
g =
gen
(
)
;
g.
next
(
)
;
// { value: 1, done: false }
g.
return
(
'foo'
)
;
// { value: "foo", done: true }
g.
next
(
)
;
// { value: undefined, done: true }
Якщо ми знову використаємо generator.return()
у завершеному генераторі, він знову поверне це значення (MDN).
Часто ми не використовуємо його, оскільки в більшості випадків хочемо отримати всі значення, що повертаються, але це може бути корисно, коли ми хочемо зупинити генератор у певному стані.
Підсумки
- Генератори створюються функціями-генераторами
function* f(…) {…}
. - Усередині генераторів (лише в них) існує оператор
yield
. - Зовнішній код і генератор можуть обмінюватися результатами за допомогою викликів
next/yield
.
У сучасному JavaScript генератори використовуються рідко. Але іноді вони стають у пригоді, оскільки здатність функції обмінюватися даними з кодом, що її викликає, під час самого виконання є досить унікальною. І, безсумнівно, вони чудово підходять для створення об’єктів, що перебираються.
Крім того, у наступному розділі ми ознайомимося з асинхронними генераторами, які використовуються для зчитування потоків асинхронно згенерованих даних (наприклад, які посторінково завантажуються з мережі) у циклах for await ... of
.
У вебпрограмуванні ми часто працюємо з потоковими даними, тому це ще один дуже важливий варіант використання.
Коментарі
<code>
, для кількох рядків – обгорніть їх тегом<pre>
, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)