先交代一下背景。前段时间团队里有人报了一个性能问题:一段看起来非常普通的向量计算代码,在 -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 自己并不知道 size() 和 operator[] 怎么实现,它只是把调用转发给模板参数 Derived。这个基类的价值有两点:第一,它给所有数组、表达式对象提供了一个统一的“概念接口”;第二,它让模板推导有了一个唯一的切入点——后面所有运算符重载都接收 const ExprBase<T>&,于是在编译期就能从基类引用还原出真正的派生类型。
2.2 二元表达式节点:内部只存引用不计算结果
表达式树内部的核心类型是二元表达式节点。它保存左操作数和右操作数的引用,提供一个 operator[],真正需要某个位置的值时才临时提取:
cpp复制template <typename Op, typename L, typename R>
class BinaryExpr : public ExprBase<BinaryExpr<Op, L, R>> {
public:
BinaryExpr(const L& lhs, const R& rhs)
: lhs_(lhs), rhs_(rhs) {}
size_t size() const {
size_t l = lhs_.size();
size_t r = rhs_.size();
return l > r ? l : r;
}
double operator[](size_t i) const {
return Op::apply(lhs_[i], rhs_[i]);
}
private:
const L& lhs_;
const R& rhs_;
};
这段代码的关键点在于:构造函数里没有对数组做任何运算,只是把引用保存下来。等别人调用 expr[i] 时,才让左右孩子分别把自己的 [i] 拿出来,经过 Op::apply 处理。如果你把这个结构想象成一棵树,那 size() 是从左子树拿到的,operator[] 则是递归地往左右孩子要数。
Op 是一个编译期的运算标签,比如加法就是:
cpp复制struct AddOp {
static double apply(double lhs, double rhs) { return lhs + rhs; }
};
以后想支持减法、乘法,就再写几个类似的空结构体,加几个 operator-、operator* 重载就行,表达式的构建逻辑完全复用。
2.3 重载加法运算符:返回的不是数,是结构
运算符重载是这套机制的门面。你希望 a + b 能被识别为一个表达式节点,那 operator+ 就不能返回 Vec,而要返回 BinaryExpr:
cpp复制template <typename L, typename R>
auto operator+(const ExprBase<L>& lhs, const ExprBase<R>& rhs)
-> BinaryExpr<AddOp, L, R> {
return BinaryExpr<AddOp, L, R>(
static_cast<const L&>(lhs),
static_cast<const R&>(rhs));
}
这里还需要额外处理一下“数组 + 标量”的情况。10.0 不是一个 ExprBase,所以需要针对 double 单独重载,里面把标量包装成 Scalar 节点:
cpp复制template <typename L>
auto operator+(const ExprBase<L>& lhs, double rhs)
-> BinaryExpr<AddOp, L, Scalar> {
return BinaryExpr<AddOp, L, Scalar>(
static_cast<const L&>(lhs), Scalar(rhs));
}
template <typename R>
auto operator+(double lhs, const ExprBase<R>& rhs)
-> BinaryExpr<AddOp, Scalar, R> {
return BinaryExpr<AddOp, Scalar, R>(
Scalar(lhs), static_cast<const R&>(rhs));
}
Scalar 是表达式树的叶子节点之一,它保存一个 double,size() 返回 0(表示它不决定表达式长度),operator[] 无论传什么下标都返回同一个值:
cpp复制class Scalar : public ExprBase<Scalar> {
public:
explicit Scalar(double value) : value_(value) {}
size_t size() const { return 0; }
double operator[](size_t) const { return value_; }
private:
double value_;
};
2.4 模板推导的细节:为什么基类引用能推导出派生类
有个细节值得单独强调:operator+ 的形参类型是 const ExprBase<L>&,但实际传入的往往是 Vec、BinaryExpr<...> 这些派生类对象。C++ 模板推导允许这种情况:当形参是 ExprBase<L> 的引用,实参是一个派生类对象时,编译器会在它的基类列表里寻找匹配的 ExprBase<L> 实例,从而推导出 L。
你可能会问,为什么不直接在运算符重载里写成 const L&,省得还要 static_cast?因为那样的话,operator+ 会变成一个对任意类型 T 都能匹配的重载,太宽泛了。用 ExprBase<L>& 作为形参,等于告诉编译器:我只接受那些从 ExprBase 派生出来的、符合表达式接口的类型。static_cast 的作用是把静态类型从基类还原成真正的派生类型,否则后面 BinaryExpr 构造函数拿到的是基类引用,无法正确保存派生类型信息。
到这一步,整个骨架已经立起来了:Vec 是叶子,Scalar 是标量叶子,BinaryExpr 是内部节点,运算符重载负责把树搭起来。
3. 一份可直接运行的实现:创建、组合、求值(含完整代码)
3.1 完整示例代码
下面是一份基于上面所有设计、能直接编译运行的完整代码。加法和标量混算都支持,Vec 可以从任意表达式对象构造和赋值。建议你复制到本地跑一遍,看看输出是不是预期中的全 16:
cpp复制#include <cstddef>
#include <iostream>
#include <type_traits>
#include <vector>
// ---------- 表达式基类 ----------
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];
}
};
// ---------- 数组叶子节点 ----------
class Vec : public ExprBase<Vec> {
public:
explicit Vec(size_t n) : data_(n) {}
Vec(std::initializer_list<double> list) : data_(list) {}
size_t size() const { return data_.size(); }
double operator[](size_t i) const { return data_[i]; }
double& operator[](size_t i) { return data_[i]; }
// 从任意表达式对象构造
template <typename Expr>
Vec(const Expr& e,
std::enable_if_t<!std::is_same_v<std::decay_t<Expr>, Vec>, int> = 0)
: data_(e.size()) {
for (size_t i = 0; i < size(); ++i) {
data_[i] = e[i];
}
}
// 从任意表达式对象赋值
template <typename Expr>
Vec& operator=(const Expr& e) {
data_.resize(e.size());
for (size_t i = 0; i < size(); ++i) {
data_[i] = e[i];
}
return *this;
}
private:
std::vector<double> data_;
};
// ---------- 标量叶子节点 ----------
class Scalar : public ExprBase<Scalar> {
public:
explicit Scalar(double value) : value_(value) {}
size_t size() const { return 0; }
double operator[](size_t) const { return value_; }
private:
double value_;
};
// ---------- 运算标签 ----------
struct AddOp {
static double apply(double lhs, double rhs) { return lhs + rhs; }
};
// ---------- 二元表达式节点 ----------
template <typename Op, typename L, typename R>
class BinaryExpr : public ExprBase<BinaryExpr<Op, L, R>> {
public:
BinaryExpr(const L& lhs, const R& rhs)
: lhs_(lhs), rhs_(rhs) {}
size_t size() const {
size_t l = lhs_.size();
size_t r = rhs_.size();
return l > r ? l : r;
}
double operator[](size_t i) const {
return Op::apply(lhs_[i], rhs_[i]);
}
private:
const L& lhs_;
const R& rhs_;
};
// ---------- 运算符重载 ----------
template <typename L, typename R>
auto operator+(const ExprBase<L>& lhs, const ExprBase<R>& rhs)
-> BinaryExpr<AddOp, L, R> {
return BinaryExpr<AddOp, L, R>(
static_cast<const L&>(lhs),
static_cast<const R&>(rhs));
}
template <typename L>
auto operator+(const ExprBase<L>& lhs, double rhs)
-> BinaryExpr<AddOp, L, Scalar> {
return BinaryExpr<AddOp, L, Scalar>(
static_cast<const L&>(lhs), Scalar(rhs));
}
template <typename R>
auto operator+(double lhs, const ExprBase<R>& rhs)
-> BinaryExpr<AddOp, Scalar, R> {
return BinaryExpr<AddOp, Scalar, R>(
Scalar(lhs), static_cast<const R&>(rhs));
}
// ---------- 测试 ----------
int main() {
Vec a = {1.0, 2.0, 3.0, 4.0, 5.0};
Vec b = {5.0, 4.0, 3.0, 2.0, 1.0};
Vec c = a + b + 10.0;
for (size_t i = 0; i < c.size(); ++i) {
std::cout << c[i] << (i + 1 == c.size() ? '\n' : ' ');
}
return 0;
}
3.2 逐行拆解 a + b + 10.0 的执行过程
a + b 这一步,编译器去匹配 operator+。因为 a 和 b 都是 Vec,Vec 继承自 ExprBase<Vec>,所以模板推导得到 L = Vec, R = Vec,返回一个临时 BinaryExpr<AddOp, Vec, Vec> 对象。这个临时对象内部,lhs_ 引用着 a,rhs_ 引用着 b。
接着这个临时对象与 10.0 相加,走的是 operator+(const ExprBase<L>&, double) 这条重载。L 被推导成 BinaryExpr<AddOp, Vec, Vec>,右侧的 10.0 被包装成 Scalar(10.0)。这一次返回的类型是:
cpp复制BinaryExpr<AddOp, BinaryExpr<AddOp, Vec, Vec>, Scalar>
最后 Vec c = ... 触发那个带 enable_if 的模板构造函数,Expr 被推导成上面那个嵌套的 BinaryExpr 类型。构造函数先调用 e.size(),这个 size() 会递归到左子树取 a 和 b 的公共长度;然后进入循环,逐个下标访问 e[i]。
访问 e[i] 时发生了什么?内层 BinaryExpr 的 operator[] 会调 Op::apply(lhs_[i], rhs_[i]),也就是取出 a[i] 和 b[i] 相加,然后外层 BinaryExpr 再把结果与 Scalar 的 10.0 相加。整个过程没有虚函数,没有中间数组,没有堆内存分配,只有纯粹的逐元素计算。
3.3 编译器做了什么才让它跟手写循环一样快
很多人第一次看完会好奇:这样的递归调用难道不慢吗?答案是,在足够高的优化级别下,它不会比手写循环慢,因为所有“递归”都是编译期的模板实例化,不是运行时的函数递归。
BinaryExpr<..., ...>::operator[] 是一个短的、内联友好的函数。当你写下 Vec c = a + b + 10.0; 时,编译器实例化出完整的表达式类型,随后在 Vec 构造函数循环里,data_[i] = e[i] 会被连环内联展开,最终生成的核心代码本质上等价于:
cpp复制for (size_t i = 0; i < n; ++i) {
c.data_[i] = a.data_[i] + b.data_[i] + 10.0;
}
这块展开发生在编译阶段,运行时看不见任何模板痕迹。所谓“表达式模板”,本质就是让模板在编译期生成一段针对当前表达式的定制代码,把运算符重载的运行时开销消于无形。
3.4 实测对比:我在三种编译配置下的性能结果
我在自己机器上拿 10^7 个元素的数组,每种方式各跑 10 次取平均,大致结果如下(平台不同差异很大,主要看相对趋势):
| 实现方式 | 编译选项 | 耗时(约) | 说明 |
|---|---|---|---|
| 朴素运算符重载 | -O2 |
420ms | 一次表达式多次全量遍历,多次分配 |
| 表达式模板 | -O2 |
190ms | 单次全量遍历,无中间分配 |
| 手写单层循环 | -O2 |
175ms | 理论上也接近内存带宽上限 |
手写循环的耗时本质上就是“读两遍数 + 写一遍结果”的最低访存成本。表达式模板能把开销压到这个量级,靠的就是把多步运算融合进同一次循环。朴素重载多出来的那部分,就是一次一次分配临时数组、来回搬运数据付出的代价。
如果你在代码里看到一段性能敏感代码用了朴素运算符重载,先别急着优化算法本身,看看是不是表达式模板就能把那个“三倍差距”直接消除。
4. 表达式模板的坑与边界:为什么它没有占领整个C++世界
4.1 auto陷阱与悬垂引用
表达式模板最大的坑,就是表达式对象内部存的是引用。这也就意味着,用一个 auto 去接收表达式结果是一件很危险的事。
cpp复制Vec a = /* ... */;
auto e = a + 10.0; // e 持有 a 的引用,没问题
// ... 之后再用 e 也是安全的,因为 a 还在作用域里
auto bad = a + make_vec(); // make_vec() 返回临时 Vec
// bad 内部持有了那个临时 Vec 的引用
// 临时对象在表达式结束后立刻析构,bad 变成悬垂引用
更经典的问题是把表达式返回出去:
cpp复制auto build_expr(const Vec& a, const Vec& b) {
return a + b; // 返回的表达式对象引用着 a、b
} // 如果 a、b 在函数外部析构,再使用这个表达式就是UB
Eigen 的文档里反反复复强调不要用 auto 保存表达式,原因就在这里:表达式模板是延迟求值的,而延迟求值的前提是操作数活得够久。一旦操作数是临时对象,延迟就变成了悬垂。解决方案也很简单:要么直接用最终类型接收表达式结果,要么确保表达式对象生命周期内操作数一定存活。
4.2 编译时间与二进制体积的代价
表达式模板把大量计算搬到了编译期,代价就是编译时间长到让人抓狂。一个稍复杂的表达式会实例化出多层嵌套模板类型,每一层都要生成对应的 operator[]、size() 等代码。你在源码里看到的 v = a * b + c * d - e; 只有一行,但背后对应的类型可能是:
cpp复制BinaryExpr<
SubOp,
BinaryExpr<AddOp,
BinaryExpr<MulOp, Vec, Vec>,
BinaryExpr<MulOp, Vec, Vec>>,
Vec>
编译器需要为整个类型树生成代码,并且在优化阶段把这些代码融化成一段高效的循环。表达式越长,类型嵌套越深,模板实例化数量越多,编译时间和内存消耗都会明显上升。如果你尝试过把很长的表达式写进一个模板深深的代码库里,你应该体会过那种“编译一次够我喝杯咖啡”的感觉。
二进制体积也有类似问题:每出现一种新的表达式组合,就生成一份新的特化代码。十个不同形状的表达式可能编译出十份看起来很相似的循环。现代编译器有各种去重手段,但也没法完全消除。
4.3 调试时的一屏模板报错
“C++八股文”里经常调侃模板报错信息长得像天书。表达式模板把这个问题放大了。当某个表达式类型不匹配或者某个 operator[] 传参出错时,编译器输出的错误信息可能从 ExprBase 一路列到 BinaryExpr 的模板嵌套层,几百行起步。哪怕对模板很熟的人,也得花点耐心一行行往下翻,找到真正的错误来源。
这一点在实际工程里是很现实的门槛。写入一个数值库核心的人必须能驾驭这种报错,但项目里的普通业务代码成员未必有这个心理准备。C++20 之后的 Concepts 能一定程度改善这个问题,给模板加约束后,编译器能报出“这里需要符合可索引要求的类型,但你传了不对的类型”这样更清晰的提示,但表达式模板库里 Concepts 的应用还没有普及到能大幅降低门槛的程度。
4.4 一个通常被忽略的问题:缓存局部性与并行化
表达式模板把多个操作融合进一个循环里,减少了数组遍历次数。这听起来全是好处,但数值计算里有个更复杂的维度:分块与缓存局部性。
拿矩阵乘法举例。理论上只要把 C = A * B 里的元素逐个算出来,用表达式模板也能写成一次循环遍历,但真正的性能瓶颈在于如何把矩阵切成小块,使得每次访问的数据尽量落在 CPU 的 L1/L2 缓存里。朴素的一层层展开反而会造成反复缓存未命中。所以你在 Eigen 内部看不到简单的“一个循环遍历所有元素”,而是大量关于分块大小、数据对齐、SIMD 指令选择的专门处理。表达式的融合只是性能优化的一环,真正的数值库还需要在表达式树上做更高层的变换。
并行化也存在冲突。表达式模板的单核串行循环写起来很自然,但一旦考虑多线程,比如把数组分成几段让不同线程分别求值,就需要小心处理每个线程的临时结果和对共享缓存的争抢。如果在表达式树里加上线程调度逻辑,代码复杂度会急剧上升;如果不加,潜在并行能力又被牺牲了。这也是很多工业界数值内核宁可手写长循环、配置 OpenMP 或 SIMD,也不轻易上表达式模板的另一个原因。
5. 现代C++视角:还值得手写表达式模板吗?
5.1 编译器优化后朴素代码还有多大差距
很多人会问:现代编译器的优化能力已经很强,朴素运算符重载在某些情况下是不是也能被优化到一样快?这个问题得分场景。
对于非常简单的表达式,比如 a + b、a * 2.0,如果数组大小在编译期已知,且整个计算都在一个函数里,编译器确实可以把临时数组优化掉,生成和手写循环几乎一样的机器码。但这依赖多个条件同时成立:函数能内联、数组生命周期清晰、编译器能证明临时变量不逃逸。
一旦表达式变长、跨越多个函数边界、或者数组是运行时动态分配,编译器就无能为力了。它不可能在两个编译单元之间自动完成跨函数的循环融合。所以对于库代码、算法核心这类场景,表达式模板依然是可靠的选择。
5.2 C++20时代的替代方案速览
C++20 之后,标准库给了一种很好的“懒视图”工具:std::ranges::views。比如想表达一个对数组逐元素加 1 的视图:
cpp复制auto view = a | std::views::transform([](double x) { return x + 1.0; });
这里的 view 不会立刻计算结果,它也是延迟求值的,内部保存了一个可调用的函数对象和底层数据的引用。这个思路和表达式模板很像,理论上可以用组合 views 的方式实现很多流式计算。
但实际用下来,views 的组合能力还是不如表达式模板自然。表达式模板可以写出 a + b * c - d 这种和普通数学书写几乎一致的代码,views 则必须显式地把每个操作写成变换。更重要的是,views 是“通用算法库”视角,不会针对数组计算做循环融合、SIMD 优化和分块调度。当性能目标很高时,标准库的动态多态、类型擦除层还是有不少额外开销。
所以我的观察是:通用代码里优先用 views,又短又安全;要在数值计算里追求极限性能,表达式模板仍然无法被替代。
5.3 现实中仍在重度依赖表达式模板的领域
目前最依赖表达式模板的,还是那几个高性能数值计算库:Eigen、Blaze、Boost.uBLAS。它们把表达式模板的功夫做到了很深的层次,普通使用者其实不需要自己手写表达式模板,理解原理、学会正确使用就够了。
另一个隐藏应用是自动微分。很多自动微分库用表达式模板在编译期构建计算图,把每个函数的求导规则作为“运算标签”嵌入表达式树,从而在运行时一次性完成前向或反向求导。这种做法在编译器、物理仿真、机器学习编译器等领域都能见到。
对绝大多数普通业务开发者来说,手写表达式模板的机会可能不多,但如果你要写一个小型数值库、DSL、或者任何“让用户代码写得像数学公式”的接口,理解这套机制是绕不开的。它让我有点想起模板元编程的很多技巧:编译期能做的就不留给运行时,类型系统能表达的结构就不需要运行时对象。说得玄一点,这是一次让类型系统替你干活的思想实验。
最后分享一点我自己的体会。第一次手写表达式模板时,我凑了一晚上才把那段能跑的代码弄明白,期间被 auto 悬垂引用坑过一次,被模板推导失败折磨过好几次,调试了半天发现是 static_cast 写漏了。但那次之后,我对 C++ 模板的认知上了一个大台阶。建议想练手的朋友,就从本文这份 Vec 加法开始,自己加上乘法、减法,再试试 Vec 支持点积、以及混合标量运算,跑通之后你再看 Eigen 的源码,会发现它的很多设计思路你已经有能力猜个大概了。
