More Details on Generators
Greetings, friends! Generators are a fascinating construct in programming. In my previous article, I gave a brief introduction on what generators are and how to use them. Consider this post a follow-up discussion as I describe generators in greater detail and discuss how to avoid common pitfalls.
Quick Refresher on Generators
Generators are both iterators and iterables, since they adhere to both the iterator and iterable protocols. They allow us to pause execution of a function and return to it later. This allows us to evaluate the function lazily, so we don't end up iterating through an entire iterable such as all the elements of an array. Let's look at a simple generator function that returns a generator:
function *generator() {
// First next() call begins here, before the first yield statement
yield 1;
// Second next() call begins here
yield 2;
// Calling next() a third time or more results in a value of undefined
// and the done property is set to true
}
const g = generator();
console.log(g.next()); // { value: 1, done: false }
console.log(g.next()); // { value: 2, done: false }
console.log(g.next()); // { value: undefined, done: true }
A generator function is created by placing an asterisk *
directly to the left of the function name or directly after the function
keyword such as function*
. You use the yield
keyword to specify what value should be returned when the next
method is called on the generator.
Passing values to the "next" Method
Previously, we've been passing no values to the next method, but it actually accepts one parameter. When you pass a value to the next
method, it will be the result of the next yield statement run in your generator.
function *generator() {
const value = yield 1;
console.log(value);
yield 2;
}
const g = generator();
console.log(g.next()) // { value: 1, done: false }
console.log(g.next("Pizza"))
/* OUTPUT:
Pizza
{ value: 2, done: false }
*/
console.log(g.next()) // { value: undefined, done: done }
It's important to understand where the generator starts and stops execution if you want to pass values to the next
method and use them reliably in the generators. When the first invocation of the next
method happens, the value passed to it is always ignored:
function *generator() {
// The first invocation of next() starts BEFORE the first yield statement
const value = yield 1;
// The first invocation of next() ends AFTER reaching "yield 1" but BEFORE assigning the result of "yield 1" to the variable, "value"
// The second invocation of next() starts BEFORE the assignment to "value" occurs
console.log(value); // value = Cheese
yield 2;
// The second invocation of next() ends here, AFTER reaching "yield 2"
}
const g = generator();
console.log(g.next('Pizza')) // { value: 1, done: false }
console.log(g.next('Cheese'))
/* Output:
Cheese
{ value: 2, done: false }
*/
console.log(g.next('Pineapple')) // {value: undefined, done: true}
As you can see, it's meaningless to pass a value on the first invocation of the next
method. It's also meaningless to pass a value to a generator that has already completed, since you will only get back a value of undefined
. Keep this in mind when you want to pass values to the next
method, so you can incorporate correct conditional logic or other business logic in your generator.
When we run the next
method for the first time in the above example, it runs up to the yield 1
statement. However, the assignment operation that takes place when we run const value = yield 1
doesn't happen during the first next
invocation. This is why we never see "Pizza" being logged. The following assignment only will occur after the second next
invocation:
const value = 'Cheese';
Returning a Value in Generators
We can also return values in generators when they complete instead of receiving an undefined value with the done
property is set to true. However, a value of undefined
will be returned if you keep trying to iterate the completed generator.
function *generator() {
const value = yield 3;
return value * 4;
}
const g = generator();
console.log(g.next()); // { value: 3, done: false }
console.log(g.next(2)); // { value: 8, done: true }
console.log(g.next()); // Logs { value: undefined, done: true } because the generator can only return a value once upon completion
It's important to note that if you iterate through this generator using a for...of
loop or other constructs that iterates through iterables such as Array.from()
, the value in the return
statement will be ignored.
function *generator() {
yield 3;
return 4;
}
const g = generator();
for (const value of g) {
console.log(value);
}
// OUTPUT: 3
// You will not see the value of 4 appear here
The "return" Method
Generators not only have a next
method, but they also have a return method as well, not to be confused with generators that return values, as described in the above section. Don't mix up these two! The return
method is not the same as using a return
statement inside the generator.
function *generator() {
yield 1;
yield 2;
yield 3;
}
const g = generator();
console.log(g.next()) // { value: 1, done: false }
console.log(g.return("Pineapple")) // { value: "Pineapple", done: true }
console.log(g.next()) // { value: undefined, done: true }
When you use a generator's return
method, it will automatically complete the generator and skip the other yield statements. Even if you ran an infinite while loop inside the generator, the return
method will still complete the generator:
function *generator() {
while (true) {
yield 1;
yield 2;
}
}
const g = generator();
console.log(g.next()) // { value: 1, done: false }
console.log(g.return("Pineapple")) // { value: "Pineapple", done: true }
console.log(g.next()) // { value: undefined, done: true }
However, if you yield inside a finally
clause, then you can prevent the return
method from terminating the generator. The next invocation of the next
method will then finish the generator with the value passed into the return
method.
function *generator() {
try {
console.log('Started');
yield;
} finally {
yield 'Not finished, yet!';
}
}
const g = generator();
console.log(g.next());
/* Output:
Started
{value: undefined, done: false}
*/
console.log(g.return('Result')); // {value: "Not finished, yet!", done: false}
console.log(g.next()); // {value: "Result", done: true}
console.log(g.next()); // {value: undefined, done: true}
Infinite Generators
If you want to iterate through your generator infinitely, you can use a loop!
function *generator() {
while(true) {
yield 1;
yield 2;
yield 3;
}
}
const g = generator();
console.log(g.next().value); // 1
console.log(g.next().value); // 2
console.log(g.next().value); // 3
console.log(g.next().value); // 1
console.log(g.next().value); // 2
console.log(g.next().value); // 3
// repeats forever...
for...of
on an infinite generator or infinite iterable as that may lead to a memory leak and cause your browser to crash and/or potentially cause your computer to freeze.The "throw" Method
Generators also have a throw method available on their prototype. This method throws an error into the generator and resumes its execution. In the following example, we are catching the error, so the JavaScript program continues to run and the generator continues to execute.
function* gen() {
while(true) {
try {
yield 42;
} catch(e) {
console.log('Error caught!');
}
}
}
const g = gen();
console.log(g.next()); // { value: 42, done: false }
console.log(g.throw(new Error('Something went wrong')));
/* Output:
"Error caught!"
{ value: 42, done: false }
*/
// Since this generator has a while loop, the generator can keep yielding values
console.log(g.next()); // { value: 42, done: false }
The yield* keyword
The yield* keyword behaves a bit different than yield
. The yield*
expression is used to delegate to another generator or iterable. Essentially, it will forward a next
method call to another generator or iterable:
function* func1() {
yield 42;
}
function* func2() {
yield* func1();
}
const iterator = func2();
console.log(iterator.next().value);
// expected output: 42
We can delegate to arrays as well, since they are considered iterables:
function* delegateToArray() {
yield* [1, 2, 3];
}
const g = delegateToArray();
console.log(g.next()); // {value: 1, done: false}
console.log(g.next()); // {value: 2, done: false}
console.log(g.next()); // {value: 3, done: false}
console.log(g.next()); // {value: undefined, done: true}
This lets us iterate through an array lazily, so we don't have to walk through every element of the array. This could help boost performance if you have a large array of values. Suppose you're performing complex calculations, but you want to give the JavaScript event loop time to "breathe" so that it can perform other operations like painting objects to the screen or performing AJAX requests to make API calls to a remote server. You can run through the next
method dynamically about 100 times, give the event loop time to gasp for air, run through another 100 elements in the array, and repeat.
Strings can be iterated over character by character:
function* delegateToString() {
yield* 'nate';
}
const g = delegateToString();
console.log(g.next()); // {value: 'n', done: false}
console.log(g.next()); // {value: 'a', done: false}
console.log(g.next()); // {value: 't', done: false}
console.log(g.next()); // {value: 'e', done: false}
console.log(g.next()); // {value: undefined, done: true}
You can even iterate over parameters in an argument passed to a generator function:
function* delegateToArgument() {
yield* Array.from(arguments);
}
const g = delegateToArgument(1, 2, 3);
console.log(g.next()); // {value: 1, done: false}
console.log(g.next()); // {value: 2, done: false}
console.log(g.next()); // {value: 3, done: false}
console.log(g.next()); // {value: undefined, done: true}
Let's look at a more complex example:
function* g1() {
yield* [1, 2, 3];
return 'foo';
}
function* g2() {
// First invocation of next() starts here
const g1ReturnValue = yield* g1();
console.log(g1ReturnValue) // 'foo'
return 'bar';
}
const iterator = g2();
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
/* Output:
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }
foo
{ value: 'bar', done: true }
{ value: undefined, done: true }
*/
Try to follow along this example piece by piece. We make two generator functions but only make an instance of the g2
generator. Then, we call next
for the first time, which delegates to the g1
generator:
const g1ReturnValue = yield* g1();
The second and third next
invocations will delegate to the array in g1
as well.
yield* [1, 2, 3];
The fourth next
invocation will set g1ReturnValue
equal to foo
and log it to the console.
const g1ReturnValue = yield* g1();
console.log(g1ReturnValue) // 'foo'
The fifth next
invocation will complete the generator. Since there's a return value in g2
, we will see a value
of bar
and done
set to true
.
return 'bar';
Running a sixth next
invocation or more tries to call a completed generator which results in a value
of undefined
. See! That example wasn't so bad now that you're a generator pro!
Calling the "next" Method Dynamically
Let's say we want to call the next
method dynamically, say an "n" number of times. It would be a hassle to keep manually calling the next
method over and over 😵. Luckily, we can just use a simple loop that calls next
as many times as we want:
function *generator() {
while(true) {
yield 1;
yield 2;
yield 3;
}
}
const g = generator();
function runGen(gen, runCount) {
for (let i = 0; i < runCount; i++) {
console.log(gen.next().value);
}
}
runGen(g, 2);
/* Output:
1
2
*/
runGen(g, 2);
/* Output:
3
1
*/
Iterating through Generators Multiple Times
In my previous article, I mentioned that you can iterate through a generator only once. However, this only applies to iterating through a single generator instance. If we create a new instance each time we want to iterate through our generator, then it becomes possible to iterate through it "multiple times."
function *generator() {
yield 1;
yield 2;
yield 3;
}
// Iterate through a generator instance
for (const value of generator()) {
console.log(value);
}
/* OUTPUT:
1
2
3
*/
// Iterate through the generator a second time by using a new generator instance
for (const value of generator()) {
console.log(value);
}
/* OUTPUT:
1
2
3
*/
If we try to iterate through a single instance, it won't work:
function *generator() {
yield 1;
yield 2;
yield 3;
}
const g = generator();
// Iterate through a generator instance
for (const value of g) {
console.log(value);
}
/* OUTPUT:
1
2
3
*/
// Iterate through the same generator instance
for (const value of g) {
console.log(value);
}
// Nothing will be output here
Making a Toggle With Three or More Values
Let's have some fun. It's easy to create a toggle between two values because you can use a boolean to represent 2 states and switch between them easily:
let flag = true;
flag = !flag; // flag is now false
It's harder to make a toggle between three or more values. However, we can easily toggle between multiple states using an infinite generator:
function *generator() {
while(true) {
yield 1;
yield 2;
yield 3;
}
}
const g = generator();
console.log(g.next().value); // 1
console.log(g.next().value); // 2
console.log(g.next().value); // 3
console.log(g.next().value); // 1
console.log(g.next().value); // 2
console.log(g.next().value); // 3
Let's use this concept to toggle the color of a square between three colors: red, green, and blue in that order.
Let's use some HTML to make a square and a button:
<div id="square"></div>
<button id="toggle-button">Toggle</button>
Next, we'll add in a little CSS:
#square {
background-color: red;
width: 200px;
height: 200px;
}
#toggle-button {
margin-top: 1rem;
}
Then, we'll add the logic needed to toggle the color of the square between red, green, and blue:
const square = document.getElementById('square');
const toggleButton = document.getElementById('toggle-button');
function *toggleColor() {
while(true) {
yield 'green';
yield 'blue';
yield 'red';
}
}
const toggle = toggleColor();
toggleButton.addEventListener('click', () => {
square.style.backgroundColor = toggle.next().value;
});
When finished, you can click on the button to toggle between the different colors. Each time you click the button, the background color of the square will be set to the next iterated value of the generator.
You can see the finished example on CodePen. Since the default color is red, we yield green first, blue second, and red third. If we keep clicking the button, the cycle will continue forever, since the generator never finishes due to the infinite while loop.
Using this idea, you could create a theme switcher for your website that toggles between multiple states instead of simply light and dark mode! Maybe you want a sepia theme or maybe you're more daring and want to let your customers customize the theme of your website 😃.
Conclusion
As you can tell, generators are very powerful, but you must be careful on how they are written. Generators can help improve performance of your application depending on how they're used. Generally, you shouldn't need generators, but in case you do, you are now all the wiser 😎. I hope my articles have helped generate a bunch of generator experts! 🙂