惰性求值与生成器:无限序列、std::views::iota、自定义生成器
聊到函数式编程,有个概念你迟早得面对——惰性求值。说白了,就是“用到的时候才算”。
我刚开始接触这个思想时,觉得挺反直觉的。毕竟我们写 C++ 习惯了:变量声明了,值就得在那。但惰性求值偏不,它告诉你:“别急,等我真正需要这个值的时候,我再吭哧吭哧去算。”
你想想看,这有什么用?
用处大了去了。尤其是当你面对无限序列的时候。
无限序列?听着像天方夜谭
没错,计算机内存是有限的,怎么可能存下无限的东西?但惰性求值给了我们一个取巧的办法:我不存全部,我只存“怎么生成下一个”的规则。
举个最简单的例子——自然数序列:1, 2, 3, 4, ... 无穷无尽。你不可能把所有的自然数都放到 vector 里。但你可以定义一个生成器,每次需要的时候,它告诉你下一个数是什么。
这就是惰性求值的精髓:按需计算。
std::views::iota:C++20 给你的礼物
C++20 的 Ranges 库带来了 std::views::iota。这东西就是个惰性生成的无限序列。
我项目中用过一次,当时需要生成一组连续的索引,但又不想提前分配内存。iota 正好解决这个问题。
#include <ranges>
#include <iostream>
int main() {
// 生成从 0 开始的无限序列
auto numbers = std::views::iota(0);
// 只取前 10 个
for (auto n : numbers | std::views::take(10)) {
std::cout << n << " ";
}
// 输出:0 1 2 3 4 5 6 7 8 9
return 0;
}
看到没?iota(0) 理论上生成 0, 1, 2, ... 一直到无穷。但实际运行时,它只生成了前 10 个。因为 take(10) 这个“消费者”只要求了 10 个值。
核心理解:iota 本身不存储任何序列。它只存储一个起始值,以及递增的规则。每次被“问”到下一个值时,它才计算并返回。
iota 的两种形态
iota 有两种常见用法:
| 形式 | 说明 | 示例 |
|---|---|---|
iota(起始值) |
无限序列,从起始值开始递增 | iota(5) → 5, 6, 7, 8, ... |
iota(起始值, 结束值) |
有限序列,不包含结束值 | iota(3, 7) → 3, 4, 5, 6 |
我个人习惯用第一种形式配合 take,这样更灵活。想取多少个就取多少个,不用提前定死。
自定义生成器:自己动手,丰衣足食
iota 只能生成递增整数。但实际需求往往更复杂。比如斐波那契数列、随机数序列、或者某种业务逻辑的递推。
这时候就需要自定义生成器了。
在 C++20 的 Ranges 体系中,自定义生成器本质上是一个满足 std::input_or_output_iterator 和 std::sentinel_for 约束的类型。但别被这些术语吓到,说白了,你只需要实现几个简单的操作:
operator*():返回当前值operator++():推进到下一个值operator==:判断是否结束(对于无限序列,永远返回 false)
来看一个斐波那契生成器的例子:
#include <ranges>
#include <iostream>
class FibonacciGenerator {
public:
// 迭代器类型
class Iterator {
public:
using value_type = unsigned long long;
using difference_type = std::ptrdiff_t;
value_type operator*() const { return current_; }
Iterator& operator++() {
// 斐波那契递推
unsigned long long next = a_ + b_;
a_ = b_;
b_ = next;
current_ = a_;
return *this;
}
bool operator==(const std::default_sentinel_t&) const {
return false; // 无限序列,永不结束
}
private:
friend class FibonacciGenerator;
Iterator() : a_(0), b_(1), current_(0) {}
unsigned long long a_, b_, current_;
};
Iterator begin() const { return Iterator{}; }
std::default_sentinel_t end() const { return {}; }
};
int main() {
FibonacciGenerator fib;
for (auto val : fib | std::views::take(15)) {
std::cout << val << " ";
}
// 输出:0 1 1 2 3 5 8 13 21 34 55 89 144 233 377
return 0;
}
小技巧:对于无限序列,end() 返回 std::default_sentinel。迭代器的 operator== 永远返回 false,这样循环就不会自然终止。你必须用 take 或 take_while 来限制取值的数量。
我曾经踩过的坑
嗯,这里要注意。自定义生成器有个容易翻车的地方——迭代器失效。
我曾经写过一个生成器,内部状态用指针维护。结果在 operator++ 里移动了指针,但外部保存了旧的迭代器副本。一访问,崩了。
教训是什么?生成器的迭代器最好是轻量值类型。所有状态都内嵌在迭代器内部,不要依赖外部存储。这样拷贝、赋值都不会出问题。
惰性求值的性能真相
你可能会问:惰性求值是不是比提前算好要慢?
答案是:看情况。
如果整个序列最终都会被消费,那惰性求值反而多了一层函数调用的开销,确实慢一点。但如果只取前几个元素,惰性求值就赢了——它避免了大量无意义的计算。
我个人的经验是:当序列的生成成本高,或者你只取一小部分时,惰性求值是明显的性能赢家。反之,如果序列生成成本极低(比如 iota 的递增),那差别可以忽略不计。
一张图理清核心逻辑
下面这张 SVG 图展示了惰性求值与生成器的核心流程:
组合起来:iota + 变换 + 过滤
iota 真正的威力在于和 Ranges 的其他视图组合。你可以链式调用,构建出复杂的处理管道。
#include <ranges>
#include <iostream>
#include <vector>
int main() {
// 生成 0 到无穷,取前 20 个偶数,再乘以 3
auto result = std::views::iota(0)
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * 3; })
| std::views::take(10);
for (auto v : result) {
std::cout << v << " ";
}
// 输出:0 6 12 18 24 30 36 42 48 54
return 0;
}
这个管道里,iota 只生成到第 20 个偶数就停了——因为 take(10) 要 10 个值,而每 2 个数才有一个偶数,所以 iota 实际生成了 20 个数。不多不少,刚刚好。
注意:在管道中,filter 和 take 的顺序会影响性能。先 filter 再 take,意味着 filter 会一直过滤直到 take 收集够数量。如果 filter 条件很苛刻,可能会生成大量中间值。我建议把 take 尽量放在前面,减少不必要的计算。
什么时候用自定义生成器?
我个人总结了三类场景:
- 数学序列:斐波那契、素数、阶乘等。这些序列有明确的递推关系,适合用生成器表达。
- 状态机驱动:比如遍历树结构、解析协议流。每次推进一步,返回当前状态。
- 资源受限环境:嵌入式或高性能场景下,不想为中间结果分配内存。生成器按需计算,零额外存储。
说白了,只要你的数据是“可以一步步算出来的”,就值得考虑生成器。
最后说两句
惰性求值和生成器,是 C++ 函数式编程里非常优雅的一笔。它让你能用声明式的方式描述“无限”的数据流,而不用操心内存和计算时机。
我刚开始用的时候也犯过嘀咕:这东西真的实用吗?后来在几个数据处理模块里用上了,代码量减少不说,可读性也上了一个台阶。嗯,真香。
记住:生成器定义规则,消费者决定边界。把握好这个原则,你就能驾驭无限序列了。