Generators Flashcards

1
Q

What are generators?

A

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
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
2
Q

What kind of generators are there?

A

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();
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
3
Q

How can you implement an iterable with a generator?

A

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
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
4
Q

What roles can a generator play?

A

Generators can play three roles:

  • Iterators (data producers): Each yield can return a value via next(), 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 from next() (via a parameter). That means that generators become data consumers that pause until a new value is pushed into them via next().
  • 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).
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
5
Q

Explain how a generator can be a data producer (iterable).

A

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 }
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
6
Q

Can a generator return a value?

A

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.

How well did you know this?
1
Not at all
2
3
4
5
Perfectly
7
Q

Can a generator throw an exception?

A

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.
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
8
Q

Can you yield outside of a generator?

A

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
    }
}
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
9
Q

How can you call a generator from within a generator?

A

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']
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
10
Q

What is the result of yield*?

A

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' ]
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
11
Q

How can you use a generator to iterate over a tree?

A

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
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
12
Q

How can you create an observer generator (aka data consumer generator)?

A

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.

How well did you know this?
1
Not at all
2
3
4
5
Perfectly
13
Q

What is the purpose of the first next() invocation of a generator?

A

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).

How well did you know this?
1
Not at all
2
3
4
5
Perfectly
14
Q

Why is yield a loosely bind operand?

A

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;
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
15
Q

Explain generator object return() method

A

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 }
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
16
Q

Explain generator object throw() method

A

throw() throws an exception at the location of the yield that led to the last suspension of the generator. Let’s examine how that works via the following generator function.

function* genFunc1() {
    try {
        console.log('Started');
        yield; // (A)
    } catch (error) {
        console.log('Caught: ' + error);
    }
}

In the following interaction, we first use next() to start the generator and proceed until the yield in line A. Then we throw an exception from that location.

> const genObj1 = genFunc1();

> genObj1.next()
Started
{ value: undefined, done: false }

> genObj1.throw(new Error('Problem!'))
Caught: Error: Problem!
{ value: undefined, done: true }

The result of throw() (shown in the last line) stems from us leaving the function with an implicit return.

17
Q

Does yield* forwards next()?

A

Yes, it does.

The following generator function caller() invokes the generator function callee() via yield*.

function* callee() {
    console.log('callee: ' + (yield));
}
function* caller() {
    while (true) {
        yield* callee();
    }
}

callee logs values received via next(), which allows us to check whether it receives the value ‘a’ and ‘b’ that we send to caller.

> const callerObj = caller();

> callerObj.next() // start
{ value: undefined, done: false }

> callerObj.next('a')
callee: a
{ value: undefined, done: false }

> callerObj.next('b')
callee: b
{ value: undefined, done: false }

throw() and return() are forwarded in a similar manner.

18
Q

Describe generator’s full interface

A

The full interface of generator objects, Generator, handles both output and input:

interface Generator {
    next(value? : any) : IteratorResult;
    throw(value? : any) : IteratorResult;
    return(value? : any) : IteratorResult;
}
interface IteratorResult {
    value : any;
    done : boolean;
}

This interface is described in the spec in the section “Properties of Generator Prototype”.

The interface Generator combines two interfaces: Iterator for output and Observer for input.

interface Iterator { // data producer
    next() : IteratorResult;
    return?(value? : any) : IteratorResult;
}

interface Observer { // data consumer
    next(value? : any) : void;
    return(value? : any) : void;
    throw(error) : void;
}
19
Q

What are synchronous generators?

A

Synchronous generators are special versions of function definitions and method definitions that always return synchronous iterables:

// Generator function declaration
function* genFunc1() { /*···*/ }

// Generator function expression
const genFunc2 = function* () { /*···*/ };

// Generator method definition in an object literal
const obj = {
  * generatorMethod() {
    // ···
  }
};

// Generator method definition in a class definition
// (class declaration or class expression)
class MyClass {
  * generatorMethod() {
    // ···
  }
}

Asterisks (*) mark functions and methods as generators:

  • Functions: The pseudo-keyword function* is a combination of the keyword function and an asterisk.
  • Methods: The * is a modifier (similar to static and get).
20
Q

How are the iterables returned by generators filled?

A

Via the yield operator. If we call a generator function, it returns an iterable (actually, an iterator that is also iterable). The generator fills that iterable via the yield operator:

function* genFunc1() {
  yield 'a';
  yield 'b';
}

const iterable = genFunc1();
// Convert the iterable to an Array, to check what’s inside:
assert.deepEqual(
  Array.from(iterable), ['a', 'b']
);

// We can also use a for-of loop
for (const x of genFunc1()) {
  console.log(x);
}

Output:

a
b

21
Q

How does yield work?

A

Using a generator function involves the following steps:

1.- Function-calling it returns an iterator iter (that is also an iterable).
2.- Iterating over iter repeatedly invokes iter.next(). Each time, we jump into the body of the generator function until there is a yield that returns a value.

Therefore, yield does more than just add values to iterables – it also pauses and exits the generator function:

Like return, a yield exits the body of the function and returns a value (to/via .next()).
Unlike return, if we repeat the invocation (of .next()), execution resumes directly after the yield.

22
Q

Why does yield pause execution?

A

Due to pausing, generators provide many of the features of coroutines (think processes that are multitasked cooperatively). For example, when we ask for the next value of an iterable, that value is computed lazily (on demand). The following two generator functions demonstrate what that means.

/**
 * Returns an iterable over lines
 */
function* genLines() {
  yield 'A line';
  yield 'Another line';
  yield 'Last line';
}

/**
 * Input: iterable over lines
 * Output: iterable over numbered lines
 */
function* numberLines(lineIterable) {
  let lineNumber = 1;
  for (const line of lineIterable) { // input
    yield lineNumber + ': ' + line; // output
    lineNumber++;
  }
}

Note that the yield in numberLines() appears inside a for-of loop. yield can be used inside loops, but not inside callbacks (more on that later).

Let’s combine both generators to produce the iterable numberedLines:

const numberedLines = numberLines(genLines());
assert.deepEqual(
  numberedLines.next(), {value: '1: A line', done: false});
assert.deepEqual(
  numberedLines.next(), {value: '2: Another line', done: false});

The key benefit of using generators here is that everything works incrementally: via numberedLines.next(), we ask numberLines() for only a single numbered line. In turn, it asks genLines() for only a single unnumbered line.

This incrementalism continues to work if, for example, genLines() reads its lines from a large text file: If we ask numberLines() for a numbered line, we get one as soon as genLines() has read its first line from the text file.

Without generators, genLines() would first read all lines and return them. Then numberLines() would number all lines and return them. We therefore have to wait much longer until we get the first numbered line.

23
Q

How can you call a generator from a generator?

A

Via the yield* operator.

yield only works directly inside generators – so far we haven’t seen a way of delegating yielding to another function or method.

Let’s first examine what does not work: in the following example, we’d like foo() to call bar(), so that the latter yields two values for the former. Alas, a naive approach fails:

function* bar() {
  yield 'a';
  yield 'b';
}
function* foo() {
  // Nothing happens if we call `bar()`:
  bar();
}
assert.deepEqual(
  Array.from(foo()), []
);

Why doesn’t this work? The function call bar() returns an iterable, which we ignore.

What we want is for foo() to yield everything that is yielded by bar(). That’s what the yield* operator does:

function* bar() {
  yield 'a';
  yield 'b';
}
function* foo() {
  yield* bar();
}
assert.deepEqual(
  Array.from(foo()), ['a', 'b']
);

In other words, the previous foo() is roughly equivalent to:

function* foo() {
  for (const x of bar()) {
    yield x;
  }
}

Note that yield* works with any iterable:

function* gen() {
  yield* [1, 2];
}
assert.deepEqual(
  Array.from(gen()), [1, 2]
);