std::generator (C++23)
C++23 std::generator produces lazy, pull-based sequences via co_yield. Models input_range — compose with views, use recursive elements_of, yield by reference.
std::generator<Ref, V, Allocator>since C++23A synchronous, pull-based coroutine type that produces a lazy input range by suspending at each co_yield expression and resuming on iterator increment.
Overview
std::generator is the first concrete coroutine type shipped in the C++ standard library, arriving in C++23 via <generator>. Unlike general coroutines built on <coroutine>, it provides a ready-made, range-compatible interface: a generator object models std::ranges::input_range and can be passed directly to any algorithm or view that accepts a range.
The coroutine body may use co_yield and co_return, but not co_await — std::generator is strictly synchronous and pull-based. Execution does not begin until the caller first advances the iterator (i.e., calls begin()). Each subsequent increment resumes the coroutine until the next co_yield or until the function returns. The generator is move-only and single-pass: iterating it twice, or copying it, is not possible.
The coroutine frame is heap-allocated by default, though compilers may apply Heap Allocation ELision Optimization (HALO) when the generator's lifetime is provably bounded within the caller's frame.
Syntax
// <generator>, C++23
template<
class Ref,
class V = void, // value_type; void → std::remove_cvref_t<Ref>
class Allocator = void // frame allocator; void → global new/delete
>
class std::generator;The Ref template parameter is the reference type seen by the iterator's operator*. V is the value type — rarely specified explicitly. Common instantiations:
std::generator<int> // value semantics: yields int&&, value_type = int
std::generator<int&> // lvalue ref: yields int&, value_type = int
std::generator<const std::string&> // const ref: no copies on yield
std::generator<int, long> // Ref=int, V=long — explicit value_type overrideYielding a nested range delegates to std::ranges::elements_of (C++23):
co_yield std::ranges::elements_of(some_range);
co_yield std::ranges::elements_of(some_range, alloc); // with allocator hintTemplate Parameters in Depth
All three template parameters interact with what the caller sees and how memory is managed.
Ref — The Yielded Type
Ref determines the type returned by iterator::operator*. It can be:
- A plain value type (
int,std::string) —operator*returns an rvalue reference (int&&). The value lives in the coroutine frame and is moved/copied into the caller's variable on assignment. - An lvalue reference (
int&,std::string&) —operator*returns a direct lvalue reference into the frame. No copies, but the caller must not store the reference past the next increment. - A const lvalue reference (
const int&,const std::string&) — the safest reference mode: the caller gets read-only access, and the type still binds to a temporary via const extension rules.
// C++23 — three Ref choices and their trade-offs
// 1. Value: co_yield copies/moves into the frame slot; caller receives T&&
std::generator<int> by_value() {
int x = 42;
co_yield x; // copy into frame yield slot
co_yield x + 1; // temporary OK: bound to int&&
}
// 2. Lvalue ref: zero-copy, but yielded object must outlive the increment
std::generator<int&> by_ref(std::vector<int>& v) {
for (int& elem : v)
co_yield elem; // caller gets int& directly into v's storage
// co_yield 42; // error: cannot bind int& to rvalue — caught at compile time
}
// 3. Const lvalue ref: safe reference, binds to locals too
std::generator<const int&> by_cref() {
int local = 7;
co_yield local; // OK: const int& binds to local variable
co_yield 99; // OK: const int& binds to temporary (lifetime extended to frame)
}Best Practice. Use generator<const T&> when traversing existing data and generator<T> when synthesising new values. Avoid generator<T&> with locals — a local lives in the frame, survives suspension, and is safe, but the pattern is easy to misread as yielding references to external storage.
V — The value_type
V is the value_type alias exposed to range algorithms and std::ranges::range_value_t<G>. The default is std::remove_cvref_t<Ref>, which is almost always what you want. Override it only when the semantic element type should differ from the yielded reference type — for example, a generator that yields std::string_view references but whose elements are logically std::string:
// C++23 — explicit value_type override
std::generator<std::string_view, std::string> tokenise(std::string_view text) {
// Yields views into text; algorithms that need the value_type see std::string
for (auto token : split_view(text))
co_yield token;
}In practice, specifying V is rare. Most code leaves it as void.
Allocator — The Frame Allocator
By default the coroutine frame is allocated with ::operator new. Passing a custom allocator as the third argument routes all frame allocation through it:
// C++23 — PMR arena for the frame
#include <generator>
#include <memory_resource>
std::generator<int, void, std::pmr::polymorphic_allocator<>>
counting(int n, std::allocator_arg_t, std::pmr::polymorphic_allocator<> alloc) {
// 'alloc' is captured in the frame; the frame itself was allocated from alloc
for (int i = 0; i < n; ++i)
co_yield i;
}
// Caller supplies a monotonic arena — no heap traffic:
std::byte buf[256];
std::pmr::monotonic_buffer_resource arena{buf, sizeof(buf)};
auto gen = counting(10, std::allocator_arg, &arena);
for (int v : gen)
std::print("{} ", v);The std::allocator_arg_t tag must appear as the first parameter after the generator's own parameters (or as the very first parameter for non-member functions), followed immediately by the allocator value. The standard ensures the compiler passes the allocator to the promise type's operator new.
Best Practice. Use a PMR arena allocator when a generator is called many times in a tight loop and profiling shows frame allocation as a bottleneck. In most code, the default global allocator is fine.
Examples
Finite and Infinite Sequences
#include <generator>
#include <ranges>
#include <print>
std::generator<int> range(int first, int last) { // C++23
for (int i = first; i < last; ++i)
co_yield i;
}
std::generator<long long> fibonacci() { // infinite, C++23
long long a = 0, b = 1;
for (;;) {
co_yield a;
std::tie(a, b) = std::pair{b, a + b};
}
}
// Both compose naturally with ranges views (C++20/23)
for (int i : range(0, 5))
std::print("{} ", i); // 0 1 2 3 4
auto fibs = fibonacci() | std::views::take(10); // C++20
for (long long f : fibs)
std::print("{} ", f); // 0 1 1 2 3 5 8 13 21 34Yielding by Reference
Instantiate with a reference type to avoid copies when iterating containers:
#include <generator>
#include <vector>
#include <string>
// T& reference type — operator* returns string&, no copies
std::generator<const std::string&>
lines_of(const std::vector<std::string>& v) {
for (const auto& s : v)
co_yield s;
}
std::vector<std::string> data = {"alpha", "beta", "gamma"};
for (const std::string& s : lines_of(data))
process(s); // zero copiesLifetime caveat. The yielded reference is only valid until the next iterator increment. Storing it past that point is undefined behaviour.
Recursive Delegation with elements_of
Naive recursive generators that co_yield child generators one element at a time create O(depth) iterator chains per element. std::ranges::elements_of (C++23) solves this by delegating directly into the child coroutine frame:
#include <generator>
struct Node {
int value;
std::vector<Node> children;
};
std::generator<int> preorder(const Node& n) {
co_yield n.value;
for (const auto& child : n.children)
co_yield std::ranges::elements_of(preorder(child)); // O(1) per element
}
Node tree{1, {{2, {{4, {}}, {5, {}}}}, {3, {{6, {}}}}}};
for (int v : preorder(tree))
std::print("{} ", v); // 1 2 4 5 3 6Without elements_of, the same recursion degrades to O(n·depth) because each layer adds an iterator wrapper.
Composing with Ranges Algorithms
std::generator is an input range, so it participates in the full ranges pipeline:
#include <generator>
#include <ranges>
#include <algorithm>
#include <numeric>
std::generator<int> primes() {
std::vector<int> known;
for (int n = 2; ; ++n) {
if (std::ranges::none_of(known, [n](int p){ return n % p == 0; })) {
known.push_back(n);
co_yield n;
}
}
}
// ranges::to (C++23) — collect into a container
auto first_20 = primes()
| std::views::take(20)
| std::ranges::to<std::vector>(); // C++23
// fold_left (C++23) — sum primes under 100
int total = std::ranges::fold_left(
primes() | std::views::take_while([](int p){ return p < 100; }),
0, std::plus{});I/O Streaming
#include <generator>
#include <fstream>
#include <string>
std::generator<std::string> read_lines(std::string_view path) {
std::ifstream f{std::string(path)};
std::string line;
while (std::getline(f, line))
co_yield line;
}
// Process a large file with backpressure — only reads what's consumed
for (const auto& line : read_lines("data.csv")
| std::views::filter([](const std::string& l){ return !l.starts_with('#'); })
| std::views::take(1000))
{
ingest(line);
}Composing with std::views
Because std::generator models std::ranges::input_range, any range adaptor from <ranges> works directly via the pipe | operator. The composition is fully lazy: no values are produced until the consuming loop pulls them.
#include <generator>
#include <ranges>
#include <print>
std::generator<int> naturals() { // C++23 — infinite 0, 1, 2, …
for (int i = 0; ; ++i)
co_yield i;
}
// views::take — consume only N elements from an infinite sequence
for (int v : naturals() | std::views::take(5))
std::print("{} ", v); // 0 1 2 3 4
// views::filter — keep only even numbers
for (int v : naturals() | std::views::filter([](int x){ return x % 2 == 0; })
| std::views::take(5))
std::print("{} ", v); // 0 2 4 6 8
// views::transform — apply a function to each yielded value
for (int v : naturals() | std::views::transform([](int x){ return x * x; })
| std::views::take(5))
std::print("{} ", v); // 0 1 4 9 16Chaining multiple adaptors does not materialise intermediate vectors — each | step just wraps the previous range in an adaptor object. The generator only runs as the outermost loop pulls elements through the entire pipeline.
Best Practice. Pair infinite generators with views::take or views::take_while to guarantee termination. A range-based for loop over a bare infinite generator will loop forever.
// Correct — bounded by take:
auto sq = naturals() | std::views::transform([](int x){ return x*x; })
| std::views::take(10);
for (int v : sq) { /* terminates */ }
// Dangerous — no bound, infinite loop:
// for (int v : naturals()) { ... }Infinite Sequences
Generators shine for infinite sequences: the function body simply never returns. The natural-numbers example above is the canonical case. Because values are produced on demand, the generator costs nothing until an element is pulled.
// C++23 — infinite Fibonacci
std::generator<long long> fibonacci() {
long long a = 0, b = 1;
for (;;) {
co_yield a;
auto next = a + b;
a = b;
b = next;
}
}
// Safe: take(N) stops the pipeline before re-entering the loop
auto first10 = fibonacci() | std::views::take(10)
| std::ranges::to<std::vector>(); // C++23
// first10 == {0, 1, 1, 2, 3, 5, 8, 13, 21, 34}
// views::take_while works too:
for (long long f : fibonacci() | std::views::take_while([](long long v){ return v < 100; }))
std::print("{} ", f); // 0 1 1 2 3 5 8 13 21 34 55 89Common Pitfall. Forgetting views::take when consuming an infinite generator leads to a loop that runs until the process is killed. There is no compile-time protection:
// Compiles fine, runs forever:
for (int v : naturals())
std::print("{}\n", v);The generator itself is not the problem — it is the consuming loop that needs a bound.
Recursive Generators and Tree Traversal
The elements_of delegation pattern extends naturally to tree structures. Here is an inorder traversal that recursively yields the left subtree, then the current node's value, then the right subtree:
#include <generator>
#include <memory>
#include <print>
struct BSTNode {
int value;
std::unique_ptr<BSTNode> left, right;
};
// Inorder traversal — C++23
std::generator<int> inorder(const BSTNode* node) {
if (!node) co_return;
co_yield std::ranges::elements_of(inorder(node->left.get())); // left subtree
co_yield node->value; // current node
co_yield std::ranges::elements_of(inorder(node->right.get())); // right subtree
}
// Build a small BST: 4
// / \
// 2 6
// / \ / \
// 1 3 5 7
auto root = /* ... build tree ... */;
for (int v : inorder(root.get()))
std::print("{} ", v); // 1 2 3 4 5 6 7Each elements_of call delegates execution directly into the recursive frame rather than wrapping it in a new iterator layer. The depth of the tree is O(log n) for a balanced BST, and elements_of ensures each yielded value traverses only that many delegation steps — not O(n·depth) as in the naïve loop pattern.
Best Practice. Always use co_yield std::ranges::elements_of(sub_gen) rather than for (auto v : sub_gen) co_yield v; when delegating to a child generator. The latter is a correctness anti-pattern as well as a performance problem: it re-enters the outer generator once per element of the inner sequence.
Generator as Input Range
std::generator satisfies std::ranges::input_range, so every range algorithm and view in the standard library accepts it directly.
#include <generator>
#include <algorithm>
#include <numeric>
#include <ranges>
#include <vector>
#include <print>
std::generator<int> squares(int n) { // C++23
for (int i = 1; i <= n; ++i)
co_yield i * i;
}
// ranges::for_each — visit each element
std::ranges::for_each(squares(5), [](int v){ std::print("{} ", v); });
// 1 4 9 16 25
// ranges::copy — materialise into a vector
std::vector<int> out;
std::ranges::copy(squares(5), std::back_inserter(out));
// out == {1, 4, 9, 16, 25}
// ranges::fold_left (C++23) — sum: 1+4+9+16+25 = 55
int sum = std::ranges::fold_left(squares(5), 0, std::plus{});
// ranges::max_element
auto mx = std::ranges::max_element(squares(5)); // iterator to 25Single-pass constraint. A generator can only be iterated once. Attempting to use it in any algorithm that requires a multi-pass (forward) range is a compile error:
auto gen = squares(5);
std::ranges::sort(gen); // error: sort requires random_access_rangeAlgorithms that would need to restart from the beginning — like std::ranges::sort, std::ranges::nth_element, binary search — require materialising the generator first:
auto vec = squares(20) | std::ranges::to<std::vector>(); // C++23
std::ranges::sort(vec);The same applies to iterating the same generator object twice: after the first pass completes, the generator is exhausted. Calling begin() a second time is undefined behaviour.
Performance Characteristics
Understanding the cost model helps decide when generators are the right tool.
Frame Allocation and HALO
Each call to a generator function allocates a coroutine frame on the heap (via ::operator new by default). The frame holds all local variables that need to survive a suspension point, the current suspension index, and the promise object. For a simple counter generator this might be 32–64 bytes; for a generator with many locals it can be larger.
Compilers are permitted — but not required — to apply Heap Allocation ELision Optimization (HALO). HALO stack-allocates the frame when the compiler can prove:
- The generator object's lifetime is strictly nested within the caller's frame.
- The generator object is not moved after creation.
- The compiler has full visibility into the callee (not just a declaration).
In practice HALO fires reliably for simple inlined generators consumed in the same function. It rarely fires for generators returned from functions, stored in containers, or passed across translation unit boundaries.
Per-Resume Overhead
Each ++it (iterator increment) resumes the coroutine. The resume is a call through std::coroutine_handle<>::resume(), which is effectively an indirect function call. On modern x86-64 hardware this is roughly 10–50 ns per resume, depending on instruction cache pressure and branch prediction. For tight numerical loops generating millions of values per second, this overhead becomes the dominant cost.
Comparison Table
| Approach | Setup boilerplate | Per-element cost | Memory | Multi-pass |
|---|---|---|---|---|
std::generator | Minimal (coroutine) | ~10–50 ns (indirect call + resume) | One frame on heap (unless HALO) | No |
| Hand-written iterator | High (iterator class, sentinel, begin/end) | Near-zero (inline increment) | Object size only | Yes |
std::vector (materialise) | None | ~1 ns (sequential memory) | Full sequence in memory | Yes |
std::views::iota | None | ~1 ns (stateless arithmetic) | None | Yes (random-access) |
When to use std::generator:
- The sequence is naturally expressed as imperative code with state that changes between yields (e.g., parsing, tree traversal, stateful protocol steps).
- The sequence is potentially infinite or very large, and you only need a prefix.
- The sequence is constructed lazily from external I/O (file lines, network packets).
- The alternative would require a hand-rolled iterator with significant boilerplate.
When to materialise instead:
- The sequence is small and will be iterated multiple times.
- The consuming algorithm requires random access or multi-pass (sorting, binary search).
- Per-element cost is the bottleneck and profiling confirms generator overhead dominates.
- The sequence is purely arithmetic — prefer
std::views::iota(stateless, SIMD-friendly).
// C++23 — when NOT to use generator: iota is better for pure integer ranges
for (int i : std::views::iota(0, 1'000'000)) // zero overhead, vectorisable
process(i);
// Use generator only when state is needed between yields:
std::generator<int> sieve_of_eratosthenes(int limit) {
std::vector<bool> composite(limit + 1, false);
for (int p = 2; p <= limit; ++p) {
if (!composite[p]) {
co_yield p;
for (int m = p * p; m <= limit; m += p)
composite[m] = true;
}
}
}Error Handling
If the generator body throws an unhandled exception, the exception is captured in the coroutine frame and rethrown at the caller's side on the next call to ++it (the iterator increment that attempted to resume into the throwing code). The generator is then exhausted.
// C++23 — exception propagates to the iterator increment
std::generator<int> risky(std::span<int> data) {
for (int v : data) {
if (v < 0)
throw std::range_error{"negative value"};
co_yield v;
}
}
std::array<int, 4> arr{1, 2, -1, 3};
auto gen = risky(arr);
try {
for (int v : gen) // exception rethrown here on third element
std::print("{} ", v); // prints: 1 2 (then throws)
} catch (const std::range_error& e) {
std::print("error: {}\n", e.what()); // error: negative value
}To yield a sentinel error value instead of throwing, handle the error inside the generator body:
// C++23 — yield std::expected to report errors without exceptions
#include <expected>
std::generator<std::expected<int, std::string>> safe_parse(std::string_view input) {
for (char c : input) {
if (std::isdigit(c))
co_yield c - '0';
else
co_yield std::unexpected{std::string{"bad char: "} + c};
}
}
for (auto result : safe_parse("12x4")) {
if (result)
std::print("value: {}\n", *result);
else
std::print("error: {}\n", result.error());
}
// value: 1
// value: 2
// error: bad char: x
// value: 4Alternatively, contain the error inside the generator with a try-catch that replaces the problematic element with a default or logs and continues:
// C++23 — catch inside the generator body, yield fallback values
std::generator<int> tolerant_parse(const std::vector<std::string>& tokens) {
for (const auto& tok : tokens) {
try {
co_yield std::stoi(tok);
} catch (const std::invalid_argument&) {
co_yield 0; // substitute 0 for non-numeric tokens
}
}
}Common Pitfall. Unlike std::future, there is no separate get()/wait() step that surfaces the exception. The exception surfaces exactly at the iterator increment that resumed the throwing frame. If you iterate via a range-for loop, that means the loop body may never see the problematic element — the exception fires in the implicit ++it before the body runs for that iteration.
Best Practices
Prefer std::generator over hand-rolled iterators for stateful sequences. Custom sentinel-based iterators require a sentinel type, an iterator class, begin/end wiring, and correct copy/move semantics — generators express the same logic linearly, with the compiler handling the state machine.
Use elements_of for all recursive or delegating generators. Any pattern that yields from a sub-generator inside a loop must use co_yield std::ranges::elements_of(sub) rather than an inner for-loop, or performance degrades quadratically with depth.
Choose the right Ref type upfront. generator<T> copies or moves each yielded value; generator<T&> yields references. The latter is faster for large objects but requires the referent to outlive each iteration step. generator<const T&> is the common middle ground for read-only traversal.
Control frame allocation for hot paths. Pass a custom allocator as the third template argument, or use the std::allocator_arg_t tag in the coroutine parameter list to provide a per-call allocator. Stack allocators eliminate heap traffic entirely when generator lifetime is bounded.
Bound all infinite generators before consuming. Always pair an infinite generator with views::take(N) or views::take_while(pred). There is no compiler warning for iterating an unbounded infinite sequence.
Common Pitfalls
Calling begin() more than once. std::generator is a single-pass range. A second call to begin() after iteration has started is undefined behaviour. Store the iterator if you need to resume iteration manually.
auto gen = fibonacci();
auto it = gen.begin(); // starts the coroutine
++it; ++it;
// auto it2 = gen.begin(); // UB — do not do thisStoring a yielded reference past the next increment. With generator<T&>, the reference returned by operator* is backed by a variable inside the coroutine frame. After ++it, that frame location may hold the next value:
auto& ref = *it; // valid now
++it;
use(ref); // UB — frame updatedMoving a generator during iteration. Moving a std::generator invalidates all iterators pointing into it. Do not move the generator object while holding a live iterator.
Expecting co_await to work. std::generator's promise type does not define await_transform, so co_await inside a generator body is a compile error. For asynchronous lazy sequences, you need a separate async generator type (e.g., from Asio or cppcoro).
Quadratic recursion without elements_of. Yielding child generator elements in a for-loop creates a chain of iterator layers:
// Slow — O(depth) chain per element:
for (int v : child_generator())
co_yield v;
// Correct — O(1) per element:
co_yield std::ranges::elements_of(child_generator());Assuming HALO always fires. Heap allocation for the coroutine frame is the default. If you measure allocation overhead in a hot path and HALO did not fire, switch to a PMR arena allocator rather than relying on the optimiser.
See Also
std::ranges::elements_of— C++23 delegation wrapper used withco_yield- Coroutines overview —
co_yield,co_await,co_return, promise types std::views::iota— C++20 stateless integer sequence; prefer over generator when no state is neededstd::ranges::to— C++23 range-to-container conversion- Fold expressions —
std::ranges::fold_left(C++23) for accumulating generator output