C++表达式模板全解析:如何避免临时对象实现高性能数组运算

📅 发布时间:2026/9/7 16:48:55
C++表达式模板全解析:如何避免临时对象实现高性能数组运算
上个月我帮朋友调一段图像分块处理逻辑里面有一个非常常见的写法dst a b c其中 a、b、c 是分别代表三个通道数据的浮点数组。跑起来之后整体帧率明显不对劲我随手加了几行时间统计发现这句“简单”的代码占掉了将近三成的耗时。问题不在 CPU 不够快而在于那个看起来人畜无害的加号——每个operator都会生成一个完整的临时数组。这就是我今天想聊的表达式模板Expression Templates要解决的核心问题。简单说它利用 C 模板在编译期把a b c解析成一个“表达式树”先不真正计算等到赋值那一刻才一次性求值从而避免中间临时对象、减少内存遍历次数。这套技术你在 Eigen、Blaze、Armadillo 这类线性代数库里天天用但很多 C 开发者并不清楚底层怎么运作。这篇文章从零实现一个最小可用的表达式模板把原理、性能、踩坑点一次性讲透适合写过一点模板、想理解高性能数值库底层思路的读者。1. 表达式模板要解决的真实痛点1.1 一个运算符重载引发的“慢镜头”先看一个最简单的三维向量加乘运算struct Vec { double x, y, z; }; Vec operator(Vec const a, Vec const b) { return {a.x b.x, a.y b.y, a.z b.z}; } Vec operator*(Vec const a, Vec const b) { return {a.x * b.x, a.y * b.y, a.z * b.z}; }对象体积小直接返回三个 double不会有什么问题。但一旦把元素个数从 3 扩大到几十万、几百万也就是变成真正的数组运算时事情就不一样了std::vectordouble a, b, c, d; // ... d (a b) * (a c);普通重载方案里a b会生成一个临时std::vectordoublea c又生成一个临时 vector最后一步做逐元素相乘把结果写入d。为了算这一条表达式需要分配两次大块内存、遍历三次数组第一次算ab第二次算ac第三次逐元素乘还要承受两次“写入临时数组再读回来”的 cache 压力。在大数组场景下这个代价非常直观假如每个数组有 1000 万个 double一个中间临时数组就是 80MB。两个临时数组同时存活160MB 的数据在内存和缓存之间倒腾速度自然快不起来。1.2 延迟计算把“先算完再存起来”改成“边遍历边算”表达式模板的思路完全不一样。它不在operator里立刻计算而是把运算信息储存在一个轻量的表达式对象里。这个对象不持有真正的数据数组只保存参与运算的操作数引用、运算类型和表达式结构。当代码写成(a b) * (a c)普通重载返回的是“计算结果”而表达式模板返回的是一棵类型层面的“语法树”。等真正执行d ...赋值时表达式模板才逐下标去求值整个过程只遍历一次目标数组for (size_t i 0; i d.size(); i) { d[i] (a[i] b[i]) * (a[i] c[i]); }中间没有临时数组没有额外分配也没多一次写入和读取。这相当于把代码的可读性保留了又把性能拉回到了手写 for 循环的级别。这也是表达式模板几十年来一直在数值计算领域屹立不倒的根本原因。一句话理解表达式模板它把表达式“编译”成了一段可递归展开的求值公式延迟到需要真实数据时才执行。2. 从零搭建最小表达式模板2.1 先写一个公共基类用 CRTP 避免虚函数开销任何高性能数值方案都不能容忍在每个元素上调用虚函数。表达式模板要做的事情是在编译期确定每个节点类型因此最常见的做法是 CRTPCuriously Recurring Template Pattern奇异递归模板模式。公共基类本身不存储任何数据只负责把调用转发给真正的派生类templatetypename Derived struct Expr { Derived const self() const noexcept { return static_castDerived const(*this); } std::size_t size() const noexcept { return self().size(); } double operator[](std::size_t i) const { return self()[i]; } };通过继承ExprDerived我们可以在模板参数中约束“这是表达式家族的一份子”同时不需要虚表因为self()的 static_cast 在编译期就能确定。2.2 实现二元运算节点二元运算是表达式树里最常见的内部节点。一个a b表达式在类型层面可以表示成templatetypename OP, typename L, typename R struct BinOp : ExprBinOpOP, L, R { L const l; R const r; BinOp(L const lhs, R const rhs) : l(lhs), r(rhs) {} std::size_t size() const noexcept { return std::max(l.size(), r.size()); } double operator[](std::size_t i) const { return OP::apply(l[i], r[i]); } };BinOp只保存左操作数和右操作数的常量引用本身不分配内存也不拷贝数组。它的operator[]按需递归访问左右节点。运算标签单独写成空结构体让每个操作对应一个静态函数struct Add { static double apply(double a, double b) noexcept { return a b; } }; struct Sub { static double apply(double a, double b) noexcept { return a - b; } }; struct Mul { static double apply(double a, double b) noexcept { return a * b; } }; struct Div { static double apply(double a, double b) noexcept { return a / b; } };这样后续想增加新的运算符不需要改动BinOp本身只需要加一个标签和一个静态 apply 函数。2.3 操作符重载返回表达式而不是返回结果关键点来了operator和operator*返回的不是数值数组而是BinOp对象templatetypename L, typename R auto operator(ExprL const lhs, ExprR const rhs) { return BinOpAdd, L, R(lhs.self(), rhs.self()); } templatetypename L, typename R auto operator*(ExprL const lhs, ExprR const rhs) { return BinOpMul, L, R(lhs.self(), rhs.self()); }为什么写成ExprL const而不是直接L const因为这样能通过基类参数只接收继承自Expr的类型参与运算的节点都被限定在“表达式体系”内。lhs.self()把基类引用还原成真正的派生类然后放进BinOp。当写下a b a * b时编译器会把它实例化成类似下面的结构BinOp Add, Vec, BinOp Add, Vec, BinOpMul, Vec, Vec 这是一个编译期嵌套类型实际占用的空间只有几个引用。2.4 真正存储数据的 Vec 类有了表达式节点还得有一个“叶子节点”——存储真实数据并最终接收结果的容器。class Vec : public ExprVec { public: explicit Vec(std::size_t n) : data_(n, 0.0) {} Vec(std::initializer_listdouble init) : data_(init) {} std::size_t size() const noexcept { return data_.size(); } double operator[](std::size_t i) const { return data_[i]; } double operator[](std::size_t i) { return data_[i]; } templatetypename E Vec(ExprE const expr) : data_(expr.size()) { for (std::size_t i 0; i size(); i) { data_[i] expr[i]; } } templatetypename E Vec operator(ExprE const expr) { for (std::size_t i 0; i size(); i) { data_[i] expr[i]; } return *this; } private: std::vectordouble data_; };这段代码里的模板构造函数和模板赋值函数就是表达式真正发生“结算”的地方。Vec(ExprE const expr)让Vec d a b;这种写法可以直接工作Vec operator(ExprE const expr)让d a b;在已存在的容器上完成赋值。遍历时expr[i]看起来像一次普通下标访问实际上会沿着嵌套类型一路递归展开把整个表达式编译成内联的一串浮点运算。以d (a b) * (a c)为例循环体最终会被优化成大致等价于d[i] (a[i] b[i]) * (a[i] c[i]);编译器完全有能力把递归调用内联展开因为所有类型在编译期都已经确定了。写到这里还差一个东西。如果只支持Vec Vec那Vec 2.0这类标量混合运算就写不了。实际数值计算里肯定需要给整个向量加一个标量所以接着看标量支持。3. 进阶标量参与与代码组织3.1 把标量也包装成表达式节点最简单的方式是定义一个小型包装类struct Scalar : ExprScalar { double v; explicit Scalar(double value) noexcept : v(value) {} std::size_t size() const noexcept { return 1; } double operator[](std::size_t) const noexcept { return v; } };然后为Vec double和double Vec各自写一个重载templatetypename L auto operator(ExprL const lhs, double rhs) { return BinOpAdd, L, Scalar(lhs.self(), Scalar(rhs)); } templatetypename R auto operator(double lhs, ExprR const rhs) { return BinOpAdd, Scalar, R(Scalar(lhs), rhs.self()); }一个细节是BinOp存的是操作数的引用而Scalar(rhs)是临时构造的对象。单个完整表达式内它还是安全的因为临时对象会活到该表达式结束。但如果你用auto把表达式保存到更长生命周期后面就有陷阱我在第 5 节单独展开。乘法、减法、除法的标量版本完全可以照样画葫芦。唯一要小心的是不要让重载产生二义性当两侧都是Vec时编译器应该优先选择“表达式 表达式”模板而不是“表达式 double”模板由于Vec无法转换为double重载解析不会产生歧义所以这类额外重载是安全的。3.2 用 C20 concept 让约束更清晰如果项目可以使用 C20代码会简洁很多。可以定义一个表达式类型约束templatetypename T concept ExprNode std