Exploring JavaScript Generators: Simplifying Complex Iterations
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 bynext()
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.
Subscribe to my newsletter
Read articles from Asif Siddique directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by