What are Generator Functions?
Generator functions are a special types of functions in JavaScript, introduced in ES6, that have the built-in capability to be paused and resumed allowing us to take control of the execution flow and generate multiple values.
The syntax, as shown below, is pretty much similar with regular functions apart from the new function* keyword.
function* someGeneratorFunction() {
// function code goes here
}
But what does it mean to “pause and resume” execution?
To understand this better, let’s look at how regular functions behave.
Regular functions execute from start to finish sequentially. Once a function is invoked, it continues execution until it encounters either a return statement or reaches the end of the function body.
A basic demonstration:
function someRegularFunction() {
console.log("1");
console.log("2");
console.log("3");
}
someRegularFunction();
// Output will be as follows:
// 1
// 2
// 3
In the snippet above, when the function is called, it executes each line in a linear manner (by order they were defined) and it is reflected in the output.
Generator Functions on the other hand can use the yield keyword to pause execution and generate a value. When this value is yielded, the state of the function is saved and can be resumed at a later time, by calling the next() method, from where it left off.
This means that we have the ability to yield multiple values by exiting and re-entering the function.
A basic demonstration:
Note: Outputs of the calls are commented below each line
function* someGeneratorFunction() {
console.log("Start of the function");
yield 1;
console.log("Middle of the function");
yield 2;
console.log("End of the function");
}
const generator = someGeneratorFunction(); // Returns generator object
console.log(generator.next().value);
// Start of the function
// 1
console.log(generator.next().value);
// Middle of the function
// 2
console.log(generator.next().value);
// End of the function
// undefined
In the snippet above, calling the generator function someGeneratorFunction* returns a generator object. On this object, we can call the next() method, which will cause the generator function to execute. If a yield is encountered, the method returns an object that contains a value property containing the yielded value and a boolean done property which indicates if the generator has yielded its last value or not. To demonstrate this, we will log the entire object returned from next() (as opposed to the value property as we did in the previous example).
function* someGeneratorFunction() {
yield 1;
}
const generator = someGeneratorFunction();
console.log(generator.next()); // {value: 1, done: false}
console.log(generator.next()); // {value: undefined, done: true}
What happens if we add a return statement in a generator function?
Much like how it behaves in regular functions, a return statement will cause the generator function to finish executing, making the subsequent lines of codes unreachable. It does this by setting the done property to true.
function* yieldAndReturn() {
yield 1;
return "Returned";
yield "Unreachable";
}
const generator = yieldAndReturn();
console.log(generator.next()); // { value: 1, done: false }
console.log(generator.next()); // { value: "Returned", done: true }
console.log(generator.next()); // { value: undefined, done: true }
Note: In a real world implementation, we wouldn’t know how many times to request for the values, so we would need to keep requesting values in a loop until we receive the done: true property.
Why and When do we use Generator Functions?
You might have been wondering — couldn’t this “pause and resume” functionality be achieved using regular functions?
Well, sure it can:
- Using Callbacks: Consider you need to make multiple API requests where each call depends on the result of previous call. Slowly but surely you will find yourself in a callback hell. This happens when the number of calls you need to make increases, making the code complex and hard to understand.
Here is a painful example:
asyncFunction1(arg1, (err, result1) => {
if (err) {
console.error(err);
} else {
asyncFunction2(result1, (err, result2) => {
if (err) {
console.error(err);
} else {
asyncFunction3(result2, (err, result3) => {
if (err) {
console.error(err);
} else {
// Do something with the final result
}
});
}
});
}
});
- Promises: Continuing with our previous multiple API call scenario, we might think using a Promise.then() will solve the issue, but it is a trap. Even thought it is more readable than the callback hell, we will only end up with complex promise chains that can be hard to understand & debug:
asyncFunction1(arg1)
.then(result1 => asyncFunction2(result1))
.then(result2 => asyncFunction3(result2))
.then(result3 => {
// Do something with the final result
})
.catch(err => {
console.error(err);
});
Why use Generator Functions?
- Readability: Generator functions provide a simple to understand syntax. The yield clearly denotes the parts of the function where it pauses and resumes, making it easier to read & maintain our code.
- Stateful Iteration: Generator functions create iterator objects which can be used to re-enter the function and generate multiple values. They maintain internal state between successive yield statements, which makes it easier to carry out computations across iterations.
- Lazy Evaluation: The values of a generator function are yielded on demand as opposed to being returned all at once. This helps with efficient memory usage as we don’t need to store all values to memory upfront.
When to use Generator Functions:
Let’s see a few scenarios where using a generator function might come in handy.
- Iteration and Sequences: Useful when working with infinite sets without needing to provide the values all at once, such as data streams or even a simple fibonacci sequence :)
function* fibonacciSequence() {
let a = 0;
let b = 1;
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
const fibonacciGenerator = fibonacciSequence();
console.log(fibonacciGenerator.next().value); // Output: 0
console.log(fibonacciGenerator.next().value); // Output: 1
console.log(fibonacciGenerator.next().value); // Output: 1
console.log(fibonacciGenerator.next().value); // Output: 2
2. Asynchronous Programming
Generator functions handle asynchronous code with ease by avoiding complex promise chains. They are the foundations on which async/await syntax is built on.
Since yield stops the execution of the function, we can use it to wait for an asynchronous request to resolve.
Solving the previous callback hell
Remember our previous callback example where we encountered the callback hell? Recalling that we needed to make multiple API calls where the current call depends on the result of the previous one, here is how generator functions solves it:
function* asyncFunction1(arg1) {
try {
const result1 = yield async_function1(arg1);
const result2 = yield async_function2(result1);
const result3 = yield async_function3(result2);
// Do something with the final result
} catch (err) {
console.error(err);
}
}
function run(generator) {
const iterator = generator(); // Create the generator object
function iterate({ value, done }) { // Destructure the value & done properties
if (done) {
return;
}
value
.then((result) => iterate(iterator.next(result))) // Call the next iteration and pass the current result
.catch((err) => iterate(iterator.throw(err)));
}
iterate(iterator.next()); // Start the iteration
}
run(asyncFunction1);
This code can easily be rewritten using async/await — Even better!
Note that async/await uses generator functions behind the scene.
async function asyncFunction1(arg1) {
try {
const result1 = await asyncFunction1(arg1);
const result2 = await asyncFunction2(result1);
const result3 = await asyncFunction3(result2);
// Do something with the final result
} catch (err) {
console.error(err);
}
}
asyncFunction1();
Conclusion
As we have seen, generator functions allow you to define functions that can be paused and resumed, enabling the creation of iterators and asynchronous programming constructs like async/await. They provide an easy to understand syntax, as well as optimized method of handling iterations for yielding multiple values on demand. These features make them particularly useful in scenarios that involve working with sequences, implementing iterators, and writing asynchronous code.