先交代一下背景。前段时间团队里有人报了一个性能问题:一段看起来非常普通的向量计算代码,在 -O2 下跑得极慢,远低于理论内存带宽能支撑的上限。我拿到代码一看,几百行逻辑里最核心的就是一行:
cpp复制v = a + b + c * d;
这行代码本身没什么问题,问题是它出自一个用朴素运算符重载实现的数学库。operator+、operator* 每调用一次,就分配一整块新内存,然后全数组遍历一遍。于是这一行简单代码,背后其实是三次分配、多次全量内存遍历,数据量一大,性能自然崩。我跟对方说:“这不是你算法的问题,是这行表达式天生在烧内存带宽。”那怎么救?C++老玩家基本都会想到同一个名字:表达式模板(Expression Templates)。
表达式模板不是什么新东西,早在1994年就被用于 Blitz++ 这类数值库,后来 Eigen、Boost.uBLAS、Blaze 这些库更是把它用到了极致。它的核心思路也直白到有点反直觉:让 a + b 这个表达式的“结果”不是一个计算完的数组,而是一个描述“怎么计算”的结构体,真正的运算被推迟到等号赋值那一刻,一次性融合完成。这篇文章我就把自己对表达式模板的理解、手写实现过程、遇到的坑,以及它在现代 C++ 里的位置,一次讲清楚。
1. 临时对象拖垮性能的根因:稀疏运算符重载的真实代价
1.1 三次加法产生两个隐身临时量
先看朴素实现长什么样。假设我们要给一组 double 数组提供加法,通常第一个版本长这样:
cpp复制struct Vec {
size_t n;
double* data;
Vec(size_t n_) : n(n_), data(new double[n_]) {}
~Vec() { delete[] data; }
};
Vec operator+(const Vec& a, const Vec& b) {
Vec r(a.n);
for (size_t i = 0; i < r.n; ++i) {
r.data[i] = a.data[i] + b.data[i];
}
return r;
}
这段代码写起来轻松,用起来方便,但代价全藏在看不见的地方。当你写下 v = a + b + c * d 时,编译器会按优先级把表达式拆成若干步子计算,每遇到一次运算符重载,就是一个临时对象诞生:
- 计算
c * d,分配一块内存,遍历写一遍,得到临时对象t1。 - 计算
a + b,分配一块内存,遍历写一遍,得到临时对象t2。 - 计算
t2 + t1,再分配一块内存,遍历写一遍,得到临时对象t3。 - 把
t3逐个赋值给v,再遍历读一遍。
这还没算 t1、t2 析构时释放内存的开销。对几百万个元素的数组来说,一次表达式求值等于把同样大小的数据在内存里来回搬运了好几遍。现代 CPU 的数学运算速度已经快过内存带宽不少,所以这种写法瓶颈根本不在计算本身,而在无意义的访存。最讽刺的是,手写一个循环一次遍历就能做完的事,运算符重载反而把事情搞复杂了。
1.2 求值不是必须发生在运算符里的
表达式模板对这个问题给出的解法是:把“计算过程”和“计算时机”解耦。a + b 时不真正做加法,只把“要对 a 和 b 做加法”这个意图存进一个轻量级对象里返回。这个对象内部保存的是 a、b 的引用,以及一个表示“加法”的元信息,不分配内存,不遍历数组。等到整个表达式的最外层结果被用来给一个 Vec 赋值或构造时,才统一触发真正的逐元素求值。
这意味着前面那段 v = a + b + c * d 的求值过程变成:先构建一棵表达式树,树的叶子是 a、b、c、d,内部节点是加法和乘法;然后当 v 去“接收”这棵树时,写一个循环,逐个下标 i,把 a[i] + b[i] + c[i] * d[i] 算出来写入 v[i]。整个表达式只遍历一次数组,只分配一次内存(给 v 自己),临时对象全部是栈上轻量结构。
这个思路之所以叫“模板”,是因为表达式树的每个节点类型都是由 C++ 模板在编译期生成的。a + b 产生一个 BinaryExpr<AddOp, Vec, Vec> 类型,(a + b) + (c * d) 产生一个更复杂的嵌套类型。这些类型只在编译期存在,运行时唯一的实体就是几个存放引用的栈对象。
1.3 表达式模板在数值库里的地位
如果你用过 Eigen,你会发现在它的文档里经常出现“不要用 auto 储存表达式结果”这样的警告,这正是因为它依赖表达式模板。MatrixXd C = A * B; 里,A * B 不是一个算好的矩阵,而是一个表示乘法的表达式对象,直到 C 开始接收它时才真正完成矩阵乘法。然而对于 operator* 这种重载,为了性能它默认会采用延迟求值;在某些场景下(比如 A * B * C 连乘多个矩阵),Eigen 甚至会重新分析表达式树,选择更好的矩阵乘法结合顺序。
Blitz++、Blaze、Boost.uBLAS 也是同样的思想。可以说,凡是追求极致性能的 C++ 数值库,没有一个能绕过表达式模板。理解了它,你才真正看得懂这些库为什么敢做那么“激进”的设计。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 表达式模板的设计骨架:让表达式本身成为一种类型
2.1 从CRTP开始:一个能统一树叶与树节点的基类
要手写表达式模板,第一步是让“数组”和“表达式”能共用一套接口。因为表达式的求值本质是 expr[i] 返回下标 i 处的值,数组本身也能做到这一点,那不如定义一个基类:
cpp复制template <typename Derived>
class ExprBase {
public:
size_t size() const {
return static_cast<const Derived&>(*this).size();
}
double operator[](size_t i) const {
return static_cast<const Derived&>(*this)[i];
}
};
这招叫 CRTP(Curiously Recurring Template Pattern),C++ 里实现静态多态的经典技巧。ExprBase 自己并不知道 `s
