Skip to content
C++

std::generator — First-Class Generators in C++23

C++20 introduced coroutines as a language mechanism — a framework for writing suspendable functions. But C++20 deliberately shipped no concrete coroutine types in the standard library. You had to write your own promise_type, your own handle management, and your own iterator shim before you could use co_yield to build a generator. C++23 fixes this with std::generator: the first concrete coroutine type in the standard library. It replaces the hand-rolled infrastructure with a single well-specified class that integrates directly with the ranges library.

Before and after: hand-rolled vs std::generator

To understand what std::generator saves you, it helps to see the code it replaces. In C++20, building a generator for the Fibonacci sequence requires a full custom type with a nested promise_type, a coroutine handle, and an iterator that wraps that handle. The logic of the generator — just two additions and a yield — is buried in a hundred lines of machinery. With std::generator, the machinery disappears and only the logic remains.

C++20: hand-rolled generator (~80 lines of boilerplate)

template <typename T>
struct Generator {
    struct promise_type {
        T current_value;
        auto get_return_object() {
            return Generator{
                handle_type::from_promise(*this)};
        }
        auto initial_suspend() {
            return std::suspend_always{}; }
        auto final_suspend() noexcept {
            return std::suspend_always{}; }
        auto yield_value(const T value) {
            current_value = value;
            return std::suspend_always{}; }
        void return_void() {}
        void unhandled_exception() {
            std::exit(1); }
    };
    using handle_type =
        std::coroutine_handle<promise_type>;
    Generator(handle_type h) : coro(h) {}
    ~Generator() {
        if (coro) coro.destroy(); }
    Generator(const Generator&) = delete;
    Generator(Generator&& o) noexcept
        : coro(o.coro) { o.coro = nullptr; }
    T getValue() {
        return coro.promise().current_value; }
    bool next() {
        coro.resume();
        return !coro.done(); }
    handle_type coro;
};

Generator<int> fibonacci() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;
        auto c = a + b; a = b; b = c;
    }
}

C++23: std::generator (~3 lines)

#include <generator>

std::generator<int> fibonacci()
{
    int a = 0, b = 1;
    while (true)
    {
        co_yield a;
        auto c = a + b;
        a = b;
        b = c;
    }
}

int main()
{
    // It's a range — use range-for directly:
    for (int fib : fibonacci()
                   | std::views::take(10))
    {
        std::print("{} ", fib);
    }
    // 0 1 1 2 3 5 8 13 21 34
}

The entire promise machinery, handle lifecycle, and iterator protocol are handled internally by std::generator. The function body contains only the algorithm.

What std::generator is

std::generator<Ref, Val, Alloc> is a coroutine return type from <generator>. A function whose return type is std::generator<T> becomes a coroutine: it may use co_yield to produce values one at a time and suspend execution between productions. Unlike an eager function that computes and returns all values at once, the coroutine body runs only as far as the next co_yield each time the caller requests a value. The result is lazy: values are computed on demand, and an infinite sequence is perfectly expressible because no value is computed until it is consumed.

std::generator models std::ranges::input_range: it is single-pass (you cannot iterate it twice), non-copyable (only movable — there is exactly one owner of the coroutine frame), and its iterator is a std::input_iterator. These constraints reflect the nature of a coroutine: it has internal state that advances monotonically.

template<
    class Ref,                       // type yielded by reference (what co_yield produces)
    class Val   = std::remove_cvref_t<Ref>,   // value type (for iterator value_type)
    class Alloc = void               // custom allocator for the coroutine frame
>
class generator;

In the common case, you only specify the yield type: std::generator<int> yields int values. The distinction between Ref and Val matters when you want to yield references (e.g. std::generator<std::string&, std::string> yields references but the iterator's value_type is std::string).

Usage patterns

Finite sequence — iota-style counter

std::generator<int> iota(int start, int stop, int step = 1)
{
    for (int i = start; i < stop; i += step)
        co_yield i;
}

// Use directly with range algorithms:
auto evens = iota(0, 20, 2);
std::println("sum of evens 0..18: {}",
    std::ranges::fold_left(evens, 0, std::plus{}));
// sum of evens 0..18: 90

Infinite sequence — prime numbers

std::generator<int> primes()
{
    auto is_prime = [](int n) {
        if (n < 2) return false;
        for (int d = 2; d * d <= n; ++d)
            if (n % d == 0) return false;
        return true;
    };
    for (int n = 2; ; ++n)
        if (is_prime(n)) co_yield n;
}

// views::take makes an infinite range usable:
for (int p : primes() | std::views::take(10))
    std::print("{} ", p);
// 2 3 5 7 11 13 17 19 23 29

Yielding by reference — no copies for large objects

// Yield std::string& so callers see references, not copies
std::generator<std::string&, std::string>
lines_of(std::vector<std::string>& vec)
{
    for (auto& line : vec)
        co_yield line;   // yields reference — zero copies
}

std::vector<std::string> log { "ERROR: disk full", "INFO: started", "WARN: slow" };
for (const auto& line : lines_of(log))
    std::println("{}", line);

Combining with ranges — composing pipelines

std::generator<int> naturals()
{
    for (int i = 1; ; ++i) co_yield i;
}

// Compose freely with range adaptors:
auto pipeline = naturals()
    | std::views::filter([](int n){ return n % 3 == 0; })
    | std::views::transform([](int n){ return n * n; })
    | std::views::take(5);

for (int v : pipeline) std::print("{} ", v);
// 9 36 81 144 225  (squares of 3,6,9,12,15)

std::ranges::elements_of — recursive generators

A common pattern in generators is yielding all values from a sub-sequence — for example, flattening a tree or composing two generators. Naively, you would write a nested loop: for (auto v : sub) co_yield v;. This works but is O(depth) coroutine suspensions per value. C++23 introduces std::ranges::elements_of(range), a tag type that tells std::generator's promise to use symmetric transfer — the inner generator runs directly without re-entering the outer coroutine at each step. This is both faster and avoids stack overflow in deeply recursive generators.

// Tree flattening with recursive generators
struct Node {
    int value;
    std::vector<Node> children;
};

std::generator<int> flatten(const Node& node)
{
    co_yield node.value;
    for (const auto& child : node.children)
        // elements_of: symmetric transfer to child generator
        co_yield std::ranges::elements_of(flatten(child));
}

// Compare with the naive (slower) approach:
std::generator<int> flatten_naive(const Node& node)
{
    co_yield node.value;
    for (const auto& child : node.children)
        for (int v : flatten_naive(child))  // re-enters outer coroutine each step
            co_yield v;
}
Node tree {
    1, {
        Node{ 2, { Node{4, {}}, Node{5, {}} } },
        Node{ 3, { Node{6, {}}, Node{7, {}} } }
    }
};

for (int v : flatten(tree)) std::print("{} ", v);
// 1 2 4 5 3 6 7   (pre-order traversal)

elements_of also accepts any range, not just generators

co_yield std::ranges::elements_of(some_vector); yields all elements of the vector from a generator, with efficient transfer semantics. This replaces the pattern of writing a loop over a subrange inside a generator.

Key design properties

Synchronous and single-threaded

std::generator is a synchronous pull-based mechanism — the caller and generator alternate on the same thread. It does not use co_await, has no scheduler, and does not yield to other threads. For async scenarios, use coroutine libraries (cppcoro, Asio) that provide task<T> and async_generator<T>.

Non-copyable, movable

A std::generator object cannot be copied — there is exactly one owner of the coroutine frame. It can be moved (e.g. stored in a container or returned from a factory function). Iterating a moved-from generator is undefined behaviour.

Single-pass input range

std::generator models input_range, not forward_range. Once you have advanced the iterator, you cannot go back. You cannot iterate the generator twice; once it is exhausted, it is done.

Exception propagation

If an exception escapes from the coroutine body, it is stored and rethrown when the caller next dereferences the iterator. The generator becomes unusable after an exception escapes.

Custom allocators

The coroutine frame is heap-allocated by default. You can pass a custom allocator as the third template argument to control where the frame is placed — useful for embedded systems or arenas.

When to use std::generator

ScenarioUse std::generator?Why
Lazy infinite sequence (primes, Fibonacci, counter)YesNatural expression of the algorithm; callers control how many values to consume
Tree/graph traversal without materialising all nodesYesYields nodes lazily; elements_of handles recursive cases efficiently
Transforming or filtering a data stream on-the-flyConsider ranges insteadPure transformations are often more composable as range adaptors (filter, transform)
Async I/O — reading lines from a network socketNo — use async_generator (cppcoro) or Asiostd::generator is synchronous; blocking I/O stalls the whole thread
Producer-consumer across threadsNo — use a concurrent queue + coroutinesstd::generator is single-threaded; no synchronisation primitives
Building a pull-based data pipeline for numerical processingYesLazy evaluation means only the consumed elements are computed; composes with range adaptors

std::generator vs. hand-rolled generators

The generators lesson covered how to build a generator by writing a custom promise_type. That knowledge is still valuable — it gives you the mental model for how std::generator works internally, and you will need it when building async generators or other coroutine types that the standard does not yet provide. But for the common case of a synchronous, lazy, pull-based sequence, std::generator is the right tool.

AspectHand-rolled generatorstd::generator
Boilerplate~80 lines: promise_type, handle, iterator, destructor0 lines — just write the function body
Range integrationManual iterator requiredAutomatically models input_range
Recursive yieldingManual nested loops (O(depth) re-suspensions)elements_of() with symmetric transfer
Exception handlingMust implement unhandled_exception() yourselfHandled internally; rethrown at dereference
Custom allocatorsFull control via promise_typeThird template parameter Alloc
When to use insteadAsync generators, non-standard suspend/resume semanticsStandard synchronous pull-based sequences
Sign in to track progress