Exploring JavaScript Generators: Simplifying Complex Iterations

Asif SiddiqueAsif Siddique
4 min read

The function* declaration creates a binding of a new generator function to a given name. A generator function can be exited and later re-entered, with its context (variable bindings) saved across re-entrances.

The above definition from MDN might seem a bit complex, but let me simplify: Do all functions run to completion? Until ES6, that was indeed the case.

Basics of Generator Functions

As a child, you may have used or seen vending machines. They operate similarly to generator functions. When you press a button, the machine yields an item. With each press, it produces an output until the machine is fully exhausted.

To create a generator function, simply place an asterisk (*) after the function keyword, and voilà, you've created a generator function.

function* gen()
function *gen()
function * gen()
function*gen()

All of the above ways of defining generator functions are correct. There is no strict rule regarding the placement of the asterisk; it is merely a matter of stylistic preference. Personally, I prefer function *gen(). However, your preference may differ.

Using Generator functions

Let's start by defining a simple generator function, followed by a deep dive into its workings.

function *vending(){
    yield "Redbull";
}
const vendingMachine = vending() // returns [object Generator]

The above is a generator function, defined using the function* syntax. When called, a generator function does not execute immediately; instead, it returns a special type of object called a "generator object" ([object Generator]). This object, which can also be referred to as an iterator, has several methods: next(), and return(). We will discuss these methods in detail.

next() method:

The next() method resumes the execution of the generator function from where it was paused. The generator itself maintains the state and data necessary to resume execution. The returned object from next() has two properties: value and done.

  • value: It contains the resulted value given by next() method.

  • done: It contains a boolean value which indicates whether generator has completed (true) or not (false).

function *vendingMachine(){
    yield "Redbull";
    yield "Monster";
    yield "Sting Energy";
}

const vending = vendingMachine()
console.log(vending.next()) // Output: {value: "Redbull", done: false}
console.log(vending.next()) // Output: {value: "Monster", done: false}
console.log(vending.next()) // Output: {value: "Sting Energy", done: false}
console.log(vending.next()) // Output: {value: undefined, done: true}

The above code snippet demonstrates the execution of the next() method. First, we create a generator object by calling the generator function, then log the values produced by calling the next() method on the generator object. For each iteration, it produces an object with value and done properties. The value property holds the value yielded by the generator, while the done property indicates whether the generator has finished execution. If the generator has not completed, value will hold the yielded value, and done will be false. Once the generator function has completed, value will be undefined, and done will be true.

I will illustrate a basic flow diagram to clarify how things work behind the scenes.

return() method:

The return() method when runs terminates the generator forcefully, when called it won't let the subsequent statement run rather completes the flow. It also takes an optional parameter that is the final value returned.

function *vendingMachine(){
    yield "Redbull";
    yield "Monster";
    yield "Sting Energy";
}

const vending = vendingMachine()
console.log(vending.next()) // Output: {value: "Redbull", done: false}
console.log(vending.return("Error")) // Output: {value: "Error", done: true}
console.log(vending.next()) // Output: {value: undefined, done: true} 
console.log(vending.next()) // Output: {value: undefined, done: true}

Yield keyword:

Yield keyword signals a pause point, which pauses the execution flow and produces the value. Yield can come anywhere in the expression, the execution will pause on every yield.

The yield* keyword is used to iterate over an iterable, like a list or a generator, and yield each value of that iterable one by one. Imagine a vending machine that offers different drinks. The yield* expression lets us iterate through the drinks and yield each one individually.

function *vendingMachine(){
    yield "Redbull"
    yield* ["Apple", "Orange", "Papaya"]
}

const vending = vendingMachine()
console.log(vending.next()) // {value: 'Redbull', done: false}
console.log(vending.next()) // {value: 'Apple', done: false}
console.log(vending.next()) // {value: 'Orange', done: false}
console.log(vending.next()) // {value: 'Papaya', done: false}
console.log(vending.next()) // {value: undefined, done: true}

Benefits of generator functions

  • Lazy Evaluation: It evaluates only when needed instead of evaluating all at once.

  • Memory Efficient: They are memory efficient because they operate on a "when needed" model, only evaluating when the next() method is called.

  • Stateful: Generators maintain their state while executing, allowing them to resume execution from where they left off when called again.

Conclusion

I have provided a comprehensive overview of generator functions, generator objects, and several essential methods. We delved into the workings, syntax, and flow of generator functions. The true power of generators lies in their ability to pause and resume execution, yielding only the necessary data at each step. This capability makes them exceptionally powerful for handling asynchronous tasks, managing streams, and creating custom iterators. These are the basics of generator functions; we will explore more advanced topics in future series.

0
Subscribe to my newsletter

Read articles from Asif Siddique directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Asif Siddique
Asif Siddique