错误处理与异常安全¶
难度:⭐~⭐⭐⭐ | 建议用时:2周 | 前置要求:RAII与智能指针 RAII 与资源管理、移动语义与完美转发 移动语义与
noexcept
前置自测¶
📋 答不出 ≥ 2 题 → 先回顾 RAII与智能指针 和 移动语义与完美转发
- RAII 的核心保证是什么?异常发生时,
std::lock_guard为什么仍然会释放互斥锁? - 移动语义与完美转发 中
std::vector扩容时为什么更偏好noexcept移动构造?如果移动可能抛异常会发生什么? - 构造函数失败时,对象的析构函数会不会执行?已经构造完成的成员对象会怎样?
- 为什么析构函数不应该向外抛异常?什么情况下会直接调用
std::terminate()? - 在 SLAM 配置加载中,文件不存在、字段缺失、字段类型错误分别适合用异常、错误码还是
expected风格表达?
本章目标¶
学完本章,你将能够:
- 理解 C++ 异常的传播、匹配、栈展开和 RAII 释放资源的完整机制。
- 为自己的类和函数设计清晰的异常安全保证:基本保证、强保证、无异常保证。
- 在 SLAM、ROS2、OpenCV、文件 IO 和配置解析中选择合适的错误处理边界,避免让异常穿过 C ABI、线程入口、实时回调和不受控的库边界。
本章在课程中的位置:RAII与智能指针 已经建立了 RAII 的资源管理基础:资源绑定到对象生命周期,析构函数在离开作用域时释放资源。移动语义与完美转发 又说明了 noexcept 对移动语义和 std::vector 扩容选择的影响。本章把这两条线合在一起:异常不是孤立的语法特性,而是 C++ 用来把错误传播、资源释放和接口契约连接起来的语言机制。
知识树¶
本章覆盖的知识点形成以下结构。树的根是"C++ 如何表达和传播错误",树干是异常机制和 RAII 的配合,分支是不同的错误表达工具和工程边界。
C++ 错误处理
├── 错误分类与设计框架 ⭐⭐
│ ├── 可预期 vs 不可预期
│ ├── 可恢复 vs 不可恢复
│ └── 四种语言的哲学对比
├── 异常机制 ⭐⭐(核心主干)
│ ├── throw / catch / 栈展开 ⭐⭐
│ ├── 标准异常层级 ⭐⭐
│ ├── table-based unwinding 实现 ⭐⭐⭐
│ └── 异常对象生命周期 ⭐⭐⭐
├── 异常安全保证 ⭐⭐⭐
│ ├── 基本保证 / 强保证 / 无异常保证
│ ├── copy-and-swap 模式
│ └── vector::push_back 与 noexcept 的连接
├── 对象生命周期硬边界 ⭐⭐⭐
│ ├── 构造函数异常
│ ├── 析构函数异常
│ ├── noexcept 承诺与条件 noexcept
│ └── move_if_noexcept
├── 接口错误表达 ⭐⭐⭐
│ ├── 异常版 / 错误码版 / expected 风格
│ ├── optional 与 expected 的区别
│ └── C++23 expected 的 monadic 操作
└── 工程边界 ⭐⭐⭐
├── OpenCV / 文件 IO 边界
├── C ABI / 线程 / ROS2 回调边界
└── SLAM 启动-运行-退出三阶段策略
8.1 错误处理的真实问题:错误不是一种东西 ⭐⭐¶
动机:SLAM 系统为什么不能只写 if (!ok) return false;¶
一个 Mini-LIO 或视觉 SLAM 程序在启动和运行过程中会遇到很多失败:
| 场景 | 示例 | 错误性质 | 典型处理 |
|---|---|---|---|
| 配置错误 | YAML 中相机内参缺失 | 可预期、可诊断 | 报告具体字段并停止启动 |
| 文件错误 | vocabulary 文件路径错误 | 可预期、外部输入导致 | 返回错误或抛出异常 |
| 数据错误 | 一帧点云为空、图像解码失败 | 可恢复或可跳帧 | 记录日志,丢弃本帧 |
| 程序 bug | vector 越界、空指针解引用 |
不应恢复 | 修复代码,必要时终止 |
| 硬件异常 | LiDAR 断连、相机超时 | 可恢复但有状态影响 | 状态机降级或重连 |
| 资源耗尽 | 内存分配失败、文件描述符耗尽 | 可能不可恢复 | 释放资源、降级、终止 |
这些错误看起来都叫“失败”,但它们的处理策略完全不同。把所有错误都压成一个 bool,就像把所有传感器数据都压成一个 double:类型简单了,语义却丢了。
更糟的是,机器人程序通常有多层边界:
配置文件 / 标定文件
│
▼
启动初始化层 ── 打开相机、加载地图、构造算法模块
│
▼
运行时回调层 ── ROS2 callback、线程循环、定时器
│
▼
实时敏感路径 ── 控制环、时间预算严格的感知管线
不同层的错误传播成本不同。启动阶段抛异常通常可以接受,因为程序还没有进入实时循环;控制环里抛异常则可能带来不可预测的延迟,还会让错误处理路径难以验证。
如果不分类会怎样¶
假设配置加载函数只返回 false:
调用者只能知道“失败了”,但不知道失败原因:
CameraConfig config;
if (!loadCameraConfig("camera.yaml", config)) {
std::cerr << "load config failed\n";
return 1;
}
这个接口隐藏了至少四类信息:
- 文件不存在。
- 文件存在但 YAML 格式损坏。
- 字段存在但类型错误。
- 字段合法但数值不满足约束,例如焦距为负数。
如果后续系统在现场部署时启动失败,日志里只有一句 “load config failed”,排查人员只能重新编译、加日志、再部署。错误处理不是为了让程序“显得健壮”,而是为了让失败发生时系统能给出足够的信息。
本质洞察:错误处理的本质不是“把失败藏起来”,而是**把失败的类型、位置、严重性和恢复策略显式化**。异常、错误码、
expected风格只是三种表达工具,真正的设计对象是错误边界。
历史与设计原因:C++ 为什么同时保留多种错误表达¶
C 语言时代主要依靠返回值和全局错误状态:
| 方式 | 示例 | 优点 | 局限 |
|---|---|---|---|
| 返回错误码 | int open(...) 返回 -1 |
ABI 简单,跨语言稳定 | 调用者容易忽略 |
| 全局错误状态 | errno |
不改变返回值类型 | 线程、组合和可读性问题 |
| 输出参数 | bool parse(T& out) |
避免异常 | 调用点冗长,错误信息有限 |
C++ 引入构造函数、析构函数、运算符重载和 RAII 后,单靠错误码出现了一个根本问题:构造函数没有返回值。如果 std::ifstream、std::vector、自定义 Camera 对象在构造阶段失败,错误码无法自然返回。异常正是为了解决这种“正常返回通道不可用”的失败传播问题。
但 C++ 没有因此抛弃错误码。原因很现实:
- C ABI、操作系统 API、硬件驱动仍大量使用错误码。
- 实时和嵌入式系统常常禁用异常。
- 有些失败是正常业务分支,使用异常会让控制流显得过重。
- 跨线程、跨回调、跨语言边界时,异常传播风险很高。
因此现代 C++ 的工程实践不是”异常 vs 错误码二选一”,而是在不同边界使用不同表达。
四种语言的错误处理哲学对比¶
C++、C、Go 和 Rust 在错误处理上做了四种截然不同的设计选择,每种选择背后都有清晰的工程哲学。理解这些选择之间的权衡,能帮助判断在不同场景下哪种策略更合适。
C 的选择:返回值 + errno。C 没有异常、没有泛型、没有析构函数。唯一的错误通道是返回值。open() 返回 -1 表示失败,malloc() 返回 NULL 表示分配失败。这个方案的优势是极致简单和零运行时开销——函数调用和返回的成本完全可预测,适合操作系统内核和嵌入式场景。代价是调用者可以直接忽略返回值而不产生任何编译期警告(C23 之前),错误信息只能通过 errno 全局变量携带一个整数码。
Go 的选择:多返回值 (result, error)。Go 没有异常,也不打算加入异常。错误通过函数的第二个返回值显式传递。f, err := os.Open(“file.txt”) 要求调用者必须处理 err。Go 的哲学是”错误是正常控制流的一部分,不应该用特殊机制处理”。优势是错误路径始终可见、可预测,代码审查时一眼能看到哪些错误被处理了。代价是大量 if err != nil { return err } 样板代码,以及缺乏自动资源清理(Go 用 defer 部分弥补,但不如 RAII 精确)。
Rust 的选择:Result<T, E> + ? 运算符。Rust 的 Result 是代数数据类型,要么是 Ok(value),要么是 Err(error)。调用者必须用模式匹配或 ? 运算符处理错误——编译器不允许忽略 Result。? 运算符让错误传播几乎和异常一样简洁:let config = load_config()?; 如果失败会自动返回错误。Rust 的哲学是”把错误处理的正确性从运行时移到编译时”。优势是零运行时开销(没有栈展开机制)、编译器强制处理所有错误路径、错误类型是签名的一部分。代价是错误类型转换和 trait 实现需要额外工作。
C++ 的选择:多机制共存。C++ 同时拥有异常、错误码、std::optional、std::expected(C++23)。这不是设计混乱,而是 C++ 必须服务的场景过于广泛——从嵌入式微控制器到高性能服务器,从纯 C++ 代码到混合 C/Fortran/Python 调用。C++ 的哲学是”没有一种错误处理方式适合所有场景,语言应提供多种工具,由工程师根据边界选择”。优势是灵活性极强,可以在同一个项目的不同层级使用不同策略。代价是需要团队建立清晰的错误边界规范——否则同一个项目里三种风格混杂、错误处理策略不一致,反而比只有一种方式更难维护。
| 语言 | 核心机制 | 编译器是否强制处理 | 运行时开销 | 适用场景 |
|---|---|---|---|---|
| C | 返回值 + errno |
否 | 零 | 内核、嵌入式 |
| Go | (T, error) |
约定(可忽略 _) |
极低 | 网络服务、云基础设施 |
| Rust | Result<T, E> |
是 | 零 | 系统编程、安全敏感 |
| C++ | 异常 + 错误码 + expected |
取决于选择 | 异常有栈展开成本 | 全场景 |
C++23 的 std::expected<T, E> 实际上是向 Rust Result 靠拢的一步。它保留了 C++ 的零开销承诺(不使用异常时不付栈展开代价),同时通过类型系统让错误信息不可忽略(配合 [[nodiscard]])。可以预见,未来 C++ 的错误处理最佳实践会越来越多地采用 expected 风格,而把异常保留给构造函数失败和跨多层传播的罕见错误。
三种常见错误处理风格¶
| 风格 | 典型签名 | 适合场景 | 不适合场景 |
|---|---|---|---|
| 异常 | Config loadOrThrow(path) |
构造失败、初始化失败、违反前置条件的库调用 | 硬实时循环、C ABI、线程入口外抛 |
| 错误码 | ErrorCode load(path, Config& out) |
C 接口、底层驱动、性能敏感路径 | 需要携带复杂上下文的多层错误 |
expected 风格 |
expected<Config, Error> load(path) |
可预期失败、需要类型安全错误传播 | C++ 标准库支持不足的旧工具链 |
可以把三者类比为交通系统:
- 异常像高速公路的紧急出口:平时不走,出事时快速离开正常路径。
- 错误码像每个路口的红绿灯:每一步都显式检查,规则简单但容易被忽略。
expected风格像导航软件:正常路线和失败原因都在类型里,调用者必须看见。
这个类比也有边界:程序错误不像交通拥堵那样可以自然消散,未处理的错误会变成资源泄漏、数据损坏或进程终止。
工程分类框架¶
设计错误处理时,可以先回答四个问题:
| 问题 | 选项 | 对策略的影响 |
|---|---|---|
| 错误是否可预期? | 文件缺失可预期;空指针 bug 不可预期 | 可预期错误适合显式返回;bug 适合断言或终止 |
| 调用者能否恢复? | 可换路径重试;无法恢复 | 可恢复错误应携带细节;不可恢复错误应尽早暴露 |
| 是否跨边界? | C ABI、线程、ROS2 回调、插件 | 边界内捕获,转换为边界认可的错误形式 |
| 是否在实时敏感路径? | 控制环、硬件回调 | 避免异常传播,偏向状态码或预分配错误通道 |
决策流程可以写成:
错误发生点
│
├── 是否是程序员 bug?
│ ├── 是 → assert / 合约检查 / 终止,修复代码
│ └── 否
│
├── 是否跨 C ABI、线程入口、回调边界?
│ ├── 是 → 在边界内捕获,转换为错误码、日志或状态
│ └── 否
│
├── 是否位于实时敏感路径?
│ ├── 是 → 避免异常传播,使用错误码或 expected 风格
│ └── 否
│
└── 错误是否罕见且无法在当前层处理?
├── 是 → 异常
└── 否 → expected / optional / 错误码
⚠️ 常见陷阱¶
⚠️ 编程陷阱:返回
false但丢失错误上下文错误做法:
bool load(Config& out)失败时只返回false。现象:调用者无法区分“文件不存在”和“字段类型错误”,日志只能写成笼统失败。
根本原因:布尔值只能表达成功或失败,不能表达失败的结构。
正确做法:至少返回枚举错误码;如果错误需要携带字段名、路径、底层异常信息,使用异常或
expected<Config, ConfigError>。💡 概念误区:把异常当成高级版
goto新手想法:“哪里懒得写返回值,哪里就
throw。”实际上:异常适合表达“当前层无法处理、需要越过多层调用栈的罕见失败”。如果一个分支是正常业务结果,例如“这一帧没有检测到特征点”,用异常会让正常控制流变得隐蔽。
正确做法:把“失败是否罕见、当前层能否处理、是否跨边界”作为选择依据。
练习¶
- 分类题:把以下失败分成“异常 / 错误码 /
expected/ 断言”四类:配置文件不存在、Camera*为空、LiDAR 本帧点数为 0、std::vector下标越界、标定外参矩阵不是刚体变换。 - 接口设计题:为
loadVocabulary(path)设计三种接口:异常版、错误码版、expected风格版。比较调用点可读性。 - 跨章综合题:结合 RAII与智能指针 的 RAII 和 移动语义与完美转发 的移动语义,设计一个
MapDatabase类:构造时打开文件,移动时转移文件句柄,加载失败时给出可诊断错误。
8.2 C++ 异常机制:从 throw 到 catch ⭐⭐¶
异常是 C++ 中最强大也最容易被误用的错误处理工具。它的核心设计目标是解决一个根本问题:当错误发生在调用栈的深处,而恢复逻辑在调用栈的顶层时,如何让错误信息跨越中间所有不关心这个错误的函数层?用错误码的话,每一层都要手写 if (err) return err; 的转发代码;用异常的话,错误自动越过所有中间层,直到到达能处理它的 catch 块。
异常不是"更好的 goto"。它有精确的语言规则:throw 创建异常对象并启动传播,传播过程触发栈展开(逐层析构局部对象),catch 按类型匹配异常并恢复控制流。这个过程中,RAII 保证了所有已获取的资源被正确释放。理解异常的关键是把它看作"错误传播通道"——正常返回值是业务数据的通道,异常是错误信息的通道,两个通道互不干扰。
动机:构造函数没有返回值,错误如何离开?¶
考虑一个 CameraModel:
class CameraModel {
public:
explicit CameraModel(const std::string& yaml_path);
Eigen::Vector2d project(const Eigen::Vector3d& point) const;
private:
double fx_ = 0.0;
double fy_ = 0.0;
double cx_ = 0.0;
double cy_ = 0.0;
};
构造函数要读取配置文件、解析字段、检查数值范围。失败时它没有返回值可以写:
可以把对象拆成“两阶段初始化”:
但两阶段初始化让对象出现“半初始化状态”:默认构造后的 camera 是否可用?忘记调用 init() 会怎样?成员函数是否每次都要检查 initialized_?这些问题会污染整个类的设计。
异常提供了更直接的语义:构造成功,对象可用;构造失败,对象不存在。
两阶段初始化的工程代价¶
在讨论异常的替代方案之前,有必要详细分析"不用异常"的最常见做法——两阶段初始化——带来的真实工程代价。这种模式在嵌入式系统和禁用异常的项目中很常见,但它引入了一个根本性的设计缺陷:对象存在"已构造但未初始化"的中间状态。
两阶段初始化的代价不仅仅是"多写几行检查代码"。它改变了整个类的不变量体系。一个使用异常构造的 CameraModel,其不变量是"只要对象存在,内参就是合法的"——所有成员函数都可以安全地假设 fx_ > 0。一个使用两阶段初始化的 CameraModel,其不变量退化为"对象可能合法也可能不合法"——每个成员函数都要先检查 initialized_ 标志,或者接受"在未初始化对象上调用会得到错误结果"的风险。
这种不变量的弱化会在整个代码库中传播。如果 SlamSystem 持有一个 CameraModel 成员,SlamSystem 的每个使用 CameraModel 的方法也要检查"相机是否初始化了"。如果忘记检查,bug 可能在数千帧后才因为"用零焦距投影导致除零"而暴露——此时的错误位置和根本原因(构造时配置文件缺失)已经相隔很远。
如果不用异常会怎样:半初始化对象¶
class CameraModel {
public:
bool init(const std::string& yaml_path) {
if (!readYaml(yaml_path)) {
return false;
}
initialized_ = true;
return true;
}
Eigen::Vector2d project(const Eigen::Vector3d& point) const {
if (!initialized_) {
return Eigen::Vector2d::Zero(); // 静默给出假结果
}
return {fx_ * point.x() / point.z() + cx_,
fy_ * point.y() / point.z() + cy_};
}
private:
bool initialized_ = false;
double fx_ = 0.0;
double fy_ = 0.0;
double cx_ = 0.0;
double cy_ = 0.0;
};
这个设计的问题不是“代码长”,而是**不变量变弱了**。理想情况下,CameraModel 的对象一旦存在,就应满足“内参已经加载且合法”。两阶段初始化把这个不变量降级成“可能合法,也可能不合法”,于是每个成员函数都要防御。
异常版反而更简单:
class CameraModel {
public:
explicit CameraModel(const std::string& yaml_path) {
auto config = loadCameraConfig(yaml_path); // 失败时抛异常
fx_ = config.fx;
fy_ = config.fy;
cx_ = config.cx;
cy_ = config.cy;
}
Eigen::Vector2d project(const Eigen::Vector3d& point) const {
return {fx_ * point.x() / point.z() + cx_,
fy_ * point.y() / point.z() + cy_};
}
private:
double fx_ = 0.0;
double fy_ = 0.0;
double cx_ = 0.0;
double cy_ = 0.0;
};
这里的接口契约很清楚:构造函数要么成功建立一个合法对象,要么失败并阻止对象存在。
历史与设计原因¶
C++ 异常在 1990 年代进入语言,核心动机之一就是让构造函数、运算符重载和泛型库能够在不破坏正常返回值的情况下报告失败。标准库容器也依赖这个机制:std::vector 分配失败时抛 std::bad_alloc,std::string::at() 越界时抛 std::out_of_range。
异常的设计目标不是“让错误处理变神奇”,而是解决三个具体问题:
| 问题 | 错误码的困难 | 异常的作用 |
|---|---|---|
| 构造函数失败 | 无返回值 | 直接阻止对象构造完成 |
| 多层调用传播 | 每层都要手写转发 | 自动越过无法处理的层 |
| 返回值已被业务使用 | 错误码挤占返回值 | 错误通道与正常返回值分离 |
这也是为什么异常和 RAII 几乎是一对设计组合。异常负责离开失败路径,RAII 负责在离开过程中释放资源。
异常和 RAII 的关系,类似于消防系统中"火警报警器"和"自动喷淋装置"的配合:报警器(异常)发现问题并传递信号,喷淋装置(RAII 析构函数)在信号到达时自动执行清理。如果只有报警器没有喷淋,火警发生后虽然知道着火了,但灭火要靠人工逐房间操作(手动释放资源);如果只有喷淋没有报警器,清理会执行,但上层不知道发生了什么事故(错误被静默吞掉)。两者配合才形成完整的安全体系。不同的是,程序中的"喷淋"是确定性的——C++ 保证析构函数按照构造的逆序执行,而现实消防系统可能存在设备故障。
如果 C++ 没有引入异常机制,构造函数的失败表达会成为一个根本性难题。构造函数没有返回值,唯一的"正常返回"就是对象成功存在。没有异常,程序员被迫采用两阶段初始化:先默认构造一个"空壳"对象,再调用 init() 检查返回值。这种模式让对象出现"已构造但未初始化"的中间状态,每个成员函数都要防御这个状态,类的不变量被严重削弱。异常让构造函数可以直接表达"我无法建立一个合法对象",调用者永远不会看到半成品。
理论:异常对象、传播与匹配¶
异常传播的完整流程可以分为五个阶段:(1)throw 表达式创建异常对象——这个对象不在栈上,而在运行时管理的特殊存储区域中,确保栈展开期间它不会被销毁;(2)运行时开始沿调用栈向上搜索匹配的 catch 块;(3)每离开一个作用域,该作用域内已构造的局部对象按构造逆序析构(栈展开);(4)找到类型匹配的 catch 块后,控制流转移到该 catch 块中;(5)catch 块执行完毕后,程序从 try-catch 语句之后继续执行。
如果整个调用栈上都没有匹配的 catch 块,程序调用 std::terminate() 终止。这意味着未捕获的异常不会被"忽略"——它会导致进程终止,这通常比静默继续运行在损坏状态下更安全。
最小异常流程如下:
#include <stdexcept>
#include <string>
double parsePositive(const std::string& text) {
double value = std::stod(text);
if (value <= 0.0) {
throw std::invalid_argument("value must be positive");
}
return value;
}
int main() {
try {
double fx = parsePositive("-3.0");
} catch (const std::invalid_argument& e) {
// 捕获更具体的错误
} catch (const std::exception& e) {
// 捕获标准异常基类
}
}
关键规则:
throw expr;会创建异常对象,异常对象的生命周期由运行时管理。- 异常沿调用栈向上传播,寻找第一个类型匹配的
catch。 catch按书写顺序匹配,派生类应写在基类前。- 通常用
catch (const T& e)捕获,避免拷贝和对象切片。 - 在
catch内使用单独的throw;可以重新抛出当前异常,保留原始动态类型。
错误示例:
try {
throw std::runtime_error("config parse failed");
} catch (std::exception e) { // 按值捕获,会切片
// e 的动态类型信息被切掉,只剩 std::exception 部分
}
正确写法:
try {
throw std::runtime_error("config parse failed");
} catch (const std::exception& e) {
// 保留动态类型,避免额外拷贝
}
标准异常层级的设计逻辑¶
C++ 标准库的异常类型形成一棵以 std::exception 为根的继承树。这个层级设计的目的不是让异常类型"更面向对象",而是让 catch 块能按不同的粒度捕获异常:catch (const std::exception&) 捕获所有标准异常,catch (const std::runtime_error&) 只捕获运行时错误,catch (const std::invalid_argument&) 只捕获参数非法错误。这种层级让调用者可以根据自己的恢复能力选择捕获粒度——配置加载器可能只关心 invalid_argument(参数问题可以提示用户修改配置),而 main() 函数可能捕获所有 exception(记录日志并退出)。
标准异常树的两个主要分支——logic_error 和 runtime_error——反映了两种根本不同的错误性质。logic_error 表示"程序逻辑错误"(如参数越界、状态不允许),理论上可以通过修改代码来预防。runtime_error 表示"运行环境导致的错误"(如文件不存在、网络断开),即使代码完全正确也可能发生。这个区分在工程上有实际意义:logic_error 通常暗示"修 bug",runtime_error 通常暗示"加重试逻辑或降级策略"。
标准异常类型速查¶
| 类型 | 典型含义 | 示例 |
|---|---|---|
std::logic_error |
调用方违反逻辑前提 | 非法参数、状态不允许 |
std::invalid_argument |
参数值不合法 | 负焦距、空路径 |
std::out_of_range |
索引或范围错误 | vector::at() 越界 |
std::runtime_error |
运行环境导致失败 | 文件缺失、解析失败 |
std::bad_alloc |
内存分配失败 | 容器扩容失败 |
std::system_error |
系统调用错误 | 线程、互斥锁、文件系统错误 |
注意:std::logic_error 和 std::runtime_error 的边界不是绝对的。工程上更重要的是让异常消息带上可定位信息:路径、字段名、期望范围、实际值。
给异常补上下文¶
异常传播的一个常见问题是信息丢失。底层解析函数抛出 missing key fx,但调用者需要知道的是"哪个文件的、哪个模块的、哪套相机配置的 fx 缺失"。如果不在每层添加上下文信息,最终在 main() 的 catch 块中看到的只是一个脱离语境的底层错误消息——这在现场部署时几乎无法用于问题定位。
补上下文的标准做法是在中间层 catch 然后 throw 一个包含更多信息的新异常,或者使用 std::throw_with_nested 保留完整的异常链。注意不要写 throw e;(这会切片派生异常的动态类型),而应该写 throw;(重新抛出当前异常,保留原始类型)或构造新异常。
底层异常通常信息不足。配置加载可以在每层添加上下文:
CameraConfig loadCameraConfigOrThrow(const std::filesystem::path& path) {
try {
return parseCameraYaml(path);
} catch (const std::exception& e) {
throw std::runtime_error(
"failed to load camera config '" + path.string() + "': " + e.what());
}
}
如果不补上下文会怎样?底层只说 missing key fx,调用者不知道是哪个文件、哪个模块、哪套相机配置。补上下文不是包装错误,而是保留排查链。
⚠️ 常见陷阱¶
⚠️ 编程陷阱:按值捕获异常
错误做法:
catch (std::exception e)。现象:派生异常被切片,动态类型和附加信息丢失。
根本原因:按值捕获会用静态类型构造一个新对象。
正确做法:
catch (const std::exception& e);需要重新抛出时使用throw;,不要写throw e;。⚠️ 编程陷阱:捕获顺序从基类到派生类
错误做法:
try { throw std::out_of_range("bad index"); } catch (const std::exception& e) { // 先匹配到这里 } catch (const std::out_of_range& e) { // 永远到不了 }正确做法:先捕获具体类型,再捕获基类。
💡 概念误区:所有异常都应该在最近的位置捕获
实际上:只有当前层能恢复时才捕获。解析函数发现字段缺失,但不知道该使用默认值、停止启动还是切换传感器,就不应该吞掉异常。
正确做法:在能做出恢复决策的边界捕获,例如命令行入口、ROS2 节点启动、线程函数顶层。
练习¶
- 代码修复:给一段
catch (std::exception e)的代码改成正确捕获方式,并说明throw;与throw e;的区别。 - 设计题:为
CameraModel构造函数写出异常消息格式,要求包含配置路径、字段名、实际值。 - 分析题:
std::stod("abc")会抛什么异常?std::vector<int>{}.at(0)又会抛什么异常?它们分别适合在哪一层捕获?
8.3 栈展开:异常离开时,谁来收拾现场 ⭐⭐¶
栈展开(stack unwinding)是 C++ 异常机制中最精妙的部分。它解决的核心问题是:异常把控制流从函数中间直接带走,但函数中已经构造的局部对象仍然持有资源(内存、文件句柄、互斥锁、GPU 资源),这些资源必须被正确释放。栈展开保证了一个至关重要的语言承诺:无论控制流如何离开作用域(正常返回、异常、或到达作用域结尾),已构造的自动对象都会按构造的逆序析构。
这个承诺是 RAII 和异常安全的基石。没有栈展开,RAII 就只能在正常路径上工作——lock_guard 在正常返回时能释放锁,但在异常路径上锁就永久泄漏了。有了栈展开,RAII 在所有控制流路径上都有效——这就是为什么 C++ 程序员敢于在 lock_guard 和 unique_ptr 之间放可能抛异常的代码,而不需要像 C 程序员那样写大量的 goto cleanup 跳转。
动机:异常路径比正常路径更需要资源安全¶
回顾 RAII与智能指针:RAII 把资源释放绑定到对象生命周期。那里用 std::lock_guard、std::ifstream、std::vector 解决了“忘记释放”的问题。本章关心的是更强的情形:不是正常离开作用域,而是异常把控制流从函数中间直接带走。
void processFrame(const std::filesystem::path& image_path) {
std::ifstream file(image_path, std::ios::binary);
std::vector<unsigned char> bytes(1024 * 1024);
std::lock_guard<std::mutex> lock(global_map_mutex);
decodeImage(bytes); // 这里可能抛异常
updateMap(); // 这里也可能抛异常
}
如果 decodeImage() 抛异常,函数不会执行到结尾。问题是:文件会关闭吗?vector 内存会释放吗?互斥锁会解锁吗?
答案是会。原因就是**栈展开**。
如果没有栈展开会怎样¶
没有栈展开的世界里,异常就像从楼上直接跳到出口,中间楼层的门全都没人关:
void badManualCode() {
FILE* file = std::fopen("image.png", "rb");
std::mutex m;
m.lock();
decodeImage(); // 抛异常时跳出函数
m.unlock();
std::fclose(file);
}
异常发生后:
| 资源 | 正常路径释放位置 | 异常路径结果 |
|---|---|---|
| 文件句柄 | std::fclose(file) |
泄漏 |
| 互斥锁 | m.unlock() |
永久锁住 |
| 堆内存 | delete / free |
泄漏 |
| GPU 资源 | cudaFree |
显存泄漏 |
这就是 C++ 强调 RAII 的根本原因。异常让控制流不再线性,RAII 让清理逻辑重新变得线性:构造顺序入栈,析构顺序出栈。
从更广的视角来看,手动资源管理和 RAII 的区别,类似于手动挡汽车和自动挡汽车的区别。手动挡(手动 lock/unlock、fopen/fclose)给驾驶者完全控制,但在"紧急刹车"(异常)时驾驶者可能来不及完成所有操作。自动挡(RAII)把离合操作绑定到变速箱状态:无论驾驶者是正常停车还是紧急制动,变速箱都会自动回到正确档位。不同之处在于,程序中的"紧急制动"(异常)可能从调用栈的任意深度发生,手动清理要求程序员预见所有可能的异常点并逐一处理——这在实践中几乎不可能做到。
栈展开的实现机制:table-based unwinding 的由来¶
栈展开不是语言层面的抽象概念,它在底层有具体的实现方式。现代编译器(GCC、Clang、MSVC)使用的主流方案是 table-based unwinding(基于表的栈展开),它的设计历史和替代方案的对比能帮助理解异常的真实成本结构。
历史上存在两种主要的栈展开实现方式:
方案一:setjmp/longjmp 方案(早期 C++ 编译器采用)。在每个 try 块入口处调用 setjmp 保存寄存器和栈状态,抛出异常时通过 longjmp 跳回保存点。优势是实现简单。代价是每次进入 try 块都要执行 setjmp,无论是否真的发生异常——这让正常执行路径也要付出代价。在 1990 年代的基准测试中,try 块的进入成本大约相当于一次函数调用。对于嵌套多层 try 的 SLAM 初始化代码,这个开销会累积。
方案二:table-based unwinding(现代编译器采用,遵循 Itanium C++ ABI 或 MSVC 的 SEH 方案)。编译器在编译时为每个函数生成一张"展开表"(unwind table),记录每个程序计数器(PC)范围内有哪些对象需要析构、catch 块在哪个地址。正常执行路径不执行任何额外指令——try 块的进入在生成的机器码中完全不可见。只有在抛出异常时,运行时库才查表、调用析构函数、跳转到 catch 块。
| 方案 | 正常路径开销 | 异常路径开销 | 代码/数据大小 |
|---|---|---|---|
setjmp/longjmp |
每个 try 都有 setjmp 成本 |
longjmp + 清理 |
较小 |
| table-based | 零(无额外指令) | 查表 + 遍历 + 析构 | 展开表增加二进制大小(通常 5-15%) |
现代编译器选择 table-based 方案的原因很明确:在机器人系统和大多数应用中,异常是罕见路径。让罕见路径变慢(查表开销)换取常见路径完全零开销,是更好的工程权衡。GCC 的 libgcc 中实现了 _Unwind_RaiseException() 函数,它沿着调用栈的返回地址链逐帧查表,找到每一帧中需要析构的对象并调用析构函数,直到遇到匹配的 catch 块。
这也解释了为什么"异常只用于罕见路径"是性能准则而非风格偏好:table-based 方案在正常路径零开销,但异常路径的查表和析构调用比普通 if 检查慢一到两个数量级。如果把异常用于每帧都发生的"特征点不够"判断,性能会显著下降——不是因为异常机制设计不好,而是因为 table-based 方案刻意把开销全部推到了异常路径上。
noexcept 在这个实现中的额外作用:编译器知道 noexcept 函数不会抛出异常,因此可以省略为该函数生成展开表条目。这不仅略微减小二进制大小,更重要的是让优化器确信该函数调用不会中断正常控制流,从而更积极地做内联、指令重排和寄存器分配。
理论:栈展开的精确规则¶
当异常从某个点抛出后,运行时开始寻找匹配的 catch。在查找过程中,每离开一个作用域,该作用域内已经完整构造的自动对象会按**构造的逆序**析构。
#include <iostream>
#include <stdexcept>
#include <string>
struct Trace {
std::string name;
explicit Trace(std::string n) : name(std::move(n)) {
std::cout << "construct " << name << "\n";
}
~Trace() noexcept {
std::cout << "destroy " << name << "\n";
}
};
void inner() {
Trace a("inner.a");
Trace b("inner.b");
throw std::runtime_error("boom");
}
void outer() {
Trace c("outer.c");
inner();
Trace d("outer.d");
}
int main() {
try {
outer();
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << "\n";
}
}
典型输出:
construct outer.c
construct inner.a
construct inner.b
destroy inner.b
destroy inner.a
destroy outer.c
caught: boom
注意 outer.d 没有构造,因此不会析构。C++ 只析构已经完整构造的对象。
构造函数抛异常时的栈展开¶
构造函数里抛异常时,规则更细:
- 对象本身没有构造完成,因此它的析构函数不会执行。
- 已经构造完成的基类子对象和成员子对象会按逆序析构。
- 尚未构造的成员不会析构。
struct Member {
std::string name;
explicit Member(std::string n) : name(std::move(n)) {}
~Member() noexcept {}
};
struct SensorNode {
Member log_;
Member camera_;
Member lidar_;
explicit SensorNode(bool fail)
: log_("log")
, camera_("camera")
, lidar_("lidar") {
if (fail) {
throw std::runtime_error("sensor calibration failed");
}
}
~SensorNode() noexcept {
// 只有 SensorNode 完整构造成功后,析构函数才会执行
}
};
如果异常在构造函数体中抛出,log_、camera_、lidar_ 都已经构造完成,会自动析构;SensorNode::~SensorNode() 不会执行。这个规则要求资源必须放在成员对象自己的 RAII 封装中,而不是等到外层对象析构函数里手动释放。
与 RAII与智能指针 的桥接:RAII 是栈展开的清理钩子¶
回顾 RAII与智能指针:unique_ptr 的析构释放内存,lock_guard 的析构释放锁,fstream 的析构关闭文件。栈展开做的事情不是“知道每种资源怎么释放”,而是**统一调用析构函数**。真正懂资源释放的是每个 RAII 类型本身。
这就像仓库消防系统:火警发生时,中央系统只负责触发每个区域的消防装置;每个区域具体喷淋、断电、关阀由本地装置完成。栈展开是中央触发机制,RAII 对象是本地清理装置。
catch (...) 的位置¶
catch (...) 可以捕获任何异常:
try {
runPipeline();
} catch (...) {
logFatal("unknown exception in pipeline");
throw; // 通常不要直接吞掉
}
它适合放在边界层:
| 边界 | 作用 |
|---|---|
main() 顶层 |
记录最后错误并转换为进程退出码 |
| 线程函数顶层 | 防止异常逃出线程入口导致终止 |
| ROS2 回调内部 | 防止异常逃出执行器回调 |
| C ABI 包装函数 | 转换为 C 风格错误码 |
它不适合放在底层算法函数里静默吞错。吞掉未知异常会让系统继续运行在未知状态,比直接停止更危险。
⚠️ 常见陷阱¶
⚠️ 编程陷阱:把资源释放写在外层析构函数中
错误做法:构造函数先获取裸资源,再在类析构函数里释放。
现象:如果构造函数中途抛异常,外层类析构函数不会执行,裸资源泄漏。
根本原因:对象未完整构造时,最外层析构函数不会运行。
正确做法:每个资源立即放入成员 RAII 类型,例如
std::unique_ptr、std::fstream、自定义句柄类。⚠️ 编程陷阱:
catch (...)后继续运行但状态未知错误做法:捕获所有异常后只打印一行日志,然后继续使用可能已经部分更新的数据结构。
现象:后续错误位置远离真正失败点,地图状态、缓存状态或锁状态难以判断。
正确做法:只在明确边界捕获未知异常;捕获后要么转换成失败状态,要么停止当前任务,要么重新抛出。
💡 思维陷阱:认为异常安全只关心内存泄漏
实际上:异常安全还关心锁是否释放、对象不变量是否保持、容器状态是否可用、输出文件是否半写入、地图是否部分更新。
正确做法:把“异常发生后系统处于什么状态”作为设计问题,而不是只检查有没有
delete。
练习¶
- 输出推演:为
Trace示例增加第三层函数,手写异常抛出后的构造和析构顺序。 - 重构题:把一个
FILE*+malloc+mutex.lock()的函数改成ifstream+vector+lock_guard,说明异常路径释放顺序。 - 构造函数题:设计一个
DatasetReader,包含日志文件、图像目录句柄、配置对象三个成员。说明任意一个成员构造失败时哪些析构函数会执行。
8.4 异常安全保证:失败后对象还能不能用 ⭐⭐⭐¶
异常安全保证是本章的核心概念——它回答的不是"异常发生时是否释放了资源"(那是栈展开和 RAII 的职责),而是"异常发生后对象处于什么状态"。这个问题对调用者来说至关重要:如果一个 Map::addKeyFrame() 操作失败了,调用者需要知道地图是否还能使用、是否可以重试、还是必须重建。
异常安全保证的概念源自 David Abrahams 在 1990 年代末对 C++ 标准库异常安全性的系统分析。他提出了三个层级(基本保证、强保证、无异常保证),这个分类至今仍是工业界的标准术语。理解这三个层级的关键是把它们看作调用者和实现者之间的"状态契约"——实现者承诺异常后的对象状态满足特定条件,调用者据此决定恢复策略。
动机:不泄漏资源还不够¶
假设一个地图类维护关键帧:
class Map {
public:
void addKeyFrame(KeyFrame kf);
private:
std::vector<KeyFrame> keyframes_;
std::unordered_map<int, std::size_t> id_to_index_;
};
addKeyFrame() 需要同时更新 keyframes_ 和 id_to_index_。如果 keyframes_.push_back() 成功,但 id_to_index_.emplace() 因内存分配失败抛异常,地图会出现不一致:向量里有关键帧,索引表里没有。
这不是资源泄漏,却是状态损坏。异常安全保证讨论的正是这个问题:异常发生后,对象还保持什么状态?
如果不声明保证会怎样¶
void Map::addKeyFrame(KeyFrame kf) {
keyframes_.push_back(std::move(kf));
id_to_index_.emplace(keyframes_.back().id(), keyframes_.size() - 1);
}
失败路径:
push_back 成功
│
▼
id_to_index_.emplace 分配内存
│
├── 成功 → 状态一致
└── 抛异常 → keyframes_ 已变化,id_to_index_ 未变化
这段代码至少有基本资源安全,因为标准容器自己不会泄漏。但 Map 的高层不变量已经坏了。
三层异常安全保证¶
| 保证 | 含义 | 异常后状态 | 示例 |
|---|---|---|---|
| 基本保证 | 不泄漏资源,对象仍有效 | 状态可用但可能已改变 | 容器部分操作 |
| 强保证 | 要么成功完成,要么回到调用前 | 状态不变 | copy-and-swap、先构造临时对象 |
| 无异常保证 | 函数承诺不抛异常 | 一定完成或内部处理 | 析构函数、移动操作、swap |
| 无保证 | 资源或不变量可能损坏 | 不应继续使用 | 裸资源手写清理失败 |
基本保证不是”可以乱”,它至少要求对象仍满足不变量。强保证更像数据库事务:要么提交,要么回滚。无异常保证是最高等级,通常用于清理、移动、交换和底层基础操作。
三级保证的形式化定义与验证方法¶
异常安全保证可以用前置条件/后置条件的语言更精确地表达:
设函数 f() 的前置条件为 P,即调用前对象状态满足某组不变量。设 S_before 为调用前的对象状态快照。
- 无保证:如果
f()抛异常,对象可能不满足P,资源可能泄漏。调用者不能对异常后的对象做任何假设。 - 基本保证:如果
f()抛异常,对象仍满足P(不变量成立),没有资源泄漏,但状态可能不等于S_before。调用者可以安全地析构对象或调用其他满足前置条件P的操作。 - 强保证:如果
f()抛异常,对象状态等于S_before——就像f()从未被调用过。调用者可以重试或继续使用对象。 - 无异常保证:
f()承诺不抛异常。P在调用前后都成立,且操作一定完成。
验证一个函数提供哪级保证的实用方法:
- 列出所有可能抛异常的操作。标准容器的
push_back、emplace、operator new、拷贝构造都可能抛。swap、pop_back、移动noexcept类型的赋值通常不抛。 - 在每个抛异常点画一条”异常切割线”。线的左边是已经执行的操作,线的右边是还没执行的操作。
- 检查每条切割线处的状态:对象的不变量是否还成立?有没有已分配但未接管的资源?有没有已修改但未回滚的容器索引?
- 基本保证的最低要求:每条切割线处,对象可析构、可赋值、不变量成立。
- 强保证的要求:每条切割线处,对象状态与调用前完全相同。
这种分析方法在代码审查中非常实用。与其笼统地说”这个函数是异常安全的”,不如在文档或注释中标注每个可能的异常点和对应的状态。
三层保证的关系类似于建筑物的结构安全等级。基本保证相当于”地震后建筑不倒塌,可以安全撤离”——结构仍然有效,但内部装修可能已经改变。强保证相当于”地震后建筑完好如初,或者根本没有开工”——要么完全成功建成,要么回到空地状态。无异常保证相当于”这根承重柱绝对不会断”——它是其他保证的基础设施,如果它失败,整栋建筑的安全等级都无从谈起。不同的是,建筑安全等级由材料物理性质决定,而异常安全等级由程序员的代码结构决定——选择先构造临时对象还是原地修改,直接决定了保证等级。
如果所有操作都不声明异常安全保证会怎样?调用者无法推理失败后的状态。一个 Map::addKeyFrame() 失败后,调用者不知道地图是否还能查询、能否继续添加下一帧、析构是否安全。最终结果是:要么在每个调用点写大量防御代码,要么忽略失败继续运行并祈祷不出问题。异常安全保证把这个推理工作从调用者转移到实现者——实现者在设计时就确定失败后的状态,调用者只需查看文档就知道如何恢复。
本质洞察:异常安全保证不是给编译器看的装饰,而是给调用者看的状态契约。调用者真正关心的是:失败后这个对象能不能析构、能不能重试、能不能继续服务下一帧。
基本保证:对象有效但内容可能改变¶
基本保证适合代价较高、回滚复杂、但对象能保持可用的操作。
class FrameBuffer {
public:
void append(Frame frame) {
frames_.push_back(std::move(frame));
// 如果 push_back 抛异常,std::vector 自己保持有效
// FrameBuffer 没有额外索引,因此基本保证足够
}
private:
std::vector<Frame> frames_;
};
基本保证要求:
- 没有资源泄漏。
- 所有成员对象仍满足自己的不变量。
- 类整体仍处于可析构、可赋值、可继续调用文档允许函数的状态。
它不要求操作前后状态相同。例如追加失败后缓冲区可能仍是原状态,也可能有某些内部容量变化,但对外可用。
强保证:先准备,后提交¶
强保证的通用模式是“先在临时对象中完成可能失败的工作,再用不抛异常的操作提交”。
修复 Map::addKeyFrame():
class Map {
public:
void addKeyFrame(KeyFrame kf) {
auto new_keyframes = keyframes_; // 可能抛异常,原对象未变
auto new_index = id_to_index_; // 可能抛异常,原对象未变
const int id = kf.id();
new_keyframes.push_back(std::move(kf));
new_index.emplace(id, new_keyframes.size() - 1);
keyframes_.swap(new_keyframes); // swap 应为 noexcept
id_to_index_.swap(new_index); // 提交
}
private:
std::vector<KeyFrame> keyframes_;
std::unordered_map<int, std::size_t> id_to_index_;
};
这个实现清晰但可能昂贵,因为每次添加关键帧都复制整张地图。工程上常用更细粒度的强保证:
void Map::addKeyFrame(KeyFrame kf) {
const int id = kf.id();
// 先让索引表预留空间,可能抛异常,但此时状态未变
id_to_index_.reserve(id_to_index_.size() + 1);
keyframes_.reserve(keyframes_.size() + 1);
// reserve 成功后,后续 push_back/emplace 更不容易因扩容抛异常
keyframes_.push_back(std::move(kf));
try {
id_to_index_.emplace(id, keyframes_.size() - 1);
} catch (...) {
keyframes_.pop_back(); // pop_back 对标准容器不抛异常,前提是元素析构不抛
throw;
}
}
这里的关键是:回滚动作本身必须不抛异常,否则强保证会被第二个错误破坏。
无异常保证:基础设施必须可靠¶
无异常保证常见于:
| 函数 | 为什么需要不抛 |
|---|---|
| 析构函数 | 栈展开时若再抛异常会终止 |
| 移动构造/移动赋值 | 容器扩容依赖 noexcept 选择移动 |
swap |
强保证的提交步骤依赖不抛交换 |
| 日志清理、锁释放 | 清理路径不应制造新失败 |
class PointCloud {
public:
PointCloud(PointCloud&& other) noexcept
: points_(std::move(other.points_)) {}
PointCloud& operator=(PointCloud&& other) noexcept {
points_ = std::move(other.points_);
return *this;
}
void swap(PointCloud& other) noexcept {
points_.swap(other.points_);
}
private:
std::vector<Eigen::Vector3d> points_;
};
这里要非常谨慎:如果某个成员的移动赋值可能抛异常,外层就不能诚实地标记 noexcept。错误标注的后果不是“优化失败”,而是异常逃出时直接终止程序。
copy-and-swap:强保证的经典模式¶
copy-and-swap 是实现强异常安全保证的最经典模式。它的核心思想可以用三个步骤概括:(1)构造一个临时对象作为"新状态"——这一步可能抛异常,但原对象完全不受影响;(2)用 swap 把临时对象和当前对象交换——swap 必须是 noexcept 的;(3)临时对象在作用域结束时自动析构,带走旧状态。如果第一步失败,第二步和第三步不会执行,原对象保持不变——这就是强保证。
这个模式的精妙之处在于它把"可能失败的准备工作"和"不可失败的提交操作"完全分开。所有可能抛异常的操作(内存分配、拷贝构造、数据验证)都在临时对象上完成;提交操作(swap)只交换几个指针或标量,绝不分配内存。
class Config {
public:
Config& operator=(Config other) { // 按值传参:先拷贝或移动出临时对象
swap(other); // 不抛提交
return *this;
}
void swap(Config& other) noexcept {
std::swap(camera_name_, other.camera_name_);
std::swap(fx_, other.fx_);
std::swap(fy_, other.fy_);
}
private:
std::string camera_name_;
double fx_ = 0.0;
double fy_ = 0.0;
};
这个模式的直觉是“先把新房子盖好,再搬家”。盖房子失败,旧房子还在;搬家动作必须可靠。
但它不总是最佳选择:
| 场景 | 是否适合 copy-and-swap | 原因 |
|---|---|---|
| 小对象配置 | 适合 | 拷贝成本低,强保证简单 |
| 大型地图 | 不适合 | 拷贝整图代价太高 |
| 资源句柄 | 适合移动和 swap | 所有权交换便宜 |
| 实时循环热路径 | 谨慎 | 临时分配可能破坏时间预算 |
std::vector::push_back 的强保证与 移动语义与完美转发 的连接¶
回顾 移动语义与完美转发:std::vector 扩容时,如果元素移动构造是 noexcept,容器会移动旧元素;如果移动可能抛异常且元素可拷贝,容器更倾向于拷贝旧元素。这不是保守,而是为了维持强保证。
扩容过程:
旧缓冲区:[a b c]
需要插入 d,但容量不足
1. 分配新缓冲区 可能抛,旧缓冲区未变
2. 把 a b c 搬到新缓冲区 如果搬到一半抛,必须能回滚
3. 构造 d 可能抛,仍需回滚
4. 释放旧缓冲区并提交 不应抛
如果移动构造可能抛异常,且移动已经改变了源对象,失败时旧缓冲区可能只剩半残状态。拷贝虽然慢,但源对象不变,回滚更容易。因此 noexcept 移动不仅影响性能,也影响异常安全策略。
⚠️ 常见陷阱¶
⚠️ 编程陷阱:维护多个容器索引却只更新一半
错误做法:先修改主容器,再修改索引表,中间没有回滚。
现象:异常后主数据和索引不一致,后续查询返回错误结果。
正确做法:先完成可能失败的准备工作,再提交;或在
catch中用不抛操作回滚。⚠️ 编程陷阱:强保证依赖会抛异常的回滚动作
错误做法:失败后调用一个可能分配内存的
restore()。现象:原异常尚未处理,回滚又抛异常,状态更混乱。
正确做法:回滚动作只使用
noexcept操作,例如pop_back()、指针交换、状态位恢复。💡 概念误区:强保证总是更好
实际上:强保证常常需要复制、临时对象或额外内存。对大型地图和实时路径,基本保证加清晰恢复策略可能更实际。
正确做法:把强保证用在配置、事务式更新、外部可见状态切换;把基本保证用于成本过高但状态可保持有效的内部缓存。
练习¶
- 保证判断题:分析
std::vector::push_back、std::map::insert、自定义Map::addKeyFrame分别能提供哪一级异常安全保证。 - 代码题:把一个“更新向量 + 更新索引表”的函数改成强保证版本,要求失败后两个容器都回到调用前状态。
- 性能分析题:为什么在百万点云容器里追求所有操作强保证可能不划算?结合 移动语义与完美转发 的移动成本说明。
8.5 构造函数、析构函数与 noexcept:对象生命周期的硬边界 ⭐⭐⭐¶
构造函数和析构函数是 C++ 对象模型中最特殊的两个函数——它们定义了对象的"出生"和"死亡"。在异常安全的语境下,它们比普通成员函数更加敏感:构造函数失败时对象不应该存在(半初始化对象是不变量的噩梦),析构函数失败时程序可能正在处理另一个异常(栈展开期间再抛异常会直接终止)。
noexcept 在这两个函数上的作用也完全不同。构造函数标 noexcept 是"我承诺构造不会失败"——这限制了构造函数能做的事情(不能分配内存、不能打开文件、不能解析配置)。析构函数的 noexcept 则是 C++11 之后的默认行为——语言设计者认为析构函数不抛异常是如此重要,以至于把它变成了默认规则而不是需要显式声明的选项。
本节把构造函数异常、析构函数异常和 noexcept 三个话题放在一起,因为它们共同定义了对象生命周期的"硬边界"——违反这些边界不是"代码不够优雅",而是"程序直接终止"。
动机:最危险的异常往往发生在清理阶段¶
构造函数和析构函数是 C++ 对象生命周期的入口和出口。入口失败时,对象不能存在;出口失败时,程序可能已经在处理另一个失败。二者都比普通成员函数更敏感。
class ScopedTimer {
public:
explicit ScopedTimer(std::string name)
: name_(std::move(name))
, start_(std::chrono::steady_clock::now()) {}
~ScopedTimer() noexcept {
// 清理、记录、释放都不应向外抛异常
}
private:
std::string name_;
std::chrono::steady_clock::time_point start_;
};
RAII与智能指针 的 ScopedTimer 已经把析构函数写成 noexcept。本节解释为什么这不是风格偏好,而是语言边界。
构造函数异常:失败即对象不存在¶
构造函数和异常是天然的搭档。构造函数没有返回值——成功时"返回"的是一个可用的对象,失败时唯一的正常表达方式就是异常。如果不用异常,就只能采用两阶段初始化(先默认构造再调用 init()),这会引入"半初始化"的中间状态——每个成员函数都要检查 initialized_ 标志,类的不变量从"对象存在即合法"退化为"对象可能合法也可能不合法"。
从 RAII 的角度看,构造函数抛异常还有一个精确的语义:对象从未存在过。如果 CameraModel camera("bad.yaml") 的构造函数抛了异常,camera 不是一个"失败的对象"——它根本不存在,不会调用析构函数,不会留下任何痕迹。这个语义让调用者可以安全地假设"只要对象存在,它就满足不变量"。
构造函数适合在以下情形抛异常:
| 场景 | 原因 |
|---|---|
| 必需资源获取失败 | 没有资源就无法建立类不变量 |
| 参数违反不可恢复约束 | 负焦距、非法图像尺寸 |
| 配置缺失导致对象不可用 | 缺少相机模型或外参 |
| 底层库初始化失败 | 无法打开设备、无法加载模型 |
示例:
class Vocabulary {
public:
explicit Vocabulary(const std::filesystem::path& path) {
std::ifstream input(path);
if (!input) {
throw std::runtime_error("cannot open vocabulary: " + path.string());
}
loadFromStream(input);
if (words_.empty()) {
throw std::runtime_error("empty vocabulary: " + path.string());
}
}
private:
void loadFromStream(std::istream& input);
std::vector<std::string> words_;
};
构造成功后,Vocabulary 的不变量很强:词表非空,后续匹配函数不必每次检查“是否初始化”。
析构函数异常:为什么危险¶
析构函数抛异常是 C++ 中最危险的错误之一。要理解为什么,需要从 C++ 异常传播的基本限制说起:C++ 运行时一次只能传播一个异常。当一个异常正在传播时(栈展开过程中),如果又产生了第二个异常,语言规则没有提供同时处理两个异常的机制——既不能把它们排队、也不能嵌套传播。面对这个无法解决的困境,C++ 选择了最极端也最安全的策略:直接终止程序。
C++11 起,析构函数默认是 noexcept(true),前提是成员和基类析构函数也不抛。若异常逃出 noexcept 析构函数,程序调用 std::terminate()。这个默认行为不是编译器的"保守选择",而是语言设计的逻辑必然——析构函数是 RAII 和栈展开的基础设施,如果基础设施本身不可靠,整个异常安全体系都会崩塌。
更危险的是栈展开期间:
struct BadDestructor {
~BadDestructor() noexcept(false) {
throw std::runtime_error("destructor failed");
}
};
void f() {
BadDestructor bad;
throw std::runtime_error("original failure");
}
f() 抛出 original failure 后开始栈展开,销毁 bad。如果 bad 的析构函数再抛出第二个异常,C++ 无法同时传播两个异常,于是直接调用 std::terminate()。
边界要说清楚:
| 情况 | 结果 |
|---|---|
析构函数默认 noexcept(true),异常逃出 |
std::terminate() |
析构函数显式 noexcept(false),且没有正在传播的异常 |
异常可以传播,但强烈不推荐 |
析构函数显式 noexcept(false),且栈展开中又抛 |
std::terminate() |
| 析构函数内部捕获并处理所有异常 | 安全 |
因此工程准则很简单:析构函数不要向外抛异常。
本质洞察:
noexcept析构函数不是"限制",而是 C++ 对象模型的逻辑必然。 析构函数的职责是"善后",善后过程本身不能制造新问题。 如果清理代码也可能失败,就会出现"失败的失败"——这在任何工程系统中都是灾难性的死循环。 因此析构函数必须是"无条件可靠的最后一道防线"。
析构失败怎么办¶
析构函数不能抛异常,但析构过程中可能遇到真实的失败——文件写入缓冲区刷新失败、网络连接断开超时、GPU 资源释放返回错误码。这些失败是客观存在的,"不能抛异常"不等于"假装失败不存在"。关键是选择合适的处理策略,在不引入新异常的前提下尽可能保留错误信息。
工程上有一个重要的设计原则:把"可能失败的清理"和"必须成功的清理"分开。文件写入的 flush() 可能失败——如果调用者关心写入是否成功,应该在析构前显式调用 closeOrThrow();析构函数只做"兜底关闭",即使失败也只记录日志而不抛异常。这种双层设计让调用者可以在正常路径上获得详细的错误反馈,同时保证异常路径上析构函数的安全性。
常用方案:
- 析构函数中记录日志后吞掉异常。
- 提供显式
close(),让调用者在正常路径处理错误。 - 析构函数只做兜底清理,业务错误由显式提交函数返回。
class OutputFile {
public:
explicit OutputFile(const std::filesystem::path& path)
: stream_(path, std::ios::binary) {
if (!stream_) {
throw std::runtime_error("cannot open output file: " + path.string());
}
}
void write(const std::vector<std::byte>& data) {
if (data.size() >
static_cast<std::size_t>(std::numeric_limits<std::streamsize>::max())) {
throw std::runtime_error("output buffer is too large for stream write");
}
stream_.write(reinterpret_cast<const char*>(data.data()),
static_cast<std::streamsize>(data.size()));
if (!stream_) {
throw std::runtime_error("write failed");
}
}
void closeOrThrow() {
stream_.flush();
if (!stream_) {
throw std::runtime_error("flush failed");
}
stream_.close();
if (!stream_) {
throw std::runtime_error("close failed");
}
closed_ = true;
}
~OutputFile() noexcept {
try {
if (!closed_) {
stream_.close();
}
} catch (...) {
// 析构函数不能让异常逃出;真实项目中写入不抛异常的日志通道
}
}
private:
std::ofstream stream_;
bool closed_ = false;
};
这个设计把“调用者必须知道的写入失败”放在 closeOrThrow(),把析构函数变成兜底清理。
noexcept 对编译器优化的具体影响¶
noexcept 对移动语义的影响是本章与 移动语义与完美转发 连接最紧密的知识点。回顾 移动语义与完美转发:std::vector 在扩容时需要把旧元素搬到新缓冲区。搬迁策略的选择——移动还是拷贝——取决于元素的移动构造函数是否是 noexcept 的。这不是性能优化的"锦上添花",而是异常安全保证的直接要求。
理解这个连接需要回到异常安全的根本逻辑:vector::push_back 承诺强异常安全保证(要么成功,要么状态不变)。扩容过程中如果搬迁到一半失败了,已经被移动的元素在旧缓冲区中已经处于"被掏空"的状态——无法恢复原状,强保证被打破。但如果用拷贝代替移动,旧缓冲区的元素始终完好,搬迁失败时直接丢弃新缓冲区就能回滚。因此,只有移动构造承诺不抛异常(noexcept)时,容器才敢使用移动——因为移动不会失败,就不存在"搬到一半"的中间状态。
这就是为什么忘记给移动构造标 noexcept 可能导致巨大的性能回退:一个包含百万点云的 vector,每次扩容都从移动退回到拷贝,时间开销可能从微秒级跳到毫秒级。
noexcept 不只是文档标注——它在两个层面直接影响生成的代码。
第一层:std::vector 的扩容策略选择。这是 noexcept 最著名的工程影响。当 vector 需要扩容时,它必须把旧缓冲区中的元素搬到新缓冲区。如果元素的移动构造是 noexcept,容器直接移动;如果移动可能抛异常且类型可拷贝,容器选择拷贝。原因在于强异常安全保证:移动操作改变源对象,如果搬到一半抛异常,已经被移动的源对象无法恢复原状,旧缓冲区变成"半残"。拷贝不改变源对象,抛异常时直接丢弃新缓冲区即可回滚。
在 SLAM 系统中,std::vector<KeyFrame> 可能包含数千个关键帧。如果 KeyFrame 的移动构造没有标 noexcept,每次 push_back 触发扩容时都会拷贝而非移动所有已有关键帧——这可能把一次微秒级操作变成毫秒级操作。仅仅加上 noexcept 就能让性能提升几个数量级,前提是移动构造确实不会抛异常。
第二层:编译器的控制流优化。当编译器看到一个 noexcept 函数调用时,它确信该调用不会产生异常传播路径。这意味着:
- 调用点之后的代码不需要准备异常处理跳转。
- 优化器可以更自由地将
noexcept函数内联。 - 寄存器分配器不需要在调用点保存可能被异常路径使用的寄存器。
- 某些调用约定中可以省略栈帧指针的保存。
这些优化单独看都不大,但在点云逐点处理的内循环中累积起来可以有可观的效果。
noexcept 的真实含义¶
noexcept 是承诺,不是祝福。理解这一点至关重要:noexcept 不会阻止函数内部抛出异常,它只是告诉编译器和标准库"我承诺异常不会逃出这个函数"。如果承诺被违反(异常确实逃出了),程序不会优雅地处理这个异常——它会直接调用 std::terminate() 终止整个进程。这意味着错误的 noexcept 标注比不标注更危险:不标注时异常至少还有机会被上层捕获;标注了却违反承诺,结果是无条件的进程终止。
如果异常逃出 noexcept 函数,程序调用 std::terminate()。这意味着错误标注 noexcept 比不标注更危险。
noexcept 有三种常见用途:
| 用途 | 示例 | 目的 |
|---|---|---|
| 函数声明承诺 | ~T() noexcept |
异常不得逃出 |
| 条件判断运算符 | noexcept(expr) |
编译期判断表达式是否可能抛 |
条件 noexcept |
noexcept(noexcept(T(...))) |
泛型中传播成员操作的承诺 |
条件 noexcept 在泛型代码中尤其重要。一个模板类无法预先知道其成员类型的移动构造是否会抛异常——std::string 的移动构造是 noexcept 的,但某些自定义类型的移动构造可能会分配内存。条件 noexcept 通过编译期查询成员类型的异常规范,让外层类型自动"继承"内层类型的承诺:
template <typename T>
class Box {
public:
// 条件 noexcept:Box 的移动构造是否不抛,取决于 T 的移动构造是否不抛
Box(Box&& other) noexcept(std::is_nothrow_move_constructible_v<T>)
: value_(std::move(other.value_)) {}
private:
T value_;
};
这里外层 Box<T> 的移动构造是否 noexcept,完全取决于 T 的移动构造是否不抛。当 T = std::string 时,Box<std::string> 的移动构造是 noexcept,因此 std::vector<Box<std::string>> 扩容时会使用移动而非拷贝。当 T = SomeHeavyType(其移动构造可能抛异常)时,Box<SomeHeavyType> 的移动构造不是 noexcept,vector 扩容时会退回到拷贝策略。这种"承诺自动传播"的机制,让泛型容器能在不了解具体类型的情况下做出最优的异常安全决策。
noexcept 与 std::move_if_noexcept¶
std::move_if_noexcept 是标准库中 noexcept 影响实际代码行为的最典型工具。它的名字精确描述了行为:如果类型的移动构造是 noexcept 的,就返回右值引用(触发移动);否则返回 const 左值引用(触发拷贝)。这个工具让容器在不了解元素类型的情况下,自动选择最安全的搬迁策略。
理解 move_if_noexcept 的决策逻辑,需要结合 移动语义与完美转发 的移动语义和本章的异常安全保证来看。移动操作会修改源对象——如果移动到一半抛异常,源对象已经处于"被移动过"的半残状态,无法恢复。拷贝操作不修改源对象——即使拷贝到一半失败,源对象完好无损。因此,对于需要维持强异常安全保证的容器扩容操作,只有在移动确保不抛异常时才敢使用移动。
标准库经常使用 std::move_if_noexcept:
template <typename T>
void relocateOne(T& src, void* dst) {
::new (dst) T(std::move_if_noexcept(src));
}
其决策可以理解为:
这正是 移动语义与完美转发 中 vector 扩容选择的语言基础。
什么时候不要写 noexcept¶
noexcept 的使用需要在"接口承诺"和"实现自由度"之间做出权衡。一旦一个函数被标记为 noexcept 并成为公共 API 的一部分,移除 noexcept 就是一个破坏性变更——依赖 noexcept 选择移动策略的 vector 会退回到拷贝,代码的性能特征会发生变化。因此,noexcept 应该只在你确信函数永远不需要抛异常的情况下使用——不是"当前实现不抛",而是"接口语义决定了不应该抛"。
析构函数天然属于"不应该抛"的范畴(清理操作不应制造新问题)。移动操作也通常属于"不应该抛"的范畴(移动只是指针交换和所有权转移,不分配新资源)。但一般的业务函数——配置加载、数据处理、算法求解——通常不应该标 noexcept,因为它们的实现可能在未来需要分配内存、调用外部库或处理更复杂的错误情况。
不要机械地给所有函数加 noexcept。以下函数通常不应标注:
| 函数 | 原因 |
|---|---|
| 会分配内存的函数 | new、容器扩容可能抛 bad_alloc |
| 会调用可能抛异常的库函数 | OpenCV、文件系统、解析器 |
| 需要把错误传播给调用者 | 标注后无法传播,只能终止 |
| 尚未能证明不抛的泛型函数 | 依赖模板参数行为 |
应该优先标注:
| 函数 | 原因 |
|---|---|
| 析构函数 | 清理边界 |
| 移动构造和移动赋值 | 容器性能与异常安全 |
swap |
强保证提交步骤 |
| 简单观察函数 | 如 size(), empty(),前提是确实不抛 |
⚠️ 常见陷阱¶
⚠️ 编程陷阱:为了“优化”乱加
noexcept错误做法:
void loadConfig() noexcept内部打开文件、解析 YAML、分配字符串。现象:解析器抛异常时程序直接终止,调用者没有处理机会。
根本原因:
noexcept是承诺,违反承诺的后果是终止。正确做法:只在能证明异常不会逃出的函数上标注;否则保持可抛或在函数内部捕获并转换错误。
⚠️ 编程陷阱:析构函数里调用可能抛异常的日志接口
错误做法:析构函数中调用会分配内存、格式化字符串并可能抛异常的日志库。
现象:正常路径偶尔终止;异常路径更容易终止。
正确做法:析构函数中只调用不抛的清理函数;必要时
try/catch (...)包住日志。💡 概念误区:
noexcept是让函数更快的咒语实际上:
noexcept的主要意义是接口契约。某些场景下标准库会利用它选择更快路径,但前提是承诺真实。正确做法:先保证语义,再考虑优化。错误的
noexcept会把可恢复错误变成进程终止。
练习¶
- 判断题:以下函数哪些应该标
noexcept:析构函数、loadYaml()、移动构造、swap()、projectPoint()、saveMap()。逐一说明理由。 - 代码题:实现一个
ScopedFd,构造时接管文件描述符,析构时关闭,支持移动但禁止拷贝。移动操作和析构函数应如何标注? - 分析题:某类的移动构造内部会分配内存,它是否应该标
noexcept?这会如何影响std::vector<该类>?
8.6 错误码、异常与 expected 风格:接口如何表达失败 ⭐⭐⭐¶
前面几节讨论了异常的传播机制、栈展开和异常安全保证。本节把视角从"机制"切换到"接口设计":同一个可能失败的操作,应该用异常、错误码还是 expected 风格表达?这不是风格偏好的问题,而是由调用边界、性能约束和错误恢复策略共同决定的工程选择。
一个成熟的机器人项目通常同时使用三种风格:启动初始化阶段用异常(失败时程序无法继续),C ABI 和硬件驱动边界用错误码(异常不能穿越 ABI),可预期的业务失败用 expected(调用者需要知道失败原因且能恢复)。理解每种风格的适用场景和代价结构,是设计清晰错误边界的基础。
动机:同一个配置加载器,三种合理接口¶
配置加载是机器人软件中最典型的错误处理场景。它既可能发生文件 IO 错误,又可能发生字段缺失、类型错误、数值越界。下面以相机配置为例:
struct CameraConfig {
double fx = 0.0;
double fy = 0.0;
double cx = 0.0;
double cy = 0.0;
int width = 0;
int height = 0;
};
目标是读取:
问题不是“哪种错误处理方式绝对正确”,而是“哪种接口最符合调用边界”。
方案一:异常版¶
异常版是最简洁的接口设计。函数返回值就是成功结果,失败通过异常传播。调用者不需要检查返回值——要么得到可用的 CameraConfig,要么异常会自动跳到最近的 catch 块。这种设计特别适合启动阶段的配置加载:配置错误时程序根本无法继续,把所有初始化代码放在一个大的 try-catch 中是最自然的错误边界。
异常版的一个重要设计细节是错误消息的结构。好的异常消息应该包含:出错的文件路径、具体的字段名、期望的值范围和实际的值。"load failed" 这种消息在现场部署时毫无用处——排查人员需要知道是"camera.yaml 的 fx 字段值为 -3.0,期望为正数"这种精度的信息。
CameraConfig loadCameraConfigOrThrow(const std::filesystem::path& path) {
std::ifstream input(path);
if (!input) {
throw std::runtime_error("cannot open config: " + path.string());
}
CameraConfig cfg;
cfg.fx = readRequiredDouble(input, "camera.fx");
cfg.fy = readRequiredDouble(input, "camera.fy");
cfg.cx = readRequiredDouble(input, "camera.cx");
cfg.cy = readRequiredDouble(input, "camera.cy");
cfg.width = readRequiredInt(input, "camera.width");
cfg.height = readRequiredInt(input, "camera.height");
if (cfg.fx <= 0.0 || cfg.fy <= 0.0) {
throw std::invalid_argument("camera focal length must be positive");
}
if (cfg.width <= 0 || cfg.height <= 0) {
throw std::invalid_argument("camera image size must be positive");
}
return cfg;
}
调用点:
int main(int argc, char** argv) {
try {
auto config = loadCameraConfigOrThrow("camera.yaml");
CameraModel camera(config);
run(camera);
} catch (const std::exception& e) {
std::cerr << "startup failed: " << e.what() << "\n";
return 1;
}
}
异常版适合启动阶段。配置失败时程序无法继续,错误从底层解析函数传播到 main(),中间层不需要手写转发。
方案二:错误码版¶
错误码版的核心设计思想是"把错误信息编码在返回值中"。与异常不同,错误码不会改变控制流——函数正常返回,调用者必须主动检查返回值来发现错误。这种显式风格在 C 语言传统中根深蒂固,至今仍在操作系统接口、硬件驱动和实时系统中广泛使用。
错误码版的一个关键设计决策是:错误码应该是枚举而不是整数。用枚举代替 int 返回值有两个好处:编译器可以检查 switch 是否覆盖了所有枚举值,调用者也不会把错误码和业务数据混淆。此外,如果错误需要携带详细信息(如文件路径、字段名、期望值),应该把错误码和描述信息打包成一个结构体:
enum class ConfigErrorCode {
Ok,
FileNotFound,
ParseError,
MissingField,
InvalidValue
};
struct ConfigError {
ConfigErrorCode code = ConfigErrorCode::Ok;
std::string message;
};
ConfigError loadCameraConfig(const std::filesystem::path& path, CameraConfig& out) {
std::ifstream input(path);
if (!input) {
return {ConfigErrorCode::FileNotFound,
"cannot open config: " + path.string()};
}
CameraConfig tmp;
auto fx = tryReadDouble(input, "camera.fx");
if (!fx) {
return {ConfigErrorCode::MissingField, "missing camera.fx"};
}
tmp.fx = *fx;
// 其余字段同理
if (tmp.fx <= 0.0) {
return {ConfigErrorCode::InvalidValue, "camera.fx must be positive"};
}
out = std::move(tmp);
return {};
}
调用点:
CameraConfig config;
ConfigError err = loadCameraConfig("camera.yaml", config);
if (err.code != ConfigErrorCode::Ok) {
std::cerr << err.message << "\n";
return 1;
}
错误码版的优势是边界清晰、无异常传播,适合 C API、实时敏感路径或明确要求不用异常的工程。缺点是调用者可能忘记检查返回值。可以用 [[nodiscard]] 缓解:
方案三:expected 风格¶
expected 风格代表了现代 C++ 错误处理的演进方向——它结合了异常的类型安全性(错误信息不可被忽略)和错误码的可预测性(不改变控制流)。expected 本质上是一种”和类型”(sum type):一个 expected<Config, Error> 对象在任意时刻要么持有一个 Config,要么持有一个 Error,不可能同时持有两者,也不可能什么都没有。这种设计源自函数式编程语言中的 Either/Result 类型,在 Rust 语言中以 Result<T, E> 的形式成为错误处理的标准范式。
C++23 引入 std::expected<T, E>。它表示”要么有 T,要么有 E”。如果工具链尚未完整支持,可以使用 tl::expected、outcome 或项目内轻量封装。本章重点是风格,而不是依赖某个库。与错误码版相比,expected 版有一个根本优势:成功值和错误信息是返回类型的一部分,调用者必须先检查是否成功才能访问值——编译器不允许直接使用可能不存在的 Config:
#include <expected>
[[nodiscard]] std::expected<CameraConfig, ConfigError>
loadCameraConfigExpected(const std::filesystem::path& path) {
std::ifstream input(path);
if (!input) {
return std::unexpected(ConfigError{
ConfigErrorCode::FileNotFound,
"cannot open config: " + path.string()});
}
CameraConfig cfg;
auto fx = tryReadDouble(input, "camera.fx");
if (!fx) {
return std::unexpected(ConfigError{
ConfigErrorCode::MissingField,
"missing camera.fx"});
}
cfg.fx = *fx;
return cfg;
}
调用点:
auto result = loadCameraConfigExpected("camera.yaml");
if (!result) {
std::cerr << result.error().message << "\n";
return 1;
}
CameraConfig config = std::move(*result);
expected 风格的核心优势是:失败是类型的一部分。调用者看到返回类型就知道必须处理错误。
C++23 std::expected 的 monadic 操作 ⭐⭐⭐¶
C++23 不仅引入了 std::expected 本身,还为它提供了三个 monadic 操作——and_then、transform、or_else。这些操作让 expected 的链式错误传播变得和 Rust 的 ? 运算符一样简洁,消除了嵌套 if 检查的"金字塔"代码结构。
理解这些操作之前,先回顾一下没有它们时的写法。假设配置加载分三步:读文件、解析 YAML、验证字段。每一步都可能失败:
// 没有 monadic 操作的传统写法——嵌套检查
auto file_result = readFile(path);
if (!file_result) {
return std::unexpected(file_result.error());
}
auto yaml_result = parseYaml(*file_result);
if (!yaml_result) {
return std::unexpected(yaml_result.error());
}
auto config_result = validateConfig(*yaml_result);
if (!config_result) {
return std::unexpected(config_result.error());
}
return *config_result;
每一步都要检查、解包、传播错误。这和 Go 语言的 if err != nil { return err } 问题一样——正确逻辑被错误处理样板代码淹没。
C++23 的 monadic 操作让同样的逻辑写成链式调用:
// C++23 monadic 操作——链式错误传播
auto config = readFile(path)
.and_then(parseYaml) // 成功时调用 parseYaml,失败时自动传播错误
.and_then(validateConfig) // 成功时调用 validateConfig
.transform(applyDefaults); // 成功时转换值,失败时原样返回
三个操作的语义精确定义如下:
| 操作 | 签名(简化) | 语义 |
|---|---|---|
and_then(f) |
f 返回 expected<U, E> |
成功时调用 f(*this),返回新的 expected;失败时直接返回当前错误 |
transform(f) |
f 返回 U |
成功时用 f(*this) 转换值,包装为 expected<U, E>;失败时原样返回 |
or_else(f) |
f 返回 expected<T, G> |
失败时调用 f(error()),可以恢复或转换错误;成功时原样返回 |
or_else 在机器人配置中特别有用——它可以实现"尝试主配置,失败时回退到默认配置"的恢复策略:
auto config = loadCameraConfig("camera.yaml")
.or_else([](const ConfigError& err) -> std::expected<CameraConfig, ConfigError> {
if (err.code == ConfigErrorCode::FileNotFound) {
return CameraConfig::defaults(); // 文件不存在时使用默认配置
}
return std::unexpected(err); // 其他错误继续传播
});
反事实推理:如果 C++ 从一开始就有
expected和 monadic 操作,异常在 C++ 生态中的地位可能会大不相同。异常的核心优势之一是"自动穿越不关心错误的中间层",而and_then链式调用也能做到这一点——只是以显式的类型系统方式而非隐式的控制流方式。但异常在构造函数失败表达上的优势仍然不可替代:expected需要工厂函数绕过构造函数的返回值限制,而异常直接从构造函数内部报告失败。
optional 与 expected 的区别¶
std::optional<T> 表达“可能没有值”,但不表达“为什么没有值”。
| 类型 | 表达能力 | 适合场景 |
|---|---|---|
std::optional<T> |
有值 / 无值 | 查找结果不存在、可接受的空结果 |
std::expected<T, E> |
成功值 / 失败原因 | 配置解析、IO、可诊断失败 |
KISS-ICP 等现代 SLAM 代码中常用 optional 表达“可能没有匹配结果”这类正常分支。配置加载失败则更适合 expected 或异常,因为调用者需要知道失败原因。
三种接口的对比¶
| 维度 | 异常 | 错误码 | expected 风格 |
|---|---|---|---|
| 调用点简洁度 | 高 | 中 | 中 |
| 错误上下文 | 强 | 取决于错误结构 | 强 |
| 调用者是否容易忽略 | 不易忽略,但可能捕获过早 | 容易忽略,需 [[nodiscard]] |
配合 [[nodiscard]] 不易忽略 |
| 跨 C ABI | 不适合 | 适合 | 需要转换 |
| 实时敏感路径 | 通常避免 | 适合 | 适合 |
| 构造函数失败 | 很适合 | 不自然 | 可用工厂函数返回 |
| 多层传播 | 自动 | 手写转发 | 显式链式传播 |
选择建议:
启动初始化、构造失败、库内部罕见错误
→ 异常
底层 C 接口、硬件驱动、实时敏感路径
→ 错误码或 expected
业务上可预期失败且需要携带原因
→ expected 风格
只是“没有结果”而非错误
→ optional
异常与错误码的混合边界¶
真实的机器人软件几乎不可能只使用一种错误处理方式。C++ 核心代码使用异常和 RAII,但外层需要暴露 C ABI 接口给 Python 绑定、ROS 节点或硬件驱动;内层算法可能使用异常来报告配置错误,但回调函数中不允许异常逃出执行器边界。因此,混合边界是常态而非例外。
混合边界的核心设计原则是:异常不能穿越不可控的边界。C ABI 没有异常传播机制,异常逃出 extern "C" 函数是未定义行为。线程入口函数如果不捕获异常,会导致 std::terminate()。ROS2 回调如果抛异常,可能破坏执行器的内部状态。因此,每个边界都需要一个"翻译层"——在边界内侧捕获异常,翻译成边界外侧能理解的错误表达。
真实工程常常混合使用:
extern "C" int create_camera(const char* path, CameraHandle** out) noexcept {
if (out == nullptr) {
return -1;
}
try {
auto camera = std::make_unique<CameraModel>(path);
*out = reinterpret_cast<CameraHandle*>(camera.release());
return 0;
} catch (const std::exception& e) {
logErrorNoThrow(e.what());
*out = nullptr;
return -2;
} catch (...) {
logErrorNoThrow("unknown error");
*out = nullptr;
return -3;
}
}
这里内部可以使用异常,因为 C++ 构造函数和 RAII 很自然;但 extern "C" 边界不能让异常逃出,所以边界函数捕获并转换成整数错误码。
⚠️ 常见陷阱¶
⚠️ 编程陷阱:错误码返回值没有
[[nodiscard]]错误做法:
Error load(Config& out);调用者可以直接忽略返回值。现象:加载失败后继续使用默认配置,系统行为异常但没有明显失败点。
正确做法:给错误返回类型或函数加
[[nodiscard]],让编译器提醒未检查错误。⚠️ 编程陷阱:用
optional隐藏诊断信息错误做法:
std::optional<Config> loadConfig(path)。现象:失败时只有
nullopt,无法定位是文件、格式还是字段问题。正确做法:只有“无结果是正常情况”时用
optional;需要诊断时用异常或expected。💡 思维陷阱:项目必须统一成一种错误处理风格
实际上:同一个项目可以在不同边界使用不同风格。统一的是策略规则,不是语法形式。
正确做法:写清楚边界:模块内部可用异常,实时回调返回状态,C ABI 捕获并转错误码。
练习¶
- 接口改写:把异常版
loadCameraConfigOrThrow()改写成std::expected<CameraConfig, ConfigError>版本。 - 边界题:为一个 C 插件接口
int init_slam(const char* config)写 C++ 包装,要求内部可抛异常,但异常不能逃出接口。 - 讨论题:如果团队编译选项包含
-fno-exceptions,构造失败应该如何表达?比较工厂函数返回expected<std::unique_ptr<T>, E>与两阶段初始化。
8.7 第三方库与系统边界:OpenCV、线程、C ABI、ROS2 ⭐⭐⭐¶
动机:异常最容易在边界失控¶
在纯 C++ 函数内部,异常、RAII 和栈展开配合良好。但机器人系统不是纯 C++ 小程序,它会调用 OpenCV、PCL、CUDA、C 驱动、ROS2 执行器和自建线程。边界越多,越不能假设异常可以随意穿透。
常见边界:
| 边界 | 风险 | 建议 |
|---|---|---|
| OpenCV API | 可能抛 cv::Exception,也可能返回空对象 |
立即检查返回值,在模块边界捕获 |
| C ABI | C 代码不知道 C++ 异常 | 不让异常逃出 extern "C" |
| 线程入口 | 异常逃出线程函数会终止 | 在线程顶层捕获 |
| ROS2 回调 | 异常逃出回调会影响执行器稳定性 | 回调内部捕获并转状态 |
| 实时敏感循环 | 栈展开延迟不易界定 | 避免异常传播 |
OpenCV:既有异常,也有空返回¶
OpenCV 的错误风格并不单一。
| API 类型 | 典型失败表现 | 示例处理 |
|---|---|---|
| 图像读取 | 常返回空 cv::Mat |
检查 image.empty() |
| 参数错误、内部断言 | 抛 cv::Exception |
catch (const cv::Exception&) |
FileStorage 打开 |
可通过 isOpened() 检查,解析错误可能抛 |
打开后检查,外层捕获 |
示例:
#include <opencv2/core.hpp>
#include <opencv2/imgcodecs.hpp>
cv::Mat loadImageOrThrow(const std::filesystem::path& path) {
cv::Mat image = cv::imread(path.string(), cv::IMREAD_GRAYSCALE);
if (image.empty()) {
throw std::runtime_error("cannot read image: " + path.string());
}
return image;
}
配置读取:
#include <opencv2/core.hpp>
CameraConfig loadOpenCvConfigOrThrow(const std::filesystem::path& path) {
try {
cv::FileStorage fs(path.string(), cv::FileStorage::READ);
if (!fs.isOpened()) {
throw std::runtime_error("cannot open OpenCV config: " + path.string());
}
CameraConfig cfg;
cfg.fx = static_cast<double>(fs["Camera.fx"]);
cfg.fy = static_cast<double>(fs["Camera.fy"]);
cfg.cx = static_cast<double>(fs["Camera.cx"]);
cfg.cy = static_cast<double>(fs["Camera.cy"]);
if (cfg.fx <= 0.0 || cfg.fy <= 0.0) {
throw std::invalid_argument("Camera.fx and Camera.fy must be positive");
}
return cfg;
} catch (const cv::Exception& e) {
throw std::runtime_error(
"OpenCV failed while reading '" + path.string() + "': " + e.what());
}
}
ORB-SLAM3 一类代码常见 std::cerr 加返回值的风格;OpenCV 内部则可能抛 cv::Exception。因此“项目自己不抛异常”不等于“程序不会遇到异常”。只要调用会抛异常的库,就要在合适边界处理。
文件 IO:流状态不是异常¶
C++ iostream 默认不会在打开失败时抛异常,而是设置状态位:
std::ifstream input(path);
if (!input) {
throw std::runtime_error("cannot open file: " + path.string());
}
也可以开启异常掩码:
工程上更常见的是显式检查状态,因为它更容易控制错误消息。对大型二进制地图文件,读取后还应检查实际读到的字节数:
std::vector<std::byte> readAllBytesOrThrow(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
if (!input) {
throw std::runtime_error("cannot open file: " + path.string());
}
input.seekg(0, std::ios::end);
const auto end = input.tellg();
if (end < 0) {
throw std::runtime_error("cannot get file size: " + path.string());
}
input.seekg(0, std::ios::beg);
if (!input) {
throw std::runtime_error("cannot seek file begin: " + path.string());
}
const auto size = static_cast<std::size_t>(end);
if (size >
static_cast<std::size_t>(std::numeric_limits<std::streamsize>::max())) {
throw std::runtime_error("file is too large for one stream read: " +
path.string());
}
std::vector<std::byte> bytes(size);
input.read(reinterpret_cast<char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
if (input.bad()) {
throw std::runtime_error("read failed: " + path.string());
}
if (input.gcount() != static_cast<std::streamsize>(bytes.size())) {
throw std::runtime_error("short read: " + path.string());
}
return bytes;
}
这里的异常属于初始化和 IO 层,不属于实时控制层。
文件 IO 的错误状态有多个层次:badbit 通常表示底层读失败,eofbit 可以和短读同时出现,failbit 也可能来自前面的定位操作。
因此读二进制文件时不要只看 !input,还要核对实际读取字节数。
这类边界检查看起来啰嗦,但它把“文件损坏”“权限错误”“读取不完整”区分开,调试日志才会有意义。
C ABI 边界:不能让异常逃出¶
extern "C" 函数、C 回调、动态库插件入口都应视为异常边界。C 语言没有 C++ 异常语义,异常穿过这类边界没有可移植保证,轻则资源清理不完整,重则直接终止。
正确模式:
extern "C" int process_frame(const ImageView* image) noexcept {
try {
if (image == nullptr) {
return -1;
}
processFrameCpp(*image);
return 0;
} catch (const std::exception& e) {
logErrorNoThrow(e.what());
return -2;
} catch (...) {
logErrorNoThrow("unknown C++ exception");
return -3;
}
}
边界函数本身标记 noexcept 是合理的,因为它捕获所有异常并转换为错误码。关键是内部必须真的兜住所有异常。
线程边界:异常不能逃出线程函数¶
如果异常逃出 std::thread 的入口函数,程序会调用 std::terminate()。线程没有自动把异常传回创建者的机制。
错误写法:
正确写法:
std::exception_ptr mapping_error;
std::mutex error_mutex;
std::thread mapping_thread([&] {
try {
runMappingLoop();
} catch (...) {
std::lock_guard<std::mutex> lock(error_mutex);
mapping_error = std::current_exception();
}
});
mapping_thread.join();
if (mapping_error) {
std::rethrow_exception(mapping_error);
}
这个模式把线程内异常保存为 std::exception_ptr,在主线程汇合点重新抛出。它适合初始化任务、离线处理任务、后台加载任务。对长期运行的机器人节点,更常见的是转换成状态机错误并触发停机或重启。
ROS2 回调边界:回调内处理,节点状态外显¶
ROS2 C++ 节点中的订阅回调、定时器回调、服务回调通常由 executor 调用。异常逃出回调会让控制权进入执行器实现和用户 spin() 外层,结果取决于调用方式和外层是否捕获。工程上不应依赖执行器替你定义恢复策略。
建议模式:
void SlamNode::imageCallback(const sensor_msgs::msg::Image::SharedPtr msg) {
try {
auto frame = convertImage(*msg);
tracker_.track(frame);
} catch (const cv::Exception& e) {
RCLCPP_ERROR(get_logger(), "OpenCV error in image callback: %s", e.what());
diagnostics_.reportFrameDrop("opencv_error");
} catch (const std::exception& e) {
RCLCPP_ERROR(get_logger(), "image callback failed: %s", e.what());
diagnostics_.reportFrameDrop("callback_exception");
}
}
服务回调可以把错误写进响应:
void SlamNode::saveMapCallback(
const SaveMap::Request::SharedPtr request,
SaveMap::Response::SharedPtr response) {
try {
map_.save(request->path);
response->success = true;
} catch (const std::exception& e) {
response->success = false;
response->message = e.what();
}
}
这里的原则是:回调是边界,边界内捕获;错误通过日志、诊断状态、服务响应或节点生命周期状态显式外传。
实时敏感路径的分界¶
本章只讲错误处理策略,不展开实时调度、优先级反转、锁竞争和内存预分配;这些内容放到后续实时 C++ 章节。这里需要记住一条边界:
| 层级 | 异常策略 |
|---|---|
| 启动配置 | 可以使用异常,失败后停止启动 |
| 离线工具 | 可以使用异常,顶层捕获并报告 |
| 普通 ROS2 回调 | 回调内捕获,转换为状态或响应 |
| 实时控制循环 | 避免异常传播,使用预分配状态码或错误通道 |
| C/硬件驱动边界 | 不让异常逃出,转换为错误码 |
反事实思考:如果在硬实时控制循环中抛异常会怎样?
- 栈展开要执行多个析构函数,路径长度随当前调用栈和局部对象变化。
- 异常对象和错误消息可能触发分配。
- 最坏情况延迟难以界定。
- 回调边界不一定能保持控制器状态一致。
因此实时路径通常把“错误”设计成状态机输入,而不是异常传播。
⚠️ 常见陷阱¶
⚠️ 编程陷阱:以为项目代码不抛异常,第三方库就不会抛
错误做法:调用 OpenCV、文件系统、解析库时完全不写边界捕获。
现象:配置文件损坏或图像格式异常时进程直接退出。
正确做法:在 IO、配置、回调、线程顶层捕获第三方库异常,并转换为项目错误。
⚠️ 编程陷阱:异常逃出线程函数
错误做法:线程入口直接调用可能抛异常的函数。
现象:后台线程出现错误时整个进程终止,主线程没有机会记录上下文。
正确做法:线程入口最外层
try/catch,保存exception_ptr或更新错误状态。⚠️ 编程陷阱:异常穿过 C ABI
错误做法:
extern "C"函数内部直接调用 C++ 代码,异常向外传播。现象:行为不可移植,可能绕过 C 侧清理逻辑。
正确做法:C ABI 函数标记
noexcept,内部捕获所有异常并返回错误码。
练习¶
- OpenCV 题:写一个
loadCalibration(),使用cv::FileStorage读取Camera.fx,要求捕获cv::Exception并补上文件路径。 - 线程题:把一个后台地图加载线程改写为
exception_ptr传播错误的版本。 - ROS2 题:为订阅回调设计错误处理策略:图像为空、OpenCV 解码失败、跟踪器内部状态错误分别如何处理?
8.8 SLAM 项目中的错误处理边界:从启动到运行 ⭐⭐¶
动机:SLAM 错误不是只发生在一个函数里¶
一个 SLAM 系统通常有以下阶段:
启动阶段
├── 解析命令行
├── 加载配置
├── 加载词袋 / 地图 / 标定
└── 构造传感器与算法模块
运行阶段
├── 接收图像 / 点云 / IMU
├── 前端跟踪
├── 局部建图
├── 回环检测
└── 后端优化
退出阶段
├── 停止线程
├── 保存地图
└── 释放设备与文件
每个阶段的错误策略不同。启动阶段失败通常应终止启动;运行阶段某一帧失败可能只丢帧;退出阶段失败需要报告但不能让析构函数抛异常。
启动阶段:异常很自然¶
class SlamSystem {
public:
explicit SlamSystem(const std::filesystem::path& config_path)
: config_(loadCameraConfigOrThrow(config_path))
, camera_(config_)
, vocabulary_(config_.vocabulary_path)
, tracker_(camera_, vocabulary_) {}
private:
CameraConfig config_;
CameraModel camera_;
Vocabulary vocabulary_;
Tracker tracker_;
};
这个构造顺序表达了强不变量:SlamSystem 构造完成后,配置、相机、词袋、跟踪器都可用。如果任一步失败,系统对象不存在,已构造成员自动析构。
这比下面的设计更安全:
SlamSystem system;
system.loadConfig(path);
system.initCamera();
system.initVocabulary();
system.initTracker();
后者每一步都可能失败,调用者必须记住顺序和检查结果。一步漏检,系统就进入半初始化状态。
运行阶段:帧级失败通常不应摧毁整个节点¶
运行时失败要按严重性区分:
| 失败 | 示例 | 建议 |
|---|---|---|
| 单帧质量问题 | 图像为空、点云太少 | 丢帧,记录统计 |
| 短时传感器问题 | 时间戳跳变、消息延迟 | 降级或等待同步 |
| 状态不一致 | 地图索引损坏 | 停止模块,报告严重错误 |
| 不可恢复错误 | 内存耗尽、关键线程失败 | 停止节点或进程 |
帧级接口可以用 expected 风格:
enum class TrackErrorCode {
EmptyImage,
NotEnoughFeatures,
TimestampOutOfOrder,
InternalError
};
struct TrackError {
TrackErrorCode code;
std::string message;
};
std::expected<PoseEstimate, TrackError>
Tracker::track(const Frame& frame) {
if (frame.image.empty()) {
return std::unexpected(TrackError{
TrackErrorCode::EmptyImage,
"empty image"});
}
auto features = extractFeatures(frame.image);
if (features.size() < min_features_) {
return std::unexpected(TrackError{
TrackErrorCode::NotEnoughFeatures,
"not enough features"});
}
return estimatePose(features);
}
调用者可以按错误类型决定是否丢帧、重定位或停止。
退出阶段:显式保存,析构兜底¶
保存地图是业务动作,不应只放在析构函数里:
class MapServer {
public:
void saveOrThrow(const std::filesystem::path& path) const {
std::ofstream output(path, std::ios::binary);
if (!output) {
throw std::runtime_error("cannot open map file: " + path.string());
}
writeMap(output);
if (!output) {
throw std::runtime_error("failed to write map: " + path.string());
}
}
~MapServer() noexcept {
// 只释放资源,不报告业务失败
}
private:
void writeMap(std::ostream& output) const;
};
析构函数负责释放资源;保存失败应由显式 saveOrThrow() 或 save() 返回值报告。这样调用者能决定重试路径、提示用户或保存到临时文件。
ORB-SLAM 风格、OpenCV 风格、嵌入式风格¶
SLAM 代码库常见三类风格:
| 风格 | 代表场景 | 优点 | 风险 |
|---|---|---|---|
cerr + 返回值 |
传统研究代码、ORB-SLAM 系列风格 | 简单,便于快速实验 | 调用者可能忽略,错误结构弱 |
| 异常 | OpenCV、现代 C++ 初始化层 | 上下文传播自然,构造失败表达清楚 | 边界处理不当会终止 |
| 禁用异常 | 嵌入式、实时敏感构建 | 二进制和延迟更可控 | 需要系统性替代表达,不能混用抛异常接口 |
使用 -fno-exceptions 时,代码不能抛异常,也不能依赖异常传播。需要注意:
- 标准库和第三方库是否支持无异常构建要逐一确认。
new失败的行为和库配置有关,工程上应避免在关键路径动态分配。- 构造失败常用工厂函数返回错误对象,例如
expected<std::unique_ptr<T>, Error>。 - 接口文档必须写清楚失败返回值,不能只靠日志。
本章不深入讨论无异常构建的工具链细节,只强调策略:如果禁用异常,必须用类型系统补回错误信息,而不是退回到零散日志。
配置解析案例:从文件到强类型对象¶
一个稳健的配置加载流程:
读取文件
│
├── 文件打不开 → FileNotFound
▼
解析格式
│
├── YAML / XML 损坏 → ParseError
▼
读取字段
│
├── 缺字段 → MissingField(field)
├── 类型不对 → TypeMismatch(field)
▼
检查数值
│
├── 范围不合法 → InvalidValue(field, value, constraint)
▼
构造强类型 Config
配置对象应尽量强类型:
struct ImageSize {
int width;
int height;
};
struct CameraIntrinsics {
double fx;
double fy;
double cx;
double cy;
ImageSize image_size;
};
CameraIntrinsics makeIntrinsics(double fx,
double fy,
double cx,
double cy,
int width,
int height) {
if (fx <= 0.0 || fy <= 0.0) {
throw std::invalid_argument("focal length must be positive");
}
if (width <= 0 || height <= 0) {
throw std::invalid_argument("image size must be positive");
}
return {fx, fy, cx, cy, {width, height}};
}
把检查放进构造函数或工厂函数后,后面的算法不再一遍遍判断“焦距是否为正”。这和 现代类设计与特殊成员函数 的类不变量、RAII与智能指针 的 RAII 思想一致:越早建立不变量,后面代码越简单。
⚠️ 常见陷阱¶
⚠️ 编程陷阱:在运行时回调里直接构造会抛异常的大对象
错误做法:每帧回调里重新打开配置、构造模型、分配大型缓存。
现象:偶发 IO 错误或分配失败直接影响运行时稳定性。
正确做法:启动阶段完成配置和资源构造;运行阶段只处理帧级可恢复错误。
⚠️ 编程陷阱:保存地图只依赖析构函数
错误做法:
~MapServer()中保存地图并试图报告失败。现象:析构失败无法可靠传给调用者,异常还可能导致终止。
正确做法:提供显式
save()/saveOrThrow(),析构函数只释放资源。💡 思维陷阱:不用异常就等于更可靠
实际上:不用异常只是不使用一种传播机制。如果错误码被忽略、日志不结构化、状态不外显,可靠性反而更差。
正确做法:无异常风格也要有强类型错误、
[[nodiscard]]、边界转换和清晰状态机。
练习¶
- 系统设计题:为 Mini-LIO 划分启动、运行、退出三个阶段,并为每个阶段列出推荐错误处理方式。
- 配置题:实现
makeIntrinsics()的expected风格版本,错误中包含字段名和约束描述。 - 边界题:某项目内部使用异常,但 ROS2 回调不能让异常逃出。画出从
Tracker::track()到回调日志的错误转换流程。
8.9 异常安全编码清单:让代码默认站在安全一边 ⭐⭐¶
动机:异常安全不是最后加上的检查¶
异常安全的代码不是在每个函数末尾加一个 try/catch。更好的做法是让类型和局部结构天然安全:
| 设计动作 | 效果 |
|---|---|
| 资源立即进入 RAII 对象 | 异常路径自动释放 |
| 先构造临时对象,后提交 | 更容易提供强保证 |
| 移动、析构、交换保持不抛 | 容器和回滚更可靠 |
| 错误边界集中捕获 | 避免异常穿过危险边界 |
| 失败信息结构化 | 排查更快 |
异常安全像机械结构的冗余设计:不是事故发生后才临时焊一根支架,而是在受力路径设计时就让载荷有明确传递路线。
局部编码规则¶
- 不要裸拥有资源。
// 避免
auto* buffer = new double[n];
// 推荐
std::vector<double> buffer(n);
auto ptr = std::make_unique<Sensor>();
- 不要在资源获取和 RAII 接管之间插入可能抛异常的代码。
// 避免:new T() 成功后,emplace_back 可能扩容抛异常,裸指针泄漏
std::vector<std::unique_ptr<T>> items;
items.emplace_back(new T());
// 推荐
items.push_back(std::make_unique<T>());
- 提交前完成所有可能失败的准备工作。
void renameTopic(std::string new_name) {
validateTopicName(new_name); // 可能抛,状态未变
topic_name_ = std::move(new_name); // 提交
}
- 在边界捕获,不在底层乱捕获。
int main() {
try {
return runApplication();
} catch (const std::exception& e) {
std::cerr << "fatal: " << e.what() << "\n";
return 1;
}
}
- 析构函数中不让异常逃出。
类设计规则¶
| 类成员 | 建议 |
|---|---|
| 构造函数 | 建立完整不变量;失败时抛异常或使用工厂返回错误 |
| 析构函数 | noexcept,只清理资源 |
| 拷贝操作 | 明确深拷贝语义;失败时保持原对象可用 |
| 移动操作 | 尽量 noexcept,移动后源对象可析构、可赋值 |
swap |
尽量 noexcept,支撑强保证 |
| 普通成员函数 | 文档化异常安全保证 |
小型异常安全示例:参数更新¶
假设运行时可以更新跟踪器参数:
struct TrackerParams {
int max_features = 1000;
double min_response = 0.01;
};
class Tracker {
public:
void updateParams(TrackerParams params) {
validate(params); // 可能抛异常,旧参数未变
params_ = params; // 简单赋值,提交
rebuildThresholds(); // 如果这里可能抛异常,强保证被破坏
}
private:
void validate(const TrackerParams& params) const {
if (params.max_features <= 0) {
throw std::invalid_argument("max_features must be positive");
}
if (params.min_response < 0.0) {
throw std::invalid_argument("min_response must be non-negative");
}
}
TrackerParams params_;
};
为了强保证,应先在临时对象中构造派生数据:
class Tracker {
public:
void updateParams(TrackerParams params) {
validate(params);
auto new_thresholds = buildThresholds(params); // 可能抛,旧状态未变
params_ = params;
thresholds_ = std::move(new_thresholds); // 提交
}
private:
std::vector<double> buildThresholds(const TrackerParams& params) const;
void validate(const TrackerParams& params) const;
TrackerParams params_;
std::vector<double> thresholds_;
};
⚠️ 常见陷阱¶
⚠️ 编程陷阱:
emplace_back(new T())导致异常路径泄漏错误做法:把裸指针直接传给容器中的
unique_ptr构造。现象:如果容器扩容先发生并抛异常,裸指针没有进入
unique_ptr。正确做法:先用
std::make_unique<T>()创建 RAII 对象,再放入容器。⚠️ 编程陷阱:更新配置时先改状态再验证
错误做法:先写入成员变量,再检查合法性。
现象:验证失败时对象已经进入非法状态。
正确做法:先验证临时对象,完成可能失败的计算,再提交。
💡 思维陷阱:用
try/catch包住一切就是异常安全实际上:捕获异常只能改变传播路径,不能自动恢复对象不变量。
正确做法:用 RAII 和提交顺序让失败路径天然安全。
练习¶
- 代码修复:找出
emplace_back(new T())的异常安全问题,并改成make_unique写法。 - 保证标注题:为一个
Tracker::updateParams()写文档注释,说明它提供基本保证还是强保证。 - 综合题:设计一个
RuntimeConfigManager,支持加载新配置、验证、原子替换旧配置。要求失败后旧配置仍可用。
本章小结¶
| 知识点 | 核心要义 | 难度 |
|---|---|---|
| 错误分类 | 先区分可预期错误、程序 bug、边界错误和实时路径错误,再选择表达方式 | ⭐⭐ |
| 异常机制 | throw 创建异常对象,沿调用栈传播,按 catch 顺序匹配 |
⭐⭐ |
| 栈展开 | 异常传播时,已构造的局部对象按逆序析构,RAII 因此释放资源 | ⭐⭐ |
| 构造函数异常 | 构造失败表示对象不存在;已构造成员会析构,外层析构函数不执行 | ⭐⭐⭐ |
| 析构函数边界 | 析构函数不应抛异常;栈展开中析构再抛会 std::terminate() |
⭐⭐⭐ |
| 异常安全保证 | 基本保证保有效,强保证保状态不变,无异常保证承诺不抛 | ⭐⭐⭐ |
noexcept |
是接口承诺,不是优化咒语;错误标注会把异常变成终止 | ⭐⭐⭐ |
std::move_if_noexcept |
标准库根据移动是否不抛,在性能和强保证之间选择 | ⭐⭐ |
| 错误码 | 适合 C ABI、底层驱动、实时敏感路径,但需要 [[nodiscard]] 和结构化错误 |
⭐⭐ |
expected 风格 |
把成功值和失败原因放进返回类型,适合可预期且需要诊断的失败 | ⭐⭐⭐ |
| OpenCV 边界 | 既可能返回空对象,也可能抛 cv::Exception,需要双重处理 |
⭐⭐ |
| ROS2 回调 | 回调内捕获异常,转换为日志、诊断状态或响应,不让异常逃出边界 | ⭐⭐ |
一句话总结:异常负责把罕见失败从当前层带到能处理的边界,RAII 负责在传播过程中释放资源,异常安全保证负责说明失败后对象状态,而 noexcept 负责标出绝不能失败的基础操作。机器人系统中的关键不是坚持某一种语法,而是在启动、运行、实时路径和外部边界上使用一致的错误策略。
累积项目:本章新增模块¶
项目:Mini-LIO(从零构建轻量级 LiDAR-Inertial Odometry)
本章新增:异常安全的配置加载与错误边界模块
| 章节 | 模块 | 状态 |
|---|---|---|
| 类型系统与值类别推导 | 类型安全的传感器数据类型定义 | 已完成 |
| 编译模型基础 | 编译单元拆分与头文件组织 | 已完成 |
| 现代类设计与特殊成员函数 | Frame/MapPoint 类设计 | 已完成 |
| RAII与智能指针 | 智能指针重构与 ScopedTimer | 已完成 |
| 移动语义与完美转发 | 移动语义优化的点云管线 | 已完成 |
| 错误处理与异常安全 | 配置加载、错误传播、边界捕获 | 本章 |
本章任务:
- 实现
ConfigError与ConfigErrorCode:覆盖文件不存在、解析失败、缺字段、类型错误、数值非法。 - 实现异常版配置加载器:
CameraConfig loadCameraConfigOrThrow(path),用于启动阶段。 - 实现
expected风格配置加载器:std::expected<CameraConfig, ConfigError> loadCameraConfig(path),用于可恢复工具或无异常风格模块。 - 为 ROS2 回调增加边界捕获:图像为空时丢帧,OpenCV 异常时记录诊断,内部严重错误时切换节点状态。
- 为资源类补齐
noexcept移动和析构:ScopedTimer、ScopedFd、点云缓冲区移动操作都应检查标注是否真实。
建议目录:
mini_lio/
include/mini_lio/config.hpp
include/mini_lio/error.hpp
src/config.cpp
src/slam_node.cpp
tests/config_test.cpp
关键检查:
- 配置文件缺失时,错误信息包含路径。
- 字段缺失时,错误信息包含字段名。
- 非法数值不会进入
CameraConfig。 - ROS2 回调中异常不会逃出回调函数。
- 移动操作的
noexcept标注与成员类型一致。
延伸阅读¶
| 资源 | 内容 | 难度 |
|---|---|---|
| The C++ Programming Language(Bjarne Stroustrup)异常处理章节 | C++ 异常与 RAII 的设计背景 | ⭐⭐⭐ |
| Effective Modern C++ Item 14(Scott Meyers) | noexcept 的语义与移动操作关系 |
⭐⭐ |
| C++ Core Guidelines E.1-E.31 | 异常、错误码、RAII、析构函数准则 | ⭐⭐ |
| C++ Core Guidelines C.36-C.37、C.66、C.83-C.85 | 析构函数、移动操作、swap 的 noexcept 建议 |
⭐⭐ |
| cppreference: exception handling | throw、catch、栈展开、std::terminate 的语言规则 |
⭐⭐ |
cppreference: std::expected |
C++23 expected<T,E> 的接口与示例 |
⭐⭐⭐ |
| Herb Sutter, “GotW #8: Exception Safety” | 基本保证、强保证、无异常保证的经典讨论 | ⭐⭐⭐ |
OpenCV documentation: cv::Exception, cv::FileStorage |
OpenCV 异常与配置文件读取行为 | ⭐⭐ |
| ROS2 rclcpp examples and logging guides | 回调边界、日志和节点错误处理实践 | ⭐⭐ |
🔧 故障排查手册¶
| 症状 | 可能原因 | 排查步骤 | 相关章节 |
|---|---|---|---|
| 程序在配置文件错误时直接退出,没有清晰日志 | OpenCV 或解析库异常未在启动边界捕获 | 1. 在 main() 或节点启动处捕获 std::exception 2. 单独捕获 cv::Exception 3. 错误消息补充文件路径和字段名 |
错误处理与异常安全、软件工程/日志配置与序列化 |
std::vector<MyType> 扩容时性能异常差 |
MyType 移动构造未标 noexcept,容器退回拷贝 |
1. 检查移动构造和移动赋值签名 2. 检查成员移动是否不抛 3. 用计时器比较拷贝和移动次数 | 移动语义与完美转发、错误处理与异常安全 |
| 异常发生后地图索引偶尔查不到关键帧 | 多容器更新只完成一半,缺少强保证或回滚 | 1. 找到所有同时更新主容器和索引的函数 2. 先 reserve 或构造临时对象 3. 失败时用不抛操作回滚 | RAII与智能指针、错误处理与异常安全 |
| 后台线程出错时整个进程终止 | 异常逃出 std::thread 入口函数 |
1. 在线程入口最外层加 try/catch 2. 保存 std::exception_ptr 3. 在线程汇合点或状态机中处理 |
错误处理与异常安全、并发与系统编程/线程管理与互斥同步 |
| ROS2 回调偶发导致节点停止处理消息 | 回调内异常逃出 executor 调用路径 | 1. 在回调内部捕获 cv::Exception 和 std::exception 2. 将错误转换为日志、诊断或服务响应 3. 对严重错误切换节点状态 |
错误处理与异常安全、软件工程/ROS2高级集成 |
析构期间偶发 std::terminate() |
析构函数或析构中调用的函数抛异常 | 1. 检查析构函数是否调用会抛的日志、flush、close 2. 提供显式 closeOrThrow() 3. 析构函数内部捕获所有异常 |
RAII与智能指针、错误处理与异常安全 |
| C 插件接口调用 C++ 模块后崩溃 | C++ 异常穿过 extern "C" 或 C 回调边界 |
1. 给边界函数标 noexcept 2. 内部捕获所有异常 3. 转换为 C 错误码和不抛日志 |
错误处理与异常安全 |