跳转至

变参模板、折叠表达式与 CRTP

难度:⭐⭐⭐~⭐⭐⭐⭐ | 建议用时:2 周 | 前置要求:类型系统与值类别推导 类型系统与值类别、移动语义与完美转发、函数模板与类模板基础、模板特化SFINAE与类型萃取 模板特化与类型萃取


前置自测

答不出 2 题以上时,先回顾 类型系统与值类别推导、移动语义与完美转发、函数模板与类模板基础 和 模板特化SFINAE与类型萃取。

  1. typename... ArgsArgs&&... argsstd::forward<Args>(args)... 中三个省略号分别在展开什么?
  2. 函数参数 T&& 在什么条件下是转发引用?为什么进入函数体后 args 都是左值?
  3. (... && checks) 对空参数包的结果是什么?(... + values) 对空参数包为什么不成立?
  4. class SO3 : public LieGroupBase<SO3> 中,LieGroupBase<SO3> 的成员函数里为什么能 static_cast<SO3&>(*this)
  5. Eigen 表达式模板为什么可能让 auto expr = A + B; 保存“表达式”而不是“结果矩阵”?

本章目标

学完本章,你应能:

  • 精确说明参数包能在什么位置展开,以及多个包同时展开时的长度约束。
  • 从递归展开理解 C++11 变参模板,再迁移到 C++17 折叠表达式。
  • 区分一元/二元、左/右折叠,解释空包 identity、求值顺序和短路边界。
  • 把 类型系统与值类别推导 的值类别、引用折叠和 移动语义与完美转发 的完美转发重新连成一条规则链。
  • 用 CRTP 实现静态多态,并解释 static_cast<Derived&>(*this) 成立的对象模型条件。
  • 识别 CRTP 的不完整类型边界、错误继承风险、构建成本和错误信息成本。
  • 用小型表达式模板理解“先记录表达式树、再求值”的模式,以及 .eval() 的时机。
  • 设计“外层运行时选择,内层静态内核”的机器人库结构。

本章在课程中的位置:模板特化SFINAE与类型萃取 解决“类型能力不同怎么办”。本章解决两个新的变化维度:参数数量不同,以及基类希望在编译期知道派生类。前者对应变参模板和折叠表达式,后者对应 CRTP 和表达式模板。Eigen、Sophus、manif、nanoflann、IKFoM 这些机器人 C++ 库的高级接口,背后都依赖这两条主线。


14.1 变参模板:参数个数也是类型系统的一部分 ⭐⭐⭐

工程问题:日志、工厂和因子构造参数数量不固定

机器人系统里常见这类调用:

logLine("frame=", frame_id, " points=", point_count, " time=", stamp);
auto factor = makeFactor<PriorFactor<Pose3>>(key, prior, noise);
auto between = makeFactor<BetweenFactor<Pose3>>(key1, key2, relative, noise);

参数数量和参数类型都在变化。如果为每个参数个数写重载,接口会爆炸;如果统一收成字符串,又会丢失类型信息、引入格式化开销和错误延迟。

变参模板的历史背景与设计动机

在理解变参模板的语法之前,有必要理解它要解决的根本问题。这个问题的核心可以用一句话概括:C++ 的类型系统如何表达”参数数量未知”这一维度的变化?

在 C++11 之前,程序员面对”不确定数量的参数”只有两条路:

第一条路:C 风格可变参数(va_list/va_arg)。 这是 printf 家族使用的机制。调用者可以传入任意数量的参数,被调函数通过 va_startva_argva_end 宏逐个提取。但这种机制完全放弃了类型安全——编译器不知道第三个参数是 int 还是 double,格式串和实参类型不匹配时行为未定义。更严重的是,C 风格可变参数不支持非 POD 类型(如 std::stringEigen::Vector3d),因为 va_arg 不会调用构造函数或析构函数。在机器人项目中,几乎所有有意义的类型都是非 POD 的,因此 C 风格可变参数从一开始就不是可行选项。

第二条路:为每个参数个数提供重载。 这是 C++03 时代的标准做法。Boost.Tuple、Boost.Bind 和早期的 std::make_pairstd::make_tuple 都是这样实现的——为 1 个参数、2 个参数、3 个参数......一直到某个上限(通常是 10 或 20 个)分别写一个重载或偏特化。Boost.Preprocessor 库专门用宏来批量生成这些重载,但生成出来的代码维护起来极其痛苦,IDE 和调试器也很难处理几十个几乎相同的函数签名。

变参模板的设计动机正是要消除这两条路的缺陷:在保持完整类型安全的前提下,让参数数量成为类型系统的一部分。它的核心洞见是——既然 C++ 模板已经能把”类型”作为参数(template<typename T>),那么把”类型的序列”也作为参数就是一个自然的推广。typename... Args 声明的不是一个类型参数,而是一个类型参数包——它可以代表零个、一个或任意多个类型。编译器在模板实例化时根据调用者提供的实参推导出包的内容,然后按模式展开生成代码。

这个设计选择有一个深远的后果:参数包是编译期构造,不是运行时容器。编译器在编译时就知道包里有几个元素、每个元素是什么类型,因此可以对每个元素做独立的类型检查和优化。这和 std::vector<std::any> 这种运行时容器形成鲜明对比——后者把类型信息擦除了,编译器无法做静态检查。变参模板的代价是每种不同的参数组合都会实例化一份独立的代码,可能增加编译时间和二进制体积;但收益是零运行时开销和完整的类型安全。

从语言设计的角度看,变参模板的引入标志着 C++ 模板系统从”固定参数个数的泛型”走向”参数个数也参与泛型”的新阶段。这使得标准库中大量需要固定个数重载的组件(如 std::tuplestd::variantstd::function)可以用统一的机制实现,也为后来的折叠表达式(C++17)和 std::apply(C++17)奠定了基础。

反面失败:重载数量随参数个数增长

void logLine(const std::string& a);
void logLine(const std::string& a, int b);
void logLine(const std::string& a, int b, const std::string& c);
void logLine(const std::string& a, int b, const std::string& c, double d);

这类接口无法覆盖真实系统。因子图构造、ROS2 参数注册、事件上报、任务队列封装都需要”任意数量、任意类型”的参数。

抽象不变量:参数包保存的是一列类型和一列表达式

template <typename... Args>
void logLine(Args&&... args);

这里有两层包:

写法 含义
typename... Args 类型参数包,可能是 int, double, const char*
Args&&... args 函数参数包,每个类型对应一个实参表达式
sizeof...(Args) 类型包长度
sizeof...(args) 值包长度
std::forward<Args>(args)... 对每个参数分别转发

调用:

logLine("frame=", 42, " score=", 0.9);

可以理解为:

Args = {const char (&)[7], int, const char (&)[8], double}
args = {"frame=", 42, " score=", 0.9}
sizeof...(Args) = 4

这里的“包”不是运行时容器。它没有 begin()end(),也不能在运行时按下标访问。参数包是编译期语法结构:编译器在实例化模板时,把同一个模式复制多份,再把每一份中的包元素替换成对应类型或表达式。

这一区别很重要:

template <typename... Args>
void f(Args... args) {
    // args 不是一个数组,下面这种写法不成立:
    // args[0]
}

如果确实需要运行时索引,应先把参数包放入 std::tuplestd::array 或业务容器;如果只是逐个调用、逐个构造、逐个检查,直接展开参数包更合适。

规则推导:参数包为什么用 ... 展开——语言设计的选择

在进入展开位置的具体规则之前,有一个值得思考的语言设计问题:为什么 C++ 要用 ... 这种展开语法,而不是像 Python 的 *args 那样简单地"解包"?

Python 的 *args 模型是运行时解包:args 是一个 tuple 对象,*args 在函数调用时把 tuple 的元素依次取出作为位置参数。解包的对象是一个运行时容器,解包操作发生在运行时。这个模型简单直观,但它要求 args 是一个具体的数据结构——有明确的类型、可以迭代、可以按下标访问。

C++ 的参数包不是运行时容器。Args... 是一个编译期的类型序列,args... 是一个编译期的表达式序列。它们没有统一的类型(每个参数可能是不同类型),不存在于任何数据结构中,也不能在运行时迭代。C++ 选择 ... 作为展开语法,正是因为展开操作的本质是**编译期的模式复制**——编译器把 ... 左侧的模式复制多份,每份替换不同的包元素。

这种设计选择有一个重要后果:展开不限于函数调用的参数位置。因为 ... 的语义是"把模式复制到允许逗号分隔列表的任何位置",所以参数包可以在函数实参、模板实参、初始化列表、基类列表、成员初始化列表甚至 lambda 捕获中展开。Python 的 *args 只能在函数调用中使用,因为解包操作绑定在函数调用的语义上;C++ 的 ... 能在更多位置使用,因为模式复制是通用的语法操作。

但这种通用性也带来了复杂性。f(args...) 展开后是 f(a1, a2, a3),这和 Python 的 f(*args) 结果相同;但 std::tuple<Args*...> 展开后是 std::tuple<A1*, A2*, A3*>——模式 Args* 中的 * 被保留在每份拷贝中。这种"展开模式而非展开名字"的行为,是 C++ 参数包最容易误解的地方。

参数包展开不是只能出现在函数调用里。常见位置如下:

展开位置 示例 作用
函数调用实参 f(args...) 把包展开成参数列表
模板实参 std::tuple<Args...> 形成异构类型
初始化列表 int dummy[] = {(use(args), 0)...}; C++11 中常用于逐个执行
基类列表 struct X : Bases... {} 继承多个基类
成员初始化 X(Args... xs) : members(xs)... {} 初始化多个成员
lambda 捕获 [args...] {} C++20 前后支持形式略有差异
折叠表达式 (std::cout << ... << args) C++17 直接折叠

为什么要支持这么多展开位置?因为 C++ 的元编程需求跨越了声明和表达式两个层面。函数调用展开解决"把参数传给另一个函数";模板实参展开解决"根据参数类型构造复合类型";基类列表展开解决"继承多个混入基类"。如果只支持函数调用一个位置,std::tuplestd::variant、Mixin 继承这些基础设施都无法用变参模板实现。

省略号展开的不是单个名字,而是它左侧或周围的**模式**。看下面三个例子:

#include <tuple>
#include <utility>

template <typename... Args>
using PointerTuple = std::tuple<Args*...>;

template <typename F, typename... Args>
void callEach(F&& f, Args&&... args) {
    (std::forward<F>(f)(std::forward<Args>(args)), ...);
}

template <typename... Args>
auto tieAsTuple(Args&... args) {
    return std::tuple<Args&...>(args...);
}

Args*... 的模式是 Args*,展开结果是 A*, B*, C*std::forward<Args>(args)... 的模式是整段 std::forward<Args>(args),每次展开会同时替换一个类型和一个表达式。Args&... 的模式是 Args&,用于形成一列引用类型。

判断一个省略号绑定到哪里,可以问两个问题:

问题 判断方式
哪些名字是包 查找 typename...class...auto... 或函数参数包
哪段语法被复制 ... 所在的最小可展开模式

错误经常出现在“以为只展开名字,实际展开了整个模式”。例如 std::pair<Args, Args>... 会得到 std::pair<A, A>, std::pair<B, B>;它不会自动形成所有两两组合。若要笛卡尔积,需要另写嵌套模板或普通生成逻辑。

多个包在同一个模式里同时展开时,长度必须一致。实际代码里通常不要写两个相邻的裸类型包,因为编译器没有函数参数或外层类型帮助时,很难知道该从哪里把 A...B... 分开。更清楚的写法是先把两组类型放进两个外层列表:

#include <array>
#include <type_traits>

template <typename... Ts>
struct TypeList {};

template <typename AList, typename BList>
struct SameTypes;

template <typename... A, typename... B>
struct SameTypes<TypeList<A...>, TypeList<B...>> {
    static_assert(sizeof...(A) == sizeof...(B),
                  "packs must have the same length");

    static constexpr auto value() {
        return std::array<bool, sizeof...(A)>{std::is_same_v<A, B>...};
    }
};

std::is_same_v<A, B>... 的每一次展开都需要同时取一个 A 和一个 B。长度不一致时无法成对展开。TypeList<A...>TypeList<B...> 的外壳让分组边界显式可见,也让错误信息更接近真实意图。

这种规则不是库限制,而是语法规则。一个展开模式里出现两个包时,编译器必须知道第 0 个 A 配第 0 个 B、第 1 个 A 配第 1 个 B。如果两个包长度不同,最后几项没有配对对象,展开式没有明确含义。

类型层面的 zip 可以这样理解:

#include <tuple>
#include <utility>

template <typename AList, typename BList>
struct ZipTypes;

template <typename... A, typename... B>
struct ZipTypes<TypeList<A...>, TypeList<B...>> {
    static_assert(sizeof...(A) == sizeof...(B),
                  "type lists must have the same length");

    using type = std::tuple<std::pair<A, B>...>;
};

std::pair<A, B>... 是成对展开,不是先展开所有 A 再展开所有 B。它生成的是 pair<A0, B0>, pair<A1, B1>, ...

空包也是合法包。调用 logLine() 时,Argsargs 长度都是 0;std::tuple<Args...> 会变成 std::tuple<>f(args...) 会变成 f()。因此,设计变参接口时要明确零参数是否有语义:

#include <iostream>
#include <utility>

template <typename... Args>
void requireAtLeastOne(Args&&... args) {
    static_assert(sizeof...(Args) > 0,
                  "at least one argument is required");
    (std::cout << ... << std::forward<Args>(args));
}

另一种写法是把第一个参数单独拿出来:

#include <iostream>
#include <utility>

template <typename First, typename... Rest>
void printNonEmpty(First&& first, Rest&&... rest) {
    std::cout << std::forward<First>(first);
    ((std::cout << ' ' << std::forward<Rest>(rest)), ...);
}

这类接口天然不接受空包,因为模板参数 First 无法从零个实参中推导出来。若零参数代表“什么也不做”,可以保留单个包;若零参数是调用错误,应把这个约束写在函数签名或 static_assert 上。

工程边界:参数包不是“无结构参数垃圾桶”

变参模板适合参数数量变化但处理规则统一的场景。若每个参数都有业务含义,命名结构体往往更好:

struct IcpConfig {
    double voxel_size;
    int max_iterations;
    double max_correspondence_distance;
};

configure(0.2, 30, 1.5)configure(IcpConfig{...}) 更难读,也更容易把参数顺序传错。

代码验证:参数数量和类型仍然保留

#include <cstddef>
#include <type_traits>

template <typename... Args>
constexpr std::size_t countArgs(Args&&...) {
    return sizeof...(Args);
}

static_assert(countArgs() == 0);
static_assert(countArgs(1) == 1);
static_assert(countArgs(1, 2.0, "lidar") == 3);

template <typename... Args>
constexpr bool allArithmetic(Args&&...) {
    return (std::is_arithmetic_v<std::remove_reference_t<Args>> && ...);
}

static_assert(allArithmetic(1, 2.0, 3.0f));
static_assert(!allArithmetic(1, "scan"));

这里没有把参数转成字符串或基类指针。类型信息仍在编译期可用。

变参模板的引入从根本上改变了 C++ 泛型编程的表达能力。在 C++11 之前,处理"不确定数量的参数"只有两条路:要么用 C 风格的可变参数(printf...,完全放弃类型安全),要么为每个参数个数写一个重载(Boost.PP 宏生成,维护噩梦)。变参模板让"参数数量"成为类型系统的一部分——编译器知道有几个参数、每个参数是什么类型,可以做编译期检查和优化。

可以把参数包类比成数学中的"有限序列"。typename... Args 声明了一个类型序列 \((T_1, T_2, \ldots, T_n)\)Args&&... args 声明了一个对应的值序列 \((v_1, v_2, \ldots, v_n)\)sizeof...(Args) 就是序列长度 \(n\)。展开操作 f(args...) 就是把序列展开成函数调用的参数列表 \(f(v_1, v_2, \ldots, v_n)\)。不同的是,数学序列可以用下标随机访问,而参数包只能通过展开、递归或 std::tuple 间接访问——这是编译期语法结构与运行时数据结构的根本区别。

本质洞察:参数包不是"编译期容器"。它没有 begin()end()operator[],不能在运行时迭代。参数包是**编译期的代码生成指令**——它告诉编译器"把同一个模式复制 N 份,每份替换不同的类型和表达式"。理解这一点,就能明白为什么展开位置受限、为什么不能运行时索引、为什么多个包同时展开时长度必须一致。

⚠️ 编程陷阱:以为参数包可以用下标访问 错误做法:写 args[0]std::get<0>(args...) 来访问参数包的第一个元素。 现象:编译错误,args 不是数组、不是 tuple、不支持下标。 根本原因:参数包是语法结构,不是运行时对象。编译器在模板实例化时会展开参数包,但展开前它不是任何 C++ 类型的实例。 正确做法:如果需要按位置访问,先把参数包放入 std::tupleauto t = std::make_tuple(args...); std::get<0>(t);。或者用"头+尾"递归拆分:template <typename Head, typename... Tail> void f(Head&& head, Tail&&... tail);

💡 概念误区:认为参数包展开是运行时循环 新手想法:"f(args...) 就是在运行时遍历每个参数调用 f。" 实际上:参数包展开完全发生在编译期。f(a, b, c) 不是"循环三次调用 f",而是"生成一次以三个参数调用 f 的代码"。(g(args), ...) 是逗号折叠表达式,会生成 g(a), g(b), g(c) 这样的顺序表达式,但这不是循环——它是编译器直接生成的三条语句。 正确理解:把参数包展开想象成"编译期的复制粘贴"——编译器把模式中的包名替换成每个具体参数,生成 N 份代码。

🧠 思维陷阱:把所有"多个参数"的场景都用变参模板 新手想法:"变参模板能接受任意参数,所以我应该把所有函数都改成变参模板。" 实际上:当每个参数有明确的业务含义时(如 ICP 配置的体素大小、最大迭代次数、最大对应距离),命名结构体比参数包更好。configure(0.2, 30, 1.5)configure(IcpConfig{.voxel_size=0.2, .max_iter=30, .max_dist=1.5}) 更容易传错参数顺序。 正确思维:变参模板适合"参数数量变化但处理规则统一"的场景(日志、工厂、注册)。"参数数量固定但可能需要默认值"的场景用默认参数或 builder 模式,"每个参数有不同语义"的场景用命名结构体。

练习

  1. [推导题]:写出 logLine("frame=", 42, " score=", 0.9) 的完整展开过程:Args 被推导为什么?args 是什么?sizeof...(Args) 是多少?std::forward<Args>(args)... 展开后是什么?
  2. [代码题]:写一个 makeVector() 变参模板函数,接受任意数量的同类型参数,返回 std::vector<T>。要求:使用 static_assert 确保所有参数类型相同,用折叠表达式或初始化列表构造向量。
  3. [跨章综合题]:回顾 模板特化SFINAE与类型萃取 的 enable_if 和 detected idiom。设计一个 logIfPrintable(args...) 函数,只输出那些支持 operator<< 的参数,跳过不支持的参数。提示:需要结合逗号折叠和 if constexpr

14.2 从递归展开到折叠表达式 ⭐⭐⭐

参数包递归展开的编译器行为:模板实例化模型

在进入具体代码之前,理解编译器如何处理参数包的递归展开非常重要。这不是运行时的函数递归,而是编译期的模板实例化递归——两者在机制、代价和终止条件上完全不同。

编译期递归 vs 运行时递归。 运行时递归是一个函数在执行过程中调用自己,每次调用消耗栈空间,终止条件是某个运行时值(如 n == 0)。编译期递归(模板实例化递归)是编译器在处理模板时发现需要实例化另一个模板,而那个模板又需要实例化更多模板。每次”递归”消耗的不是运行时栈空间,而是编译器的内存和时间。终止条件不是运行时值,而是模板参数包为空时匹配到非模板重载。

这种区分至关重要。当你写 printRecursive(“a”, 42, 3.14) 时,编译器并不是在运行时调用三层函数。编译器在编译时为每种参数类型组合生成一个独立的函数实例——printRecursive<const char*, int, double>printRecursive<int, double>printRecursive<double>printRecursive()——共四个函数。在优化级别 -O1 以上,这些函数通常被完全内联到调用点,最终生成的机器码是三条连续的输出语句,没有任何函数调用开销。

编译器的模板实例化深度限制。 由于模板实例化递归消耗编译器资源,所有主流编译器都有实例化深度限制。GCC 和 Clang 默认限制为 1024 层(可通过 -ftemplate-depth=N 调整),MSVC 默认为 500 层。如果参数包有 2000 个元素(在某些代码生成场景中确实可能发生),递归展开就会触及这个限制。折叠表达式不受此限制,因为它只产生一个函数实例。

编译时间和错误信息的代价模型。 递归展开的编译时间代价与参数数量成正比——N 个参数产生 N+1 个函数实例,每个实例都需要做模板参数推导、类型检查和代码生成。更隐蔽的代价是错误信息深度:如果第 N 个参数类型不满足要求(如不支持 operator<<),错误信息会包含从最外层调用到第 N 层实例化的完整调用栈。对于 20 个参数的日志调用,这可能意味着 40 行以上的诊断输出。这就是为什么 C++17 引入折叠表达式后,递归展开在新代码中应尽量被替代——不是因为运行时性能不同,而是因为编译时代价更低、错误信息更短。

工程问题:C++11 老代码为什么写递归模板

C++11 引入变参模板时,还没有折叠表达式。逐个处理参数通常靠”头 + 尾”递归:

#include <iostream>
#include <utility>

void printRecursive() {
    std::cout << '\n';
}

template <typename Head, typename... Tail>
void printRecursive(Head&& head, Tail&&... tail) {
    std::cout << std::forward<Head>(head);
    printRecursive(std::forward<Tail>(tail)...);
}

调用:

printRecursive("frame=", 42, " cost=", 1.5);

展开直觉:

printRecursive("frame=", 42, " cost=", 1.5)
  输出 "frame="
  printRecursive(42, " cost=", 1.5)
    输出 42
    printRecursive(" cost=", 1.5)
      输出 " cost="
      printRecursive(1.5)
        输出 1.5
        printRecursive()
          输出换行

递归终止函数 printRecursive() 是必要的。没有零参数版本,递归到最后会找不到可调用函数。

递归为什么会终止,可以从模板推导看清楚。每次调用都把参数包拆成一个 Head 和剩余的 Tail...

调用层级 Head Tail... 长度
第 1 层 "frame=" 3
第 2 层 42 2
第 3 层 " cost=" 1
第 4 层 1.5 0
第 5 层 调用零参数重载

只要每一步都消耗一个参数,Tail... 的长度就严格减少,最终进入零参数重载。若递归函数没有减少包长度,例如继续调用 printRecursive(std::forward<Head>(head), std::forward<Tail>(tail)...),那就不是展开,而是递归调用自身;运行时会无限递归直到栈溢出。

空包输入也由同一套规则处理:

printRecursive();

它直接选择零参数重载,不会实例化 Head, Tail... 版本。理解这一点后,就能区分“递归终止条件”与“空包合法性”:终止函数使递归展开有终点,空包调用则是终止函数本身的正常入口。

递归展开 vs 折叠表达式:编译器视角的代码生成差异

递归模板和折叠表达式最终生成的运行时代码通常相同(在优化后),但它们在编译器内部的处理方式和代价模型有显著差异。理解这些差异,有助于在工程中做出正确的选择。

递归展开的编译器处理过程

当编译器看到 printRecursive("frame=", 42, " cost=", 1.5) 时,它需要做以下工作:

实例化 printRecursive<const char(&)[7], int, const char(&)[7], double>
  → 生成函数体:输出 "frame=",调用 printRecursive(42, " cost=", 1.5)

实例化 printRecursive<int, const char(&)[7], double>
  → 生成函数体:输出 42,调用 printRecursive(" cost=", 1.5)

实例化 printRecursive<const char(&)[7], double>
  → 生成函数体:输出 " cost=",调用 printRecursive(1.5)

实例化 printRecursive<double>
  → 生成函数体:输出 1.5,调用 printRecursive()

选择非模板重载 printRecursive()
  → 生成函数体:输出换行

编译器为 4 个参数生成了 5 个独立的函数实例(4 个模板实例 + 1 个终止重载)。每个实例都有自己的类型签名、函数体、调试信息和可能的错误诊断上下文。在优化阶段(-O1 以上),编译器会尝试把这些函数内联到调用点,最终生成一段顺序执行的代码。但在编译的模板实例化阶段,这 5 个函数是真实存在的独立实体。

如果参数数量增加到 20 个(在日志系统中很常见),编译器需要实例化 21 个函数。如果其中某个参数类型有问题(比如不支持 operator<<),错误信息会包含整个递归调用栈——每一层实例化都会出现在错误信息中,导致几十行甚至上百行的诊断输出。

折叠表达式的编译器处理过程

当编译器看到 (std::cout << ... << args) 时,它的处理更加直接:

实例化 printFold<const char(&)[7], int, const char(&)[7], double>
  → 展开折叠表达式为:
    (((std::cout << "frame=") << 42) << " cost=") << 1.5
  → 这是一条完整的表达式,在单个函数实例中求值

编译器只生成 1 个函数实例,其中包含一条展开后的长表达式。没有递归调用,没有终止函数,没有多层实例化。如果某个参数类型不支持 operator<<,错误信息直接指向这条展开的表达式,而不是一长串递归调用栈。

代码生成对比

维度 递归展开 折叠表达式
模板实例化数量 N+1 个函数(N 个参数) 1 个函数
编译时间 随参数数量线性增长 几乎不随参数数量增长
错误信息深度 N 层嵌套 1 层(指向展开的表达式)
生成的机器码(优化后) 相同(内联后合并) 相同
表达能力 每一步可以做不同操作 所有步骤必须使用同一个运算符

何时仍然需要递归? 折叠表达式的限制在于"所有参数都用同一个运算符组合"。如果每一步的处理逻辑不同(例如第一个参数是格式字符串、第二个参数是值、第三个参数是后缀),折叠表达式无法直接表达。此时需要递归展开或辅助函数。

另一个递归不可避免的场景是"累积状态"。例如构建一个字符串时需要在每个参数之间插入分隔符:

recursive: head + "," + recursive(tail...)  // 每一步都需要上一步的结果
fold:      (args + ... + ???)               // 无法在元素之间插入分隔符

这种情况下可以用辅助 lambda 和逗号折叠组合,但代码可读性可能不如显式递归。

规则推导:C++17 折叠表达式让常见递归变成语法

template <typename... Args>
void printFold(Args&&... args) {
    (std::cout << ... << std::forward<Args>(args)) << '\n';
}

这大致等价于:

(((std::cout << arg1) << arg2) << arg3) << ...

折叠表达式不是新的能力,而是把常见展开模式标准化。读老代码时要能识别递归展开;写新 C++17 代码时,优先用折叠表达式表达简单统一操作。

同一个打印函数还可以写成逗号折叠:

template <typename... Args>
void printFoldComma(Args&&... args) {
    ((std::cout << std::forward<Args>(args)), ...);
    std::cout << '\n';
}

输出折叠 (std::cout << ... << args) 更像“把流和所有片段组合起来”;逗号折叠更像“逐个执行输出动作”。二者都常见,选择时看你想强调值组合还是顺序副作用。

工程边界:递归仍有可读价值

如果每一步处理都要改变状态、产生不同返回类型,或在展开过程中维护复杂上下文,显式递归或普通辅助函数可能比一行折叠更容易读。折叠表达式适合”对每个参数做同一种操作,然后组合结果”。

从递归展开到折叠表达式的演进,可以类比从手写循环到 STL 算法的演进。手写 for 循环灵活但容易出错(忘记终止条件、越界访问);std::for_each 和范围 for 把循环结构标准化,减少了出错机会。同样,递归展开灵活但容易忘记终止函数或写错展开方向;折叠表达式把”逐个处理参数”标准化为一行语法。代价是折叠表达式只能表达简单的统一操作,复杂的逐步处理仍然需要递归。

⚠️ 编程陷阱:递归展开忘记写终止函数 错误做法:只写了 template <typename Head, typename... Tail> void print(Head&& head, Tail&&... tail) 的递归版本,没有写零参数的终止重载。 现象:编译错误(Tail... 为空时找不到匹配的 print()),或者更隐蔽——如果恰好有一个同名但签名不匹配的函数,可能调用了错误的重载。 根本原因:递归展开的终止条件不是自动产生的。每次递归消耗一个参数,最终 Tail... 为空,此时需要一个零参数版本来结束递归。 正确做法:始终为递归展开提供一个明确的终止重载。C++17 中可以用 if constexpr (sizeof...(Tail) > 0) 在同一个函数内终止,避免额外重载。

💡 概念误区:认为递归模板会导致运行时递归 新手想法:”递归展开会在运行时一层层调用函数,浪费栈空间。” 实际上:递归展开是编译期机制。编译器为每种参数数量实例化一个独立的函数。在优化级别 -O1 以上,这些函数通常会被完全内联,最终生成的机器码是顺序执行的,没有函数调用开销。 正确理解:递归模板的”递归”发生在编译期(模板实例化),不是运行时。编译成本(更多模板实例化、更长的错误信息)是真实的,但运行时成本通常为零。

练习

  1. [代码题]:用 C++11 递归展开写一个 countTypes<Args...>(),返回参数包中满足 std::is_floating_point_v 的类型个数。然后用 C++17 折叠表达式重写。对比两种实现的代码量。
  2. [分析题]:给定 printRecursive(1, 2.0, “hello”),画出完整的模板实例化树:哪些函数被实例化?每个实例的 HeadTail... 分别是什么?最终调用了几个不同的函数?

14.3 折叠表达式:形式、空包和求值顺序 ⭐⭐⭐

工程问题:把一组参数组合成一个结果

常见需求:

allFinite(a, b, c)
sumCost(r1, r2, r3)
appendAll(vector, x, y, z)
trace("iter=", iter, " cost=", cost)

这些都可以看成”把参数包折成一个值或一串副作用”。

折叠表达式的设计哲学:从函数式编程借鉴的核心抽象

折叠表达式不是 C++ 凭空发明的概念。它直接来自函数式编程中的 fold/reduce 操作——这是计算机科学中最基本的列表处理原语之一。理解这个数学背景,有助于深入掌握折叠表达式的形式选择和边界情况。

fold 的数学定义。 给定一个二元运算 \(\oplus\)、一个序列 \((a_1, a_2, \ldots, a_n)\) 和一个初始值 \(e\),左折叠(fold-left)定义为:

\[\text{foldl}(\oplus, e, [a_1, \ldots, a_n]) = (\cdots((e \oplus a_1) \oplus a_2) \cdots) \oplus a_n\]

右折叠(fold-right)定义为:

\[\text{foldr}(\oplus, e, [a_1, \ldots, a_n]) = a_1 \oplus (a_2 \oplus (\cdots (a_n \oplus e) \cdots))\]

\(\oplus\) 满足结合律时(如加法、乘法),左右折叠结果相同;当 \(\oplus\) 不满足结合律时(如减法、除法、字符串拼接、流输出),方向决定语义。

为什么 C++ 选择了四种形式而不是两种? Haskell 只提供 foldlfoldr,都需要初始值。C++ 多了两种”一元折叠”(没有初始值)。设计动机是工程实用性:很多常见的折叠操作有”自然的”初始值。逻辑与的”空集上全称命题为真”(true)、逻辑或的”空集上不存在满足项”(false)、逗号表达式的”什么也不做”(void())——这些都是数学和逻辑上有明确定义的空操作结果。C++ 标准把这三个运算符的空包 identity 写进了语言规则,让一元折叠在零参数时仍然合法。

但对于加法、乘法、最大值、最小值这类运算,”空序列的结果”在不同业务中含义不同:统计计数的空包求和应该是 0,但概率乘积的空包结果应该是 1,而工资总额的空包结果可能应该报错。标准没有为算术运算符定义空包 identity,要求程序员用二元折叠显式给出初始值。这不是疏忽,而是有意的设计决策——强制程序员思考空包的业务含义,避免隐含的默认值在特定业务场景中产生错误结果。

折叠方向的工程含义。 在纯数学中,结合运算的折叠方向无关紧要。但在 C++ 中,运算符可能被重载,重载后的行为可能不满足结合律。最典型的例子是 operator<< 的流输出——std::cout << a << b 的括号结构是 (std::cout << a) << b,必须让 std::ostream 始终在左侧,因此必须用左折叠。如果写成右折叠 a << (b << std::cout),语义完全不同甚至无法编译。

另一个不满足结合律的例子是 std::string 的拼接。(s1 + s2) + s3s1 + (s2 + s3) 的最终结果相同,但性能可能不同:左折叠 ((s1 + s2) + s3) 先创建 s1+s2 的临时对象,再把 s3 追加上去;右折叠 s1 + (s2 + s3) 先创建 s2+s3 的临时对象,再把 s1 前置。如果 s1 已经有足够的预分配容量,左折叠可能只需要一次内存分配;右折叠可能需要两次。这说明折叠方向不仅影响语义正确性,还可能影响性能。

std::accumulate 到折叠表达式的演进脉络。 C++98 的 std::accumulate 是折叠操作的运行时版本:它接受迭代器范围、初始值和二元运算,在运行时遍历容器并累积结果。C++17 的折叠表达式是编译期版本:它在编译时展开参数包,生成一条完整的表达式。两者的关系类似于运行时循环和编译期展开的关系——折叠表达式生成的是”平坦的”表达式代码,没有循环判断和迭代器解引用的开销。对于参数包长度在编译期已知的场景(如日志、工厂构造、前置条件检查),折叠表达式比 std::accumulate 更直接。

规则推导:四种折叠形式

形式 写法 展开直觉
一元右折叠 (args + ...) a1 + (a2 + (a3 + ...))
一元左折叠 (... + args) ((a1 + a2) + a3) + ...
二元右折叠 (args + ... + init) a1 + (a2 + (a3 + init))
二元左折叠 (init + ... + args) ((init + a1) + a2) + a3

一元折叠没有初始值,参数包不能为空,除非运算符有标准规定的空包 identity。二元折叠有初始值,通常更适合工程代码,因为它把“没有参数时返回什么”写得很明确。

求和通常写二元左折叠,给空包一个初始值:

template <typename... Values>
auto sum(Values... values) {
    return (0 + ... + values);
}

static_assert(sum() == 0);
static_assert(sum(1, 2, 3) == 6);

左折叠和右折叠对加法、乘法这类数学上常见的结合运算看起来差别不大,但 C++ 运算符不总是满足数学结合律。字符串拼接、矩阵乘法、带状态的重载运算符、流输出都会让方向变得重要。

例如流输出必须让 std::ostream 留在左侧:

#include <iostream>
#include <utility>

template <typename... Args>
void traceLine(Args&&... args) {
    (std::cout << ... << std::forward<Args>(args)) << '\n';
}

这是二元左折叠,展开后类似:

(((std::cout << arg1) << arg2) << arg3)

如果写成 (std::forward<Args>(args) << ... << std::cout),多数参数类型根本没有“把流作为右操作数”的含义。折叠方向不是排版偏好,而是表达式树形状。

空包 identity

只有少数一元折叠对空包有内置 identity:

表达式 空包结果
(... && args) true
(... || args) false
(args, ...) void()

算术折叠没有通用空包结果:

template <typename... Values>
auto badSum(Values... values) {
    return (... + values);
}

badSum() 遇到空包会编译失败。想支持零参数,就写二元折叠并给出初始值。

identity 的工程含义是“没有元素时是否仍有合理结果”。前置条件检查可以认为空集合上的全称命题为真,所以 allChecks() 对空包返回 true 合理;任意一个条件成立可以认为空集合上不存在成立项,所以 anyChecks() 对空包返回 false 合理;求和、最大值、最小值则需要业务给出明确初始值。

template <typename... Checks>
constexpr bool allPassed(Checks&&... checks) {
    return (... && checks());
}

template <typename... Checks>
constexpr bool anyPassed(Checks&&... checks) {
    return (... || checks());
}

static_assert(allPassed());
static_assert(!anyPassed());

最大值没有通用空包 identity:

template <typename T, typename... Rest>
T maxValue(T first, Rest... rest) {
    ((first = first < rest ? rest : first), ...);
    return first;
}

这个接口通过单独的 first 参数表达“至少有一个值”。相比给一个任意初始值,这种签名更能反映语义。

求值顺序与短路

&&|| 折叠保留短路语义:

template <typename... Checks>
bool allChecks(Checks&&... checks) {
    return (checks() && ...);
}

前一个检查返回 false 后,后面的检查不再执行。适合前置条件验证。

短路不是“折叠表达式的优化”,而是 &&|| 本身的语言规则。折叠只改变表达式树形状,不改变这些运算符的短路语义。工程上可以利用这一点,把昂贵检查放在便宜检查后面:

#include <cmath>
#include <utility>

template <typename... Predicates>
bool validateMeasurement(Predicates&&... predicates) {
    return (std::forward<Predicates>(predicates)() && ...);
}

bool measurementExample(double range) {
    return validateMeasurement(
        [&] { return range >= 0.0; },
        [&] { return range < 200.0; },
        [&] { return std::isfinite(range); });
}

这里第三个检查只有在前两个检查通过时才执行。实际项目里可以把“指针非空、维度匹配、时间戳单调”放在前面,把需要访问大数据块的检查放在后面。

逗号折叠按从左到右执行,适合明确的副作用:

template <typename... Messages>
void emitAll(Messages&&... messages) {
    (emit(std::forward<Messages>(messages)), ...);
}

不要把带副作用的函数塞进 +* 这类算术折叠里。算术运算表达的是值组合,不应承担顺序副作用。日志、注册、统计计数这类场景,用逗号折叠或普通循环更清晰。

逗号折叠常用于把多个对象注册到同一个容器:

#include <string>
#include <utility>
#include <vector>

struct Registry {
    void add(std::string name) {
        names.push_back(std::move(name));
    }

    std::vector<std::string> names;
};

template <typename... Names>
void registerNames(Registry& registry, Names&&... names) {
    (registry.add(std::forward<Names>(names)), ...);
}

展开顺序是从左到右,因此 registerNames(reg, "imu", "lidar", "camera") 的注册顺序稳定。若顺序是业务语义的一部分,逗号折叠比把副作用藏在构造临时数组中更直接。

折叠表达式还适合构造小型工程策略:

#include <cmath>

template <typename... Residuals>
double sumSquared(Residuals... residuals) {
    return (0.0 + ... + (residuals * residuals));
}

template <typename... Weights>
bool allFiniteWeights(Weights... weights) {
    return (... && std::isfinite(weights));
}

第一个例子是二元左折叠,空包结果为 0.0。第二个例子是一元左折叠,空包结果为 true。这两种边界都写进了接口语义。

代码验证:短路和顺序

#include <utility>
#include <vector>

template <typename... Checks>
bool allChecks(Checks&&... checks) {
    return (checks() && ...);
}

template <typename Container, typename... Values>
void appendAll(Container& container, Values&&... values) {
    (container.emplace_back(std::forward<Values>(values)), ...);
}

void example() {
    int calls = 0;
    const bool ok = allChecks(
        [&] { ++calls; return true; },
        [&] { ++calls; return false; },
        [&] { ++calls; return true; });

    std::vector<int> values;
    appendAll(values, 1, 2, 3);

    (void)ok;
}

calls 最终为 2,因为 && 在第二个检查失败后短路。appendAll 的插入顺序由逗号折叠保证。

折叠表达式的四种形式看起来只是括号和省略号的位置不同,但选错方向会导致语义错误。一个直观的记忆方法是:省略号在右边就是右折叠(从右边开始折叠),省略号在左边就是左折叠(从左边开始折叠)。对于加法、乘法这类满足结合律的运算,左右折叠结果相同;但对于减法、除法、字符串拼接、流输出这类不满足结合律的运算,方向很重要。

⚠️ 编程陷阱:一元折叠对空包编译失败 错误做法:写 (... + args) 并期望空参数时返回 0。 现象sum() 调用(零参数)导致编译错误,因为加法一元折叠没有标准定义的空包 identity。 根本原因:C++ 标准只为 &&(identity: true)、||(identity: false)和逗号(identity: void())定义了空包 identity。算术运算符没有——因为"0 是加法单位元"这种数学知识不在语言规则中。 正确做法:使用二元折叠并提供初始值:(0 + ... + args)。这等价于告诉编译器"空包时返回 0"。

💡 概念误区:认为折叠表达式会改变运算符的求值顺序 新手想法:"左折叠从左到右求值,右折叠从右到左求值。" 实际上:折叠方向决定的是**括号结构**(表达式树形状),不是**求值顺序**。(... + args) 生成 ((a1 + a2) + a3)(args + ...) 生成 (a1 + (a2 + a3))。对于内置类型的 +,两种括号结构的求值顺序都是从左到右(C++17 保证)。但对于重载的 operator+,括号结构决定了哪些操作数先结合。 正确理解:折叠方向 = 括号结构 = 表达式树形状。这会影响重载运算符的调用顺序和中间结果的类型,但不等同于"从右到左执行"。

🧠 思维陷阱:把副作用藏在算术折叠中 新手想法:"我可以用 (0 + ... + (doSomething(args), 1)) 来对每个参数执行操作并计数。" 实际上:这种写法把副作用(doSomething)藏在值组合(+)的语义中,读者很难分辨"这行代码是在求和还是在执行操作"。逗号折叠 (doSomething(args), ...) 明确表达了"逐个执行副作用"的意图。 正确思维:值组合用算术/逻辑折叠(+&&||),副作用用逗号折叠。不要混用——即使能工作,也会让代码意图模糊。

练习

  1. [对比题]:分别用一元左折叠和一元右折叠写字符串拼接 concat("a", "b", "c")。展开后的括号结构是什么?如果 operator+std::string 有性能差异(左侧已有容量 vs 创建新临时对象),哪种折叠方向更高效?
  2. [代码题]:用二元左折叠写一个 clampAll(min_val, max_val, values...),把每个 value 限制在 [min_val, max_val] 范围内,返回限制后的值之和。处理空包情况。
  3. [跨章综合题]:结合 模板特化SFINAE与类型萃取 的 if constexpr 和 detected idiom,写一个 sumNorms(args...) 函数:对有 .norm() 方法的参数调用 .norm() 累加,对算术类型直接用 std::abs() 累加。提示:需要在逗号折叠中嵌入 if constexpr 辅助函数。

14.4 完美转发:值类别、引用折叠与 std::forward ⭐⭐⭐

完美转发要解决的根本问题:跨函数边界的值类别保持

完美转发是 C++11 中最精巧的语言机制之一,也是最容易被误解的机制之一。在进入具体语法之前,需要从"值类别"这个概念出发理解它要解决的问题。

什么是值类别?为什么它重要? C++ 中每个表达式都有两个独立的属性:类型(type)和值类别(value category)。类型决定"这个表达式代表什么数据",值类别决定"这个表达式代表的数据是否可以被安全地'窃取'"。左值(lvalue)表示"这个表达式指向一个有持久存在的对象,之后可能还会被使用"——你不应该随意移动它的内容。右值(rvalue,更精确地说是 prvalue 和 xvalue)表示"这个表达式指向一个即将消亡的对象,可以安全地窃取它的资源"——移动构造函数正是依赖这个信号来避免不必要的深拷贝。

问题的核心:值类别在函数调用边界丢失。 C++ 有一条基本规则:有名字的东西就是左值。函数参数有名字(如 args),所以在函数体内 args 这个表达式总是左值——无论调用者传入的是左值还是右值。这意味着当你把一个临时对象(右值)传给工厂函数时,进入函数体后它就"退化"成了左值——"可以被窃取"的信号丢失了。如果工厂函数把这个左值传给构造函数,构造函数会执行拷贝而非移动——性能白白浪费。

完美转发的目标正是解决这个信息丢失问题:让中间层函数(如工厂函数、包装器)能透明地传递调用者给出的值类别信号,使最终的目标函数看到与直接调用相同的值类别。这需要三条独立语言规则的精确配合:转发引用推导(在推导上下文中 T&& 的特殊行为)、引用折叠(T& && 如何简化为 T&)和 std::forward(根据推导结果条件性地恢复右值属性)。

完美转发在机器人代码中的重要性。 在 SLAM 系统中,工厂函数和包装器极其常见:makeFactor<PriorFactor<Pose3>>(key, prior, noise) 创建因子、emplace_back(args...) 原地构造容器元素、std::make_unique<Module>(args...) 创建模块。这些中间层如果不正确转发值类别,每次调用都可能产生不必要的深拷贝。对于持有大型数据的对象(如包含百万点的点云、大型矩阵),拷贝的代价是内存分配和数据复制——在实时系统中这可能意味着帧率下降。

三条规则的协作关系总览。 完美转发不是一条规则,而是三条独立规则的精确配合:

规则 作用 如果缺少会怎样
转发引用推导 T 的推导结果中编码值类别信息 无法区分调用者传入的是左值还是右值
引用折叠 T& && 简化为合法类型 T& T& && 这种类型没有意义
std::forward<T> 根据 T 的推导结果恢复值类别 所有参数退化为左值,触发拷贝而非移动

三者缺一不可。去掉转发引用推导,T 的推导不会携带值类别信息;去掉引用折叠,推导出的类型不合法;去掉 std::forward,值类别信息虽然保存在 T 中但不会被使用。

工程问题:工厂函数不能把右值误变成左值

因子构造、模块注册、回调包装常写:

template <typename T, typename... Args>
std::unique_ptr<T> makeModule(Args&&... args) {
    return std::make_unique<T>(std::forward<Args>(args)...);
}

这里的 std::forward<Args>(args)... 不是装饰语法,而是保证每个实参的值类别被保留下来。

反面失败:进入函数体后所有命名参数都是左值

template <typename T, typename... Args>
std::unique_ptr<T> makeModuleBad(Args&&... args) {
    return std::make_unique<T>(args...);
}

即使调用端传入临时对象,args 在函数体里是有名字的变量,因此表达式 args 是左值。makeModuleBad 会把所有参数作为左值传给构造函数,可能导致额外拷贝,甚至选错重载。

抽象不变量:转发引用依赖模板推导

T&& 只有在 T 需要被推导时才是转发引用:

template <typename T>
void f(T&& value);  // value 是转发引用

void g(std::string&& value);  // value 是右值引用,不是转发引用

转发引用能同时接收左值和右值,靠的是 类型系统与值类别推导 的引用折叠规则:

推导前 叠加 折叠结果
T& & T&
T& && T&
T&& & T&
T&& && T&&

口诀是:只要出现 &,结果就是左值引用;只有 &&&& 叠加才得到右值引用。

规则推导:std::forward<T> 是条件移动

#include <type_traits>
#include <utility>

template <typename T>
decltype(auto) forwardLike(T&& value) {
    return std::forward<T>(value);
}

static_assert(std::is_lvalue_reference_v<decltype(forwardLike(std::declval<int&>()))>);
static_assert(std::is_rvalue_reference_v<decltype(forwardLike(std::declval<int>()))>);

std::forward<T>(value) 的作用不是“总是移动”,而是:

  • 如果 T 被推导为左值引用,就返回左值。
  • 如果 T 被推导为非引用类型,就返回右值。

这正好恢复调用现场的值类别。

工程边界:转发后不要继续假设参数内容仍完整

如果某个参数被转发为右值,目标构造函数可能移动它。转发后继续读取该参数,可能只得到被移动后的有效但未指定状态。工厂函数通常应在转发后立刻返回,不再使用 args

代码验证:三种写法的语义差异

#include <memory>
#include <string>
#include <utility>

struct Module {
    explicit Module(const std::string&) {}
    explicit Module(std::string&&) {}
};

template <typename... Args>
std::unique_ptr<Module> badForward(Args&&... args) {
    return std::make_unique<Module>(args...);
}

template <typename... Args>
std::unique_ptr<Module> forceMove(Args&&... args) {
    return std::make_unique<Module>(std::move(args)...);
}

template <typename... Args>
std::unique_ptr<Module> goodForward(Args&&... args) {
    return std::make_unique<Module>(std::forward<Args>(args)...);
}

args... 全部变左值;std::move(args)... 全部强制右值,可能误移动左值;std::forward<Args>(args)... 才是逐个保留值类别。

类型系统与值类别推导 连接:值类别和引用折叠是同一条链

完美转发容易误解,是因为它同时涉及两个层面:

层面 问题 规则来源
模板推导 Args 被推导成什么类型 转发引用推导
表达式值类别 函数体里的 args 是左值还是右值 类型系统与值类别推导 值类别

看一个最小例子:

#include <type_traits>
#include <utility>

template <typename T>
struct ForwardProbe {
    using parameter_type = T&&;
    using forwarded_type = decltype(std::forward<T>(std::declval<T&&>()));
};

static_assert(std::is_same_v<
              ForwardProbe<int&>::parameter_type,
              int&>);
static_assert(std::is_same_v<
              ForwardProbe<int>::parameter_type,
              int&&>);

当调用端传入左值 int x 时,T 推导为 int&T&& 折叠成 int&。当调用端传入 1 时,T 推导为 intT&& 保持 int&&。这就是转发引用能同时接收左值和右值的原因。

进入函数体后,value 有名字,所以表达式 value 总是左值:

template <typename T>
void inspect(T&& value) {
    // decltype(value) 可能是 int& 或 int&&
    // 但表达式 value 本身是左值
    (void)value;
}

std::forward<T>(value) 的工作,是用 T 里保存的推导结果把表达式值类别恢复出来。

std::forward<T> 为什么不是普通 cast

可以把 std::forward<T>(value) 近似理解为:

static_cast<T&&>(value)

但关键不在 static_cast,而在模板实参 T。如果 T = std::string&,那么 T&& 折叠为 std::string&,结果仍是左值;如果 T = std::string,那么 T&&std::string&&,结果才是右值。

也就是说,std::forward<T> 依赖“调用现场留下来的类型证据”。随手写一个 cast 没有这份证据:

#include <string>
#include <type_traits>
#include <utility>

void consume(const std::string&);
void consume(std::string&&);

template <typename T>
void wrapper(T&& value) {
    consume(std::forward<T>(value));  // 按调用现场转发
}

template <typename T>
void alwaysRvalue(T&& value) {
    consume(static_cast<std::remove_reference_t<T>&&>(value));  // 强制右值
}

alwaysRvalue 会把左值也当成可移动对象,语义接近 std::movewrapper 才是完美转发:左值仍按左值传递,右值才按右值传递。

对参数包来说,每个元素都独立转发:

template <typename F, typename... Args>
decltype(auto) invokeForward(F&& f, Args&&... args) {
    return std::forward<F>(f)(std::forward<Args>(args)...);
}

Args... 中可能同时有左值引用和非引用类型,因此不能用一次统一的 std::move 或统一的 static_cast 替代。

完美转发是 C++11 中最精巧的机制之一,它同时依赖三个独立规则的配合:转发引用推导(T&& 在推导上下文中的特殊行为)、引用折叠(& + && = &)和 std::forward 的条件转换(根据 T 的推导结果选择返回左值还是右值)。三者缺一不可——去掉转发引用推导,就不知道调用者传的是左值还是右值;去掉引用折叠,T& && 这样的类型就没有意义;去掉 std::forward,进入函数体后所有参数都退化为左值。

完美转发的信息保持问题:从引用折叠规则推导

完美转发解决的是一个"值类别信息丢失"问题。这个问题看似简单,但理解它的解决方案需要把三条独立的语言规则串联起来。

问题的本质:在 C++ 中,表达式有两种基本值类别——左值(有名字、有地址、可以被再次引用)和右值(即将被销毁、可以被"窃取"资源)。当调用者传入一个临时对象(右值)时,这携带了一个语义信号:"我不再需要这个对象,你可以移动它"。但一旦这个信号穿过函数参数传递,它就丢失了。

为什么会丢失?因为 C++ 的一条基本规则:有名字的东西就是左值。函数参数有名字(args),所以在函数体内,args 这个表达式是左值——无论调用者传入的是左值还是右值。这就是信息丢失的地点。

调用现场:makeModule(std::string("hello"))  // "hello" 是右值(临时对象)
函数签名:void makeModule(Args&&... args)   // T 被推导为 std::string(非引用)
函数体内:args 是 std::string&& 类型        // 但 args 是有名字的,所以表达式 args 是左值!

引用折叠规则提供了类型层面的信息编码:虽然表达式 args 在函数体内总是左值,但**模板参数 T 中编码了调用现场的值类别信息**。这是三条规则的第一条。

当调用者传入左值 x 时:T 被推导为 std::string&(左值引用)。这是转发引用推导的特殊规则——只有 T&& 形式的参数,在接收左值时才会把 T 推导为左值引用。

当调用者传入右值 std::string("hello") 时:T 被推导为 std::string(非引用)。这是正常的推导。

于是 T 的推导结果就成了"值类别信息的载体":T 是引用类型说明来源是左值,T 是非引用类型说明来源是右值。

引用折叠确保形参类型正确T 推导出来后,形参类型 T&& 需要折叠。

  • T = std::string& 时,T&& = std::string& && = std::string&(引用折叠规则:& + && = &
  • T = std::string 时,T&& = std::string&&(没有折叠)

所以形参确实能同时接收左值和右值——左值通过左值引用绑定,右值通过右值引用绑定。

std::forward<T> 利用 T 中的信息恢复值类别std::forward<T>(args) 的实现可以近似理解为 static_cast<T&&>(args)。根据 T 的推导结果:

  • T = std::string& 时:static_cast<std::string& &&>(args) = static_cast<std::string&>(args) → 返回左值
  • T = std::string 时:static_cast<std::string&&>(args) → 返回右值

这就完成了"信息恢复":std::forward<T> 根据 T 中保存的推导证据,选择性地把表达式从左值转回右值。如果调用者传的是左值,forward 保持左值;如果调用者传的是右值,forward 恢复右值。

完整信息流

调用现场                    → T 的推导          → 函数体内                → forward 后
──────────────────────────────────────────────────────────────────────────────────────
传入左值 x                  → T = string&       → args 是左值            → forward 返回左值
传入右值 string("hello")    → T = string        → args 是左值(信息丢失) → forward 返回右值(信息恢复)

从设计哲学看,完美转发的精巧之处在于:它把"值类别"这个本来只存在于表达式层面的瞬时属性,通过模板推导编码到了"类型"层面(T 的推导结果),然后在需要的时候通过 forward 从类型中解码出来。类型在编译期一直存在,不会因为跨越函数边界而丢失——这就是为什么完美转发能"保持"值类别信息。

⚠️ 编程陷阱:对同一个参数多次转发 错误做法auto a = std::forward<T>(x); auto b = std::forward<T>(x); 现象:如果 x 是右值引用,第一次 forward 可能触发移动,第二次 forward 操作的是已被移动的对象——状态未定义。 根本原因std::forward 可能将参数转为右值,允许下游移动它。移动后对象处于"有效但未指定"状态,再次转发是逻辑错误。 正确做法:每个参数在一条转发链中只 forward 一次。如果需要多次使用同一个参数,先用它做只读操作,最后一次才 forward

💡 概念误区:认为 std::forwardstd::move 做同一件事 新手想法:"std::forwardstd::move 都是把东西变成右值引用。" 实际上std::move 是**无条件转为右值**——不管输入是左值还是右值,输出都是右值引用。std::forward<T> 是**条件转发**——当 T 被推导为左值引用时保持左值,当 T 被推导为非引用类型时转为右值。std::move 说"我不再需要这个对象",std::forward 说"保持调用者给的语义"。 正确理解std::move 用于明确表示"这个对象可以被移走";std::forward 用于透明地传递调用者的意图。两者使用场景完全不同。

练习

  1. [推导题]:给定 template <typename T> void f(T&& x);,分别写出 f(42)f(lvalue)f(std::move(lvalue)) 三种调用中 T 的推导结果、T&& 折叠后的类型、以及 std::forward<T>(x) 的返回类型。
  2. [代码题]:写一个 emplace_back_all(container, args...) 函数,把所有参数完美转发到容器的 emplace_back。用逗号折叠实现,验证移动语义(传入 std::string 右值时不应发生拷贝)。
  3. [跨章综合题]:回顾 移动语义与完美转发 的移动语义和 类型系统与值类别推导 的值类别。解释为什么 std::make_unique<T>(std::forward<Args>(args)...) 中的 std::forward 对性能至关重要——如果改成 args...(不转发),对一个持有 std::vector<double> 成员的类,构造时会多出多少次内存分配?

14.5 tupleapply 与保存参数包 ⭐⭐⭐

参数包的生命周期困境与 tuple 的解决方案

参数包存在一个根本性的限制:它只存在于编译期的模板实例化过程中,不是一个运行时可操作的对象。你不能把参数包存进一个类成员变量,不能把它返回给调用者,不能把它放进容器——因为参数包不是 C++ 类型系统中的一等公民。这个限制在需要"延迟使用参数"的场景中造成了实际困难。

延迟使用的工程需求。 在机器人系统中,"接收参数"和"使用参数"经常不在同一个时间点。例如:

  1. 因子图构造:配置文件指定了一个因子类型和它的构造参数,但因子不应该在配置解析时创建——而应该在优化器组装因子图时才创建。参数需要被保存一段时间。
  2. 任务队列:一个线程产生"创建某个对象"的请求,另一个线程在合适的时机执行创建。请求中的构造参数需要被安全地跨线程传递。
  3. 延迟初始化:某些模块只有在第一次使用时才应该初始化,但构造参数在程序启动时就已经确定。参数需要被保存到首次使用时。

所有这些场景都需要一种能把参数包"固化"为运行时对象的机制。std::tuple 正是这个机制——它把编译期的类型列表和值列表固化为一个运行时对象,使参数能被存储、传递和延迟使用。std::apply 则是反向操作——把 tuple 中的值重新展开为函数参数。tuple + apply 构成了参数包的"打包 -> 存储 -> 展开"完整生命周期。

为什么不用 std::vector<std::any> 初学者可能会想到用运行时容器来保存任意参数。但 std::vector<std::any> 有两个严重缺陷:(1) 类型信息被擦除了——取出时需要 std::any_cast 并且必须知道正确的类型,类型错误只在运行时才被发现;(2) 每个元素可能需要堆分配,对小对象不友好。std::tuple 在编译期保留了完整的类型信息,取出时用 std::get<I> 是类型安全的,存储上紧凑排列(类似结构体),没有额外的堆分配。代价是 tuple 的大小在编译时必须确定——你不能在运行时动态增加元素。

工程问题:参数包不总是立即展开

有些场景需要把构造参数先保存起来,稍后再创建对象。例如配置解析后保存因子构造参数,等图优化器真正组装时再创建残差对象。

#include <tuple>
#include <utility>

template <typename Factor, typename... Args>
class FactorBuilder {
public:
    explicit FactorBuilder(Args&&... args)
        : args_(std::forward<Args>(args)...) {}

    Factor create() const {
        return std::apply(
            [](const auto&... values) {
                return Factor(values...);
            },
            args_);
    }

private:
    std::tuple<std::decay_t<Args>...> args_;
};

std::tuple<std::decay_t<Args>...> 保存的是值,而不是对调用现场临时对象的引用。默认工程写法应优先生命周期安全,只有在明确需要引用语义时再使用 std::reference_wrapper

规则推导:std::apply 背后是索引序列展开

std::apply 可以理解为:

#include <tuple>
#include <utility>

template <typename F, typename Tuple, std::size_t... I>
decltype(auto) applyImpl(F&& f, Tuple&& tuple, std::index_sequence<I...>) {
    return std::forward<F>(f)(
        std::get<I>(std::forward<Tuple>(tuple))...);
}

template <typename F, typename Tuple>
decltype(auto) applyLike(F&& f, Tuple&& tuple) {
    constexpr std::size_t n =
        std::tuple_size_v<std::remove_reference_t<Tuple>>;

    return applyImpl(std::forward<F>(f),
                     std::forward<Tuple>(tuple),
                     std::make_index_sequence<n>{});
}

std::make_index_sequence<n> 生成 0..n-1,再把 std::get<I>(tuple)... 展开成普通函数调用。C++17 后一般直接用 std::apply

工程边界:tuple 适合内部保存,不适合作为公共配置接口

tuple<double, int, double> 对读者没有语义。公共配置更适合命名结构体;tuple 适合作为模板内部的参数包载体。

std::tuple 在变参模板生态中扮演"参数包的运行时镜像"角色。参数包只存在于编译期,无法跨函数保存;tuple 把编译期的类型列表和值列表固化为一个运行时对象,使参数能被存储、传递和延迟使用。std::apply 则是反向操作——把 tuple 中的值重新展开为函数参数。这一"打包→存储→展开"的链条,在因子图构造、任务队列和延迟初始化中非常常见。

⚠️ 编程陷阱:用 std::tuple<Args...> 保存引用 错误做法std::tuple<Args...> args_ 直接保存,其中 Args 包含引用类型。 现象:保存的引用指向已销毁的临时对象,后续使用时读取垃圾数据或段错误。 根本原因:如果 Args 被推导为 const std::string&tuple 会保存引用而非值。临时对象在语句结束后销毁,引用悬垂。 正确做法:用 std::tuple<std::decay_t<Args>...> 保存值副本,确保生命周期安全。只在明确需要引用语义时才使用 std::reference_wrapper

💡 概念误区:认为 std::apply 只能用于 std::tuple 新手想法:"std::applytuple 专用函数。" 实际上std::apply 可以用于任何满足 std::tuple_sizestd::get 协议的类型。std::pairstd::array、甚至自定义的结构化绑定类型都可以与 std::apply 配合。这再次印证了 模板特化SFINAE与类型萃取 中 traits 协议的思想——std::apply 依赖的是协议(tuple_size + get),不是具体类型。 正确理解std::apply 是"对 tuple-like 类型的通用展开",不限于 std::tuple

练习

  1. [代码题]:实现一个 DelayedFactory<T, Args...> 类,在构造时保存参数(使用 std::decay_t),在 create() 方法中用 std::apply 展开参数构造 T 对象。验证对 std::string 参数的移动语义。
  2. [思考题]:为什么 std::make_index_sequence<N> 生成的是 index_sequence<0, 1, ..., N-1> 而不是直接给 tuple 添加一个 expand() 方法?从语言设计角度讨论"编译期整数序列"这个工具的通用性。

14.6 CRTP:基类在编译期知道派生类 ⭐⭐⭐

CRTP 的起源、命名与核心思想

CRTP(Curiously Recurring Template Pattern,奇异递归模板模式)的名字来自 James Coplien 在 1995 年 C++ Report 上的一篇文章。他注意到当时多个独立的 C++ 库不约而同地使用了一种"看起来奇怪的递归"模式:一个类在继承基类时,把自己的名字作为基类的模板参数。这种写法在语法上像是"循环定义"——class SO3 : public Base<SO3>——但实际上不是循环,因为模板实例化的规则允许在基类实例化时 SO3 只需要是一个已声明但尚未定义完成的类型。

CRTP 要解决的核心问题是:如何让基类在编译期就知道派生类的类型? 这个问题的本质是信息流方向的问题。在普通继承中,信息从基类流向派生类——派生类知道基类有哪些成员、哪些虚函数、什么布局。但基类对派生类一无所知,它不知道有谁继承了自己,也不知道派生类有什么额外的成员或方法。虚函数通过运行时的 vtable 间接调用"反转"了一小部分信息流——基类可以在运行时调用派生类的重写版本——但这种反转是运行时的,编译器在编译基类时仍然不知道派生类的任何信息。

CRTP 通过一个精巧的类型系统技巧实现了编译期的信息流反转:把派生类的类型作为模板参数传给基类。于是 Base<SO3> 在编译时就知道 Derived = SO3,可以在成员函数中把 this 静态转换为 SO3&,调用 SO3 的方法,甚至在函数签名中使用 SO3 作为返回类型。这一切都发生在编译期——没有 vtable、没有间接调用、没有运行时类型查询。

CRTP 与虚函数的根本差异可以从"信息在何时可用"的角度理解:

维度 虚函数(运行时多态) CRTP(编译期多态)
基类何时知道派生类 运行时(通过 vtable) 编译时(通过模板参数)
调用目标如何确定 运行时查 vtable 表项 编译时确定,可内联
返回类型能否是派生类型 不能(协变返回类型受限) 能(Derived 是已知类型)
不同派生类能否放入同一容器 能(通过基类指针) 不能(每个 Base<D> 是不同类型)
是否可跨动态库边界 能(vtable 跟随对象) 困难(模板实例化在编译时)
新增派生类是否需要重编译 不需要(只要遵守虚接口) 需要(基类模板重新实例化)

这张表的核心信息是:CRTP 用编译期的确定性换取了运行时的灵活性。它适合"类型在编译时已经确定,但需要公共接口和高频调用"的场景——李群数学库、表达式模板、数值内核。虚函数适合"类型在运行时才确定,需要插件化和动态扩展"的场景——驱动接口、算法工厂、GUI 回调。

从模板元编程的角度理解 CRTP。 CRTP 可以被看作模板元编程中的一种"编译期多态分派"机制。在模板元编程中,类型本身就是"值"——模板参数是编译期的输入,模板特化和 SFINAE 是编译期的条件分支。CRTP 基类 Base<Derived> 的成员函数 derived().doSomething() 就是一次编译期的"虚调用"——编译器根据 Derived 的类型选择对应的 doSomething() 实现,这个选择完全在编译期完成,生成的机器码中没有任何间接调用痕迹。

工程问题:Eigen 的 Matrix 能用虚函数吗?

回顾 继承与多态深入:你已经学了虚函数做运行时多态——基类声明虚函数,派生类重写实现,通过基类指针调用时自动分派到正确的版本。这种机制非常适合插件和驱动:

// 运行时多态——适合传感器驱动这种"类型在运行时确定"的场景
class SensorDriver {
public:
    virtual ~SensorDriver() = default;
    virtual Frame read() = 0;
};

现在的问题是:Eigen 的 Matrix3d 能用这种模式吗? 假设我们想让 SO3(三维旋转)和 SE3(刚体变换)共享 inverse()log()operator* 等接口,直觉上似乎可以写一个 LieGroupBase 虚基类。但仔细想想就会发现三个致命障碍:

障碍一:返回类型。 SO3::inverse() 应该返回 SO3SE3::inverse() 应该返回 SE3。但如果基类声明 virtual LieGroupBase inverse() = 0,所有派生类的 inverse() 都只能返回 LieGroupBase(或其指针/引用)。这意味着 auto inv = q.inverse() 得到的是基类类型,后续 inv * p 这样的链式调用需要向下转换——对于"每个操作都应返回同类型"的数学值对象,这是不可接受的。

障碍二:性能。 在 SLAM 后端的 Gauss-Newton 迭代中,每次迭代可能计算数百个因子的残差和雅可比。每个因子内部都会调用多次李群操作(inverseexplogoperator*)。如果每次调用都走 vtable 间接跳转,CPU 的分支预测器无法预知目标地址,指令流水线可能被打断。更关键的是,虚函数阻止了编译器内联——一个只有几行的 SO3::inverse() 本可以被编译器完全展开到调用点,消除函数调用开销并允许 SIMD 向量化,但虚调用让这一切不可能。

障碍三:对象模型。 虚函数要求通过指针或引用使用对象(否则会发生切片)。但数学值对象(四元数、旋转矩阵、变换矩阵)应该像 double 一样按值传递、按值返回、可以放在栈上。要求所有李群操作都通过 std::unique_ptr<LieGroupBase> 传递,不仅代码冗长,还引入了不必要的堆分配。

这三个障碍归结为一个核心矛盾:虚函数把类型信息延迟到运行时,但数学库的类型在编译时就已经完全确定SO3 永远是 SO3,不会在运行时变成 SE3。我们需要的是一种"编译期就知道派生类型"的多态机制——这正是 CRTP 要解决的问题。

CRTP 的解决方案是:让基类在编译期就知道派生类的具体类型。基类模板参数 Derived 就是最终的具体类型,所以基类可以声明"返回 Derived"——这在虚函数中不可能做到。

CRTP 的编译期多态原理:为什么 static_cast<Derived*>(this) 安全

CRTP 的核心操作是在基类成员函数中写 static_cast<Derived&>(*this)。初学者经常担心:向下转型(从基类到派生类)难道不危险吗?static_cast 不是不做运行时检查吗?让我们从对象内存布局和 C++ 类型系统两个角度推导这个操作的安全性。

对象内存布局的保证:当你写 class SO3 : public LieGroupBase<SO3> 时,C++ 对象模型保证:每个 SO3 实例在内存中**包含**一个 LieGroupBase<SO3> 子对象。标准布局下,基类子对象位于派生类对象的起始地址。

SO3 对象在内存中的布局:
┌──────────────────────────────────────────┐
│ LieGroupBase<SO3> 子对象(基类部分)      │ ← this 指向这里
│  (通常无数据成员,大小为 1 字节或空基类优化为 0)  │
├──────────────────────────────────────────┤
│ SO3 自身的数据成员                        │
│  例如:Eigen::Matrix3d matrix_;          │
└──────────────────────────────────────────┘

LieGroupBase<SO3> 的成员函数执行时,this 指向基类子对象。由于基类子对象位于 SO3 对象的起始位置,this 实际上也是 SO3 对象的地址(在标准布局下)。static_cast<SO3&>(*this) 告诉编译器"请把这个基类引用当作派生类引用",编译器根据继承关系做地址调整——在单继承无虚函数的情况下,调整量为零,static_cast 不产生任何机器码。

类型系统的保证static_cast 的向下转型在 C++ 中的前提条件是:(1) 存在继承关系,(2) 真实对象确实是目标类型。条件 (1) 由 class SO3 : public LieGroupBase<SO3> 保证——编译器可以验证。条件 (2) 是 CRTP 的**设计约定**——只要程序员遵循"每个派生类用自己的名字作为 CRTP 基类的模板参数"这条规则,条件就自动满足。

与虚函数的代码生成对比

虚函数调用的代码生成:

; ptr->inverse(),ptr 类型为 LieGroupBase*
mov  rax, [rdi]          ; 从对象头部读取 vtable 指针
mov  rax, [rax + 8]      ; 在 vtable 中查找 inverse() 的函数指针(偏移 8 = 第 2 个虚函数)
call rax                 ; 间接调用 — 分支预测器可能猜错

CRTP 调用的代码生成(编译器内联后):

; q.inverse(),q 类型为 SO3
; LieGroupBase<SO3>::inverse() 被内联
; static_cast<const SO3&>(*this).inverseImpl() 被内联
; SO3::inverseImpl() 的代码直接嵌入调用点
; 没有间接调用,没有 vtable 查找

在优化模式下,CRTP 的整个调用链——基类函数 inverse()static_cast → 派生类函数 inverseImpl()——被编译器完全内联展开。最终的机器码中没有任何函数调用指令,就像直接在调用点写了 inverseImpl() 的函数体一样。

虚函数的间接调用代价:虚函数的 call rax(间接调用)本身只是一条指令,约 2-5 纳秒。但间接调用对 CPU 的指令流水线有一个深层影响:分支预测器不知道目标地址。直接调用(如 call 0x400abc)的目标地址编码在指令中,CPU 在取指阶段就知道接下来执行哪段代码。间接调用(call rax)的目标地址存在寄存器中,CPU 需要执行到这条指令才能确定目标——在此之前,流水线上预取的指令可能全部作废。现代 CPU 有间接分支预测缓存(BTB, Branch Target Buffer),但在多态调用目标频繁变化时(如遍历包含多种派生类的容器),预测准确率会下降。

对于 SO3::inverse() 这种在优化内循环中每帧调用数千次的函数,消除间接调用、允许编译器内联并进一步优化(如 SIMD 向量化、常量折叠)的收益是显著的。

CRTP 写法

下面的代码展示了 CRTP 的最小形态。注意 derived() 方法是整个模式的关键——它把基类的 this 指针安全地转换为派生类引用,使基类能调用派生类的实现:

template <typename Derived>
class LieGroupBase {
public:
    const Derived& derived() const {
        return static_cast<const Derived&>(*this);
    }

    Derived& derived() {
        return static_cast<Derived&>(*this);
    }

    Derived inverse() const {
        return derived().inverseImpl();
    }
};

class SO3 : public LieGroupBase<SO3> {
public:
    SO3 inverseImpl() const {
        return SO3{};
    }
};

LieGroupBase<SO3> 是一个具体基类类型。基类成员函数里知道 Derived = SO3,因此能转发到 SO3::inverseImpl()

对象模型:static_cast<Derived&>(*this) 何时成立

当定义:

class SO3 : public LieGroupBase<SO3> {};

一个 SO3 对象内部包含一个 LieGroupBase<SO3> 基类子对象。在基类成员函数中,*this 指向这个基类子对象。只要真实最派生对象确实是 SO3,从 LieGroupBase<SO3>& 静态转回 SO3& 是成立的。

成立条件可以概括为:

条件 说明
派生类按自身类型继承 Derived : Base<Derived>
继承关系明确 不应形成二义性基类
对象真实类型匹配 Base<SO3> 子对象必须来自 SO3 对象
不在构造/析构中调用依赖派生完整状态的函数 此时派生部分可能尚未构造或已经析构

从对象布局看,CRTP 基类不是“拥有”派生类,也没有虚表帮它动态确认类型。它只持有当前基类子对象的 this 指针。static_cast<Derived&>(*this) 是一次向下转换,编译器根据静态继承关系做必要的地址调整;运行时不会检查真实对象是否就是 Derived

因此,CRTP 的安全性来自设计约定:

template <typename Derived>
class Base {
public:
    Derived& self() {
        return static_cast<Derived&>(*this);
    }
};

class Good : public Base<Good> {};

Good 对象中确实有一个 Base<Good> 子对象,所以从这个子对象回到 Good 是合理的。若把 Base<Good> 放进别的类型,编译器仍可能接受语法,但对象模型假设已经被破坏。

构造和析构阶段还要格外谨慎:

template <typename Derived>
class BaseCtorBad {
public:
    BaseCtorBad() {
        // 不要在这里调用 static_cast<Derived&>(*this).initImpl()
    }
};

构造基类子对象时,派生类自己的成员还没有构造完成;析构基类子对象时,派生类成员已经开始失效。即使转换表达式能写出来,调用依赖派生状态的函数也可能读到未初始化或已析构的数据。CRTP 基类构造函数应只初始化基类自身状态,公共算法放到普通成员函数中,由完整对象构造后再调用。

反面失败:错误继承会破坏假设

class Wrong : public LieGroupBase<SO3> {};

Wrong 对象里确实有一个 LieGroupBase<SO3> 子对象,但它不是 SO3 对象的一部分。此时 derived()Wrong 当成 SO3,设计已经错误。C++17 不能在 CRTP 基类里完美阻止这种写法,因为基类不知道“真实最派生类型”是什么。

可以在测试或外层算法入口加入自检:

#include <type_traits>

template <template <typename> class Base, typename Actual>
constexpr void checkCrtpSelf() {
    static_assert(std::is_base_of_v<Base<Actual>, Actual>,
                  "CRTP type must inherit Base<Itself>");
}

template <typename Derived>
class DistanceBase {
public:
    double score(double a, double b) const {
        return static_cast<const Derived&>(*this).scoreImpl(a, b);
    }
};

class GoodDistance : public DistanceBase<GoodDistance> {
public:
    double scoreImpl(double a, double b) const {
        return (a - b) * (a - b);
    }
};

class BadDistance : public DistanceBase<GoodDistance> {};

static_assert(std::is_base_of_v<DistanceBase<GoodDistance>, GoodDistance>);
static_assert(!std::is_base_of_v<DistanceBase<BadDistance>, BadDistance>);

BadDistance 的第二个断言能暴露“没有继承 DistanceBase<BadDistance>”。工程上更常见的做法是命名约定、单元测试和 C++20 Concepts 约束公共接口共同使用。

另一类错误是多层继承时把中间类写进模板参数:

template <typename Derived>
class FactorInterface {
public:
    double evaluate(double x) const {
        return static_cast<const Derived&>(*this).evaluateImpl(x);
    }
};

class BaseFactor : public FactorInterface<BaseFactor> {};

class RangeFactor : public BaseFactor {
public:
    double evaluateImpl(double x) const {
        return x * x;
    }
};

RangeFactor 继承到的是 FactorInterface<BaseFactor>,不是 FactorInterface<RangeFactor>。调用 evaluate() 时会尝试在 BaseFactor 上找 evaluateImpl(),而不是在 RangeFactor 上找。CRTP 通常要求最终具体类型直接参与 Base<Concrete>,不要把可继续派生的中间类当成 Derived

如果确实需要公共中间层,可以让中间层继续保持模板形式:

template <typename Derived>
class FactorCommon : public FactorInterface<Derived> {
public:
    double robustWeight(double residual) const {
        return 1.0 / (1.0 + residual * residual);
    }
};

class BearingFactor : public FactorCommon<BearingFactor> {
public:
    double evaluateImpl(double x) const {
        return x;
    }
};

这样 FactorInterface<Derived> 仍然拿到最终具体类型。

不完整类型边界

在这一行:

class SO3 : public LieGroupBase<SO3> {

SO3 还没有定义完成。CRTP 基类在类体开头不能可靠访问 SO3::TangentSO3::Scalar 或检测 SO3::inverseImpl()。下面这种检查位置过早:

template <typename Derived>
class BaseBad {
    static_assert(sizeof(Derived) > 0);
};

类似地,在基类类体中直接访问派生类嵌套类型也常常过早:

template <typename Derived>
class ManifoldBaseBad {
    // 这里可能失败,因为 Derived 尚未定义完:
    // using Scalar = typename Derived::Scalar;
};

派生类体还没结束时,Derived::Scalar 是否存在、Derived::DoF 是多少、Derived 是否有某个成员函数,都可能还不可见。CRTP 基类的类体应尽量只保存与 Derived 无关的定义,或把依赖派生类完整性的逻辑放到延迟实例化的位置。

更稳的做法是把检查延迟到成员函数实例化时:

#include <type_traits>
#include <utility>

template <typename T, typename = void>
struct has_inverse_impl : std::false_type {};

template <typename T>
struct has_inverse_impl<T, std::void_t<decltype(std::declval<const T&>().inverseImpl())>>
    : std::true_type {};

template <typename Derived>
class BaseChecked {
public:
    Derived inverse() const {
        static_assert(has_inverse_impl<Derived>::value,
                      "Derived must provide inverseImpl() const");
        return static_cast<const Derived&>(*this).inverseImpl();
    }
};

inverse() 被真正使用时,派生类通常已经完整,诊断会更稳定。

不是所有“放进成员函数”的写法都能延迟检查。若成员函数签名直接写 typename Derived::Tangentdecltype(Derived::DoF),这些名字仍可能在 Base<Derived> 作为基类实例化时被检查,位置依然过早。更稳的写法是让公开签名尽量不依赖派生类内部名字,把检查放到函数体或 traits 中:

#include <type_traits>

template <typename T>
struct manifold_traits;

template <typename Derived>
class ManifoldBase {
public:
    static constexpr int dof() {
        return manifold_traits<Derived>::dof;
    }

    template <typename Delta>
    Derived plus(const Delta& delta) const {
        static_assert(std::is_same_v<
                          Delta,
                          typename manifold_traits<Derived>::Tangent>,
                      "delta must match the manifold tangent type");
        return static_cast<const Derived&>(*this).plusImpl(delta);
    }
};

dof()plus() 的函数体只有在被使用时才需要完整检查。实际库里常会把派生类的静态信息放到独立模板中:

template <typename T>
struct manifold_traits;

template <typename Derived>
class ManifoldBaseWithTraits {
public:
    static constexpr int dof() {
        return manifold_traits<Derived>::dof;
    }
};

traits 可以在派生类定义后特化,也可以由库提供宏或辅助类型集中声明。这样能减少在不完整类型阶段直接探测派生类内部成员的机会。

CRTP 的完整设计权衡:编译期多态的收益与代价

在决定是否使用 CRTP 之前,工程师需要全面理解它的收益和代价,避免过度使用或使用不足。

收益一:零开销抽象。 CRTP 的最大优势是编译器可以完全内联调用链。当 LieGroupBase<SO3>::inverse() 调用 static_cast<const SO3&>(*this).inverseImpl() 时,编译器在编译期就知道目标函数是 SO3::inverseImpl(),可以直接内联。最终的机器码中没有函数调用指令、没有 vtable 查找、没有间接跳转。对于在优化内循环中每帧调用数千次的数学操作(如四元数乘法、李代数指数映射),这种零开销至关重要。在 SLAM 后端的 Gauss-Newton 迭代中,每次迭代可能需要计算数百个因子的残差和雅可比,每个因子内部都会调用多次李群操作——消除这些调用的间接开销是 CRTP 在数学库中被广泛使用的直接原因。

收益二:返回类型保留。 虚函数的返回类型只能是基类类型或基类指针/引用(协变返回类型允许返回派生类指针/引用,但限制较多)。CRTP 基类知道 Derived 的完整类型,因此可以声明返回 Derived。这对数学值类型至关重要——SO3::inverse() 应该返回 SO3SE3::inverse() 应该返回 SE3,不应该返回某个抽象基类。返回具体类型让后续操作可以链式调用(q.inverse() * p),也让编译器能推导整个表达式链的类型。

收益三:编译期类型检查。 CRTP 基类可以在成员函数实例化时对 Derivedstatic_assert 检查。例如检查 Derived 是否提供了 inverseImpl() 方法、是否有正确的嵌套类型 Tangent、自由度是否匹配。这些检查在编译期完成,不需要运行时开销。

代价一:代码膨胀。 LieGroupBase<SO3>LieGroupBase<SE3> 是完全不同的类型——编译器为每种 Derived 实例化一份完整的基类代码。如果基类有 20 个成员函数、10 种派生类型,就会生成 200 个函数实例。在优化编译后,大部分会被内联消除,但在 debug 模式下这些函数都是真实存在的,增加了二进制体积和编译时间。

代价二:丧失运行时灵活性。 LieGroupBase<SO3>LieGroupBase<SE3> 是不同类型,不能放在同一个容器中、不能通过统一的基类指针操作。如果需要"一个容器存放多种李群类型"或"运行时根据配置选择李群类型",CRTP 无法直接做到。这需要额外的 type erasure 层或 std::variant 来桥接。

代价三:错误信息复杂度。 当 CRTP 派生类缺少某个方法或方法签名不匹配时,编译器错误信息通常指向基类的 static_cast 位置或基类调用派生类方法的位置,而非真正的问题位置(派生类定义)。C++20 的 Concepts 可以缓解这个问题——通过在基类入口处显式检查 Derived 是否满足接口要求。

代价四:调试体验下降。 在 debug 模式下,CRTP 的调用链可能不会被内联——调试器会显示从 LieGroupBase<SO3>::inverse()SO3::inverseImpl() 的完整调用栈,而虚函数版本可能更直观(只显示 SO3::inverse())。在优化模式下,内联使得 CRTP 的调用链完全消失,断点设置和变量观察变得困难。

决策框架。 综合以上分析,可以用以下问题决定是否使用 CRTP:

问题 是 -> CRTP 否 -> 虚函数
类型在编译时确定吗?
操作是否在热路径中高频调用?
返回类型是否必须是具体派生类型?
是否需要运行时替换不同的实现?
是否需要跨动态库边界?

在实际的机器人 C++ 库中,这个判断通常很清晰:数学值类型(李群、向量、矩阵、四元数)用 CRTP,系统架构类型(传感器驱动、算法模块、通信接口)用虚函数。

工程边界:CRTP 不是运行时插件系统

LieGroupBase<SO3>LieGroupBase<SE3> 是不同类型。CRTP 不自然支持“把不同派生对象放进同一个基类指针容器并运行时选择”。需要运行时替换、动态库插件、配置选择算法时,虚函数、类型擦除或 std::variant 更合适。

还要注意 ABI 边界。CRTP 模板类通常把实现放在头文件,调用方会实例化自己的 Base<Derived> 组合。若把这类类型直接暴露给二进制插件接口,编译器版本、标准库版本、编译选项和头文件内容都会进入 ABI 关系。稳定插件接口应优先暴露普通抽象类、C API、PImpl 或类型擦除对象,把 CRTP 留在库内部热路径。

CRTP 的命名(Curiously Recurring Template Pattern)本身就暗示了它的"奇特"之处:一个类在定义自己时,把自己的名字作为模板参数传给基类。这在语法上看起来像"循环定义"——SO3 的定义依赖 LieGroupBase<SO3>,而 LieGroupBase<SO3> 又需要知道 SO3。之所以不是真正的循环,是因为在基类模板实例化时 SO3 只需要是一个"已声明但尚未定义完"的类型(前向声明就够了),基类的成员函数体要等到被使用时才实例化——那时 SO3 已经定义完毕。

从虚函数到 CRTP 的迁移,可以类比从"运行时查表"到"编译期内联"的优化。虚函数通过 vtable 在运行时查找正确的函数指针,代价是一次间接调用(通常 ~2-5ns);CRTP 在编译时就确定了调用目标,编译器可以直接内联。对于 SO3::inverse() 这种在优化内循环中每帧调用数千次的函数,消除间接调用的收益是显著的。但 CRTP 的代价也很明确:每种 Derived 都会实例化一份 Base<Derived>,增加编译时间和二进制体积。

本质洞察:CRTP 的本质是**把运行时多态的类型信息提前到编译期**。虚函数说"我在运行时才知道你是谁",CRTP 说"我在编译时就知道你是谁——因为你在继承时告诉了我"。这种信息提前使得编译器可以内联、优化、推导返回类型,但也意味着丧失了运行时替换的灵活性。

⚠️ 编程陷阱:CRTP 派生类写错了模板参数 错误做法class SE3 : public LieGroupBase<SO3> {}——把 SE3 误写成继承 LieGroupBase<SO3>现象:代码可能编译通过,但 LieGroupBase<SO3> 的成员函数会把 *this 转换为 SO3&,实际对象是 SE3——对象模型假设被破坏,可能导致数据损坏或段错误。 根本原因:C++ 不会自动检查"派生类必须把自己传给 CRTP 基类"。class A : public Base<B> 在语法上完全合法。 正确做法:在公共接口或单元测试中加入 static_assert(std::is_base_of_v<LieGroupBase<T>, T>) 自检。C++20 中可以在基类构造函数中用 requires 子句约束。

💡 概念误区:认为 CRTP 可以完全替代虚函数 新手想法:"CRTP 性能更好,所以我应该把所有虚函数都改成 CRTP。" 实际上:CRTP 无法支持"运行时选择不同的派生类型"。你不能写 std::vector<LieGroupBase<???>> groups——因为 LieGroupBase<SO3>LieGroupBase<SE3> 是不同类型,不能放在同一个容器里。需要运行时多态(插件加载、配置驱动、类型擦除容器)的场景,虚函数仍然是正确选择。 正确理解:CRTP 适合"类型在编译时确定、调用频率高、需要内联"的数值热路径;虚函数适合"类型在运行时确定、调用频率低、需要扩展性"的架构边界。

🧠 思维陷阱:在 CRTP 基类构造函数中调用派生类方法 新手想法:"基类构造函数中可以调用 derived().init() 来初始化派生类特有的状态。" 实际上:基类构造函数执行时,派生类的成员变量尚未构造完成。此时 static_cast<Derived&>(*this) 在语法上合法(对象布局已确定),但通过它访问的派生类成员可能是未初始化的垃圾值。这和虚函数在构造函数中不走派生版本是同一个原因:对象在构造过程中"还不完全是派生类"。 正确思维:CRTP 基类构造函数只初始化基类自身状态。依赖派生类完整性的操作放到普通成员函数中,由完整构造后的对象调用。

练习

  1. [代码题]:为一个 DistanceMetric CRTP 基类实现两个派生类:EuclideanDistanceManhattanDistance,分别计算欧几里得距离和曼哈顿距离。在基类中提供 compute() 方法转发到派生类的 computeImpl()。用 static_assert 验证正确的继承关系。
  2. [分析题]:解释为什么 class Wrong : public LieGroupBase<SO3> {} 这种错误在 C++17 中无法被 CRTP 基类自动检测到。如果你是 C++20 标准委员会成员,你会提议什么语言特性来防止这种错误?
  3. [跨章综合题]:回顾 类型转换 的 static_cast 和 继承与多态深入 的虚函数。CRTP 中的 static_cast<Derived&>(*this) 和虚函数的 dynamic_cast<Derived*>(base_ptr) 在安全性、性能和使用场景上有什么区别?设计一个场景,其中 static_cast 的错误比 dynamic_cast 的失败更难调试。

14.7 表达式模板:先记录表达式树,再求值 ⭐⭐⭐⭐

表达式模板的理论基础:延迟求值与循环融合

表达式模板是 CRTP 最经典的应用场景,也是 C++ "零开销抽象"哲学的最高水平展示。理解表达式模板需要先理解它要解决的性能问题的数学本质,以及"延迟求值"这种编程范式的来龙去脉。

问题的数学本质:矩阵临时对象的代价。 考虑一个简单的矩阵表达式 C = A + B + D,其中 ABCD 都是 \(N \times N\) 矩阵。如果用最朴素的 operator+ 实现(每次 + 返回一个新矩阵),计算过程会产生一个临时矩阵:

  1. tmp = A + B:分配 \(N^2\) 个 double,遍历 \(N^2\) 个元素执行加法。
  2. C = tmp + D:分配 \(N^2\) 个 double(或复用 C 的存储),遍历 \(N^2\) 个元素执行加法。
  3. 释放 tmp

总共遍历了 \(2N^2\) 个元素(两次遍历),分配了 \(N^2\) 个 double 的临时存储。而最优的实现只需要一次遍历:C[i] = A[i] + B[i] + D[i]——遍历 \(N^2\) 个元素,零临时存储。对于 \(N = 1000\)(百万级元素),临时对象的分配和额外遍历造成的性能差距是数倍级别的。

延迟求值的编程范式。 表达式模板的核心思想——"先记录操作,稍后求值"——来自函数式编程中的延迟求值(lazy evaluation)。在 Haskell 中,所有表达式默认延迟求值:let result = map (+1) [1..1000000] 不会立即计算一百万个加法,而是返回一个"待计算的描述"。只有当 result 的某个元素真正被使用时,对应的加法才会执行。这种策略避免了不必要的中间结果,也允许编译器对整个计算管线做全局优化。

C++ 的表达式模板用编译期类型系统模拟了这种延迟求值。A + B 不返回结果矩阵,而返回一个"加法节点"对象——这个对象只保存了对 AB 的引用以及"加法"这个操作标签。(A + B) + D 再生成一个嵌套的加法节点。整棵表达式树编码在类型中:SumExpr<SumExpr<Matrix, Matrix>, Matrix>。当最终赋值 C = expr 时,赋值运算符遍历表达式树,对每个元素 i 计算 A[i] + B[i] + D[i]——只遍历一次,零临时对象。

循环融合(loop fusion)的自动化。 手写最优代码需要把多个循环合并为一个循环——这叫"循环融合"。表达式模板自动实现了循环融合:每个元素的计算通过表达式树的递归 operator[] 访问完成,编译器在内联所有层级后看到的是一个单一循环体中的完整表达式。这和 GPU 编程中的"内核融合"(kernel fusion)是同一个优化思想——减少中间数据的读写次数,让计算密度更高。

表达式模板为什么依赖 CRTP? 表达式树中的每个节点(矩阵、加法、乘法、转置)都需要提供统一的接口(operator[]size()),但每个节点的求值逻辑不同。CRTP 让所有节点继承 VecExpr<Derived>,基类提供公共接口,派生类提供具体求值。由于编译器在编译时知道每个节点的具体类型,整条求值链可以被完全内联。如果用虚函数实现(每个节点有虚 operator[]),内联不可能,循环融合也不可能——性能优势完全丧失。

工程问题:矩阵表达式不应产生多余临时对象

表达式模板是 CRTP 最经典的应用场景。在理解了 CRTP 的"基类知道派生类"机制之后,表达式模板只是把这个机制推广到了表达式树:每个运算符(加法、乘法、转置)生成一个新的 CRTP 派生类节点,节点保存操作数的引用,直到最终赋值时才一次性遍历整棵树求值。这种"延迟求值"(lazy evaluation)模式让编译器可以看到完整的计算结构,从而做出更好的优化决策。

表达式:

C = A + B + D;

如果每个 operator+ 都立即返回完整矩阵,可能产生:

tmp = A + B
C = tmp + D

对大矩阵或高频循环,这会增加内存读写。表达式模板的思路是:A + B 不立即算结果,而是返回一个表达式节点;整个表达式树在赋值或显式 eval() 时一次求值。

小型可读实现

下面是一个教学版一维向量表达式模板:

#include <cassert>
#include <cstddef>
#include <initializer_list>
#include <vector>

template <typename Derived>
class VecExpr {
public:
    const Derived& derived() const {
        return static_cast<const Derived&>(*this);
    }

    double operator[](std::size_t i) const {
        return derived()[i];
    }

    std::size_t size() const {
        return derived().size();
    }
};

class Vec : public VecExpr<Vec> {
public:
    explicit Vec(std::size_t n) : data_(n) {}

    Vec(std::initializer_list<double> values) : data_(values) {}

    template <typename Expr>
    explicit Vec(const VecExpr<Expr>& expr) {
        assign(expr.derived());
    }

    template <typename Expr>
    Vec& operator=(const VecExpr<Expr>& expr) {
        assign(expr.derived());
        return *this;
    }

    double operator[](std::size_t i) const {
        return data_[i];
    }

    double& operator[](std::size_t i) {
        return data_[i];
    }

    std::size_t size() const {
        return data_.size();
    }

private:
    template <typename Expr>
    void assign(const Expr& expr) {
        data_.resize(expr.size());
        for (std::size_t i = 0; i < expr.size(); ++i) {
            data_[i] = expr[i];
        }
    }

    std::vector<double> data_;
};

template <typename Lhs, typename Rhs>
class SumExpr : public VecExpr<SumExpr<Lhs, Rhs>> {
public:
    SumExpr(const Lhs& lhs, const Rhs& rhs) : lhs_(lhs), rhs_(rhs) {
        assert(lhs.size() == rhs.size());
    }

    double operator[](std::size_t i) const {
        return lhs_[i] + rhs_[i];
    }

    std::size_t size() const {
        return lhs_.size();
    }

private:
    const Lhs& lhs_;
    const Rhs& rhs_;
};

template <typename Lhs, typename Rhs>
SumExpr<Lhs, Rhs> operator+(const VecExpr<Lhs>& lhs,
                            const VecExpr<Rhs>& rhs) {
    return SumExpr<Lhs, Rhs>(lhs.derived(), rhs.derived());
}

template <typename Expr>
Vec eval(const VecExpr<Expr>& expr) {
    return Vec(expr);
}

使用方式:

void expressionExample() {
    Vec a{1.0, 2.0, 3.0};
    Vec b{4.0, 5.0, 6.0};
    Vec c{7.0, 8.0, 9.0};

    Vec result = eval(a + b + c);  // 在同一条语句内消费表达式树
}

a + b 返回 SumExpr<Vec, Vec>a + b + c 返回 SumExpr<SumExpr<Vec, Vec>, Vec>。表达式节点保存操作数引用,最终 eval() 或赋值时循环求值。 上面的轻量实现只能在同一条语句内消费嵌套表达式:a + b 这个左子表达式本身是临时对象,不能被外层表达式跨语句保存。 真实表达式模板库会用更复杂的 closure 规则决定“引用外部对象”还是“拥有子表达式”。

这棵表达式树的类型包含了计算结构:

using Expr1 = SumExpr<Vec, Vec>;
using Expr2 = SumExpr<Expr1, Vec>;

Expr2 不是矩阵结果,而是”左侧再加右侧”的节点。每次 operator[] 都会递归访问左右子表达式,直到读到真正的 Vec。因此,表达式模板把一次性临时矩阵换成了更多内联的小函数调用。优化器能把这些调用展开时,收益明显;表达式过深或编译器看不透时,收益会下降。

从 Eigen 的 Matrix<> + operator+ 到 SIMD 代码:中间发生了什么

上面的教学实现揭示了表达式模板的骨架。但 Eigen 的真实实现远比这复杂,而且最终目标不只是”消除临时对象”——它还要生成能利用 CPU SIMD 指令集(SSE、AVX、NEON)的高效机器码。让我们追踪一个简单表达式 C = A + B(其中 ABC 都是 Matrix4d)从 C++ 源码到最终机器码的完整旅程。

第 1 步:运算符返回表达式节点

当编译器看到 A + B 时,Eigen 的 operator+ 不计算 4x4 矩阵的 16 个元素之和,而是返回一个类型为 CwiseBinaryOp<internal::scalar_sum_op<double>, Matrix4d, Matrix4d> 的轻量对象。这个对象只保存对 AB 的 const 引用以及运算标签(加法),大小只有 2 个指针 + 少量元数据,约 16 字节——远小于 Matrix4d 的 128 字节(16 个 double)。

第 2 步:赋值运算符触发求值

C = expr 调用 Matrix4d::operator=(const CwiseBinaryOp<...>&)。这是一个模板成员函数,编译器为具体的表达式类型实例化一份赋值代码。赋值的核心逻辑是:逐元素(或逐”包”)求值表达式树,把结果写入 C 的数据缓冲区。

第 3 步:向量化分派

Eigen 在编译期检查三个条件来决定是否使用 SIMD:

  1. 数据类型是否支持——double 支持 SSE2/AVX,float 支持 SSE/AVX/NEON
  2. 内存是否对齐——Matrix4d 的数据缓冲区是否按 16/32 字节对齐
  3. 维度是否是 SIMD 宽度的倍数——4 个 double 正好填满一个 AVX-256 寄存器

对于 Matrix4d,所有条件通常满足。Eigen 会选择向量化路径。

第 4 步:包求值(Packet Evaluation)

在向量化路径中,Eigen 不是逐个 double 求值,而是一次处理一个”包”(packet)。对 AVX-256 来说,一个包是 4 个 double(32 字节)。Matrix4d 有 16 个 double,只需要 4 次包操作就能完成整个矩阵的加法。

每次包操作的伪代码:

packet_a = load_aligned(&A.data()[i * 4])   // 从 A 加载 4 个 double
packet_b = load_aligned(&B.data()[i * 4])   // 从 B 加载 4 个 double
packet_c = packet_add(packet_a, packet_b)   // SIMD 加法:一条指令加 4 个 double
store_aligned(&C.data()[i * 4], packet_c)   // 把结果存入 C

第 5 步:编译器生成机器码

经过模板实例化、内联展开和优化后,编译器生成的 x86-64 汇编大致如下(AVX-256):

vmovapd  (%rdi), %ymm0          ; 从 A 加载前 4 个 double 到 ymm0
vaddpd   (%rsi), %ymm0, %ymm0   ; A[0:3] + B[0:3],结果在 ymm0
vmovapd  %ymm0, (%rdx)          ; 存入 C[0:3]

vmovapd  32(%rdi), %ymm0        ; 加载 A[4:7]
vaddpd   32(%rsi), %ymm0, %ymm0 ; A[4:7] + B[4:7]
vmovapd  %ymm0, 32(%rdx)        ; 存入 C[4:7]

vmovapd  64(%rdi), %ymm0        ; 加载 A[8:11]
vaddpd   64(%rsi), %ymm0, %ymm0 ; A[8:11] + B[8:11]
vmovapd  %ymm0, 64(%rdx)        ; 存入 C[8:11]

vmovapd  96(%rdi), %ymm0        ; 加载 A[12:15]
vaddpd   96(%rsi), %ymm0, %ymm0 ; A[12:15] + B[12:15]
vmovapd  %ymm0, 96(%rdx)        ; 存入 C[12:15]

总共只有 12 条指令——4 次加载 A、4 次加法(同时加载 B)、4 次存储 C。没有临时矩阵、没有函数调用、没有循环判断。这就是表达式模板 + SIMD 的最终效果。

关键观察:从 C = A + B 这行可读的数学表达式,到 12 条高效的 SIMD 指令,中间经过了模板实例化(生成表达式节点类型)、赋值运算符分派(选择向量化路径)、包求值(每次处理 4 个 double)和编译器优化(内联 + 指令选择)四个阶段。每个阶段都由 C++ 的零开销抽象机制保证:模板实例化在编译期完成不产生运行时代价,inline 函数被展开后没有调用开销,表达式节点的引用在优化后被消除。

这就是为什么 Eigen 的固定大小矩阵运算能达到接近手写汇编的性能——表达式模板让编译器看到了完整的计算结构,CRTP 让编译器知道了所有中间类型,固定维度让编译器能完全展开循环并使用 SIMD。三者协同,实现了”写数学公式、跑汇编速度”的目标。

eval 时机与悬垂风险

表达式模板最重要的边界是生命周期。表达式节点通常保存引用:

auto expr = a + b;
a[0] = 100.0;
Vec result = eval(expr);

result[0] 会使用修改后的 a[0]。这不是 bug,而是延迟求值的直接结果。

更危险的是临时对象:

auto expr = Vec{1.0, 2.0, 3.0} + b;
Vec result = eval(expr);

左侧临时 Vec 在语句结束后销毁,expr 内部引用悬垂。真实 Eigen 表达式更复杂,但风险同源。需要跨语句保存结果时,应显式求值:

Vec safe = eval(a + b);

一个实用规则是:表达式如果只在同一条语句里消费,可以保持懒求值;如果要放进变量、lambda、任务队列或类成员,就优先 .eval() 成拥有数据的对象。

auto lazy = a + b;       // 只适合同一作用域内立即消费
Vec owned = eval(a + b); // 可以跨语句保存

对于 a + b + c 这类嵌套表达式,上面的教学实现必须写成 Vec owned = eval(a + b + c);。 如果要支持 auto expr = a + b + c; 之后再求值,表达式节点就不能简单保存所有操作数的引用,而要按 closure 语义保存子表达式。

另一个边界是别名。假设右侧表达式读 a,左侧赋值也写 a

a = a + b;

这个简单加法通常没有问题,因为每个位置只读写对应元素。但更复杂的表达式可能把一个元素写坏后,后续计算还需要原值。线性代数库常通过 .eval() 或专门的 alias 检查解决:

Vec tmp = eval(a + b);
a = tmp;

真实矩阵库中的典型例子是转置、块赋值和自乘。A = A.transpose() 这类表达式如果直接逐元素覆盖,可能在读完原矩阵前改掉数据。.eval() 的本质是人为插入一个拥有数据的中间结果,用可预测的生命周期换取安全语义。

调试成本:类型是真实结构,不是友好名字

表达式模板的类型会迅速增长:

auto expr = a + b + c;

调试器里看到的可能是 SumExpr<SumExpr<Vec, Vec>, Vec>,真实库中还会包含块表达式、转置表达式、标量乘法表达式、映射表达式和 traits。错误信息也会把这些类型全部展开。

工程上通常用三种方式控制成本:

做法 目的
在公共接口处使用具名概念或 traits 把错误限制在入口
在关键边界调用 .eval() 缩短表达式类型并固定生命周期
给复杂表达式拆出中间变量 让调试器和日志能观察具体结果

表达式模板不是“越懒越好”。它的价值在于减少不必要临时对象,而不是把所有中间状态都隐藏起来。数值调试、性能剖析和异常诊断需要可观察点时,显式求值是一种工程工具。

工程边界:表达式模板适合数值表达式,不适合普通业务对象

表达式模板的收益来自减少临时对象和保留表达式结构;成本是类型复杂、错误信息长、生命周期不直观、编译时间增加。线性代数库、自动微分库、图像表达式库适合这种设计;普通配置对象、消息对象、业务状态机不应默认使用表达式模板。

表达式模板是 CRTP 最典型的工程应用之一。每个表达式节点(加法、乘法、转置)都是一个继承 VecExpr<Derived> 的 CRTP 派生类,基类提供统一的 operator[]size() 接口,派生类提供具体的求值逻辑。这种结构让 a + b + c 的类型成为 SumExpr<SumExpr<Vec, Vec>, Vec>——类型本身编码了计算结构,编译器可以利用这个结构进行优化(如融合循环、消除临时对象)。

⚠️ 编程陷阱:用 auto 保存 Eigen 表达式并跨语句使用 错误做法auto result = A + B; 然后在下一条语句中使用 result现象:如果 AB 是临时对象,result 内部保存的引用已经悬垂,使用时读到垃圾数据或段错误。 根本原因:表达式模板的节点通常保存操作数的引用(而非拷贝),以避免不必要的数据复制。auto 保存的是表达式节点对象,不是求值结果。如果操作数是临时对象,它们在语句结束后销毁,表达式节点中的引用悬垂。 正确做法:对需要跨语句保存的表达式结果,使用具体类型承接:Eigen::Matrix3d result = A + B;Vec result = eval(a + b);

💡 概念误区:认为表达式模板总是更快 新手想法:"延迟求值消除了临时对象,所以表达式模板总是比立即求值快。" 实际上:表达式模板把临时对象的分配成本换成了更多的函数调用(每次 operator[] 都要递归访问表达式树)。对于小矩阵,递归访问的开销可能超过分配临时对象的开销。对于深度嵌套的表达式,编译器可能无法完全内联所有调用层级。Eigen 的做法是在某些情况下自动插入 .eval() 来控制表达式深度。 正确理解:表达式模板的收益取决于矩阵大小和表达式复杂度。大矩阵的 C = A + B + D 收益显著(避免两次全矩阵分配和复制);小矩阵的简单运算可能收益为零甚至负。

练习

  1. [分析题]:画出 Vec result = eval(a + b + c) 的完整类型和求值过程。a + b 的类型是什么?(a + b) + c 的类型是什么?eval() 如何遍历表达式树?对第 i 个元素,求值路径经过了几次 operator[] 调用?
  2. [代码题]:在教学版表达式模板的基础上,增加一个 ScaleExpr<Expr> 节点,实现标量乘法 2.0 * a。验证 eval(2.0 * a + b) 的结果正确。注意保持 CRTP 的继承结构。
  3. [跨章综合题]:回顾 Eigen基础与SLAM数学预备 中 Eigen 表达式模板的 auto 陷阱和 .eval() 机制。解释为什么 auto expr = A.transpose(); A(0,0) = 100; B = expr;B 的值会受到 A 的修改影响,以及 .eval() 如何解决这个问题。用本节的教学版表达式模板构造一个类似的 aliasing 场景。

14.8 机器人库案例:抽象目的而不是名字列表 ⭐⭐⭐

Sophus / manif:李群静态接口

SO3SE3Sim3 共享很多数学接口:inverse()log()exp()operator*。它们的存储、自由度、指数映射公式不同,但调用形态类似。CRTP 让基类提供公共外壳,派生类提供具体公式,返回类型仍然是具体派生类型。

抽象目的:让李群操作像普通值类型一样使用,同时避免虚函数和堆分配。

为什么不是普通继承加虚函数?因为李群对象通常是小型值对象,会频繁出现在残差计算、雅可比传播和状态更新中。SO3 的乘法应返回 SO3SE3 的乘法应返回 SE3,虚基类接口很难表达“返回当前具体类型”。如果统一返回基类指针,还会引入所有权、分配和动态派发问题。

CRTP 让公共接口写在一处:

template <typename Derived>
class LieGroup {
public:
    Derived inverse() const {
        return static_cast<const Derived&>(*this).inverseImpl();
    }
};

派生类只实现公式细节。调用端看到的是具体类型,编译器也能内联短小函数。这类设计尤其适合固定维度矩阵、固定自由度和按值传递的数学对象。

Eigen:表达式模板与静态多态

Eigen 的矩阵表达式通过表达式模板记录计算结构,赋值时统一求值。CRTP 提供表达式节点的公共接口,例如尺寸、索引访问、派生类型转发。

抽象目的:让 C = A * B + D 看起来像数学公式,执行时尽量接近手写内核。

Eigen 不是为了炫耀类型技巧才使用表达式模板,而是因为矩阵代码最怕两个成本:中间临时对象和动态尺寸分支。表达式节点把 A * B + D 的结构留到赋值点,库可以在那一刻选择合适内核、检查尺寸、决定是否需要临时对象。

CRTP 在这里承担“表达式共有协议”的角色。矩阵、块、转置、乘法节点、加法节点都可以暴露相似接口,但每个节点的尺寸、访问方式和求值策略不同。虚函数无法让这些信息参与编译期优化;CRTP 可以把节点类型留在模板参数中。

这也解释了为什么 Eigen 经常建议在某些位置显式 .eval():表达式模板默认延迟求值,库会尽量优化,但别名、生命周期和调试可见性仍需要开发者给出边界。

nanoflann:数据集适配而非虚函数点访问

KD-Tree 查询内层会频繁访问第 idx 个点的第 dim 个坐标。如果每次访问都走虚函数,内层循环会有间接调用成本。nanoflann 风格的适配器让数据集类型在编译期固定,点访问可内联。

抽象目的:把“数据集如何取点”交给适配器,同时让搜索内核保留静态类型。

KD-Tree 的搜索流程大致固定:访问节点、计算距离、维护候选集。变化的是点云存储方式:std::vector<Vec3>、结构体数组、Eigen 矩阵、PCL 点云、自定义内存映射都可能出现。若让每个点访问都通过虚函数进入数据集对象,热路径会被间接调用污染。

适配器风格把访问协议写成编译期约定:

struct CloudAdaptor {
    const std::vector<Vec3>& points;

    std::size_t kdtree_get_point_count() const {
        return points.size();
    }

    double kdtree_get_pt(std::size_t index, int dim) const {
        const Vec3& p = points[index];
        return dim == 0 ? p.x : (dim == 1 ? p.y : p.z);
    }
};

搜索内核模板拿到 CloudAdaptor 的具体类型后,kdtree_get_pt() 可以被内联。这样既不要求复制点云,也不要求所有数据集继承同一个虚基类。库的扩展点是“满足访问协议”,不是“成为某个基类的派生类”。

IKFoM / FAST-LIO2 风格:编译期维度和流形结构

误差状态 Kalman 滤波常有固定状态维度、固定噪声维度、固定切空间维度。非类型模板参数和 traits 可以把这些维度放进类型系统,使 Eigen 使用固定大小矩阵,减少动态分配。

抽象目的:让滤波器内核知道状态结构和维度,从而生成更稳定的数值代码。

这类滤波框架还需要表达“状态不是普通欧氏向量”。姿态在 SO3 上,位姿在 SE3 上,偏置和速度在欧氏空间中。更新状态时不能简单写 x += dx,而要对不同块执行对应的 boxplus 或流形加法。

traits 可以把这些结构信息集中起来:

template <typename State>
struct eskf_traits;

template <typename State>
class ErrorStateKernel {
public:
    static constexpr int state_dim = eskf_traits<State>::state_dim;
    static constexpr int noise_dim = eskf_traits<State>::noise_dim;

    State boxplus(const State& x,
                  const typename eskf_traits<State>::Tangent& dx) const {
        return eskf_traits<State>::boxplus(x, dx);
    }
};

滤波内核不需要知道每个状态类的成员布局,只依赖 traits 暴露的维度和操作。编译期维度让矩阵类型更具体,流形操作让状态更新符合数学结构。这就是模板、traits、CRTP 在机器人状态估计中经常同时出现的原因。

从这些案例中可以提炼出一条共同的设计原则:机器人数学库使用模板技术的目的不是追求泛型的通用性,而是追求数值的高效性。CRTP 不是为了"让 SO3SE3 共享代码"(继承也能做到),而是为了让 SO3::inverse() 能被编译器内联到 MPC 的内层循环中。traits 不是为了"适配多种点云"(运行时接口也能做到),而是为了让配准残差的计算路径中没有虚调用和动态分派。表达式模板不是为了"让矩阵公式好看"(裸循环也能算),而是为了让 C = A * B + D 的执行效率逼近手写的融合循环。

⚠️ 编程陷阱:照搬库的模板架构到业务代码 错误做法:在自己的项目中模仿 Eigen 的表达式模板或 Sophus 的 CRTP 层级,用于非数值计算场景(如配置解析、消息处理)。 现象:编译时间大幅增加,错误信息难以阅读,新团队成员需要很长时间才能理解代码结构,但性能并没有显著提升。 根本原因:CRTP、表达式模板、traits 的收益只在"编译期类型信息能带来优化"的场景中体现。配置解析和消息处理通常不是性能瓶颈,运行时多态和简单的继承反而更容易维护。 正确做法:先用性能分析工具确认热路径,只在热路径上使用编译期多态技术。其他地方使用虚函数和简单接口。

练习

  1. [调研题]:阅读 Sophus 库的 SO3 实现(sophus/so3.hpp),找出它的 CRTP 基类、inverse()log() 方法的转发机制。与本章的 LieGroupBase<Derived> 设计对比,Sophus 有哪些额外的工程考量?
  2. [设计题]:假设你要为一个 ESKF(误差状态卡尔曼滤波)设计 traits。状态包含:位置(Vector3d,欧氏空间)、姿态(Quaterniond,SO(3) 流形)、速度(Vector3d,欧氏空间)、陀螺仪偏置(Vector3d,欧氏空间)。设计 eskf_traits<MyState> 的完整接口:state_dimnoise_dimboxplus()boxminus()。讨论为什么姿态部分的 boxplus 不能简单写成加法。

14.9 混合设计:外层运行时选择,内层静态内核 ⭐⭐⭐

工程问题:真实系统同时需要配置灵活性和热路径性能

点云配准模块通常有两类需求:

  1. 启动时根据配置选择 ICP、GICP、NDT。
  2. 每次迭代内部高频计算残差、雅可比、权重和线性系统。

第一类需求是架构边界,适合运行时多态;第二类需求是数值热路径,适合模板、CRTP、traits、折叠表达式。把两者混成一种抽象都会失衡:全部虚函数会拖累内层优化;全部模板会让运行时选择困难并扩大构建成本。

分层结构

外层运行时接口:
  RegistrationBase
  IcpRegistration
  GicpRegistration
  makeRegistration(config)

内层静态内核:
  RegistrationKernel<Factor, Rejector, Reduction>
  FactorBase<Derived>
  point_cloud_traits<Cloud>
  fold/reduction helpers

外层给系统集成提供稳定接口,内层让编译器看到具体类型。

这层边界可以按三个问题划分:

问题 适合位置 原因
运行时配置选哪种算法 外层 配置文件、命令行、插件加载都发生在运行时
每个残差怎么计算 内层 类型固定后可内联、可用固定维度矩阵
二进制接口暴露什么 外层 稳定 ABI 不应依赖大量模板实例

一个真实工程结构通常是:配置解析和模块生命周期走普通类层次;进入一次 align()optimize()update() 后,把配置好的策略对象交给模板内核。模板内核不跨动态库边界,也不出现在公共插件 ABI 中。

这种分层思想可以类比操作系统的内核态和用户态。用户态接口(系统调用)是稳定的、运行时可选的、ABI 兼容的——对应外层的虚函数接口。内核态代码是高度优化的、针对具体硬件编译的、不直接暴露给用户——对应内层的模板内核。系统调用是两层之间的"分界线",对应代码中 align() 方法的调用边界。

教学化代码

下面的实现展示了这种分层的最小可运行示例。外层 RegistrationBase 是纯虚接口,IcpRegistration 继承它并实现 align();内层 RegistrationKernel<Factor> 是模板类,Factor 类型在编译时固定。分界线就是 IcpRegistration::align() 方法——在这个方法内部,模板内核被创建和使用:

#include <algorithm>
#include <cmath>
#include <limits>
#include <memory>
#include <utility>
#include <vector>

struct Vec3 {
    double x;
    double y;
    double z;
};

double squaredDistance(const Vec3& a, const Vec3& b) {
    const double dx = a.x - b.x;
    const double dy = a.y - b.y;
    const double dz = a.z - b.z;
    return dx * dx + dy * dy + dz * dz;
}

struct RegistrationResult {
    bool converged = false;
    int iterations = 0;
    double fitness = std::numeric_limits<double>::infinity();
};

class RegistrationBase {
public:
    virtual ~RegistrationBase() = default;

    virtual RegistrationResult align(const std::vector<Vec3>& source,
                                     const std::vector<Vec3>& target) = 0;
};

template <typename Derived>
class FactorBase {
public:
    double residual(const Vec3& source, const Vec3& target) const {
        return static_cast<const Derived&>(*this).residualImpl(source, target);
    }
};

class PointToPointFactor : public FactorBase<PointToPointFactor> {
public:
    double residualImpl(const Vec3& source, const Vec3& target) const {
        return squaredDistance(source, target);
    }
};

template <typename Factor>
class RegistrationKernel {
public:
    explicit RegistrationKernel(Factor factor) : factor_(std::move(factor)) {}

    RegistrationResult run(const std::vector<Vec3>& source,
                           const std::vector<Vec3>& target) const {
        const std::size_t n = std::min(source.size(), target.size());
        if (n == 0) {
            return {};
        }

        double cost = 0.0;
        for (std::size_t i = 0; i < n; ++i) {
            cost += factor_.residual(source[i], target[i]);
        }

        return RegistrationResult{
            std::isfinite(cost),
            1,
            cost / static_cast<double>(n)
        };
    }

private:
    Factor factor_;
};

class IcpRegistration final : public RegistrationBase {
public:
    RegistrationResult align(const std::vector<Vec3>& source,
                             const std::vector<Vec3>& target) override {
        RegistrationKernel<PointToPointFactor> kernel{PointToPointFactor{}};
        return kernel.run(source, target);
    }
};

调用端只看 RegistrationBase;内层残差仍是具体类型,编译器可以内联 PointToPointFactor::residualImpl()

运行时选择可以只放在工厂层:

#include <memory>
#include <stdexcept>

enum class RegistrationKind {
    PointToPoint,
    RobustPointToPoint
};

class RobustPointFactor : public FactorBase<RobustPointFactor> {
public:
    double residualImpl(const Vec3& source, const Vec3& target) const {
        const double d2 = squaredDistance(source, target);
        return d2 / (1.0 + d2);
    }
};

class RobustIcpRegistration final : public RegistrationBase {
public:
    RegistrationResult align(const std::vector<Vec3>& source,
                             const std::vector<Vec3>& target) override {
        RegistrationKernel<RobustPointFactor> kernel{RobustPointFactor{}};
        return kernel.run(source, target);
    }
};

std::unique_ptr<RegistrationBase> makeRegistration(RegistrationKind kind) {
    switch (kind) {
    case RegistrationKind::PointToPoint:
        return std::make_unique<IcpRegistration>();
    case RegistrationKind::RobustPointToPoint:
        return std::make_unique<RobustIcpRegistration>();
    }
    throw std::invalid_argument("unknown registration kind");
}

工厂函数是运行时分发点;RegistrationKernel<PointToPointFactor>RegistrationKernel<RobustPointFactor> 是两个静态内核实例。这样做的代价是每新增一个内置策略组合,都要编译一个对应实例;收益是进入内层循环后没有虚调用。

ABI 边界也更清楚:外部模块只需要知道 RegistrationBase 和工厂函数,内部可以自由调整模板参数、traits 和表达式节点。若把 RegistrationKernel<Factor, Rejector, Reduction> 直接放进公共接口,任何策略类型变化都会影响调用方编译和二进制兼容。

构建成本和错误信息边界

模板、CRTP、表达式模板通常要求定义放在头文件里,带来三类成本:

成本 表现 缓解方式
编译时间 改一个头文件触发大量重编译 控制头文件依赖、显式实例化常用组合
二进制体积 策略组合过多导致实例增多 收敛组合、避免无意义模板参数
错误信息 深层模板栈难读 在接口处加 static_assert 或 Concepts

显式实例化可以把常用组合收敛到 .cpp

// registration_kernel.hpp
template <typename Factor>
class RegistrationKernel;

extern template class RegistrationKernel<PointToPointFactor>;
// registration_kernel.cpp
template class RegistrationKernel<PointToPointFactor>;

代价是支持类型集合变得更显式。对框架内置策略,这是合理的;对开放扩展点,仍要保留头文件模板。

错误信息的边界同样需要设计。不要让使用者在 align() 深处才看到几十层模板展开。更好的入口形态是:

#include <type_traits>

template <typename Factor>
class RegistrationKernel {
    static_assert(std::is_default_constructible_v<Factor>,
                  "Factor must be default constructible");
};

或者在 C++20 中使用 Concepts 给出具名约束。入口越靠外,诊断越接近调用者的错误。数值内核可以很复杂,但接口诊断要短。

这种"外层运行时、内层编译期"的混合设计,是高性能机器人 C++ 框架的标准架构模式。它同时满足了两个看似矛盾的需求:系统集成需要灵活性(运行时配置、插件加载、动态切换算法),而数值计算需要性能(内联、固定维度、零虚调用)。关键在于找到正确的分界线——这条线通常位于"配置解析完成、开始数值迭代"的位置。

本质洞察:混合设计的核心判断不是"用模板还是用虚函数",而是"类型在什么时刻确定"。配置文件被解析之前,算法类型是运行时变量——用虚函数。配置解析完成后,算法类型在整个运行期间固定——用模板。模板内核不跨越 align() 调用的边界,虚函数不进入残差计算的内层循环。

⚠️ 编程陷阱:把模板内核暴露给插件 ABI 错误做法:公共动态库接口直接暴露 RegistrationKernel<PointToPointFactor, KdTreeRejector, ParallelReduction> 这样的模板类型。 现象:更换任何一个策略类型都需要重新编译所有依赖的模块;不同编译器版本或编译选项产生的 ABI 不兼容,导致链接错误或运行时崩溃。 根本原因:模板类的 ABI 包含完整的类型参数信息。策略类型的任何变化(甚至成员变量重排)都会改变 ABI。 正确做法:公共接口只暴露 RegistrationBase 抽象类和工厂函数。模板内核是库内部的实现细节,不出现在公共头文件中。

🧠 思维陷阱:认为运行时多态和编译期多态只能二选一 新手想法:"这个模块要么全用模板,要么全用虚函数。" 实际上:高质量的机器人库几乎都是混合使用。GTSAM 的因子图结构是运行时多态(Factor 基类、NonlinearFactor 派生类),但每个因子内部的残差计算使用模板标量(doubleJet)。Open3D 的点云容器是运行时类(PointCloud),但内部的并行计算使用模板内核。 正确思维:把系统分成"架构层"和"计算层"。架构层面对的是人(配置、集成、调试),运行时多态更友好;计算层面对的是编译器(优化、内联、向量化),编译期多态更高效。

练习

  1. [设计题]:为一个点云滤波模块设计混合架构。外层接口支持运行时选择滤波算法(体素滤波、统计滤波、半径滤波),内层内核使用模板固定点类型和数据结构。画出类图,标注虚函数和模板的分界线。
  2. [代码题]:在已有的 RegistrationBaseRegistrationKernel 基础上,增加一个 GicpFactor CRTP 因子,计算 GICP 残差(\(r = (s - t)^T (C_s + R C_t R^T)^{-1} (s - t)\),其中 \(C_s, C_t\) 是协方差矩阵)。创建对应的 GicpRegistration 运行时包装类。
  3. [跨章综合题]:综合 函数模板与类模板基础(模板基础)、模板特化SFINAE与类型萃取(traits 和 SFINAE)和本章(CRTP 和混合设计),为一个 Mini SLAM 后端设计完整的类型架构。需要支持:多种因子类型(先验、里程计、回环)通过 traits 适配、因子残差通过 CRTP 静态分派、因子图结构通过虚函数实现运行时灵活性。写出关键类和 traits 的声明(不需要完整实现)。

14.10 累积项目:变参日志、CRTP 因子和静态内核 ⭐⭐⭐

项目目标

在 Mini Registration 框架中加入三个能力:

  1. 变参日志:支持任意数量可输出参数。
  2. CRTP 距离因子:基类提供公共 score(),派生类实现 scoreImpl()
  3. 静态内核:用模板固定因子类型,外层仍可通过运行时接口选择算法。

可验证目标如下:

目标 验证方式 期望结果
日志 运行 logLine("p2p=", value) 输出片段顺序与传入顺序一致
因子构造 makeKernel<WeightedDistance>(0.5) 权重通过完美转发进入距离策略
距离策略 分别运行点到点和加权距离 同一数据上加权结果为点到点结果的一半
表达式节点 组合两个距离策略 组合节点的结果等于两个策略结果之和
负例诊断 调用缺少 scoreImpl() 的策略 报错停在 DistanceBase::score() 附近

这些目标覆盖本章的主线:参数包用于日志和构造,折叠表达式用于顺序输出,完美转发用于保留构造参数语义,CRTP 用于距离策略,表达式节点用于组合策略,静态内核用于热路径。

可验证实现

#include <algorithm>
#include <cmath>
#include <iostream>
#include <limits>
#include <memory>
#include <type_traits>
#include <utility>
#include <vector>

struct Vec3 {
    double x = 0.0;
    double y = 0.0;
    double z = 0.0;
};

double squaredDistance(const Vec3& a, const Vec3& b) {
    const double dx = a.x - b.x;
    const double dy = a.y - b.y;
    const double dz = a.z - b.z;
    return dx * dx + dy * dy + dz * dz;
}

template <typename... Args>
void logLine(Args&&... args) {
    (std::cout << ... << std::forward<Args>(args)) << '\n';
}

template <typename>
inline constexpr bool always_false_v = false;

template <typename Derived>
class DistanceBase {
public:
    double score(const Vec3& source, const Vec3& target) const {
        if constexpr (has_score_impl<Derived>::value) {
            return static_cast<const Derived&>(*this).scoreImpl(source, target);
        } else {
            static_assert(always_false_v<Derived>,
                          "Derived must provide scoreImpl(Vec3, Vec3) const");
        }
    }

private:
    template <typename T, typename = void>
    struct has_score_impl : std::false_type {};

    template <typename T>
    struct has_score_impl<
        T,
        std::void_t<decltype(std::declval<const T&>().scoreImpl(
            std::declval<Vec3>(), std::declval<Vec3>()))>>
        : std::bool_constant<
              std::is_convertible_v<
                  decltype(std::declval<const T&>().scoreImpl(
                      std::declval<Vec3>(), std::declval<Vec3>())),
                  double>> {};
};

class PointToPointDistance : public DistanceBase<PointToPointDistance> {
public:
    double scoreImpl(const Vec3& source, const Vec3& target) const {
        return squaredDistance(source, target);
    }
};

class WeightedDistance : public DistanceBase<WeightedDistance> {
public:
    explicit WeightedDistance(double weight) : weight_(weight) {}

    double scoreImpl(const Vec3& source, const Vec3& target) const {
        return weight_ * squaredDistance(source, target);
    }

private:
    double weight_ = 1.0;
};

template <typename Lhs, typename Rhs>
class SumDistance : public DistanceBase<SumDistance<Lhs, Rhs>> {
public:
    SumDistance(Lhs lhs, Rhs rhs)
        : lhs_(std::move(lhs)), rhs_(std::move(rhs)) {}

    double scoreImpl(const Vec3& source, const Vec3& target) const {
        return lhs_.score(source, target) + rhs_.score(source, target);
    }

private:
    Lhs lhs_;
    Rhs rhs_;
};

template <typename Lhs, typename Rhs>
SumDistance<Lhs, Rhs> addDistances(Lhs lhs, Rhs rhs) {
    return SumDistance<Lhs, Rhs>(std::move(lhs), std::move(rhs));
}

template <typename Distance>
class RegistrationKernel {
public:
    explicit RegistrationKernel(Distance distance)
        : distance_(std::move(distance)) {}

    double meanScore(const std::vector<Vec3>& source,
                     const std::vector<Vec3>& target) const {
        const std::size_t n = std::min(source.size(), target.size());
        if (n == 0) {
            return std::numeric_limits<double>::infinity();
        }

        double sum = 0.0;
        for (std::size_t i = 0; i < n; ++i) {
            sum += distance_.score(source[i], target[i]);
        }
        return sum / static_cast<double>(n);
    }

private:
    Distance distance_;
};

template <typename Distance, typename... Args>
RegistrationKernel<Distance> makeKernel(Args&&... args) {
    return RegistrationKernel<Distance>(
        Distance(std::forward<Args>(args)...));
}

static_assert(std::is_base_of_v<
              DistanceBase<PointToPointDistance>,
              PointToPointDistance>);
static_assert(std::is_base_of_v<
              DistanceBase<WeightedDistance>,
              WeightedDistance>);

int main() {
    std::vector<Vec3> source{{0.0, 0.0, 0.0}, {1.0, 0.0, 0.0}};
    std::vector<Vec3> target{{0.0, 0.0, 0.0}, {2.0, 0.0, 0.0}};

    auto point_to_point = makeKernel<PointToPointDistance>();
    auto weighted = makeKernel<WeightedDistance>(0.5);
    auto combined = RegistrationKernel(
        addDistances(PointToPointDistance{}, WeightedDistance{0.5}));

    logLine("p2p=", point_to_point.meanScore(source, target));
    logLine("weighted=", weighted.meanScore(source, target));
    logLine("combined=", combined.meanScore(source, target));
}

这段程序的第二个点对距离为 1.0,第一个点对距离为 0.0,因此点到点均值为 0.5,加权均值为 0.25,组合均值为 0.75。日志至少应包含:

p2p=0.5
weighted=0.25
combined=0.75

SumDistance<Lhs, Rhs> 是一个小型表达式节点:它保存两个距离策略,在 scoreImpl() 中组合它们。这个节点不像矩阵表达式那样保存引用,而是按值保存策略对象,因此生命周期更简单。若策略对象很大,可以改成 std::shared_ptr、引用包装或显式拥有的配置对象,但接口语义必须写清楚。

负例诊断

如果派生类没有实现正确签名:

class BrokenDistance : public DistanceBase<BrokenDistance> {};

并调用:

void negativeDistanceExample() {
    BrokenDistance distance;
    (void)distance.score(Vec3{}, Vec3{});
}

应触发接口处的诊断:

Derived must provide scoreImpl(Vec3, Vec3) const

这比让错误出现在内核深处更容易定位。这里的检查放在 score() 成员函数里,是为了避开 CRTP 派生类不完整的边界。

工程边界检查

检查项 合格标准
变参日志 logLine() 支持零个或多个可输出参数
转发 工厂函数使用 std::forward<Args>(args)...
折叠 日志使用输出折叠,不把副作用藏进算术折叠
CRTP DistanceBase<Derived> 无虚函数,通过静态转发调用派生实现
诊断 缺少 scoreImpl() 的类型在 score() 入口报错
混合设计 外层可保留运行时接口,内层 kernel 固定距离因子类型
生命周期 需要跨语句保存表达式结果时显式求值

可再加入两个负例,分别检查构造和继承参数:

class WrongSelf : public DistanceBase<PointToPointDistance> {};

void wrongSelfExample() {
    WrongSelf distance;
    (void)distance.score(Vec3{}, Vec3{});
}

这个例子不是缺少成员函数那么简单,而是 DistanceBase<Derived>Derived 写成了别的类型。诊断可能表现为错误的向下转换或找错实现类型。工程测试应包含 std::is_base_of_v<DistanceBase<T>, T> 这类自检。

void constructionExample() {
    auto ok = makeKernel<WeightedDistance>(0.5);
    (void)ok;

    // makeKernel<WeightedDistance>();
}

最后一行若打开,应在 WeightedDistance 构造处报错,因为该策略需要权重参数。这验证了变参工厂没有吞掉构造约束,参数包只是转发真实构造需求。


14.11 C++23/26 展望:deducing this 与 CRTP 的未来 ⭐⭐⭐⭐

这一节解决什么问题:CRTP 是本章最重要但也最复杂的技术。C++23 引入的 deducing this 提供了一种更简洁的替代方案。本节分析它能替代 CRTP 的哪些场景,以及 CRTP 仍然不可替代的场景。

deducing this:显式对象参数

C++23 允许非静态成员函数用 this auto&& self 的形式声明显式对象参数。这让函数能推导出调用它的对象的实际类型,无需 CRTP 的模板参数传递。

// CRTP 版本:基类需要模板参数 Derived
template <typename Derived>
struct LieGroupBase {
    auto inverse() const {
        return static_cast<const Derived&>(*this).inverseImpl();
    }
    auto log() const {
        return static_cast<const Derived&>(*this).logImpl();
    }
};

struct SO3 : LieGroupBase<SO3> {
    SO3 inverseImpl() const { /* ... */ return {}; }
    auto logImpl() const { /* ... */ return Eigen::Vector3d{}; }
};

// C++23 deducing this 版本:不需要 CRTP 模板参数
struct LieGroupBase23 {
    auto inverse(this auto const& self) {
        return self.inverseImpl();
    }
    auto log(this auto const& self) {
        return self.logImpl();
    }
};

struct SO3_23 : LieGroupBase23 {
    SO3_23 inverseImpl() const { return {}; }
    auto logImpl() const { return Eigen::Vector3d{}; }
};

deducing this 的优势:(1) 消除了错误继承风险——不再有”把 SO3 传给 LieGroupBase<SE3>”的可能性;(2) 基类不再是模板,减少了代码膨胀和编译时间;(3) 错误信息更友好——不再涉及 static_cast<Derived&> 的类型转换。

CRTP 仍然不可替代的场景:(1) 基类需要在**类体中**(而非成员函数中)使用派生类的类型别名(如 Derived::ScalarDerived::Dimension)——deducing this 只在函数签名中推导,不能用于类的非函数成员;(2) 需要在基类中根据派生类类型选择不同的成员变量布局;(3) 需要静态方法的转发(deducing this 不适用于静态方法)。

Eigen 和 Sophus 会迁移吗

Eigen 的表达式模板系统深度依赖 CRTP——MatrixBase<Derived> 在类体中使用 DerivedRowsColsScalar 等编译期常量来决定存储布局和返回类型。这些使用发生在类模板参数层面,不在成员函数签名中,因此 deducing this 无法替代。Eigen 的 CRTP 在可预见的未来会保持不变。

Sophus 和 manif 的 CRTP 使用模式更接近”基类转发”——基类方法调用派生类方法。这类模式大部分可以用 deducing this 简化,但迁移需要 ABI 变更和最低标准的提升。

如果 CRTP 是”编译期虚函数表”的类比,那么 deducing this 更像是”编译期 dynamic_cast”——它在每次调用时推导出实际类型,而不是在类定义时绑定。两者的权衡是:CRTP 在类定义时就确定了所有类型关系(更静态、更可预测),deducing this 在每次调用时才确定(更灵活、代码更短),但可能延迟类型错误的发现。

⚠️ 编程陷阱:混用 deducing this 和 CRTP 错误做法:在同一个继承层级中,部分方法用 CRTP 的 static_cast<Derived&>(*this) 转发,部分方法用 deducing thisthis auto&& self 推导。 现象:两种机制的类型推导路径不同,可能导致意外的模板实例化和 ODR 违反。 正确做法:一个类层级选择一种机制,不混用。新项目优先考虑 deducing this(如果编译器支持 C++23);维护旧项目保持 CRTP。

练习

  1. [代码题]:用 deducing this 重写 14.6 节的 LieGroupBaseSO3/SE3 示例。对比编译错误信息(故意传错类型时)的可读性差异。
  2. [分析题]:阅读 Eigen 的 MatrixBase 类定义,列出至少 3 处”在类体中使用 Derived 类型别名”的位置。解释为什么这些用法无法被 deducing this 替代。

本章小结

本章围绕两条主线展开:参数包处理”数量变化”,CRTP 处理”编译期静态多态”。

工具 核心规则 机器人场景
变参模板 类型包和值包成对推导 日志、因子构造、工厂函数
参数包展开 展开位置决定生成语法 函数调用、tuple、基类列表
递归展开 C++11 逐个处理参数 老代码阅读
折叠表达式 C++17 用运算符合并参数包 检查、求和、插入、输出
完美转发 保留每个实参值类别 make_unique 包装、emplace
tuple / apply 保存并重新展开参数包 延迟构造、任务队列
CRTP Base<Derived> 静态转发 Sophus、manif、Eigen
表达式模板 记录表达式树,赋值时求值 Eigen 矩阵表达式
混合设计 外层运行时,内层静态 配准、滤波、优化内核

核心工程判断是:静态多态适合类型固定、调用频繁、需要内联的数值内核;运行时多态适合插件、配置选择和稳定 ABI。高质量机器人 C++ 框架通常同时使用二者,只是放在不同层。

本章知识体系的内在逻辑

回顾本章的九个主题,它们之间存在清晰的递进和依赖关系,构成一棵知识树:

根节点:参数数量也是变化维度。 C++ 模板系统在 C++11 之前只能处理"类型变化"(template<typename T>),变参模板扩展了它处理"数量变化"的能力。这是整个章节的出发点。

第一分支:参数包的处理方式从递归到折叠。 14.1-14.3 讲的是同一个问题的两种解法——C++11 的递归展开和 C++17 的折叠表达式。理解递归展开有助于阅读老代码和理解编译器的实例化行为;使用折叠表达式有助于写出更简洁、编译更快、错误信息更短的新代码。两者在运行时生成的代码通常相同。

第二分支:完美转发解决值类别穿越函数边界的问题。 14.4 看似独立,实际上与变参模板紧密耦合——工厂函数几乎总是变参模板(参数数量不固定),而变参工厂函数几乎总是需要完美转发(保留每个参数的值类别)。14.5 的 tuple/apply 进一步解决了"参数包不能跨函数保存"的生命周期困境。

第三分支:CRTP 解决编译期多态的需求。 14.6-14.7 从完全不同的方向引入——不是关于参数数量,而是关于"基类如何在编译期知道派生类"。CRTP 的引入动机是李群数学库的性能需求,表达式模板是 CRTP 的最高水平应用。两者共同服务于"零开销抽象"的目标。

汇合点:混合设计。 14.8-14.9 把前面所有工具汇合成一个架构模式——外层运行时多态提供灵活性,内层变参模板 + CRTP + 折叠表达式提供性能。这不是"选哪个工具"的问题,而是"每个工具放在哪层"的问题。14.10 的累积项目把这个分层设计变成可验证的代码。

与前后章节的关系。 本章依赖 函数模板与类模板基础(模板基础,提供 template<typename T> 的基本理解)、模板特化SFINAE与类型萃取(SFINAE 和 traits,提供类型检查和适配的工具)和 移动语义与完美转发(移动语义,提供值类别的概念)。本章为 Concepts与Policy(Concepts 和 Policy-based Design)做准备——Concepts 将取代 SFINAE 在接口处约束模板参数,Policy 将利用本章的模板技术实现可组合的策略架构。

理解这棵知识树的结构,比记住每个技术的语法细节更重要。当你在真实项目中遇到变参工厂、CRTP 接口或表达式模板时,先定位它在知识树中的位置("这是在解决参数数量问题还是编译期多态问题?"),再去查具体语法。


延伸阅读

资源 难度 内容概要
Vandevoorde, Josuttis, Gregor. C++ Templates: The Complete Guide (2nd ed, 2017) Ch4, 函数模板与类模板基础, 并发与系统编程/原子操作与内存模型 ⭐⭐⭐ 变参模板、折叠表达式、CRTP 的权威参考
Jason Turner. "C++ Weekly: Ep 330 - Fold Expressions" (YouTube) ⭐⭐ 折叠表达式的简洁教学,包含常见陷阱和最佳实践
Sophus 库源码:sophus/so3.hpp, sophus/lie_group_base.hpp ⭐⭐⭐ 生产级 CRTP 李群实现,LieGroupBase<Derived> 的经典范例
Eigen 源码:Eigen/src/Core/CwiseBinaryOp.h ⭐⭐⭐⭐ 表达式模板的工业级实现,包含别名检测和求值优化
IKFoM / FAST-LIO2 源码:IKFoM_toolkit/esekfom/esekfom.hpp ⭐⭐⭐ 误差状态卡尔曼滤波的 traits + 模板设计
nanoflann 源码:nanoflann.hpp ⭐⭐ 静态适配器 KD-Tree,展示了如何用模板替代虚函数热路径
Scott Meyers. Effective Modern C++ Item 24-26 ⭐⭐⭐ 转发引用、std::forward、引用折叠的深入解释和工程指南

故障排查手册

现象 常见原因 检查方式 修复方式
参数包展开失败 展开位置不合法或多个包长度不一致 查看 ... 绑定的模式 改成合法展开位置,先检查长度
工厂函数发生额外拷贝 使用 args... 而非 std::forward<Args>(args)... 给类型加拷贝/移动计数 使用完美转发
左值被错误移动 使用 std::move(args)... 检查调用端是否传入左值 改为 std::forward<Args>(args)...
空参数求和失败 一元算术折叠没有 identity 查找 (... + values) 改为 (0 + ... + values)
副作用执行顺序异常 用算术折叠承载副作用 检查折叠运算符 改用逗号折叠或普通循环
CRTP 调用行为异常 派生类没有按 Base<Itself> 继承 检查 std::is_base_of_v<Base<T>, T> 修正继承参数并加接口测试
CRTP 检查过早失败 在派生类完整前检测成员 static_assert 是否在基类类体开头 把检查放到成员函数实例化点
auto 保存表达式后结果变化 表达式模板延迟求值 检查是否跨语句保存表达式 用具体类型或 eval() 承接结果
编译时间显著上升 模板组合和头文件依赖膨胀 统计实例化组合和包含关系 收敛策略组合、显式实例化常用类型