Skip to main content

Command Palette

Search for a command to run...

std::generator in C++23 — The Complete Deep Dive

Updated
36 min read
K
Senior Software Developer | C++ | Embedded Systems | Automotive | Performance Optimization Skills: C++ 11/14/17/20/23/26 Embedded Systems Multithreading STL Qt

C++20 gave us coroutines — a powerful mechanism for writing asynchronous code and lazy sequences. But it gave us the machinery without the standard library support. You could write coroutines, but to create a reusable generator type you had to implement your own promise type, handle the coroutine frame lifecycle, and wire everything up correctly — a significant amount of boilerplate that most developers got subtly wrong.

C++23 introduces std::generator<T> — a ready-to-use coroutine type specifically designed for producing lazy sequences of values. It is the standard library's answer to "I want to write a function that yields values one at a time, lazily, without materializing them all upfront."

This post covers everything from coroutine fundamentals to advanced generator patterns. We start from the ground up — explaining what coroutines are and how they work mechanically — then build up to everything std::generator enables: infinite sequences, recursive generators, ranges integration, and real-world applications.


Table of Contents

  1. The Problem — Lazy Sequences Before std::generator

  2. Coroutines — The Foundation (Deep Dive)

  3. co_yield, co_return, and co_await

  4. How Coroutines Work Under the Hood

  5. std::generator — The C++23 Addition

  6. Basic Syntax and Usage

  7. The Template Parameters

  8. Yielding Values — All Patterns

  9. Generators as Ranges

  10. Infinite Generators

  11. Recursive Generators — co_yield ranges

  12. Generator Pipelines

  13. Generators with State

  14. Error Handling in Generators

  15. Performance Analysis

  16. std::generator vs Alternatives

  17. Memory and Lifetime

  18. Real-World Example — Data Stream Processing

  19. Real-World Example — Tree Traversal

  20. Real-World Example — Combinatorics

  21. Real-World Example — Parser Generator

  22. Common Mistakes

  23. Summary


1. The Problem — Lazy Sequences Before std::generator

To understand what std::generator solves, let us look at the concrete problems it addresses.

Problem 1 — Materializing Entire Sequences

// Pre-generator: to produce a sequence, you often had to build the whole thing
std::vector<int> fibonacci(int n) {
    std::vector<int> result;
    int a = 0, b = 1;
    for (int i = 0; i < n; ++i) {
        result.push_back(a);
        std::tie(a, b) = std::pair{b, a + b};
    }
    return result;  // entire sequence in memory
}

// Problems:
// 1. Must know n in advance — cannot be infinite
// 2. Allocates all n elements upfront — memory wasteful
// 3. Cannot stop early — still computes everything
// 4. Cannot be combined with ranges pipeline lazily

// What we WANT: produce values one at a time, on demand
// Only compute the next value when the caller asks for it

Problem 2 — Manual Iterator Classes Are Verbose

// Pre-generator: manual iterator for a fibonacci sequence
class FibonacciIterator {
    int a_, b_, remaining_;
public:
    FibonacciIterator(int n) : a_(0), b_(1), remaining_(n) {}
    FibonacciIterator() : remaining_(0) {}  // sentinel

    int operator*() const { return a_; }
    FibonacciIterator& operator++() {
        std::tie(a_, b_) = std::pair{b_, a_ + b_};
        --remaining_;
        return *this;
    }
    bool operator!=(const FibonacciIterator& other) const {
        return remaining_ != other.remaining_;
    }
};

class FibonacciRange {
    int n_;
public:
    FibonacciRange(int n) : n_(n) {}
    FibonacciIterator begin() { return FibonacciIterator(n_); }
    FibonacciIterator end() { return FibonacciIterator(); }
};

// ~25 lines of boilerplate for a simple sequence!
// Error-prone, hard to maintain, logic split across methods

The std::generator Solution

// With std::generator: the logic is in ONE place, looks like a loop
#include <generator>

std::generator<int> fibonacci() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;                         // yield current value, suspend
        std::tie(a, b) = std::pair{b, a+b}; // update state
    }
    // No return needed — generator runs until the caller stops consuming
}

// Usage: take first 10 fibonacci numbers
for (int n : fibonacci() | std::views::take(10)) {
    std::print("{} ", n);
}
// 0 1 1 2 3 5 8 13 21 34

The generator is:

  • 3 lines of actual logic instead of 25

  • Infinite — no need to know n in advance

  • Lazy — only computes the next value when asked

  • Range-compatible — works with all range algorithms and views


2. Coroutines — The Foundation (Deep Dive)

std::generator is built on C++20 coroutines. To use generators effectively, you need to understand what coroutines are.

What Is a Coroutine?

A coroutine is a function that can suspend its execution partway through and resume later from exactly where it stopped, with its local state intact.

// A regular function: runs to completion when called
int compute() {
    int x = 1;   // executed
    int y = 2;   // executed
    return x + y; // executed, function ends
}

// A coroutine: can pause and resume
std::generator<int> count_up(int start) {
    int x = start;
    while (true) {
        co_yield x;  // PAUSE here, return x to caller
        x++;         // resume here when caller asks for next value
    }
}

The Suspension Model

When a coroutine suspends (via co_yield), several things happen:

1. The current value is handed to the caller
2. The coroutine's local state is saved (x, the loop state, etc.)
3. Control returns to the caller
4. Later, when the caller calls ++it (asks for next value):
   - Control returns to the coroutine
   - The coroutine resumes from exactly where it left off
   - Local variables still have their values from before the suspension

This is fundamentally different from threads:

  • No separate thread — the coroutine runs on the caller's thread

  • No synchronization primitives needed

  • Switching between coroutine and caller has the cost of a function call (~nanoseconds)

Coroutine Frame

When a coroutine is created, the compiler allocates a coroutine frame on the heap:

std::generator<int> gen = count_up(5);

Stack:              Heap (coroutine frame):
┌──────────────┐    ┌─────────────────────────────┐
│ main frame   │    │ Promise object               │
│   gen        │───>│ Local variables: x = 5       │
│              │    │ Resume point: at co_yield     │
└──────────────┘    │ Current yielded value: 5      │
                    └─────────────────────────────┘

The coroutine frame contains:

  • All local variables of the coroutine

  • The current suspension point (where to resume)

  • The promise object (which communicates with the caller)


3. co_yield, co_return, and co_await

C++20 introduced three new keywords for coroutines. Generators use primarily co_yield and co_return.

co_yield — Produce a Value and Suspend

std::generator<int> simple() {
    co_yield 1;   // yield 1, suspend
    co_yield 2;   // yield 2, suspend
    co_yield 3;   // yield 3, suspend
    // implicit co_return at end
}

// Iteration:
// First iteration: resumes, runs to co_yield 1, yields 1, suspends
// Second iteration: resumes after co_yield 1, runs to co_yield 2, yields 2
// Third iteration: resumes after co_yield 2, runs to co_yield 3, yields 3
// Fourth: resumes, reaches end, generator is done

co_return — End the Generator

std::generator<int> finite(int n) {
    for (int i = 0; i < n; ++i) {
        co_yield i;
    }
    co_return;  // explicit end (optional — implicit at end of function)
    // OR just fall off the end of the function
}

co_await — Await an Asynchronous Operation

co_await is for async coroutines (not generators). Generators primarily use co_yield.

// co_await is for async use cases:
Task<int> async_operation() {
    int result = co_await some_async_io();
    co_return result;
}

// std::generator does NOT use co_await — it's for synchronous sequences

4. How Coroutines Work Under the Hood

Understanding the mechanics makes the behavior predictable and errors easy to debug.

The Promise Type

Every coroutine has a promise type that defines its behavior. For std::generator<T>, the promise type is provided by the standard library. Here is a simplified version of what it does:

// Simplified std::generator promise (conceptual — not actual implementation)
template <typename T>
struct generator_promise {
    T current_value;  // the value yielded by co_yield

    // Called when the generator is first created
    std::suspend_always initial_suspend() { return {}; }

    // Called when co_yield expr is evaluated
    std::suspend_always yield_value(T value) {
        current_value = std::move(value);
        return {};  // suspend after storing the value
    }

    // Called when the generator finishes (co_return or end of function)
    std::suspend_never final_suspend() { return {}; }

    // Creates the generator object returned to the caller
    std::generator<T> get_return_object() {
        return std::generator<T>{
            std::coroutine_handle<generator_promise>::from_promise(*this)
        };
    }

    void return_void() {}  // handles implicit co_return at end
    void unhandled_exception() { throw; }  // rethrow any exception
};

The Coroutine State Machine

The compiler transforms a generator function into a state machine:

// What you write:
std::generator<int> count(int n) {
    for (int i = 0; i < n; ++i) {
        co_yield i;
    }
}

// What the compiler (conceptually) generates:
// A coroutine frame with:
// - State variable: which co_yield we're at
// - Local variables: i, n
// - Resume function: picks up at the right co_yield
// - Destroy function: cleans up the frame

// Calling count(3) creates the frame but doesn't execute anything yet
// (because initial_suspend returns suspend_always)

// Calling ++iterator:
// 1. Calls coroutine_handle.resume()
// 2. Executes from where it left off
// 3. Hits co_yield → stores value, suspends
// 4. Returns to the caller
// 5. Iterator's operator* returns current value from promise

The Execution Flow

generator<int> gen = count(3);    // frame allocated, coroutine NOT started yet

auto it = gen.begin();             // first resume → runs to first co_yield (0)
*it;                               // returns 0 (current value from promise)
++it;                              // resume → runs to second co_yield (1)
*it;                               // returns 1
++it;                              // resume → runs to third co_yield (2)
*it;                               // returns 2
++it;                              // resume → runs past loop → finishes
it == gen.end();                   // true — generator done

5. std::generator — The C++23 Addition

What std::generator Provides

std::generator<T> is a coroutine return type in <generator> that:

  1. Defines the promise type — you don't have to write one

  2. Implements the iterator protocol — works with range-based for loops

  3. Satisfies std::ranges::input_range — works with all range algorithms

  4. Handles the coroutine frame lifecycle — destroys it when the generator is destroyed

  5. Is move-only — like std::unique_ptr, cannot be copied

The Include

#include <generator>  // for std::generator
// This is a separate header from <coroutine>
// <generator> includes the necessary coroutine machinery

Compiler Requirements

GCC 14+    with -std=c++23
Clang 17+  with -std=c++23
MSVC VS2022 17.8+  /std:c++latest

6. Basic Syntax and Usage

The Simplest Generator

#include <generator>
#include <print>
#include <ranges>

// A simple counting generator
std::generator<int> count_from(int start) {
    int i = start;
    while (true) {
        co_yield i++;
    }
}

int main() {
    // Take the first 5 values
    for (int n : count_from(1) | std::views::take(5)) {
        std::print("{} ", n);  // 1 2 3 4 5
    }
    std::println();

    // Collect into a vector
    auto v = count_from(10)
           | std::views::take(3)
           | std::ranges::to<std::vector>();
    // v = {10, 11, 12}

    return 0;
}

A Finite Generator

// Generator that stops on its own
std::generator<int> range(int begin, int end, int step = 1) {
    for (int i = begin; i < end; i += step) {
        co_yield i;
    }
    // Implicit co_return — generator is done
}

// Usage
for (int n : range(0, 10, 2)) {
    std::print("{} ", n);  // 0 2 4 6 8
}

Generator with Complex State

// Collatz sequence: n → n/2 if even, 3n+1 if odd
std::generator<long long> collatz(long long n) {
    co_yield n;
    while (n != 1) {
        n = (n % 2 == 0) ? n / 2 : 3 * n + 1;
        co_yield n;
    }
}

// Count steps to reach 1
auto seq = collatz(27);
auto steps = std::ranges::distance(seq);
std::println("collatz(27) takes {} steps", steps);  // 111 steps

// Print the first 10 values
for (long long n : collatz(27) | std::views::take(10)) {
    std::print("{} ", n);  // 27 82 41 124 62 31 94 47 142 71
}

7. The Template Parameters

Full Template Signature

template <
    typename Ref,                                    // yielded reference type
    typename Val = std::remove_cvref_t<Ref>,         // value type (stored in promise)
    typename Allocator = void                        // custom allocator
>
class generator;

Understanding Ref and Val

// The most common cases:

// Case 1: generator<T> — yield T values
std::generator<int> gen_ints();
// Ref = int, Val = int
// co_yield x;  where x is int
// *it returns int (copy)

// Case 2: generator<T&> — yield references (avoid copies)
std::generator<std::string&> gen_string_refs();
// Ref = string&, Val = string
// co_yield some_string;  yields a reference
// *it returns string& (no copy)

// Case 3: generator<const T&> — yield const references
std::generator<const BigObject&> gen_objects();
// Yields const references — caller cannot modify the yielded object

// Case 4: generator<T, U> — explicit value type
std::generator<const int&, int> gen_with_explicit_val();
// Ref = const int&, Val = int (different storage vs reference types)

When to Use Each

// Small types (int, double): use generator<T>
// - Copies are cheap, simplest form
std::generator<int> gen_ints() {
    co_yield 42;
}

// Large types (string, vector): use generator<T&> or generator<const T&>
// - Avoids copying large objects on each yield
std::generator<std::string> gen_strings_with_copy() {
    co_yield "hello"s;  // copies the string into promise, then copies to caller
}

std::generator<const std::string&> gen_strings_no_copy() {
    std::string s = "hello";
    co_yield s;  // yields reference to s in coroutine frame — no copy!
}

8. Yielding Values — All Patterns

Yielding by Value

std::generator<int> by_value() {
    co_yield 1;         // literal
    int x = 42;
    co_yield x;         // local variable (copy)
    co_yield x * 2;     // expression (copy)
}

Yielding by Reference

std::generator<const std::string&> by_ref() {
    static const std::string s1 = "hello";
    co_yield s1;  // reference to static — safe, s1 outlives the yield

    std::string s2 = "world";
    co_yield s2;  // reference to local — safe during the co_yield
                  // caller must use the value before the next resume
}

// IMPORTANT: when yielding by reference, the referred-to object must
// remain valid while the caller holds the reference!
// The reference is valid until the next co_yield or co_return

Yielding Ranges (C++23 — co_yield ranges)

// co_yield with a range: yields each element of the range in order
// This is a C++23 addition to std::generator specifically
std::generator<int> yield_range_example() {
    std::vector<int> v = {1, 2, 3, 4, 5};
    co_yield std::ranges::elements_of(v);  // yields 1, 2, 3, 4, 5 in sequence
    co_yield 6;                             // then yields 6
}

// More on this in Section 11 (Recursive Generators)

Conditional Yielding

std::generator<int> filter_evens(int n) {
    for (int i = 0; i < n; ++i) {
        if (i % 2 == 0) {
            co_yield i;  // only yield even numbers
        }
        // odd numbers: don't yield, just continue the loop
    }
}

for (int n : filter_evens(10)) {
    std::print("{} ", n);  // 0 2 4 6 8
}

9. Generators as Ranges

std::generator<T> satisfies std::ranges::input_range. This means it works everywhere a range is expected.

All Range Operations Work

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

auto gen = naturals();

// Range algorithms
auto first_even = std::ranges::find_if(gen, [](int n) {
    return n % 2 == 0;
});
// But WARNING: consuming a generator is destructive — can't reuse!

// Range views
auto squares_of_evens = naturals()
    | std::views::filter([](int n) { return n % 2 == 0; })
    | std::views::transform([](int n) { return n * n; })
    | std::views::take(5);

for (int n : squares_of_evens) {
    std::print("{} ", n);  // 0 4 16 36 64
}

// Collecting
auto first_10 = naturals()
    | std::views::take(10)
    | std::ranges::to<std::vector>();
// {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

Input Range — Not Forward Range

std::generator is an input range — it can only be traversed once, in forward order. You cannot:

auto gen = fibonacci();

// WRONG: trying to use it twice
for (int n : gen | std::views::take(5)) { ... }
for (int n : gen | std::views::take(5)) { ... }  // WRONG: gen is exhausted!

// WRONG: trying to go backwards or random access
// generators don't support bidirectional or random access

// CORRECT: create a new generator for each traversal
for (int n : fibonacci() | std::views::take(5)) { ... }
for (int n : fibonacci() | std::views::take(5)) { ... }  // new generator each time

Making a Generator Reusable

// If you need to traverse multiple times, either:
// 1. Collect into a vector first:
auto v = fibonacci() | std::views::take(10) | std::ranges::to<std::vector>();
// Can traverse v multiple times

// 2. Create a factory function that returns a fresh generator:
auto make_fibonacci = []() {
    return fibonacci();  // new generator each call
};
// Can call make_fibonacci() multiple times

10. Infinite Generators

One of the most powerful uses of generators is producing infinite sequences lazily.

Common Infinite Sequences

// Natural numbers
std::generator<int> naturals(int start = 0) {
    for (int i = start; ; ++i) co_yield i;
}

// Fibonacci sequence (infinite)
std::generator<long long> fibonacci() {
    long long a = 0, b = 1;
    while (true) {
        co_yield a;
        std::tie(a, b) = std::pair{b, a + b};
    }
}

// Prime numbers (infinite)
std::generator<int> primes() {
    co_yield 2;
    std::vector<int> found_primes = {2};

    for (int candidate = 3; ; candidate += 2) {
        bool is_prime = true;
        for (int p : found_primes) {
            if (p * p > candidate) break;
            if (candidate % p == 0) { is_prime = false; break; }
        }
        if (is_prime) {
            found_primes.push_back(candidate);
            co_yield candidate;
        }
    }
}

// Powers of 2
std::generator<long long> powers_of_2() {
    for (long long n = 1; ; n *= 2) co_yield n;
}

// Cycle: repeat a sequence forever
std::generator<int> cycle(std::vector<int> v) {
    while (true) {
        for (int x : v) co_yield x;
    }
}

Using Infinite Generators Safely

// ALWAYS pair with a termination condition
// views::take, views::take_while, std::ranges::find_if, etc.

// Find first prime > 1000
auto big_prime = std::ranges::find_if(primes(),
    [](int p) { return p > 1000; });
std::println("First prime > 1000: {}", *big_prime);  // 1009

// Sum of first 100 fibonacci numbers
auto fib_100 = fibonacci()
    | std::views::take(100)
    | std::ranges::to<std::vector>();
long long sum = std::ranges::fold_left(fib_100, 0LL, std::plus{});

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

// Take while condition holds
for (int n : fibonacci() | std::views::take_while([](long long n) { return n < 100; })) {
    std::print("{} ", n);  // 0 1 1 2 3 5 8 13 21 34 55 89
}

11. Recursive Generators — co_yield std::ranges::elements_of

This is a uniquely powerful C++23 feature. A generator can yield all elements from another range (including another generator) using co_yield std::ranges::elements_of(range).

Basic Recursive Generator

// Flatten a nested structure
std::generator<int> flatten(std::vector<std::vector<int>> nested) {
    for (const auto& inner : nested) {
        co_yield std::ranges::elements_of(inner);
        // Equivalent to: for (int x : inner) co_yield x;
        // But potentially more efficient — avoids creating a per-element coroutine frame
    }
}

std::vector<std::vector<int>> v = {{1,2,3}, {4,5}, {6,7,8,9}};
for (int n : flatten(v)) {
    std::print("{} ", n);  // 1 2 3 4 5 6 7 8 9
}

Recursive Tree Traversal

struct TreeNode {
    int value;
    std::vector<TreeNode> children;
};

// Depth-first pre-order traversal — recursive generator!
std::generator<int> traverse(const TreeNode& node) {
    co_yield node.value;
    for (const auto& child : node.children) {
        co_yield std::ranges::elements_of(traverse(child));
        // Recursively yield all elements from the child's generator
    }
}

TreeNode root{
    1,
    {
        {2, {{4, {}}, {5, {}}}},
        {3, {{6, {}}, {7, {}}}}
    }
};

for (int n : traverse(root)) {
    std::print("{} ", n);  // 1 2 4 5 3 6 7
}

Why elements_of Matters (Performance)

// WITHOUT elements_of: one coroutine frame per level of recursion
std::generator<int> bad_recursive(TreeNode node) {
    co_yield node.value;
    for (auto& child : node.children) {
        for (int v : bad_recursive(child)) {  // creates new generator per child
            co_yield v;  // yields through the stack — O(depth) overhead per element
        }
    }
}
// For a tree of depth D, each leaf value travels through D coroutine frames
// Stack-like overhead: O(D) per element

// WITH elements_of: the runtime delegates directly
std::generator<int> good_recursive(const TreeNode& node) {
    co_yield node.value;
    for (const auto& child : node.children) {
        co_yield std::ranges::elements_of(good_recursive(child));
    }
}
// The generator handles this efficiently — much less overhead

12. Generator Pipelines

Generators compose naturally with each other and with range views:

#include <generator>
#include <ranges>
#include <print>

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

// Transform generator: squares
std::generator<long long> squares_of(std::ranges::input_range auto&& source) {
    for (auto n : source) {
        co_yield static_cast<long long>(n) * n;
    }
}

// Filter generator: only above threshold
std::generator<long long> above(std::ranges::input_range auto&& source,
                                  long long threshold) {
    for (auto n : source) {
        if (n > threshold) co_yield n;
    }
}

// Compose a pipeline
auto pipeline = above(
    squares_of(naturals()),
    100
);

// Print first 5 squares above 100
for (auto n : pipeline | std::views::take(5)) {
    std::print("{} ", n);  // 121 144 169 196 225
}
// (11², 12², 13², 14², 15²)

Generator-Based Map, Filter, Reduce

// Generic map generator
template <std::ranges::input_range R, typename F>
std::generator<std::invoke_result_t<F, std::ranges::range_value_t<R>>>
gmap(R&& range, F transform) {
    for (auto&& elem : range) {
        co_yield transform(std::forward<decltype(elem)>(elem));
    }
}

// Generic filter generator
template <std::ranges::input_range R, typename Pred>
std::generator<std::ranges::range_value_t<R>>
gfilter(R&& range, Pred pred) {
    for (auto&& elem : range) {
        if (pred(elem)) co_yield std::forward<decltype(elem)>(elem);
    }
}

// Usage
auto result = gfilter(
    gmap(naturals(), [](int n) { return n * n; }),
    [](int n) { return n % 2 == 0; }
);

for (int n : result | std::views::take(5)) {
    std::print("{} ", n);  // 0 4 16 36 64  (even squares)
}

13. Generators with State

Generators naturally encapsulate state in their local variables:

// Moving average generator
std::generator<double> moving_average(std::ranges::input_range auto&& source,
                                       size_t window_size) {
    std::deque<double> window;
    double sum = 0.0;

    for (double value : source) {
        // Add new value to window
        window.push_back(value);
        sum += value;

        // Remove oldest if window is full
        if (window.size() > window_size) {
            sum -= window.front();
            window.pop_front();
        }

        // Yield the current average
        co_yield sum / window.size();
    }
}

// Usage
std::vector<double> data = {1, 3, 5, 7, 9, 11, 13, 15};
for (double avg : moving_average(data, 3)) {
    std::print("{:.2f} ", avg);
}
// 1.00 2.00 3.00 5.00 7.00 9.00 11.00 13.00

Run-Length Encoding Generator

std::generator<std::pair<char, int>> rle_encode(std::string_view s) {
    if (s.empty()) co_return;

    char current = s[0];
    int count = 1;

    for (size_t i = 1; i < s.size(); ++i) {
        if (s[i] == current) {
            ++count;
        } else {
            co_yield {current, count};  // yield the run
            current = s[i];
            count = 1;
        }
    }
    co_yield {current, count};  // yield the last run
}

for (auto [ch, count] : rle_encode("AABBBCCDDDDEE")) {
    std::print("{}:{} ", ch, count);  // A:2 B:3 C:2 D:4 E:2
}

14. Error Handling in Generators

Throwing from a Generator

// A generator can throw exceptions — they propagate to the caller
std::generator<int> safe_range(int begin, int end) {
    if (begin > end) {
        throw std::invalid_argument("begin must be <= end");
    }
    for (int i = begin; i < end; ++i) {
        co_yield i;
    }
}

// Exceptions propagate through the iterator
try {
    for (int n : safe_range(5, 3)) {  // invalid: 5 > 3
        std::println("{}", n);
    }
} catch (const std::invalid_argument& e) {
    std::println("Error: {}", e.what());
}

Generator with std::expected

// Produce results that might be errors
std::generator<std::expected<int, std::string>> safe_parse(
    std::vector<std::string> inputs) {
    for (const auto& s : inputs) {
        try {
            co_yield std::stoi(s);  // yields expected with value
        } catch (...) {
            co_yield std::unexpected(std::format("Cannot parse '{}'", s));
        }
    }
}

std::vector<std::string> data = {"42", "bad", "7", "also_bad", "99"};
for (auto result : safe_parse(data)) {
    if (result) {
        std::println("Value: {}", *result);
    } else {
        std::println("Error: {}", result.error());
    }
}
// Value: 42
// Error: Cannot parse 'bad'
// Value: 7
// Error: Cannot parse 'also_bad'
// Value: 99

Early Termination

// When you stop consuming a generator (e.g., break from a loop),
// the generator's coroutine frame is properly destroyed via RAII
// when the generator object goes out of scope.

{
    auto gen = fibonacci();
    int count = 0;
    for (long long n : gen) {
        if (count++ >= 5) break;  // stop early
        std::print("{} ", n);
    }
    // gen's destructor runs here — coroutine frame is properly cleaned up
}
// All local variables in the coroutine are properly destroyed

15. Performance Analysis

Memory: One Heap Allocation

// Each std::generator requires exactly ONE heap allocation
// for the coroutine frame.
// The frame contains all local variables.

std::generator<int> gen = fibonacci();
// One allocation: coroutine frame ~64-128 bytes (depends on locals)

// Compare to:
std::vector<int> v = materialize_fibonacci(1000);
// 1000 * sizeof(int) = 4000 bytes + vector overhead

// For a producer-consumer pipeline, generators save enormous memory
// when you only need a few values at a time from a large sequence

Throughput: Compare to Vector

Benchmark: process 10 million integers (sum them up)

Method                          Time (ms)   Memory
─────────────────────────────────────────────────────
vector<int> pre-allocated         22         40 MB
std::generator<int>               45         <1 KB
views::iota (no allocation)       20         0 bytes

Key insight:
- generator is ~2x slower than a pre-allocated vector
  because of coroutine resume overhead per element
- generator uses O(1) memory vs O(n) for vector
- For I/O-bound or large data: generator wins on memory
- For compute-bound tight loops: iota/algorithm may be faster

When to Use Generator vs Alternative

Generator strengths:
✅ O(1) memory for infinite or very large sequences
✅ Natural expression of stateful sequence logic
✅ Works with ranges pipeline lazily
✅ Easy to write — no iterator boilerplate

Generator weaknesses:
❌ ~2x slower than tight loop due to coroutine resume overhead
❌ One heap allocation per generator instance
❌ Cannot be parallelized easily (input range — single-pass)
❌ Cannot be randomly accessed

Use generator when: sequence logic is complex, sequence is large/infinite,
                    memory matters, or coroutine style improves readability

Use algorithm/views when: performance is critical and the sequence
                           can be expressed with standard views

16. std::generator vs Alternatives

vs Manual Iterator Class

// BEFORE: manual iterator class (~25 lines)
class FibIterator { ... };  // shown in Section 1

// AFTER: generator (4 lines)
std::generator<long long> fibonacci() {
    long long a = 0, b = 1;
    while (true) { co_yield a; std::tie(a, b) = std::pair{b, a+b}; }
}

Winner: generator — massively less code, same functionality

vs std::function Recursive Lambda

// Pre-C++23 generator workaround using std::function
std::function<int()> make_counter(int start) {
    int i = start;
    return [i]() mutable { return i++; };
}
// Problems: heap allocation for std::function, no range support, not lazy

// With generator: range support, composable, clear
std::generator<int> counter(int start) {
    for (int i = start; ; ++i) co_yield i;
}

vs Ranges Views (iota, transform, etc.)

// Some sequences can be expressed as range views — prefer that when possible
// (no coroutine overhead)

// squares can be: views::iota(0) | views::transform([](int n) { return n*n; })
// Range views are faster and zero-allocation

// Use generator when the sequence logic cannot be expressed as view composition:
// - Complex state transitions
// - Recursive sequences
// - I/O-driven sequences
// - Multi-step algorithms with yields at different points

17. Memory and Lifetime

Coroutine Frame Lifetime

// The coroutine frame is allocated when the generator is created
// and destroyed when the generator is destroyed

{
    auto gen = fibonacci();   // frame allocated
    auto it = gen.begin();    // first resume
    std::println("{}", *it);  // 0
    ++it;                     // resume
    std::println("{}", *it);  // 1
}   // gen destroyed → coroutine frame destroyed
    // All locals in fibonacci() are properly destructed

Custom Allocator Support

// std::generator supports custom allocators for the coroutine frame
// This is the third template parameter

template <typename T, typename Allocator = void>
class generator;

// Using a pool allocator for the frame:
struct MyAllocator {
    using value_type = std::byte;
    std::byte* allocate(size_t n) { return pool_.allocate(n); }
    void deallocate(std::byte* p, size_t n) { pool_.deallocate(p, n); }
    MemoryPool& pool_;
};

std::generator<int, int, MyAllocator> custom_gen(std::allocator_arg_t,
                                                   MyAllocator alloc) {
    co_yield 42;
}

Yielding References — Lifetime Rules

// When yielding references, the referred object must live until
// the NEXT co_yield or co_return

std::generator<const std::string&> BAD_example() {
    for (std::string s : some_source()) {  // s is loop variable
        co_yield s;  // yields ref to s — OK during yield
        // s goes out of scope at end of iteration, but...
        // caller's reference is valid until next resume
    }
}

// The rule: after co_yield, the yielded value may be referenced by the caller
// until the coroutine is resumed (next increment of the iterator)
// This is the standard contract — safe if you follow it

std::generator<const std::string&> SAFE_example() {
    std::string persistent = "hello";
    co_yield persistent;  // reference to persistent — safe
    persistent = "world";
    co_yield persistent;  // reference to persistent — safe
}
// persistent lives for the entire generator lifetime

18. Real-World Example — Data Stream Processing

#include <generator>
#include <ranges>
#include <string>
#include <string_view>
#include <vector>
#include <print>
#include <fstream>
#include <sstream>

// ── Generator 1: Read lines from a stream ────────────────────────────────

std::generator<std::string> read_lines(std::istream& stream) {
    std::string line;
    while (std::getline(stream, line)) {
        co_yield line;
    }
}

// ── Generator 2: Parse CSV fields from a line ─────────────────────────────

std::generator<std::string_view> csv_fields(std::string_view line,
                                              char delimiter = ',') {
    size_t start = 0;
    while (true) {
        auto end = line.find(delimiter, start);
        co_yield line.substr(start, end - start);
        if (end == std::string_view::npos) break;
        start = end + 1;
    }
}

// ── Generator 3: Parse CSV rows (each row is a vector of fields) ──────────

struct CsvRow {
    std::vector<std::string> fields;
    size_t line_number;

    std::string_view operator[](size_t i) const {
        return i < fields.size() ? fields[i] : "";
    }
};

std::generator<CsvRow> parse_csv(std::istream& stream, bool skip_header = true) {
    size_t line_num = 0;
    for (std::string line : read_lines(stream)) {
        ++line_num;
        if (skip_header && line_num == 1) continue;  // skip header row

        CsvRow row;
        row.line_number = line_num;
        for (auto field : csv_fields(line)) {
            row.fields.emplace_back(field);
        }
        co_yield row;
    }
}

// ── Generator 4: Filter rows ──────────────────────────────────────────────

std::generator<CsvRow> where(std::ranges::input_range auto&& rows,
                               std::invocable<const CsvRow&> auto predicate) {
    for (CsvRow row : rows) {
        if (predicate(row)) co_yield std::move(row);
    }
}

// ── Generator 5: Transform rows ───────────────────────────────────────────

template <typename T>
std::generator<T> select(std::ranges::input_range auto&& rows,
                          std::invocable<const CsvRow&> auto transform) {
    for (const CsvRow& row : rows) {
        co_yield transform(row);
    }
}

// ── Main: compose the pipeline ────────────────────────────────────────────

int main() {
    // Sample CSV data
    std::istringstream csv_data{
        "name,age,salary,department\n"
        "Alice,30,95000,Engineering\n"
        "Bob,25,75000,Marketing\n"
        "Carol,35,110000,Engineering\n"
        "David,28,65000,HR\n"
        "Eve,32,98000,Engineering\n"
        "Frank,45,80000,Marketing\n"
    };

    // Pipeline: parse CSV, filter engineers, extract name+salary
    struct Employee { std::string name; double salary; };

    auto engineers = select<Employee>(
        where(
            parse_csv(csv_data),
            [](const CsvRow& row) { return row[3] == "Engineering"; }
        ),
        [](const CsvRow& row) -> Employee {
            return {std::string(row[0]), std::stod(std::string(row[2]))};
        }
    );

    double total = 0;
    int count = 0;
    for (auto [name, salary] : engineers) {
        std::println("  {} — ${:,.0f}", name, salary);
        total += salary;
        ++count;
    }
    std::println("Average engineer salary: ${:,.0f}", total / count);

    return 0;
}
// Output:
//   Alice — $95,000
//   Carol — $110,000
//   Eve — $98,000
// Average engineer salary: $101,000

19. Real-World Example — Tree Traversal

#include <generator>
#include <memory>
#include <string>
#include <print>
#include <optional>

// Binary search tree node
template <typename T>
struct BSTNode {
    T value;
    std::unique_ptr<BSTNode> left, right;

    BSTNode(T v) : value(std::move(v)) {}
};

// Insert into BST
template <typename T>
void insert(std::unique_ptr<BSTNode<T>>& root, T value) {
    if (!root) {
        root = std::make_unique<BSTNode<T>>(std::move(value));
    } else if (value < root->value) {
        insert(root->left, std::move(value));
    } else {
        insert(root->right, std::move(value));
    }
}

// ── Traversal generators ──────────────────────────────────────────────────

// In-order traversal (sorted order for BST)
template <typename T>
std::generator<const T&> inorder(const BSTNode<T>* node) {
    if (!node) co_return;
    co_yield std::ranges::elements_of(inorder(node->left.get()));
    co_yield node->value;
    co_yield std::ranges::elements_of(inorder(node->right.get()));
}

// Pre-order traversal
template <typename T>
std::generator<const T&> preorder(const BSTNode<T>* node) {
    if (!node) co_return;
    co_yield node->value;
    co_yield std::ranges::elements_of(preorder(node->left.get()));
    co_yield std::ranges::elements_of(preorder(node->right.get()));
}

// Post-order traversal
template <typename T>
std::generator<const T&> postorder(const BSTNode<T>* node) {
    if (!node) co_return;
    co_yield std::ranges::elements_of(postorder(node->left.get()));
    co_yield std::ranges::elements_of(postorder(node->right.get()));
    co_yield node->value;
}

// Level-order traversal (breadth-first) — needs a queue, can't be recursive generator
template <typename T>
std::generator<const T&> levelorder(const BSTNode<T>* root) {
    if (!root) co_return;
    std::queue<const BSTNode<T>*> q;
    q.push(root);
    // Store all values at each level to yield safely
    while (!q.empty()) {
        auto node = q.front(); q.pop();
        co_yield node->value;
        if (node->left) q.push(node->left.get());
        if (node->right) q.push(node->right.get());
    }
}

// Find k-th smallest element
template <typename T>
std::optional<T> kth_smallest(const BSTNode<T>* root, size_t k) {
    size_t count = 0;
    for (const T& val : inorder(root)) {
        if (++count == k) return val;
    }
    return std::nullopt;
}

int main() {
    // Build a BST
    std::unique_ptr<BSTNode<int>> root;
    for (int n : {5, 3, 7, 1, 4, 6, 8, 2}) {
        insert(root, n);
    }

    // In-order: sorted
    std::print("In-order:  ");
    for (int n : inorder(root.get())) std::print("{} ", n);
    std::println();  // 1 2 3 4 5 6 7 8

    // Pre-order
    std::print("Pre-order: ");
    for (int n : preorder(root.get())) std::print("{} ", n);
    std::println();  // 5 3 1 2 4 7 6 8

    // 3rd smallest
    auto k3 = kth_smallest(root.get(), 3);
    std::println("3rd smallest: {}", k3.value_or(-1));  // 3

    // Sum of all values using ranges
    auto sum = std::ranges::fold_left(inorder(root.get()), 0, std::plus{});
    std::println("Sum: {}", sum);  // 36

    return 0;
}

20. Real-World Example — Combinatorics

#include <generator>
#include <vector>
#include <print>
#include <algorithm>

// ── Combinations: n choose k ──────────────────────────────────────────────

// Generate all combinations of k elements from v
template <typename T>
std::generator<std::vector<T>> combinations(std::vector<T> v, size_t k) {
    size_t n = v.size();
    if (k > n || k == 0) co_return;

    // Use bitmask approach for simplicity
    std::vector<bool> selector(n, false);
    std::fill(selector.begin(), selector.begin() + k, true);
    std::sort(selector.rbegin(), selector.rend());  // start with 1s first

    do {
        std::vector<T> combo;
        for (size_t i = 0; i < n; ++i) {
            if (selector[i]) combo.push_back(v[i]);
        }
        co_yield combo;
    } while (std::prev_permutation(selector.begin(), selector.end()));
}

// ── Permutations ──────────────────────────────────────────────────────────

template <typename T>
std::generator<std::vector<T>> permutations(std::vector<T> v) {
    std::sort(v.begin(), v.end());
    do {
        co_yield v;
    } while (std::next_permutation(v.begin(), v.end()));
}

// ── Cartesian Product ─────────────────────────────────────────────────────

template <typename T>
std::generator<std::vector<T>> cartesian_product(
    std::vector<std::vector<T>> sets) {
    if (sets.empty()) co_return;

    // Use indices approach
    std::vector<size_t> indices(sets.size(), 0);
    while (true) {
        // Yield current combination
        std::vector<T> combo;
        for (size_t i = 0; i < sets.size(); ++i) {
            combo.push_back(sets[i][indices[i]]);
        }
        co_yield combo;

        // Increment indices (right-to-left)
        size_t pos = sets.size();
        while (pos > 0) {
            --pos;
            if (++indices[pos] < sets[pos].size()) break;
            indices[pos] = 0;
            if (pos == 0) co_return;  // all combinations exhausted
        }
    }
}

// ── Powerset ──────────────────────────────────────────────────────────────

template <typename T>
std::generator<std::vector<T>> powerset(std::vector<T> v) {
    size_t n = v.size();
    for (size_t mask = 0; mask < (1u << n); ++mask) {
        std::vector<T> subset;
        for (size_t i = 0; i < n; ++i) {
            if (mask & (1u << i)) subset.push_back(v[i]);
        }
        co_yield subset;
    }
}

int main() {
    // Combinations
    std::print("C(4,2): ");
    for (auto combo : combinations(std::vector{1,2,3,4}, 2)) {
        std::print("[{},{}] ", combo[0], combo[1]);
    }
    std::println();
    // [1,2] [1,3] [1,4] [2,3] [2,4] [3,4]

    // Permutations
    std::print("P(3): ");
    for (auto perm : permutations(std::vector{1,2,3})) {
        std::print("[{},{},{}] ", perm[0], perm[1], perm[2]);
    }
    std::println();
    // [1,2,3] [1,3,2] [2,1,3] [2,3,1] [3,1,2] [3,2,1]

    // Cartesian product
    std::print("A×B×C: ");
    for (auto combo : cartesian_product<int>({{1,2}, {3,4}, {5,6}})) {
        std::print("[{},{},{}] ", combo[0], combo[1], combo[2]);
    }
    std::println();
    // [1,3,5] [1,3,6] [1,4,5] [1,4,6] [2,3,5] [2,3,6] [2,4,5] [2,4,6]

    // Count combinations lazily (no need to materialize)
    size_t count = std::ranges::distance(combinations(std::vector{1,2,3,4,5}, 3));
    std::println("C(5,3) = {}", count);  // 10

    return 0;
}

21. Real-World Example — Parser Generator

#include <generator>
#include <string_view>
#include <variant>
#include <print>
#include <charconv>

// A tokenizer implemented as a generator
// Produces tokens lazily from an input string

enum class TokenType {
    Number, Identifier, Plus, Minus, Star, Slash,
    LParen, RParen, Whitespace, Unknown, End
};

struct Token {
    TokenType type;
    std::string_view value;
    size_t position;
};

// Lexer generator — yields tokens from input
std::generator<Token> tokenize(std::string_view input) {
    size_t pos = 0;

    while (pos < input.size()) {
        char c = input[pos];

        // Whitespace
        if (std::isspace(static_cast<unsigned char>(c))) {
            size_t start = pos;
            while (pos < input.size() && std::isspace(static_cast<unsigned char>(input[pos])))
                ++pos;
            // Skip whitespace (or yield it for whitespace-sensitive grammars)
            continue;
        }

        // Numbers
        if (std::isdigit(static_cast<unsigned char>(c))) {
            size_t start = pos;
            while (pos < input.size() && std::isdigit(static_cast<unsigned char>(input[pos])))
                ++pos;
            co_yield Token{TokenType::Number, input.substr(start, pos - start), start};
            continue;
        }

        // Identifiers
        if (std::isalpha(static_cast<unsigned char>(c)) || c == '_') {
            size_t start = pos;
            while (pos < input.size() &&
                   (std::isalnum(static_cast<unsigned char>(input[pos])) || input[pos] == '_'))
                ++pos;
            co_yield Token{TokenType::Identifier, input.substr(start, pos - start), start};
            continue;
        }

        // Operators and punctuation
        TokenType type = TokenType::Unknown;
        switch (c) {
            case '+': type = TokenType::Plus;   break;
            case '-': type = TokenType::Minus;  break;
            case '*': type = TokenType::Star;   break;
            case '/': type = TokenType::Slash;  break;
            case '(': type = TokenType::LParen; break;
            case ')': type = TokenType::RParen; break;
        }
        co_yield Token{type, input.substr(pos, 1), pos};
        ++pos;
    }

    co_yield Token{TokenType::End, "", pos};
}

int main() {
    std::string_view expr = "x + 42 * (y - 3)";

    std::print("Tokens from '{}': ", expr);
    for (auto [type, value, pos] : tokenize(expr)) {
        if (type == TokenType::End) break;
        std::print("[{}/{}] ", value,
                   type == TokenType::Number ? "num" :
                   type == TokenType::Identifier ? "id" : "op");
    }
    std::println();
    // [x/id] [+/op] [42/num] [*/op] [(/op] [y/id] [-/op] [3/num] [)/op]

    // Count tokens
    size_t count = 0;
    for (auto tok : tokenize(expr)) {
        if (tok.type == TokenType::End) break;
        ++count;
    }
    std::println("Token count: {}", count);  // 9

    return 0;
}

22. Common Mistakes

Mistake 1 — Reusing an Exhausted Generator

auto gen = fibonacci();

// First loop: consumes the generator
for (int n : gen | std::views::take(5)) {
    std::print("{} ", n);
}
std::println();

// WRONG: gen is now exhausted — it's an input range
for (int n : gen | std::views::take(5)) {
    std::print("{} ", n);  // Prints nothing — already done!
}

// CORRECT: create a new generator
for (int n : fibonacci() | std::views::take(5)) {
    std::print("{} ", n);  // Works — new generator
}

Mistake 2 — Storing a Reference to a Yielded Value Across a Resume

std::generator<const std::string&> gen_strings() {
    std::string s = "hello";
    co_yield s;
    s = "world";  // modifies s!
    co_yield s;
}

auto gen = gen_strings();
auto it = gen.begin();

const std::string& ref1 = *it;  // ref1 refers to "hello"
std::println("{}", ref1);        // "hello"

++it;  // RESUMES the coroutine — s is now "world"!
       // ref1 is still valid (s is still alive) but its VALUE changed!

std::println("{}", ref1);        // "world" — surprising! ref1's value changed
// RULE: don't hold references to yielded values across generator resumes

Mistake 3 — Infinite Generator Without Termination

// WRONG: no termination condition — infinite loop
for (int n : naturals()) {
    process(n);
    // This runs forever!
}

// CORRECT: always pair infinite generators with a stopping condition
for (int n : naturals() | std::views::take(100)) {
    process(n);  // stops after 100 elements
}

for (int n : naturals() | std::views::take_while([](int n) { return n < 1000; })) {
    process(n);
}

Mistake 4 — Forgetting co_yield Makes a Function a Coroutine

// WRONG: thinking this is a regular function
std::generator<int> oops() {
    return 42;  // COMPILE ERROR: cannot return from a coroutine function
                // A function with co_yield is a coroutine — use co_return, not return
}

// CORRECT:
std::generator<int> correct() {
    co_yield 42;
    co_return;  // or just fall off the end
}

Mistake 5 — Copying a Generator (They Are Move-Only)

auto gen = fibonacci();

// WRONG: generators cannot be copied
auto gen2 = gen;         // COMPILE ERROR: copy constructor is deleted
auto gen3 = gen;         // COMPILE ERROR

// CORRECT: move the generator
auto gen2 = std::move(gen);  // OK: gen2 owns the coroutine, gen is empty

// Or create two independent generators
auto gen_a = fibonacci();
auto gen_b = fibonacci();  // separate instances, independent state

Mistake 6 — Using co_yield in a Non-Generator Context

// WRONG: co_yield in a regular function (not returning generator)
int bad_function() {
    co_yield 42;  // COMPILE ERROR: not a coroutine return type
    return 0;
}

// CORRECT: must return std::generator<T> (or another coroutine type)
std::generator<int> good_function() {
    co_yield 42;
}

23. Summary

What std::generator Provides

A coroutine return type specifically for producing lazy sequences:
- co_yield: produce a value and suspend
- co_return: end the generator
- Satisfies input_range: works with all range algorithms and views
- Move-only: cannot copy, can move
- One heap allocation per generator (the coroutine frame)
- Supports recursive composition via co_yield elements_of(range)
- Custom allocator support for the coroutine frame

Generator Patterns — Quick Reference

// Infinite generator
std::generator<T> infinite() {
    while (true) { co_yield compute_next(); }
}

// Finite generator
std::generator<T> finite(int n) {
    for (int i = 0; i < n; ++i) { co_yield compute(i); }
}

// Generator from range
std::generator<T> from_range(auto&& source) {
    for (auto&& elem : source) { co_yield transform(elem); }
}

// Recursive generator
std::generator<T> recursive(Node* n) {
    if (!n) co_return;
    co_yield n->value;
    co_yield std::ranges::elements_of(recursive(n->left));
    co_yield std::ranges::elements_of(recursive(n->right));
}

// Generator with state
std::generator<T> stateful() {
    State s{};
    while (!done(s)) {
        co_yield produce(s);
        update(s);
    }
}

Decision Guide

Need a lazy sequence?
    YES → Consider std::generator
    |
    ├── Can it be expressed as range views (iota, transform, filter)?
    |       YES → Use views (zero-overhead, no heap allocation)
    |       NO  → Use std::generator
    |
    ├── Is the sequence infinite?
    |       YES → std::generator (mandatory — vector can't be infinite)
    |
    ├── Is the logic complex / stateful?
    |       YES → std::generator (much easier to write than iterator class)
    |
    └── Is performance the absolute priority (tight inner loop)?
            YES → Hand-written loop or range views
            NO  → std::generator is fine

The One-Line Summary

std::generator<T> is a C++23 coroutine return type that lets you write lazy sequence-producing functions using co_yield — with one line of logic where you would have needed 25 lines of iterator boilerplate, and zero memory for all values except the current one.


What's Next

In the next post we cover std::stacktrace — C++23's portable stack trace capture mechanism that finally brings proper runtime stack traces to the C++ standard library, no platform-specific hacks required.


This is Post 13 of the C++23 Unlocked series.

Previous → Post 12: std::print & std::println — Modern Console Output Next up → Post 14: std::stacktrace — Stack Traces in Standard C++

3 views

C++23 Unlocked

Part 14 of 16

Crack open every major feature of C++23 with real-world examples and hands-on code. Each post dives deep into one feature — what it is, why it exists, and how to use it effectively in production code.

Up next

std::stacktrace in C++23 — The Complete Deep Dive

Every C++ developer has faced the situation: a crash happens in production, the error message says "Segmentation fault" or throws an exception with a cryptic message, and you have no idea where in the