小场景:老板在群里 @ 我们,“小X,上季度的数据报表发一下”,我们秒回“好的老板,在做了”,然后切回浏览器继续摸鱼。
五分钟后老板又 @,“还没好?”,我们才不紧不慢地关掉浏览器,把早就存在桌面上的文件拖进微信,我们其实早就做完了,只是在等一个真正需要交付的时刻才把结果掏出来。
在 C++ 的世界里,这种不见棺材不落泪、不到截止日期不出活的行为,有一个听起来很学术、实际上很摸鱼的名字:延迟计算,或者叫惰性求值。
你可能会说:“这不就是懒吗?我写代码已经够懒了,连注释都不想写,还需要专门学怎么让代码懒?”
哎,阿达西,你这话说的嘛,勺子倒过来舀奶茶呢?我们那叫懒散?那是羊群上山吃草,鞭子抽到屁股跟前才知道跑。
西加加的惰性求值叫懒惰,歹得很这玩意,人家让编译器这个巴郎子记下睡大觉这个章程,然后掐着表叫起床。
什么,差一个字?骆驼和毛驴子都叫牲口,一个驮丝绸一个驮柴火,档次的呢。
理解延迟计算
1. 什么是延迟计算?
延迟计算就是将表达式的求值推迟到真正需要其结果的那一刻。
举个我们都懂的栗子:
int add(int a, int b)
{
std::cout << "add..." << std::endl;
return a + b;
}
int main()
{
auto recipe = add(3, 4); // 普通函数:当场求值
auto lazy_eval = [&]{ return add(3, 4); };
// 只有调用 lazy_eval() 时才开始求值
}
lazy_eval 它就是个装了代码的信封,不是结果数值。
核心价值:
- 省计算:3+4 如果最后根本没用上,急求值就白算了。
- 省内存:如果整出个无限列表,那就废了。
- 能处理未来才知道的数据:比如输入、网络回包。
2. C++内置的延迟计算
逻辑运算符 && 和 ||
if (ptr != nullptr && ptr->value > 10) { ... }
这里面藏着强制延迟计算规则:
- 先算 ptr != nullptr。
- 只有这玩意为 true,才去算 ptr->value > 10。
- 如果第一步就 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(int, int)> 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<double> total_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{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 构建流水线
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<int> fibonacci(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 就像一个暂停键。
- co_yield a; 时,函数暂停,把值 a 传出去。
- 下次循环迭代时,函数会从暂停点之后的那一行恢复执行。
- 局部变量 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,平时只维护一个轻量级的待办事项视图,截止日期到了才能激发真正的潜能。