函数模板与类模板基础¶
难度:⭐⭐⭐~⭐⭐⭐⭐ | 建议用时:2 周 | 前置要求:类型系统与值类别推导-Eigen基础与SLAM数学预备,尤其是函数、类、引用、移动语义、Eigen 基础
本章目标¶
学完本章后,你应该能做到:
- 把模板理解为编译期接口,而不是“少写重复代码”的语法技巧。
- 用 P/A 推导规则解释
T、T&、const T&、T&&、数组、函数名和const的推导结果。 - 判断哪些隐式转换参与函数调用,哪些转换不参与模板参数推导。
- 按候选函数、可行函数、最佳匹配三步分析普通函数与函数模板的重载决议。
- 设计简单类模板、成员函数模板和 CTAD 推导指引,并理解每个类模板实例的对象身份。
- 理解
typename、template与 two-phase lookup 在依赖上下文中的真实错误路径。 - 使用非类型模板参数表达维度、容量和策略边界,并知道什么时候该保留运行期配置。
- 解释模板定义可见性、ODR、显式实例化和 PCL
.h/.hpp/.cpp组织模式。 - 按“变化发生在编译期还是运行期”选择模板、虚函数或类型擦除。
- 为一个真实的点云注册/距离/体素滤波小工具设计可测试的模板接口。
前置自测¶
答不出两题以上,建议先回到 类型系统与值类别推导 的类型推导、RAII与智能指针 的函数重载、移动语义与完美转发 的引用和值类别、Eigen基础与SLAM数学预备 的 Eigen 编译期维度。模板错误之所以难读,是因为它们把“接口检查”提前到了编译期:编译器不是在运行算法,而是在为某组类型生成算法。
auto x = expr与模板参数T value的推导规则有什么共同点?template <typename T> void f(T);、void f(T&)、void f(const T&)、void f(T&&)面对const int左值时分别如何推导?add(1, 2.0)为什么不能把T推导成double?隐式转换为什么没有帮忙?std::vector<int>和std::vector<double>之间有没有继承或子类型关系?- 模板成员函数为什么不能是
virtual? - 为什么很多模板实现必须放在头文件或
.hpp中? - 在模板里写
m.block<3,3>(0,0)为什么有时需要m.template block<3,3>(0,0)?
本章在课程中的位置¶
Eigen基础与SLAM数学预备 讲了 Eigen。
Eigen 的核心类型本身就是模板:
如果不理解模板,你只能“照抄”这些类型。
理解模板后,你会看到它们表达的真实信息:
机器人 C++ 代码中,模板不是炫技,也不是主要为了少打几个函数。它解决的是一个更根本的问题:
例如:
| 算法 | 变化维度 |
|---|---|
| 点云滤波 | PointXYZ、PointXYZI、自定义点类型 |
| 残差计算 | double、Ceres 自动微分标量 |
| 因子图 | Pose2、Pose3、Rot3 |
| 小矩阵运算 | 3 维、6 维、15 维 |
| KD-Tree | 2D 点、3D 点、高维描述子 |
没有模板,你会写出大量重复代码。
有模板但设计不好,你会得到难读的错误信息和缓慢的编译。
本章目标是先掌握基础模板机制,为 模板特化SFINAE与类型萃取-Concepts与Policy 的 SFINAE、traits、CRTP、宏和 Concepts 打地基。
本质洞察:模板的价值不是“少写几遍代码”,而是把一类变化从运行时对象提升到编译期接口。编译期接口能带来维度检查、内联优化和类型适配,但也会把复杂度转移到编译模型、实例化边界和错误诊断上。写模板前先问:这个变化是否稳定到足以成为类型系统的一部分?
知识树¶
函数模板与类模板基础
├── 为什么需要模板(12.1)⭐⭐⭐
│ ├── 重复函数 → 类型参数化
│ ├── 模板 vs 宏 ← 类型安全
│ └── 实例化 = 编译器生成代码
├── 函数模板参数推导(12.2)⭐⭐⭐⭐
│ ├── P/A 模式匹配规则
│ ├── T / T& / const T& / T&& 四种形参
│ ├── 数组退化与函数名退化
│ └── 转换不参与推导 ← Ceres 标量陷阱
├── 显式模板参数与重载(12.3)⭐⭐⭐
│ ├── 返回类型无法推导 → 显式指定
│ └── 候选→可行→最佳 三步重载决议
├── 类模板(12.4)⭐⭐⭐⭐
│ ├── 每种参数组合 = 独立类型
│ ├── 编译期多态 vs 运行时多态 vs 类型擦除
│ └── GTSAM/PCL 中的类模板
├── 成员函数模板(12.5)⭐⭐⭐
│ ├── 跨标量转换 ← Eigen .cast<T>()
│ └── 不能是 virtual ← vtable 大小固定
├── CTAD(12.6)⭐⭐⭐
│ ├── C++17 类模板参数推导
│ └── 推导指引 ← 构造参数→模板参数
├── 依赖名称与两阶段查找(12.7)⭐⭐⭐⭐
│ ├── typename ← 依赖类型消歧
│ ├── template ← 依赖成员模板消歧
│ └── two-phase lookup 机制
├── 非类型模板参数(12.8)⭐⭐⭐⭐
│ ├── 维度作为类型身份 ← Eigen Matrix
│ └── 编译期维度 vs 运行期维度
├── 模板编译模型(12.9)⭐⭐⭐⭐
│ ├── 包含模型 vs 显式实例化
│ ├── extern template
│ └── PCL .h/.hpp/.cpp 组织
└── 接口设计(12.10)⭐⭐⭐⭐
├── 模板 = 编译期变化 → 点类型、标量
├── 虚函数 = 运行期变化 → 算法选择
└── 类型擦除 = 隐藏类型 → 回调、任务队列
上面是本章的知识树。树根是"同一逻辑如何适配不同类型",树干是函数模板和类模板的实例化机制,树枝是推导规则、编译模型和接口设计。每个节点之间标注了递进关系:从函数模板(12.1-12.3)到类模板(12.4-12.6),再到编译模型(12.7-12.9),最后到架构选型(12.10)。读完本章后,你脑中应该有这棵完整的树,而不是零散的模板语法记忆。
12.1 为什么需要模板:从重复函数到类型参数 ⭐⭐⭐¶
这一节解决什么问题:为什么不能为每种类型各写一份函数?模板的核心价值不是"少打几个字",而是把类型从"硬编码在函数签名中"提升为"函数的参数"。
动机:同一逻辑重复在不同类型上¶
模板的核心思想可以类比到文档模板(Word 模板)。一个简历模板定义了布局和结构——标题在哪里、正文用什么字体、页边距多宽。不同的人用同一个模板填入不同的内容,得到格式一致但内容不同的简历。C++ 模板也是类似的:函数模板定义了算法结构(比较、加法、距离计算),不同的类型(int、double、Jet<double,N>)"填入"这个模板,编译器为每种类型生成格式一致但操作不同的机器码。两者的关键区别在于:文档模板在"使用时"产生结果,C++ 模板在"编译时"产生结果——这意味着模板的类型错误会在编译期暴露,而不是等到运行时。
在 SLAM 和机器人控制的代码中,同一个数学公式经常需要对多种类型工作。距离函数要同时处理 double 和 Ceres 的自动微分标量 Jet<double, N>,点云质心要同时处理 PointXYZ 和 PointXYZI,Kalman 滤波器要同时支持 15 维和 18 维状态。如果为每种类型各写一份函数,代码量会迅速膨胀,而且修改数学公式时要同步修改所有副本——漏改任何一份都会导致行为不一致的 bug。
如果 C++ 没有模板,PCL 的每种点类型(PointXYZ、PointXYZI、PointXYZRGBA 等二十多种)都要复制一份完整的体素滤波算法。Eigen 的每种标量-维度组合(Matrix3d、Matrix3f、MatrixXd 等)都要各写一份矩阵乘法。Ceres 的自动微分根本无法实现——残差函数必须同时对 double 和 Jet 有效,没有模板就意味着每个残差写两份。模板不是"少写重复代码"的语法糖,它是让整个机器人 C++ 库生态得以存在的基础设施。
先看一个普通函数:
如果需要 double:
如果需要 float:
三段代码的算法完全一样。
不同的只有类型。
模板把类型变成参数:
调用:
编译器会根据实参推导 T。
模板不是宏¶
宏也能做类似事情:
但宏有几个问题:
- 没有类型检查。
- 参数可能被多次求值。
- 错误信息指向展开后代码。
- 作用域控制弱。
模板是 C++ 类型系统的一部分。
它仍然会检查语法和类型。
例如:
如果 NotComparable 没有 operator<,编译器会报错。
这说明模板函数不是“任意类型都能用”。
它隐含要求 T 支持 <。
Concepts与Policy 会用 Concepts 把这种要求写到接口上。
本章先理解模板基础机制。
模板实例化¶
如果编译器不做实例化会怎样? 一种替代设计是让模板在运行时根据类型分派——这正是 Java 泛型的做法(类型擦除)。Java 的 ArrayList<Integer> 和 ArrayList<String> 在运行时是同一个类,类型参数在编译后被擦除。代价是:运行时无法为特定类型做优化(不能为 int 生成避免装箱的特化代码),也不能把类型信息用于编译期检查(不能拒绝 ArrayList<int> 因为基本类型不能做类型参数)。C++ 选择了相反的方向——为每种类型生成独立的机器码。代价是编译时间和二进制体积,收益是零运行时开销和完整的编译期类型检查。
模板本身不是函数。
它是生成函数的模式。
当你写:
编译器会生成类似两份函数:
这叫实例化。
读模板错误时要记住:
错误通常发生在实例化某个具体类型时。
模板实例化的编译模型:为什么模板定义必须放在头文件¶
初学者经常困惑:普通函数可以声明在 .h、定义在 .cpp,为什么模板函数通常要把定义也放在头文件中?这个问题触及 C++ 编译模型的核心。
分离编译模型:C++ 采用分离编译——每个 .cpp 文件独立编译成一个 .o 目标文件(翻译单元),最后由链接器把它们组合成可执行文件。编译器处理 a.cpp 时看不到 b.cpp 的内容,只能通过 #include 的头文件获取声明。对普通函数来说,编译器只需要声明就能生成调用代码(call 指令),函数体的机器码由定义所在的翻译单元提供,链接器负责把调用点连接到正确的地址。
模板打破了这个模型。模板函数不是函数——它是生成函数的"蓝图"。当编译器在 a.cpp 中看到 maxValue(1, 2) 时,它需要**为 T = int 生成一份完整的函数机器码**。这意味着编译器必须看到 maxValue 的完整定义(函数体),而不仅仅是声明。如果函数体在 b.cpp 中,编译 a.cpp 时编译器看不到它,就无法实例化 maxValue<int>。
这就是为什么模板定义通常放在头文件中——每个使用模板的翻译单元都需要看到完整定义,才能各自实例化所需的版本。这种做法称为"包含模型"(inclusion model)。
包含模型的代价:每个翻译单元都独立实例化模板,可能导致同一个模板实例(如 maxValue<int>)在多个 .o 文件中各有一份。链接器会通过"弱符号"(weak symbol)或 COMDAT 折叠去重,但编译阶段的重复工作是实实在在的。对于大型模板库(如 Eigen),这会显著增加编译时间。
显式实例化:如果你确切知道模板会被哪些类型实例化,可以在 .cpp 中做显式实例化:
// in max_value.h
template <typename T> T maxValue(T a, T b); // 只有声明
// in max_value.cpp
template <typename T> T maxValue(T a, T b) { return a < b ? b : a; } // 定义
template int maxValue<int>(int, int); // 显式实例化 int 版本
template double maxValue<double>(double, double); // 显式实例化 double 版本
这样 maxValue 的函数体只编译一次,其他翻译单元通过链接找到这些预先实例化的版本。代价是你必须预先列举所有需要的类型——如果有人用 maxValue(1.0f, 2.0f) 但你没有显式实例化 float 版本,链接器会报"未定义符号"。
extern template:C++11 引入了 extern template,用于告诉编译器"这个模板实例化已经在其他翻译单元中完成,不要在这里重复实例化"。它是编译加速的工具,不改变语义:
PCL 库大量使用 extern template 和显式实例化来控制编译时间。PCL 头文件通常只包含模板声明,.cpp 文件中为常用点类型(PointXYZ、PointXYZI、PointXYZRGBA)做显式实例化。这也解释了为什么 PCL 项目中经常看到 .h、.hpp、.cpp 三层文件组织:.h 放声明,.hpp 放模板定义(需要自定义点类型时 #include),.cpp 放显式实例化。
| 模型 | 定义位置 | 优点 | 缺点 |
|---|---|---|---|
| 包含模型 | 头文件 | 任何类型都能实例化 | 编译慢、头文件膨胀 |
| 显式实例化 | .cpp |
编译快、代码隐藏 | 必须预先列举所有类型 |
extern template |
头文件 + .cpp |
减少重复实例化 | 需要两处配合声明 |
机器人例子:距离函数¶
理解了模板的基本语法后,来看一个来自真实机器人场景的例子。在 SLAM 系统中,距离计算是最高频的操作之一——ICP 配准的最近邻搜索、体素滤波的空间分格、回环检测的描述子匹配都依赖距离函数。如果距离函数只支持 double 标量,那么 Ceres 的自动微分——它需要把标量从 double 换成 Jet<double, N> 来自动计算导数——就无法使用同一份距离计算代码。模板让同一个距离函数同时服务于数值求值和自动微分。
点云算法常需要距离:
double squaredDistance(const Eigen::Vector3d& a,
const Eigen::Vector3d& b) {
return (a - b).squaredNorm();
}
如果 Ceres 自动微分需要 T 标量:
template <typename T>
T squaredDistance(const Eigen::Matrix<T, 3, 1>& a,
const Eigen::Matrix<T, 3, 1>& b) {
return (a - b).squaredNorm();
}
同一函数现在支持:
也支持自动微分标量。
前提是 T 支持 Eigen 所需的加减乘除。
常见陷阱¶
⚠️ 概念陷阱:以为模板能接受任何类型
模板只是把类型参数化。函数体里用到了
<、+、.norm(),类型就必须支持这些操作。⚠️ 宏替代陷阱:用宏做类型泛型
泛型算法优先用模板。宏适合 预处理器与宏 中的预处理阶段能力,不适合普通类型抽象。
⚠️ 实例化陷阱:只看模板定义,不看出错的具体类型
模板错误要同时看模板定义和实例化点。很多错误不是模板本身错,而是某个类型不满足隐含要求。
练习¶
- 改写题:把
maxInt()、maxDouble()改成函数模板。 - 约束题:给
maxValue()传入没有<的类型,观察错误信息。 - 机器人题:写一个
squaredNorm()模板函数,支持Eigen::Matrix<T, 3, 1>。
12.2 函数模板参数推导 ⭐⭐⭐⭐¶
模板参数推导是 C++ 编译器最复杂的子系统之一,也是模板编程中最多"看不懂编译器在干什么"困惑的来源。理解推导规则的关键不是死记硬背表格,而是掌握一个核心原则:编译器在推导阶段只做模式匹配,不做类型转换。它把形参类型中的模板参数视为"未知数",把实参类型视为"已知数",通过模式匹配解出未知数。如果同一个模板参数被两个实参推导出不同的值(如 T = int 和 T = double),推导直接失败——编译器不会替你决定"应该用哪个"。
这条规则对机器人代码有直接影响。Ceres 残差函数中,如果你把 Eigen::Vector3d(T = double)和 Eigen::Matrix<Jet<double,6>, 3, 1>(T = Jet<double,6>)混传给同一个模板函数,编译器不会帮你做标量类型统一——你必须显式地用 .cast<T>() 把常量和测量值转换到正确的标量类型。
P/A 推导模型¶
本质洞察:模板参数推导的本质不是"编译器帮你选类型",而是"编译器用实参的类型去匹配形参中的未知数"。推导阶段是一个纯粹的方程求解过程——给定
P(T) = A,解出T。它不会"帮忙"做类型转换,就像代数方程求解不会"帮忙"近似数值一样。理解了这个本质,所有推导规则都变成了同一原理的不同实例。
模板参数推导可以类比到正则表达式匹配。正则表达式 (\d+)-(\w+) 面对字符串 "42-hello" 时,它不会把 "42" 先转成别的格式再匹配——它只做精确的模式匹配,提取出捕获组的值。C++ 模板推导也是如此:编译器把形参类型中的 T 当作"捕获组",把实参类型当作"待匹配的字符串",通过模式匹配解出 T 的值。如果同一个 T 被两个实参匹配出不同的值,推导直接失败——就像正则表达式要求同一个反向引用 \1 必须匹配相同的子串。
模板参数推导看起来很"自动",但它遵循精确的规则。理解这些规则的关键不是死记每种情况的结果,而是抓住一个核心思路:编译器在推导阶段只做模式匹配,不做类型转换。它把形参类型中的模板参数视为"未知数",把实参类型视为"已知数",通过模式匹配解出未知数的值。这和正则表达式匹配字符串的思路完全一致——正则只做匹配,不做字符替换。
函数模板参数推导可以用 P/A 模型理解:
例如:
对第一个参数,P = T,A = int,推出 T = int。对第二个参数也推出 T = int,推导一致,所以实例化 add<int>。
换成:
第一个参数推出 T = int,第二个参数推出 T = double。推导阶段不会先把 1 转成 double 再统一成 T = double,因为模板参数推导的目标是从实参类型中解出模板参数,而不是先做完整重载转换。需要调用者显式指定:
此时 T 已知为 double,普通函数调用阶段才允许把 1 转成 double。
也可以把接口改成两个模板参数:
这会分别推导 A = int、B = double,返回类型再由 a + b 决定。接口选择取决于你想表达什么不变量:add(T,T) 表达“两个输入同类型”,addMixed(A,B) 表达“两个输入可相加即可”。
T、T&、const T&、T&&¶
P/A 推导的结果会因为形参的写法不同而不同——T(按值)、T&(左值引用)、const T&(常量引用)、T&&(转发引用)四种形式各有规则。理解它们的关键是意识到每种形式表达了不同的"对实参的要求":按值说"我需要一份副本,原件是否 const 与我无关",左值引用说"我要绑定你的原件,你必须是左值",常量引用说"我只读取,什么都接受",转发引用说"你是什么我就是什么——完美转发"。
不同形参模式会改变推导。先准备一个验证工具:
#include <type_traits>
template <typename T, typename U>
constexpr bool same = std::is_same_v<T, U>;
按值参数:
template <typename T>
void byValue(T value) {
static_assert(same<T, int>);
}
const int x = 42;
byValue(x);
T value 会去掉引用和值类别,并丢掉顶层 const。原因和 类型系统与值类别推导 的 auto x = expr 一样:按值参数会构造一个新对象,原对象本身是否 const 不限制副本。
左值引用参数 T&:
template <typename T>
void byRef(T& value);
int a = 1;
const int ca = 2;
byRef(a); // T = int,参数类型 int&
byRef(ca); // T = const int,参数类型 const int&
T& 的重点是“必须绑定左值”,不是“必然绑定非常量对象”。如果传入 const int 左值,const 会进入 T 本身,因此参数类型变成 const int&。但 T& 不能绑定右值:
const T& 能绑定左值和右值:
template <typename T>
void byConstRef(const T& value);
byConstRef(a); // T = int,参数类型 const int&
byConstRef(ca); // T = int,参数类型 const int&
byConstRef(3); // T = int,参数类型 const int&
注意 const T& 中的 const 已经写在形参模式里,所以 T 通常不再推导出顶层 const。
转发引用:
template <typename T>
void byForward(T&& value);
int i = 0;
const int ci = 0;
byForward(i); // T = int&,参数类型 int&
byForward(ci); // T = const int&,参数类型 const int&
byForward(0); // T = int,参数类型 int&&
只有当形参写成“待推导的 T&&”时,它才是转发引用。若类型已经确定,例如 std::vector<int>&&,那就是普通右值引用。
汇总如下:
| 形参模式 | 实参 | T 推导 |
形参最终类型 |
|---|---|---|---|
T |
const int 左值 |
int |
int |
T& |
int 左值 |
int |
int& |
T& |
const int 左值 |
const int |
const int& |
const T& |
const int 左值 |
int |
const int& |
T&& |
int 左值 |
int& |
int& |
T&& |
int 右值 |
int |
int&& |
可以用编译期断言验证:
template <typename T>
void verifyForward(T&& value) {
(void)value;
}
static_assert(std::is_same_v<decltype(42), int>);
函数体内验证 T 时常用辅助类型打印或 static_assert 分支;本章重点是能在调用点推导出结果。
数组、函数名与退化¶
C++ 从 C 继承了一个看似方便但经常导致信息丢失的规则:数组在按值传递时"退化"为指针。这意味着函数内部无法知道数组的长度——int data[3] 传入后变成了 int*,长度信息被丢弃。理解退化规则对模板编程很重要,因为它直接影响 T 被推导成什么类型。
按值传参时,数组会退化成指针:
引用参数保留数组类型和长度:
template <typename T, std::size_t N>
void printArraySize(const T (&array)[N]) {
(void)array;
std::cout << N << "\n";
}
int data[3]{};
printArraySize(data); // N = 3
函数名也类似。按值传参会退化成函数指针,引用传参可以保留函数类型:
int score(double);
template <typename T>
void takeByValue(T value);
template <typename T>
void takeByRef(T& value);
takeByValue(score); // T = int (*)(double)
takeByRef(score); // T = int(double)
这解释了为什么模板工具函数如果要保留数组维度或函数签名,必须使用引用形参。
转换不参与推导,但可参与调用¶
这是模板参数推导中最容易踩到的坑之一——初学者会直觉地认为 add(1, 2.0) 应该工作(毕竟 int 可以隐式转换成 double),但编译器拒绝了。原因是模板推导阶段和普通函数调用阶段遵循不同的规则:推导阶段只做模式匹配、不做类型转换,调用阶段才允许隐式转换。理解这个”两阶段”的区别,是避免 Ceres 残差中标量类型不匹配错误的关键。
模板推导阶段一般不使用普通隐式转换来”猜模板参数”。例如:
template <typename T>
void takeSame(T a, T b);
takeSame(1, 2); // T = int
// takeSame(1, 2.0); // 推导冲突
但如果显式给出模板参数,推导阶段结束,普通调用转换可以发生:
这条规则对机器人代码很重要。Ceres 残差里常见:
template <typename T>
Eigen::Matrix<T, 3, 1> transform(
const Eigen::Quaternion<T>& q,
const Eigen::Matrix<T, 3, 1>& t,
const Eigen::Matrix<T, 3, 1>& p);
如果你把 Eigen::Vector3d 和 Eigen::Matrix<ceres::Jet<double,N>,3,1> 混传,模板推导不会自动替你统一标量。常量和测量值应显式 .cast<T>():
Eigen 中的推导问题¶
Eigen 是模板参数推导在机器人代码中最重要的应用场景。Eigen 的所有操作(加法、乘法、转置、block 提取等)都返回"表达式模板"而非具体矩阵——这意味着 a + b 的返回类型不是 Eigen::Vector3d,而是一个复杂的表达式类型 CwiseBinaryOp<...>。如果你的模板函数参数写成具体的 Eigen::Matrix<T, Rows, Cols>,它就无法接受表达式;但如果写成 Eigen::MatrixBase<Derived>,它能接受所有 Eigen 表达式类型。
考虑:
调用:
这里 Derived 会推导成具体 Eigen 类型。更重要的是,它也能接住表达式:
如果接口写成:
它只适合具体 Matrix,不适合大量 Eigen 表达式。MatrixBase<Derived> 是 Eigen 泛型接口的基础模式。但要记住:Derived 可能是表达式类型,函数不能把它长期保存为引用成员,否则可能悬垂。
返回类型推导¶
模板函数的返回类型也可以参与推导。在 C++11 中,返回类型必须显式指定或使用尾返回类型 -> decltype(expr) 来推导。C++14 简化了这个过程——返回类型可以直接写 auto,让编译器从 return 语句的表达式类型推导。这在简单工具函数中非常方便,但在数值计算的公共接口中要谨慎使用——Eigen 的表达式模板会让 auto 返回一个临时表达式对象而非具体矩阵值,如果表达式引用了局部变量,就会产生悬垂引用。
C++14 起可以写:
返回类型由表达式推导。这适合局部工具,但公共数值接口要谨慎。对 Eigen,返回 auto 可能返回表达式而不是值;如果表达式引用局部变量,就会产生生命周期错误。更稳的公共接口常写具体返回类型:
template <typename Scalar>
Eigen::Matrix<Scalar, 3, 1> residual3(
const Eigen::Matrix<Scalar, 3, 1>& predicted,
const Eigen::Matrix<Scalar, 3, 1>& measured) {
return predicted - measured;
}
常见陷阱¶
⚠️ 混合类型陷阱:
T无法同时是int和double单模板参数要求相关实参推导一致。需要混合类型时,设计多个模板参数或显式指定模板参数。
⚠️ 转换陷阱:以为推导阶段会自动做隐式转换
add(1, 2.0)不会先把1转成double再推导。显式指定add<double>后,普通调用转换才会发生。⚠️ 退化陷阱:数组按值传递变成指针
想保留数组长度时,用引用形式
T (&array)[N]。⚠️ Eigen 陷阱:函数只接受具体 Matrix,拒绝表达式
泛型 Eigen 参数常用
MatrixBase<Derived>或Ref,但不要长期保存表达式引用。
练习¶
- 推导题:判断
add(1,2)、add<double>(1,2)、addMixed(1,2.0)的模板参数和返回类型。 - 验证题:用
std::is_same_v验证T、T&、const T&、T&&面对const int左值和右值时的推导结果。 - 数组题:写一个模板函数打印 C 数组长度,并解释为什么按值参数拿不到长度。
- Eigen 题:写
normOf(MatrixBase<Derived>),测试它能否接受a + b。
12.3 显式模板参数、重载与接口清晰度 ⭐⭐⭐¶
模板参数推导虽然方便,但并非总是可行。当模板参数只出现在返回类型中、或者需要指定的类型与实参类型不同时,必须显式指定模板参数。此外,当函数模板和普通函数同时存在时,重载决议规则会在模板函数和非模板函数之间做出选择——理解这个选择规则对设计清晰的泛型接口至关重要。
显式模板参数和函数模板重载的交互,是 C++ 模板系统中容易产生困惑的区域。核心规则很简单:在同等匹配质量下,非模板函数优先于模板函数。这条规则的设计动机是让程序员能够为特定类型提供"手工优化"版本,覆盖泛型模板的默认行为。
什么时候需要显式指定¶
显式指定模板参数:
常见原因:
| 场景 | 示例 | 原因 |
|---|---|---|
| 推导冲突 | add<double>(1, 2.5) |
两个实参类型不同 |
| 返回类型无法从参数推导 | makeZero<Vector3d>() |
模板参数只出现在返回类型 |
| 需要控制标量 | point.cast<float>() |
精度选择 |
| 调用模板成员函数 | m.template block<3,3>(0,0) |
依赖上下文消歧 |
显式模板参数不是失败后的万能补丁。它的合理用途是:调用点掌握了编译期类型意图,但这些意图无法从函数实参完整推导出来。常见于返回类型工厂、标量控制、Eigen .cast<T>()、Ceres 自动微分残差中的常量转换。
模板参数只在返回类型中¶
调用时无法从参数推导 T。
必须写:
这类函数适合工厂或类型构造。
但如果滥用,会让调用点变啰嗦。
函数模板与普通重载¶
可以同时写:
void logValue(double value) {
std::cout << "double: " << value << "\n";
}
template <typename T>
void logValue(const T& value) {
std::cout << "generic: " << value << "\n";
}
分析重载时,不要只问“哪个看起来更像”。C++ 重载决议大致分三步:
- 候选函数:名字查找找到的所有同名函数和函数模板实例候选。
- 可行函数:实参数量匹配,且每个实参都能转换到形参类型。
- 最佳匹配:比较转换序列成本、模板/非模板偏序等规则,选出最优。
例如:
候选有普通函数 void logValue(double) 和模板实例 void logValue<double>(const double&)。普通函数是精确匹配,模板也是可行。通常非模板函数在同等匹配质量下优先,因此会调用普通函数。
但这个例子:
普通函数需要 int -> double 转换,模板可以实例化为 logValue<int>(const int&),后者匹配更好,所以可能调用模板版本。
这种组合适合:
- 常见类型走专门路径。
- 其他类型走通用路径。
模板特化SFINAE与类型萃取 会进一步讲特化和 SFINAE。
本章先记住:模板也参与重载解析。
普通函数、模板与转换成本¶
下面的例子展示“普通函数不总是赢”:
void process(double value) {
std::cout << "double\n";
}
template <typename T>
void process(T value) {
std::cout << "template\n";
}
process(1.0); // double
process(1); // template,避免 int 到 double 的转换
如果你想让所有算术类型都走同一条路径,不要靠“普通函数应该优先”的直觉,而要设计明确接口。基础阶段可以用重载区分,后续章节会用 Concepts 或 traits 收敛约束。
另一个常见场景是字符串字面量:
字符串字面量类型是 const char[6]。模板可以直接推导数组引用或指针形式,普通 std::string 重载需要自定义转换。结果可能不是你以为的 std::string 版本。公共接口中,如果某个重载承担语义边界,应写得更具体,或者删除过宽泛的模板。
显式模板参数何时必要¶
必要的典型场景:
template <typename MatrixType>
MatrixType makeIdentity() {
return MatrixType::Identity();
}
Eigen::Matrix3d R = makeIdentity<Eigen::Matrix3d>();
MatrixType 只出现在返回类型中,无法从参数推导。显式指定合理。
不必要甚至有害的场景:
template <typename PointT>
double zOf(const PointT& p) {
return p.z;
}
PointXYZ p{};
double z = zOf<PointXYZ>(p);
这里 PointT 能从 p 推导出来,显式写出反而增加耦合。如果将来 p 的类型从 PointXYZ 改成 PointXYZI,调用点还要手动改模板参数。
机器人例子:标量类型控制¶
许多残差函数需要同时支持 double 和自动微分标量。
可以写:
template <typename T>
Eigen::Matrix<T, 3, 1> transformPoint(
const Eigen::Quaternion<T>& q,
const Eigen::Matrix<T, 3, 1>& t,
const Eigen::Matrix<T, 3, 1>& p) {
return q * p + t;
}
调用时:
Eigen::Quaterniond q = Eigen::Quaterniond::Identity();
Eigen::Vector3d t = Eigen::Vector3d::Zero();
Eigen::Vector3d p(1.0, 2.0, 3.0);
Eigen::Vector3d out = transformPoint(q, t, p);
自动微分时,Ceres 会用 T 替换 double。
如果函数内部写死 double:
template <typename T>
Eigen::Matrix<T, 3, 1> badResidual(
const Eigen::Matrix<T, 3, 1>& p) {
Eigen::Vector3d origin = Eigen::Vector3d::Zero();
return p - origin; // 对自动微分标量不成立
}
正确写法是让常量跟随模板标量:
template <typename T>
Eigen::Matrix<T, 3, 1> goodResidual(
const Eigen::Matrix<T, 3, 1>& p) {
const Eigen::Matrix<T, 3, 1> origin =
Eigen::Matrix<T, 3, 1>::Zero();
return p - origin;
}
模板接口一旦承诺支持 T,函数体里的常量、矩阵和中间变量也必须尊重这个承诺。
常见陷阱¶
⚠️ 显式指定陷阱:用显式模板参数掩盖接口问题
如果每次调用都要写很长模板参数,可能说明函数参数无法帮助推导,接口设计需要调整。
⚠️ 重载陷阱:普通函数和模板函数匹配结果不符合直觉
读重载问题时,要看候选函数、转换成本和模板推导结果。
⚠️ 标量陷阱:模板内部写死
doubleCeres 自动微分残差中,内部向量应使用
Matrix<T, ...>,常量用.cast<T>()或T(...)。
练习¶
- 工厂题:写
makeIdentity<MatrixType>(),返回矩阵单位阵。 - 重载题:写一个普通
logValue(double)和模板logValue(T),观察不同调用选择。 - 残差题:把一个写死
Vector3d的点变换函数改成标量模板。
12.4 类模板:把类型参数放进对象结构 ⭐⭐⭐⭐¶
前面三节解决了"函数如何适配不同类型"的问题——函数模板、参数推导和重载决议。但函数只是计算的载体,数据需要住在对象里。当对象本身也需要参数化时——容器的元素类型、滤波器的状态维度、点云的点类型——就需要类模板。
类模板把模板的威力从函数扩展到了对象结构。函数模板让同一个算法适配不同类型,类模板让同一个数据结构适配不同类型。两者结合,就能构建出像 Eigen Matrix<Scalar, Rows, Cols> 这样同时参数化标量类型、行数和列数的数值基础设施。
理解类模板的关键是:每种模板参数组合生成一个独立的类类型。vector<int> 和 vector<double> 不是同一个类的两个实例——它们是两个完全独立的类,各有自己的成员函数、内存布局和 ABI。这意味着 vector<int> 和 vector<double> 之间没有继承关系,不能互相赋值,也不能放入同一个容器(除非通过类型擦除或基类指针)。这个”类型隔离”特性是类模板安全性的来源,也是某些场景下不便利的原因。
动机:容器和算法对象也需要泛型¶
如果 C++ 没有类模板会怎样? 你需要为每种点类型分别定义一个容器类:PointCloudXYZ、PointCloudXYZI、PointCloudXYZRGBA……每个类的内部逻辑(添加点、删除点、遍历点)完全相同,只是元素类型不同。当你修改了遍历逻辑(比如加了线程安全锁),需要在所有 20 个容器类中同步修改——漏改任何一个都会引入不一致的行为。Java 在泛型出现之前(JDK 1.5 之前)就经历了这种痛苦:所有集合都存储 Object 引用,每次取出元素都需要强制类型转换,运行时才发现类型错误。C++ 的类模板在编译期就消除了这种不安全的类型转换。
函数模板解决”同一函数适配不同类型”——一个 squaredDistance<T>() 函数能同时处理 double 和 Jet 标量。但如果数据结构本身也需要参数化呢?一个点云缓冲区需要根据点类型决定每个元素占多少内存、如何存储和访问。一个 Kalman 滤波器需要根据状态维度决定协方差矩阵的大小。这些不是”函数如何计算”的问题,而是”对象如何组织数据”的问题——这就是类模板要解决的层次。
类模板解决”同一对象结构适配不同类型”。
最常见例子:
std::vector<T> 是类模板。
T 决定元素类型。
一个简单类模板¶
下面的 PointBuffer 是一个最小的类模板示例。它把点的类型 PointT 作为模板参数,内部使用 std::vector<PointT> 存储数据。这个设计的关键意义是:同一份代码可以服务 Eigen::Vector3d(三维点)、Eigen::Vector2d(二维点)、自定义 PointXYZI(带强度的点)等任意点类型,而不需要为每种类型重新编写容器逻辑。
编译器在看到 PointBuffer<Eigen::Vector3d> 时,会为这个具体类型生成一份完整的类定义——包括成员函数的机器码和内部 vector 的具体元素类型。这个过程称为类模板实例化。每种不同的模板参数组合会产生一个独立的类类型。
template <typename PointT>
class PointBuffer {
public:
void add(const PointT& point) {
points_.push_back(point);
}
const PointT& operator[](std::size_t i) const {
return points_[i];
}
std::size_t size() const {
return points_.size();
}
private:
std::vector<PointT> points_;
};
使用:
类模板实例是不同类型¶
a 和 b 类型不同。
不能直接互相赋值。
这对接口设计很重要。
如果一个函数只接受 PointBuffer<Vector3d>,它不会接受 PointBuffer<Vector2d>。
这背后的对象模型很直接:类模板不是一个运行期类,PointBuffer<Eigen::Vector3d> 和 PointBuffer<Eigen::Vector2d> 是两个不同的类。它们有各自的成员函数实例、各自的 std::vector<PointT> 成员类型,也可能有不同对象大小和不同 ABI。
可以验证:
这对机器人接口影响很大。PointCloud<PointXYZ> 和 PointCloud<PointXYZI> 没有继承关系,即使两个点类型都包含 x,y,z。如果算法只需要 xyz,应该通过模板、traits 或 Eigen::Ref 式视图表达“我需要 xyz 能力”,而不是期待容器之间自动转换。
隐藏维度风险¶
类模板的灵活性也带来一个微妙的风险:模板参数可能隐藏了对算法正确性至关重要的数学信息。在机器人代码中,状态向量的维度、协方差矩阵的大小、李群的自由度都是算法公式中的关键常量。如果这些信息被埋在模板参数的深处,代码审查者很难一眼看出"这个滤波器处理的是 15 维状态还是 18 维状态"。
类模板很容易把关键数学维度藏起来:
调用点看到 Filter<StateVector>,但不知道状态维度是 15、18 还是动态。更清楚的接口可以显式把维度放入类型别名或非类型模板参数:
template <typename Scalar, int StateDim>
class ErrorStateFilter {
public:
using StateVector = Eigen::Matrix<Scalar, StateDim, 1>;
using Covariance = Eigen::Matrix<Scalar, StateDim, StateDim>;
void correct(const StateVector& dx);
};
这样 ErrorStateFilter<double, 15> 在类型名里就暴露了关键契约。反面是模板实例数量会增加,公共 API 也更长。经验规则是:如果维度决定算法公式,就显式表达;如果维度只是数据规模,就留给运行期成员。
GTSAM 中的类模板思路¶
因子图里有很多变量类型:
一个 Between 因子的逻辑是:
这个逻辑对 Pose2 和 Pose3 都成立。
因此可以写成类似:
template <typename VALUE>
class BetweenFactor {
public:
BetweenFactor(Key key1, Key key2, const VALUE& measured)
: key1_(key1), key2_(key2), measured_(measured) {}
private:
Key key1_;
Key key2_;
VALUE measured_;
};
真实 GTSAM 更复杂。
但核心思路就是:因子的值类型作为模板参数。
PCL 中的点类型模板¶
PCL 里常见:
点类型影响:
- 点字段有哪些。
- 内存布局是什么。
- 算法能访问哪些信息。
例如体素滤波只需要 xyz。
颜色滤波需要 rgb。
强度滤波需要 intensity。
模板让同一个滤波器结构适配不同点类型。
编译期多态 vs 运行时多态 vs 类型擦除:三角对比¶
到目前为止,本章一直在讲模板——它是 C++ 中"编译期多态"的主要工具。但模板不是实现"同一接口、不同行为"的唯一方式。C++ 至少提供了三种机制,每种机制在"类型信息何时确定"和"性能开销"之间做了不同的权衡。理解这三种机制的本质差异,是做出正确架构决策的基础——在机器人项目中,经常需要同时使用三者,让每种机制承担它最擅长的那层工作。理解它们的本质差异和适用场景,是做出正确架构决策的基础。
编译期多态(模板):类型在编译期确定,编译器为每种类型生成独立的机器码。
template <typename T> double norm(const T& v);
// 编译器看到 norm(Vector3d{}) 时,生成 norm<Vector3d> 的机器码
// 编译器看到 norm(Vector2d{}) 时,生成 norm<Vector2d> 的机器码
// 两份机器码独立,没有间接调用
运行时多态(虚函数):类型在运行期确定,通过虚函数表(vtable)间接调用。
class Shape { virtual double area() const = 0; };
class Circle : public Shape { double area() const override; };
class Rect : public Shape { double area() const override; };
// 给定 Shape* ptr,调用 ptr->area() 时,通过 vtable 查找正确的函数指针
类型擦除(std::function、std::any、自定义擦除):类型在编译期"隐藏"在内部,外部接口统一。
std::function<double(double)> fn = [](double x) { return x * x; };
// fn 的类型不包含 lambda 的具体类型信息
// 内部通过堆分配 + 虚调用实现间接
三种机制的核心差异在于**类型信息何时可用**:
| 维度 | 模板(编译期多态) | 虚函数(运行时多态) | 类型擦除 |
|---|---|---|---|
| 类型何时确定 | 编译期 | 运行期 | 编译期确定,但接口隐藏 |
| 函数调用开销 | 零(可内联) | vtable 间接调用 ~2-5ns | 类似虚函数 |
| 能否放入同一容器 | 不能(不同模板实例是不同类型) | 能(基类指针容器) | 能(统一类型) |
| 能否在运行时切换实现 | 不能 | 能 | 能 |
| 编译时间 | 每种类型都实例化,编译慢 | 只编译一次 | 中等 |
| 二进制体积 | 每种类型一份代码副本 | 共享基类代码 | 中等 |
| 错误诊断 | 编译错误信息冗长 | 运行时可能段错误 | 运行时异常 |
| 返回类型是否可因类型而异 | 是 | 受基类返回类型限制 | 通常固定 |
| 典型用途 | Eigen 矩阵、Ceres 残差、数值内核 | 插件接口、传感器驱动、GUI 事件 | 回调、任务队列、配置系统 |
在机器人系统中的选型准则:
"变化发生在编译期还是运行期"是最重要的判断标准。如果你的代码在编译时就知道"这里处理的是 PointXYZ",模板是正确选择——编译器能内联所有操作,消除间接调用,利用固定维度做 SIMD 优化。这是 Eigen、PCL 内核、Ceres 残差的设计基础。
如果你的代码在运行时才知道"用户选择了哪种传感器驱动"或"配置文件指定了哪种优化器",虚函数是正确选择——它让你能写 std::vector<std::unique_ptr<Optimizer>> optimizers,在运行时遍历并调用每个优化器的 solve() 方法。
类型擦除适合"我不关心具体类型,只关心它能做什么"的场景。std::function<void()> 可以包装普通函数、lambda、成员函数绑定,调用者不需要知道它包装了什么——只需要能调用它。任务队列、事件系统和回调注册是典型用途。
一个成熟的机器人系统通常同时使用三种机制。以 SLAM 系统为例:Ceres 残差函数用模板支持 double 和 Jet<double, N> 两种标量类型(编译期多态);前端里传感器接口用虚函数支持不同 LiDAR 驱动(运行时多态);ROS 回调用 std::function 注册消息处理函数(类型擦除)。三者各司其职,不应该强行统一到一种机制。
常见陷阱¶
⚠️ 实例类型陷阱:
Container<A>和Container<B>没有继承关系即使
A能转换成B,Container<A>也不会自动转换成Container<B>。⚠️ 接口陷阱:类模板参数过多
参数越多,调用点越难读。能通过默认参数、traits 或 Policy 分层的,不要都堆在一个模板参数列表里。
⚠️ 语义陷阱:模板参数名太泛
typename T在小函数里可以;类模板公共接口中,PointT、Scalar、StateDim更清楚。
练习¶
- 实现题:写
PointBuffer<PointT>,支持add()、size()、operator[]。 - 接口题:设计
KdTree<PointT, Dim>的类模板参数,并说明每个参数的语义。 - 阅读题:找一个 PCL 类模板,解释它的
PointT影响了哪些代码路径。
12.5 成员函数模板与跨类型操作 ⭐⭐⭐¶
动机:类模板内部仍可能需要泛型函数¶
本质洞察:类模板参数定义了对象"是什么"(身份),成员函数模板定义了对象"能和什么交互"(能力)。
Vec3<double>的身份是"双精度三维向量",但它的cast<float>()能力让它能够跨越精度边界与Vec3<float>交互。身份是静态的,能力可以是泛型的——这种分工避免了"为每种交互组合都定义一个类型"的组合爆炸。
类模板参数决定对象的主要类型身份——Vec3<double> 和 Vec3<float> 是不同的类型。但有些操作天然需要跨越类型边界:你可能想把 Vec3<double> 转换为 Vec3<float>,或者从 Vec3<int> 构造一个 Vec3<double>。这些操作的输入类型和类模板参数不同,因此不能用类模板参数表达——需要在成员函数上引入额外的模板参数。
成员函数模板有一个重要的语言限制:它不能是 virtual 的。原因是虚函数表在编译时就必须确定大小和布局——编译器需要知道虚函数表中有多少个条目。但成员函数模板可以被任意多种类型实例化,编译器无法在定义类时预知会有多少种实例化,因此无法为它们预留虚函数表条目。
在机器人代码中,成员函数模板最常见的用途是标量类型转换(如 Eigen 的 .cast<T>())和跨类型构造:
例如:
template <typename Scalar>
class Vec3 {
public:
Vec3(Scalar x, Scalar y, Scalar z)
: x_(x), y_(y), z_(z) {}
template <typename OtherScalar>
Vec3<OtherScalar> cast() const {
return Vec3<OtherScalar>(
static_cast<OtherScalar>(x_),
static_cast<OtherScalar>(y_),
static_cast<OtherScalar>(z_));
}
private:
Scalar x_;
Scalar y_;
Scalar z_;
};
Vec3<double> 是类模板实例。
cast<float>() 是成员函数模板。
Eigen 中的 .cast<T>() 就是类似思想。
跨类型拷贝构造¶
跨类型拷贝构造是成员函数模板最典型的应用场景。下面的代码允许 Vector3<double> 从 Vector3<float> 构造——编译器会为每种源类型生成一份独立的构造函数。注意构造函数标记为 explicit:这是为了防止隐式的精度降低转换(如从 double 到 float)静默发生。Eigen 的 .cast<T>() 也遵循类似的显式转换原则。
template <typename Scalar>
class Vector3 {
public:
template <typename OtherScalar>
explicit Vector3(const Vector3<OtherScalar>& other)
: x_(static_cast<Scalar>(other.x())),
y_(static_cast<Scalar>(other.y())),
z_(static_cast<Scalar>(other.z())) {}
Scalar x() const { return x_; }
Scalar y() const { return y_; }
Scalar z() const { return z_; }
private:
Scalar x_ = 0;
Scalar y_ = 0;
Scalar z_ = 0;
};
这样 Vector3<float> 可以从 Vector3<double> 构造。
是否加 explicit 要谨慎。
数值类型转换可能丢精度。
默认让调用者显式写出转换更稳。
成员模板与普通成员¶
成员函数模板不能是虚函数。
原因是虚函数需要运行时虚表入口。
模板函数则在编译期按类型实例化,实例数量不固定。
因此下面想法不成立:
如果需要运行时多态,使用普通虚函数、类型擦除或公共基类。
如果需要编译期泛型,使用模板。
不要把两个机制强行混在同一个函数上。
常见陷阱¶
⚠️ 虚函数陷阱:成员函数模板不能是 virtual
运行时多态和编译期模板解决不同问题。插件边界用虚函数,数值内核用模板。
⚠️ 隐式转换陷阱:跨标量构造过于宽松
double到float可能丢精度。公共接口中常用explicit。⚠️ 职责陷阱:类模板和成员模板同时承担太多变化
类模板负责对象主类型,成员模板负责局部转换或泛型操作。两者边界要清楚。
练习¶
- 实现题:为
Vector3<Scalar>写cast<OtherScalar>()。 - 判断题:为什么成员函数模板不能是虚函数?
- 设计题:一个点云滤波器既要支持不同
PointT,又要支持不同输出标量,哪些用类模板,哪些用成员模板?
12.6 CTAD:类模板参数推导 ⭐⭐⭐¶
动机:减少重复类型书写¶
CTAD 的设计可以类比到自然语言中的代词消解。当你说"把书放到桌子上,然后打开它"时,听者知道"它"指的是"书"——不需要重复说"打开那本书"。C++17 之前,std::pair<int, double> p(1, 2.0) 中的 <int, double> 就像每次提到对象都要重复全名一样冗余。CTAD 让编译器像人类理解代词一样,从上下文(构造函数实参)推断出类模板参数。但代词消解有时也会产生歧义("他给他一本书"——谁给谁?),CTAD 同样可能在复杂场景下推导出意外类型——所以数学核心类型仍然推荐显式指定。
在 C++17 之前,使用类模板时必须显式指定所有模板参数——即使这些参数完全可以从构造函数的实参中推断出来。例如,std::pair<int, double> p(1, 2.0) 中的 <int, double> 是多余的信息:编译器从 1 就能知道第一个类型是 int,从 2.0 就能知道第二个类型是 double。这种冗余在简单场景中只是噪声,但在复杂模板嵌套中(如 std::map<std::string, std::vector<std::pair<int, double>>>)会严重降低可读性。
C++17 引入的 CTAD(Class Template Argument Deduction,类模板参数推导)解决了这个问题。它的核心思想是:既然函数模板能从实参推导类型参数(如 std::make_pair(1, 2.0) 推导出 pair<int, double>),类模板的构造函数也应该有同样的能力。
CTAD 的推导规则和函数模板参数推导非常相似——编译器把构造函数的形参模式和实参类型进行匹配,解出模板参数。但类模板比函数模板多了一个复杂度:一个类模板可能有多个构造函数,每个构造函数可能暗示不同的模板参数推导结果。此外,用户还可以通过"推导指引"(deduction guide)显式告诉编译器应该如何推导。
它允许编译器从构造函数实参推导类模板参数。
标准库例子:
推导为:
再如:
推导为:
自定义类模板的推导¶
template <typename T>
class Box {
public:
explicit Box(T value) : value_(value) {}
private:
T value_;
};
Box b(3); // 推导 Box<int>
这能减少调用点噪声。
但不一定总是适合公共接口。
推导指引¶
当构造函数的形参类型和类模板参数之间的关系不够直接时,编译器无法自动推导模板参数——此时需要程序员提供"推导指引"(deduction guide)来显式指定推导规则。推导指引的语法看起来像一个没有函数体的函数声明,它告诉编译器"当用这种类型的参数构造时,模板参数应该是什么"。
有些类型需要推导指引:
template <typename Iterator>
class Range {
public:
Range(Iterator first, Iterator last)
: first_(first), last_(last) {}
private:
Iterator first_;
Iterator last_;
};
template <typename Iterator>
Range(Iterator, Iterator) -> Range<Iterator>;
推导指引告诉编译器:
推导指引是接口的一部分。它不只是“让编译器聪明一点”,而是在声明:哪些构造参数足以决定类模板身份。写错推导指引,会让对象类型在调用点被静默推成错误形式。
例如一个固定维度向量包装:
template <typename Scalar, int Dim>
class SmallVector {
public:
explicit SmallVector(const Eigen::Matrix<Scalar, Dim, 1>& value)
: value_(value) {}
private:
Eigen::Matrix<Scalar, Dim, 1> value_;
};
如果你希望从 Eigen 向量推导 Scalar 和 Dim:
template <typename Scalar, int Dim>
SmallVector(const Eigen::Matrix<Scalar, Dim, 1>&)
-> SmallVector<Scalar, Dim>;
调用点:
但这种简洁也隐藏了维度。对数学核心对象,显式写 SmallVector<double, 3> 有时反而更清楚。CTAD 适合局部辅助对象,不适合让公共状态、协方差、李代数维度从调用点消失。
机器人项目中的 CTAD¶
CTAD 常见于现代 C++ 辅助类型:
std::lock_guard lock(mutex);
std::array points{p0, p1, p2};
std::pair association{source_index, target_index};
它能让代码更简洁。
但对于数学类型,显式类型常常更好:
比让读者从构造推导矩阵类型更清楚。
判断 CTAD 是否合适,可以问三个问题:
- 推导出的类型是否是读者真正关心的数学契约?
- 构造参数是否唯一决定模板参数?
- 将来修改构造参数时,类型是否可能无意变化?
std::lock_guard lock(mutex) 的类型不重要,CTAD 很合适。Eigen::Matrix3d R 的维度和标量很重要,显式类型更合适。RegistrationKernel<PointXYZ, 3> 中点类型和维度决定算法路径,也应显式写出。
常见陷阱¶
⚠️ 可读性陷阱:CTAD 让重要类型消失
数学代码中,维度和标量类型很重要。不要为了少写几个字符,让读者看不出是
Matrix3d还是MatrixXd。⚠️ 推导陷阱:初始化列表推导出意外类型
std::vector v{1, 2, 3}很清楚;混合类型初始化可能推导失败或得到不符合预期的类型。⚠️ 接口陷阱:公共 API 过度依赖 CTAD
CTAD 适合调用点简化,不适合隐藏关键数学契约。
练习¶
- 推导题:判断
std::pair p{1, 2.0}、std::array a{1,2,3}的类型。 - 设计题:为
Range<Iterator>写推导指引。 - 讨论题:为什么 Eigen 数学代码中经常仍然显式写
Matrix3d?
12.7 依赖名称、typename 与 template 关键字 ⭐⭐⭐⭐¶
typename 和 template 关键字在模板代码中的使用是初学者最常遇到的"看不懂的编译错误"之一。这两个关键字的存在不是语言设计的疏忽,而是 C++ 两阶段名字查找(two-phase lookup)机制的逻辑必然。理解为什么需要它们,需要站在编译器的角度思考一个根本问题:模板定义中依赖模板参数的名字,在模板还没有被实例化时,编译器怎么知道这个名字代表的是类型、静态成员还是模板?
答案是:编译器不知道。因此它需要程序员显式告知——typename 告诉编译器"这是一个类型名",template 告诉编译器"后面的尖括号是模板参数列表而不是比较运算符"。在 Eigen 代码中,这个问题尤其常见:m.template block<3,3>(0,0) 中的 template 关键字就是为了告诉编译器 block 后面的 <3,3> 是模板参数,而不是两次小于比较。
动机:模板里有些名字要等实例化才知道¶
看一个模板:
template <typename Container>
void printFirst(const Container& c) {
typename Container::value_type value = c[0];
std::cout << value << "\n";
}
Container::value_type 依赖模板参数 Container。
在模板定义时,编译器不知道它是类型、静态成员还是其他东西。
所以需要 typename 明确告诉编译器:
这背后是 two-phase lookup(两阶段名字查找)。理解这个机制,是理解 typename 和 template 关键字为什么必要的关键。
两阶段名字查找的完整机制:
C++ 编译器处理模板代码时,不是"看到模板定义就跳过、等实例化时再检查"。它会在两个不同的时间点做两次名字解析——这就是 two-phase lookup:
第一阶段(模板定义阶段):编译器读到模板定义时(还没有任何实例化),立刻对模板代码做初步检查。在这个阶段,编译器做三件事:
- 检查所有不依赖模板参数的名字(非依赖名字)。例如
std::cout、std::size_t、全局函数名。这些名字必须在定义点已经可见,否则立刻报错——即使模板从未被实例化。 - 检查基本语法结构。括号配对、分号、关键字的正确使用都在此阶段验证。
- 遇到依赖于模板参数的名字时,**推迟查找**到第二阶段,但仍然需要知道它的"语法类别"——是类型名还是非类型名,是模板还是非模板。这就是
typename和template存在的原因。
第二阶段(模板实例化阶段):当模板被某个具体类型实例化时(例如 printFirst(std::vector<int>{})),编译器用具体类型替换模板参数,然后解析第一阶段推迟的依赖名字。此时 Container 变成了 std::vector<int>,Container::value_type 变成了 std::vector<int>::value_type(即 int),编译器能完全确认它的含义。
为什么需要两个阶段? 这个设计的根本原因是 C++ 模板的"开放性"——模板参数可以是任何类型,不同类型可能让同一个名字有完全不同的含义。考虑:
如果 T::x 是类型名,T::x * y 是指针声明;如果 T::x 是静态成员变量,T::x * y 是乘法。编译器在第一阶段不知道 T 是什么,所以无法判断。C++ 的解决方案是:默认把依赖限定名当作非类型,除非用 typename 显式标注。
类似地,m.block<3,3>(0,0) 中的 < 可能是模板参数列表的开始(block 是模板成员函数),也可能是小于号(m.block < 3)。编译器在第一阶段不知道 m 的具体类型(因为它依赖模板参数),所以默认把 < 当小于号。template 关键字告诉编译器"后面的 < 是模板参数列表"。
两阶段查找的实际影响:
- 第一阶段的错误在模板定义时就报出,即使模板从未被实例化。这是好事——你能提前发现拼写错误(如把
std::cout写成std::count)。 - 第二阶段的错误只在特定实例化时才报出。这意味着模板可能对某些类型编译成功、对其他类型失败——这正是 SFINAE(模板特化SFINAE与类型萃取)的工作基础。
- 不同编译器对两阶段查找的严格程度不同。MSVC 历史上在第一阶段非常宽松(几乎推迟所有检查到第二阶段),GCC 和 Clang 更严格。这解释了为什么有些模板代码在 MSVC 下编译通过但在 GCC 下报错——通常是因为缺少
typename或template。
缺少 typename¶
错误写法:
编译器可能把 Container::value_type 当作非类型名字解析。
正确写法:
真实错误路径通常是这样的:
template <typename Cloud>
void reserveLike(const Cloud& cloud) {
Cloud::PointType p = cloud.points.front();
(void)p;
}
如果 Cloud::PointType 是依赖类型,编译器在模板定义阶段不能确认它是类型,于是报类似:
修复:
template <typename Cloud>
void reserveLike(const Cloud& cloud) {
typename Cloud::PointType p = cloud.points.front();
(void)p;
}
如果这个名字来自 traits,也同样需要:
template <typename PointT>
void inspectPoint() {
using Scalar = typename PointTraits<PointT>::Scalar;
std::cout << sizeof(Scalar) << "\n";
}
Eigen 中的 template 关键字¶
回顾 Eigen基础与SLAM数学预备 的 block:
在非模板代码里这样写没问题。
但在模板中:
template <typename Derived>
Eigen::Matrix3d topLeft3x3(const Eigen::MatrixBase<Derived>& matrix) {
return matrix.block<3, 3>(0, 0);
}
这可能报错。
因为 matrix 的具体类型依赖 Derived。
编译器看到 < 时,不确定它是模板参数开始,还是小于号。
正确写法:
template <typename Derived>
Eigen::Matrix3d topLeft3x3(const Eigen::MatrixBase<Derived>& matrix) {
return matrix.template block<3, 3>(0, 0);
}
template 关键字告诉编译器:
这个错误常见于 Eigen 泛型代码,因为 MatrixBase<Derived> 上有大量成员模板。更完整的例子:
template <typename Derived>
Eigen::Vector3d translationOf(
const Eigen::MatrixBase<Derived>& T) {
return T.template block<3, 1>(0, 3);
}
如果少写 template,编译器在第一阶段看到 T.block < 3,可能把 < 当成小于号,而不是模板参数列表开始,于是报很绕的语法错误。template 关键字在这里不是修饰返回类型,而是给解析器一个消歧信号。
两个关键字的分工¶
本质洞察:
typename和template关键字的存在揭示了一个深层设计权衡:C++ 选择在"编译器可能误解"的地方要求程序员显式消歧,而不是像某些语言那样推迟所有检查到实例化阶段。这种设计让编译器能在模板定义阶段就捕获非依赖名字的错误——你拼错了std::cout,即使模板从未被实例化也会报错。代价是程序员需要手动添加typename和template,这在 Eigen 泛型代码中尤其频繁。C++20 放松了部分场景下typename的要求,但template消歧在可预见的未来仍然必要。
| 关键字 | 解决什么歧义 | 示例 |
|---|---|---|
typename |
依赖名字是类型 | typename T::value_type |
template |
依赖对象上的成员是模板 | m.template block<3,3>() |
它们只在依赖上下文中常见。
普通非模板代码里一般不需要。
机器人代码中的典型场景¶
Eigen 泛型函数:
template <typename Derived>
auto rotationBlock(const Eigen::MatrixBase<Derived>& T) {
return T.template block<3, 3>(0, 0);
}
容器 traits:
template <typename Cloud>
void inspectCloud(const Cloud& cloud) {
using PointT = typename Cloud::PointType;
std::cout << sizeof(PointT) << "\n";
}
模板模板参数和 traits 会让依赖名称更多。
模板特化SFINAE与类型萃取 会继续展开。
本章先把这两个关键字记熟。
可以用一个判断流程:
名字里是否出现模板参数相关的作用域或对象?
否:普通名字查找,不需要 typename/template
是:它是类型名吗?
是:在前面加 typename
它是成员模板调用吗?
是:在成员名前加 template
注意 typename 只能放在类型名前:
template 放在依赖对象或依赖作用域之后、成员模板名之前:
两者可以同时出现:
这种写法在初学阶段不常写,但读库源码时会遇到。拆开看就不神秘:Traits<T> 依赖 T,Rebind 是成员模板,Type 是依赖类型。
常见陷阱¶
⚠️ 语法陷阱:在模板函数里调用 Eigen
block<>()忘记template只要对象类型依赖模板参数,调用模板成员函数时就要考虑
m.template block<...>()。⚠️ 类型陷阱:依赖类型名前忘记
typename
T::value_type、traits<T>::Scalar、Cloud::PointType常需要typename。⚠️ 记忆陷阱:把两个关键字混用
typename修饰类型名;template修饰成员模板调用。它们解决的歧义不同。
练习¶
- 修错题:修复一个模板函数中
Container::value_type缺少typename的错误。 - Eigen 题:写
topLeft3x3(MatrixBase<Derived>),正确使用template block。 - 解释题:用自己的话说明为什么编译器在模板定义阶段无法确定依赖名字。
12.8 非类型模板参数:把维度放进编译期 ⭐⭐⭐⭐¶
动机:维度是机器人数学的重要契约¶
本质洞察:非类型模板参数把"数值常量"提升为"类型身份的一部分"。
Matrix<double, 3, 1>中的3和1不是对象的属性——它们是类型的属性。这个区别至关重要:对象属性只能在运行时检查(if (v.size() != 3) throw ...),类型属性在编译时就被检查(Matrix<double, 3, 1>不能赋值给Matrix<double, 4, 1>)。把维度从对象层面提升到类型层面,等价于把运行时错误转化为编译时错误——这是 C++ 类型系统最强大的工程价值之一。
如果没有非类型模板参数会怎样? 所有 Eigen 矩阵都只能是 MatrixXd——动态大小。3x3 旋转矩阵和 1000x1000 的 Hessian 矩阵用同一个类型表达,编译器无法区分它们。一个接受 MatrixXd 参数的函数可以被传入任何尺寸的矩阵——3x3 的旋转矩阵不小心传到了期望 4x4 齐次矩阵的函数中,编译器不会报错,只有运行时才会崩溃(或者更糟:静默产生错误结果)。非类型模板参数让 Matrix3d 和 Matrix4d 成为不同类型,编译器在函数边界就阻止了这种混用。
Eigen 类型:
这里的 3 和 1 是非类型模板参数。
它们不是类型。
它们是编译期整数。
你也可以写自己的维度模板:
template <int Dim>
class Point {
public:
using Vector = Eigen::Matrix<double, Dim, 1>;
explicit Point(const Vector& value) : value_(value) {}
const Vector& vector() const { return value_; }
private:
Vector value_;
};
使用:
Point<2> 和 Point<3> 是不同类型。
这里没有提供默认构造函数,是为了让“默认点”这件事必须显式表达。图优化、像素坐标和三维路标里的零向量语义并不总是相同;如果项目确实需要默认值,可以再添加 Point() : value_(Vector::Zero()) {},并在接口文档里说明默认值代表什么。
ESKF 维度¶
template <int StateDim, int NoiseDim>
class ErrorStateKalmanFilter {
public:
using StateVector = Eigen::Matrix<double, StateDim, 1>;
using Covariance = Eigen::Matrix<double, StateDim, StateDim>;
using NoiseCovariance = Eigen::Matrix<double, NoiseDim, NoiseDim>;
void predict(const Eigen::Matrix<double, StateDim, StateDim>& F,
const Eigen::Matrix<double, StateDim, NoiseDim>& G,
const NoiseCovariance& Q) {
P_ = F * P_ * F.transpose() + G * Q * G.transpose();
}
private:
Covariance P_ = Covariance::Identity();
};
这样编译器能检查 F、G、Q 的维度组合。
如果把这些都写成 MatrixXd,错误会更晚暴露。
非类型模板参数的关键约束是:它的值必须在编译期已知,并且适合作为类型身份的一部分。ErrorStateKalmanFilter<15, 12> 与 ErrorStateKalmanFilter<18, 15> 是不同类型。它们的成员函数会分别实例化,成员 Covariance 的对象大小也不同。
这适合表达“算法结构维度”:
不适合表达频繁变化的部署参数:
这些值应作为构造参数或配置对象存在。
std::size_t N 数组维度¶
template <typename T, std::size_t N>
double mean(const std::array<T, N>& values) {
T sum{};
for (const auto& value : values) {
sum += value;
}
return static_cast<double>(sum) / static_cast<double>(N);
}
N 在编译期可知。
这让函数不需要额外传长度。
运行期维度还是编译期维度¶
读到这里你可能会问:既然编译期维度这么好(能做类型检查、能让编译器优化),为什么不把所有维度都做成非类型模板参数?答案是两个字:组合爆炸。每种维度组合都会生成独立的机器码——Filter<15,12> 和 Filter<18,15> 是两份完全独立的代码。如果维度来自配置文件(用户可能填 3、6、9、12、15、18 任意一个),你要么为所有可能的值都做显式实例化(代码膨胀),要么改成运行期维度(用 VectorXd 代替 Vector<N>)。
判断的原则是:如果维度由算法的数学结构决定(如 ESKF 的状态维度),它在编译期确定、一旦选定不会变——适合模板参数。如果维度由数据或配置决定(如地图中路标点的数量),它在运行期才知道——应留给运行期。
| 问题 | 推荐 |
|---|---|
| 3D 点、6D 李代数、15D ESKF | 编译期维度 |
| 变量数量随地图大小变化 | 运行期维度 |
| 小固定矩阵热路径 | 编译期维度 |
| 配置决定维度 | 运行期维度 |
| 模板实例数量可能爆炸 | 谨慎使用编译期维度 |
编译期维度带来优化和检查。
但每个维度组合都可能生成新代码。
如果维度来自配置文件,就不应作为模板参数。
还要区分“值域有限且稳定”和“理论上可变”。例如 Dim 只可能是 2 或 3,作为模板参数很合理:
而体素滤波的 max_points_per_voxel 可能随数据集调整,即使它是整数,也不一定适合做模板参数。模板参数越多,类型组合越多,编译时间、二进制体积和错误信息都会增长。
非类型模板参数与 Eigen 的 int¶
Eigen 的维度模板参数使用 int,并用 Eigen::Dynamic 表示动态维度:
标准库容器容量常用 std::size_t:
两者不要混用得含糊。Eigen 维度接口里用 int 更自然;数组容量、索引上界等标准库语义用 std::size_t 更自然。跨边界时应显式转换并检查范围,避免负值、窄化转换和编译器警告被忽略。
常见陷阱¶
⚠️ 灵活性陷阱:把运行时配置做成非类型模板参数
模板参数编译期固定。部署时要改 YAML 的值,不适合做模板参数。
⚠️ 代码体积陷阱:维度组合太多
Filter<15,12>、Filter<18,15>、Filter<21,18>都会实例化不同代码。基础库要控制组合数量。⚠️ 类型转换陷阱:
int和std::size_t混用模板维度常用
int配合 Eigen,容器大小常用std::size_t。接口中要清楚区分。
练习¶
- 实现题:写
Point<Dim>,内部使用Matrix<double, Dim, 1>。 - 滤波题:设计
ErrorStateKalmanFilter<StateDim, NoiseDim>的类型别名。 - 取舍题:判断体素大小、状态维度、最大迭代次数分别适合编译期还是运行期。
12.9 模板编译模型与显式实例化 ⭐⭐⭐⭐¶
为什么模板通常写在头文件¶
模板必须放在头文件中的原因,可以类比到"菜谱 vs 成品菜"。普通函数就像预制好的成品菜——厨房(.cpp)做好了,服务员(链接器)直接端到客人桌上。模板更像菜谱——客人(调用方)说"我要一份牛排"时,厨房需要看到完整的菜谱才能做出"牛排版本"的菜。如果菜谱锁在另一个厨房里(定义在其他 .cpp 中),当前厨房只看到"这里有道菜叫 X"(只有声明),就无法做出任何东西。显式实例化相当于"厨房预先按菜谱做好了牛排和鸡排放在保温柜里"——客人只能从预制的选项中挑选,不能点菜谱上没做好的新口味。
这个问题每个 C++ 初学者都会遇到:把模板函数的定义放在 .cpp 文件中,编译没问题,链接时却报"未定义符号"。这不是语法错误,而是 C++ 编译模型的根本限制。理解这个限制需要回到"分离编译"的基本原理。
普通函数可以在头文件声明、.cpp 定义:
模板不同。
当编译器在某个 .cpp 里看到:
它需要看到模板定义,才能生成 normGeneric<Vector3d>。
因此模板通常放在头文件:
更准确地说,模板实例化点必须能看到完整定义。只有声明不够,因为编译器不知道要生成什么机器代码。普通函数可以先编译 .cpp 得到一个符号,调用方只需要声明;模板函数需要在调用方根据具体类型生成实例,或者链接到库中已经显式生成的实例。
这也是很多模板链接错误的来源:
解决方式只有两类:
- 把模板定义放到头文件或被头文件包含的
.hpp/.ipp中。 - 在某个
.cpp中显式实例化所有支持的类型。
头文件模板的代价¶
如果不把模板定义放在头文件会怎样? 假设你把 squaredDistance<T>() 的函数体放在 math.cpp 中。编译 slam.cpp 时,编译器看到 squaredDistance(a, b) 的调用,需要为 T = double 生成函数机器码——但函数体在 math.cpp 中,编译器看不到。编译阶段不会报错(它知道有个叫 squaredDistance 的模板函数),但链接阶段会报 undefined reference to squaredDistance<double>——因为没有任何翻译单元生成了 squaredDistance<double> 的代码。C++ 的模块系统(C++20 modules)是解决这个问题的长期方案,但目前机器人库生态还没有广泛采用。
| 代价 | 表现 |
|---|---|
| 编译时间增加 | 每个使用点都可能实例化 |
| 错误信息变长 | 模板栈展开复杂 |
| 实现暴露 | 代码都在头文件 |
| 二进制体积 | 多个类型生成多份代码 |
这不是说模板不好。
而是模板要服务于明确变化维度。
还要理解 ODR。模板定义放在头文件里,被多个 .cpp 包含,看起来像“同一个函数定义出现多次”。C++ 允许这类模板定义在多个翻译单元出现,只要它们完全一致;编译器和链接器会处理相同实例的合并。但如果你用宏或条件编译让不同翻译单元看到不同模板定义,就会违反 ODR,表现可能是链接错误,也可能是更隐蔽的运行异常。
因此模板头文件要避免依赖调用方局部宏改变语义。公共模板接口应尽量让配置显式体现在模板参数、函数参数或 traits 中。
显式实例化¶
如果只支持少数类型,可以显式实例化。
头文件:
// voxel_filter.hpp
#pragma once
template <typename PointT>
class VoxelFilter {
public:
void filter();
};
struct PointXYZ;
struct PointXYZI;
extern template class VoxelFilter<PointXYZ>;
extern template class VoxelFilter<PointXYZI>;
实现文件:
// voxel_filter.hpp or voxel_filter_impl.hpp
template <typename PointT>
void VoxelFilter<PointT>::filter() {
// implementation
}
实例化文件:
// voxel_filter.cpp
#include "voxel_filter.hpp"
#include "voxel_filter_impl.hpp"
template class VoxelFilter<PointXYZ>;
template class VoxelFilter<PointXYZI>;
调用方如果只使用这两个类型,就可以链接已经编译好的实例。
extern template 的作用是告诉其他翻译单元:这个实例会在别处生成,你们不要重复实例化。真正生成代码的是 .cpp 中的:
如果头文件写了 extern template class VoxelFilter<PointXYZ>;,但 .cpp 里忘了对应显式实例化,编译可能通过,链接会失败。显式实例化把错误从“模板定义不可见”变成“支持类型清单必须完整维护”。
PCL 的 .h/.hpp/.cpp 模式¶
PCL 经常把模板代码分成:
这种组织方式的目的:
- 让接口声明相对清楚。
- 把实现从主头文件中分离出来。
- 对常见点类型预编译,降低调用方编译成本。
- 仍然允许扩展方包含
.hpp使用自定义点类型。
一个典型使用边界是:
普通调用方:
include voxel_grid.h
使用库预编译的 PointXYZ / PointXYZI 实例
扩展调用方:
include voxel_grid.h
include voxel_grid.hpp
使用自定义 PointXYZIRT,自己在项目中实例化
这同时服务两类需求:常见类型编译快,自定义类型仍然可扩展。代价是组织结构更复杂,文档必须说明“自定义点类型需要包含实现头”。
什么时候显式实例化¶
回顾前面的讨论:包含模型把定义放在头文件,任何类型都能实例化但编译慢;显式实例化把定义放在 .cpp 中,编译快但只支持预定义的类型集合。选择哪种模式取决于你的库面向的用户:如果用户可能使用任意自定义类型(如 Eigen 的标量参数),包含模型更合适;如果支持的类型集合是有限且稳定的(如 PCL 的 20 种点类型),显式实例化能显著减少编译时间。
| 场景 | 建议 |
|---|---|
| 支持类型集合固定 | 显式实例化 |
| 调用方会扩展任意类型 | 头文件实现 |
| 编译时间很长 | 考虑显式实例化常用组合 |
| 模板实现依赖大量内部细节 | 用 .hpp 分离 |
| ABI 稳定 SDK | 谨慎暴露模板实现 |
常见陷阱¶
⚠️ 链接陷阱:模板只声明不定义
编译通过声明,链接时找不到具体实例。模板使用点必须看到定义,或者库中显式实例化了该类型。
⚠️ 重复实例化陷阱:所有
.cpp都编译同一模板大型模板会增加编译时间。常用类型可以用显式实例化收敛。
⚠️ 扩展陷阱:显式实例化后忘记自定义类型路径
如果调用方要用
PointXYZIRT,要么库提供实例,要么调用方能包含模板实现。
练习¶
- 组织题:把
VoxelFilter<PointT>拆成.hpp声明、.ipp实现、.cpp实例化三部分。 - 链接题:只在
.cpp定义模板函数,不显式实例化,观察链接错误。 - PCL 题:解释为什么 PCL 要同时支持预编译点类型和自定义点类型。
12.10 机器人接口设计:模板、虚函数与类型擦除 ⭐⭐⭐⭐¶
模板不负责所有灵活性¶
模板、虚函数和类型擦除的分工,可以类比到建筑中的三种灵活性层次。模板像是建筑的结构骨架——在设计图纸阶段(编译期)就确定了,一旦浇筑混凝土就不能改变。虚函数像是建筑的可拆卸隔墙——结构完成后,住户(运行时)可以根据需要调整房间布局。类型擦除像是万能插座——你不需要知道插入的是台灯还是电脑,只要它符合电气接口(std::function 的调用签名)就能工作。把运行时配置做成模板参数,就像把隔墙浇筑进承重结构——以后每次想调整都要拆房子重建(重新编译)。
初学者学完模板后常犯的一个错误是"一切都用模板"——连配置文件中的体素大小都做成模板参数。但模板有一个根本限制:模板参数的值必须在编译期确定。部署时想改体素大小就要重新编译——这在现场调试和快速迭代中是不可接受的。
正确的思路是按"变化发生的时间"分层。模板适合编译期变化——类型、维度、策略这些在编译某个节点或库时就确定了的信息。虚函数适合运行时变化——算法选择、传感器驱动、插件加载这些由配置文件或用户输入决定的信息。类型擦除适合隐藏具体类型但保留值语义或调用接口——回调、任务队列、事件处理这些"调用者不关心具体类型"的场景。
选择工具时先问一句:变化发生在编译期还是运行期?
如果点类型是 PointXYZ 还是 PointXYZI,通常在编译某个节点或库时就确定了;内层循环要访问 x,y,z,不希望每个点都走虚函数,这适合模板。若算法是 ICP、GICP 还是 NDT,往往由 YAML 或命令行在启动时选择;这适合虚函数、工厂或类型擦除。若回调对象类型很多,但调用者不关心具体类型,例如终止条件、日志 sink、局部代价函数,可以使用 std::function 或自定义类型擦除。
选型表:
| 变化来源 | 推荐工具 | 示例 |
|---|---|---|
| 点类型在编译期确定 | 类模板 | VoxelGrid<PointT> |
| 标量类型用于自动微分 | 函数模板 | residual<T>() |
| 状态维度固定 | 非类型模板参数 | Filter<15,12> |
| 算法由配置文件选择 | 虚函数 + 工厂 | ICP/GICP/NDT |
| 回调类型多样但局部使用 | 函数对象 / std::function |
日志、终止条件 |
| 外部类型适配 | traits | 点云、流形、标量 |
反面:把配置项做成模板参数¶
另一个常见的设计错误是把运行时配置参数做成模板参数。下面的代码把体素大小 VoxelSize 做成了模板参数——这意味着每换一个体素大小就需要重新编译。在现场调试时,操作员想把体素从 0.1m 改成 0.2m 来看看效果,却发现必须拿到源代码重新 cmake && make,这显然不可接受。
错误倾向:
在很多标准版本中,浮点非类型模板参数支持有限。
更重要的是,体素大小通常来自配置文件。
部署时改参数不应重新编译。
更合适:
template <typename PointT>
class VoxelFilter {
public:
explicit VoxelFilter(double voxel_size)
: voxel_size_(voxel_size) {}
private:
double voxel_size_;
};
PointT 是编译期结构差异。
voxel_size 是运行期配置。
反面:把点类型做成虚接口¶
如果每个点访问都走虚函数:
class PointBase {
public:
virtual double x() const = 0;
virtual double y() const = 0;
virtual double z() const = 0;
};
点云内层循环会产生大量虚调用。
这通常不适合高频点处理。
点类型更适合模板和 traits。
算法外层可以用虚函数选择 ICP/GICP/NDT。
内层点访问用模板。
分层结构¶
配置文件
↓
运行时工厂选择算法对象
↓
虚接口 RegistrationBase
↓
具体算法类 GicpRegistration
↓
模板内核 RegistrationKernel<PointT, Policy>
↓
Eigen 小矩阵和点访问 traits
这条链路把变化分层。
不同层使用不同 C++ 工具。
可以把它写成接口骨架:
class RegistrationBase {
public:
virtual ~RegistrationBase() = default;
virtual Eigen::Isometry3d align(
const std::vector<Eigen::Vector3d>& source,
const std::vector<Eigen::Vector3d>& target) = 0;
};
template <typename PointT>
class IcpKernel {
public:
Eigen::Isometry3d align(const std::vector<PointT>& source,
const std::vector<PointT>& target);
};
外层 RegistrationBase 让配置系统能在运行期选择算法。内层 IcpKernel<PointT> 让点访问和小矩阵计算在编译期展开。两层不要混成一个“所有东西都是模板”的类,也不要把每个点都包装成虚接口。
类型擦除的位置¶
类型擦除常见形式是 std::function:
它适合低频控制逻辑,例如每次迭代结束判断是否停止。不适合每个点、每个残差、每个矩阵元素调用,因为它会隐藏具体类型并引入间接调用成本。类型擦除的价值是降低接口耦合,不是替代所有模板。
一个实用分界:
常见陷阱¶
⚠️ 工具陷阱:只因为模板更快就全部模板化
外层配置和插件边界需要运行时灵活性。模板不是部署配置机制。
⚠️ 性能陷阱:内层点访问做成虚函数
每点、每残差、每矩阵元素的热路径应避免虚分派。
⚠️ 维护陷阱:模板参数表达运行时含义
模板参数应该表达类型、维度、策略等编译期稳定信息。运行时参数应留在对象状态中。
练习¶
- 分层题:为点云配准系统画出运行时算法选择和编译期点类型适配的边界。
- 重构题:把
VoxelFilter<PointT, VoxelSize>改成VoxelFilter<PointT>+ 构造参数。 - 讨论题:为什么
PointT适合模板,而max_iterations通常不适合模板? - [跨章综合题]:结合 Eigen基础与SLAM数学预备 中 Eigen 的固定维度矩阵(
Matrix<double, 3, 1>的非类型模板参数3和1)和本章的非类型模板参数知识,解释为什么Eigen::Matrix<double, N, 1>中N是非类型模板参数而非运行时参数。设计一个模板函数template <int N> void processState(const Eigen::Matrix<double, N, 1>& state),让编译器根据N的不同值生成不同的优化代码。讨论:如果改用Eigen::VectorXd(动态大小),函数签名和内部实现需要如何变化?性能会有什么影响?
12.11 累积项目:Mini Template Registration ⭐⭐⭐⭐¶
项目目标¶
本章项目实现一个小型模板化点云注册工具。它不替代 PCL 或 Ceres,但要形成真实闭环:点类型通过 traits 访问,距离函数能处理 Eigen 表达式,体素滤波用哈希网格计算 centroid,注册内核完成最近邻对应和 SVD 刚体估计。
它的目标是练习:
- 用函数模板表达 Eigen 向量和点类型的距离计算。
- 用类模板表达
PointT这种编译期点结构变化。 - 用非类型模板参数表达 3D 注册内核的维度契约。
- 用 traits 把点字段访问从算法中剥离。
- 用显式实例化收敛常见点类型的编译成本。
- 把
voxel_size、对应距离阈值和迭代次数留作运行期配置。
目录:
mini_template_registration/
├── include/
│ ├── point_traits.hpp
│ ├── distance.hpp
│ ├── voxel_key.hpp
│ ├── voxel_filter.hpp
│ ├── registration_kernel.hpp
│ └── svd_transform.hpp
├── src/
│ └── voxel_filter_instantiations.cpp
└── tests/
├── point_traits_test.cpp
├── distance_test.cpp
├── voxel_filter_test.cpp
└── registration_kernel_test.cpp
点类型与 traits¶
先定义两个公共点类型:
#pragma once
namespace mini_registration {
struct PointXYZ {
double x = 0.0;
double y = 0.0;
double z = 0.0;
};
struct PointXYZI {
double x = 0.0;
double y = 0.0;
double z = 0.0;
double intensity = 0.0;
};
} // namespace mini_registration
再用 traits 表达“算法如何读写 xyz”:
#pragma once
#include <Eigen/Dense>
#include "point_types.hpp"
namespace mini_registration {
template <typename PointT>
struct PointTraits;
template <>
struct PointTraits<PointXYZ> {
using Scalar = double;
static constexpr int kDim = 3;
static Eigen::Vector3d xyz(const PointXYZ& p) {
return Eigen::Vector3d(p.x, p.y, p.z);
}
static void setXyz(PointXYZ& p, const Eigen::Vector3d& v) {
p.x = v.x();
p.y = v.y();
p.z = v.z();
}
};
template <>
struct PointTraits<PointXYZI> {
using Scalar = double;
static constexpr int kDim = 3;
static Eigen::Vector3d xyz(const PointXYZI& p) {
return Eigen::Vector3d(p.x, p.y, p.z);
}
static void setXyz(PointXYZI& p, const Eigen::Vector3d& v) {
p.x = v.x();
p.y = v.y();
p.z = v.z();
}
};
} // namespace mini_registration
PointT 不是任意类型。它必须有对应的 PointTraits<PointT>,并且 traits 要提供 xyz() 和 setXyz()。这就是模板接口的隐含契约。
函数模板:距离¶
#pragma once
#include <Eigen/Dense>
#include <stdexcept>
#include "point_traits.hpp"
namespace mini_registration {
template <typename DerivedA, typename DerivedB>
double squaredDistanceEigen(const Eigen::MatrixBase<DerivedA>& a,
const Eigen::MatrixBase<DerivedB>& b) {
static_assert(DerivedA::ColsAtCompileTime == 1 ||
DerivedA::ColsAtCompileTime == Eigen::Dynamic);
static_assert(DerivedB::ColsAtCompileTime == 1 ||
DerivedB::ColsAtCompileTime == Eigen::Dynamic);
if (a.rows() != b.rows()) {
throw std::invalid_argument("dimension mismatch");
}
if (a.cols() != 1 || b.cols() != 1) {
throw std::invalid_argument("expected column vectors");
}
return (a - b).squaredNorm();
}
template <typename PointA, typename PointB>
double squaredDistancePoint(const PointA& a, const PointB& b) {
const Eigen::Vector3d av = PointTraits<PointA>::xyz(a);
const Eigen::Vector3d bv = PointTraits<PointB>::xyz(b);
return (av - bv).squaredNorm();
}
} // namespace mini_registration
这里刻意分成两个函数:Eigen 表达式走 MatrixBase<Derived>,点类型走 PointTraits。如果写成一个过宽泛的 distance(T,U),错误信息会更晚、更难读。
体素 key 与哈希¶
体素滤波需要把连续坐标映射到离散格子。voxel_size 是运行期配置,体素 key 是运行期值,不应做成模板参数。
#pragma once
#include <cstddef>
#include <functional>
namespace mini_registration {
struct VoxelKey {
int x = 0;
int y = 0;
int z = 0;
bool operator==(const VoxelKey& other) const {
return x == other.x && y == other.y && z == other.z;
}
};
struct VoxelKeyHash {
std::size_t operator()(const VoxelKey& key) const {
const std::size_t hx = std::hash<int>{}(key.x);
const std::size_t hy = std::hash<int>{}(key.y);
const std::size_t hz = std::hash<int>{}(key.z);
return hx ^ (hy << 1) ^ (hz << 2);
}
};
} // namespace mini_registration
类模板:体素滤波¶
#pragma once
#include <cmath>
#include <stdexcept>
#include <unordered_map>
#include <vector>
#include <Eigen/Dense>
#include "point_traits.hpp"
#include "voxel_key.hpp"
namespace mini_registration {
template <typename PointT>
class VoxelFilter {
public:
explicit VoxelFilter(double voxel_size)
: voxel_size_(voxel_size) {
if (voxel_size_ <= 0.0) {
throw std::invalid_argument("voxel size must be positive");
}
}
std::vector<PointT> filter(const std::vector<PointT>& input) const {
struct Accumulator {
Eigen::Vector3d sum = Eigen::Vector3d::Zero();
int count = 0;
PointT representative{};
};
std::unordered_map<VoxelKey, Accumulator, VoxelKeyHash> grid;
grid.reserve(input.size());
for (const PointT& point : input) {
const Eigen::Vector3d p = PointTraits<PointT>::xyz(point);
const VoxelKey key{
static_cast<int>(std::floor(p.x() / voxel_size_)),
static_cast<int>(std::floor(p.y() / voxel_size_)),
static_cast<int>(std::floor(p.z() / voxel_size_))};
auto& acc = grid[key];
if (acc.count == 0) {
acc.representative = point;
}
acc.sum += p;
++acc.count;
}
std::vector<PointT> output;
output.reserve(grid.size());
for (auto& [key, acc] : grid) {
(void)key;
PointT centroid = acc.representative;
PointTraits<PointT>::setXyz(
centroid,
acc.sum / static_cast<double>(acc.count));
output.push_back(centroid);
}
return output;
}
private:
double voxel_size_;
};
} // namespace mini_registration
PointT 是编译期结构差异,voxel_size_ 是运行期配置。PointXYZI 的 intensity 在这个版本中保留体素内第一个代表点的强度;如果业务需要平均强度,应扩展 traits 或 accumulator,而不是在算法里硬编码字段名。
SVD 刚体估计¶
对应点已知时,用 SVD 估计刚体变换:
#pragma once
#include <stdexcept>
#include <vector>
#include <Eigen/Dense>
namespace mini_registration {
inline Eigen::Isometry3d estimateRigidSvd(
const std::vector<Eigen::Vector3d>& source,
const std::vector<Eigen::Vector3d>& target) {
if (source.size() != target.size() || source.size() < 3) {
throw std::invalid_argument("need at least three correspondences");
}
Eigen::Vector3d mean_source = Eigen::Vector3d::Zero();
Eigen::Vector3d mean_target = Eigen::Vector3d::Zero();
for (std::size_t i = 0; i < source.size(); ++i) {
mean_source += source[i];
mean_target += target[i];
}
mean_source /= static_cast<double>(source.size());
mean_target /= static_cast<double>(target.size());
Eigen::Matrix3d W = Eigen::Matrix3d::Zero();
for (std::size_t i = 0; i < source.size(); ++i) {
W += (source[i] - mean_source) *
(target[i] - mean_target).transpose();
}
Eigen::JacobiSVD<Eigen::Matrix3d> svd(
W,
Eigen::ComputeFullU | Eigen::ComputeFullV);
Eigen::Matrix3d U = svd.matrixU();
Eigen::Matrix3d V = svd.matrixV();
Eigen::Matrix3d R = V * U.transpose();
if (R.determinant() < 0.0) {
V.col(2) *= -1.0;
R = V * U.transpose();
}
Eigen::Isometry3d T = Eigen::Isometry3d::Identity();
T.linear() = R;
T.translation() = mean_target - R * mean_source;
return T;
}
} // namespace mini_registration
这个函数不是类模板,因为进入这一层后,点已经被 traits 转换成 Vector3d。模板变化在点访问层处理,SVD 层保持数学接口清晰。
注册内核¶
#pragma once
#include <limits>
#include <stdexcept>
#include <vector>
#include <Eigen/Dense>
#include "point_traits.hpp"
#include "svd_transform.hpp"
namespace mini_registration {
struct RegistrationResult {
Eigen::Isometry3d T_target_source = Eigen::Isometry3d::Identity();
int correspondences = 0;
double pre_alignment_nn_mse = 0.0;
};
template <typename PointT, int Dim>
class RegistrationKernel {
public:
static_assert(Dim == 3, "this chapter implements only 3D registration");
explicit RegistrationKernel(double max_correspondence_distance)
: max_sq_dist_(max_correspondence_distance *
max_correspondence_distance) {
if (max_correspondence_distance <= 0.0) {
throw std::invalid_argument(
"max correspondence distance must be positive");
}
}
RegistrationResult alignOnce(
const std::vector<PointT>& source,
const std::vector<PointT>& target) const {
if (source.empty() || target.empty()) {
throw std::invalid_argument("source and target must be non-empty");
}
std::vector<Eigen::Vector3d> matched_source;
std::vector<Eigen::Vector3d> matched_target;
double total_error = 0.0;
for (const PointT& sp : source) {
const Eigen::Vector3d sv = PointTraits<PointT>::xyz(sp);
double best_dist = std::numeric_limits<double>::infinity();
Eigen::Vector3d best_target = Eigen::Vector3d::Zero();
for (const PointT& tp : target) {
const Eigen::Vector3d tv = PointTraits<PointT>::xyz(tp);
const double dist = (sv - tv).squaredNorm();
if (dist < best_dist) {
best_dist = dist;
best_target = tv;
}
}
if (best_dist <= max_sq_dist_) {
matched_source.push_back(sv);
matched_target.push_back(best_target);
total_error += best_dist;
}
}
if (matched_source.size() < 3) {
throw std::runtime_error("not enough correspondences");
}
RegistrationResult result;
result.T_target_source =
estimateRigidSvd(matched_source, matched_target);
result.correspondences =
static_cast<int>(matched_source.size());
result.pre_alignment_nn_mse =
total_error / static_cast<double>(matched_source.size());
return result;
}
private:
double max_sq_dist_;
};
} // namespace mini_registration
这是最小闭环,不是完整 ICP。这里记录的是变换估计前的最近邻均方距离 pre_alignment_nn_mse,不是应用 T_target_source 后的最终配准残差。完整系统还会反复迭代、变换 source、重建对应、检查收敛和退化。但这里已经能检验模板边界:PointT 走 traits,Dim 走编译期断言,阈值走运行期构造参数,SVD 层保持非模板数学函数。
显式实例化¶
#include "point_types.hpp"
#include "registration_kernel.hpp"
#include "voxel_filter.hpp"
namespace mini_registration {
template class VoxelFilter<PointXYZ>;
template class VoxelFilter<PointXYZI>;
template class RegistrationKernel<PointXYZ, 3>;
template class RegistrationKernel<PointXYZI, 3>;
} // namespace mini_registration
这展示了 PCL 风格的组织方式。常见点类型可以由库预编译。自定义点类型则需要调用方提供 PointTraits<CustomPoint>,并确保模板定义可见,或在自己的 .cpp 中显式实例化。
项目检查¶
最小检查:
PointTraits<PointXYZ>::xyz()返回正确Vector3d,setXyz()能写回点字段。squaredDistanceEigen(Vector3d, Vector3d)返回正确平方距离,并能接收a + b表达式。squaredDistancePoint(PointXYZ, PointXYZI)只使用 xyz,不依赖 intensity。VoxelFilter<PointXYZ>对同一体素内两个点输出一个 centroid。VoxelFilter<PointXYZI>保留代表点 intensity,xyz 使用 centroid。VoxelFilter<PointT>对非正体素大小抛异常。estimateRigidSvd()能恢复已知旋转和平移,且det(R)接近 1。- 共线点集的 SVD 奇异值应进入后续诊断扩展,不应被当作高可信配准。
RegistrationKernel<PointXYZ, 3>对平移后的点集能估计出接近平移的T_target_source。RegistrationKernel<PointXYZ, 2>编译失败,因为本章内核只实现 3D。- 显式实例化文件覆盖
PointXYZ和PointXYZI;自定义点类型路径在文档中说明清楚。
12.12 前瞻:C++20 Concepts 如何简化模板约束 ⭐⭐⭐¶
本章多次提到模板的一个核心痛点:模板对类型的要求是隐式的——函数体里用了 <,类型就必须支持 <,但这个约束没有写在接口上。调用者只有在实例化失败时才知道自己的类型不满足要求,而此时错误信息已经嵌套了多层模板展开,极难阅读。
C++20 引入的 Concepts 正是为了解决这个问题。它允许你把模板对类型的要求**显式声明在接口上**,就像函数参数类型声明了"这个参数必须是 int"一样。
如果没有 Concepts(C++11/14/17 的做法):
// 隐式约束:T 必须支持 <,但接口上看不出来
template <typename T>
T maxValue(T a, T b) {
return a < b ? b : a; // 如果 T 没有 operator<,在这里报错
}
错误信息类似:error: no match for 'operator<' in 'a < b',嵌套在模板实例化栈中。
有 Concepts(C++20 的做法):
#include <concepts>
// 显式约束:T 必须满足 std::totally_ordered
template <std::totally_ordered T>
T maxValue(T a, T b) {
return a < b ? b : a;
}
如果 T 不满足 std::totally_ordered,错误信息直接说"约束不满足",而不是在函数体深处报 operator< 缺失。
自定义 Concept:
template <typename T>
concept EigenVector = requires(T v) {
{ v.norm() } -> std::convertible_to<double>;
{ v.rows() } -> std::convertible_to<int>;
{ v(0) };
};
template <EigenVector V>
double safeNorm(const V& v) {
return v.norm();
}
Concepts 对机器人代码的实际影响在于:它让模板接口的契约从"文档中的注释"变成了"编译器能检查的声明"。目前机器人社区的主流库(Eigen、PCL、Ceres、GTSAM)仍以 C++14/17 为目标,还没有广泛使用 Concepts。但新写的项目代码如果目标平台支持 C++20,应优先使用 Concepts 代替 SFINAE——后者的语法和错误信息都远不如 Concepts 友好。
本质洞察:Concepts 不是"新的模板语法",而是把模板系统从"鸭子类型"(duck typing)推向"显式接口"(explicit interface)。在鸭子类型中,"如果它走起来像鸭子,叫起来像鸭子,那它就是鸭子"——类型只要能通过函数体中的所有操作就行。Concepts 改为"它必须声明自己是鸭子"——约束在接口上显式表达,错误在调用点而非实例化深处报出。这是 C++ 模板系统 25 年来最重要的改进。
模板特化SFINAE与类型萃取-Concepts与Policy 会详细展开 Concepts 的完整用法、自定义约束和与 SFINAE 的对比。本章先建立基本认知:Concepts 是模板隐式约束的显式替代。
本章小结¶
本章讲的是模板基础,而不是模板技巧。
核心内容如下:
| 主题 | 核心判断 |
|---|---|
| 函数模板 | 编译期接口模式,类型必须满足函数体使用的操作 |
| 参数推导 | 按 P/A 规则从实参类型和值类别推导模板参数 |
| 转换边界 | 普通隐式转换不参与推导,显式模板参数后才进入调用转换 |
| 重载决议 | 候选、可行、最佳三步分析,普通函数不总是优先 |
| 显式指定 | 推导不足或需要控制类型时使用,不应用来掩盖接口混乱 |
| 类模板 | 对象结构按类型参数化,每个实例是不同类型 |
| 成员函数模板 | 类内部局部泛型操作 |
| CTAD | C++17 的类模板参数推导,适合局部辅助对象,不适合隐藏关键数学维度 |
typename |
告诉编译器依赖名字是类型 |
template |
告诉编译器依赖成员是模板 |
| two-phase lookup | 非依赖名字定义阶段解析,依赖名字实例化阶段解析 |
| 非类型模板参数 | 把维度、容量等编译期稳定值放入类型 |
| 编译模型 | 模板使用点需要看到定义,或依赖显式实例化 |
| ODR | 头文件模板定义必须在所有翻译单元保持一致 |
| PCL 模式 | .h 声明、.hpp 实现、.cpp 实例化 |
| 模板/虚函数/类型擦除 | 按变化发生在编译期还是运行期分层 |
最重要的工程判断是:
延伸阅读¶
| 资源 | 重点 |
|---|---|
| cppreference templates | 模板语法和规则细节 |
| C++ Templates: The Complete Guide | 系统理解模板机制 |
| Eigen 源码 | Matrix<Scalar, Rows, Cols> 和 MatrixBase<Derived> |
| PCL 源码 | .h/.hpp/.cpp 模板组织和显式实例化 |
| GTSAM 因子源码 | 类模板在因子图中的用途 |
| Ceres 自动微分教程 | 函数模板如何支持 Jet 标量 |
🔧 故障排查手册¶
| 现象 | 可能原因 | 检查路径 |
|---|---|---|
no matching function |
模板参数推导失败 | 看每个实参推导出的类型 |
dependent-name is not a type |
缺少 typename |
检查 T::value_type 等依赖类型 |
expected primary-expression before '<' |
缺少 template 关键字 |
检查 m.template block<>() |
| 链接时报模板函数缺失 | 定义不可见且未显式实例化 | 检查头文件和实例化 .cpp |
| 编译时间突然增加 | 模板实例过多 | 看新增类型组合 |
| Eigen 表达式不匹配 | 参数类型太具体 | 考虑 MatrixBase<Derived> |
| 自定义点类型不能用 PCL 算法 | 缺少点 traits 或实例化路径 | 检查点类型注册和模板实现可见性 |
| 运行时参数需要重新编译 | 错把配置做成模板参数 | 改成构造参数或配置对象 |