跳转至

错误处理与异常安全

难度:⭐~⭐⭐⭐ | 建议用时:2周 | 前置要求:RAII与智能指针 RAII 与资源管理、移动语义与完美转发 移动语义与 noexcept


前置自测

📋 答不出 ≥ 2 题 → 先回顾 RAII与智能指针 和 移动语义与完美转发

  1. RAII 的核心保证是什么?异常发生时,std::lock_guard 为什么仍然会释放互斥锁?
  2. 移动语义与完美转发 中 std::vector 扩容时为什么更偏好 noexcept 移动构造?如果移动可能抛异常会发生什么?
  3. 构造函数失败时,对象的析构函数会不会执行?已经构造完成的成员对象会怎样?
  4. 为什么析构函数不应该向外抛异常?什么情况下会直接调用 std::terminate()
  5. 在 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

bool loadCameraConfig(const std::string& path, CameraConfig& out);

调用者只能知道“失败了”,但不知道失败原因:

CameraConfig config;
if (!loadCameraConfig("camera.yaml", config)) {
    std::cerr << "load config failed\n";
    return 1;
}

这个接口隐藏了至少四类信息:

  1. 文件不存在。
  2. 文件存在但 YAML 格式损坏。
  3. 字段存在但类型错误。
  4. 字段合法但数值不满足约束,例如焦距为负数。

如果后续系统在现场部署时启动失败,日志里只有一句 “load config failed”,排查人员只能重新编译、加日志、再部署。错误处理不是为了让程序“显得健壮”,而是为了让失败发生时系统能给出足够的信息。

本质洞察:错误处理的本质不是“把失败藏起来”,而是**把失败的类型、位置、严重性和恢复策略显式化**。异常、错误码、expected 风格只是三种表达工具,真正的设计对象是错误边界。

历史与设计原因:C++ 为什么同时保留多种错误表达

C 语言时代主要依靠返回值和全局错误状态:

方式 示例 优点 局限
返回错误码 int open(...) 返回 -1 ABI 简单,跨语言稳定 调用者容易忽略
全局错误状态 errno 不改变返回值类型 线程、组合和可读性问题
输出参数 bool parse(T& out) 避免异常 调用点冗长,错误信息有限

C++ 引入构造函数、析构函数、运算符重载和 RAII 后,单靠错误码出现了一个根本问题:构造函数没有返回值。如果 std::ifstreamstd::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::optionalstd::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。”

实际上:异常适合表达“当前层无法处理、需要越过多层调用栈的罕见失败”。如果一个分支是正常业务结果,例如“这一帧没有检测到特征点”,用异常会让正常控制流变得隐蔽。

正确做法:把“失败是否罕见、当前层能否处理、是否跨边界”作为选择依据。

练习

  1. 分类题:把以下失败分成“异常 / 错误码 / expected / 断言”四类:配置文件不存在、Camera* 为空、LiDAR 本帧点数为 0、std::vector 下标越界、标定外参矩阵不是刚体变换。
  2. 接口设计题:为 loadVocabulary(path) 设计三种接口:异常版、错误码版、expected 风格版。比较调用点可读性。
  3. 跨章综合题:结合 RAII与智能指针 的 RAII 和 移动语义与完美转发 的移动语义,设计一个 MapDatabase 类:构造时打开文件,移动时转移文件句柄,加载失败时给出可诊断错误。

8.2 C++ 异常机制:从 throwcatch ⭐⭐

异常是 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;
};

构造函数要读取配置文件、解析字段、检查数值范围。失败时它没有返回值可以写:

CameraModel::CameraModel(const std::string& yaml_path) {
    // 这里无法 return ErrorCode
}

可以把对象拆成“两阶段初始化”:

CameraModel camera;
if (!camera.init("camera.yaml")) {
    return 1;
}

但两阶段初始化让对象出现“半初始化状态”:默认构造后的 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_allocstd::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) {
        // 捕获标准异常基类
    }
}

关键规则:

  1. throw expr; 会创建异常对象,异常对象的生命周期由运行时管理。
  2. 异常沿调用栈向上传播,寻找第一个类型匹配的 catch
  3. catch 按书写顺序匹配,派生类应写在基类前。
  4. 通常用 catch (const T& e) 捕获,避免拷贝和对象切片。
  5. 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_errorruntime_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_errorstd::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 节点启动、线程函数顶层。

练习

  1. 代码修复:给一段 catch (std::exception e) 的代码改成正确捕获方式,并说明 throw;throw e; 的区别。
  2. 设计题:为 CameraModel 构造函数写出异常消息格式,要求包含配置路径、字段名、实际值。
  3. 分析题std::stod("abc") 会抛什么异常?std::vector<int>{}.at(0) 又会抛什么异常?它们分别适合在哪一层捕获?

8.3 栈展开:异常离开时,谁来收拾现场 ⭐⭐

栈展开(stack unwinding)是 C++ 异常机制中最精妙的部分。它解决的核心问题是:异常把控制流从函数中间直接带走,但函数中已经构造的局部对象仍然持有资源(内存、文件句柄、互斥锁、GPU 资源),这些资源必须被正确释放。栈展开保证了一个至关重要的语言承诺:无论控制流如何离开作用域(正常返回、异常、或到达作用域结尾),已构造的自动对象都会按构造的逆序析构

这个承诺是 RAII 和异常安全的基石。没有栈展开,RAII 就只能在正常路径上工作——lock_guard 在正常返回时能释放锁,但在异常路径上锁就永久泄漏了。有了栈展开,RAII 在所有控制流路径上都有效——这就是为什么 C++ 程序员敢于在 lock_guardunique_ptr 之间放可能抛异常的代码,而不需要像 C 程序员那样写大量的 goto cleanup 跳转。

动机:异常路径比正常路径更需要资源安全

回顾 RAII与智能指针:RAII 把资源释放绑定到对象生命周期。那里用 std::lock_guardstd::ifstreamstd::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/unlockfopen/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++ 只析构已经完整构造的对象。

构造函数抛异常时的栈展开

构造函数里抛异常时,规则更细:

  1. 对象本身没有构造完成,因此它的析构函数不会执行。
  2. 已经构造完成的基类子对象和成员子对象会按逆序析构。
  3. 尚未构造的成员不会析构。
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_ptrstd::fstream、自定义句柄类。

⚠️ 编程陷阱:catch (...) 后继续运行但状态未知

错误做法:捕获所有异常后只打印一行日志,然后继续使用可能已经部分更新的数据结构。

现象:后续错误位置远离真正失败点,地图状态、缓存状态或锁状态难以判断。

正确做法:只在明确边界捕获未知异常;捕获后要么转换成失败状态,要么停止当前任务,要么重新抛出。

💡 思维陷阱:认为异常安全只关心内存泄漏

实际上:异常安全还关心锁是否释放、对象不变量是否保持、容器状态是否可用、输出文件是否半写入、地图是否部分更新。

正确做法:把“异常发生后系统处于什么状态”作为设计问题,而不是只检查有没有 delete

练习

  1. 输出推演:为 Trace 示例增加第三层函数,手写异常抛出后的构造和析构顺序。
  2. 重构题:把一个 FILE* + malloc + mutex.lock() 的函数改成 ifstream + vector + lock_guard,说明异常路径释放顺序。
  3. 构造函数题:设计一个 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 在调用前后都成立,且操作一定完成。

验证一个函数提供哪级保证的实用方法:

  1. 列出所有可能抛异常的操作。标准容器的 push_backemplaceoperator new、拷贝构造都可能抛。swappop_back、移动 noexcept 类型的赋值通常不抛。
  2. 在每个抛异常点画一条”异常切割线”。线的左边是已经执行的操作,线的右边是还没执行的操作。
  3. 检查每条切割线处的状态:对象的不变量是否还成立?有没有已分配但未接管的资源?有没有已修改但未回滚的容器索引?
  4. 基本保证的最低要求:每条切割线处,对象可析构、可赋值、不变量成立。
  5. 强保证的要求:每条切割线处,对象状态与调用前完全相同。

这种分析方法在代码审查中非常实用。与其笼统地说”这个函数是异常安全的”,不如在文档或注释中标注每个可能的异常点和对应的状态。

三层保证的关系类似于建筑物的结构安全等级。基本保证相当于”地震后建筑不倒塌,可以安全撤离”——结构仍然有效,但内部装修可能已经改变。强保证相当于”地震后建筑完好如初,或者根本没有开工”——要么完全成功建成,要么回到空地状态。无异常保证相当于”这根承重柱绝对不会断”——它是其他保证的基础设施,如果它失败,整栋建筑的安全等级都无从谈起。不同的是,建筑安全等级由材料物理性质决定,而异常安全等级由程序员的代码结构决定——选择先构造临时对象还是原地修改,直接决定了保证等级。

如果所有操作都不声明异常安全保证会怎样?调用者无法推理失败后的状态。一个 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_;
};

基本保证要求:

  1. 没有资源泄漏。
  2. 所有成员对象仍满足自己的不变量。
  3. 类整体仍处于可析构、可赋值、可继续调用文档允许函数的状态。

它不要求操作前后状态相同。例如追加失败后缓冲区可能仍是原状态,也可能有某些内部容量变化,但对外可用。

强保证:先准备,后提交

强保证的通用模式是“先在临时对象中完成可能失败的工作,再用不抛异常的操作提交”。

修复 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()、指针交换、状态位恢复。

💡 概念误区:强保证总是更好

实际上:强保证常常需要复制、临时对象或额外内存。对大型地图和实时路径,基本保证加清晰恢复策略可能更实际。

正确做法:把强保证用在配置、事务式更新、外部可见状态切换;把基本保证用于成本过高但状态可保持有效的内部缓存。

练习

  1. 保证判断题:分析 std::vector::push_backstd::map::insert、自定义 Map::addKeyFrame 分别能提供哪一级异常安全保证。
  2. 代码题:把一个“更新向量 + 更新索引表”的函数改成强保证版本,要求失败后两个容器都回到调用前状态。
  3. 性能分析题:为什么在百万点云容器里追求所有操作强保证可能不划算?结合 移动语义与完美转发 的移动成本说明。

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();析构函数只做"兜底关闭",即使失败也只记录日志而不抛异常。这种双层设计让调用者可以在正常路径上获得详细的错误反馈,同时保证异常路径上析构函数的安全性。

常用方案:

  1. 析构函数中记录日志后吞掉异常
  2. 提供显式 close(),让调用者在正常路径处理错误
  3. 析构函数只做兜底清理,业务错误由显式提交函数返回
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 标注比不标注更危险:不标注时异常至少还有机会被上层捕获;标注了却违反承诺,结果是无条件的进程终止。

void mustNotThrow() noexcept {
    throw std::runtime_error("bad");  // 编译器可能警告;运行时会 terminate
}

如果异常逃出 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> 的移动构造不是 noexceptvector 扩容时会退回到拷贝策略。这种"承诺自动传播"的机制,让泛型容器能在不了解具体类型的情况下做出最优的异常安全决策。

noexceptstd::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));
}

其决策可以理解为:

T 的移动构造不抛?
  ├── 是 → 返回 T&&,使用移动
  └── 否
       ├── T 可拷贝 → 返回 const T&,使用拷贝
       └── T 不可拷贝 → 只能移动,风险由类型承担

这正是 移动语义与完美转发 中 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 会把可恢复错误变成进程终止。

练习

  1. 判断题:以下函数哪些应该标 noexcept:析构函数、loadYaml()、移动构造、swap()projectPoint()saveMap()。逐一说明理由。
  2. 代码题:实现一个 ScopedFd,构造时接管文件描述符,析构时关闭,支持移动但禁止拷贝。移动操作和析构函数应如何标注?
  3. 分析题:某类的移动构造内部会分配内存,它是否应该标 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;
};

目标是读取:

camera.fx
camera.fy
camera.cx
camera.cy
camera.width
camera.height

问题不是“哪种错误处理方式绝对正确”,而是“哪种接口最符合调用边界”。

方案一:异常版

异常版是最简洁的接口设计。函数返回值就是成功结果,失败通过异常传播。调用者不需要检查返回值——要么得到可用的 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]] 缓解:

[[nodiscard]] ConfigError loadCameraConfig(const std::filesystem::path& path,
                                           CameraConfig& out);

方案三: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::expectedoutcome 或项目内轻量封装。本章重点是风格,而不是依赖某个库。与错误码版相比,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_thentransformor_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 需要工厂函数绕过构造函数的返回值限制,而异常直接从构造函数内部报告失败。

optionalexpected 的区别

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 捕获并转错误码。

练习

  1. 接口改写:把异常版 loadCameraConfigOrThrow() 改写成 std::expected<CameraConfig, ConfigError> 版本。
  2. 边界题:为一个 C 插件接口 int init_slam(const char* config) 写 C++ 包装,要求内部可抛异常,但异常不能逃出接口。
  3. 讨论题:如果团队编译选项包含 -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::ifstream input;
input.exceptions(std::ios::badbit);
input.open(path);

工程上更常见的是显式检查状态,因为它更容易控制错误消息。对大型二进制地图文件,读取后还应检查实际读到的字节数:

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::thread mapping_thread([] {
    runMappingLoop();  // 抛异常时直接 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/硬件驱动边界 不让异常逃出,转换为错误码

反事实思考:如果在硬实时控制循环中抛异常会怎样?

  1. 栈展开要执行多个析构函数,路径长度随当前调用栈和局部对象变化。
  2. 异常对象和错误消息可能触发分配。
  3. 最坏情况延迟难以界定。
  4. 回调边界不一定能保持控制器状态一致。

因此实时路径通常把“错误”设计成状态机输入,而不是异常传播。

⚠️ 常见陷阱

⚠️ 编程陷阱:以为项目代码不抛异常,第三方库就不会抛

错误做法:调用 OpenCV、文件系统、解析库时完全不写边界捕获。

现象:配置文件损坏或图像格式异常时进程直接退出。

正确做法:在 IO、配置、回调、线程顶层捕获第三方库异常,并转换为项目错误。

⚠️ 编程陷阱:异常逃出线程函数

错误做法:线程入口直接调用可能抛异常的函数。

现象:后台线程出现错误时整个进程终止,主线程没有机会记录上下文。

正确做法:线程入口最外层 try/catch,保存 exception_ptr 或更新错误状态。

⚠️ 编程陷阱:异常穿过 C ABI

错误做法extern "C" 函数内部直接调用 C++ 代码,异常向外传播。

现象:行为不可移植,可能绕过 C 侧清理逻辑。

正确做法:C ABI 函数标记 noexcept,内部捕获所有异常并返回错误码。

练习

  1. OpenCV 题:写一个 loadCalibration(),使用 cv::FileStorage 读取 Camera.fx,要求捕获 cv::Exception 并补上文件路径。
  2. 线程题:把一个后台地图加载线程改写为 exception_ptr 传播错误的版本。
  3. 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]]、边界转换和清晰状态机。

练习

  1. 系统设计题:为 Mini-LIO 划分启动、运行、退出三个阶段,并为每个阶段列出推荐错误处理方式。
  2. 配置题:实现 makeIntrinsics()expected 风格版本,错误中包含字段名和约束描述。
  3. 边界题:某项目内部使用异常,但 ROS2 回调不能让异常逃出。画出从 Tracker::track() 到回调日志的错误转换流程。

8.9 异常安全编码清单:让代码默认站在安全一边 ⭐⭐

动机:异常安全不是最后加上的检查

异常安全的代码不是在每个函数末尾加一个 try/catch。更好的做法是让类型和局部结构天然安全:

设计动作 效果
资源立即进入 RAII 对象 异常路径自动释放
先构造临时对象,后提交 更容易提供强保证
移动、析构、交换保持不抛 容器和回滚更可靠
错误边界集中捕获 避免异常穿过危险边界
失败信息结构化 排查更快

异常安全像机械结构的冗余设计:不是事故发生后才临时焊一根支架,而是在受力路径设计时就让载荷有明确传递路线。

局部编码规则

  1. 不要裸拥有资源
// 避免
auto* buffer = new double[n];

// 推荐
std::vector<double> buffer(n);
auto ptr = std::make_unique<Sensor>();
  1. 不要在资源获取和 RAII 接管之间插入可能抛异常的代码
// 避免:new T() 成功后,emplace_back 可能扩容抛异常,裸指针泄漏
std::vector<std::unique_ptr<T>> items;
items.emplace_back(new T());

// 推荐
items.push_back(std::make_unique<T>());
  1. 提交前完成所有可能失败的准备工作
void renameTopic(std::string new_name) {
    validateTopicName(new_name);       // 可能抛,状态未变
    topic_name_ = std::move(new_name); // 提交
}
  1. 在边界捕获,不在底层乱捕获
int main() {
    try {
        return runApplication();
    } catch (const std::exception& e) {
        std::cerr << "fatal: " << e.what() << "\n";
        return 1;
    }
}
  1. 析构函数中不让异常逃出
~Resource() noexcept {
    try {
        release();
    } catch (...) {
        logErrorNoThrow("release failed");
    }
}

类设计规则

类成员 建议
构造函数 建立完整不变量;失败时抛异常或使用工厂返回错误
析构函数 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 和提交顺序让失败路径天然安全。

练习

  1. 代码修复:找出 emplace_back(new T()) 的异常安全问题,并改成 make_unique 写法。
  2. 保证标注题:为一个 Tracker::updateParams() 写文档注释,说明它提供基本保证还是强保证。
  3. 综合题:设计一个 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 已完成
移动语义与完美转发 移动语义优化的点云管线 已完成
错误处理与异常安全 配置加载、错误传播、边界捕获 本章

本章任务:

  1. 实现 ConfigErrorConfigErrorCode:覆盖文件不存在、解析失败、缺字段、类型错误、数值非法。
  2. 实现异常版配置加载器CameraConfig loadCameraConfigOrThrow(path),用于启动阶段。
  3. 实现 expected 风格配置加载器std::expected<CameraConfig, ConfigError> loadCameraConfig(path),用于可恢复工具或无异常风格模块。
  4. 为 ROS2 回调增加边界捕获:图像为空时丢帧,OpenCV 异常时记录诊断,内部严重错误时切换节点状态。
  5. 为资源类补齐 noexcept 移动和析构ScopedTimerScopedFd、点云缓冲区移动操作都应检查标注是否真实。

建议目录:

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 析构函数、移动操作、swapnoexcept 建议 ⭐⭐
cppreference: exception handling throwcatch、栈展开、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::Exceptionstd::exception 2. 将错误转换为日志、诊断或服务响应 3. 对严重错误切换节点状态 错误处理与异常安全、软件工程/ROS2高级集成
析构期间偶发 std::terminate() 析构函数或析构中调用的函数抛异常 1. 检查析构函数是否调用会抛的日志、flush、close 2. 提供显式 closeOrThrow() 3. 析构函数内部捕获所有异常 RAII与智能指针、错误处理与异常安全
C 插件接口调用 C++ 模块后崩溃 C++ 异常穿过 extern "C" 或 C 回调边界 1. 给边界函数标 noexcept 2. 内部捕获所有异常 3. 转换为 C 错误码和不抛日志 错误处理与异常安全