Skip to content

Specification §11

Generators (yields)

language-design.md §11 · 47 lines · 2 min read

Generators are for lazy sequences and iteration within a single process. They are not for concurrency, which is the role of processes and channels.

Coroutine-style (bidirectional) is redundant: an “accumulator” that receives and returns values is just a process with a channel Channel[<-In, ->Out].

@generator fn fibonacci() -> int {
var a, b := 0, 1
loop {
yield a
a, b = b, a + b
}
}
loop n in fibonacci().take(10) { ... }

Restriction (stackless). Being zero-allocation and in the same process (above), the generator is a stackless state-machine, so yield only happens at the generator’s own level, not inside an ordinary function it calls (an ordinary function has no way to suspend the generator that called it). For arbitrary suspension across calls, what you want is a process (stackful, section 3), not a generator. With the scheduler it composes well: a next() that touches io.* suspends the process (stackful), and the generator’s state-machine is preserved in that suspension.

Abandoning a generator runs its defers. A consumer is free to stop early — break out of the loop, return from inside it, or exhaust a take(n) before the source runs dry — and the generator is then torn down at the point where it was suspended, running everything it owed there, innermost scope first and each scope in LIFO. A generator that acquires a resource therefore releases it even when nobody drains it:

@generator fn lines(path: string) -> string {
f := open(path)
defer f.close() // runs even if the consumer breaks on line 3
...
}

This is the same rule a dying process follows (section 8): a scope that will never be resumed still owes its cleanups. Without it the lazy pipeline would be a leak waiting to happen, and defer would stop being the answer to “release this, whatever happens”. Being stackless is what makes it cheap: since defer is block-scoped, what a suspension owes is known at compile time, so the teardown is ordinary code at a known point, not a runtime unwinder.

lines_from_file("log.txt")
.filter(fn(l) => l.contains("ERROR"))
.map(fn(l) => parse_entry(l))
.take(100)

Each stage is a Generator[T]. Zero intermediate allocation, all in the same process. Impossible to do with the same efficiency via processes and channels.

The lazy-sequence vocabulary on a Generator[T] is map, filter, take, drop, reduce, enumerate, zip and collect: the shape-preserving ones (map/filter/take/drop) and enumerate/zip return a new Generator (lazy, composing by . with no intermediate allocation), while reduce folds to a single value and collect materializes into a container you choose. (enumerate yields (index, value) and zip yields (a, b), using tuple positional access, section 7.)