std::generator in C++23 — The Complete Deep Dive
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
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:
Defines the promise type — you don't have to write one
Implements the iterator protocol — works with range-based for loops
Satisfies
std::ranges::input_range— works with all range algorithmsHandles the coroutine frame lifecycle — destroys it when the generator is destroyed
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 usingco_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++
