Generators Flashcards
What are generators?
You can think of generators as processes (pieces of code) that you can pause and resume:
function* genFunc() { // (A) console.log('First'); yield; console.log('Second'); }
Note the new syntax: function*
is a new “keyword” for generator functions (there are also generator methods). yield
is an operator with which a generator can pause itself. Additionally, generators can also receive input and send output via yield.
When you call a generator function genFunc()
, you get a generator object genObj
that you can use to control the process:
const genObj = genFunc();
The process is initially paused in line A. genObj.next()
resumes execution, a yield
inside genFunc()
pauses execution:
genObj.next(); // Output: First genObj.next(); // output: Second
” What are generators? “ (exploringjs.com). Retrieved October 4, 2024.
What kind of generators are there?
There are four kinds of generators:
1.- Generator function declarations:
function* genFunc() { ··· } const genObj = genFunc();
2.- Generator function expressions:
const genFunc = function* () { ··· }; const genObj = genFunc();
3.- Generator method definitions in object literals:
const obj = { * generatorMethod() { ··· } }; const genObj = obj.generatorMethod();
4.- Generator method definitions in class definitions (class declarations or class expressions):
class MyClass { * generatorMethod() { ··· } } const myInst = new MyClass(); const genObj = myInst.generatorMethod();
” Kinds of generators” (exploringjs.com). Retrieved October 7, 2024.
How can you implement an iterable with a generator?
The objects returned by generators are iterable; each yield
contributes to the sequence of iterated values. Therefore, you can use generators to implement iterables, which can be consumed by various ES6 language mechanisms: for-of loop
, spread operator (...
), etc.
The following function
returns an iterable over the properties of an object, one [key, value]
pair per property:
function* objectEntries(obj) { const propKeys = Reflect.ownKeys(obj); for (const propKey of propKeys) { // `yield` returns a value and then pauses // the generator. Later, execution continues // where it was previously paused. yield [propKey, obj[propKey]]; } }
objectEntries()
is used like this:
const jane = { first: 'Jane', last: 'Doe' }; for (const [key,value] of objectEntries(jane)) { console.log(`${key}: ${value}`); } // Output: // first: Jane // last: Doe
“Use case: implementing iterables” (exploringjs.com). Retrieved October 7, 2024.
What roles can a generator play?
Generators can play three roles:
-
Iterators (data producers): Each
yield
can return a value vianext()
, which means that generators can produce sequences of values via loops and recursion. Due to generator objects implementing the interface Iterable, these sequences can be processed by any ECMAScript 6 construct that supports iterables. Two examples are:for-of
loops and the spread operator (...
). -
Observers (data consumers):
yield
can also receive a value fromnext()
(via a parameter). That means that generators become data consumers that pause until a new value is pushed into them vianext()
. - Coroutines (data producers and consumers): Given that generators are pausable and can be both data producers and data consumers, not much work is needed to turn them into coroutines (cooperatively multitasked tasks).
“Roles played by generators #” (exploringjs.com). Retrieved October 10, 2024.
Explain how a generator can be a data producer (iterable).
Generator objects can be data producers. This card shows an expample where an generator implements both the interfaces Iterable and Iterator (shown below). That means that the result of a generator function is both an iterable and an iterator. The full interface of generator objects will be shown later.
interface Iterable { [Symbol.iterator]() : Iterator; } interface Iterator { next() : IteratorResult; } interface IteratorResult { value : any; done : boolean; }
I have omitted method return()
of interface Iterable
, because it is not relevant in this section.
A generator function produces a sequence of values via yield
, a data consumer consumes thoses values via the iterator method next()
. For example, the following generator function produces the values 'a'
and 'b'
:
function* genFunc() { yield 'a'; yield 'b'; }
This interaction shows how to retrieve the yielded values via the generator object genObj
:
> const genObj = genFunc(); > genObj.next() { value: 'a', done: false } > genObj.next() { value: 'b', done: false } > genObj.next() // done: true => end of sequence { value: undefined, done: true }
” Generators as iterators (data production) #” (exploringjs.com). Retrieved October 10, 2024.
Can a generator return a value?
Yes. All generators have a return
value. If no return
is explicitly set then it has an implicit return
which is equivalent to returning undefined
. Let’s examine a generator with an explicit return:
function* genFuncWithReturn() { yield 'a'; yield 'b'; return 'result'; }
The returned value shows up in the last object returned by next()
, whose property done
is true
:
> const genObjWithReturn = genFuncWithReturn(); > genObjWithReturn.next() { value: 'a', done: false } > genObjWithReturn.next() { value: 'b', done: false } > genObjWithReturn.next() { value: 'result', done: true }
However, most constructs that work with iterables ignore the value inside the done
object:
for (const x of genFuncWithReturn()) { console.log(x); } // Output: // a // b const arr = [...genFuncWithReturn()]; // ['a', 'b']
yield*
, an operator for making recursive generator calls, does consider values inside done
objects.
” Returning from a generator #” (exploringjs.com). Retrieved October 10, 2024.
Can a generator throw an exception?
Yes, If an exception leaves the body of a generator then next()
throws it:
function* genFunc() { throw new Error('Problem!'); } const genObj = genFunc(); genObj.next(); // Error: Problem!
That means that next()
can produce three different “results”:
- For an item
x
in an iteration sequence, it returns{ value: x, done: false }
- For the end of an iteration sequence with a return value
z
, it returns{ value: z, done: true }
- For an exception that leaves the generator body, it throws that exception.
“Throwing an exception from a generator #” (exploringjs.com). Retrieved October 14, 2024.
Can you yield
outside of a generator?
No, A significant limitation of generators is that you can only yield
while you are (statically) inside a generator function. That is, yielding in callbacks doesn’t work:
function* genFunc() { ['a', 'b'].forEach(x => yield x); // SyntaxError }
yield
is not allowed inside non-generator functions, which is why the previous code causes a syntax error. In this case, it is easy to rewrite the code so that it doesn’t use callbacks (as shown below). But unfortunately that isn’t always possible.
function* genFunc() { for (const x of ['a', 'b']) { yield x; // OK } }
“You can only yield in generators #” (exploringjs.com). Retrieved October 14, 2024.
How can you call a generator from within a generator?
With yield*
.
Directily calling the generator from within a generator does not work!
function* foo() { yield 'a'; yield 'b'; } function* bar() { yield 'x'; foo(); // does nothing! yield 'y'; }
Calling foo()
returns an object, but does not actually execute foo()
. That’s why ECMAScript 6 has the operator yield*
for making recursive generator calls:
function* bar() { yield 'x'; yield* foo(); yield 'y'; } // Collect all values yielded by bar() in an array const arr = [...bar()]; // ['x', 'a', 'b', 'y'] Internally, yield* works roughly as follows: function* bar() { yield 'x'; for (const value of foo()) { yield value; } yield 'y'; }
The operand of yield*
does not have to be a generator object, it can be any iterable:
function* bla() { yield 'sequence'; yield* ['of', 'yielded']; yield 'values'; } const arr = [...bla()]; // ['sequence', 'of', 'yielded', 'values']
“Recursion via yield*
#” (exploringjs.com). Retrieved October 14, 2024.
What is the result of yield*
?
The result of yield*
is the end-of-iteration value:
function* genFuncWithReturn() { yield 'a'; yield 'b'; return 'The result'; } function* logReturned(genObj) { const result = yield* genObj; console.log(result); // (A) }
If we want to get to line A, we first must iterate over all values yielded by logReturned()
:
> [...logReturned(genFuncWithReturn())] The result [ 'a', 'b' ]
“yield*
considers end-of-iteration values #” (exploringjs.com). Retrieved October 18, 2024.
How can you use a generator to iterate over a tree?
Generators shine on tree iteration: they let you implement an iterator via recursion. As an example, consider the following data structure for binary trees. It is iterable, because it has a method whose key is Symbol.iterator
. That method is a generator method and returns an iterator when called.
class BinaryTree { constructor(value, left=null, right=null) { this.value = value; this.left = left; this.right = right; } /** Prefix iteration */ * [Symbol.iterator]() { yield this.value; if (this.left) { yield* this.left; // Short for: yield* this.left[Symbol.iterator]() } if (this.right) { yield* this.right; } } }
The following code creates a binary tree and iterates over it via for-of
:
const tree = new BinaryTree('a', new BinaryTree('b', new BinaryTree('c'), new BinaryTree('d')), new BinaryTree('e')); for (const x of tree) { console.log(x); } // Output: // a // b // c // d // e
“Iterating over trees #” (exploringjs.com). Retrieved October 18, 2024.
How can you create an observer generator (aka data consumer generator)?
As consumers of data, generator objects conform to the second half of the generator interface, Observer:
interface Observer { next(value? : any) : void; return(value? : any) : void; throw(error) : void; }
As an observer, a generator pauses until it receives input. There are three kinds of input, transmitted via the methods specified by the interface:
-
next()
sends normal input. -
return()
terminates the generator. -
throw()
signals an error.
For example:
function* dataConsumer() { console.log('Started'); console.log(`1. ${yield}`); // (A) console.log(`2. ${yield}`); return 'result'; } const genObj = dataConsumer();
We now call genObj.next()
, which starts the generator. Execution continues until the first yield, which is where the generator pauses. The result of next()
is the value yielded in line A (undefined
, because yield
doesn’t have an operand). In this section, we are not interested in what next()
returns, because we only use it to send values, not to retrieve values.
genObj.next() // Started // { value: undefined, done: false }
We call next()
two more times, in order to send the value 'a'
to the first yield and the value 'b'
to the second yield:
genObj.next('a') // 1. a // { value: undefined, done: false } genObj.next('b') // 2. b // { value: 'result', done: true }
The result of the last next()
is the value returned from dataConsumer()
. done
being true
indicates that the generator is finished.
Unfortunately, next()
is asymmetric, but that can’t be helped: It always sends a value to the currently suspended yield, but returns the operand of the following yield.
” Generators as observers (data consumption) #” (exploringjs.com). Retrieved December 4, 2024.
What is the purpose of the first next()
invocation of a generator?
When using a generator as an observer, it is important to note that the only purpose of the first invocation of next()
is to start the observer. It is only ready for input afterwards, because this first invocation advances execution to the first yield
. Therefore, any input you send via the first next()
is ignored:
function* gen() { // (A) while (true) { const input = yield; // (B) console.log(input); } } const obj = gen(); obj.next('a'); obj.next('b'); // Output: // b
Initially, execution is paused in line A. The first invocation of next()
:
1.- Feeds the argument 'a'
of next()
to the generator, which has no way to receive it (as there is no yield
). That’s why it is ignored.
2.- Advances to the yield
in line B and pauses execution.
3.- Returns yield
’s operand (undefined
, because it doesn’t have an operand).
The second invocation of next()
:
1.- Feeds the argument 'b'
of next()
to the generator, which receives it via the yield
in line B and assigns it to the variable input.
2.- Then execution continues until the next loop iteration, where it is paused again, in line B.
3.- Then next()
returns with that yield
’s operand (undefined
).
“The first next()
#” (exploringjs.com). Retrieved December 4, 2024.
Why is yield
a loosely bind operand?
yield
binds very loosely, so that we don’t have to put its operand in parentheses:
yield a + b + c;
This is treated as:
yield (a + b + c);
Not as:
(yield a) + b + c;
As a consequence, many operators bind more tightly than yield
and you have to put yield
in parentheses if you want to use it as an operand. For example, you get a SyntaxError
if you make an unparenthesized yield an operand of plus:
console.log('Hello' + yield); // SyntaxError console.log('Hello' + yield 123); // SyntaxError console.log('Hello' + (yield)); // OK console.log('Hello' + (yield 123)); // OK
You do not need parentheses if yield
is a direct argument in a function or method call:
foo(yield 'a', yield 'b');
You also don’t need parens if you use yield
on the right-hand side of an assignment:
const input = yield;
“yield
binds loosely #” (exploringjs.com). Retrieved December 9, 2024.
Explain generator object return()
method
return()
performs a return at the location of the yield
that led to the last suspension of the generator. Let’s use the following generator function to see how that works.
function* genFunc1() { try { console.log('Started'); yield; // (A) } finally { console.log('Exiting'); } }
In the following interaction, we first use next()
to start the generator and to proceed until the yield
in line A. Then we return from that location via return()
.
> const genObj1 = genFunc1(); > genObj1.next() Started { value: undefined, done: false } > genObj1.return('Result') Exiting { value: 'Result', done: true }
“return()
terminates the generator #” (exploringjs.com). Retrieved December 9, 2024.