我写的代码竟然敢和我比摸鱼?C++ 延迟计算那些事儿

0 阅读13分钟

小场景:老板在群里 @ 我们,“小X,上季度的数据报表发一下”,我们秒回“好的老板,在做了”,然后切回浏览器继续摸鱼。

五分钟后老板又 @,“还没好?”,我们才不紧不慢地关掉浏览器,把早就存在桌面上的文件拖进微信,我们其实早就做完了,只是在等一个真正需要交付的时刻才把结果掏出来。

在 C++ 的世界里,这种不见棺材不落泪、不到截止日期不出活的行为,有一个听起来很学术、实际上很摸鱼的名字:延迟计算,或者叫惰性求值

你可能会说:“这不就是懒吗?我写代码已经够懒了,连注释都不想写,还需要专门学怎么让代码懒?”

哎,阿达西,你这话说的嘛,勺子倒过来舀奶茶呢?我们那叫懒散?那是羊群上山吃草,鞭子抽到屁股跟前才知道跑。

西加加的惰性求值叫懒惰,歹得很这玩意,人家让编译器这个巴郎子记下睡大觉这个章程,然后掐着表叫起床。

什么,差一个字?骆驼和毛驴子都叫牲口,一个驮丝绸一个驮柴火,档次的呢。

理解延迟计算

1. 什么是延迟计算?

延迟计算就是将表达式的求值推迟到真正需要其结果的那一刻。

举个我们都懂的栗子:

int add(int a, int b) 
{ 
    std::cout << "add..." << std::endl; 
    return a + b; 
}

int main() 
{
    auto recipe = add(34); // 普通函数:当场求值
    auto lazy_eval = [&]{ return add(34); };
    // 只有调用 lazy_eval() 时才开始求值
}

lazy_eval 它就是个装了代码的信封,不是结果数值。

核心价值:

  • 省计算:3+4 如果最后根本没用上,急求值就白算了。
  • 省内存:如果整出个无限列表,那就废了。
  • 能处理未来才知道的数据:比如输入、网络回包。

2. C++内置的延迟计算

逻辑运算符 && 和 ||

if (ptr != nullptr && ptr->value > 10) { ... }

这里面藏着强制延迟计算规则:

  1. 先算 ptr != nullptr。
  2. 只有这玩意为 true,才去算 ptr->value > 10。
  3. 如果第一步就 false,第二步压根不执行,这叫短路。

来个简单易懂的栗子:

甲:你是程序员吗?并且 你头发少吗?
乙:我不是程序员。
甲:那行,后面头发少的问题我就不问了,省得你尴尬。

|| 同理:

if (is_cached() || fetch_from_db()) {}

缓存里有就直接短路,谁特么还去查数据库?这种遇真即停的机制,帮我们省了多少时间。

条件运算符 ? :

int val = condition ? expensive_func1() : expensive_func2();

它只算被选中的那个分支,另一个分支的代码虽然写了,但仿佛不存在。

逗号运算符 ,

int x = (printf("算前面\n"), 21); // 先执行左边,然后丢弃结果,最后求值右边作为整体结果

它也算一种顺序上的延迟,虽然前面的表达式求值了,但结果被扔了,最终结果来自最后一个表达式。

不过现在这玩意儿主要用在 for 循环头里装逼,或者宏定义里搞事情。

这些都是最原始的惰性求值,C++ 从早期就把短路求值刻在骨子里了。

只不过后来大家觉得不过瘾,才有了 C++20 Ranges 那种自定义延迟。

封装可推迟的计算

对于延迟计算,怎么能少了 lambda 和 std::function 这俩玩意。

1. 使用 Lambda 表达式延迟执行

lambda 就是一个匿名函数的字面量,随手写,随手传,轻如鸿毛:

auto lazy_add = [a=3, b=4] { 
    std::cout << "终于有人喊我啦" << std::endl; 
    return a + b; 
};
// 此时 a 和 b 的值已经被捕获

int result = lazy_add(); // 现在才开始求值

lambda 作为延迟计算的三大优势:

  • 就地定义,语义顺滑:我们可以在传参的地方当场现编一段逻辑,不用像函数指针那样还得单独写个函数。
  • 捕获列表决定延迟时的保存方式。
  • 每个 lambda 都是独一无二的匿名类型,编译器能把它优化到连渣都不剩,常常直接内联进调用点,零开销。

不过 lambda 本身是急求值构造、延迟求值执行,捕获列表在定义 lambda 的那一刻 就发生作用了:

int x = 5;
auto lazy = [x] { return x * 2; };
x = 10; 
std::cout << lazy(); // 输出 10,不是 20,x被捕获时是快照值 5

若想跟随外界变化,用引用捕获 [&x],但我们就得保证 lazy 执行时 x 还健在。

2. 使用 std::function 传递延迟行为

std::function 是个类型擦除的万能容器,就是个大胃袋,只要是可调用对象就能装下,不过需要它们参数列表和返回类型匹配。

std::function<int(intint)> lazy_op; // 接受俩 int 返回 int

// 我们可以往里塞各种东西:
lazy_op = [](int a, int b) { return a + b; }; // lambda
lazy_op = std::multiplies<int>{}; // 函数对象
lazy_op = [](int a, int b) { return a ^ b; }; // 异或

它和纯 lambda 的区别:

特性lambda(匿名类型)std::function
身份具体类型通用接口
开销零成本抽象,可内联有类型擦除代价(虚函数/函数指针调用),有微小开销
存储通过 auto 或模板推导可以作为类成员、容器元素、函数参数统一类型

示例:任务队列 / 回调管理器

class TaskScheduler 
{
    std::vector<std::function<void()>> tasks; // 万能任务清单
    
public:
    void defer(std::function<void()> task) { tasks.push_back(task); }
    
    void run_all() 
    {
        for (auto& task : tasks) task(); // 延迟到此刻统一执行
    }
};

// 使用
scheduler.defer([]{ download_file(); });
scheduler.defer([&]{ process_data(); }); // 注意引用悬垂哦

std::function 内部用了小对象优化,小的 lambda 不会堆分配。

但它会引入一次间接调用(类似函数指针),如果需要频繁构造/调用 std::function,那时我们应该拥抱模板或者尝试使用 std::move_only_function。

与 std::bind 的延迟组合技:

std::function<Result(Args)> lazy_pipeline = std::bind(some_func, std::placeholders::_1, 21);

先绑死部分参数,真正调用才需要传剩余参数,这叫部分应用,也是延迟计算的一种。

缓存计算结果

第一次喊我干活我认了,第二次你还拿同样的问题烦我,我直接甩你一脸上次的答案。

1. 自定义一个 Lazy<T> 模板类

先说一下思路:

  • 使用 std::function<T()> 存储计算函数
  • 使用 std::optional<T> 缓存计算结果
  • 首次调用时计算并缓存,后续调用直接返回。

欸,就这么简单,直接上代码:

template<typename T>
class Lazy 
{
    std::function<T()> compute_;
    mutable std::optional<T> cache_; // mutable 让 const 下也能写缓存

public:
    // 构造函数:接受任何可调用对象,塞进compute_
    template<typename Callable>
    Lazy(Callable&& c) : compute_(std::forward<Callable>(c)) {}

    // 这里我们简单起见允许拷贝,就不做处理了

    // 求值运算符
    T& value() const 
    {
        if (!cache_.has_value()) 
        {
            cache_ = compute_();
        }
        return *cache_;
    }

    // 重载类型转换
    operator T& () const { return value(); }
};

使用场景:

// 假设我们要算公司去年总利润,我们知道这玩意要查八张表,慢得要死
Lazy<doubletotal_profit([] {
    std::cout << "正在计算中..." << std::endl;
    return 3.14;
    });

// 第一次调用
std::cout << total_profit.value() << std::endl; // 打印“正在计算中...”,然后返回结果3.14

// 第二次调用
std::cout << total_profit << std::endl; // 直接出结果

Lazy 在第一次遇到难题,吭哧吭哧算半天,然后把答案记在 optional 里。下次有人再来问,他直接回答:“这事我早研究过了,答案是 3.14。” 完美诠释什么叫一次劳动,终身复用

不过这里有个坑爹细节,就是mutable。因为 value() 从语义上讲应该是 const 成员函数(它不改变对象的逻辑状态,只是首次调用时填充了物理缓存),但编译器不认这套,所以必须用 mutable 把 cache_ 标记成逻辑常量中的物理变量。

2. 标准库方案:std::optional + std::once_flag

这时我们可能会想:我手搓的 Lazy<T> 挺爽,但万一多线程环境呢?俩线程同时计算,结果俩线程都去查数据库,重复劳动

标准库送来温暖:std::once_flag 配合 std::call_once。它的设计初衷是保证初始化函数只执行一次,哪怕多个线程同时调用。

  • std::call_once 用于确保某个可调用对象在多线程环境中只执行一次。
  • 一个 std::once_flag 类型的对象,如果被传递给多个 std::call_once 调用,将允许这些调用相互协调,从而只有一个调用能够实际完成。

我们把上面的手搓版改成标准库版:

template<typename T>
class ThreadSafeLazy 
{
    std::function<T()> compute_;
    mutable std::optional<T> cache_;
    mutable std::once_flag flag_;

public:
    template<typename Callable>
    ThreadSafeLazy(Callable&& c) : compute_(std::forward<Callable>(c)) {}

    T& value() const 
    {
        std::call_once(flag_, [this] {
            cache_ = compute_();
        });
        return *cache_;
    }

    operator T&() const { return value(); }
};

std::call_once(flag_, lambda) 保证:无论多少线程同时到达 value(),第一个进来的线程执行 lambda,其他线程乖巧排队等待,并且不会重复执行。

如果 call_once 调用的函数抛出异常,once_flag 不会变成“已完成”状态,下次再调用 call_once 会重新尝试执行,这很合理。

注意咯,注意咯,无论手搓版还是标准库版,都假设:计算结果一旦生成就永远有效,且不需要刷新。

如果我们的计算依赖于外部会变化的东西(比如当前时间、某个配置文件),那这种“一次计算终身缓存”就没啥卵用了。

要解决也不难,给 Lazy 加个 reset() 成员函数,清空 cache_,下次再求值就会重新算。

序列与流的惰性求值

如果说之前那些东西体现的是一个单点,那么现在就是一条线,emm...算是一条流水线。

1. C++20 Ranges 与视图

C++20 Ranges 的核心思想是视图 (View),它像一副屌炸天的眼镜,戴上它看数据,数据就变了模样,但原始数据本身纹丝不动

这就是惰性求值的精髓:只有当我们伸手去取元素时,整条线上的计算才真正发生。

| 管道操作符,就是组装这条线的传送带:

#include <iostream>
#include <vector>
#include <ranges>

int main() 
{
    std::vector<int> data{12345678910};

    // 构建流水线
    auto lazy_pipeline = data
        | std::views::filter([](int n) { return n % 2 == 0; }) // 选偶数
        | std::views::transform([](int n) { return n * n; }) // 求平方
        | std::views::take(3); // 取前3个

    // 此时,任何计算都还没发生,lazy_pipeline只是一个轻量级的视图对象。

    // 执行计算
    std::cout << "Lazy results: ";
    for (int v : lazy_pipeline) {
        std::cout << v << " ";
    }
    std::cout << std::endl; // 输出: Lazy results: 4 16 36

    // 原始数据毫发无伤
    std::cout << "Original data: ";
    for (int v : data) {
        std::cout << v << " ";
    }
    // 输出: Original data: 1 2 3 4 5 6 7 8 9 10
}

在这个栗子中:

  • 构建 lazy_pipeline 时,我们只是定义了一个计算计划。
  • 直到 for 循环迭代,它才真正运转:filter 检查、transform 计算、take 控制数量,所有操作在单次遍历中协同完成。

当然了,看到视图这玩意,就得关心一下生命周期问题:

视图不持有数据,只持有对原数据的引用。原数据要是没了,视图就成了悬空指针,再访问它就是无了。

所以我们需要确保数据源比视图活得久。

还有像 filter 这类视图,迭代器会退化成功能更弱的类型,不再支持 + 或 [] 等随机访问操作。这是惰性计算的代价,数据不连续了。

2. C++23 的 std::generator 与协程

之前的 Ranges 只能处理已有数据,而 std::generator 能够凭空造数据,喜欢无中生友。

它是协程的一种应用,允许我们像写普通函数一样,惰性地生成一个序列。

核心思想就是:每次执行 co_yield 时协程生成序列中的一个元素,迭代器递增会恢复协程执行,直到序列结束。

// 一个看起来像普通函数的生成器
std::generator<intfibonacci(int max) 
{
    int a = 0, b = 1;
    while (a <= max) 
    {
        co_yield a; // 关键的地方
        auto next = a + b;
        a = b;
        b = next;
    }
    // 函数结束,生成器自动结束
}

int main() 
{
    // 调用函数,返回一个生成器对象
    auto fib_gen = fibonacci(100);

    std::cout << "Fibonacci numbers up to 100: ";
    for (int v : fib_gen) {
        std::cout << v << " ";
    }
    // 输出: Fibonacci numbers up to 100: 0 1 1 2 3 5 8 13 21 34 55 89
}

核心机制:co_yield 就像一个暂停键。

  1. co_yield a; 时,函数暂停,把值 a 传出去。
  2. 下次循环迭代时,函数会从暂停点之后的那一行恢复执行。
  3. 局部变量 a、b、max 的状态都被完整保留,就像时间停止了一样。

这么好用的东西也是有代价的:

  • 非随机访问:它是 input_range,只能单向前进,不能 fib_gen[5]。
  • 一次性用品:遍历一次后,协程就结束了,不能倒带重来。要重来得重新调用 fibonacci(100) 创建新实例。
  • 性能开销:挂起/恢复有成本,不适合极高频率的 co_yield。
  • 生命周期陷阱:co_yield 出的引用/指针必须指向协程栈外的对象,否则容易出现垂悬引用。

3. 表达式模板

最后登场的,是 C++ 模板元编程领域的一尊大神——表达式模板。

它的目标非常明确:干掉数值计算中,由运算符重载产生的所有不必要的临时对象。

假设我们有个数学向量类 Vector:

Vector a, b, c, d;
Vector result = a + b + c + d;

普通的运算符重载会生成多个临时对象,效率低下。

表达式模板则改变思路,让 operator+ 不直接计算,而是返回一个代表“表达式树”的轻量级对象。

核心思路:只有等到最终赋值 = 时,表达式模板对象才被求值,而且是在单次循环内,对整个表达式进行融合计算,避免了任何中间向量。

// 定义表达式模板 (简化示意)
template<typename L, typename R>
struct VectorSum 
{
    const L& lhs;
    const R& rhs;
    VectorSum(const L& l, const R& r) : lhs(l), rhs(r) {}
    
    // 真正的计算延迟到这里
    auto operator[](size_t i) const { return lhs[i] + rhs[i]; }
};

// 重载 + 操作符,让它返回表达式模板
template<typename L, typename R>
auto operator+(const L& lhs, const R& rhs) 
{
    return VectorSum<L, R>(lhs, rhs);
}

// Vector 类需要有一个模板赋值运算符
template<typename Expr>
Vector& Vector::operator=(const Expr& expr) 
{
    for (size_t i = 0; i < size(); ++i) 
    {
        data[i] = expr[i]; // 这里才递归地触发整个表达式树的求值
    }
    return *this;
}

代码执行流程:

  • auto expr = a + b + c + d; 实际上构建了一棵类型为 VectorSum<...> 的编译期表达式树。
  • Vector result = expr; 时,operator= 触发计算。循环内,expr[i] 递归展开,调用各层 VectorSum::operator[],最终等价于 result[i] = a[i] + b[i] + c[i] + d[i];,零临时对象,单次遍历。

这玩意实现起来复杂,除非造轮子否则无需手动编写,我们可以使用 Eigen 库(一个C++模板库),它就是表达式模板的集大成者。

还有很多优秀的第三方库提供延迟计算工具,就不介绍了。

结尾

写到这里,我忽然意识到一个问题,我这篇文章本身就是一次惰性求值的典范。

我想这周输出一篇技术分享,我拖到周六晚上才开始动笔,过程中还穿插了俩次 cos 马桶上的沉思者。

但你看,我不也按时完成了(并非)?而且洋洋洒洒几千字,技术点一个没落下,好吧...有些地方确实偷懒了,但这是人之常情嘛。

所以说,惰性求值的最高境界不是代码写得好,而是把整个人生活成一个 lazy view,平时只维护一个轻量级的待办事项视图,截止日期到了才能激发真正的潜能。