跳转至

函数模板与类模板基础

难度:⭐⭐⭐~⭐⭐⭐⭐ | 建议用时:2 周 | 前置要求:类型系统与值类别推导-Eigen基础与SLAM数学预备,尤其是函数、类、引用、移动语义、Eigen 基础


本章目标

学完本章后,你应该能做到:

  1. 把模板理解为编译期接口,而不是“少写重复代码”的语法技巧。
  2. 用 P/A 推导规则解释 TT&const T&T&&、数组、函数名和 const 的推导结果。
  3. 判断哪些隐式转换参与函数调用,哪些转换不参与模板参数推导。
  4. 按候选函数、可行函数、最佳匹配三步分析普通函数与函数模板的重载决议。
  5. 设计简单类模板、成员函数模板和 CTAD 推导指引,并理解每个类模板实例的对象身份。
  6. 理解 typenametemplate 与 two-phase lookup 在依赖上下文中的真实错误路径。
  7. 使用非类型模板参数表达维度、容量和策略边界,并知道什么时候该保留运行期配置。
  8. 解释模板定义可见性、ODR、显式实例化和 PCL .h/.hpp/.cpp 组织模式。
  9. 按“变化发生在编译期还是运行期”选择模板、虚函数或类型擦除。
  10. 为一个真实的点云注册/距离/体素滤波小工具设计可测试的模板接口。

前置自测

答不出两题以上,建议先回到 类型系统与值类别推导 的类型推导、RAII与智能指针 的函数重载、移动语义与完美转发 的引用和值类别、Eigen基础与SLAM数学预备 的 Eigen 编译期维度。模板错误之所以难读,是因为它们把“接口检查”提前到了编译期:编译器不是在运行算法,而是在为某组类型生成算法。

  1. auto x = expr 与模板参数 T value 的推导规则有什么共同点?
  2. template <typename T> void f(T);void f(T&)void f(const T&)void f(T&&) 面对 const int 左值时分别如何推导?
  3. add(1, 2.0) 为什么不能把 T 推导成 double?隐式转换为什么没有帮忙?
  4. std::vector<int>std::vector<double> 之间有没有继承或子类型关系?
  5. 模板成员函数为什么不能是 virtual
  6. 为什么很多模板实现必须放在头文件或 .hpp 中?
  7. 在模板里写 m.block<3,3>(0,0) 为什么有时需要 m.template block<3,3>(0,0)

本章在课程中的位置

Eigen基础与SLAM数学预备 讲了 Eigen。

Eigen 的核心类型本身就是模板:

Eigen::Matrix<double, 3, 1>
Eigen::Matrix<float, Eigen::Dynamic, Eigen::Dynamic>

如果不理解模板,你只能“照抄”这些类型。

理解模板后,你会看到它们表达的真实信息:

Scalar = double
Rows = 3
Cols = 1

机器人 C++ 代码中,模板不是炫技,也不是主要为了少打几个函数。它解决的是一个更根本的问题:

同一套算法,如何适配不同标量、不同点类型、不同维度、不同位姿类型?

例如:

算法 变化维度
点云滤波 PointXYZPointXYZI、自定义点类型
残差计算 double、Ceres 自动微分标量
因子图 Pose2Pose3Rot3
小矩阵运算 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++ 模板也是类似的:函数模板定义了算法结构(比较、加法、距离计算),不同的类型(intdoubleJet<double,N>)"填入"这个模板,编译器为每种类型生成格式一致但操作不同的机器码。两者的关键区别在于:文档模板在"使用时"产生结果,C++ 模板在"编译时"产生结果——这意味着模板的类型错误会在编译期暴露,而不是等到运行时。

在 SLAM 和机器人控制的代码中,同一个数学公式经常需要对多种类型工作。距离函数要同时处理 double 和 Ceres 的自动微分标量 Jet<double, N>,点云质心要同时处理 PointXYZPointXYZI,Kalman 滤波器要同时支持 15 维和 18 维状态。如果为每种类型各写一份函数,代码量会迅速膨胀,而且修改数学公式时要同步修改所有副本——漏改任何一份都会导致行为不一致的 bug。

如果 C++ 没有模板,PCL 的每种点类型(PointXYZPointXYZIPointXYZRGBA 等二十多种)都要复制一份完整的体素滤波算法。Eigen 的每种标量-维度组合(Matrix3dMatrix3fMatrixXd 等)都要各写一份矩阵乘法。Ceres 的自动微分根本无法实现——残差函数必须同时对 doubleJet 有效,没有模板就意味着每个残差写两份。模板不是"少写重复代码"的语法糖,它是让整个机器人 C++ 库生态得以存在的基础设施。

先看一个普通函数:

int maxInt(int a, int b) {
    return a < b ? b : a;
}

如果需要 double

double maxDouble(double a, double b) {
    return a < b ? b : a;
}

如果需要 float

float maxFloat(float a, float b) {
    return a < b ? b : a;
}

三段代码的算法完全一样。

不同的只有类型。

模板把类型变成参数:

template <typename T>
T maxValue(T a, T b) {
    return a < b ? b : a;
}

调用:

int i = maxValue(3, 5);
double d = maxValue(1.0, 2.0);

编译器会根据实参推导 T

模板不是宏

宏也能做类似事情:

#define MAX_VALUE(a, b) ((a) < (b) ? (b) : (a))

但宏有几个问题:

  1. 没有类型检查。
  2. 参数可能被多次求值。
  3. 错误信息指向展开后代码。
  4. 作用域控制弱。

模板是 C++ 类型系统的一部分。

它仍然会检查语法和类型。

例如:

struct NotComparable {};

NotComparable a;
NotComparable b;
auto c = maxValue(a, b);

如果 NotComparable 没有 operator<,编译器会报错。

这说明模板函数不是“任意类型都能用”。

它隐含要求 T 支持 <

Concepts与Policy 会用 Concepts 把这种要求写到接口上。

本章先理解模板基础机制。

模板实例化

如果编译器不做实例化会怎样? 一种替代设计是让模板在运行时根据类型分派——这正是 Java 泛型的做法(类型擦除)。Java 的 ArrayList<Integer>ArrayList<String> 在运行时是同一个类,类型参数在编译后被擦除。代价是:运行时无法为特定类型做优化(不能为 int 生成避免装箱的特化代码),也不能把类型信息用于编译期检查(不能拒绝 ArrayList<int> 因为基本类型不能做类型参数)。C++ 选择了相反的方向——为每种类型生成独立的机器码。代价是编译时间和二进制体积,收益是零运行时开销和完整的编译期类型检查。

模板本身不是函数。

它是生成函数的模式。

当你写:

int a = maxValue(1, 2);
double b = maxValue(1.0, 2.0);

编译器会生成类似两份函数:

int maxValue<int>(int a, int b);
double maxValue<double>(double a, double b);

这叫实例化。

读模板错误时要记住:

模板定义是一套模式。
模板实例是某个具体类型下生成的函数或类。

错误通常发生在实例化某个具体类型时。

模板实例化的编译模型:为什么模板定义必须放在头文件

初学者经常困惑:普通函数可以声明在 .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,用于告诉编译器"这个模板实例化已经在其他翻译单元中完成,不要在这里重复实例化"。它是编译加速的工具,不改变语义:

extern template class std::vector<int>;  // 不在本翻译单元实例化 vector<int>

PCL 库大量使用 extern template 和显式实例化来控制编译时间。PCL 头文件通常只包含模板声明,.cpp 文件中为常用点类型(PointXYZPointXYZIPointXYZRGBA)做显式实例化。这也解释了为什么 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();
}

同一函数现在支持:

Eigen::Vector3d a;
Eigen::Vector3d b;
double d = squaredDistance(a, b);

也支持自动微分标量。

前提是 T 支持 Eigen 所需的加减乘除。

常见陷阱

⚠️ 概念陷阱:以为模板能接受任何类型

模板只是把类型参数化。函数体里用到了 <+.norm(),类型就必须支持这些操作。

⚠️ 宏替代陷阱:用宏做类型泛型

泛型算法优先用模板。宏适合 预处理器与宏 中的预处理阶段能力,不适合普通类型抽象。

⚠️ 实例化陷阱:只看模板定义,不看出错的具体类型

模板错误要同时看模板定义和实例化点。很多错误不是模板本身错,而是某个类型不满足隐含要求。

练习

  1. 改写题:把 maxInt()maxDouble() 改成函数模板。
  2. 约束题:给 maxValue() 传入没有 < 的类型,观察错误信息。
  3. 机器人题:写一个 squaredNorm() 模板函数,支持 Eigen::Matrix<T, 3, 1>

12.2 函数模板参数推导 ⭐⭐⭐⭐

模板参数推导是 C++ 编译器最复杂的子系统之一,也是模板编程中最多"看不懂编译器在干什么"困惑的来源。理解推导规则的关键不是死记硬背表格,而是掌握一个核心原则:编译器在推导阶段只做模式匹配,不做类型转换。它把形参类型中的模板参数视为"未知数",把实参类型视为"已知数",通过模式匹配解出未知数。如果同一个模板参数被两个实参推导出不同的值(如 T = intT = double),推导直接失败——编译器不会替你决定"应该用哪个"。

这条规则对机器人代码有直接影响。Ceres 残差函数中,如果你把 Eigen::Vector3dT = 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 = parameter pattern,函数形参类型中含模板参数的模式
A = argument type,调用点实参表达式的类型和值类别

例如:

template <typename T>
T add(T a, T b) {
    return a + b;
}

auto x = add(1, 2);

对第一个参数,P = TA = int,推出 T = int。对第二个参数也推出 T = int,推导一致,所以实例化 add<int>

换成:

auto z = add(1, 2.0);

第一个参数推出 T = int,第二个参数推出 T = double。推导阶段不会先把 1 转成 double 再统一成 T = double,因为模板参数推导的目标是从实参类型中解出模板参数,而不是先做完整重载转换。需要调用者显式指定:

auto z = add<double>(1, 2.0);

此时 T 已知为 double,普通函数调用阶段才允许把 1 转成 double

也可以把接口改成两个模板参数:

template <typename A, typename B>
auto addMixed(A a, B b) {
    return a + b;
}

这会分别推导 A = intB = double,返回类型再由 a + b 决定。接口选择取决于你想表达什么不变量:add(T,T) 表达“两个输入同类型”,addMixed(A,B) 表达“两个输入可相加即可”。

TT&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& 不能绑定右值:

// byRef(3);  // 不可行

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>
void f(T value);

int data[3]{};
f(data);  // T = int*

引用参数保留数组类型和长度:

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); // 推导冲突

但如果显式给出模板参数,推导阶段结束,普通调用转换可以发生:

takeSame<double>(1, 2.0);  // 1 转成 double

这条规则对机器人代码很重要。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::Vector3dEigen::Matrix<ceres::Jet<double,N>,3,1> 混传,模板推导不会自动替你统一标量。常量和测量值应显式 .cast<T>()

Eigen::Matrix<T, 3, 1> measured = measured_double_.cast<T>();

Eigen 中的推导问题

Eigen 是模板参数推导在机器人代码中最重要的应用场景。Eigen 的所有操作(加法、乘法、转置、block 提取等)都返回"表达式模板"而非具体矩阵——这意味着 a + b 的返回类型不是 Eigen::Vector3d,而是一个复杂的表达式类型 CwiseBinaryOp<...>。如果你的模板函数参数写成具体的 Eigen::Matrix<T, Rows, Cols>,它就无法接受表达式;但如果写成 Eigen::MatrixBase<Derived>,它能接受所有 Eigen 表达式类型。

考虑:

template <typename Derived>
double normOf(const Eigen::MatrixBase<Derived>& v) {
    return v.norm();
}

调用:

Eigen::Vector3d p;
double n = normOf(p);

这里 Derived 会推导成具体 Eigen 类型。更重要的是,它也能接住表达式:

Eigen::Vector3d a;
Eigen::Vector3d b;
double n = normOf(a + b);

如果接口写成:

template <typename T>
double normOfConcrete(const Eigen::Matrix<T, 3, 1>& v);

它只适合具体 Matrix,不适合大量 Eigen 表达式。MatrixBase<Derived> 是 Eigen 泛型接口的基础模式。但要记住:Derived 可能是表达式类型,函数不能把它长期保存为引用成员,否则可能悬垂。

返回类型推导

模板函数的返回类型也可以参与推导。在 C++11 中,返回类型必须显式指定或使用尾返回类型 -> decltype(expr) 来推导。C++14 简化了这个过程——返回类型可以直接写 auto,让编译器从 return 语句的表达式类型推导。这在简单工具函数中非常方便,但在数值计算的公共接口中要谨慎使用——Eigen 的表达式模板会让 auto 返回一个临时表达式对象而非具体矩阵值,如果表达式引用了局部变量,就会产生悬垂引用。

C++14 起可以写:

template <typename A, typename B>
auto multiply(A a, B b) {
    return a * b;
}

返回类型由表达式推导。这适合局部工具,但公共数值接口要谨慎。对 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 无法同时是 intdouble

单模板参数要求相关实参推导一致。需要混合类型时,设计多个模板参数或显式指定模板参数。

⚠️ 转换陷阱:以为推导阶段会自动做隐式转换

add(1, 2.0) 不会先把 1 转成 double 再推导。显式指定 add<double> 后,普通调用转换才会发生。

⚠️ 退化陷阱:数组按值传递变成指针

想保留数组长度时,用引用形式 T (&array)[N]

⚠️ Eigen 陷阱:函数只接受具体 Matrix,拒绝表达式

泛型 Eigen 参数常用 MatrixBase<Derived>Ref,但不要长期保存表达式引用。

练习

  1. 推导题:判断 add(1,2)add<double>(1,2)addMixed(1,2.0) 的模板参数和返回类型。
  2. 验证题:用 std::is_same_v 验证 TT&const T&T&& 面对 const int 左值和右值时的推导结果。
  3. 数组题:写一个模板函数打印 C 数组长度,并解释为什么按值参数拿不到长度。
  4. Eigen 题:写 normOf(MatrixBase<Derived>),测试它能否接受 a + b

12.3 显式模板参数、重载与接口清晰度 ⭐⭐⭐

模板参数推导虽然方便,但并非总是可行。当模板参数只出现在返回类型中、或者需要指定的类型与实参类型不同时,必须显式指定模板参数。此外,当函数模板和普通函数同时存在时,重载决议规则会在模板函数和非模板函数之间做出选择——理解这个选择规则对设计清晰的泛型接口至关重要。

显式模板参数和函数模板重载的交互,是 C++ 模板系统中容易产生困惑的区域。核心规则很简单:在同等匹配质量下,非模板函数优先于模板函数。这条规则的设计动机是让程序员能够为特定类型提供"手工优化"版本,覆盖泛型模板的默认行为。

什么时候需要显式指定

显式指定模板参数:

auto value = add<double>(1, 2.5);

常见原因:

场景 示例 原因
推导冲突 add<double>(1, 2.5) 两个实参类型不同
返回类型无法从参数推导 makeZero<Vector3d>() 模板参数只出现在返回类型
需要控制标量 point.cast<float>() 精度选择
调用模板成员函数 m.template block<3,3>(0,0) 依赖上下文消歧

显式模板参数不是失败后的万能补丁。它的合理用途是:调用点掌握了编译期类型意图,但这些意图无法从函数实参完整推导出来。常见于返回类型工厂、标量控制、Eigen .cast<T>()、Ceres 自动微分残差中的常量转换。

模板参数只在返回类型中

template <typename T>
T makeZero() {
    return T::Zero();
}

调用时无法从参数推导 T

必须写:

Eigen::Vector3d p = makeZero<Eigen::Vector3d>();

这类函数适合工厂或类型构造。

但如果滥用,会让调用点变啰嗦。

函数模板与普通重载

可以同时写:

void logValue(double value) {
    std::cout << "double: " << value << "\n";
}

template <typename T>
void logValue(const T& value) {
    std::cout << "generic: " << value << "\n";
}

分析重载时,不要只问“哪个看起来更像”。C++ 重载决议大致分三步:

  1. 候选函数:名字查找找到的所有同名函数和函数模板实例候选。
  2. 可行函数:实参数量匹配,且每个实参都能转换到形参类型。
  3. 最佳匹配:比较转换序列成本、模板/非模板偏序等规则,选出最优。

例如:

logValue(1.0);

候选有普通函数 void logValue(double) 和模板实例 void logValue<double>(const double&)。普通函数是精确匹配,模板也是可行。通常非模板函数在同等匹配质量下优先,因此会调用普通函数。

但这个例子:

logValue(1);

普通函数需要 int -> double 转换,模板可以实例化为 logValue<int>(const int&),后者匹配更好,所以可能调用模板版本。

这种组合适合:

  1. 常见类型走专门路径。
  2. 其他类型走通用路径。

模板特化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 收敛约束。

另一个常见场景是字符串字面量:

void setName(std::string name);

template <typename T>
void setName(T value);

setName("lidar");

字符串字面量类型是 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,函数体里的常量、矩阵和中间变量也必须尊重这个承诺。

常见陷阱

⚠️ 显式指定陷阱:用显式模板参数掩盖接口问题

如果每次调用都要写很长模板参数,可能说明函数参数无法帮助推导,接口设计需要调整。

⚠️ 重载陷阱:普通函数和模板函数匹配结果不符合直觉

读重载问题时,要看候选函数、转换成本和模板推导结果。

⚠️ 标量陷阱:模板内部写死 double

Ceres 自动微分残差中,内部向量应使用 Matrix<T, ...>,常量用 .cast<T>()T(...)

练习

  1. 工厂题:写 makeIdentity<MatrixType>(),返回矩阵单位阵。
  2. 重载题:写一个普通 logValue(double) 和模板 logValue(T),观察不同调用选择。
  3. 残差题:把一个写死 Vector3d 的点变换函数改成标量模板。

12.4 类模板:把类型参数放进对象结构 ⭐⭐⭐⭐

前面三节解决了"函数如何适配不同类型"的问题——函数模板、参数推导和重载决议。但函数只是计算的载体,数据需要住在对象里。当对象本身也需要参数化时——容器的元素类型、滤波器的状态维度、点云的点类型——就需要类模板。

类模板把模板的威力从函数扩展到了对象结构。函数模板让同一个算法适配不同类型,类模板让同一个数据结构适配不同类型。两者结合,就能构建出像 Eigen Matrix<Scalar, Rows, Cols> 这样同时参数化标量类型、行数和列数的数值基础设施。

理解类模板的关键是:每种模板参数组合生成一个独立的类类型vector<int>vector<double> 不是同一个类的两个实例——它们是两个完全独立的类,各有自己的成员函数、内存布局和 ABI。这意味着 vector<int>vector<double> 之间没有继承关系,不能互相赋值,也不能放入同一个容器(除非通过类型擦除或基类指针)。这个”类型隔离”特性是类模板安全性的来源,也是某些场景下不便利的原因。

动机:容器和算法对象也需要泛型

如果 C++ 没有类模板会怎样? 你需要为每种点类型分别定义一个容器类:PointCloudXYZPointCloudXYZIPointCloudXYZRGBA……每个类的内部逻辑(添加点、删除点、遍历点)完全相同,只是元素类型不同。当你修改了遍历逻辑(比如加了线程安全锁),需要在所有 20 个容器类中同步修改——漏改任何一个都会引入不一致的行为。Java 在泛型出现之前(JDK 1.5 之前)就经历了这种痛苦:所有集合都存储 Object 引用,每次取出元素都需要强制类型转换,运行时才发现类型错误。C++ 的类模板在编译期就消除了这种不安全的类型转换。

函数模板解决”同一函数适配不同类型”——一个 squaredDistance<T>() 函数能同时处理 doubleJet 标量。但如果数据结构本身也需要参数化呢?一个点云缓冲区需要根据点类型决定每个元素占多少内存、如何存储和访问。一个 Kalman 滤波器需要根据状态维度决定协方差矩阵的大小。这些不是”函数如何计算”的问题,而是”对象如何组织数据”的问题——这就是类模板要解决的层次。

类模板解决”同一对象结构适配不同类型”。

最常见例子:

std::vector<int>
std::vector<double>
std::vector<Eigen::Vector3d>

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_;
};

使用:

PointBuffer<Eigen::Vector3d> positions;
positions.add(Eigen::Vector3d(1.0, 2.0, 3.0));

类模板实例是不同类型

PointBuffer<Eigen::Vector3d> a;
PointBuffer<Eigen::Vector2d> b;

ab 类型不同。

不能直接互相赋值。

这对接口设计很重要。

如果一个函数只接受 PointBuffer<Vector3d>,它不会接受 PointBuffer<Vector2d>

这背后的对象模型很直接:类模板不是一个运行期类,PointBuffer<Eigen::Vector3d>PointBuffer<Eigen::Vector2d> 是两个不同的类。它们有各自的成员函数实例、各自的 std::vector<PointT> 成员类型,也可能有不同对象大小和不同 ABI。

可以验证:

static_assert(!std::is_same_v<
    PointBuffer<Eigen::Vector3d>,
    PointBuffer<Eigen::Vector2d>>);

这对机器人接口影响很大。PointCloud<PointXYZ>PointCloud<PointXYZI> 没有继承关系,即使两个点类型都包含 x,y,z。如果算法只需要 xyz,应该通过模板、traits 或 Eigen::Ref 式视图表达“我需要 xyz 能力”,而不是期待容器之间自动转换。

隐藏维度风险

类模板的灵活性也带来一个微妙的风险:模板参数可能隐藏了对算法正确性至关重要的数学信息。在机器人代码中,状态向量的维度、协方差矩阵的大小、李群的自由度都是算法公式中的关键常量。如果这些信息被埋在模板参数的深处,代码审查者很难一眼看出"这个滤波器处理的是 15 维状态还是 18 维状态"。

类模板很容易把关键数学维度藏起来:

template <typename StateVector>
class Filter {
public:
    void correct(const StateVector& dx);
};

调用点看到 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 中的类模板思路

因子图里有很多变量类型:

Pose2
Pose3
Rot3
Point3

一个 Between 因子的逻辑是:

预测相对关系 = between(x_i, x_j)
误差 = measured^{-1} * predicted

这个逻辑对 Pose2Pose3 都成立。

因此可以写成类似:

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 里常见:

pcl::PointCloud<pcl::PointXYZ>
pcl::PointCloud<pcl::PointXYZI>
pcl::VoxelGrid<pcl::PointXYZ>

点类型影响:

  1. 点字段有哪些。
  2. 内存布局是什么。
  3. 算法能访问哪些信息。

例如体素滤波只需要 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 残差函数用模板支持 doubleJet<double, N> 两种标量类型(编译期多态);前端里传感器接口用虚函数支持不同 LiDAR 驱动(运行时多态);ROS 回调用 std::function 注册消息处理函数(类型擦除)。三者各司其职,不应该强行统一到一种机制。

常见陷阱

⚠️ 实例类型陷阱:Container<A>Container<B> 没有继承关系

即使 A 能转换成 BContainer<A> 也不会自动转换成 Container<B>

⚠️ 接口陷阱:类模板参数过多

参数越多,调用点越难读。能通过默认参数、traits 或 Policy 分层的,不要都堆在一个模板参数列表里。

⚠️ 语义陷阱:模板参数名太泛

typename T 在小函数里可以;类模板公共接口中,PointTScalarStateDim 更清楚。

练习

  1. 实现题:写 PointBuffer<PointT>,支持 add()size()operator[]
  2. 接口题:设计 KdTree<PointT, Dim> 的类模板参数,并说明每个参数的语义。
  3. 阅读题:找一个 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:这是为了防止隐式的精度降低转换(如从 doublefloat)静默发生。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 要谨慎。

数值类型转换可能丢精度。

默认让调用者显式写出转换更稳。

成员模板与普通成员

成员函数模板不能是虚函数。

原因是虚函数需要运行时虚表入口。

模板函数则在编译期按类型实例化,实例数量不固定。

因此下面想法不成立:

class Base {
public:
    template <typename T>
    virtual void process(const T& value);
};

如果需要运行时多态,使用普通虚函数、类型擦除或公共基类。

如果需要编译期泛型,使用模板。

不要把两个机制强行混在同一个函数上。

常见陷阱

⚠️ 虚函数陷阱:成员函数模板不能是 virtual

运行时多态和编译期模板解决不同问题。插件边界用虚函数,数值内核用模板。

⚠️ 隐式转换陷阱:跨标量构造过于宽松

doublefloat 可能丢精度。公共接口中常用 explicit

⚠️ 职责陷阱:类模板和成员模板同时承担太多变化

类模板负责对象主类型,成员模板负责局部转换或泛型操作。两者边界要清楚。

练习

  1. 实现题:为 Vector3<Scalar>cast<OtherScalar>()
  2. 判断题:为什么成员函数模板不能是虚函数?
  3. 设计题:一个点云滤波器既要支持不同 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)显式告诉编译器应该如何推导。

它允许编译器从构造函数实参推导类模板参数。

标准库例子:

std::vector values{1, 2, 3};

推导为:

std::vector<int>

再如:

std::pair p{1, 2.0};

推导为:

std::pair<int, double>

自定义类模板的推导

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>;

推导指引告诉编译器:

用两个 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 向量推导 ScalarDim

template <typename Scalar, int Dim>
SmallVector(const Eigen::Matrix<Scalar, Dim, 1>&)
    -> SmallVector<Scalar, Dim>;

调用点:

Eigen::Vector3d p = Eigen::Vector3d::Zero();
SmallVector wrapped(p);  // SmallVector<double, 3>

但这种简洁也隐藏了维度。对数学核心对象,显式写 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};

它能让代码更简洁。

但对于数学类型,显式类型常常更好:

Eigen::Matrix3d R = Eigen::Matrix3d::Identity();

比让读者从构造推导矩阵类型更清楚。

判断 CTAD 是否合适,可以问三个问题:

  1. 推导出的类型是否是读者真正关心的数学契约?
  2. 构造参数是否唯一决定模板参数?
  3. 将来修改构造参数时,类型是否可能无意变化?

std::lock_guard lock(mutex) 的类型不重要,CTAD 很合适。Eigen::Matrix3d R 的维度和标量很重要,显式类型更合适。RegistrationKernel<PointXYZ, 3> 中点类型和维度决定算法路径,也应显式写出。

常见陷阱

⚠️ 可读性陷阱:CTAD 让重要类型消失

数学代码中,维度和标量类型很重要。不要为了少写几个字符,让读者看不出是 Matrix3d 还是 MatrixXd

⚠️ 推导陷阱:初始化列表推导出意外类型

std::vector v{1, 2, 3} 很清楚;混合类型初始化可能推导失败或得到不符合预期的类型。

⚠️ 接口陷阱:公共 API 过度依赖 CTAD

CTAD 适合调用点简化,不适合隐藏关键数学契约。

练习

  1. 推导题:判断 std::pair p{1, 2.0}std::array a{1,2,3} 的类型。
  2. 设计题:为 Range<Iterator> 写推导指引。
  3. 讨论题:为什么 Eigen 数学代码中经常仍然显式写 Matrix3d

12.7 依赖名称、typenametemplate 关键字 ⭐⭐⭐⭐

typenametemplate 关键字在模板代码中的使用是初学者最常遇到的"看不懂的编译错误"之一。这两个关键字的存在不是语言设计的疏忽,而是 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 明确告诉编译器:

Container::value_type 是一个类型。

这背后是 two-phase lookup(两阶段名字查找)。理解这个机制,是理解 typenametemplate 关键字为什么必要的关键。

两阶段名字查找的完整机制

C++ 编译器处理模板代码时,不是"看到模板定义就跳过、等实例化时再检查"。它会在两个不同的时间点做两次名字解析——这就是 two-phase lookup:

第一阶段(模板定义阶段):编译器读到模板定义时(还没有任何实例化),立刻对模板代码做初步检查。在这个阶段,编译器做三件事:

  1. 检查所有不依赖模板参数的名字(非依赖名字)。例如 std::coutstd::size_t、全局函数名。这些名字必须在定义点已经可见,否则立刻报错——即使模板从未被实例化。
  2. 检查基本语法结构。括号配对、分号、关键字的正确使用都在此阶段验证。
  3. 遇到依赖于模板参数的名字时,**推迟查找**到第二阶段,但仍然需要知道它的"语法类别"——是类型名还是非类型名,是模板还是非模板。这就是 typenametemplate 存在的原因。

第二阶段(模板实例化阶段):当模板被某个具体类型实例化时(例如 printFirst(std::vector<int>{})),编译器用具体类型替换模板参数,然后解析第一阶段推迟的依赖名字。此时 Container 变成了 std::vector<int>Container::value_type 变成了 std::vector<int>::value_type(即 int),编译器能完全确认它的含义。

为什么需要两个阶段? 这个设计的根本原因是 C++ 模板的"开放性"——模板参数可以是任何类型,不同类型可能让同一个名字有完全不同的含义。考虑:

template <typename T>
void f() {
    T::x * y;  // 这是声明一个 T::x 类型的指针变量 y?还是乘法表达式 T::x * y?
}

如果 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 下报错——通常是因为缺少 typenametemplate

缺少 typename

错误写法:

template <typename Container>
void f(const Container& c) {
    Container::value_type value = c[0];
}

编译器可能把 Container::value_type 当作非类型名字解析。

正确写法:

typename Container::value_type value = c[0];

真实错误路径通常是这样的:

template <typename Cloud>
void reserveLike(const Cloud& cloud) {
    Cloud::PointType p = cloud.points.front();
    (void)p;
}

如果 Cloud::PointType 是依赖类型,编译器在模板定义阶段不能确认它是类型,于是报类似:

need 'typename' before 'Cloud::PointType' because 'Cloud' is a dependent scope

修复:

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:

matrix.block<3, 3>(0, 0)

在非模板代码里这样写没问题。

但在模板中:

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 关键字告诉编译器:

block 是一个模板成员函数。

这个错误常见于 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 关键字在这里不是修饰返回类型,而是给解析器一个消歧信号。

两个关键字的分工

本质洞察typenametemplate 关键字的存在揭示了一个深层设计权衡:C++ 选择在"编译器可能误解"的地方要求程序员显式消歧,而不是像某些语言那样推迟所有检查到实例化阶段。这种设计让编译器能在模板定义阶段就捕获非依赖名字的错误——你拼错了 std::cout,即使模板从未被实例化也会报错。代价是程序员需要手动添加 typenametemplate,这在 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 只能放在类型名前:

typename T::value_type value;

template 放在依赖对象或依赖作用域之后、成员模板名之前:

matrix.template block<3, 3>(0, 0);

两者可以同时出现:

typename Traits<T>::template Rebind<double>::Type value;

这种写法在初学阶段不常写,但读库源码时会遇到。拆开看就不神秘:Traits<T> 依赖 TRebind 是成员模板,Type 是依赖类型。

常见陷阱

⚠️ 语法陷阱:在模板函数里调用 Eigen block<>() 忘记 template

只要对象类型依赖模板参数,调用模板成员函数时就要考虑 m.template block<...>()

⚠️ 类型陷阱:依赖类型名前忘记 typename

T::value_typetraits<T>::ScalarCloud::PointType 常需要 typename

⚠️ 记忆陷阱:把两个关键字混用

typename 修饰类型名;template 修饰成员模板调用。它们解决的歧义不同。

练习

  1. 修错题:修复一个模板函数中 Container::value_type 缺少 typename 的错误。
  2. Eigen 题:写 topLeft3x3(MatrixBase<Derived>),正确使用 template block
  3. 解释题:用自己的话说明为什么编译器在模板定义阶段无法确定依赖名字。

12.8 非类型模板参数:把维度放进编译期 ⭐⭐⭐⭐

动机:维度是机器人数学的重要契约

本质洞察:非类型模板参数把"数值常量"提升为"类型身份的一部分"。Matrix<double, 3, 1> 中的 31 不是对象的属性——它们是类型的属性。这个区别至关重要:对象属性只能在运行时检查(if (v.size() != 3) throw ...),类型属性在编译时就被检查(Matrix<double, 3, 1> 不能赋值给 Matrix<double, 4, 1>)。把维度从对象层面提升到类型层面,等价于把运行时错误转化为编译时错误——这是 C++ 类型系统最强大的工程价值之一。

如果没有非类型模板参数会怎样? 所有 Eigen 矩阵都只能是 MatrixXd——动态大小。3x3 旋转矩阵和 1000x1000 的 Hessian 矩阵用同一个类型表达,编译器无法区分它们。一个接受 MatrixXd 参数的函数可以被传入任何尺寸的矩阵——3x3 的旋转矩阵不小心传到了期望 4x4 齐次矩阵的函数中,编译器不会报错,只有运行时才会崩溃(或者更糟:静默产生错误结果)。非类型模板参数让 Matrix3dMatrix4d 成为不同类型,编译器在函数边界就阻止了这种混用。

Eigen 类型:

Eigen::Matrix<double, 3, 1>

这里的 31 是非类型模板参数。

它们不是类型。

它们是编译期整数。

你也可以写自己的维度模板:

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> pixel(Point<2>::Vector::Zero());
Point<3> landmark(Point<3>::Vector::Zero());

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();
};

这样编译器能检查 FGQ 的维度组合。

如果把这些都写成 MatrixXd,错误会更晚暴露。

非类型模板参数的关键约束是:它的值必须在编译期已知,并且适合作为类型身份的一部分。ErrorStateKalmanFilter<15, 12>ErrorStateKalmanFilter<18, 15> 是不同类型。它们的成员函数会分别实例化,成员 Covariance 的对象大小也不同。

这适合表达“算法结构维度”:

15 维 ESKF、6 维李代数、3 维点、固定容量环形缓冲

不适合表达频繁变化的部署参数:

体素大小、最大迭代次数、话题名、传感器外参文件路径

这些值应作为构造参数或配置对象存在。

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,作为模板参数很合理:

template <int Dim>
using PointVector = Eigen::Matrix<double, Dim, 1>;

而体素滤波的 max_points_per_voxel 可能随数据集调整,即使它是整数,也不一定适合做模板参数。模板参数越多,类型组合越多,编译时间、二进制体积和错误信息都会增长。

非类型模板参数与 Eigen 的 int

Eigen 的维度模板参数使用 int,并用 Eigen::Dynamic 表示动态维度:

Eigen::Matrix<double, 3, Eigen::Dynamic>

标准库容器容量常用 std::size_t

template <typename T, std::size_t Capacity>
class FixedBuffer;

两者不要混用得含糊。Eigen 维度接口里用 int 更自然;数组容量、索引上界等标准库语义用 std::size_t 更自然。跨边界时应显式转换并检查范围,避免负值、窄化转换和编译器警告被忽略。

常见陷阱

⚠️ 灵活性陷阱:把运行时配置做成非类型模板参数

模板参数编译期固定。部署时要改 YAML 的值,不适合做模板参数。

⚠️ 代码体积陷阱:维度组合太多

Filter<15,12>Filter<18,15>Filter<21,18> 都会实例化不同代码。基础库要控制组合数量。

⚠️ 类型转换陷阱:intstd::size_t 混用

模板维度常用 int 配合 Eigen,容器大小常用 std::size_t。接口中要清楚区分。

练习

  1. 实现题:写 Point<Dim>,内部使用 Matrix<double, Dim, 1>
  2. 滤波题:设计 ErrorStateKalmanFilter<StateDim, NoiseDim> 的类型别名。
  3. 取舍题:判断体素大小、状态维度、最大迭代次数分别适合编译期还是运行期。

12.9 模板编译模型与显式实例化 ⭐⭐⭐⭐

为什么模板通常写在头文件

模板必须放在头文件中的原因,可以类比到"菜谱 vs 成品菜"。普通函数就像预制好的成品菜——厨房(.cpp)做好了,服务员(链接器)直接端到客人桌上。模板更像菜谱——客人(调用方)说"我要一份牛排"时,厨房需要看到完整的菜谱才能做出"牛排版本"的菜。如果菜谱锁在另一个厨房里(定义在其他 .cpp 中),当前厨房只看到"这里有道菜叫 X"(只有声明),就无法做出任何东西。显式实例化相当于"厨房预先按菜谱做好了牛排和鸡排放在保温柜里"——客人只能从预制的选项中挑选,不能点菜谱上没做好的新口味。

这个问题每个 C++ 初学者都会遇到:把模板函数的定义放在 .cpp 文件中,编译没问题,链接时却报"未定义符号"。这不是语法错误,而是 C++ 编译模型的根本限制。理解这个限制需要回到"分离编译"的基本原理。

普通函数可以在头文件声明、.cpp 定义:

// math.hpp
double norm3(const Eigen::Vector3d& v);
// math.cpp
double norm3(const Eigen::Vector3d& v) {
    return v.norm();
}

模板不同。

当编译器在某个 .cpp 里看到:

auto value = normGeneric(Eigen::Vector3d{});

它需要看到模板定义,才能生成 normGeneric<Vector3d>

因此模板通常放在头文件:

template <typename VectorT>
double normGeneric(const VectorT& v) {
    return v.norm();
}

更准确地说,模板实例化点必须能看到完整定义。只有声明不够,因为编译器不知道要生成什么机器代码。普通函数可以先编译 .cpp 得到一个符号,调用方只需要声明;模板函数需要在调用方根据具体类型生成实例,或者链接到库中已经显式生成的实例。

这也是很多模板链接错误的来源:

编译阶段:看到了模板声明,语法通过
链接阶段:没有任何翻译单元生成 normGeneric<Vector3d>,符号缺失

解决方式只有两类:

  1. 把模板定义放到头文件或被头文件包含的 .hpp/.ipp 中。
  2. 在某个 .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 中的:

template class VoxelFilter<PointXYZ>;

如果头文件写了 extern template class VoxelFilter<PointXYZ>;,但 .cpp 里忘了对应显式实例化,编译可能通过,链接会失败。显式实例化把错误从“模板定义不可见”变成“支持类型清单必须完整维护”。

PCL 的 .h/.hpp/.cpp 模式

PCL 经常把模板代码分成:

voxel_grid.h     声明类模板和接口
voxel_grid.hpp   模板成员函数实现
voxel_grid.cpp   对常见 PointT 显式实例化

这种组织方式的目的:

  1. 让接口声明相对清楚。
  2. 把实现从主头文件中分离出来。
  3. 对常见点类型预编译,降低调用方编译成本。
  4. 仍然允许扩展方包含 .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,要么库提供实例,要么调用方能包含模板实现。

练习

  1. 组织题:把 VoxelFilter<PointT> 拆成 .hpp 声明、.ipp 实现、.cpp 实例化三部分。
  2. 链接题:只在 .cpp 定义模板函数,不显式实例化,观察链接错误。
  3. 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 <double VoxelSize>
class VoxelFilter;

在很多标准版本中,浮点非类型模板参数支持有限。

更重要的是,体素大小通常来自配置文件。

部署时改参数不应重新编译。

更合适:

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

using StopCondition = std::function<bool(int iteration,
                                         double cost,
                                         double step_norm)>;

它适合低频控制逻辑,例如每次迭代结束判断是否停止。不适合每个点、每个残差、每个矩阵元素调用,因为它会隐藏具体类型并引入间接调用成本。类型擦除的价值是降低接口耦合,不是替代所有模板。

一个实用分界:

每帧一次、每次迭代一次、每个模块一次:可以接受虚函数或 std::function
每个点一次、每个残差一次、每个矩阵元素一次:优先模板或内联函数

常见陷阱

⚠️ 工具陷阱:只因为模板更快就全部模板化

外层配置和插件边界需要运行时灵活性。模板不是部署配置机制。

⚠️ 性能陷阱:内层点访问做成虚函数

每点、每残差、每矩阵元素的热路径应避免虚分派。

⚠️ 维护陷阱:模板参数表达运行时含义

模板参数应该表达类型、维度、策略等编译期稳定信息。运行时参数应留在对象状态中。

练习

  1. 分层题:为点云配准系统画出运行时算法选择和编译期点类型适配的边界。
  2. 重构题:把 VoxelFilter<PointT, VoxelSize> 改成 VoxelFilter<PointT> + 构造参数。
  3. 讨论题:为什么 PointT 适合模板,而 max_iterations 通常不适合模板?
  4. [跨章综合题]:结合 Eigen基础与SLAM数学预备 中 Eigen 的固定维度矩阵(Matrix<double, 3, 1> 的非类型模板参数 31)和本章的非类型模板参数知识,解释为什么 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 刚体估计。

它的目标是练习:

  1. 用函数模板表达 Eigen 向量和点类型的距离计算。
  2. 用类模板表达 PointT 这种编译期点结构变化。
  3. 用非类型模板参数表达 3D 注册内核的维度契约。
  4. 用 traits 把点字段访问从算法中剥离。
  5. 用显式实例化收敛常见点类型的编译成本。
  6. 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 中显式实例化。

项目检查

最小检查:

  1. PointTraits<PointXYZ>::xyz() 返回正确 Vector3dsetXyz() 能写回点字段。
  2. squaredDistanceEigen(Vector3d, Vector3d) 返回正确平方距离,并能接收 a + b 表达式。
  3. squaredDistancePoint(PointXYZ, PointXYZI) 只使用 xyz,不依赖 intensity。
  4. VoxelFilter<PointXYZ> 对同一体素内两个点输出一个 centroid。
  5. VoxelFilter<PointXYZI> 保留代表点 intensity,xyz 使用 centroid。
  6. VoxelFilter<PointT> 对非正体素大小抛异常。
  7. estimateRigidSvd() 能恢复已知旋转和平移,且 det(R) 接近 1。
  8. 共线点集的 SVD 奇异值应进入后续诊断扩展,不应被当作高可信配准。
  9. RegistrationKernel<PointXYZ, 3> 对平移后的点集能估计出接近平移的 T_target_source
  10. RegistrationKernel<PointXYZ, 2> 编译失败,因为本章内核只实现 3D。
  11. 显式实例化文件覆盖 PointXYZPointXYZI;自定义点类型路径在文档中说明清楚。

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 或实例化路径 检查点类型注册和模板实现可见性
运行时参数需要重新编译 错把配置做成模板参数 改成构造参数或配置对象