惰性求值与生成器:无限序列、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_iteratorstd::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,这样循环就不会自然终止。你必须用 taketake_while 来限制取值的数量。

我曾经踩过的坑

嗯,这里要注意。自定义生成器有个容易翻车的地方——迭代器失效

我曾经写过一个生成器,内部状态用指针维护。结果在 operator++ 里移动了指针,但外部保存了旧的迭代器副本。一访问,崩了。

教训是什么?生成器的迭代器最好是轻量值类型。所有状态都内嵌在迭代器内部,不要依赖外部存储。这样拷贝、赋值都不会出问题。

惰性求值的性能真相

你可能会问:惰性求值是不是比提前算好要慢?

答案是:看情况

如果整个序列最终都会被消费,那惰性求值反而多了一层函数调用的开销,确实慢一点。但如果只取前几个元素,惰性求值就赢了——它避免了大量无意义的计算。

我个人的经验是:当序列的生成成本高,或者你只取一小部分时,惰性求值是明显的性能赢家。反之,如果序列生成成本极低(比如 iota 的递增),那差别可以忽略不计。

一张图理清核心逻辑

下面这张 SVG 图展示了惰性求值与生成器的核心流程:

惰性求值与生成器核心流程 生成器 存储生成规则 不存储全部值 按需计算 每次只算一个值 算完即丢 消费者 take(n) for 循环 “下一个值是什么?” 关键点: • 生成器只负责“如何生成”,不负责“生成多少” • 消费者决定何时停止,通过 take / take_while 控制

组合起来: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 个数。不多不少,刚刚好。

注意:在管道中,filtertake 的顺序会影响性能。先 filter 再 take,意味着 filter 会一直过滤直到 take 收集够数量。如果 filter 条件很苛刻,可能会生成大量中间值。我建议把 take 尽量放在前面,减少不必要的计算。

什么时候用自定义生成器?

我个人总结了三类场景:

  1. 数学序列:斐波那契、素数、阶乘等。这些序列有明确的递推关系,适合用生成器表达。
  2. 状态机驱动:比如遍历树结构、解析协议流。每次推进一步,返回当前状态。
  3. 资源受限环境:嵌入式或高性能场景下,不想为中间结果分配内存。生成器按需计算,零额外存储。

说白了,只要你的数据是“可以一步步算出来的”,就值得考虑生成器。

最后说两句

惰性求值和生成器,是 C++ 函数式编程里非常优雅的一笔。它让你能用声明式的方式描述“无限”的数据流,而不用操心内存和计算时机。

我刚开始用的时候也犯过嘀咕:这东西真的实用吗?后来在几个数据处理模块里用上了,代码量减少不说,可读性也上了一个台阶。嗯,真香。

记住:生成器定义规则,消费者决定边界。把握好这个原则,你就能驾驭无限序列了。