写 Vector r = a + b + c; 时,直觉上应该「读 3 次、写 1 次」就完事。但 C++ 的 operator+ 是**立即求值(eager evaluation)**的:a + b 先算出一个临时 Vector,再拿它去 + c 又算出一个临时。三个向量的加法,背后是 2 个临时堆分配 + 多趟遍历。表达式模板(Expression Templates)就是 Eigen、Blaze 这些数值库用来干掉中间临时、把整条表达式压成「一次遍历、零临时」的核心技巧。这篇手写一个最小实现,并用计数器把差异量化出来。
1. 引子:a + b + c 到底发生了几次遍历
先看最朴素的重载加法,它一调用就立刻算出结果并返回一个新 Vector:
#include <cstdio>
#include <cstddef>
#include <vector>
// 两个全局计数器,量化「临时对象」与「遍历」的代价
int g_alloc_count = 0; // Vector 构造次数(含临时)
int g_read_count = 0; // 元素读取次数
class Vector {
public:
explicit Vector(std::size_t n) : data_(n, 0.0) { ++g_alloc_count; }
std::size_t size() const { return data_.size(); }
double& operator[](std::size_t i) { return data_[i]; }
double operator[](std::size_t i) const { ++g_read_count; return data_[i]; }
private:
std::vector<double> data_;
};
// 传统 operator+:每调用一次就立刻生成一个临时 Vector(值语义)
Vector operator+(const Vector& l, const Vector& r) {
Vector result(l.size()); // 临时对象:一次堆分配
for (std::size_t i = 0; i < l.size(); ++i) {
result[i] = l[i] + r[i]; // 一趟完整遍历
}
return result;
}
int main() {
constexpr std::size_t n = 4;
Vector a(n), b(n), d(n);
for (std::size_t i = 0; i < n; ++i) {
a[i] = 1.0; b[i] = 2.0; d[i] = 4.0;
}
g_alloc_count = 0; // 只看 a + b + d 这段
g_read_count = 0;
Vector r = a + b + d; // a+b 先生成临时 t1,t1+d 再生成临时 t2
std::printf("传统写法结果: ");
for (std::size_t i = 0; i < n; ++i) std::printf("%.0f ", r[i]);
std::printf("\n");
std::printf("临时 Vector 个数: %d\n", g_alloc_count);
std::printf("元素读取次数: %d\n", g_read_count);
}
传统写法结果: 7 7 7 7
临时 Vector 个数: 2
元素读取次数: 16
a + b + d 里发生了两次 operator+:第一次生成临时 t1 = a + b(1 次堆分配 + 8 次元素读取),第二次生成临时 t2 = t1 + d(又 1 次堆分配 + 8 次读取)。三个长度为 4 的向量相加,却读了 16 次元素、分配了 2 个临时对象。长度是 n 时,就是 2 个临时 + 4n 次读取。向量越长、表达式越深,这个浪费越严重。
官方文档:运算符重载(operator overloading) —— 默认的
operator+是「先算再返回」,这是临时对象产生的根源。
2. 核心概念:让 operator+ 返回「表达式对象」而不是结果
表达式的核心思想只有一句话:operator+ 不再立刻算出结果,而是返回一个只持有两个操作数引用的「表达式对象」;等真正赋值给 Vector 时,才沿着这棵表达式树做一次遍历。
「表达式对象」就是一个轻量的小结构体,它不存数据、不分配堆内存,只记录「我是谁 + 我的操作数是谁」。拿 a + b + d 来说,编译期会拼出这样一棵类型树:
r = a + b + d —— 编译期展开成的嵌套类型(每个节点都是栈上的小对象)
VecSum< VecSum<Vector,Vector>, Vector > <- 整个表达式,只有两个引用
│
├─ l: VecSum<Vector,Vector> (= a + b)
│ ├─ l: Vector a (引用,不拷贝数据)
│ └─ r: Vector b
│
└─ r: Vector d
赋值 r = expr 时才「一次性」遍历,把整棵树压平成单层循环:
for i in [0, n): r[i] = a[i] + b[i] + d[i]
关键差异在于求值时机:普通 operator+ 是「即时求值」,表达式模板是「延迟求值(lazy evaluation)」——表达式先被记录成类型,最后一步才真正算。
3. 最小实现:Vector + VecSum 表达式对象
实现套路是「CRTP(奇异递归模板模式)」:定义一个基类 VecExpr<E> 提供统一的 size() / operator[] 接口,让所有表达式对象(包括 Vector 自己)都能被 operator+ 接收。
#include <cstdio>
#include <cstddef>
#include <vector>
int g_alloc_count = 0; // Vector 构造次数(含临时)
int g_read_count = 0; // 元素读取次数
// CRTP 基类:统一表达式对象的 size() / operator[] 接口
template <typename E>
struct VecExpr {
std::size_t size() const { return static_cast<const E&>(*this).size(); }
double operator[](std::size_t i) const { return static_cast<const E&>(*this)[i]; }
};
class Vector : public VecExpr<Vector> {
public:
explicit Vector(std::size_t n) : data_(n, 0.0) { ++g_alloc_count; }
// 从任意表达式对象赋值——这里才是真正求值的地方
template <typename E>
Vector& operator=(const VecExpr<E>& expr) {
data_.resize(expr.size());
for (std::size_t i = 0; i < expr.size(); ++i) {
data_[i] = expr[i]; // 只在这里做一趟遍历
}
return *this;
}
std::size_t size() const { return data_.size(); }
double& operator[](std::size_t i) { return data_[i]; }
double operator[](std::size_t i) const { ++g_read_count; return data_[i]; }
private:
std::vector<double> data_;
};
// 加法表达式对象:只持有两个操作数的引用,不存储、不遍历
template <typename L, typename R>
struct VecSum : VecExpr<VecSum<L, R>> {
const L& l;
const R& r;
VecSum(const L& l, const R& r) : l(l), r(r) {}
std::size_t size() const { return l.size(); }
double operator[](std::size_t i) const { return l[i] + r[i]; }
};
template <typename L, typename R>
VecSum<L, R> operator+(const VecExpr<L>& l, const VecExpr<R>& r) {
return VecSum<L, R>{static_cast<const L&>(l), static_cast<const R&>(r)};
}
int main() {
constexpr std::size_t n = 4;
Vector a(n), b(n), d(n);
for (std::size_t i = 0; i < n; ++i) {
a[i] = 1.0; b[i] = 2.0; d[i] = 4.0;
}
Vector r(n);
g_alloc_count = 0; // 只看 r = a + b + d 这段
g_read_count = 0;
r = a + b + d; // 合成一个表达式对象(栈上),赋值时才一次遍历
std::printf("表达式模板结果: ");
for (std::size_t i = 0; i < n; ++i) std::printf("%.0f ", r[i]);
std::printf("\n");
std::printf("临时 Vector 个数: %d\n", g_alloc_count);
std::printf("元素读取次数: %d\n", g_read_count);
}
表达式模板结果: 7 7 7 7
临时 Vector 个数: 0
元素读取次数: 12
对比上一节:临时 Vector 从 2 个降到 0 个,元素读取从 16 次降到 12 次(即 3n 次,正好是「每个位置读 a、b、d 各一次」的理论下限)。表达式的每个操作数都被 VecSum 用引用持有,没有一次拷贝、没有一次堆分配。
官方文档:CRTP(Curiously Recurring Template Pattern) ——
VecExpr<E>用static_cast<const E&>(*this)拿到派生类,是这套写法的地基。
4. 有临时 vs 表达式模板:一张表说清差异
| 维度 | 传统 operator+ | 表达式模板 |
|---|---|---|
| 求值时机 | 立即(eager) | 延迟(lazy),赋值才求值 |
| 中间临时对象 | 每步一个 Vector | 无(表达式对象只存引用) |
| 堆分配次数 | 2 次(n 元表达式 n-1 次) | 0 次 |
| 元素读取次数 | 4n 次 | 3n 次(理论下限) |
| 表达式对象的类型 | 就一个 Vector | 深度嵌套的模板类型 |
| 编译产物 / 调试难度 | 简单 | 类型爆炸、难读难调 |
「为什么优」很直接:临时对象带来堆分配(malloc/free 的系统调用开销)和多趟遍历(缓存不友好的重复访问)。表达式模板把「n 趟循环」合成「1 趟循环」,向量里每个元素只被读一次、写一次,缓存局部性最好,也没有堆分配抖动。
5. 扩展:减法和标量乘法,表达式树长这样
加上 operator- 和标量 operator*,就能写出 (a + b) * 2.0 - c 这样的复合表达式。注意 VecScale 表达式对象除了引用,还多存一个 double factor。但依然不分配堆内存。
#include <cstdio>
#include <cstddef>
#include <vector>
template <typename E>
struct VecExpr {
std::size_t size() const { return static_cast<const E&>(*this).size(); }
double operator[](std::size_t i) const { return static_cast<const E&>(*this)[i]; }
};
class Vector : public VecExpr<Vector> {
public:
explicit Vector(std::size_t n) : data_(n, 0.0) {}
template <typename E>
Vector& operator=(const VecExpr<E>& expr) {
data_.resize(expr.size());
for (std::size_t i = 0; i < expr.size(); ++i) {
data_[i] = expr[i];
}
return *this;
}
std::size_t size() const { return data_.size(); }
double& operator[](std::size_t i) { return data_[i]; }
double operator[](std::size_t i) const { return data_[i]; }
private:
std::vector<double> data_;
};
template <typename L, typename R>
struct VecSum : VecExpr<VecSum<L, R>> {
const L& l; const R& r;
VecSum(const L& l, const R& r) : l(l), r(r) {}
std::size_t size() const { return l.size(); }
double operator[](std::size_t i) const { return l[i] + r[i]; }
};
template <typename L, typename R>
struct VecDiff : VecExpr<VecDiff<L, R>> {
const L& l; const R& r;
VecDiff(const L& l, const R& r) : l(l), r(r) {}
std::size_t size() const { return l.size(); }
double operator[](std::size_t i) const { return l[i] - r[i]; }
};
template <typename E>
struct VecScale : VecExpr<VecScale<E>> {
const E& e; double factor;
VecScale(const E& e, double f) : e(e), factor(f) {}
std::size_t size() const { return e.size(); }
double operator[](std::size_t i) const { return e[i] * factor; }
};
template <typename L, typename R>
VecSum<L, R> operator+(const VecExpr<L>& l, const VecExpr<R>& r) {
return VecSum<L, R>{static_cast<const L&>(l), static_cast<const R&>(r)};
}
template <typename L, typename R>
VecDiff<L, R> operator-(const VecExpr<L>& l, const VecExpr<R>& r) {
return VecDiff<L, R>{static_cast<const L&>(l), static_cast<const R&>(r)};
}
template <typename E>
VecScale<E> operator*(const VecExpr<E>& e, double f) {
return VecScale<E>{static_cast<const E&>(e), f};
}
int main() {
constexpr std::size_t n = 4;
Vector a(n), b(n), c(n);
for (std::size_t i = 0; i < n; ++i) {
a[i] = 1.0; b[i] = 2.0; c[i] = 3.0;
}
// 复合表达式:r = (a + b) * 2.0 - c,编译期类型是嵌套模板
Vector r(n);
r = (a + b) * 2.0 - c;
std::printf("r = (a+b)*2 - c = ");
for (std::size_t i = 0; i < n; ++i) std::printf("%.0f ", r[i]);
std::printf("\n");
// 表达式对象体积:只存引用/因子,比一个真正的 Vector 更小、更轻
std::printf("sizeof(Vector) = %zu\n", sizeof(Vector));
std::printf("sizeof(表达式对象) = %zu\n", sizeof(decltype((a + b) * 2.0 - c)));
}
r = (a+b)*2 - c = 3 3 3 3
sizeof(Vector) = 24
sizeof(表达式对象) = 16
(a + b) * 2.0 - c 的编译期类型是 VecDiff<VecScale<VecSum<Vector,Vector>>, Vector>。这就是「表达式树」在类型系统里的投影。赋值循环里,expr[i] 会一路展开成 (a[i] + b[i]) * 2.0 - c[i],仍然只遍历一趟。sizeof 那两行说明表达式对象只占 16 字节(两个引用),而真正的 Vector 占 24 字节且还要额外堆分配。
官方文档:模板形参包与折叠表达式(parameter pack / fold expressions) —— 表达式模板的「算子递归展开」思路,和折叠表达式的「包展开」是同一套编译期机制。
6. 代价:为什么不是所有库都用表达式模板
表达式模板不是免费的午餐,它有三个实打实的代价:
- 类型爆炸:
a + b + c的类型是三层嵌套的模板,报错信息里全是VecSum<VecSum<...>>,读起来很痛苦。表达式越长类型越深。 - 编译变慢:每个表达式都实例化一套新类型,表达式种类一多,编译时间和产物体积都会涨。
auto是个坑:auto e = a + b;得到的是表达式对象VecSum<...>,它持有a、b的引用。如果a、b先析构了,e就悬垂了。正确写法是Vector e = a + b;立刻求值。
第 3 条最容易踩:
#include <cstdio>
#include <cstddef>
#include <vector>
template <typename E>
struct VecExpr {
std::size_t size() const { return static_cast<const E&>(*this).size(); }
double operator[](std::size_t i) const { return static_cast<const E&>(*this)[i]; }
};
class Vector : public VecExpr<Vector> {
public:
explicit Vector(std::size_t n) : data_(n, 0.0) {}
template <typename E>
Vector& operator=(const VecExpr<E>& expr) {
data_.resize(expr.size());
for (std::size_t i = 0; i < expr.size(); ++i) data_[i] = expr[i];
return *this;
}
std::size_t size() const { return data_.size(); }
double& operator[](std::size_t i) { return data_[i]; }
double operator[](std::size_t i) const { return data_[i]; }
private:
std::vector<double> data_;
};
template <typename L, typename R>
struct VecSum : VecExpr<VecSum<L, R>> {
const L& l; const R& r;
VecSum(const L& l, const R& r) : l(l), r(r) {}
std::size_t size() const { return l.size(); }
double operator[](std::size_t i) const { return l[i] + r[i]; }
};
template <typename L, typename R>
VecSum<L, R> operator+(const VecExpr<L>& l, const VecExpr<R>& r) {
return VecSum<L, R>{static_cast<const L&>(l), static_cast<const R&>(r)};
}
// 返回一个表达式对象:它持有函数参数 t 的引用 —— 反例,不要这么写
template <typename E>
auto make_expr(const Vector& t, const VecExpr<E>& e) {
return t + e; // 悬垂!t 是参数,函数返回后 t 就销毁了
}
int main() {
Vector a(2);
a[0] = 1.0; a[1] = 2.0;
Vector b(2);
b[0] = 3.0; b[1] = 4.0;
Vector r(2);
r = make_expr(a, b); // make_expr 返回的表达式对象持有已销毁的形参引用
std::printf("r = %.0f %.0f\n", r[0], r[1]);
}
r = 4 6
这个例子里 make_expr 的参数 t 是 const Vector&,函数返回的 VecSum 只存了 t 的引用,函数一返回 t(实参 a)其实还活着(a 在 main 里没销毁),所以碰巧没崩。真正的危险是「表达式对象持有临时操作数的引用」——例如 auto e = a + b + c; 里 a + b 那个 VecSum 是纯右值,被外层 VecSum 按引用持有,纯右值一过表达式末尾就销毁,e 立即悬垂。生产代码里 Eigen 要求不要用 auto 接表达式结果,而是用 Matrix/Vector 立刻求值,正是这个原因。
官方文档:C++ Core Guidelines — 关于生命周期与悬垂引用 —— 表达式对象持有引用,生命周期问题要特别小心。
7. 延伸阅读
- 运算符重载(operator overloading)— cppreference:理解「为什么默认重载会立刻产生临时对象」
- CRTP — cppreference:
VecExpr<E>的静态多态基类写法 - 折叠表达式(fold expressions)— cppreference:同一套编译期「展开」机制,C++17 起可直接用于变参模板
- Compiler Explorer — godbolt.org:把表达式模板的
r = a + b + c丢进去,对比-O0与-O2下临时对象是否被优化掉,直观看到它的开销
本知识库内的相关篇目:
- 《类型列表与编译期算法:手写 typelist 的 map/filter》 —— 编译期想对「一组类型」做运算(取第 N 个、映射成指针、筛选整型)
- 《编译期反射模拟:C++ 没有反射怎么办》 —— C++ 至今没有运行时反射,想「遍历结构体成员」「自动序列化」只能靠模拟
- 《类型擦除:手写 any 与 function 的原理》 —— std::any 和 std::function 看起来是两种不同的东西
8. 一句话总结
表达式模板把「operator+ 立刻算」改成「operator+ 只记录表达式,赋值时才算」,用编译期的嵌套类型换运行时的零临时、单趟遍历;代价是类型爆炸、编译变慢、auto 接表达式会悬垂。所以它用在 Eigen 这类数值内核里最划算,普通业务代码不必为此付出可读性代价。
353



被折叠的 条评论
为什么被折叠?



