Vec A = B + C + D;
这行代码看起来只有一次赋值,但编译器在背后为它分配了两块和 A 一样大的临时内存、跑了三趟完整的 for 循环、再把这两块临时内存全部析构释放——而一个手写循环只需要一趟遍历、零次额外分配就能完成同样的计算。运算符重载让语法变美了,却让性能变丑了,两者在传统实现下竟然是互斥的。表达式模板(Expression Templates)正是 C++ 社区在 1995 年发明的、用编译期类型系统把这个代价彻底消灭的技术——Eigen、Blaze、Blitz++ 这些高性能数学库的核心设计,全部建立在这个技术之上。读完这篇文章,你会理解它的完整实现机制、为什么 move 语义无法替代它、以及在使用 Eigen 时怎样避开延迟求值带来的陷阱。
运算符重载的代价清单
先看最朴素的向量类和它的 operator+:
struct Vec {
std::vector<double> data;
Vec(size_t n) : data(n) {}
size_t size() const { return data.size(); }
double& operator[](size_t i) { return data[i]; }
const double& operator[](size_t i) const { return data[i]; }
};
Vec operator+(const Vec& a, const Vec& b) {
Vec result(a.size()); // malloc <a href="javascript:;">#1</a>
for (size_t i = 0; i < a.size(); ++i)
result[i] = a[i] + b[i]; // 循环 <a href="javascript:;">#1</a>
return result;
}
这段代码没有任何语法错误,也确实能正确计算两个向量的和——但当你写 A = B + C + D 的时候,编译器看到的执行序列是这样的:
步骤1: tmp1 = operator+(B, C) → malloc + 循环遍历 N 个元素
步骤2: tmp2 = operator+(tmp1, D) → malloc + 循环遍历 N 个元素
步骤3: A = tmp2 → 拷贝/移动 N 个元素
步骤4: 析构 tmp2 → free
步骤5: 析构 tmp1 → free
两次 malloc,三趟循环,两次 free。如果 N 是 10 万,你刚刚在堆上来回搬了 60 万个 double——其中有 40 万个是完全不必要的中间搬运。
而手写循环只需要:
for (size_t i = 0; i < N; ++i)
A[i] = B[i] + C[i] + D[i]; // 一趟循环,零临时对象
这就是运算符重载在数值计算场景下的根本矛盾:语法的优雅和运行时的效率,在传统实现方式下是互斥的。
Move 语义救不了你
有人会说:C++11 的移动语义不是已经解决了临时对象的问题吗?确实,如果你给 Vec 加上移动构造函数,tmp1 和 tmp2 的“拷贝”可以变成“移动”——本质上就是指针的交换,几乎零开销。
但 move 语义解决的是对象搬迁的开销,不是计算本身的开销。
仔细想:即使 tmp1 是通过 move 构造出来的,operator+(B, C) 内部那个 for 循环依然要跑一遍——它要把 B[i] + C[i] 的结果写进 tmp1。然后 operator+(tmp1, D) 内部的 for 循环也要跑一遍——它要把 tmp1[i] + D[i] 的结果写进 tmp2。两趟循环,一个都少不了。
更糟糕的是,这两趟循环的缓存行为是灾难性的:第一趟循环把 B 和 C 的数据加载到 cache 里做完加法,结果写进 tmp1;然后第二趟循环又要把 tmp1 从内存(或者 cache)里读回来,和 D 相加——tmp1 这块内存从写入到被读取,中间可能已经被其他数据挤出了 cache。对于 10 万个 double 的向量,tmp1 占 800KB,直接超过了多数 CPU 的 L1 cache 容量。
move 让你少搬了一次家具,但没有减少你跑腿的趟数——而跑腿本身才是真正的性能瓶颈。
所以真正需要解决的问题从来不是“拷贝太贵”,而是“循环太多”——我们需要一种方式,既保持 A = B + C + D 的数学语法,又能让编译器把它编译成单趟循环、零临时对象。
表达式模板——把运算推迟到赋值那一刻
表达式模板的核心思想只有一句话:operator+ 不做计算,只“记住”要做什么计算;真正的计算推迟到 operator= 赋值的那一刻再一次性完成。
这就是所谓的延迟求值(Lazy Evaluation)——不过它不是运行时的延迟,而是编译期的延迟:编译器在编译阶段把整个表达式的结构编码进类型系统,运行时只执行一次融合后的循环。
来看具体怎么做。首先,定义一个“表达式”的抽象基类(通过 CRTP 实现静态多态):
template <typename E>
struct VecExpression {
double operator[](size_t i) const {
return static_cast<const E&>(*this)[i];
}
size_t size() const {
return static_cast<const E&>(*this).size();
}
};
然后让具体的向量类继承它:
struct Vec : VecExpression<Vec> {
std::vector<double> data;
Vec(size_t n) : data(n) {}
// 关键:从任意表达式构造(触发求值)
template <typename E>
Vec(const VecExpression<E>& expr) : data(expr.size()) {
for (size_t i = 0; i < expr.size(); ++i)
data[i] = expr[i]; // 这里才真正计算!
}
double operator[](size_t i) const { return data[i]; }
size_t size() const { return data.size(); }
};
最关键的一步——operator+ 不返回 Vec,而是返回一个轻量级的“加法表达式对象”:
template <typename E1, typename E2>
struct VecSum : VecExpression<VecSum<E1, E2>> {
const E1& lhs;
const E2& rhs;
VecSum(const E1& a, const E2& b) : lhs(a), rhs(b) {}
double operator[](size_t i) const {
return lhs[i] + rhs[i]; // 不分配内存,不跑循环
}
size_t size() const { return lhs.size(); }
};
template <typename E1, typename E2>
VecSum<E1, E2> operator+(const VecExpression<E1>& a,
const VecExpression<E2>& b) {
return VecSum<E1, E2>(
static_cast<const E1&>(a),
static_cast<const E2&>(b)
);
}
现在看 A = B + C + D 发生了什么:
B + C → 返回 VecSum<Vec, Vec>(不计算,只保存引用)
(B+C) + D → 返回 VecSum<VecSum<Vec, Vec>, Vec>(还是不计算)
A = ... → Vec 的模板构造函数被调用,触发单次循环
当构造函数执行 data[i] = expr[i] 时,expr 的类型是 VecSum<VecSum<Vec, Vec>, Vec>,它的 operator[] 会递归展开:
expr[i]
= lhs[i] + rhs[i] // 外层 VecSum
= (lhs.lhs[i] + lhs.rhs[i]) + rhs[i] // 内层 VecSum 展开
= B[i] + C[i] + D[i] // 最终:直接访问原始数据
编译器在优化之后,生成的代码和你手写的 for (i...) A[i] = B[i] + C[i] + D[i] 完全一致——零临时对象,一趟循环,编译期全部解决。
这就是表达式模板的精髓:用 C++ 的类型系统在编译期构建了一棵表达式树,每个节点是一个模板类型(如 VecSum<VecSum<Vec, Vec>, Vec>),叶子节点是原始数据容器,中间节点是运算操作,而这棵树的“求值”被延迟到赋值那一刻才发生——此时编译器把整棵树内联展开,所有中间节点消失,只剩下一个直接操作叶子节点的循环。
Eigen 的工程实践
理解了原理,来看真实世界的工程实现。Eigen 是目前最广泛使用的 C++ 线性代数库,它的整个架构就建立在表达式模板之上。
在 Eigen 中,当你写 MatrixXd C = A + B 时,operator+ 返回的不是 MatrixXd,而是一个 CwiseBinaryOp<internal::scalar_sum_op<double>, const MatrixXd, const MatrixXd> 类型的表达式对象。这个类型通过 CRTP 继承自 MatrixBase,而 MatrixBase 定义了所有矩阵表达式共享的接口——包括 operator+、operator*、.transpose() 等操作,每个操作都返回一个新的表达式类型而不触发求值。
只有在以下几个时机,Eigen 才会真正触发求值:
- 赋值给具体矩阵类型:
MatrixXd C = expr; 触发构造函数求值
- 显式调用
.eval():auto C = (A + B).eval(); 强制立即求值
- 矩阵乘法:
C = A * B 因为涉及数据依赖(结果的每个元素依赖输入的整行和整列),Eigen 会自动引入临时对象
这里有一个 Eigen 用户经常踩到的坑值得警惕——auto 捕获表达式对象:
// 危险!expr 只是一个表达式对象,持有对 A 和 B 的引用
auto expr = A + B;
// 如果此时修改了 A 或 B 的值...
A(0,0) = 999;
// expr 的求值结果会反映修改后的 A
MatrixXd C = expr; // C(0,0) 包含了 999,而非原始值
auto 推导出来的类型不是 MatrixXd,而是 CwiseBinaryOp<...>——一个轻量级的表达式代理对象,它只持有对原始矩阵的引用。如果原始矩阵在表达式构造之后被修改或析构,后续求值将产生未定义行为。这就是延迟求值的固有风险:你延迟了计算,也延长了对源数据的依赖。
从 Blitz++ 到 C++20:延迟求值的演化之路
表达式模板的历史可以追溯到 1995 年,Todd Veldhuizen 在 Blitz++ 库中首次系统化地提出并实现了这一技术——他的论文《Expression Templates》发表在 C++ Report 上,开创了用模板元编程优化数值计算的先河。在那个年代,C++ 编译器的优化能力远不如今天,临时对象的开销更加致命,表达式模板几乎是高性能数值库的唯一选择。
三十年过去了,表达式模板不但没有被淘汰,反而成为了现代高性能库的标准设计模式——Eigen(2006 年至今)、Blaze(2012 年至今)、Armadillo 都在核心架构中使用了这一技术。原因很简单:即使有了 move 语义、RVO、甚至编译器的 loop fusion 优化,对于链式数学运算 A = B + C + D + E + F 这种场景,只有表达式模板能保证在任意数量的操作数下都生成最优的单循环代码——这是语言特性和编译器优化都无法自动做到的事情。
有趣的是,表达式模板背后的核心思想——延迟求值——正在以新的形式渗透进 C++ 标准库。C++20 的 std::ranges 中的 View 适配器(如 std::views::transform、std::views::filter)本质上和表达式模板做着同样的事情:它们不立即产生结果序列,而是构建一个“计算管道”的描述,只有在被实际遍历(for 循环或 .begin()/.end())时才触发计算。从 1995 年的 VecSum<VecSum<Vec, Vec>, Vec> 到 2020 年的 transform_view<filter_view<...>>,延迟求值的形式变了,但本质从未改变:不要急着算,先把“要算什么”记下来,等到真正需要结果的那一刻再一次性完成。
什么时候该用,什么时候不用
表达式模板不是银弹。它的威力只在特定条件下才能发挥:
适合使用的场景:
- 大规模数值计算——向量、矩阵运算,元素数量在数千到数百万级别
- 链式运算频繁——
A = B + C * D - E 这种多操作数的数学表达式
- 对性能有极致要求——每一次不必要的内存分配和循环遍历都不可接受
不适合使用的场景:
- 小规模数据——10 个元素的向量,临时对象的开销可以忽略不计
- 通用业务逻辑——表达式模板的复杂度(编译时间、错误信息可读性)远大于收益
- 涉及复杂数据依赖——矩阵乘法、卷积等操作的中间结果必须显式求值
如果你正在开发数学库或高性能计算框架,表达式模板是绕不过去的核心技术;如果你是 Eigen 或 Blaze 的用户,理解表达式模板能帮你做三件事——避开 auto 捕获表达式对象的悬垂引用陷阱、判断何时需要手动 .eval() 强制求值、以及在 Benchmark 中写出不被延迟求值干扰的公平对比。一句话:知道 operator+ 背后发生了什么,比会用 operator+ 更重要。
声明:本文是经过严格查阅相关权威文献和资料,形成的专业的可靠的内容。全文数据都有据可依,可回溯。特别申明:数据和资料已获得授权。本文内容,不涉及任何偏颇观点,用中立态度客观事实描述事情本身。
本文技术讨论由云栈社区整理发布。