Generator Functions are Awesome

Steve the Dev

I tend to be cautious in my adoption of new programming techniques. I don't like to jump into new features just so I can pat myself on the back and brag at parties about how I'm using all of the newest features in my projects. I prefer to understand how a feature works and have a concrete reason why I should be using some new feature before I actively embrace it.

Spread operators make my life easier because I don't need to waste time playing around with the arguments object. Destructuring assignment makes my life easier because I don't need to deal with the tedium of using objects as function arguments. Async/await makes it easier to convert and edit Promise chains.

So why, then, am I such a fan of Generator Functions? After all, MDN has three major articles describing Generator Functions, and none of them explain why you should use it. They're presented as a solution in search of a problem. In reality — just like async/await, spread operators and destructuring assignment — Generator Functions are a solution to a problem I didn't realize I had.

How they work

On the surface, a Generator Function is just another way to iterate through some series of data. Sure, it gives you a neat interface which you can define some custom series, but it's still just a new technique for iteration. My first step when trying to understand their use was to hop onto MDN and browse the Mozilla documentation. The impression I took away was that a Generator Function was just an abstraction for a stateful function — and to a certain degree, this is true. We can confirm this hypothesis with a simple iterator to generate an ever-incrementing series of numbers:

// generators are meant to generate numbers, no?
const integrator = (function* integrator() {
    let i = 0;
    while (true) {
        yield i++;
    }
})();

console.log(integrator.next().value); // 0
console.log(integrator.next().value); // 1
console.log(integrator.next().value); // 2

Unfortunately, this use-case is both more verbose and less expressive than the equivalent code using a traditional closure. It does show us how the Generator Function integrates with our code, though:

  1. The function* is executed.
  2. The Generator Function runs until it reaches yield.
  3. The Generator Function saves its state and "returns" a value.
  4. A Generator-Iterator (in this case, integrator) receives the value returned by yield.
  5. We call .next() on the Generator-Iterator.
  6. The Generator Function picks up where it left off, and then we return to Step 2.

A more substantial example

Despite the familiar syntax, interacting with a generator function is more like interacting with an external system than with a function. Generator Functions help to separate a loop's logic from its content — they let you separate the "how" from the "what" and write more expressive code. The more complicated or resource-intensive the looping logic becomes, the more helpful this separation becomes. As an example, we can write a loop that generates every prime number between two other numbers. This example includes two different (but common) ways to iterate the calculated prime numbers: callbacks and returns.

function getPrimes(start, end, callback) {
    const result = [];
    for (start; start < end; ++start) {
        let isPrime = true;
        for (let i = 2; isPrime && i < Math.abs(start); ++i) {
            if ( (start / i) === ((start / i)|0) ) {
                isPrime = false;
            }
        }
        if (isPrime) {
            result.push(start);
            if ('function' === typeof callback) {
                callback(start);
            }
        }
    }
    return result;
}

The more expressive option is to wait for the array to return and iterate through it. The downside is that we have to wait for the function to return a value before we can do anything. If done incorrectly, this can stall or crash our program before the array returns. From a user's perspective, the function could be sluggish and unstable.

getPrimes(0, 1e5).forEach((prime) => console.log(prime));

The second technique is to sacrifice readability for performance. We no longer need to wait until the function returns before executing the console.log. Unfortunately, this does not clearly communicate that we are iterating over an array, or even that this is a synchronous task:

getPrimes(0, 1e5, (prime) => console.log(prime));

Retrofitting getPrimes() to use a Generator Function mixes the benefits of these two techniques. This Generator Function is still synchronous, and the logic is identical to the previous example. The only differences for this function are that the callback and return statements have been removed and that the generator function uses "yield" instead of pushing values into an array or executing a callback:

function* getPrimes(start, end) {
    for (start; start < end; ++start) {
        let isPrime = true;
        for (let i = 2; isPrime && i < Math.abs(start); ++i) {
            if ( (start / i) === ((start / i)|0) ) {
                isPrime = false;
            }
        }
        if (isPrime) {
            yield start;
        }
    }
}

Here's where the magic happens. We can consume this generator using a for ... of loop. Suddenly, our code unambiguously reflects that we are executing a loop:

for (const prime of getPrimes(0, 1e5)) {
    console.log(prime);
}

Bear in mind that this is a toy example. Yet, even with this relatively simple function, the performance and readability benefits are noticeable. This benefit grows with the complexity of the system. For example, using generators inside an async function:

async function getApiRecords(batchSize) {
    const pages = await getPageCount(batchSize);
    return (function* () {
        for (let i = 0; i < pages; ++i) {
            yield getPageNumber(i);
        }
    })();
}

The above code would fetch the number of pages in an API, and then iterate through each page as if the function were synchronous:

async function main() {
    for (const page of getApiRecords(10)) {
        await page;
        // do things with every API page
    }
}

Final Thoughts and Practice Exercises

The more advanced your looping logic is, the more useful Generator Functions become. Whether you are dealing with huge data-sets, complicated looping rules, or generalized asynchronous behavior, Generator Functions are a great addition to a JavaScript developer's toolbox.

For practice, I encourage you to build one of the following systems to get a feel for how Generator Functions work:

  1. A Generator Function that scrambles the letters in a word without repeating any spelling variants.
  2. A Generator Function that prints all of the Roman Numerals between two parameters.
  3. A Generator Function that counts from 1 to 999, but skips any number with the same digit more than once (e.g. skip the numbers: 011, 101, and 110).

Comments

Back to top