45、C++23 前瞻:std::generator,协程生成器,简化迭代器编写。
说实话,每次写自定义迭代器,我都觉得像是在做体力活。你得定义五个类型别名,重写 operator++、operator*、operator!=…… 一套组合拳下来,代码量比业务逻辑还多。我早期做流式数据处理框架时,光迭代器适配层就写了上千行,后来重构时自己都看不下去了。
C++23 带来的 std::generator,说白了就是让你用协程的方式“吐”数据,编译器帮你把迭代器的脏活累活全干了。你只需要写一个生成器函数,剩下的交给标准库。
从“手动挡”到“自动挡”
我们先看一个最直观的例子。假设你要生成一个斐波那契数列的迭代器。传统写法长这样:
class FibIterator {
int a_{0}, b_{1};
int idx_{0}, limit_;
public:
using iterator_category = std::input_iterator_tag;
using value_type = int;
using difference_type = std::ptrdiff_t;
using pointer = const int*;
using reference = const int&;
FibIterator(int limit) : limit_(limit) {}
int operator*() const { return a_; }
FibIterator& operator++() {
int next = a_ + b_;
a_ = b_; b_ = next;
++idx_;
return *this;
}
bool operator!=(const FibIterator& other) const {
return idx_ != other.idx_;
}
// ... 还有一堆 boilerplate
};
嗯,这还只是最简版本。你要是想支持 const 迭代、反向迭代、随机访问…… 那代码量直接翻倍。我在项目中见过有人为了一个树形结构的遍历,写了四个迭代器类,最后自己都分不清哪个是哪个。
用 std::generator 怎么写?看这里:
#include <generator>
#include <ranges>
std::generator<int> fibonacci(int n) {
int a = 0, b = 1;
for (int i = 0; i < n; ++i) {
co_yield a;
int next = a + b;
a = b;
b = next;
}
}
// 使用
for (int v : fibonacci(10)) {
std::cout << v << " ";
}
// 输出: 0 1 1 2 3 5 8 13 21 34
看到了吗?没有迭代器类,没有运算符重载,没有类型别名。一个 co_yield 搞定所有。编译器会自动生成一个符合 input_range 规范的迭代器。你想想看,这省了多少事。
std::generator 的核心机制
为什么能这么简洁?因为 std::generator 本质上是一个协程包装器。它把协程的挂起/恢复机制,映射成了迭代器的 ++ 和 * 操作。
核心流程:
- 调用生成器函数时,协程启动,执行到第一个
co_yield处挂起。 operator*返回当前挂起点的值。operator++恢复协程,执行到下一个co_yield或结束。- 协程结束时,迭代器变为
end状态。
说白了,co_yield 就是“暂停一下,把值给你,等你取走了我再继续”。这个模型特别适合那些“按需生成”的场景——比如读取大文件的行、遍历树形结构、生成无穷序列。
下面这张图展示了 std::generator 的完整生命周期:
实战:树形结构的懒遍历
我在做 AST(抽象语法树)分析工具时,经常需要遍历树节点。传统做法是写一个递归函数,把所有节点塞进一个 vector 里返回。但这样有两个问题:一是内存占用大,二是无法提前终止遍历。
用 std::generator 可以做到“遍历到哪,生成到哪”。来看一个二叉树的中序遍历:
struct Node {
int value;
Node* left;
Node* right;
};
std::generator<int> inorder(Node* root) {
if (!root) co_return;
// 遍历左子树
for (int v : inorder(root->left)) {
co_yield v;
}
// 访问当前节点
co_yield root->value;
// 遍历右子树
for (int v : inorder(root->right)) {
co_yield v;
}
}
// 使用
Node* tree = buildTree();
for (int v : inorder(tree)) {
std::cout << v << " ";
if (v > 100) break; // 可以提前终止
}
注意看,这里递归调用 inorder 返回的生成器,然后通过 co_yield 把值“透传”出去。这在传统迭代器里几乎不可能优雅实现——你得手动维护一个栈来模拟递归。而协程天然支持嵌套挂起,递归生成器写起来就跟普通递归一样自然。
我的经验: 递归生成器虽然方便,但要注意协程帧的嵌套深度。每个 co_yield 都会创建一个协程帧,深度递归可能导致栈溢出。我一般限制递归深度不超过 1000 层,或者改用显式栈的迭代方式。
与现有标准库的配合
std::generator 实现了 std::ranges::input_range,所以它能直接用在 <ranges> 的管道操作中:
auto even_fibs = fibonacci(20)
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::take(5);
for (int v : even_fibs) {
std::cout << v << " "; // 输出前5个偶数斐波那契数
}
这组合起来就非常强大了。生成器负责“生产”,视图负责“加工”,消费者负责“使用”。整个数据流是懒执行的,不会产生中间容器。
| 特性 | 传统迭代器 | std::generator |
|---|---|---|
| 代码量 | 50-100 行(含 boilerplate) | 5-10 行 |
| 递归遍历 | 需要手动维护栈 | 天然支持递归 |
| 状态管理 | 成员变量 + 构造函数 | 局部变量 + 协程帧 |
| 提前终止 | 需要额外标志位 | 直接 break 即可 |
| 与 ranges 配合 | 需手动适配 | 开箱即用 |
避坑指南
好东西归好东西,用起来还是有几个坑要注意。我曾经在一个生产环境里踩过,分享给你:
我曾经犯过的错:
- 不要跨作用域传递引用:
co_yield返回的是引用还是值,取决于生成器模板参数。如果你写std::generator<int&>,返回的是局部变量的引用——协程挂起后局部变量可能被销毁,变成悬空引用。我建议默认都用值传递,除非你非常清楚自己在做什么。 - 注意协程的销毁成本: 每个
std::generator对象销毁时,会释放协程帧。如果你创建了大量短生命周期的生成器(比如在循环里反复构造),性能可能会受影响。我的做法是:如果生成器生命周期很短,考虑用普通函数+回调代替。 - 不要假设生成器是可复制或可重入的:
std::generator是 move-only 类型,而且每个生成器只能被一个消费者遍历一次。你不能像普通容器那样拷贝一份再遍历。这在多线程场景下尤其要注意。
总结
std::generator 是 C++23 里我最期待的特性之一。它把协程的威力带到了日常的迭代器编写中,让“生产-消费”模式的代码变得异常简洁。我个人习惯在以下场景优先考虑它:
- 需要懒生成序列(斐波那契、素数、排列组合等)
- 需要遍历递归数据结构(树、图、AST)
- 需要从 I/O 流中按需读取数据(大文件行读取、网络包解析)
- 需要与
<ranges>配合做数据管道处理
当然,它也不是万能的。对于性能极其敏感的场景(比如每秒调用百万次迭代),手写迭代器可能还是更优选择。但对于 90% 的日常需求,std::generator 已经足够好用了。
嗯,C++ 这些年一直在变复杂,但 std::generator 这种特性,是真正让复杂变简单的。希望你在下一个项目里也能用上它。