Skip to content

Chapter 24: Generators

Lazy Values on Demand

Sometimes you don't want all the values at once. Maybe there are too many. Maybe computing them is expensive. Maybe the sequence is infinite.

Generators solve this. A generator is a function that produces values one at a time using yield. Each time it yields, it pauses. When you ask for the next value, it resumes right where it left off.

This is lazy evaluation. No work happens until you need the result.

Basic Syntax

A generator looks like a regular function, but returns iter and uses yield instead of return:

wyn
fn counting() -> iter {
    yield 1
    yield 2
    yield 3
}

fn main() -> int {
    var gen = counting()
    print(gen.next())  // 1
    print(gen.next())  // 2
    print(gen.next())  // 3
    return 0
}

Each call to .next() runs the generator until the next yield, then pauses. The value after yield is what .next() returns.

When the generator has no more yields, .next() returns none.

Collecting into an Array

If you want all the values at once, use .collect():

wyn
fn counting() -> iter {
    yield 1
    yield 2
    yield 3
}

fn main() -> int {
    var all = counting().collect()
    print(all)  // [1, 2, 3]
    return 0
}

This runs the generator to completion and gathers everything into an array. Useful when you know the sequence is finite and you need all of it.

Generators with Loops

Generators get interesting when combined with loops:

wyn
fn range(start: int, end: int) -> iter {
    var i = start
    while i < end {
        yield i
        i = i + 1
    }
}

fn main() -> int {
    var nums = range(0, 5).collect()
    print(nums)  // [0, 1, 2, 3, 4]
    return 0
}

The while loop runs, but each iteration pauses at yield. The generator remembers i between calls. This is the key insight-local variables survive across yields.

For-In Iteration

Generators work directly with for-in loops:

wyn
fn squares(n: int) -> iter {
    var i = 0
    while i < n {
        yield i * i
        i = i + 1
    }
}

fn main() -> int {
    for x in squares(5) {
        print(x)
    }
    // 0, 1, 4, 9, 16
    return 0
}

The for-in loop calls .next() repeatedly until the generator is exhausted. No intermediate array is created-values flow directly from the generator to the loop body.

Chaining: map, filter, take

Generators support chaining operations. Each operation returns a new generator, so nothing runs until you consume the result:

wyn
fn naturals() -> iter {
    var i = 1
    while true {
        yield i
        i = i + 1
    }
}

fn main() -> int {
    // Get the first 5 even squares
    var result = naturals()
        .map(fn(x) { return x * x })
        .filter(fn(x) { return x % 2 == 0 })
        .take(5)
        .collect()

    print(result)  // [4, 16, 36, 64, 100]
    return 0
}
  • .map(fn) transforms each value
  • .filter(fn) keeps values where the function returns true
  • .take(n) stops after n values

These are lazy. naturals() is an infinite generator, but .take(5) ensures we only compute what we need. Without .take(), this would run forever.

Practical Example: Fibonacci

The Fibonacci sequence is a classic use case for generators-an infinite sequence where each value depends on the previous two:

wyn
fn fibonacci() -> iter {
    var a = 0
    var b = 1
    while true {
        yield a
        var temp = a + b
        a = b
        b = temp
    }
}

fn main() -> int {
    // First 10 Fibonacci numbers
    var fibs = fibonacci().take(10).collect()
    print(fibs)  // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

    // Fibonacci numbers under 100
    for x in fibonacci() {
        if x >= 100 {
            break
        }
        print(x)
    }

    return 0
}

Without generators, you'd need to either precompute the whole sequence or manually track state between calls. The generator handles the bookkeeping for you.

How It Works Under the Hood

Generators compile to coroutines. Here's what happens:

  1. When you call a generator function, it doesn't run the body. It creates a coroutine object that holds the function's state.
  2. Each .next() call resumes the coroutine. It runs until it hits a yield, then suspends.
  3. Local variables are stored in the coroutine object, not on the stack. That's how they survive across yields.
  4. When the function body ends (or hits a return), the coroutine is marked as done and .next() returns none.

The Wyn compiler transforms your generator into a state machine in the generated C code. Each yield becomes a suspension point with a state label. Resuming jumps back to the right label and continues execution.

This means generators are cheap. No threads, no OS overhead. Just a struct holding local variables and a state index.

Summary

Generators give you lazy sequences with clean syntax:

  • yield produces a value and pauses
  • .next() resumes and gets the next value
  • .collect() gathers all values into an array
  • .map(), .filter(), .take() chain without intermediate allocations
  • for-in consumes generators directly
  • Under the hood, it's coroutines compiled to C state machines

MIT License - v1.19.1