跳转至

运算符重载

难度:⭐~⭐⭐⭐ | 建议用时:1.5 周 | 前置要求:现代类设计与特殊成员函数 现代类设计、移动语义与完美转发 移动语义、类型转换 类型转换基础、错误处理与异常安全 错误处理基础


前置自测

📋 答不出 >= 2 题 -> 先回顾 现代类设计与特殊成员函数、移动语义与完美转发 和 类型转换

  1. T& operator=(const T&) 为什么必须返回 T&?它和拷贝构造函数的区别是什么?
  2. std::move(x) 本身会移动资源吗?如果一个类型没有移动构造函数,std::move(x) 会发生什么?
  3. explicit 关键字解决了哪类隐式转换问题?C++11 的 explicit operator bool() 有什么特殊价值?
  4. 成员函数和非成员函数在重载决议中有什么差异?为什么成员函数有一个隐式的 this 参数?
  5. 为什么 const 成员函数可以被 const 对象调用,而非 const 成员函数不可以?

本章目标

学完本章,你将能够:

  • 判断一个运算符应该写成成员函数、非成员函数还是根本不该重载,避免“语法看起来漂亮、语义却变危险”的接口设计。
  • 为机器人常见值类型实现算术、复合赋值、比较、流输出、下标、函数调用和显式布尔转换,并理解每一种写法背后的重载决议规则。
  • 看懂 Eigen 表达式模板、Sophus SE3 * point 和 Ceres Jet 自动微分中的运算符重载设计,知道它们为什么能把数学公式写成接近纸面的 C++ 表达式。

本章在课程中的位置:现代类设计与特殊成员函数 解释了类的特殊成员函数和 friend,移动语义与完美转发 解释了拷贝、移动和返回值优化。本章把这些能力用于”让自定义类型像内置类型一样参与表达式”。下一章 Lambda与STL算法 会继续沿着 operator() 这条线展开:Lambda 本质上就是带 operator() 的匿名函数对象,STL 算法也正是通过这种可调用对象完成定制行为。

知识树

本章覆盖的知识点形成以下结构。树的根是”让自定义类型进入 C++ 表达式系统”,树干是运算符重载的规则和设计原则,分支是不同类别运算符的工程应用。

运算符重载
├── 边界与规则 ⭐
│   ├── 至少一个操作数是自定义类型
│   ├── 不可重载的运算符(. :: ?: sizeof typeid)
│   └── 重载决议三阶段算法
├── 设计原则 ⭐⭐(核心主干)
│   ├── 最小惊讶原则与值语义/引用语义
│   ├── 成员 vs 非成员选择
│   ├── friend 与隐藏友元
│   └── ADL(实参相关查找)
├── 算术与复合赋值 ⭐⭐
│   ├── 先写 += 再用 += 实现 +
│   ├── 对称标量乘法
│   └── 具名函数 vs 运算符(dot/cross)
├── 比较、流输出与访问 ⭐⭐
│   ├── C++20 三路比较 <=>
│   ├── 精确相等 vs 近似比较
│   ├── operator<< 流输出
│   ├── operator[] const/non-const
│   ├── operator() 函数对象
│   ├── explicit operator bool
│   └── C++23 多参数 operator[] / static operator()
├── 高风险运算符 ⭐⭐
│   └── &&/|| 短路丧失、逗号、取地址
└── 工程应用 ⭐⭐⭐
    ├── Eigen 表达式模板
    ├── Sophus SE3 * point
    └── Ceres Jet 自动微分

9.1 运算符重载的边界:让类型进入表达式世界 ⭐

这一节解决的问题是:什么时候运算符重载能提升代码表达力,什么时候它只是把普通函数伪装成了数学符号。

动机:机器人代码天然充满数学表达式

机器人程序中的很多对象不是普通业务数据,而是带有数学结构的值:

类型 典型表达式 数学含义
Vec3 p + q 三维向量加法
Pose2 T1 * T2 位姿复合
SE3 T * point 刚体变换作用于点
Jet x * y + sin(z) 数值和导数同步传播
CostFunctor cost(params, residuals) 像函数一样调用对象

如果这些类型只能写成普通成员函数,代码会变成:

Vec3 r = add(scale(a, 0.5), scale(b, 0.5));
Pose3 world_body = compose(world_imu, imu_body);
Vec3 world_point = transform(world_body, local_point);

这段代码没有错,但它和纸面公式之间有一层厚厚的翻译:

\[ r = 0.5a + 0.5b,\quad T_{wb} = T_{wi}T_{ib},\quad p_w = T_{wb}p_b \]

运算符重载的价值就在这里:它让代码保留数学结构。

Vec3 r = 0.5 * a + 0.5 * b;
Pose3 world_body = world_imu * imu_body;
Vec3 world_point = world_body * local_point;

本质洞察:运算符重载不是“给类起一个漂亮别名”,而是把类型放进 C++ 的表达式系统。一个好的重载让代码更接近数学;一个坏的重载会让熟悉 C++ 的读者误判副作用、代价和语义。

如果不用运算符重载会怎样

完全不用运算符重载并不会让程序不能写,但会让三类代码明显变差:

  1. 数学公式变得不直观 R.multiply(p).add(t)R * p + t 更难和推导对齐。

  2. 组合表达式失去局部结构 compose(compose(T1, T2), T3) 不如 T1 * T2 * T3 清楚。

  3. 泛型代码变得难写 模板函数希望只要求类型支持 +*==,而不是要求每个库都使用同名成员函数。

不过反过来也成立:如果一个操作没有自然的数学或约定含义,硬套运算符会降低可读性。例如把 operator+ 写成“合并两个地图并触发全局优化”,调用者很难从 map1 + map2 看出它会分配内存、修改索引、启动耗时流程。

历史与设计原因:C++ 没有给你创造新语法的权力

C++ 的运算符重载继承自早期“自定义数值类型”的需求。复数、矩阵、迭代器、智能指针都需要像内置类型一样参与表达式。语言设计者选择了一个保守边界:

  • 可以重载已有运算符。
  • 不能创造新运算符。
  • 不能改变运算符的优先级、结合性和操作数个数。
  • 至少一个操作数必须是自定义类型或枚举类型。
  • 不能重载两个纯内置类型之间的运算符。

这条边界非常重要。下面的代码不可能成立:

// 错误:两个操作数都是内置类型,不能改变内置 int 的加法语义
int operator+(int a, int b) {
    return a - b;
}

C++ 要保护内置语义。如果允许重载 int + int,任何头文件都可能改变全局算术规则,整个语言会失去可推理性。

理论:运算符本质上是函数

绝大多数可重载运算符最终都会变成函数调用。成员函数形式把左操作数当作隐式对象,非成员函数形式把左右操作数都当作普通参数。

表达式 成员函数解释 非成员函数解释
a + b a.operator+(b) operator+(a, b)
a += b a.operator+=(b) 不推荐;复合赋值应写成员
a == b a.operator==(b) operator==(a, b)
out << x out.operator<<(x) operator<<(out, x)
x[i] x.operator[](i) 不能写成非成员
f(args...) f.operator()(args...) 不能写成非成员

有些运算符必须是成员函数:

必须是成员的运算符 原因
operator= 赋值改变左操作数自身,语言要求它属于被赋值对象
operator[] 下标访问以对象为容器语义,左操作数必须是对象
operator() 函数调用语义必须由被调用对象提供
operator-> 指针式成员访问必须由对象控制
转换运算符 operator T() 转换的是当前对象自身

不可重载的运算符也需要记住:

不可重载 原因
. 成员访问是语言核心机制,不能被库改写
:: 作用域解析发生在编译期名字查找阶段
?: 条件运算符的短路和类型规则太特殊
sizeof 编译期类型/对象大小查询,不是运行时函数
typeid RTTI 语言机制
.* 成员指针访问语义固定

重载决议:编译器如何从多个候选中选出最佳匹配

当编译器遇到 a + b 时,它不是简单地”找一个 operator+”,而是执行一个三阶段算法来确定最终调用哪个函数。这个算法叫做重载决议(overload resolution),它是 C++ 编译器最复杂的组件之一,也是很多”编译错误看不懂”问题的根源。

第一阶段:构建候选集。编译器收集所有可能匹配的函数:

  • 如果 a 是类类型,查找 a 的类中的成员 operator+(b)
  • 在当前作用域和外层作用域查找非成员 operator+(a, b)
  • 通过 ADL 在 ab 的关联命名空间中查找非成员 operator+(a, b)
  • 如果存在内置运算符适用的类型转换,内置 operator+ 也进入候选集。

第二阶段:筛选可行函数。对每个候选,检查实参能否匹配形参:

  • 实参数量是否正确(二元运算符需要两个操作数)。
  • 每个实参是否可以通过隐式转换匹配对应的形参类型。
  • 如果需要用户定义的转换(如 explicit 构造函数),检查是否允许。

不满足条件的候选被剔除。如果剩余集合为空,编译器报错”没有匹配的运算符”。

第三阶段:选择最佳匹配。如果有多个可行函数,编译器按照”转换代价最低”的原则排序:

匹配质量(从好到差) 示例
精确匹配(无转换) Vec3 + Vec3 直接匹配 operator+(Vec3, Vec3)
只需限定符调整 const Vec3 匹配 const Vec3& 参数
只需提升 short 提升为 int
只需标准转换 int 转为 double
需要用户定义转换 通过构造函数或转换运算符

如果没有唯一的最佳匹配(两个候选同样好),编译器报”调用有歧义”。

理解这个算法对运算符设计的指导价值在于:当你把 operator+ 写成成员函数时,左操作数不经过普通的参数匹配——它必须先是当前类的对象。这就是为什么成员 operator* 不能自然支持 2.0 * v。写成非成员后,2.0v 都经过完整的参数匹配,2.0 匹配 double 参数,v 匹配 Vec3 参数,左右对称。

最小示例:一个诚实的二维向量

先看一个小而完整的值类型。它只重载”读者看到就能猜对”的运算符。

#include <cmath>
#include <ostream>
#include <stdexcept>

struct Vec2 {
    double x = 0.0;
    double y = 0.0;

    Vec2& operator+=(const Vec2& rhs) noexcept {
        x += rhs.x;
        y += rhs.y;
        return *this;
    }

    Vec2& operator-=(const Vec2& rhs) noexcept {
        x -= rhs.x;
        y -= rhs.y;
        return *this;
    }
};

inline Vec2 operator+(Vec2 lhs, const Vec2& rhs) noexcept {
    lhs += rhs;
    return lhs;
}

inline Vec2 operator-(Vec2 lhs, const Vec2& rhs) noexcept {
    lhs -= rhs;
    return lhs;
}

inline Vec2 operator*(double s, Vec2 v) noexcept {
    v.x *= s;
    v.y *= s;
    return v;
}

inline Vec2 operator*(Vec2 v, double s) noexcept {
    return s * v;
}

inline double norm(const Vec2& v) noexcept {
    return std::sqrt(v.x * v.x + v.y * v.y);
}

inline std::ostream& operator<<(std::ostream& os, const Vec2& v) {
    return os << "Vec2(" << v.x << ", " << v.y << ")";
}

这段代码体现了本章后面会反复使用的几条规则:

规则 在示例中的体现
复合赋值写成员 operator+= 直接修改 *this
二元算术写非成员 operator+ 支持左右操作数的对称转换
复用复合赋值 operator+lhs += rhs 实现
标量乘法成对提供 s * vv * s 都能写
输出不换行 operator<< 只描述对象,不控制日志格式

语义契约:不要让运算符撒谎

运算符重载的最高原则是**最小惊讶原则**(Principle of Least Astonishment):读者看到 a + b 时,应该能正确推断出这个操作的语义——它创建了一个新对象(不修改 ab),结果在数学上等于 ab 的"和",代价和两个对象的大小成正比。如果 operator+ 实际上修改了 a、或者触发了磁盘 IO、或者启动了一个新线程,读者就会被严重误导。

违反最小惊讶原则的运算符重载比不写运算符重载更糟糕。不写运算符时,读者被迫阅读成员函数名(如 compose()transform()),至少能获得语义提示。写了一个语义不直观的运算符,读者会按照内置运算符的直觉理解代码——而这个直觉恰好是错的。

从值语义与引用语义推导最小惊讶原则

最小惊讶原则不是一条凭空规定的风格约定——它可以从 C++ 类型系统的两种根本语义模型**值语义(value semantics)**和**引用语义(reference semantics)**严格推导出来。理解这个推导过程,才能在遇到新的运算符设计决策时自主判断,而不是死记规则。

什么是值语义? 值语义意味着对象的身份由其内容决定,而非由它在内存中的位置决定。两个内容相同的 int 是"相等"的,无论它们存放在栈的哪个位置。值对象的核心性质是:拷贝一个值会产生一个独立的副本,修改副本不会影响原对象。intdoublestd::stringEigen::Vector3d 都是值类型。

什么是引用语义? 引用语义意味着对象的身份由它指向的资源决定。两个指针可能指向同一块内存——修改其中一个可见的数据,另一个也会看到变化。std::shared_ptr、原始指针、引用、迭代器都具有引用语义。

为什么 + 应该返回新对象? 因为 C++ 的内置 + 是值语义的标志性运算符。int c = a + b; 之后,ab 不变,c 是一个全新的独立值。所有初学者和有经验的程序员都带着这个心智模型阅读代码。如果自定义类型的 operator+ 修改了左操作数(引用语义行为),就在值语义的外衣下隐藏了引用语义的副作用——这正是"惊讶"的来源。

为什么 += 应该返回引用? += 的语义是"就地修改"——它明确告诉读者"左操作数会被改变"。因此它属于引用语义:修改发生在原对象上,不产生新对象。返回 *this 的引用是为了支持链式写法 (a += b) += c,同时避免不必要的拷贝。如果 += 返回值(而非引用),链式操作会修改临时对象而非原对象——这又是一个值语义/引用语义错配导致的"惊讶"。

推导汇总表

运算符 内置类型的语义模型 正确的返回类型 推导依据
a + b 值语义:不修改操作数,返回新值 T(按值返回) 保持值独立性
a += b 引用语义:修改 a,返回 a 自身 T&(引用返回) 修改发生在原对象上
a == b 值语义:只读取,不修改 bool 纯观察,无副作用
a < b 值语义:只读取,不修改 bool 纯观察,无副作用
a[i] 引用语义:返回元素的访问通道 T&const T& 允许读写容器内部
a++ 值语义:返回旧值的副本 T(按值返回) 旧值是修改前的快照
++a 引用语义:修改后返回自身 T&(引用返回) += 同理

本质洞察:最小惊讶原则的本质不是"读者觉得怎样不惊讶"这种主观判断,而是**值语义与引用语义不能错配**这条客观规则。+ 是值语义运算符就必须返回新值,+= 是引用语义运算符就必须返回引用。错配就是 bug 的来源,不是风格问题。

如果不这样设计会怎样?假设 operator+ 返回引用:

// 反面示例:operator+ 返回引用——值语义/引用语义错配
Vec3& operator+(Vec3& lhs, const Vec3& rhs) {
    lhs.x += rhs.x;  // 修改了左操作数!
    lhs.y += rhs.y;
    lhs.z += rhs.z;
    return lhs;       // 返回被修改的左操作数
}

读者写 Vec3 c = a + b; 时会以为 a 不变——但它已经被修改了。更危险的是 a + b + c 这种链式表达式:第一个 + 修改了 a,第二个 + 接收的"左操作数"已经是被篡改的 a,而不是原始的 a + b 的独立结果。整个表达式的语义变得不可预测。

一个实用的判断标准是:如果你需要在运算符的文档中花超过一句话解释"这个运算符做什么",说明这个操作不适合用运算符表达。Vec3 operator+(Vec3, Vec3) 不需要解释——三维向量加法。Map operator+(Map, Map) 则需要大量解释——两张地图"相加"是什么意思?合并?叠加?求并?这种操作应该用命名函数。

运算符重载最容易出问题的地方不是语法,而是语义。读者看到某个符号时会带着内置类型的经验:

运算符 读者的默认预期 不应做的事
+ 不修改左右操作数,返回新值 修改 lhs 或启动耗时流程
+= 修改左操作数,返回左操作数引用 返回临时对象
== 判断等价关系,通常无副作用 改变缓存或触发 I/O
< 提供稳定排序关系 用非传递的 epsilon 比较
[] 访问元素,复杂度接近 O(1) 隐式插入大量数据或跨网络查询
() 调用对象,像函数一样执行 改变看不见的全局状态
<< 写入流并返回流 打印到其他地方或自动换行

一个实用判断是:如果把运算符换成英文函数名后,函数名会是 optimizeAndMergesendToHardwareloadFromDisk,那它很可能不该是运算符。

⚠️ 常见陷阱

⚠️ 编程陷阱:试图重载内置类型之间的运算符

错误做法:给 double + doubleint * int 定义新的含义。

现象:编译器直接报错,因为两个操作数都不是自定义类型。

根本原因:C++ 只允许通过运算符重载扩展自定义类型,不能改写语言内置语义。

正确做法:把语义封装进类型。例如 MeterRadianPixel 这样的强类型可以重载它们之间的运算。

💡 概念误区:认为能重载就应该重载

新手想法:“既然 operator+ 能写,所有合并操作都写成 +。”

实际上:运算符携带强烈约定。+ 通常表示轻量、无副作用、可组合的值运算。地图合并、优化求解、文件加载这类操作应保留明确的函数名。

正确做法:只给“数学上自然、工程上可预测”的操作使用运算符。

🧠 思维陷阱:把运算符重载当成语法糖

运算符重载不是简单替换名字。它参与重载决议、隐式转换、模板推导和表达式求值顺序。设计一个运算符就是设计这个类型如何进入整个 C++ 表达式系统。

练习

  1. 判断题:下面哪些操作适合使用运算符?哪些更适合普通函数?说明理由。
  2. Vec3 + Vec3
  3. Map + Map 表示触发回环优化后合并两个地图
  4. Pose3 * Pose3
  5. Database << Frame 表示把帧写入数据库
  6. 实现题:为 struct Meter { double value; }; 实现 +-* double。禁止 Meter + Second 这类无意义操作。
  7. 分析题:为什么 C++ 不允许重载 .?如果允许重载 .,智能指针和代理对象会获得什么能力,又会破坏什么可读性?

9.2 成员还是非成员:左操作数决定接口形状 ⭐⭐

运算符重载中最重要的设计决策是"写成成员函数还是非成员函数"。这个选择不是风格偏好,而是由操作数的对称性决定的。成员函数的左操作数被隐式绑定为 this,不经过普通的参数匹配——这意味着左操作数必须恰好是当前类的对象,不会发生隐式转换。如果你的运算符在语义上是对称的(如向量加法 a + b 应该等价于 b + a),写成成员函数就会破坏这种对称性:v * 2.0 能工作(左操作数是 Vec3),但 2.0 * v 不行(左操作数是 double,没有 Vec3 的成员函数)。

这一节详细分析成员和非成员的选择准则,以及 friend 和 ADL 在这个选择中的角色。

上一节把运算符看成函数,这一节进一步回答:这个函数应该放在类里面,还是放在类外面。

动机:v * 2.0 能用,不代表 2.0 * v 也能用

假设把向量乘标量写成成员函数:

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

    Vec3 operator*(double s) const {
        return {x * s, y * s, z * s};
    }
};

这能支持:

Vec3 v{1.0, 2.0, 3.0};
Vec3 a = v * 2.0;

但不支持:

Vec3 b = 2.0 * v;  // 编译错误:double 没有 Vec3 的成员函数

原因很直接:成员运算符的左操作数必须是当前类对象。v * 2.0 会被解释为 v.operator*(2.0)2.0 * v 会尝试解释为 2.0.operator*(v),而内置 double 不可能拥有你的成员函数。

如果选错形式会怎样

成员/非成员选错后,常见后果有三种:

错误选择 后果 示例
对称二元运算写成员 左侧为其他类型时无法匹配 2.0 * v 失败
流输出写成员 必须改写 std::ostream,不可能做到 std::cout << pose
必须成员的运算符写非成员 语言不允许 operator=operator[]operator()

这也是 C++ 教程常说“二元算术优先写非成员”的根本原因。它不是风格偏好,而是重载决议的结果。

历史与设计原因:成员函数有一个不可见的左参数

成员函数本质上多了一个隐式参数 this。下面两种写法可以粗略对应:

struct Vec3 {
    Vec3 operator+(const Vec3& rhs) const;
};

// 可理解为:
Vec3 Vec3_operator_plus(const Vec3* self, const Vec3& rhs);

非成员函数没有 this,左右参数地位相同:

Vec3 operator+(const Vec3& lhs, const Vec3& rhs);

这影响隐式转换。非成员函数允许左右两侧都参与普通参数匹配;成员函数的左侧必须先成为当前类对象,不能由右侧类型的成员函数补救。

工程规则:按操作数角色选形式

运算符类别 推荐形式 理由
赋值 =、复合赋值 += 成员 修改左操作数自身
下标 []、调用 ()、箭头 -> 成员 语言要求或语义属于对象
转换 operator bool 成员 转换当前对象
对称算术 + - * / 非成员 支持左右操作数对称转换
比较 == < <=> 通常非成员或默认成员 取决于是否需要访问私有成员
流输出 << 非成员 左操作数是 std::ostream
一元 -! 成员或非成员均可 通常按是否需要隐式转换决定

friend 与隐藏友元:不是破坏封装的借口

friend 在运算符重载中的角色经常被误解。新手往往认为 friend 等于"暴露私有成员",因此抗拒使用。但在运算符重载的语境下,friend 有一个更精确的作用:它让非成员函数能够定义在类体内——这种写法称为"隐藏友元"(hidden friend),它既不是成员函数(保持了操作数的对称性),又能访问私有成员(避免了额外的公有 getter),还只能通过 ADL 找到(减少了全局命名空间污染)。

选择 friend 非成员还是普通成员的准则可以归纳为一条原则——最小惊讶原则(Principle of Least Astonishment):如果一个运算符的左操作数和右操作数在语义上是对称的(如 a + bb + a 应该等价),就写成非成员函数,让两个操作数都经过完整的参数匹配。如果运算符天然不对称(如 a += b 修改 a 而不修改 b),就写成成员函数。

非成员运算符如果需要访问私有成员,可以声明为 friend。现代 C++ 常用“隐藏友元”写法:把 friend 函数定义在类内部,但它仍然是非成员函数,只能通过实参相关查找找到。

#include <ostream>

class Pose2 {
public:
    Pose2(double x, double y, double yaw)
        : x_(x), y_(y), yaw_(yaw) {}

    Pose2& operator+=(const Pose2& delta) noexcept {
        x_ += delta.x_;
        y_ += delta.y_;
        yaw_ += delta.yaw_;
        return *this;
    }

    friend Pose2 operator+(Pose2 lhs, const Pose2& rhs) noexcept {
        lhs += rhs;
        return lhs;
    }

    friend std::ostream& operator<<(std::ostream& os, const Pose2& p) {
        return os << "Pose2(x=" << p.x_
                  << ", y=" << p.y_
                  << ", yaw=" << p.yaw_ << ")";
    }

private:
    double x_ = 0.0;
    double y_ = 0.0;
    double yaw_ = 0.0;
};

这段代码里:

  • operator+= 是成员,因为它修改左操作数。
  • operator+ 是非成员,因为加法结果是新值。
  • operator<< 是非成员,因为左操作数是 std::ostream
  • friend 只给这两个函数访问私有数据,不把成员暴露给整个外部世界。

本质洞察friend 不是“放弃封装”,而是把某个强相关的非成员函数纳入类型接口边界。关键是让友元函数少、具体、语义稳定。

ADL:为什么非成员运算符经常能”不带命名空间”找到

ADL(Argument-Dependent Lookup,实参相关查找,也叫 Koenig Lookup)是 C++ 名字查找规则中专门为运算符重载和自由函数设计的机制。它的核心规则是:当编译器在调用点找不到函数名时,还会额外在实参类型所在的命名空间中查找同名函数。

这个规则对运算符重载至关重要。假设你的 Vec3 类定义在 namespace robotics 中,operator+ 也定义在同一命名空间中。在调用点写 a + b(其中 ab 都是 robotics::Vec3)时,即使调用点没有 using namespace robotics,编译器也能通过 ADL 找到 robotics::operator+(Vec3, Vec3)——因为实参类型 Vec3 位于 robotics 命名空间中。没有 ADL,每个运算符调用都需要显式限定命名空间,运算符重载的便利性就完全丧失了。

ADL 也解释了为什么”隐藏友元”(在类体内定义的 friend 非成员函数)是运算符重载的推荐写法:隐藏友元只能通过 ADL 找到,不会污染外层命名空间——它既保持了非成员函数的操作数对称性,又限制了函数的可见范围。

ADL 的设计动机:如果没有 ADL 会怎样

实参相关查找(Argument-Dependent Lookup, ADL),也叫 Koenig lookup,是 Andrew Koenig 在 1990 年代为 C++ 提出的名字查找规则。理解它的设计动机和完整规则,对于正确放置运算符和理解模板代码中的查找行为至关重要。

Koenig 为什么提出 ADL? 核心问题来自一个简单的需求:std::cout << “hello” 应该能工作。operator<<(std::ostream&, const char*) 定义在 std 命名空间中,但调用者不应该被迫写 std::operator<<(std::cout, “hello”)。如果没有 ADL,每次使用运算符都要带命名空间前缀,运算符重载的表达力就大打折扣。ADL 让编译器根据实参类型自动在该类型所在的命名空间中查找函数,从而让运算符调用保持自然的语法。

假设没有 ADL——推演一下灾难场景。 如果 C++ 从未引入 ADL 规则,所有非限定函数调用都只在当前作用域和外层作用域中查找,那么以下日常代码全部无法编译:

// 场景1:流输出——最常见的 ADL 依赖
std::cout << hello;
// 没有 ADL 时,编译器在全局作用域找不到 operator<<(ostream&, const char*)
// 必须写成:
std::operator<<(std::cout, hello);
// 这不仅丑陋,而且链式输出变成嵌套调用:
std::operator<<(std::operator<<(std::cout, x = ), 42);
// 原本的 std::cout << “x = “ << 42 变得完全不可读
// 场景2:自定义类型的运算符——机器人代码的核心
robotics::Vec3 a{1, 2, 3}, b{4, 5, 6};
auto c = a + b;
// 没有 ADL 时,编译器找不到 robotics::operator+(Vec3, Vec3)
// 必须写成:
auto c = robotics::operator+(a, b);
// 或者在每个使用文件开头写 using namespace robotics; ——但这会引入命名空间污染
// 场景3:Sophus 李群运算——SLAM 代码的基础
Sophus::SE3d T_wb = T_wi * T_ib;
// 没有 ADL 时:
auto T_wb = Sophus::operator*(T_wi, T_ib);
// 公式 T_wb = T_wi * T_ib 的简洁性完全丧失

上面三个场景揭示了 ADL 存在的根本必要性:C++ 的命名空间机制和运算符重载机制在没有 ADL 的情况下是互相矛盾的。命名空间要求函数定义在命名空间内部以避免全局污染;运算符重载的价值在于让 a + b 这种中缀语法自然工作。如果运算符定义在命名空间中却只能通过 namespace::operator+(a, b) 调用,运算符重载就变成了一种纯粹的语法噪声。ADL 是连接命名空间封装与运算符语法便利性的桥梁。

从更深层看,ADL 实现了一种”类型携带接口”的设计哲学:一个类型不仅定义了数据结构,还”携带”了与它相关的运算——编译器通过类型自动找到这些运算,不需要使用者显式导入。这和面向对象中”方法属于对象”的思想异曲同工,只不过 ADL 把这种归属关系从成员函数扩展到了非成员函数。

**ADL 的完整查找规则**可以概括为:当一个非限定函数调用(不带 :: 前缀)发生时,除了正常的作用域查找外,编译器还会在每个实参类型的”关联命名空间”中额外查找候选函数。关联命名空间的确定规则如下:

实参类型 关联的命名空间
基本类型(intdouble 无额外关联命名空间
类类型 C C 所在的命名空间、C 的所有基类所在的命名空间
类模板特化 T<A, B> T 所在的命名空间、模板参数 AB 所在的命名空间
枚举类型 枚举所在的命名空间
指针类型 T* T 的关联命名空间

这就是为什么把运算符和类型放在同一个命名空间如此重要。如果 Vec3robotics 命名空间,operator+ 也应在 robotics 命名空间。ADL 会自动找到它,调用者不需要写 robotics::operator+(a, b)

ADL 也有一个常被忽视的陷阱:它可能找到你没预期到的函数。如果模板代码中的非限定调用意外匹配了某个命名空间中的同名函数,结果可能令人困惑。这也是 “hidden friend” 模式(在类体内定义 friend 函数)流行的原因之一——hidden friend 只能通过 ADL 找到,不能通过普通查找找到,减少了命名空间污染。

ADL 会根据实参类型所在命名空间查找函数。假设有:

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

Vec3 operator+(const Vec3& a, const Vec3& b);
}

调用处可以写:

robotics::Vec3 a{1, 2, 3};
robotics::Vec3 b{4, 5, 6};
auto c = a + b;  // ADL 找到 robotics::operator+

这也是为什么运算符通常和类型放在同一个命名空间。不要把 Vec3 放在 robotics,却把 operator+ 放在全局命名空间;那会让查找规则变得脆弱。

⚠️ 常见陷阱

⚠️ 编程陷阱:把 operator<< 写成类成员

错误做法:在 Pose 里写 std::ostream& Pose::operator<<(std::ostream&)

现象:无法支持 std::cout << pose,最多只能写成奇怪的 pose << std::cout

根本原因:成员运算符的左操作数必须是当前对象,而流输出的左操作数是 std::ostream

正确做法:写成非成员函数 std::ostream& operator<<(std::ostream&, const Pose&)

💡 概念误区:非成员函数就不是类接口的一部分

新手想法:“类接口只包括成员函数。”

实际上:和类型放在同一命名空间、以该类型为参数的非成员函数,也是类型接口的一部分。operator+operator<<swap 都常常以这种形式存在。

正确理解:成员函数表达“对象自己能做什么”;非成员函数表达“这个类型如何参与外部关系”。

🧠 思维陷阱:为了访问私有成员滥用 friend

如果十几个普通工具函数都需要 friend,通常说明类的公开查询接口不足,或者类承担了太多职责。运算符友元应当是例外,而不是访问私有状态的常规通道。

练习

  1. 改写题:把 Vec3::operator*(double) 改写成非成员函数,使 v * 2.02.0 * v 都能编译。
  2. 判断题:下列运算符必须写成成员、推荐写成非成员,还是两者都可以?
  3. operator=
  4. operator+
  5. operator[]
  6. operator<<
  7. operator==
  8. 设计题:一个 ImageView 类型只持有外部图像的指针和尺寸,不拥有数据。它的 operator() 用于像 img(row, col) 一样访问像素。这个运算符应该是成员还是非成员?是否需要 const 版本?

9.3 算术与复合赋值:先写会变的,再写不变的 ⭐⭐

算术运算符的实现有一个经典的设计模式:先写复合赋值(+=-=*=),再用复合赋值实现二元运算(+-*。这个模式的合理性来自两个层面。语义层面:a += b 是"修改 a",a + b 是"计算新值"——前者是后者的基础操作。实现层面:operator+= 就地修改、不分配额外内存,operator+ 可以通过"拷贝左操作数 + 调用 +="来实现,避免了代码重复。

这个模式在机器人数值类型(向量、位姿、残差)中尤其重要,因为它保证了复合赋值和二元运算在数学上一致。如果两者独立实现,修改一个忘记修改另一个,就会出现 (a + b)a += b; return a; 给出不同结果的 bug——这在数值优化中可能导致梯度不一致,进而导致优化器无法收敛。

这一节解决的问题是:如何为数值类型实现 +-*/+=*=,让它们既符合直觉,又能利用 移动语义与完美转发 讲过的拷贝/移动优化。

动机:复合赋值是算术运算的地基

对值类型来说,a + ba += b 的区别很清楚:

  • a + b 不应修改 ab,返回一个新值。
  • a += b 修改 a,并返回 a 自身的引用。

因此工程上常用模式是:

  1. 先实现 operator+=operator-= 这类复合赋值成员。
  2. 再用它们实现 operator+operator- 这类非成员二元算术。

这相当于先写“原地更新”,再写“复制一份后原地更新”。

如果分开手写会怎样

如果 operator+operator+= 各写一套逻辑,两个函数很容易慢慢不一致:

struct BadVec3 {
    double x = 0.0;
    double y = 0.0;
    double z = 0.0;

    BadVec3& operator+=(const BadVec3& rhs) {
        x += rhs.x;
        y += rhs.y;
        z += rhs.z;
        return *this;
    }

    BadVec3 operator+(const BadVec3& rhs) const {
        return {x + rhs.x, y + rhs.y, z};  // 错误:忘了 z + rhs.z
    }
};

这种错误在代码审查中很难看出,因为两个函数都很“像对的”。把 operator+ 统一写成 lhs += rhs 可以把真实逻辑集中在一个地方。

历史与设计原因:C++ 值语义鼓励“传值左操作数”

现代 C++ 常用下面的写法:

inline Vec3 operator+(Vec3 lhs, const Vec3& rhs) noexcept {
    lhs += rhs;
    return lhs;
}

这里左操作数按值传入,看起来多了一次拷贝,但它有三个好处:

调用形式 lhs 如何构造 好处
a + ba 是左值 拷贝 a 正好需要一个新结果
makeVec() + b 可能移动或直接构造 利用临时对象
std::move(a) + b 移动 a 调用者明确放弃 a

回顾 移动语义与完美转发:返回局部对象时,编译器可以做返回值优化;即使没有优化,移动也通常比深拷贝便宜。按值接收左操作数的模式正是现代值语义的一种自然写法。

完整示例:三维向量的算术接口

下面这个完整的 Vec3 实现遵循了前面推导的所有原则。阅读时注意三个层次的设计:(1) 复合赋值作为成员函数定义在类体内部,直接修改 *this——这是"修改自身"的引用语义操作;(2) 二元算术定义在类体外部作为非成员函数,按值接收左操作数——这是"产生新值"的值语义操作;(3) 二元算术统一通过调用复合赋值来实现,确保两者逻辑永远一致。

第一部分:复合赋值运算符(成员函数)。 这些是算术操作的地基。每个函数直接修改 *this 的数据成员并返回 *this 的引用。noexcept 标注在这里是合理的,因为浮点加减乘不会抛异常;除法需要检查除零,所以不标 noexcept

#include <cmath>
#include <ostream>

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

    // 复合赋值:修改自身,返回自身引用(引用语义)
    Vec3& operator+=(const Vec3& rhs) noexcept {
        x += rhs.x;
        y += rhs.y;
        z += rhs.z;
        return *this;  // 返回引用,支持链式调用
    }

    Vec3& operator-=(const Vec3& rhs) noexcept {
        x -= rhs.x;
        y -= rhs.y;
        z -= rhs.z;
        return *this;
    }

    Vec3& operator*=(double s) noexcept {
        x *= s;
        y *= s;
        z *= s;
        return *this;
    }

    Vec3& operator/=(double s) {
        if (s == 0.0) {
            throw std::invalid_argument("division by zero");
        }
        x /= s;
        y /= s;
        z /= s;
        return *this;
    }
};

第二部分:二元算术运算符(非成员函数)。 注意左操作数 lhsv 都是按值传入的——这不是疏忽,而是刻意设计。按值传入意味着函数内部操作的是一份副本,在副本上调用 += 不会修改原始操作数。函数返回修改后的副本,这正是值语义的标准形式。

// 二元算术:产生新值,不修改操作数(值语义)
// 左操作数按值传入 —— 函数体内修改的是副本
inline Vec3 operator+(Vec3 lhs, const Vec3& rhs) noexcept {
    lhs += rhs;    // 在副本上调用复合赋值
    return lhs;    // 返回修改后的副本
}

inline Vec3 operator-(Vec3 lhs, const Vec3& rhs) noexcept {
    lhs -= rhs;
    return lhs;
}

// 标量乘法:两个方向都支持,其中一个转发给另一个
inline Vec3 operator*(Vec3 v, double s) noexcept {
    v *= s;
    return v;
}

inline Vec3 operator*(double s, Vec3 v) noexcept {
    v *= s;         // 复用 v * s 的逻辑
    return v;
}

inline Vec3 operator/(Vec3 v, double s) {
    v /= s;
    return v;
}

第三部分:具名几何函数。 点积和范数不用运算符,而用具名函数——原因在下文解释。

// 点积和范数用具名函数,避免 * 的歧义
inline double dot(const Vec3& a, const Vec3& b) noexcept {
    return a.x * b.x + a.y * b.y + a.z * b.z;
}

inline double norm(const Vec3& v) noexcept {
    return std::sqrt(dot(v, v));
}

这里刻意没有重载 operator* 来表示点积。为什么?因为 * 对向量有两个常见含义:

  • 标量乘法:s * v
  • 点积:a * b

数学上二者都常见,但在 C++ 接口中让同一个符号根据参数类型返回 Vec3double,容易让读者在复杂表达式里误读。很多库选择显式函数名:dot(a, b)cross(a, b)。Eigen 因为有成熟的矩阵语义,a.dot(b)a.cross(b) 也采用了显式命名。

对称二元运算:标量在左侧也要自然

对称不是指实现上完全相同,而是使用上符合数学习惯。标量乘法应同时支持:

Vec3 v{1.0, 2.0, 3.0};
auto a = v * 0.5;
auto b = 0.5 * v;

这通常通过两个非成员函数完成,其中一个转发给另一个:

inline Vec3 operator*(Vec3 v, double s) noexcept {
    v *= s;
    return v;
}

inline Vec3 operator*(double s, Vec3 v) noexcept {
    return v * s;
}

如果写成成员函数,只能自然支持 v * s。这就是 9.2 节“对称二元运算写非成员”的具体应用。

语义边界:不是所有数学操作都该抢占运算符

机器人几何中还有一些操作看似可以用运算符,实际上更适合函数名:

操作 推荐接口 理由
点积 dot(a, b) 避免与标量乘法混淆
叉积 cross(a, b) ^ 在 C++ 中是异或,拿来表示叉积会误导
归一化 normalized(v)v.normalize() 有除零风险,函数名更醒目
插值 interpolate(a, b, t) 参数含义超过二元运算
坐标系变换 T * p 可接受 李群作用有强约定,Sophus/Eigen 已形成习惯

不是所有数学符号都应该挤进 C++ 运算符。 运算符集合有限,优先留给最没有歧义的操作。

⚠️ 常见陷阱

⚠️ 编程陷阱:operator+= 返回值而不是引用

错误做法Vec3 operator+=(const Vec3& rhs)

现象(a += b) += c 修改的是临时对象,链式赋值行为异常;还会产生不必要的拷贝。

根本原因:复合赋值的语义是“修改当前对象并返回当前对象”。

正确做法:返回 Vec3&,函数末尾 return *this;

💡 概念误区:二元 operator+ 应该返回 const Vec3

新手想法:“返回 const 可以防止别人修改临时结果。”

实际上:返回 const 值会阻碍移动语义和某些泛型代码,没有实际安全收益。临时值本来就只能在表达式中使用。

正确做法:按值返回 Vec3,不要返回 const Vec3

🧠 思维陷阱:为了“像数学”而重载所有符号

^ 在数学里可能表示叉积,也可能表示幂;在 C++ 里它是按位异或。给 Vec3 重载 ^ 表示叉积会让懂 C++ 的读者产生错误预期。清楚的函数名常常比短符号更好。

练习

  1. 实现题:补全上面 Vec3 的一元负号 operator-,使 Vec3 b = -a; 返回每个分量取反的新向量。
  2. 分析题:为什么 operator+ 的参数常写成 Vec3 lhs 而不是 const Vec3& lhs?结合 移动语义与完美转发 的移动语义解释。
  3. 设计题:你会不会为 Quaternion 重载 operator+?四元数相加在数学上存在,但它是不是机器人姿态接口中最应该暴露的操作?

9.4 流输出:让调试信息进入 C++ I/O 体系 ⭐

operator<< 重载在机器人代码中的价值常常被低估。调试 SLAM 系统时,你需要快速查看位姿、残差、变换矩阵、点云统计信息。如果这些类型没有流输出运算符,每次调试都要手动拼接字段——std::cout << "x=" << pose.x << " y=" << pose.y << " yaw=" << pose.yaw——冗长且容易遗漏字段。有了 operator<<,一行 std::cout << pose 就能输出完整信息。

流输出运算符必须是非成员函数(通常是 friend),因为左操作数是 std::ostream& 而不是自定义类型。这是 运算符重载.2 中"左操作数不是自己的类型时必须用非成员"规则的典型应用。

这一节解决的问题是:如何让自定义类型支持 std::cout << obj,并避免输出运算符承担过多职责。

动机:调试位姿、残差和状态时需要可读输出

机器人程序的调试常常从打印状态开始:

std::cout << "pose = " << pose << "\n";
std::cout << "residual = " << residual << "\n";
std::cout << "state = " << state << "\n";

如果没有 operator<<,调用处通常变成:

std::cout << "pose = ";
pose.print(std::cout);
std::cout << "\n";

这打断了 C++ 流式输出的链条,也让日志组合变得笨重。

如果输出函数设计不好会怎样

常见问题不是“不能输出”,而是输出函数偷偷做了太多事:

错误设计 后果
operator<< 内部自动加换行 调用者无法组合一行多段日志
输出到 std::cout 而不是传入的 os 文件流、字符串流失效
返回 void std::cout << a << b 链式输出失效
修改对象内部缓存 const 输出不再可信
打印超大矩阵全文 实时日志卡顿

历史与设计原因:<< 的左操作数是流

std::cout << pose 的左操作数是 std::ostream,而 std::ostream 属于标准库,不能把你的成员函数塞进去。因此输出运算符必须写成非成员:

std::ostream& operator<<(std::ostream& os, const Pose2& pose);

它返回 std::ostream&,是为了支持链式输出:

std::cout << "pose = " << pose << ", score = " << score << "\n";

正确写法:只描述对象,不控制日志策略

#include <ostream>

struct Pose2D {
    double x = 0.0;
    double y = 0.0;
    double yaw = 0.0;
};

inline std::ostream& operator<<(std::ostream& os, const Pose2D& p) {
    return os << "Pose2D{x=" << p.x
              << ", y=" << p.y
              << ", yaw=" << p.yaw << "}";
}

这个函数有四个细节:

  1. 参数 os 是引用,不能按值传递,因为流对象不可拷贝。
  2. Pose2Dconst&,输出不应修改对象。
  3. 返回 os,保持链式输出。
  4. 不加换行,把换行策略留给调用者。

私有成员类型的输出

如果成员是私有的,可以使用公开 getter,也可以使用友元。两种方式的取舍如下:

方式 优点 代价
getter 封装边界清楚,输出函数不需要友元 可能为了输出暴露过多访问器
friend operator<< 输出函数直接访问内部表示 增加一个受信任入口

小型值类型常用友元;大型状态类更适合提供明确的查询接口或专门的 summary() 方法。

class TrackingResult {
public:
    TrackingResult(bool ok, double score)
        : ok_(ok), score_(score) {}

    friend std::ostream& operator<<(std::ostream& os,
                                    const TrackingResult& r) {
        return os << "TrackingResult{ok=" << std::boolalpha << r.ok_
                  << ", score=" << r.score_ << "}";
    }

private:
    bool ok_ = false;
    double score_ = 0.0;
};

输出格式:稳定比华丽更重要

日志输出常常被测试、脚本或人眼扫描依赖。对教学和调试来说,推荐格式有三条:

建议 示例
类型名开头 Pose2D{x=1, y=2, yaw=0.1}
字段名明确 不写成 (1, 2, 0.1) 让读者猜
单行摘要 大对象输出摘要,细节用专门函数

大型矩阵、点云、地图不适合在 operator<< 中完整展开。可以输出形状和关键统计:

struct CloudSummary {
    std::size_t size = 0;
    double min_range = 0.0;
    double max_range = 0.0;
};

inline std::ostream& operator<<(std::ostream& os, const CloudSummary& c) {
    return os << "CloudSummary{size=" << c.size
              << ", min_range=" << c.min_range
              << ", max_range=" << c.max_range << "}";
}

⚠️ 常见陷阱

⚠️ 编程陷阱:输出运算符返回 void

错误做法void operator<<(std::ostream& os, const Pose& p)

现象std::cout << pose << "\n" 不能编译,因为第一个 << 的结果无法继续参与第二个 <<

根本原因:C++ 流输出通过返回同一个流对象实现链式调用。

正确做法:返回 std::ostream&,并返回传入的 os

💡 概念误区:operator<< 应该负责完整日志格式

新手想法:“既然在输出,就顺便加时间戳、换行、日志等级。”

实际上operator<< 应只负责对象如何表示。时间戳、日志等级、换行属于日志系统策略。

正确做法:让 operator<< 输出单个对象的可读表示,调用处决定上下文格式。

🧠 思维陷阱:把大型数据完整打印当成调试透明

百万点云、稀疏图、Hessian 矩阵完整输出会让日志不可读,还可能破坏实时性。调试接口应优先输出摘要、维度、范围、异常点数量。

练习

  1. 实现题:为 struct ImuSample { double ax, ay, az, gx, gy, gz, stamp; };operator<<,要求单行输出并包含字段名。
  2. 设计题:一个 VoxelMap 内部有几十万个体素。你会给它的 operator<< 输出哪些信息?哪些信息应放到单独的 dump() 函数?
  3. 分析题:为什么 operator<< 的第一个参数不能是 const std::ostream&

9.5 比较运算:相等、排序和浮点误差不是一回事 ⭐⭐

比较运算是运算符重载中最容易出错的领域。原因在于:日常用语中”相等”和”差不多一样”没有严格区分,但 C++ 的容器和算法对比较运算有精确的数学要求。std::setstd::map 要求比较器满足严格弱序(strict weak ordering)——反自反、不对称、传递。std::sort 同样要求严格弱序。如果你的 operator< 不满足这些性质(例如使用 epsilon 比较),容器和算法的行为就是未定义的——可能产生错误排序、元素丢失、或直接崩溃。

这一节解决的问题是:如何实现 ==!=<<=>,并避免把”近似相等”和”可排序”混在一起。

动机:比较操作决定容器、算法和测试能否工作

比较运算在机器人代码中非常常见:

if (status == TrackingStatus::Lost) { ... }
if (timestamp_a < timestamp_b) { ... }
std::sort(residuals.begin(), residuals.end());
EXPECT_TRUE(isApprox(pose_a, pose_b, 1e-9));

这里至少有三种不同语义:

语义 典型接口 说明
精确相等 a == b 离散状态、ID、整数时间戳
严格排序 a < b<=> 排序、集合、优先队列
数值近似 isApprox(a, b, eps) 浮点几何量、优化结果

把这三种语义混成一个 operator==operator<,会制造很隐蔽的 bug。

如果用 epsilon 实现 operator< 会怎样

许多人会写出类似比较器:

struct BadLess {
    bool operator()(double a, double b) const {
        return a < b - 1e-6;
    }
};

它看起来能“忽略浮点误差”,但可能破坏严格弱序。排序和 std::set 要求比较器满足:

  • 反自反:comp(a, a) 必须为 false。
  • 传递:如果 comp(a, b)comp(b, c) 为 true,则 comp(a, c) 必须为 true。
  • 等价关系稳定:既不小于彼此的元素应形成一致分组。

epsilon 比较很容易让接近的值形成不稳定等价类。结果可能是排序顺序不可预测、集合查找失败,甚至触发标准库算法的未定义行为前置条件。

历史与设计原因:C++20 用 <=> 减少重复,但没有替你定义语义

在 C++20 之前,如果你想让一个类型支持所有六种比较运算(==!=<<=>>=),你需要手动实现至少两个运算符(==<),然后从它们推导出其余四个。这不仅是冗余代码的问题,更容易出错——如果修改了 < 但忘记同步修改 ==,比较语义就会不一致。

C++20 的三路比较运算符 <=> 通过"一个运算符推导出所有比较"来解决这个问题。它的返回类型不是 bool,而是三种比较类别之一:std::strong_ordering(强序,如整数)、std::weak_ordering(弱序,如大小写不敏感字符串)、std::partial_ordering(偏序,如浮点数——因为 NaN 不可比较)。这种设计不仅减少了样板代码,还让比较语义通过类型系统显式表达。

C++20 引入三路比较运算符 <=>,可以从一个比较生成多个关系运算:

#include <compare>

struct Stamp {
    long long nanoseconds = 0;

    auto operator<=>(const Stamp&) const = default;
};

有了默认 <=>,编译器可以生成 ==!=<<=>>=。这对成员都是可比较的简单值类型非常方便。

<=> 只减少样板代码,不替你判断“什么叫相等”。对于浮点姿态、矩阵、优化残差,默认比较通常不是你真正想要的工程语义。

精确比较:适合离散值和结构化键

#include <compare>
#include <cstdint>

struct FrameId {
    std::uint64_t value = 0;

    auto operator<=>(const FrameId&) const = default;
};

struct VoxelKey {
    int x = 0;
    int y = 0;
    int z = 0;

    auto operator<=>(const VoxelKey&) const = default;
};

这类类型适合默认比较,因为成员是整数,语义清晰:

FrameId a{10};
FrameId b{20};

bool newer = b > a;  // 清楚:ID 或时间戳更大

近似比较:适合浮点几何量,但不应伪装成 ==

浮点数的比较是计算机科学中一个经典难题。IEEE 754 浮点运算的有限精度意味着 0.1 + 0.2 != 0.3(在双精度下结果是 0.30000000000000004)。对于机器人代码中的几何量——位姿、残差、优化后的坐标——精确的 == 比较几乎永远不会为真。但这不意味着应该把 epsilon 近似判断塞进 operator==——这样做会破坏等价关系的数学性质,让依赖 == 的容器和算法行为不可预测。

正确的做法是把"精确相等"和"近似相等"分成两个独立的接口:operator== 保留给精确比较(离散值、ID、整数时间戳),近似比较用命名函数(如 Eigen 的 isApprox())。这种分离让代码的意图更清晰——读者看到 == 就知道是精确判断,看到 isApprox 就知道有容差。

对于位姿、向量、矩阵,推荐使用命名函数:

#include <cmath>

struct Pose2D {
    double x = 0.0;
    double y = 0.0;
    double yaw = 0.0;
};

inline bool isApprox(const Pose2D& a, const Pose2D& b, double eps) {
    return std::abs(a.x - b.x) <= eps
        && std::abs(a.y - b.y) <= eps
        && std::abs(a.yaw - b.yaw) <= eps;
}

为什么不直接写成 operator==?因为 == 通常暗示等价关系,而 epsilon 近似不具备严格的传递性:

a = 0.0
b = 0.9 * eps
c = 1.8 * eps

a 近似等于 b
b 近似等于 c
a 不近似等于 c

这在数学上不是等价关系。如果把它放进 operator==,很多容器和算法的直觉会被破坏。

排序比较:比较的不是”对象全部意义”,而是某个键

运算符重载中有一个微妙但重要的概念区分:自然排序**和**用途排序。自然排序是指对象类型本身有唯一的、无歧义的排序方式——整数的大小、时间戳的先后。用途排序是指同一种对象可以按不同的键排序——关键帧可以按时间排序、也可以按观测数量排序、也可以按共视权重排序。

如果一个类型有自然排序,给它写 operator< 是合理的(如 FrameId)。如果排序方式取决于使用场景,就不应该写 operator<——因为不同排序方式之间没有”默认”的优劣之分,强行选择一种作为默认会误导后续开发者。此时应该用局部 Lambda 或命名比较器来表达特定的排序意图。

残差排序通常按误差大小,不代表两个残差对象存在完整的”大小”关系:

#include <algorithm>
#include <vector>

struct Residual {
    int factor_id = 0;
    double squared_error = 0.0;
};

void sortByError(std::vector<Residual>& residuals) {
    std::sort(residuals.begin(), residuals.end(),
              [](const Residual& a, const Residual& b) {
                  return a.squared_error < b.squared_error;
              });
}

这里不必给 Residual 重载 operator<。局部 Lambda 更清楚:排序依据是 squared_error,不是残差对象的自然顺序。

这正好连接到 Lambda与STL算法:很多比较逻辑不应固化成类型的全局运算符,而应作为算法调用处的可调用对象传入。

⚠️ 常见陷阱

⚠️ 编程陷阱:用浮点 epsilon 实现 operator== 后放进容器

错误做法bool operator==(Pose a, Pose b) { return isApprox(a, b, 1e-6); }

现象:测试里看似方便,但哈希表、去重、集合逻辑变得难以推理。

根本原因:epsilon 近似通常不是传递的等价关系。

正确做法operator== 保留精确结构语义;数值比较用 isApprox(a, b, eps)

💡 概念误区:所有能排序的类型都应该重载 <

新手想法:“我需要按误差排序残差,所以给 Residualoperator<。”

实际上:按误差排序只是一个局部需求。另一个场景可能按时间、因子 ID 或鲁棒核权重排序。

正确做法:只有当类型存在稳定自然顺序时才重载 <<=>;局部排序使用比较函数对象或 Lambda。

🧠 思维陷阱:把 <=> 当成比较语义的自动设计器

默认 <=> 只是按成员顺序做词典序比较。成员顺序是代码布局,不一定是领域语义。给 Pose2D{x,y,yaw} 默认排序通常没有几何意义。

练习

  1. 实现题:为 FrameIdVoxelKey 使用默认 <=>,验证 ==<> 都能工作。
  2. 分析题:为什么 Pose2Doperator== 不适合用 epsilon?给出一个违反传递性的数值例子。
  3. 设计题:一个 KeyFrame 可以按时间排序,也可以按观测点数量排序,还可以按共视权重排序。你会不会给它写 operator<?为什么?

9.6 下标与函数调用:容器访问和函数对象的共同入口 ⭐⭐

operator[]operator() 是 C++ 中最特殊的两个运算符重载——它们都必须是成员函数,且都直接影响对象的"使用手感"。operator[] 让对象看起来像数组或字典,operator() 让对象看起来像函数。这两个运算符在机器人代码中极其常见:状态向量通过 [] 访问分量,代价函数通过 () 计算残差,Eigen 矩阵通过 () 访问元素(注意 Eigen 用 () 而不是 [] 访问矩阵元素,因为 C++23 之前 [] 只能接受一个参数,无法表达 (row, col) 的二维索引)。

operator() 还有一个更深层的意义:它是连接本章和 Lambda与STL算法 Lambda 的桥梁。Lambda 本质上就是编译器生成的带 operator() 的匿名类——理解了 operator(),就理解了 Lambda 的底层机制。

这一节解决的问题是:operator[]operator() 分别适合表达什么,以及它们如何为 Lambda与STL算法 的 Lambda 和 STL 算法铺路。

动机:有些对象天然像数组,有些对象天然像函数

下标运算符用于“对象内部有可索引元素”的类型:

Vec3 p;
p[0] = 1.0;
p[1] = 2.0;
p[2] = 3.0;

函数调用运算符用于“对象带着状态执行一段计算”的类型:

HuberLoss loss{1.0};
double w = loss(residual);

它们的共同点是:对象本身不是普通函数,但可以通过运算符进入语言已有的访问/调用语法。

如果只写普通成员函数会怎样

没有 operator[],向量访问会变成:

p.set(0, 1.0);
double x = p.get(0);

这不是不可接受,但和数组、Eigen 向量、std::vector 的习惯不一致。

没有 operator(),带状态的可调用对象会变成:

double w = loss.evaluate(residual);

一旦进入 STL 算法和回调系统,普通成员函数就不如 operator() 自然:

std::transform(values.begin(), values.end(), weights.begin(), loss);

这里 loss 必须是一个可调用对象。函数对象、Lambda、函数指针都能进入这套机制。

operator[]:必须同时考虑 const 和非 const

一个可变容器通常需要两个版本:

#include <array>
#include <cstddef>
#include <stdexcept>

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

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

    double& at(std::size_t i) {
        if (i >= data_.size()) {
            throw std::out_of_range("SmallVec3 index out of range");
        }
        return data_[i];
    }

    const double& at(std::size_t i) const {
        if (i >= data_.size()) {
            throw std::out_of_range("SmallVec3 index out of range");
        }
        return data_[i];
    }

private:
    std::array<double, 3> data_{};
};

两个 operator[] 的差异:

版本 调用对象 返回值 作用
double& operator[](size_t) 非 const 对象 可写引用 v[0] = 1.0
const double& operator[](size_t) const const 对象 只读引用 double x = cv[0]

operator[] 通常不做边界检查,和标准库容器保持一致。需要检查时提供 .at(),让调用者显式选择安全性。

C++23 开始允许多参数 operator[],因此 matrix[i, j] 这类形式在新标准中有了语言支持。这消除了 operator() 承担"多维索引"和"函数调用"双重职责的历史遗留问题:

// C++23:多参数 operator[]
class Matrix3d {
public:
    double& operator[](std::size_t row, std::size_t col) {
        return data_[row * 3 + col];
    }

    const double& operator[](std::size_t row, std::size_t col) const {
        return data_[row * 3 + col];
    }

private:
    std::array<double, 9> data_{};
};

// 使用:m[1, 2] 而不是 m(1, 2)

C++23 同时允许 operator() 被标记为 static——这对函数对象和 Lambda 都有影响。无状态的函数对象(如比较器、哈希函数)可以用 static operator() 消除隐式 this 参数的传递开销:

// C++23:static operator() 的函数对象
struct SquaredNormLess {
    static bool operator()(const Vec3& a, const Vec3& b) {
        return dot(a, a) < dot(b, b);  // 不需要 this——函数对象没有状态
    }
};

但机器人生态中的 Eigen、许多图优化库和大量 C++17 项目仍以 operator() 表达多维索引。本章沿用 matrix(i, j),因为它更贴近当前主流工程代码,也更容易和 Eigen 示例衔接。

operator():矩阵索引和函数对象共用同一个符号

operator() 有两个常见用途:

  1. 多维索引,例如 image(row, col)matrix(i, j)
  2. 函数对象,例如 loss(r)cost(params, residuals)

多维索引示例:

#include <cstddef>
#include <vector>

class Grid2D {
public:
    Grid2D(std::size_t rows, std::size_t cols)
        : rows_(rows), cols_(cols), data_(rows * cols) {}

    double& operator()(std::size_t r, std::size_t c) {
        return data_[r * cols_ + c];
    }

    const double& operator()(std::size_t r, std::size_t c) const {
        return data_[r * cols_ + c];
    }

private:
    std::size_t rows_ = 0;
    std::size_t cols_ = 0;
    std::vector<double> data_;
};

函数对象示例。 operator() 的第二种用途更加深远——它让普通对象变成"可调用的"。函数对象(functor)和普通函数的区别在于:函数对象可以在构造时捕获参数并长期持有,每次调用时使用这些参数。普通函数的参数只能在调用时传入,无法在创建时"预配置"。这种能力在 SLAM 的鲁棒核函数、代价函数和回调函数中无处不在。

#include <cmath>

class HuberLoss {
public:
    explicit HuberLoss(double delta) : delta_(delta) {}  // 构造时捕获阈值

    // operator() 让对象像函数一样被调用
    double operator()(double r) const {
        const double a = std::abs(r);
        if (a <= delta_) {
            return 0.5 * r * r;           // 小残差:二次损失
        }
        return delta_ * (a - 0.5 * delta_); // 大残差:线性损失
    }

private:
    double delta_ = 1.0;  // 鲁棒核的阈值参数——构造时确定,每次调用时使用
};

这个对象像函数一样被调用——loss(residual) 和调用普通函数 huberLoss(residual, delta) 的语法几乎相同。但函数对象的优势在于:delta 在构造时就绑定了,后续调用不需要每次传入。这在 STL 算法中尤其有用——std::transform(begin, end, out, HuberLoss{1.0}) 可以把鲁棒核应用到整个残差序列上,而普通函数需要额外的适配器来绑定 delta 参数。

本质洞察operator() 把"数据"和"行为"绑定到同一个对象中——对象的成员变量是数据(如 delta_),operator() 是行为(如 Huber 损失计算)。这正是 Lambda 的前身:Lambda与STL算法 会展示 Lambda 本质上就是编译器自动生成的带 operator() 的匿名函数对象。

Ceres 代价函数:operator() 的工程形态

HuberLoss 到 Ceres 代价函数只有一步之遥。Ceres 的自动微分接口要求用户提供一个带模板 operator() 的函数对象。模板参数 T 可能是 double(普通计算)或 Jet<double, N>(自动微分计算)——同一个 operator() 既能计算残差值,也能自动计算雅可比矩阵。这种"运算符重载驱动自动微分"的设计在 运算符重载.11 中会详细展开。典型形态是:

struct ReprojectionCost {
    ReprojectionCost(double observed_x, double observed_y)
        : observed_x_(observed_x), observed_y_(observed_y) {}

    template <typename T>
    bool operator()(const T* const camera,
                    const T* const point,
                    T* residuals) const {
        const T predicted_x = camera[0] + point[0];
        const T predicted_y = camera[1] + point[1];

        residuals[0] = predicted_x - T(observed_x_);
        residuals[1] = predicted_y - T(observed_y_);
        return true;
    }

    double observed_x_ = 0.0;
    double observed_y_ = 0.0;
};

这里 operator() 不是为了“语法酷”,而是为了让对象满足“可调用”概念:

  • 它可以保存观测值。
  • 它可以被模板化,让 T 既可以是 double,也可以是 Ceres 的 Jet
  • 它可以被优化器统一调用。

与 Lambda 的桥接

Lambda 表达式本质上是编译器生成的匿名类,类里实现了 operator()。下面两段代码在心智模型上等价:

auto lambda = [scale = 2.0](double x) {
    return scale * x;
};
class GeneratedLikeLambda {
public:
    explicit GeneratedLikeLambda(double scale) : scale_(scale) {}

    double operator()(double x) const {
        return scale_ * x;
    }

private:
    double scale_ = 1.0;
};

GeneratedLikeLambda lambda{2.0};

所以本章理解 operator(),下一章学习 Lambda 时就不会把 Lambda 当成魔法。它只是函数对象的语法简写。

⚠️ 常见陷阱

⚠️ 编程陷阱:只写非 const 下标版本

错误做法:只提供 double& operator[](size_t)

现象const SmallVec3& v 无法读取 v[0]

根本原因:const 对象只能调用 const 成员函数。

正确做法:同时提供 const 和非 const 两个重载。

⚠️ 编程陷阱:operator[] 返回值而不是引用

错误做法double operator[](size_t i)

现象v[0] = 1.0 不能编译,或者修改的是临时值。

根本原因:下标访问要能代表容器内的元素位置。

正确做法:可变版本返回 T&,只读版本返回 const T& 或轻量值。

💡 概念误区:operator() 只能有一个参数列表

实际上operator() 可以重载。Eigen 中矩阵可以用 (row, col),向量可以用 (index)。函数对象也可以为不同输入类型提供多个调用重载。

注意:重载过多会让错误信息和模板推导变复杂,接口应保持少而清楚。

练习

  1. 实现题:给 Grid2D 增加边界检查版本 at(row, col),并保持 operator() 不检查边界。
  2. 分析题:为什么 std::sort 的比较器可以是 Lambda,也可以是带 operator() 的对象?
  3. 综合题:结合 现代类设计与特殊成员函数 的类设计和 移动语义与完美转发 的移动语义,设计一个 MovingAverage 函数对象,构造时接收窗口大小,operator()(double x) 每次输入一个值并返回当前滑动平均。

9.7 operator bool:让对象进入条件判断,但不进入算术 ⭐⭐

这一节解决的问题是:如何让对象写成 if (handle)if (result),同时避免它被隐式转换成整数参与无意义计算。

动机:资源句柄和结果对象常常需要“是否有效”

很多类型都有“可用/不可用”的状态:

if (image) {
    process(image);
}

if (tracking_result) {
    publishPose(tracking_result.pose());
}

这种写法比 if (image.isValid()) 更短,也符合智能指针、文件句柄、optional 类对象的习惯。

如果写成隐式 operator bool 会怎样

隐式 operator bool 的问题是 C++ 隐式转换链的一个典型案例。bool 在 C++ 中是整数类型——true 可以隐式转换为 1false 可以隐式转换为 0。这意味着任何有隐式 operator bool 的类型都会"不经意间"获得参与算术运算、比较运算和整数转换的能力——这些能力几乎从来不是设计者想要的。

C++98 风格的写法是:

struct BadHandle {
    // 隐式转换运算符——没有 explicit 关键字
    operator bool() const {
        return ptr != nullptr;
    }

    void* ptr = nullptr;
};

这允许 if (h) 这种合理用法,但同时也允许一系列荒谬的操作:

BadHandle h;

if (h) {
    // OK
}

int x = h + 3;  // 也可能编译通过:bool -> int

后者完全没有领域意义。句柄不是数字,不应参与加法。

隐式转换与重载决议的交互:为什么 explicit 如此重要

explicit 不是一个装饰性关键字——它直接参与重载决议算法的第二阶段。当编译器检查"实参能否匹配形参"时,如果形参类型需要通过用户定义的转换(构造函数或转换运算符)来匹配,explicit 标记的转换不会被考虑。这意味着 explicit 构造函数和 explicit 转换运算符被排除在隐式转换候选之外,只有显式调用(如 static_cast 或直接初始化)才能触发它们。

缺少 explicit 时,隐式转换会和重载决议产生危险的交互。考虑一个没有 explicit 的单参数构造函数:

struct Meter {
    Meter(double v) : value(v) {}  // 非 explicit:double 可以隐式转成 Meter
    double value;
};

Meter operator+(Meter a, Meter b) { return Meter{a.value + b.value}; }

这时 Meter m{1.0}; auto r = m + 2.0; 能编译——编译器用 Meter(2.0) 隐式把 double 转成 Meter。如果这是期望行为就没问题,但 auto r = 3.0 + 4.0; 在某些上下文中也可能意外匹配到这个 operator+(当候选集中出现多个可行函数时),产生不直观的结果。

加上 explicit 后,这种隐式转换路径被切断:

struct Meter {
    explicit Meter(double v) : value(v) {}  // 必须显式构造
    double value;
};

m + 2.0 不再编译,调用者必须写 m + Meter{2.0},意图清晰。

C++ Core Guidelines C.46 建议:单参数构造函数默认应标 explicit,除非你有意允许隐式转换。这条规则背后的逻辑是——隐式转换一旦参与重载决议,可能让编译器选中你没预期到的函数,而且错误信息往往指向调用点而不是转换点,定位困难。

历史与设计原因:C++11 解决了 safe-bool 问题

C++11 之前,库作者为了避免 operator bool 隐式转整数,会使用复杂的 safe-bool idiom:把对象转换成某种成员指针类型(通常是指向私有成员函数的指针),让它能进 if,但不能自然参与算术。这种技巧的典型实现需要 15-20 行 boilerplate 代码,可读性极差,而且不同库的实现方式各不相同——有的转成 void*(仍有隐式转换问题),有的转成成员数据指针(正确但晦涩),有的用 operator! 间接实现(不支持 &&|| 短路语义)。

C++11 引入 explicit operator bool() 一步到位地解决了这个问题。语言标准定义了"条件上下文"(contextual conversion)这个特殊规则:在 ifwhilefor!&&||?: 这些条件位置上,即使转换运算符是 explicit 的,编译器也允许调用它。在其他位置(如算术运算、赋值、函数参数传递),explicit 转换不被考虑。

这意味着 explicit operator bool() 精确地表达了"这个对象可以判断真假,但不是一个数字"这个语义:

  • 允许出现在条件上下文:if (x)while (x)!xx && y
  • 不允许普通隐式转换到 intdouble 等算术类型。
  • 不允许参与重载决议中的隐式转换匹配。

这是 C++ 演进中"用语言特性替代设计模式"的经典案例——safe-bool idiom 是一个模式,explicit operator bool 是语言对这个模式的直接支持。类似的演进还包括 移动语义与完美转发 的移动语义(替代了 swap 技巧)和 Concepts与Policy 的 Concepts(替代了 SFINAE 技巧)。

正确写法:显式、const、noexcept

class SensorHandle {
public:
    SensorHandle() = default;
    explicit SensorHandle(int fd) : fd_(fd) {}

    explicit operator bool() const noexcept {
        return fd_ >= 0;
    }

    int fd() const noexcept {
        return fd_;
    }

private:
    int fd_ = -1;
};

使用方式:

SensorHandle lidar{3};

if (lidar) {
    // 句柄有效
}

// int value = lidar + 1;  // 编译错误:不能隐式转成 int

结果对象示例:比返回错误码更清楚

#include <string>
#include <utility>

class TrackingResult {
public:
    static TrackingResult ok(double score) {
        return TrackingResult(true, score, "");
    }

    static TrackingResult lost(std::string reason) {
        return TrackingResult(false, 0.0, std::move(reason));
    }

    explicit operator bool() const noexcept {
        return ok_;
    }

    double score() const noexcept {
        return score_;
    }

    const std::string& reason() const noexcept {
        return reason_;
    }

private:
    TrackingResult(bool ok, double score, std::string reason)
        : ok_(ok), score_(score), reason_(std::move(reason)) {}

    bool ok_ = false;
    double score_ = 0.0;
    std::string reason_;
};

调用处:

TrackingResult result = TrackingResult::ok(0.92);

if (result) {
    useScore(result.score());
} else {
    logLost(result.reason());
}

什么时候不用 operator bool

不是所有二值状态都适合 operator bool。如果对象有多个状态,用具名查询更清楚:

类型状态 推荐方式
有效/无效 explicit operator bool() 可接受
成功/失败并带错误信息 operator bool() + error() 可接受
Initializing / Tracking / Lost / Relocalizing state() == TrackingState::Lost
质量分数高低 score() > threshold
是否为空容器 empty() 通常比 bool 更明确

operator bool 的本质是“这个对象能否被当作可用资源或成功结果”。如果问题不是这个,就用具名函数。

⚠️ 常见陷阱

⚠️ 编程陷阱:忘记 explicit

错误做法operator bool() const

现象:对象可能隐式转成 int,参与 +== 1 等无意义表达式。

根本原因:非 explicit 转换运算符会参与普通隐式转换序列。

正确做法:写 explicit operator bool() const noexcept

💡 概念误区:有了 operator bool 就要重载 operator!

新手想法:“支持 if (x) 后,还要写 if (!x)。”

实际上explicit operator bool() 已经支持布尔上下文,!x 会使用它。

正确做法:除非 ! 有特殊且清楚的语义,否则不必重载。

🧠 思维陷阱:用 bool 表达复杂状态机

SLAM 跟踪状态不是只有成功/失败。把 RelocalizingPausedLost 都压缩成 false,会让调用处失去决策信息。复杂状态应使用枚举或结果类型。

练习

  1. 实现题:为一个 FileDescriptor 类实现 explicit operator bool(),并禁止拷贝、允许移动。结合 移动语义与完美转发 思考 moved-from 后的布尔状态。
  2. 分析题:为什么 std::unique_ptr 支持 if (ptr),但不允许 int x = ptr + 1
  3. 设计题OptimizationSummaryconvergednum_iterationsfinal_costtermination_type。它适合提供 operator bool 吗?如果适合,bool 应代表哪个条件?

9.8 高风险运算符:逻辑、逗号、取地址与其他少用重载 ⭐⭐

这一节解决的问题是:哪些运算符虽然能重载,但在工程代码中应极度克制。

动机:有些符号的内置语义太强

+*[]() 的重载很常见,因为它们和数学/访问/调用关系清楚。但下面这些运算符风险更高:

运算符 内置语义 重载风险
&&|| 短路逻辑 重载后不保留短路
, 从左到右求值并返回右值 重载后读者很难判断副作用
& 取对象地址 重载后 &obj 不一定是真地址
-> 指针式成员访问 代理链复杂,错误信息难读
++-- 递增递减 前置/后置返回类型容易写错

逻辑运算符:重载后不再短路

内置 && 会短路:

if (ptr != nullptr && ptr->ready()) {
    use(ptr);
}

ptr == nullptr 时,右侧 ptr->ready() 不会执行。

但重载的 operator&& 是一次函数调用。为了调用函数,左右操作数都需要先形成实参,因此右侧表达式不会因为左侧为 false 而跳过。

#include <iostream>

struct Flag {
    bool value = false;
};

Flag operator&&(Flag a, Flag b) {
    return {a.value && b.value};
}

Flag makeRight() {
    std::cout << "right evaluated\n";
    return {true};
}

int main() {
    Flag left{false};
    Flag result = left && makeRight();  // makeRight 仍会执行
}

这就是为什么自定义类型几乎不应重载 &&||。如果你需要组合条件,优先提供具名函数:

bool allReady(const SensorState& a, const SensorState& b);
bool anyFailure(const SensorState& a, const SensorState& b);

逗号运算符:合法但极难读

Eigen 的逗号初始化很著名:

// Eigen 风格示意
// matrix << 1, 2, 3,
//           4, 5, 6;

它背后利用了 operator<< 返回一个逗号初始化器对象,再由这个对象重载 operator, 收集后续元素。这是一个为矩阵字面量服务的领域特定接口,设计成本很高,也很容易误用。

普通工程类型不建议重载逗号。读者看到 a, b 时,会默认理解为 C++ 内置逗号语义,而不是某个对象内部的状态机。

取地址运算符:会破坏最基础的调试直觉

如果重载 operator&

struct Strange {
    Strange* operator&() {
        return nullptr;
    }
};

那么:

Strange s;
auto p = &s;  // p == nullptr

这会破坏调试、泛型代码和内存工具的直觉。标准库提供 std::addressof(s) 来绕过重载并取得真实地址,但这恰恰说明 operator& 是危险的。

绝大多数类型都不应重载取地址运算符。智能指针也不重载 & 来取内部裸指针,而是提供 .get()

自增自减:迭代器可以,其他类型要谨慎

前置和后置自增通过参数区分:

class Index {
public:
    explicit Index(int value) : value_(value) {}

    Index& operator++() {
        ++value_;
        return *this;
    }

    Index operator++(int) {
        Index old = *this;
        ++(*this);
        return old;
    }

private:
    int value_ = 0;
};
形式 签名 返回 语义
前置 ++x operator++() T& 先增加,返回自身
后置 x++ operator++(int) T 先保存旧值,再增加,返回旧值

迭代器适合重载 ++,因为“移动到下一个元素”是强约定。时间戳、位姿、地图节点通常不适合,除非递增单位非常明确。

风险分级表

运算符 建议 常见合理用途
+ - * / += 可用 数值类型、几何类型
[] () << == <=> 可用 容器、函数对象、输出、比较
-> 谨慎 智能指针、迭代器、代理对象
++ -- 谨慎 迭代器、明确离散索引
&& || 尽量避免 极少数表达式模板系统
, 尽量避免 矩阵初始化 DSL
& 基本避免 极少数底层代理

⚠️ 常见陷阱

⚠️ 编程陷阱:重载 operator&& 后仍期待短路

错误做法:用 a && expensiveCheck() 表达“如果 a 成立再执行昂贵检查”。

现象expensiveCheck() 仍会执行,可能触发空指针访问或性能问题。

根本原因:重载逻辑运算符是函数调用,不保留内置逻辑运算符的短路语义。

正确做法:使用显式 if 或具名函数,让控制流保持清楚。

💡 概念误区:Eigen 能重载逗号,所以普通类型也可以

实际上:Eigen 的逗号初始化服务于矩阵字面量,并配合尺寸检查、表达式模板和文档约定。普通业务类型照搬这个技巧,通常只会让代码更难懂。

正确做法:除非在设计成熟的领域特定表达式接口,否则避免重载逗号。

🧠 思维陷阱:为了统一风格隐藏基本操作

&obj 在 C++ 中太基础。让它返回非地址对象,会让调试器、日志、泛型工具的心智模型全部失效。真正需要暴露内部指针时,用 .get().data().raw() 这样的具名函数。

练习

  1. 预测题:写一个重载 operator&& 的小例子,验证右操作数在左操作数为 false 时仍会被求值。
  2. 分析题:为什么智能指针使用 ptr.get() 获取裸指针,而不是重载 operator&
  3. 设计题:一个 VoxelIterator 是否适合重载前置和后置 ++?分别应该返回什么?

9.9 Eigen 表达式模板直觉:运算符不一定马上计算 ⭐⭐⭐

这一节解决的问题是:为什么 Eigen 的 A + B + C 看起来像普通加法,底层却不是每一步都创建临时矩阵。

动机:矩阵表达式如果按朴素方式计算,会产生大量临时对象

考虑:

Eigen::MatrixXd C = A + B + D;

朴素实现可能是:

  1. 计算 tmp = A + B,分配一块矩阵内存。
  2. 计算 C = tmp + D,再次遍历。
  3. 销毁 tmp

对于小矩阵问题不大;对于 SLAM 后端的大型雅可比、Hessian 或点云批量变换,多余临时对象会带来内存分配和缓存压力。

如果每个运算符都立即返回矩阵会怎样

假设 operator+ 总是返回完整矩阵:

Matrix operator+(const Matrix& a, const Matrix& b) {
    Matrix result(a.rows(), a.cols());
    for (int i = 0; i < result.size(); ++i) {
        result[i] = a[i] + b[i];
    }
    return result;
}

表达式 A + B + D 至少需要一个中间 Matrix。更长的表达式会产生更多中间对象:

表达式 朴素临时对象数量
A + B 0 或 1
A + B + C 至少 1
A + B + C + D 至少 2
A + B * C + D 乘法和加法都可能产生临时

表达式模板的目标是让代码看起来像数学表达式,但把实际计算延迟到最终赋值时融合成更少的循环。

历史与设计原因:零开销抽象需要”表达式对象”

表达式模板最早由 Todd Veldhuizen 在 1995 年的论文中系统阐述,后来被 Blitz++ 数值库采用,随后成为 Eigen、Boost.uBLAS 和众多线性代数库的核心技术。它的发明解决了一个 C++ 数值计算社区长期面临的矛盾:运算符重载让代码可读性接近数学公式,但朴素实现会引入临时对象,性能不如手写循环

表达式模板的理论基础可以从两个角度理解。第一个角度是**惰性求值**(lazy evaluation):运算符不立即产生结果值,而是构建一个”计算描述”,推迟到真正需要结果时才执行。这和函数式编程中的惰性求值思想一致——Haskell 中的列表操作也是构建”计算图”而不是立即计算。不同的是,C++ 表达式模板在编译期完成所有”计算图”的构造和展开,运行时没有额外的间接调用开销。

第二个角度是**消除临时对象**(temporary elimination)。朴素的 A + B + C 产生两次遍历和一个临时矩阵。表达式模板把它变成一次遍历、逐元素计算 A[i] + B[i] + C[i]。这和编译器优化中的”循环融合”(loop fusion)效果相同,但不是由编译器的通用优化器完成(通用优化器通常无法跨函数边界融合循环),而是由库的类型系统和模板元编程在编译期完成。

表达式模板是 C++ 模板元编程的经典技术,核心思想是:运算符不立刻计算数值,而是返回一个轻量对象,记录”要计算什么”。

A + Boperator+ 可能返回类似:

SumExpr<Matrix, Matrix>{A, B}

(A + B) + D,返回:

SumExpr<SumExpr<Matrix, Matrix>, Matrix>{
    SumExpr<Matrix, Matrix>{A, B},
    D
}

最终赋值给具体矩阵时,再按元素求值:

for (int i = 0; i < C.size(); ++i) {
    C[i] = A[i] + B[i] + D[i];
}

这就把多次遍历和临时矩阵变成一次遍历。

极简表达式模板示意

下面不是 Eigen 源码,只是用最小模型展示直觉:

#include <array>
#include <cstddef>

template <typename Lhs, typename Rhs>
class SumExpr {
public:
    SumExpr(const Lhs& lhs, const Rhs& rhs)
        : lhs_(lhs), rhs_(rhs) {}

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

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

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

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

    template <typename Expr>
    Vec4& operator=(const Expr& expr) {
        for (std::size_t i = 0; i < 4; ++i) {
            data_[i] = expr[i];
        }
        return *this;
    }

private:
    std::array<double, 4> data_{};
};

template <typename Lhs, typename Rhs>
auto operator+(const Lhs& lhs, const Rhs& rhs)
    -> decltype(lhs[0], rhs[0], SumExpr<Lhs, Rhs>(lhs, rhs)) {
    return SumExpr<Lhs, Rhs>(lhs, rhs);
}

真实工程中还应把这类运算符放进库自己的命名空间,避免全局无约束 operator+ 参与所有用户类型的重载决议。上面的返回类型用 lhs[0], rhs[0] 做了最小约束,只让“像表达式一样可按下标读取”的类型进入候选集。

当写:

Vec4 a;
Vec4 b;
Vec4 c;
Vec4 out;

out = a + b + c;

a + b + c 构造的是嵌套表达式对象,out.operator=(expr) 才真正循环计算。这就是 Eigen 表达式模板的直觉。

auto 捕获 Eigen 表达式的陷阱

表达式对象常常持有对原矩阵的引用。因此下面的写法很危险:

Eigen::Matrix3d A = Eigen::Matrix3d::Identity();
Eigen::Matrix3d B = Eigen::Matrix3d::Ones();

auto expr = A + B;  // expr 可能是表达式对象,不是 Matrix3d 结果
A.setZero();

Eigen::Matrix3d C = expr;  // 这里才求值,使用的是修改后的 A

正确写法是显式求值:

Eigen::Matrix3d value = A + B;
// 或
auto value = (A + B).eval();

这和 Eigen基础与SLAM数学预备 的 Eigen 基础直接衔接:auto 在普通 C++ 里常常减少冗余,但在 Eigen 表达式上可能保留了“尚未求值的计算计划”。

理论到工程:什么时候需要 .eval()

场景 建议
直接赋值给具体矩阵 MatrixXd C = A + B;
需要保存表达式结果且后续原变量会变 auto C = (A + B).eval();
函数返回局部表达式 返回具体矩阵或 .eval()
自赋值有别名风险 使用 Eigen 推荐的 .eval().noalias()
只在同一行消费表达式 通常不需要手动 .eval()

本质洞察:Eigen 的运算符重载不是为了“让矩阵能用 +”,而是为了把整个表达式变成一个可优化的计算图。operator+ 返回的不是答案本身,而是“如何得到答案”的描述。

⚠️ 常见陷阱

⚠️ 编程陷阱:用 auto 保存 Eigen 表达式

错误做法auto x = A * B;,然后修改 AB

现象:之后使用 x 得到的结果和保存时预期不同。

根本原因x 可能是延迟求值表达式,内部引用原矩阵。

正确做法:需要值就写具体类型,或使用 .eval()

💡 概念误区:表达式模板一定比临时变量快

实际上:Eigen 会根据别名、乘法代价和缓存模型决定是否引入临时。手动强行避免临时不一定更快。

正确做法:先写清楚的 Eigen 表达式;遇到性能热点时用基准测试和 Eigen 文档指导 .eval().noalias()

🧠 思维陷阱:把表达式对象当成普通值对象

表达式对象像“视图”或“计算配方”,常持有引用。它的生命周期安全依赖原始对象。跨函数返回、保存到容器、延迟执行时尤其要小心。

练习

  1. 实验题:构造 auto expr = A + B; A.setZero(); Matrix3d C = expr;,观察结果并用 .eval() 修复。
  2. 分析题:为什么 Matrix3d C = A + B + D; 可以避免中间矩阵,而 auto expr = A + B + D; 可能带来生命周期问题?
  3. 综合题:结合 移动语义与完美转发 的返回值优化,设计一个函数返回 Eigen::Matrix3d 结果。比较 return A + B;return (A + B).eval();auto expr = A + B; return expr; 的语义差异。

9.10 Sophus SE3 * point:让李群语义进入类型系统 ⭐⭐⭐

这一节解决的问题是:为什么 Sophus、manif 等李群库喜欢用 * 表示位姿复合和刚体变换作用,以及这种重载最容易错在哪里。

动机:位姿组合本来就是群运算

三维刚体变换 \(T \in SE(3)\) 通常写成:

\[ T = \begin{bmatrix} R & t \\ 0 & 1 \end{bmatrix} \]

它作用于点:

\[ p_w = R_{wb}p_b + t_{wb} \]

两个位姿复合:

\[ T_{wc} = T_{wb}T_{bc} \]

代码如果写成:

Vec3 world_point = transform(world_body, body_point);
SE3 world_camera = compose(world_body, body_camera);

语义清楚,但和数学公式仍有距离。Sophus 等库选择让代码直接贴近群运算:

Vec3 world_point = T_world_body * point_body;
SE3 T_world_camera = T_world_body * T_body_camera;

如果只用 4x4 矩阵会怎样

直接使用 Eigen::Matrix4d 也能完成变换:

Eigen::Vector4d ph;
ph << p.x(), p.y(), p.z(), 1.0;
Eigen::Vector4d qh = T.matrix() * ph;
Eigen::Vector3d q = qh.head<3>();

问题是:

矩阵写法的问题 后果
点要手动补 1 容易把方向向量也当点平移
任何 4x4 矩阵都能乘 类型系统不保证它是合法 SE(3)
位姿复合和点变换都只是矩阵乘法 坐标系语义藏在变量名里
逆、对数、指数等操作没有群接口 后端优化代码更冗长

SE3 类型把“这是刚体变换”写进类型系统。operator* 再把群乘法和群作用写进表达式系统。

历史与设计原因:李群库追求“代数公式即代码”

Sophus、manif 这类库服务的是状态估计、Bundle Adjustment、视觉惯性里程计。它们的核心公式本来就写在李群上:

\[ T_{ab}T_{bc}=T_{ac},\quad T^{-1}p,\quad \exp(\xi^\wedge),\quad \log(T) \]

operator* 表示群乘法并不是任意选择,而是沿用线性代数和群论中最稳定的符号习惯。它的价值在于让推导、论文和代码之间的翻译误差变小。

简化实现:SE3 * SE3SE3 * point

#include <Eigen/Dense>
#include <utility>

class SE3Lite {
public:
    SE3Lite(Eigen::Matrix3d R, Eigen::Vector3d t)
        : R_(std::move(R)), t_(std::move(t)) {}

    SE3Lite operator*(const SE3Lite& rhs) const {
        return SE3Lite(R_ * rhs.R_, R_ * rhs.t_ + t_);
    }

    Eigen::Vector3d operator*(const Eigen::Vector3d& p) const {
        return R_ * p + t_;
    }

    SE3Lite inverse() const {
        Eigen::Matrix3d Rt = R_.transpose();
        return SE3Lite(Rt, -Rt * t_);
    }

private:
    Eigen::Matrix3d R_ = Eigen::Matrix3d::Identity();
    Eigen::Vector3d t_ = Eigen::Vector3d::Zero();
};

两个重载使用同一个符号,但返回类型不同:

表达式 含义 返回
T1 * T2 位姿复合 SE3Lite
T * p 位姿作用于点 Eigen::Vector3d

这种重载是合理的,因为两个含义都来自同一个数学对象:群乘法与群作用。

坐标系顺序:T_ab * T_bc 不是可交换的

位姿乘法不是交换的:

\[ T_{ab}T_{bc} \neq T_{bc}T_{ab} \]

变量命名应帮助检查顺序:

SE3Lite T_world_body = T_world_imu * T_imu_body;
Eigen::Vector3d p_world = T_world_body * p_body;

下标可以这样读:

T_world_imu * T_imu_body = T_world_body
       imu      imu
       中间坐标系相消

如果写成:

SE3Lite wrong = T_imu_body * T_world_imu;

类型系统不一定能报错,因为两者都是 SE3Lite。运算符让公式更短,但不会自动理解你的坐标系命名。坐标系命名规则仍然是工程纪律。

点和方向向量的区别

刚体变换作用于点时包含平移:

\[ p' = Rp + t \]

作用于方向向量时不应包含平移:

\[ v' = Rv \]

如果所有三维量都用 Eigen::Vector3dT * v 无法知道 v 是点还是方向。工程上有三种解决方式:

方案 示例 优缺点
变量名区分 point_bodydir_body 简单,但靠纪律
具名函数 T.transformPoint(p)T.rotate(v) 清楚,稍冗长
强类型 Point3Direction3 类型安全,代码量更大

很多库提供 T * pointT.so3() * direction 两种写法。写 SLAM 代码时要明确当前量是不是会被平移。

⚠️ 常见陷阱

⚠️ 编程陷阱:位姿复合顺序写反

错误做法T_body_world * T_body_camera,只因为两个变量都“看起来是位姿”。

现象:轨迹方向、点云投影或重投影误差完全错误,但数值仍是有限值,不一定马上崩溃。

根本原因:SE(3) 乘法不交换,坐标系中间项必须对齐。

正确做法:采用 T_target_source 命名,并在乘法中检查相邻下标是否相消。

💡 概念误区:SE3 * Vector3d 对点和方向都一样

实际上:点受平移影响,方向向量不受平移影响。

正确做法:点用 T * p;方向通常用 T.so3() * v 或专门的 rotate()

🧠 思维陷阱:以为运算符能替代坐标系约束

operator* 让公式更短,但类型仍可能只是 SE3dVector3d。如果项目对坐标系安全要求很高,应考虑强类型坐标系标签,而不是只依赖命名习惯。

练习

  1. 推导题:从齐次矩阵乘法推导 T1 * T2 的平移部分为什么是 R1 * t2 + t1
  2. 实现题:给 SE3Lite 增加 rotate(const Vector3d&),只应用旋转不应用平移。
  3. 调试题:给定 T_world_lidarT_body_lidar,推导 T_world_body 应如何计算。写出下标相消过程。

9.11 Ceres Jet:用运算符重载传播导数 ⭐⭐⭐

这一节解决的问题是:为什么 Ceres 能让同一份代价函数代码既计算残差,又自动得到雅可比。

动机:手推雅可比容易错,数值差分又不够稳

非线性最小二乘中,优化器需要残差:

\[ r = f(x) \]

也需要雅可比:

\[ J = \frac{\partial f}{\partial x} \]

手写雅可比的问题:

方法 优点 问题
手推解析导数 快、准确 容易写错,维护成本高
数值差分 接口简单 步长敏感,速度慢,精度受舍入影响
自动微分 代码接近原函数,导数准确 需要模板化和运算符重载支持

Ceres 的前向自动微分使用 Jet<T, N> 类型。它把一个变量表示为:

\[ x = a + \sum_i v_i \epsilon_i \]

其中:

  • a 是数值。
  • v 是导数向量。
  • \(\epsilon_i\) 是满足 \(\epsilon_i^2 = 0\) 的无穷小符号。

如果没有运算符重载会怎样

没有运算符重载,自动微分代码可能要写成:

Jet z = add(mul(x, y), sin(theta));

一旦公式复杂,代码和数学表达式会迅速分离。

有了运算符重载,可以保留原始公式:

Jet z = x * y + sin(theta);

同一个表达式同时计算值和导数。调用者不需要把链式法则散落在业务代码里。

历史与设计原因:dual number 把链式法则塞进算术

对一个单变量 dual number:

\[ x = a + b\epsilon,\quad \epsilon^2 = 0 \]

平方得到:

\[ x^2 = (a + b\epsilon)^2 = a^2 + 2ab\epsilon + b^2\epsilon^2 = a^2 + 2ab\epsilon \]

因为 \(\epsilon^2 = 0\),一阶导数自然保留下来。更一般地:

\[ f(a + b\epsilon) = f(a) + f'(a)b\epsilon \]

这就是链式法则的代数编码。Jet 把这个想法扩展到多方向导数:v 不是一个数,而是长度为 N 的导数向量。

最小 Jet 实现:加法、乘法和指数

#include <array>
#include <cmath>
#include <cstddef>

template <std::size_t N>
struct Jet {
    double a = 0.0;
    std::array<double, N> v{};
};

template <std::size_t N>
Jet<N> makeVariable(double value, std::size_t index) {
    Jet<N> x;
    x.a = value;
    x.v[index] = 1.0;
    return x;
}

template <std::size_t N>
Jet<N> makeConstant(double value) {
    Jet<N> x;
    x.a = value;
    return x;
}

template <std::size_t N>
Jet<N> operator+(const Jet<N>& lhs, const Jet<N>& rhs) {
    Jet<N> out;
    out.a = lhs.a + rhs.a;
    for (std::size_t i = 0; i < N; ++i) {
        out.v[i] = lhs.v[i] + rhs.v[i];
    }
    return out;
}

template <std::size_t N>
Jet<N> operator-(const Jet<N>& lhs, const Jet<N>& rhs) {
    Jet<N> out;
    out.a = lhs.a - rhs.a;
    for (std::size_t i = 0; i < N; ++i) {
        out.v[i] = lhs.v[i] - rhs.v[i];
    }
    return out;
}

template <std::size_t N>
Jet<N> operator*(const Jet<N>& lhs, const Jet<N>& rhs) {
    Jet<N> out;
    out.a = lhs.a * rhs.a;
    for (std::size_t i = 0; i < N; ++i) {
        out.v[i] = lhs.a * rhs.v[i] + rhs.a * lhs.v[i];
    }
    return out;
}

template <std::size_t N>
Jet<N> exp(const Jet<N>& x) {
    Jet<N> out;
    out.a = std::exp(x.a);
    for (std::size_t i = 0; i < N; ++i) {
        out.v[i] = out.a * x.v[i];
    }
    return out;
}

乘法的导数规则来自:

\[ \frac{d}{dx}(fg)=f'g+fg' \]

代码中的:

out.v[i] = lhs.a * rhs.v[i] + rhs.a * lhs.v[i];

正是这一条规则的逐方向版本。

验证:f(x, y) = exp(x * y)

\((x, y) = (1, 2)\) 处:

\[ f = e^{xy} = e^2 \]
\[ \frac{\partial f}{\partial x} = y e^{xy} = 2e^2 \]
\[ \frac{\partial f}{\partial y} = x e^{xy} = e^2 \]

代码:

#include <iostream>

int main() {
    auto x = makeVariable<2>(1.0, 0);
    auto y = makeVariable<2>(2.0, 1);

    Jet<2> f = exp(x * y);

    std::cout << "value = " << f.a << "\n";
    std::cout << "df/dx = " << f.v[0] << "\n";
    std::cout << "df/dy = " << f.v[1] << "\n";
}

典型结果:

value = 7.38906
df/dx = 14.7781
df/dy = 7.38906

这个例子展示了 Ceres 自动微分的核心机制:你写的是值计算表达式,Jet 的运算符重载把导数传播藏在每个算术操作里。

Ceres 代价函数为什么要模板化

典型 Ceres 自动微分代价函数写成:

struct SimpleCost {
    template <typename T>
    bool operator()(const T* const xy, T* residuals) const {
        const T x = xy[0];
        const T y = xy[1];
        residuals[0] = exp(x * y) - T(3.0);
        return true;
    }
};

T = double 时,代码计算普通残差。 当 T = ceres::Jet<double, N> 时,同一段代码计算残差和导数。

这也是为什么本章同时讲 operator() 和算术运算符:Ceres 把两者组合起来了。

机制 在 Ceres 中的作用
operator() 让代价函数对象被优化器统一调用
算术运算符重载 Jet 像数值一样参与公式
数学函数重载 sincosexp 传播导数
模板参数 T doubleJet 之间切换

分支与比较:自动微分不是万能通行证

如果代价函数里有分支:

template <typename T>
bool operator()(const T* const x, T* residuals) const {
    if (x[0] > T(0.0)) {
        residuals[0] = x[0] * x[0];
    } else {
        residuals[0] = -x[0];
    }
    return true;
}

这段代码在数学上是分段函数。它不是不能自动微分,而是导数在分界点可能不连续。优化器看到的导数取决于当前路径。对于鲁棒核、绝对值、饱和函数,这类问题很常见。

工程上要区分:

情况 建议
平滑函数 直接用自动微分
分段但连续可接受 确认分界处行为
不可导或跳变 换平滑近似或显式处理
条件只控制数据有效性 在进入优化前过滤数据

⚠️ 常见陷阱

⚠️ 编程陷阱:代价函数里调用只接受 double 的函数

错误做法:模板代价函数中调用 double foo(double)

现象:当 T 被替换为 Jet 时编译失败,或导数在函数边界被截断。

根本原因:自动微分要求整条计算链都能接受 T

正确做法:函数也写成模板,或使用支持 Jet 的数学函数。

💡 概念误区:自动微分等于符号求导

实际上:Ceres Jet 是前向自动微分。它在执行原函数的同时传播导数,不生成可读的解析公式,也不是有限差分。

正确理解:自动微分的结果通常和解析导数一样精确到浮点舍入,但计算方式是程序执行层面的链式法则。

🧠 思维陷阱:有了自动微分就不用关心函数光滑性

自动微分能给出当前执行路径上的导数,但不能把不可导问题变成光滑问题。优化问题的建模质量仍然决定收敛行为。

练习

  1. 实现题:给最小 Jet 增加 operator/,推导并实现除法导数规则。
  2. 验证题:用 Jet<2> 计算 f(x, y) = x*x + sin(y)(3, pi/2) 的值和偏导数。
  3. 综合题:把 9.6 的 ReprojectionCost 改写成能同时接受 doubleJet 的模板函数对象。思考哪些函数调用必须模板化。

9.12 设计流程:从“能重载”到“应该重载” ⭐⭐

这一节把前面所有规则合并成一个工程决策流程。面对一个新类型时,不要先问“怎么写运算符”,而要先问“这个符号是否能稳定表达领域语义”。

决策流程

要不要重载某个运算符?
  |
  +-- 这个操作是否有广泛稳定的数学或 C++ 约定?
  |     |
  |     +-- 否 -> 使用具名函数
  |     |
  |     +-- 是
  |
  +-- 读者能否从符号预测副作用和复杂度?
  |     |
  |     +-- 否 -> 使用具名函数
  |     |
  |     +-- 是
  |
  +-- 是否会破坏内置运算符的重要语义?
  |     |
  |     +-- 会,例如 && 短路 -> 避免重载
  |     |
  |     +-- 不会
  |
  +-- 左操作数是否必须是当前对象?
        |
        +-- 是 -> 成员函数
        |
        +-- 否 -> 非成员函数,必要时使用受控 friend

设计检查表

检查项 自问问题
语义 这个符号在数学或 C++ 中是否已有稳定含义?
副作用 调用者会不会误以为它无副作用?
复杂度 它是否隐藏了巨大分配、I/O 或优化求解?
对称性 左右操作数是否都应允许隐式转换?
const 只读操作是否声明为 const
返回类型 复合赋值是否返回 T&?算术是否按值返回?
异常 能否 noexcept?若不能,调用者是否能预期?
生命周期 是否返回引用到临时对象?
泛型 是否能和 STL、Eigen、Ceres 等泛型代码自然配合?

综合示例:一个小型 Angle 强类型

角度是适合运算符重载的类型,因为它是数值,但又不应和裸 double 完全混用。

#include <cmath>
#include <compare>
#include <ostream>

class Radian {
public:
    explicit Radian(double value) : value_(value) {}

    double value() const noexcept {
        return value_;
    }

    Radian& operator+=(Radian rhs) noexcept {
        value_ += rhs.value_;
        return *this;
    }

    Radian& operator-=(Radian rhs) noexcept {
        value_ -= rhs.value_;
        return *this;
    }

    Radian& operator*=(double s) noexcept {
        value_ *= s;
        return *this;
    }

    auto operator<=>(const Radian&) const = default;

private:
    double value_ = 0.0;
};

inline Radian operator+(Radian lhs, Radian rhs) noexcept {
    lhs += rhs;
    return lhs;
}

inline Radian operator-(Radian lhs, Radian rhs) noexcept {
    lhs -= rhs;
    return lhs;
}

inline Radian operator*(Radian angle, double s) noexcept {
    angle *= s;
    return angle;
}

inline Radian operator*(double s, Radian angle) noexcept {
    return angle * s;
}

inline std::ostream& operator<<(std::ostream& os, Radian angle) {
    return os << angle.value() << " rad";
}

注意构造函数是 explicit

Radian a{1.0};
Radian b{2.0};
Radian c = a + b;

// Radian bad = 1.0;  // 编译错误:避免裸 double 静默变成角度

这比直接使用 double yaw 更安全,也比给裸 double 重载运算符更符合 C++ 边界。

与下一章的自然连接

本章最后要把注意力转向 operator()。函数对象是 Lambda 的前身,也是 STL 算法的定制入口:

struct ByRange {
    bool operator()(const Vec3& a, const Vec3& b) const {
        return norm(a) < norm(b);
    }
};

std::sort(points.begin(), points.end(), ByRange{});

下一章会把这个对象改写成 Lambda:

std::sort(points.begin(), points.end(),
          [](const Vec3& a, const Vec3& b) {
              return norm(a) < norm(b);
          });

两者的本质都是“一个能被调用的对象”。理解了 operator(),Lambda、STL 算法、ROS2 回调和 Ceres 代价函数就会连成一条线。

⚠️ 常见陷阱

⚠️ 编程陷阱:返回局部变量引用

错误做法const Vec3& operator+(const Vec3& a, const Vec3& b) 内部创建局部 result 并返回引用。

现象:函数返回后引用悬空,后续读取是未定义行为。

根本原因:局部对象生命周期在函数返回时结束。

正确做法:算术运算按值返回,让编译器做返回值优化。

💡 概念误区:重载越全,类型越好用

实际上:一个类型暴露的运算符越多,读者需要记住的语义越多。没有稳定含义的运算符会增加学习成本。

正确做法:先覆盖最核心、最无歧义的运算;其余操作用具名函数。

🧠 思维陷阱:把运算符当成性能优化手段

运算符本身不让代码更快。性能来自具体实现:避免临时对象、利用表达式模板、保持移动语义、减少分配。符号只是接口入口。

练习

  1. 实现题:给 Radian 增加 normalized() 函数,把角度归一化到 \([-\pi, \pi)\)。说明为什么它不适合写成某个运算符。
  2. 分析题std::sort(points.begin(), points.end(), ByRange{}) 中,ByRange{} 何时被调用?它和 Lambda 有什么关系?
  3. 跨章综合题:设计一个 ScopedTimer(RAII与智能指针 RAII)、禁止拷贝允许移动(移动语义与完美转发)、支持 if (timer) 判断是否仍在计时(本章 operator bool),并用 operator<< 输出耗时摘要。

本章小结

知识点 核心理解 常见误区
运算符边界 至少一个操作数必须是自定义类型;不能改变内置类型之间的运算 以为能重载 int + int
成员 vs 非成员 成员固定左操作数;对称二元运算和流输出通常写非成员 所有运算符都写进类里
复合赋值 += 修改自身并返回 T&+ 可基于 += 实现 operator+= 返回值
流输出 operator<< 是非成员,返回 ostream&,不自动换行 输出函数负责完整日志策略
比较运算 精确相等、严格排序、近似比较是三种语义 用 epsilon 实现全局 operator<
下标 operator[] 应有 const/non-const 版本 只写可变版本导致 const 对象不可读
函数调用 operator() 让对象成为函数对象,是 Lambda/STL/Ceres 的基础 认为 Lambda 是完全不同的机制
operator bool explicit operator bool() 表达可用性 忘记 explicit 导致对象参与算术
高风险运算符 &&|| 不保留短路;,& 容易破坏直觉 能重载就拿来做 DSL
Eigen 表达式模板 运算符返回表达式对象,赋值时才求值 auto 保存延迟表达式
Sophus SE3 * point * 表示群复合和群作用,贴近李群公式 位姿乘法顺序写反
Ceres Jet 运算符重载把链式法则嵌入算术 自动微分能解决不可导建模

一句话总结:好的运算符重载让自定义类型自然进入 C++ 表达式系统,同时保持内置符号的语义直觉;坏的运算符重载会隐藏副作用、破坏短路、混淆比较关系,让代码变得比普通函数更难懂。


累积项目:本章新增模块

项目:Mini-LIO 类型安全数学工具层

本章新增:math_types.hpp 运算符模块

在前面章节已经具备类设计、RAII 和移动语义基础后,本章为 Mini-LIO 增加一组轻量值类型和调试接口:

  1. Vec3 值类型
  2. 实现 +=-=*=+-、标量乘法。
  3. 提供 operator[] 的 const/non-const 版本。
  4. 提供 dot()cross()norm() 具名函数。

  5. Radian 强类型

  6. explicit Radian(double),禁止裸 double 静默变成角度。
  7. 支持角度加减和标量缩放。
  8. 输出格式为 1.57 rad

  9. SE3Lite 几何类型

  10. 实现 T1 * T2 位姿复合。
  11. 实现 T * point 点变换。
  12. 提供 rotate(direction) 区分方向向量。

  13. HuberLoss 函数对象

  14. operator() 计算鲁棒损失。
  15. 下一章改写为 Lambda 和 STL 算法版本,用于残差批处理。

  16. 最小 Jet<2> 实验

  17. 实现 +-*exp()
  18. 验证 f(x,y)=exp(x*y)(1,2) 的值和偏导数。

验收标准:

模块 检查方式
Vec3 0.5 * a + 0.5 * b 与手算一致
Radian Radian r = 1.0; 不能编译,Radian{1.0} 可以
SE3Lite T * T.inverse() 接近单位变换
HuberLoss 小残差走二次损失,大残差走线性损失
Jet<2> 输出 e^22e^2e^2

延伸阅读

资源 难度 说明
cppreference: Operators ⭐⭐ 可重载/不可重载运算符、成员与非成员规则、少用运算符风险
C++ Core Guidelines C.160-C.168 ⭐⭐ 运算符应模拟常规用法、对称运算用非成员函数等设计建议
Eigen 官方文档:Lazy Evaluation ⭐⭐⭐ Eigen 表达式模板、延迟求值、.eval() 和别名处理
Ceres Solver: Automatic Derivatives ⭐⭐⭐ Ceres 自动微分的使用方式和模板代价函数模式
Ceres jet.h 源码 ⭐⭐⭐⭐ Jet 类型及其算术/数学函数重载的真实实现
Sophus GitHub 仓库 ⭐⭐⭐ SE3SO3 等李群类型的运算符设计
Scott Meyers, Effective Modern C++, Item 7 与 Item 23 ⭐⭐ {} 初始化陷阱、std::move 与值类别,为运算符实现提供背景
Bjarne Stroustrup, The C++ Programming Language, Operator Overloading 章节 ⭐⭐⭐ 从语言设计角度理解运算符重载的边界

🔧 故障排查手册

症状 可能原因 排查步骤 相关章节
2.0 * v 编译失败,但 v * 2.0 正常 标量乘法只写成了成员函数 1. 检查 operator* 是否是成员;2. 增加非成员 operator*(double, Vec3);3. 确认两个方向都测试 运算符重载.2, 运算符重载.3
std::cout << pose << "\n" 编译失败 operator<< 返回 void,或写成了成员函数 1. 确认签名为 std::ostream& operator<<(std::ostream&, const Pose&);2. 函数末尾返回 os;3. 不在函数内固定输出到 std::cout 运算符重载.4
const Vec3& v 无法使用 v[0] 缺少 const 版本 operator[] 1. 添加 const T& operator[](size_t) const;2. 非 const 版本返回 T&;3. 为 const 对象写编译测试 运算符重载.6
if (handle) 可用,但 handle + 1 也能编译 转换运算符缺少 explicit 1. 把 operator bool() 改为 explicit operator bool() const noexcept;2. 检查是否存在 operator intoperator void*;3. 增加负向编译用例 运算符重载.7
a && expensiveCheck() 中右侧仍被执行 重载了 operator&&,失去短路语义 1. 搜索 operator&&operator||;2. 改成显式 if 或具名函数;3. 对右侧有副作用的表达式加测试 运算符重载.8
Eigen 表达式结果随原矩阵变化 auto 保存了延迟求值表达式 1. 把 auto expr = A + B 改成 MatrixXd expr = A + B;2. 或使用 (A + B).eval();3. 检查表达式对象是否跨越原矩阵生命周期 运算符重载.9, Eigen基础与SLAM数学预备
位姿变换结果方向完全不对 SE3 乘法顺序或坐标系命名错误 1. 使用 T_target_source 命名;2. 检查相邻下标是否相消;3. 用单位变换和已知平移做最小测试 运算符重载.10
Ceres 自动微分代价函数编译失败 模板函数内部调用了只接受 double 的函数 1. 检查代价函数中所有中间函数是否模板化;2. 使用支持 Jet 的数学函数;3. 避免把 T 强制转成 double 运算符重载.11
排序结果不稳定或 std::set 查找异常 比较器不满足严格弱序,常见于 epsilon 比较 1. 检查 operator< 或比较 Lambda 是否传递;2. 不用 epsilon 实现排序关系;3. 近似比较改为 isApprox() 运算符重载.5