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;
}