编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

    • 开发技巧

      • 信号崩溃快速排查
      • ASan内存三件套
      • GDB十命令速查
      • CoreDump破案
      • perf火焰图实战
      • 迭代器失效陷阱
      • 智能指针选型
      • 异常安全RAII
        • 1. 案例引入:两条主线
          • 1.1 主线一:转账崩盘
          • 1.2 主线二:32 行泄漏
          • 1.3 顺藤摸到根因
          • 1.4 本篇要回答什么
        • 2. 异常机制总图
          • 2.1 throw 的语义
          • 2.2 栈展开过程
          • 2.3 异常表与 DWARF
          • 2.4 双异常即终止
          • 2.5 异常的隐藏代价
        • 3. 异常安全三等级
          • 3.1 无保证:泄漏前提
          • 3.2 基本保证:不泄漏
          • 3.3 强保证:事务回滚
          • 3.4 不抛保证:noexcept
          • 3.5 等级对照与抉择
        • 4. RAII 核心机制
          • 4.1 RAII 的本质
          • 4.2 构造即获取
          • 4.3 析构即释放
          • 4.4 栈上对象的力量
          • 4.5 资源种类清单
        • 5. 智能指针武器库
          • 5.1 unique_ptr 独占
          • 5.2 shared_ptr 共享
          • 5.3 make 系列必选
          • 5.4 自定义删除器
          • 5.5 智能指针陷阱
        • 6. Copy-and-Swap 强保证
          • 6.1 朴素赋值的陷阱
          • 6.2 Copy-and-Swap 套路
          • 6.3 noexcept swap 是关键
          • 6.4 自赋值与移动赋值
          • 6.5 适用边界
        • 7. 移动语义与 noexcept
          • 7.1 移动构造的语义
          • 7.2 noexcept 决定优化
          • 7.3 五大函数的承诺
          • 7.4 析构函数禁抛
          • 7.5 noexcept 传染规则
        • 8. Scope Guard 终极清理
          • 8.1 非资源的清理需求
          • 8.2 Scope Guard 实现
          • 8.3 dismiss 提交语义
          • 8.4 现代写法 scope_exit
          • 8.5 与 RAII 的关系
        • 9. 多资源协同管理
          • 9.1 多分配的顺序陷阱
          • 9.2 函数参数求值序
          • 9.3 构造函数中的多资源
          • 9.4 容器的强保证条件
          • 9.5 PIMPL 与异常安全
        • 10. 五步设计方法论
          • 10.1 列出所有资源
          • 10.2 选择保证等级
          • 10.3 RAII 包装资源
          • 10.4 验证异常路径
          • 10.5 CI 自动化保证
        • 11. 典型场景速查
          • 11.1 文件 / socket / fd
          • 11.2 锁与临界区
          • 11.3 数据库事务
          • 11.4 容器修改的回滚
          • 11.5 工厂返回所有权
          • 11.6 C API 句柄包装
          • 11.7 内存池与对象池
        • 12. 工程化最佳实践
          • 12.1 默认基本保证
          • 12.2 noexcept 是承诺
          • 12.3 异常 vs 错误码
          • 12.4 团队规范六条
          • 12.5 lint 与 CI 兜底
          • 12.6 成熟度模型
        • 13. 综合案例串讲
          • 13.1 案例真相揭晓
          • 主线一:转账崩盘
          • 主线二:32 行泄漏
          • 13.2 一次异常的一生
          • 13.3 设计哲学回扣
          • 13.4 异常安全速查表
          • 13.5 思考题
      • 多线程锁选型
      • 编译期防御
  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 开发技巧
杨充
2026-06-15
目录

异常安全RAII

# 第29章:异常安全与 RAII

# 目录介绍

  • 1. 案例引入:两条主线
    • 1.1 主线一:转账崩盘
    • 1.2 主线二:32 行泄漏
    • 1.3 顺藤摸到根因
    • 1.4 本篇要回答什么
  • 2. 异常机制总图
    • 2.1 throw 的语义
    • 2.2 栈展开过程
    • 2.3 异常表与 DWARF
    • 2.4 双异常即终止
    • 2.5 异常的隐藏代价
  • 3. 异常安全三等级
    • 3.1 无保证:泄漏前提
    • 3.2 基本保证:不泄漏
    • 3.3 强保证:事务回滚
    • 3.4 不抛保证:noexcept
    • 3.5 等级对照与抉择
  • 4. RAII 核心机制
    • 4.1 RAII 的本质
    • 4.2 构造即获取
    • 4.3 析构即释放
    • 4.4 栈上对象的力量
    • 4.5 资源种类清单
  • 5. 智能指针武器库
    • 5.1 unique_ptr 独占
    • 5.2 shared_ptr 共享
    • 5.3 make 系列必选
    • 5.4 自定义删除器
    • 5.5 智能指针陷阱
  • 6. Copy-and-Swap 强保证
    • 6.1 朴素赋值的陷阱
    • 6.2 Copy-and-Swap 套路
    • 6.3 noexcept swap 是关键
    • 6.4 自赋值与移动赋值
    • 6.5 适用边界
  • 7. 移动语义与 noexcept
    • 7.1 移动构造的语义
    • 7.2 noexcept 决定优化
    • 7.3 五大函数的承诺
    • 7.4 析构函数禁抛
    • 7.5 noexcept 传染规则
  • 8. Scope Guard 终极清理
    • 8.1 非资源的清理需求
    • 8.2 Scope Guard 实现
    • 8.3 dismiss 提交语义
    • 8.4 现代写法 scope_exit
    • 8.5 与 RAII 的关系
  • 9. 多资源协同管理
    • 9.1 多分配的顺序陷阱
    • 9.2 函数参数求值序
    • 9.3 构造函数中的多资源
    • 9.4 容器的强保证条件
    • 9.5 PIMPL 与异常安全
  • 10. 五步设计方法论
    • 10.1 列出所有资源
    • 10.2 选择保证等级
    • 10.3 RAII 包装资源
    • 10.4 验证异常路径
    • 10.5 CI 自动化保证
  • 11. 典型场景速查
    • 11.1 文件 / socket / fd
    • 11.2 锁与临界区
    • 11.3 数据库事务
    • 11.4 容器修改的回滚
    • 11.5 工厂返回所有权
    • 11.6 C API 句柄包装
    • 11.7 内存池与对象池
  • 12. 工程化最佳实践
    • 12.1 默认基本保证
    • 12.2 noexcept 是承诺
    • 12.3 异常 vs 错误码
    • 12.4 团队规范六条
    • 12.5 lint 与 CI 兜底
    • 12.6 成熟度模型
  • 13. 综合案例串讲
    • 13.1 案例真相揭晓
    • 13.2 一次异常的一生
    • 13.3 设计哲学回扣
    • 13.4 异常安全速查表
    • 13.5 思考题

# 1. 案例引入:两条主线

异常安全这个话题,最忌讳"只讲三等级、不讲案例"。本篇用两条真实主线贯穿全文:一条来自生产环境的事故,一条来自 32 行的最小可复现代码。前者展示"复杂系统下的状态损坏全链路",后者展示"如何把 new/delete 配对在异常面前彻底失效"。

# 1.1 主线一:转账崩盘

某支付服务,对账偶发不平,差额永远是 0.01~10000 元的随机数。代码看起来无懈可击——一个最经典的"双账户转账":

// transfer.cpp —— 支付核心
class Account {
public:
    void deposit(Money m)  { balance_ += m; log_->write("+", m); }
    void withdraw(Money m) { balance_ -= m; log_->write("-", m); }
private:
    Money balance_;
    std::shared_ptr<TxnLog> log_;
};

void transfer(Account& from, Account& to, Money m) {
    from.withdraw(m);     // 第 1 步:扣款
    to.deposit(m);        // 第 2 步:到账
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

事故现象:

  • 测试环境(无 IO 故障注入):100% 通过
  • 生产环境(QPS 5 万、写日志偶发抛 IoError):对账每日偏差 12 笔

直觉怀疑:是不是数据库事务没开?打开日志:

2026-06-15 03:14:21 [transfer] from=A123 to=B456 amount=99.00
2026-06-15 03:14:21 [TxnLog] write failed: disk full
2026-06-15 03:14:22 [transfer] from=A123 to=B456 amount=99.00 OK
1
2
3

第一行 withdraw 已经把 A 的余额减了 99——但日志写盘抛了异常,transfer 函数直接被栈展开抛出函数了。重试时再扣一次。A 账户被扣了 198,B 账户只收到 99,差额 99 落到不平账上。

更要命的是:业务方看到 transfer 抛异常以为"什么都没发生"——from.withdraw 已经留下的副作用,被这种"乐观抛错"完全忽略。

直觉的盲点:

  • 表面上代码"看起来"是原子的(两行而已);
  • 但每一行内部都能抛——balance_ += m 不会抛(int 操作),log_->write(...) 会抛;
  • 异常一抛,第一步的副作用已发生,第二步永远不发生 —— 这是经典的异常不安全。

# 1.2 主线二:32 行泄漏

另一位同学发来求助:

"我就写了个工厂函数,跑 valgrind 报泄漏,可我明明 new 完都 delete 了,看不懂。"

打开他的项目,几十个文件——但触发泄漏的代码抽离出来就是 32 行:

// leak.cpp —— 全文第二条主线,32 行
#include <iostream>
#include <stdexcept>

struct Resource {
    int id;
    Resource(int i) : id(i) {
        std::cout << "alloc " << id << "\n";
        if (id == 2) throw std::runtime_error("bad init");  // ← 第二个抛
    }
    ~Resource() { std::cout << "free " << id << "\n"; }
};

struct Compound {
    Resource* a;
    Resource* b;
    Compound() {
        a = new Resource(1);     // ① 成功
        b = new Resource(2);     // ② 这里抛
        // ③ 永远到不了下面
    }
    ~Compound() {
        delete a;
        delete b;
    }
};

int main() {
    try {
        Compound c;              // ④ 构造异常,Compound 视为"未构造"
    } catch (const std::exception& e) {
        std::cerr << "caught: " << e.what() << "\n";
    }
    // ⑤ valgrind: definitely lost: 16 bytes
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

编译运行:

$ g++ -fsanitize=address leak.cpp -o leak
$ ./leak
alloc 1
alloc 2
caught: bad init

=================================================================
==12345==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 16 byte(s) in 1 object(s) allocated from:
    #0 operator new(unsigned long)
    #1 Compound::Compound() leak.cpp:18
1
2
3
4
5
6
7
8
9
10
11

两个现象非常扎眼:

  • alloc 1 打出来了,free 1 没打 —— Resource(1) 分配了但没析构;
  • ~Compound 完全没调 —— 构造抛异常的对象不会调析构。

好的调试,第一步永远是把问题简化到 MCVE。复杂工程里的资源泄漏无非三种:

  1. 构造途中抛——本案例,析构永远不跑;
  2. 多资源中间抛——第 9 章主题;
  3. 析构里抛——直接 std::terminate,第 7.4 节主题。

# 1.3 顺藤摸到根因

带着两条主线往下挖,至少藏着这些原理点:

① 异常是怎么抛出来的? 栈展开怎么走?                  → 第 2 章
② 异常安全的"基本/强/不抛"三个等级到底什么意思?        → 第 3 章
③ RAII 为什么是 C++ 的银弹? 凭什么能管所有资源?        → 第 4 章
④ unique_ptr / shared_ptr 怎么"零泄漏证明"?            → 第 5 章
⑤ Copy-and-Swap 凭什么能做到事务回滚?                  → 第 6 章
⑥ 移动构造为什么必须 noexcept?                         → 第 7 章
⑦ 非资源类的清理用什么模式?                            → 第 8 章
⑧ 多个资源在同一个函数里怎么避免半成功?                → 第 9 章
⑨ 大型工程怎么把这一切落地为团队规范?                  → 第 12 章
1
2
3
4
5
6
7
8
9

# 1.4 本篇要回答什么

层次 你将学到
原理层 异常机制、栈展开、.gcc_except_table、双异常 terminate
模型层 异常安全三等级、RAII 资源闭环、noexcept 承诺
工具层 unique_ptr / shared_ptr / make_xxx / 自定义删除器 / Scope Guard
方法层 Copy-and-Swap、构造异常、多资源协同、PIMPL 异常安全
工程层 异常 vs 错误码、团队规范、lint + CI 兜底、成熟度模型

📌 本篇定位:这是 C++ 开发技巧七件套的第八篇。前 7 篇讲"崩了怎么排查",本篇讲"怎么让程序在异常面前依然正确"——把"异常安全"从一个含糊的术语,落实到机制级保证、API 级承诺、工程级流程。读完本篇,再看任何 C++ 资源管理代码,都能立刻回答:"它达到了哪一级保证?哪一行会抛?抛了之后状态会损坏吗"。

# 2. 异常机制总图

进入"异常安全"之前,先把"异常本身怎么工作"搞清楚。99% 的"异常不安全"代码,根因都是对栈展开的机制理解不到位。

# 2.1 throw 的语义

throw std::runtime_error("oops");
1

throw 一条表达式做了三件事:

  1. 拷贝构造异常对象:在异常专用堆区(不是用户堆)分配空间,把表达式值拷过去;
  2. 查找匹配的 catch:从当前栈帧开始,沿 .gcc_except_table 表往上找匹配 catch 块;
  3. 栈展开:从抛出点开始,一帧一帧析构每个栈帧里的自动对象,直到走到 catch 帧。

关键认知:

  • 异常对象生命期由运行时管理——不在调用栈上,调用栈展开不会摧毁它;
  • 异常对象通常按值或引用接收——catch (const std::exception& e) 永远比 catch (std::exception e) 更优,避免切片;
  • throw;(无参) 重新抛出当前正在处理的异常,不是拷贝。
try { foo(); }
catch (const std::exception& e) {
    log(e.what());
    throw;          // 原样重抛,保留 dynamic type
    // throw e;     // 错!这会切片成 std::exception,丢失子类信息
}
1
2
3
4
5
6

# 2.2 栈展开过程

throw 到 catch 之间的栈帧,会按 LIFO 顺序销毁。每销毁一帧:

┌─────────────────────────────────────────────────┐
│ 1. 标记该帧为"展开中"                              │
│ 2. 找到该帧所有自动对象(局部变量)                  │
│ 3. 按构造逆序调用析构函数                           │
│ 4. 弹出该帧,跳到上一帧                            │
└─────────────────────────────────────────────────┘
1
2
3
4
5
6

ASCII 图示:

                  throw 抛出点
                       │
                       ▼
   ┌──────────────────────────────────┐
   │ 当前帧                              │
   │   局部对象 z (析构 z)               │ ← LIFO 析构
   │   局部对象 y (析构 y)               │
   │   局部对象 x (析构 x)               │
   └──────────────────────────────────┘
                       │ 上溯
                       ▼
   ┌──────────────────────────────────┐
   │ 调用者帧                            │
   │   局部 RAII 对象 (析构)             │
   │   ...                              │
   └──────────────────────────────────┘
                       │
                       ▼ 找到匹配的 try/catch
   ┌──────────────────────────────────┐
   │ catch (...) {                     │
   │   处理异常                          │
   │ }                                 │
   └──────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

RAII 的本质就是利用这个机制:自动对象的析构在展开时一定跑——所以把"释放资源"放进析构函数就等于"无论是否异常,资源都会被释放"。

# 2.3 异常表与 DWARF

栈展开靠两张表:

  • .eh_frame —— 每个 PC 对应的栈帧布局(和崩溃排查篇 8.6 节 DWARF 同源);
  • .gcc_except_table —— 每个 PC 对应的 landingpad(catch 或局部对象的清理代码)。

展开器(libgcc_s 的 _Unwind_* 系列函数)的工作流程:

1. 拿到当前 PC,查 .eh_frame_hdr → 找到当前 FDE
2. 跑 DWARF CFI 虚拟机,恢复上一帧寄存器
3. 拿到当前 PC,查 .gcc_except_table → 找到 landingpad
4. 跳转到 landingpad:执行析构 / 检查 catch type
5. 不匹配 → 继续步骤 1;匹配 → 进入 catch
1
2
3
4
5

生产构建警示:

  • -fno-exceptions:禁用异常,二进制变小,但 new 抛 bad_alloc 时变成 abort();
  • -fno-asynchronous-unwind-tables:禁 .eh_frame,连 backtrace() 都不能用;
  • strip 默认不移除 .eh_frame 和 .gcc_except_table——它们对异常运行时是必需的。

# 2.4 双异常即终止

栈展开过程中,如果某个析构函数自己抛了异常——程序立即调 std::terminate():

struct Bad {
    ~Bad() { throw std::runtime_error("dtor!"); }   // ❌ 析构里抛
};

void f() {
    Bad b;
    throw std::runtime_error("main!");              // 栈展开 → 调 ~Bad
    // → ~Bad 又抛 → std::terminate()
}
1
2
3
4
5
6
7
8
9

机制原因:展开器在调用析构函数时设了"已在展开中"标记。如果析构函数再抛,相当于"两个异常对象同时活着"——C++ 标准没法定义"该传播哪个",干脆 terminate 了事。

判断"现在是否在展开中":

struct Logger {
    ~Logger() {
        if (std::uncaught_exceptions() > 0) {
            // 正在展开中——不要再抛了!
            try { flush(); } catch (...) {}
        } else {
            flush();
        }
    }
};
1
2
3
4
5
6
7
8
9
10

C++17 引入 std::uncaught_exceptions()(返回 int),替代 C++03 的 std::uncaught_exception()(返回 bool,对嵌套异常不可靠)。

铁律:析构函数必须 noexcept——所有 RAII 类、所有智能指针、所有锁守护,析构函数从不抛。

# 2.5 异常的隐藏代价

异常的开销分布很反直觉:

不抛异常时:         接近零(仅查表的元数据开销)
抛一次异常:         数千到数万个 CPU 周期
                    ├── 拷贝异常对象(堆分配 + 构造)
                    ├── 查 .eh_frame_hdr(二分查找)
                    ├── 跑 DWARF CFI 虚拟机(每帧 1-10μs)
                    ├── 跑 .gcc_except_table(每帧匹配)
                    └── 跳到 catch / terminate
1
2
3
4
5
6
7
场景 异常代价
函数全是 noexcept 0(编译器可优化掉表项)
函数有 try/catch 但不抛 0~极小
抛一次跨 10 层栈 几十微秒
抛一次跨 100 层栈 几百微秒到毫秒级

所以异常的"零开销原则"(Zero-Cost Exception)说的是不抛时零开销——一旦抛,代价远高于错误码。异常应当用于"异常"路径,不是用于控制流。

# 3. 异常安全三等级

C++ 标准库的 <stdexcept> 头文件给出了异常安全等级的形式化定义。这是排查异常 bug 的第一参考系——你必须先回答"我的函数应该达到哪一级",才能判断它有没有做对。

# 3.1 无保证:泄漏前提

void no_guarantee() {
    auto* p = new Resource();
    do_something_that_might_throw();   // 抛 → p 永远泄漏
    delete p;
}
1
2
3
4
5

特征:

  • 抛异常后资源泄漏、状态损坏、不变量破坏;
  • 现代 C++ 代码里不应该出现这一级——主线一和主线二都是这一级的反例。

# 3.2 基本保证:不泄漏

void basic_guarantee() {
    auto p = std::make_unique<Resource>();
    do_something_that_might_throw();   // 抛 → p 自动 delete
}                                      // 资源不漏,但程序状态可能改变
1
2
3
4

承诺:

  1. 不泄漏资源——所有 RAII 对象的析构都会跑;
  2. 不变量保持——对象处于"可以析构、可以赋新值"的合法状态;
  3. 副作用可能已发生——但程序整体没崩。

典型场景:std::vector::insert 在容器已经满时抛 bad_alloc——容器仍然合法,但旧值可能已经在中间步骤被移动过。

主线一改造为基本保证:

void transfer_basic(Account& from, Account& to, Money m) {
    from.withdraw(m);     // ← 这一行抛了,from 仍合法(余额未变,因为 += 不抛)
    to.deposit(m);        // ← 这一行抛了,from 已经扣了,to 没收到
}                         // 状态不一致,但没崩
1
2
3
4

这就是真实的"基本保证"——不崩,但业务半成功。对支付场景而言,基本保证远远不够。

# 3.3 强保证:事务回滚

void strong_guarantee() {
    // 要么全成功(状态改变),要么全失败(状态完全不变)
    // 不会出现"半成功"
}
1
2
3
4

承诺:

  1. 不泄漏(自动含基本保证);
  2. 状态原子性——如果抛异常,程序状态与调用前完全相同(事务回滚)。

实现套路:

  1. Copy-and-Swap(第 6 章主题):先在临时变量里做所有可能抛的操作,最后用 noexcept swap 一步切换;
  2. 两阶段提交:分"准备"和"提交"两段,准备阶段可抛但不修改状态,提交阶段不抛。

主线一改造为强保证:

void transfer_strong(Account& from, Account& to, Money m) {
    // 第一阶段:可能抛,但都在副本上做
    Account from_copy = from;          // 拷贝构造可能抛
    Account to_copy   = to;
    from_copy.withdraw(m);             // 抛?只影响 copy
    to_copy.deposit(m);                // 抛?只影响 copy

    // 第二阶段:不抛 swap,原子切换
    using std::swap;
    swap(from, from_copy);             // noexcept
    swap(to, to_copy);                 // noexcept
}
1
2
3
4
5
6
7
8
9
10
11
12

任何阶段抛异常,from 和 to 的原值都没动——这就是事务。

# 3.4 不抛保证:noexcept

void no_throw() noexcept { /* 永远不抛 */ }
1

承诺:函数绝不抛出异常。如果违反(实际抛了),程序立即 std::terminate()——比"基本保证泄漏"还严重。

适用场景(也是必须的):

  • 析构函数(默认 noexcept);
  • 移动构造 / 移动赋值(第 7.2 节会解释为什么必须);
  • swap 函数(强保证的基础);
  • 内存释放函数(operator delete)。
// 标准库的强承诺
~std::vector() noexcept;               // 必须
std::vector(vector&&) noexcept;        // 必须(不然 push_back 不能强保证)
void swap(vector& other) noexcept;     // 必须
1
2
3
4

# 3.5 等级对照与抉择

┌─────────────────────────────────────────────────────────────┐
│  等级       │  泄漏?  │  状态?    │  典型 API                  │
├─────────────────────────────────────────────────────────────┤
│  无保证     │   会    │  可能损坏  │  ❌ 不应出现                 │
│  基本保证   │  不会   │  合法但改变│  ★ 默认目标                │
│  强保证     │  不会   │  完全回滚  │  事务/支付/不可重试操作      │
│  不抛保证   │  不会   │  完全不变  │  析构/swap/move/key 函数    │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8

选型决策树:

这个函数对外承诺什么?
├── "我自己保证不抛"     → noexcept
├── "失败时状态回到调用前" → 强保证(Copy-and-Swap / 两阶段)
├── "失败时不泄漏、可继续" → 基本保证(默认)
└── "我也不知道"           → 这是个 bug
1
2
3
4
5

实务规则(来自 Sutter & Alexandrescu C++ Coding Standards Item 71):

写代码时,至少做到基本保证。能做到强保证更好。承诺 noexcept 时要确保真做到。

# 4. RAII 核心机制

RAII(Resource Acquisition Is Initialization,资源获取即初始化)是 Bjarne Stroustrup 给 C++ 的"银弹"。它的名字略有误导——真正的精髓不在"获取",而在**"自动释放"**:把资源生命周期绑定到栈对象生命周期,让编译器替你写 try/finally。

# 4.1 RAII 的本质

用一个对象的构造-析构来代表一段资源的获取-释放。

class FileGuard {
public:
    explicit FileGuard(const char* path) : f_(std::fopen(path, "r")) {
        if (!f_) throw std::runtime_error("open failed");
    }
    ~FileGuard() noexcept { if (f_) std::fclose(f_); }

    FileGuard(const FileGuard&) = delete;             // 不可拷贝
    FileGuard& operator=(const FileGuard&) = delete;

    FileGuard(FileGuard&& o) noexcept : f_(o.f_) { o.f_ = nullptr; }
    FileGuard& operator=(FileGuard&& o) noexcept {
        std::swap(f_, o.f_);  // 临时变量析构时关掉旧 fd
        return *this;
    }

    std::FILE* get() const { return f_; }
private:
    std::FILE* f_;
};

void read_config() {
    FileGuard g("/etc/app.conf");      // ① 获取
    parse(g.get());                    // ② 用
    // 无论 parse 抛不抛,g 离开作用域时自动 fclose
}                                      // ③ 释放
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

RAII 三大铁律:

  1. 构造函数获取资源——失败立即抛异常,永不出现"半构造"对象;
  2. 析构函数释放资源——noexcept,不抛;
  3. 拷贝/移动语义明确——独占类资源禁拷贝、允许移动(unique 语义);共享类资源用引用计数(shared 语义)。

# 4.2 构造即获取

构造函数里只做一件事:获取资源。任何失败立即抛异常——抛异常的构造函数意味着对象不存在。

class Connection {
public:
    Connection(const std::string& host, int port) {
        sock_ = ::socket(AF_INET, SOCK_STREAM, 0);
        if (sock_ < 0) throw std::system_error(errno, std::generic_category());

        // 关键:::connect 失败时要清理 sock_,否则 fd 泄漏
        try {
            do_connect(host, port);   // 可能抛
        } catch (...) {
            ::close(sock_);
            throw;                    // 重新抛出
        }
    }
    ~Connection() noexcept { if (sock_ >= 0) ::close(sock_); }
private:
    int sock_ = -1;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

构造函数中的多步获取——任何一步抛都要清理之前的步骤。下面是更优雅的版本(用 RAII 拆解):

class FdGuard {              // 单一职责:管 fd
public:
    explicit FdGuard(int fd) : fd_(fd) {}
    ~FdGuard() noexcept { if (fd_ >= 0) ::close(fd_); }
    FdGuard(FdGuard&& o) noexcept : fd_(o.fd_) { o.fd_ = -1; }
    int release() { int t = fd_; fd_ = -1; return t; }
    int get() const { return fd_; }
private:
    int fd_;
};

class Connection {
public:
    Connection(const std::string& host, int port) {
        FdGuard g(::socket(AF_INET, SOCK_STREAM, 0));
        if (g.get() < 0) throw std::system_error(errno, std::generic_category());
        do_connect(g.get(), host, port);     // 抛了,g 析构自动 close
        sock_ = g.release();                 // 成功,sock_ 接管
    }
    ~Connection() noexcept { if (sock_ >= 0) ::close(sock_); }
private:
    int sock_ = -1;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

这就是 RAII 的复合用法——子资源也用 RAII 包,主对象的构造代码就再没 try/catch。

# 4.3 析构即释放

析构函数三铁律:

  1. noexcept(C++11 起,析构默认 noexcept);
  2. 释放所有资源——内存、fd、锁、引用计数;
  3. 不依赖外部状态——析构时可能在异常展开中、可能在程序结束阶段,全局对象/静态对象状态未知。

反模式:

class Logger {
    ~Logger() {
        // ❌ 析构时调用其他全局对象
        GlobalLog::get().write("destroyed");   // 全局对象可能已析构!
        // ❌ 析构时抛异常
        if (!flushed_) throw std::runtime_error("unflushed!");
        // ❌ 析构时做耗时操作
        for (int i = 0; i < 1e8; ++i) ...;
    }
};
1
2
3
4
5
6
7
8
9
10

# 4.4 栈上对象的力量

RAII 起作用的关键是栈对象的"确定性析构"——编译器保证每个栈对象的析构会被调用,无论函数怎么退出。

                   函数有 4 种退出路径
                          │
       ┌──────────┬──────┼──────┬──────────┐
       ▼          ▼      ▼      ▼          ▼
     正常 return  goto   异常   longjmp   exit/abort
       │          │      │      │          │
       ▼          ▼      ▼      ▼          ▼
     析构 ✅    析构 ✅  析构 ✅  ❌不调   ❌不调
                                 (UB)    (除非全局对象)
1
2
3
4
5
6
7
8
9
退出路径 RAII 是否生效
return ✅ 生效
goto、break、continue ✅ 生效
throw 异常 ✅ 生效(栈展开调析构)
longjmp ❌ UB,跳过析构(C++ 禁止跨非平凡对象的 longjmp)
_exit / abort ❌ 进程直接结束,析构不调
std::exit 部分——只调全局/静态对象的析构,不调栈对象

结论:只要不用 longjmp、不调 _exit/abort,RAII 就是 C++ 的100% 资源安全保证。

# 4.5 资源种类清单

凡是"获取后必须释放"的东西,都该 RAII 包:

资源类型 C 接口 RAII 包装
内存 malloc/free new/delete unique_ptr/shared_ptr/vector
文件 fopen/fclose open/close fstream、unique_ptr<FILE, decltype(&fclose)>
套接字 socket/close 自定义 Socket 类
锁 pthread_mutex_lock/unlock lock_guard/unique_lock/scoped_lock
数据库事务 BEGIN/COMMIT/ROLLBACK 自定义 Transaction 类
GPU 资源 cudaMalloc/cudaFree thrust::device_vector / 自定义
OpenGL 句柄 glGenBuffers/glDeleteBuffers 自定义 GLBuffer 类
临时文件 mkstemp / unlink 自定义 TempFile 类
HTTP 连接池 acquire/release 自定义 PooledConn 类(move-only)
计时器 / span start/stop RAII ScopedTimer

任何代码里写到"必须配对的 xxxxx_init / xxxxx_destroy"——立刻想着 RAII 包它。

# 5. 智能指针武器库

智能指针是 RAII 应用于"堆内存"的标准实现。详细选型请见 07.智能指针选型指南——本章只聚焦"异常安全"维度。

# 5.1 unique_ptr 独占

{
    auto p = std::make_unique<Widget>(args...);   // ① 构造
    p->method();                                  // ② 用
    might_throw();                                // ③ 抛了也没事
}                                                 // ④ 离开作用域:自动 delete
1
2
3
4
5

异常安全保证:

  • make_unique 抛了 bad_alloc —— 内存还没分配,零泄漏;
  • Widget 构造抛了 —— make_unique 内部 catch 后释放内存再重抛,零泄漏;
  • method() 或后续代码抛了 —— p 析构调 delete,零泄漏。

主线二改造为 unique_ptr:

struct Compound {
    std::unique_ptr<Resource> a;
    std::unique_ptr<Resource> b;
    Compound()
        : a(std::make_unique<Resource>(1))   // ① a 成功
        , b(std::make_unique<Resource>(2))   // ② b 构造抛
        // ③ a 是已构造的成员变量 → 自动析构 → free 1
        // ④ Compound 视为"未构造",~Compound 不调
    {}
};
1
2
3
4
5
6
7
8
9
10

关键认知:已构造的成员变量在外层抛异常时会被析构——这是 C++ 标准的强承诺。所以用智能指针的成员而不是裸指针,就把"主线二"的泄漏完全消除。

# 5.2 shared_ptr 共享

{
    auto sp = std::make_shared<Widget>(args);    // 控制块 + 对象一次分配
    auto sp2 = sp;                               // use_count = 2
    might_throw();
}                                                // 最后一个析构时 delete
1
2
3
4
5

异常安全特性:

  • 构造抛异常 —— make_shared 内部 catch 后释放控制块;
  • 拷贝(sp2 = sp)抛 —— 控制块原子操作 noexcept,实际拷贝构造从不抛;
  • 析构 —— 引用计数原子减一,到 0 才真正 delete。

多线程下的异常安全:

// 线程 A
std::shared_ptr<Widget> sp = get_shared();

// 线程 B 同时
sp.reset();   // 控制块的引用计数操作是原子的,不会撕裂
1
2
3
4
5

但对象本身的访问不是线程安全——sp->method() 和另一线程 sp.reset() 仍是 race(如果 sp 是同一变量)。

# 5.3 make 系列必选

// ❌ 老写法
auto sp = std::shared_ptr<Widget>(new Widget(args));

// ✅ 现代写法
auto sp = std::make_shared<Widget>(args);
1
2
3
4
5

为什么 make_shared 是异常安全的关键:

// 反例:参数求值顺序导致泄漏
void process(std::shared_ptr<Widget> a, std::shared_ptr<Widget> b);

process(std::shared_ptr<Widget>(new Widget),    // ① new Widget
        std::shared_ptr<Widget>(new Widget));   // ② new Widget

// 编译器允许的求值顺序(C++17 前):
// ①.new → ②.new → ①.shared_ptr → ②.shared_ptr
// 如果 ②.new 抛 bad_alloc → ①.new 的内存永远泄漏
1
2
3
4
5
6
7
8
9

make_shared 把"分配 + 构造 + 包装"打包成一个 noexcept 的原子操作——参数求值顺序问题完全消失:

process(std::make_shared<Widget>(),    // 异常安全
        std::make_shared<Widget>());   // 异常安全
1
2

C++17 起求值顺序有所收紧,但 make_shared / make_unique 仍是强烈推荐——还有性能优势(单次分配、控制块本地化、cache 友好)。

# 5.4 自定义删除器

// 包装 C 句柄:FILE*
auto file = std::unique_ptr<FILE, decltype(&std::fclose)>(
    std::fopen("data.bin", "r"), &std::fclose);

// lambda 版本
auto file2 = std::unique_ptr<FILE, std::function<void(FILE*)>>(
    std::fopen("data.bin", "r"),
    [](FILE* f) { if (f) std::fclose(f); }
);
1
2
3
4
5
6
7
8
9

异常安全:构造失败(fopen 返回 nullptr)——删除器仍会被调用,但对 nullptr 调 fclose 是 UB,要在删除器里判空。

通用模板:

template <typename T, void (*Deleter)(T*)>
using c_handle = std::unique_ptr<T, std::integral_constant<decltype(Deleter), Deleter>>;

// 用法
using FilePtr = c_handle<FILE, fclose_wrapper>;
1
2
3
4
5

# 5.5 智能指针陷阱

陷阱一:shared_ptr<T>(raw_ptr) 双控制块 —— 同一裸指针被两个 shared_ptr 接管,各自创建控制块,最终双 delete。详见 07.智能指针选型指南.md 13.1 节主线二。

Widget* raw = new Widget;
auto p1 = std::shared_ptr<Widget>(raw);   // 控制块 #1
auto p2 = std::shared_ptr<Widget>(raw);   // 控制块 #2 → 双 free 崩溃
1
2
3

陷阱二:循环引用导致泄漏 —— Session ↔ Connection 双向 shared_ptr 永不析构。修复用 weak_ptr 破环。

陷阱三:enable_shared_from_this 必须用 —— 在成员函数里要返回 shared_ptr<This>,绝不能 shared_ptr<This>(this)——会创建第二个控制块。

class Worker : public std::enable_shared_from_this<Worker> {
public:
    void async_run() {
        // ✅ 正确
        io_.post([self = shared_from_this()] { self->do_work(); });
        // ❌ 错误:制造第二个控制块
        // io_.post([self = std::shared_ptr<Worker>(this)] { self->do_work(); });
    }
};
1
2
3
4
5
6
7
8
9

# 6. Copy-and-Swap 强保证

强保证(事务回滚)的经典实现套路。任何赋值运算符想做到强保证,几乎都得用这个模式。

# 6.1 朴素赋值的陷阱

class Buffer {
    char* data_;
    size_t size_;
public:
    Buffer& operator=(const Buffer& rhs) {
        // ❌ 反模式:会破坏原状态
        delete[] data_;                          // ① 先释放旧的
        data_ = new char[rhs.size_];             // ② 分配新的(可能抛 bad_alloc)
        std::copy_n(rhs.data_, rhs.size_, data_);// ③ 拷贝(可能抛)
        size_ = rhs.size_;
        return *this;
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13

漏洞:

  1. ② 抛异常 → data_ 已经被 delete,但还指向野指针,析构会 double free;
  2. 即使把 data_ = nullptr 提前,状态仍然损坏——抛之前是"完整数据",抛之后是"空对象"——不满足强保证。

# 6.2 Copy-and-Swap 套路

class Buffer {
    char* data_ = nullptr;
    size_t size_ = 0;
public:
    Buffer(const Buffer& rhs)                    // 拷贝构造:可能抛
        : data_(new char[rhs.size_])
        , size_(rhs.size_)
    {
        std::copy_n(rhs.data_, rhs.size_, data_);
    }

    Buffer(Buffer&& rhs) noexcept                // 移动构造:不抛
        : data_(rhs.data_), size_(rhs.size_)
    {
        rhs.data_ = nullptr;
        rhs.size_ = 0;
    }

    ~Buffer() noexcept { delete[] data_; }

    friend void swap(Buffer& a, Buffer& b) noexcept {   // ★ 不抛 swap
        using std::swap;
        swap(a.data_, b.data_);
        swap(a.size_, b.size_);
    }

    Buffer& operator=(Buffer rhs) noexcept {            // ★ 按值接收
        swap(*this, rhs);                               // ★ 不抛交换
        return *this;
    }                                                   // rhs 析构掉旧数据
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

关键三步:

  1. 按值接收参数 Buffer rhs —— 拷贝在参数构造时发生。这一步可能抛 bad_alloc,但只在 rhs 这个局部副本上发生,*this 完全没动;
  2. swap(*this, rhs) noexcept —— 把副本(含完整数据)与 *this(含旧数据)原子交换;
  3. 函数返回,rhs 析构 —— rhs 现在持有旧数据,析构时清掉。

异常路径分析:

  • 第 1 步抛 → 函数没进入,*this 不变 ✅;
  • 第 2 步不抛(noexcept 保证) ✅;
  • 第 3 步不抛(析构 noexcept) ✅。

强保证达成。

# 6.3 noexcept swap 是关键

Copy-and-Swap 强保证的核心全押在 swap 不抛上。所以:

friend void swap(Buffer& a, Buffer& b) noexcept {
    using std::swap;
    swap(a.data_, b.data_);   // 指针 swap,noexcept
    swap(a.size_, b.size_);   // size_t swap,noexcept
}
1
2
3
4
5

swap 不抛的方法:

  1. 只交换指针 / int / 小 POD——这些类型 std::swap 默认 noexcept;
  2. 不要 swap 容器值——std::swap(vector, vector) 通常 noexcept(指针交换),但 std::swap(small_buf_vec, small_buf_vec) 在 SSO 触发时可能涉及拷贝;
  3. 自定义类的 swap 显式 noexcept——并确保实现真的不抛。

反模式:

friend void swap(BigObject& a, BigObject& b) {   // ❌ 没标 noexcept
    BigObject tmp = a;    // ❌ 拷贝可能抛 bad_alloc
    a = b;
    b = tmp;
}
1
2
3
4
5

# 6.4 自赋值与移动赋值

自赋值安全:传值参数自动解决——a = a 时 rhs 是 a 的副本,swap 后副本(含原值)和 *this(也含原值)交换,等于没动。

移动赋值合并:

Buffer& operator=(Buffer rhs) noexcept {   // 同时处理拷贝赋值和移动赋值
    swap(*this, rhs);
    return *this;
}

// 调用时:
Buffer b1, b2;
b1 = b2;                  // 拷贝构造 rhs,可能抛
b1 = std::move(b2);       // 移动构造 rhs,noexcept
1
2
3
4
5
6
7
8
9

一份代码处理两种赋值——这就是 Copy-and-Swap 的优雅之处。但代价是多一次构造——对小对象无所谓,对大对象(如 std::vector<HugeData>)可能要拆开写。

# 6.5 适用边界

Copy-and-Swap 不是万能的:

场景 推荐
普通值类型(Buffer/String/Matrix) ✅ 首选
引用计数对象(shared_ptr 内部) ❌ 不需要,原子计数已经原子
大对象 + 频繁赋值 ⚠️ 拷贝代价高,考虑直接写两个赋值(拷贝+移动)
多线程并发赋值 ⚠️ 还要加锁,光 swap 不够
不可拷贝的资源(socket/thread) ❌ 没法 Copy,只能 Move-and-Swap

# 7. 移动语义与 noexcept

C++11 移动语义让"对象转移所有权"变得高效——但移动构造能不能 noexcept,直接决定了容器能不能做到强保证。

# 7.1 移动构造的语义

class Buffer {
    char* data_;
    size_t size_;
public:
    Buffer(Buffer&& o) noexcept             // 移动构造
        : data_(o.data_), size_(o.size_)
    {
        o.data_ = nullptr;    // ★ 把源对象"清空",但保持合法可析构
        o.size_ = 0;
    }
};
1
2
3
4
5
6
7
8
9
10
11

核心规则:

  1. 窃取资源——只拷贝指针/句柄,不拷贝数据;
  2. 置空源对象——保证源对象处于"可析构、可赋新值"的状态(不要求与默认构造的对象相同,但必须合法);
  3. 不抛异常——通常只做指针操作 + 置空,天然不会抛。

# 7.2 noexcept 决定优化

为什么移动构造必须 noexcept?看 std::vector::push_back 的扩容逻辑:

// 简化伪代码
void push_back(T x) {
    if (size_ == capacity_) {
        T* new_data = allocator_.allocate(capacity_ * 2);
        // 关键决策点:是 move 还是 copy 旧数据?
        if (std::is_nothrow_move_constructible_v<T>) {
            // 移动不抛 → 安全用移动(快)
            for (size_t i = 0; i < size_; ++i)
                new (new_data + i) T(std::move(data_[i]));
        } else {
            // 移动可能抛 → 用拷贝(慢但安全)
            // 因为移动到一半抛了,无法回滚(源已被破坏)
            for (size_t i = 0; i < size_; ++i)
                new (new_data + i) T(data_[i]);
        }
        // ...
    }
    new (data_ + size_) T(std::move(x));
    ++size_;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

结论:

  • 移动构造 noexcept → 扩容用 move,性能 + 强保证;
  • 移动构造没标 noexcept(哪怕实际不抛)→ 扩容用 copy,性能掉 N 倍。
// 实测对比(vector<HugeObj> 扩容到 100 万)
struct HugeObj_BadMove { /* 移动构造没标 noexcept */ };
struct HugeObj_GoodMove { /* 移动构造标了 noexcept */ };

std::vector<HugeObj_BadMove> v1;   // 扩容用 copy,慢 5-10 倍
std::vector<HugeObj_GoodMove> v2;  // 扩容用 move,快
1
2
3
4
5
6

铁律:自定义类的移动构造/移动赋值,标 noexcept。

# 7.3 五大函数的承诺

class MyType {
public:
    MyType() noexcept;                              // 默认构造
    MyType(const MyType&);                          // 拷贝构造(可能抛 bad_alloc)
    MyType(MyType&&) noexcept;                      // ★ 移动构造,noexcept
    MyType& operator=(const MyType&);               // 拷贝赋值(可能抛)
    MyType& operator=(MyType&&) noexcept;           // ★ 移动赋值,noexcept
    ~MyType() noexcept;                             // ★ 析构,noexcept(默认)
    friend void swap(MyType&, MyType&) noexcept;    // ★ swap,noexcept
};
1
2
3
4
5
6
7
8
9
10

五大 noexcept 必选:

  1. 析构函数;
  2. 移动构造函数;
  3. 移动赋值运算符;
  4. swap 函数;
  5. 默认构造函数(如果不分配内存)。

默认行为:

  • 编译器生成的析构、移动、swap 会自动推导 noexcept(如果所有成员都满足);
  • 但自己写的版本默认不 noexcept——必须显式标注。

# 7.4 析构函数禁抛

struct Bad {
    ~Bad() {                                    // ❌ 没标 noexcept
        if (some_condition) throw std::runtime_error("oops");
        // → 一旦在栈展开中析构抛 → std::terminate()
    }
};
1
2
3
4
5
6

C++11 起,析构函数默认 noexcept——即使你不标,编译器也按 noexcept 处理。如果你真的想让析构抛(强烈不推荐),要显式 noexcept(false):

struct UnsafeDestructor {
    ~UnsafeDestructor() noexcept(false) {   // 显式告诉编译器"我可能抛"
        if (!flushed) throw std::runtime_error("not flushed!");
    }
};
1
2
3
4
5

但这种类不能放进任何标准容器——vector/map/set 都依赖元素析构 noexcept。

实务做法:析构里如果想"通知失败",用 logger 记录 + 设置全局 flag,绝不抛异常。

# 7.5 noexcept 传染规则

noexcept 是会传染的:

void inner() { /* might throw */ }
void outer() noexcept {
    inner();   // 如果 inner 真抛 → std::terminate()
}
1
2
3
4

条件 noexcept:让编译器自动推导:

template <typename T>
void copy_swap(T& a, T& b) noexcept(noexcept(swap(a, b))) {
    swap(a, b);
}

// 用法
static_assert(noexcept(copy_swap(buf1, buf2)));
1
2
3
4
5
6
7

noexcept(expr) 是编译期检查 expr 是否被标记为 noexcept——这是模板元编程里实现"条件强保证"的关键。

标准库的传染示例:

// std::vector 的强保证依赖:
//   T 的移动构造 noexcept    且
//   T 的拷贝构造 不抛(或不存在)
template <typename T>
class vector {
    void push_back(T x) {
        // 自动选 move 还是 copy
        if constexpr (std::is_nothrow_move_constructible_v<T>) {
            // 用 move
        } else if constexpr (std::is_copy_constructible_v<T>) {
            // 用 copy
        }
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14

noexcept 不是免费的承诺——它必须真做到。一旦标了 noexcept 又抛了,程序立即 terminate(),没有 catch 能救。

# 8. Scope Guard 终极清理

RAII 包资源很美,但很多清理动作不属于"资源":日志、回调、状态标记、监控埋点、临时文件路径。为这些场景写一个专门的类太累——这时候用 Scope Guard。

# 8.1 非资源的清理需求

void process_request(Request& req) {
    metric_.increment("inflight");          // 入口埋点
    log("processing " + req.id);
    auto t_start = now();

    do_actual_work(req);                    // 可能抛

    log("done " + req.id);
    metric_.decrement("inflight");          // 出口埋点
    metric_.observe("latency", now() - t_start);
}
1
2
3
4
5
6
7
8
9
10
11

如果 do_actual_work 抛了——埋点永远漏一次、监控数据永远错一笔。这不是"资源泄漏",但是**"清理动作没跑"**——影响线上观测,比内存泄漏更隐蔽。

# 8.2 Scope Guard 实现

经典实现(Andrei Alexandrescu 2000 年提出):

template <typename F>
class ScopeGuard {
    F f_;
    bool active_ = true;
public:
    explicit ScopeGuard(F f) noexcept : f_(std::move(f)) {}
    ~ScopeGuard() noexcept {
        if (active_) {
            try { f_(); } catch (...) { /* 析构禁抛 */ }
        }
    }
    void dismiss() noexcept { active_ = false; }

    ScopeGuard(const ScopeGuard&) = delete;
    ScopeGuard& operator=(const ScopeGuard&) = delete;
    ScopeGuard(ScopeGuard&& o) noexcept
        : f_(std::move(o.f_)), active_(o.active_) { o.active_ = false; }
};

template <typename F>
ScopeGuard<F> make_guard(F f) { return ScopeGuard<F>(std::move(f)); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

用法:

void process_request(Request& req) {
    metric_.increment("inflight");
    auto guard = make_guard([&] {                      // ★ 离开作用域必跑
        metric_.decrement("inflight");
    });

    auto t_start = now();
    auto latency_guard = make_guard([&] {
        metric_.observe("latency", now() - t_start);
    });

    do_actual_work(req);                               // 抛了也没事
    log("done " + req.id);
    // guard、latency_guard 离开作用域自动 decrement / observe
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

关键认知:Scope Guard 把"finally 块"的语义带回了 C++——而且比 finally 更优雅(嵌套自然、不需要语言关键字)。

# 8.3 dismiss 提交语义

dismiss() 用于实现"事务提交"——guard 默认会回滚,提交后才不回滚。

void create_user(const User& u) {
    db_.insert(u);                              // ① 写主表
    auto rollback = make_guard([&] {
        db_.remove(u.id);                       // 万一后续抛,回滚主表
    });

    db_.insert_profile(u.profile);              // ② 写副表,可能抛
    send_welcome_email(u.email);                // ③ 发邮件,可能抛

    rollback.dismiss();                         // ④ 都成了,不回滚
}
1
2
3
4
5
6
7
8
9
10
11

异常路径分析:

  • ② 抛 → rollback 析构 → 回滚主表 ✅;
  • ③ 抛 → rollback 析构 → 回滚主表 ✅;
  • ④ 调到 → rollback.dismiss() → 析构时不回滚 ✅。

这就是"Copy-and-Swap"模式之外的另一种"强保证"实现——比 swap 更灵活,能处理"无法在副本上完成"的副作用(如发邮件、调 RPC)。

# 8.4 现代写法 scope_exit

C++17 起社区提供了 <experimental/scope>(GSL、Boost.ScopeExit 也有等价物),C++23/26 正式进入标准:

#include <experimental/scope>     // 或 <scope> in C++26
using std::experimental::scope_exit;
using std::experimental::scope_fail;
using std::experimental::scope_success;

void f() {
    open_db();
    scope_exit close{[&]{ close_db(); }};       // 离开作用域必跑

    do_work();

    scope_fail rollback{[&]{ undo(); }};        // 仅异常退出时跑
    scope_success commit{[&]{ commit(); }};     // 仅正常退出时跑
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

三种语义:

类型 触发条件
scope_exit 任何离开作用域(正常 + 异常)
scope_fail 仅异常退出(栈展开中)
scope_success 仅正常退出(无异常)

与 ScopeGuard 的关系:

  • scope_exit ≈ ScopeGuard 不 dismiss 的版本;
  • scope_success ≈ ScopeGuard 默认 dismiss、最后才"取消 dismiss";
  • scope_fail ≈ ScopeGuard 标准用法(默认回滚、dismiss 表示成功)。

# 8.5 与 RAII 的关系

维度 RAII(自定义类) Scope Guard(通用 lambda)
适用 长生命周期资源 临时清理动作
重用 多处使用 一次性
表达力 强(成员函数、状态) 弱(仅 lambda)
写代码量 多 少
心智成本 类型显式 隐含在 lambda

实务规则:

  • 稳定的资源类型 → 写专门的 RAII 类(FileGuard、Connection、Transaction);
  • 一次性的清理动作 → 用 Scope Guard / scope_exit。

# 9. 多资源协同管理

单个资源的 RAII 简单。真正考验异常安全设计的是"多资源协同"——主线一是这一类的代表。

# 9.1 多分配的顺序陷阱

// 反例
struct Compound {
    Resource* a;
    Resource* b;
    Compound() {
        a = new Resource(1);     // 成功
        b = new Resource(2);     // 抛 → a 泄漏(主线二)
    }
    ~Compound() { delete a; delete b; }
};
1
2
3
4
5
6
7
8
9
10

为什么 ~Compound 不会跑:C++ 标准规定,构造函数抛异常的对象,析构函数不会被调用——因为对象从未"完整构造"过。

正解一:用智能指针成员:

struct Compound {
    std::unique_ptr<Resource> a;
    std::unique_ptr<Resource> b;
    Compound()
        : a(std::make_unique<Resource>(1))   // 已构造成员 a
        , b(std::make_unique<Resource>(2))   // 抛 → 已构造的 a 自动析构
    {}
};
1
2
3
4
5
6
7
8

为什么这个能行:C++ 标准明确——构造函数抛异常时,已构造的成员变量会被反向析构。

正解二:function-try-block(极少用,但要会看):

struct Compound {
    Resource* a;
    Resource* b;
    Compound() try : a(new Resource(1)), b(new Resource(2)) {}
    catch (...) {
        delete a;     // 注意:b 没构造成功,不需要 delete
        // 异常会自动重抛——function-try-block 不能"吞"异常
    }
    ~Compound() { delete a; delete b; }
};
1
2
3
4
5
6
7
8
9
10

用 function-try-block 的痛点:异常会自动重抛——你只是多了"清理已成功部分"的机会,不能吞掉异常。所以现代 C++ 几乎都用智能指针成员,function-try-block 几乎只在教科书里出现。

# 9.2 函数参数求值序

void process(std::unique_ptr<Widget>, std::unique_ptr<Widget>);

// 反例(C++17 前 UB-prone)
process(std::unique_ptr<Widget>(new Widget),
        std::unique_ptr<Widget>(new Widget));
// 求值序可能是:①.new → ②.new → ①.unique_ptr → ②.unique_ptr
// ②.new 抛 → ①.new 永远泄漏
1
2
3
4
5
6
7

正解:用 make_unique 把"new + 包装"绑死:

process(std::make_unique<Widget>(),
        std::make_unique<Widget>());
// 求值序:①.make_unique(含 new + 包装,原子)→ ②.make_unique
1
2
3

C++17 起改进:每个函数参数的"完整求值"必须完成后才求下一个——即使没用 make_xxx 也没了求值序泄漏。但 make_xxx 仍然推荐(清晰 + 单次分配优化)。

# 9.3 构造函数中的多资源

更复杂的场景:构造函数里既有 RAII 资源,又有"必须手动初始化的状态":

class Service {
    std::unique_ptr<DbConnection> db_;
    std::unique_ptr<Cache>        cache_;
    std::thread                   worker_;       // 启动一个后台线程
    bool                          started_ = false;
public:
    Service()
        : db_(std::make_unique<DbConnection>("..."))      // 抛 → nothing 已构造
        , cache_(std::make_unique<Cache>())               // 抛 → db_ 自动析构
        , worker_([this]{ run(); })                       // 抛 → cache_、db_ 自动析构
    {
        // 这里再可能抛?worker_ 已经在跑了!
        started_ = true;                                  // 这种"半状态"必须 noexcept
    }

    ~Service() noexcept {
        if (started_) { stop_flag_ = true; worker_.join(); }
        // cache_、db_ 自动析构
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

关键认知:

  • 成员初始化列表中的初始化按声明顺序进行,反向析构;
  • 函数体内的代码如果抛了——已构造的成员会自动析构,但已经"启动"的副作用不会回滚(比如 worker_ 线程已经跑了);
  • 所以构造函数体内的代码必须 noexcept,或用 Scope Guard 包。

# 9.4 容器的强保证条件

std::vector::push_back 的强保证文档(cppreference)原话:

If T's move constructor is not noexcept and T is not CopyInsertable into *this, vector will use the throwing move constructor. If it throws, the guarantee is waived and the effects are unspecified.

翻译:

T 的特性 push_back 异常保证
移动构造 noexcept ✅ 强保证
移动构造可能抛 + 可拷贝构造 ✅ 强保证(用 copy)
移动构造可能抛 + 不可拷贝 ⚠️ 仅基本保证

这就是为什么"自定义类的移动构造必须 noexcept"是铁律——直接关系到 vector 用户能否得到强保证。

类似的强保证函数:

  • std::vector::push_back / emplace_back / insert / resize —— 满足 noexcept move 的元素类型时
  • std::vector::reserve —— 同上
  • std::map::insert / emplace —— 失败时容器不变
  • std::list::push_back / push_front —— 强保证(基于节点)

不提供强保证的:

  • std::vector::assign —— 仅基本保证;
  • std::deque::insert(中间位置)—— 仅基本保证;
  • 所有 erase 系列(移动/拷贝时可能抛)—— 仅基本保证。

# 9.5 PIMPL 与异常安全

PIMPL(Pointer to Implementation)惯用法天然异常安全:

// widget.h
class Widget {
public:
    Widget();
    ~Widget();
    // ...
private:
    class Impl;
    std::unique_ptr<Impl> impl_;
};

// widget.cpp
class Widget::Impl {
    std::string name_;
    std::vector<Data> data_;
    Connection conn_;
};

Widget::Widget() : impl_(std::make_unique<Impl>()) {}    // ① 抛?impl_ 未分配
Widget::~Widget() = default;                              // ② Impl 已完整定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

异常安全保证:

  • 构造 Widget 时 Impl 构造抛 → make_unique 内部释放,没有半构造 Widget;
  • 析构 Widget 时 Impl 析构抛 → 析构 noexcept(编译器推导),如果真抛 terminate;
  • 拷贝/移动 Widget —— 只动 unique_ptr 指针,noexcept。

PIMPL 的隐藏好处:把"易抛的成员"(vector、string、复杂对象)藏到 .cpp 里——主类的 ABI 稳定、编译加速、异常安全自然达成。

# 10. 五步设计方法论

异常安全不是"写完代码后回头加 try/catch"——它是设计阶段就要明确的承诺。把前 9 章的方法论抽象成可复用的流程:

  ┌─────────────────────────────────────────┐
  │ 1. 列出所有资源                            │
  │    哪些是 acquire/release 对?             │
  └──────────────────┬──────────────────────┘
                     ↓
  ┌─────────────────────────────────────────┐
  │ 2. 选择保证等级                            │
  │    基本 / 强 / noexcept?                  │
  └──────────────────┬──────────────────────┘
                     ↓
  ┌─────────────────────────────────────────┐
  │ 3. RAII 包装资源                          │
  │    无裸 new/delete 出现                   │
  └──────────────────┬──────────────────────┘
                     ↓
  ┌─────────────────────────────────────────┐
  │ 4. 验证异常路径                            │
  │    每个 throw 点的状态如何?                │
  └──────────────────┬──────────────────────┘
                     ↓
  ┌─────────────────────────────────────────┐
  │ 5. CI 自动化保证                           │
  │    ASan + leak + 异常注入                 │
  └─────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 10.1 列出所有资源

设计前先列资源清单——这比写代码本身重要。主线一 transfer 的资源:

资源 获取 释放 可能抛?
from.balance_ -= 反向 += 否
to.balance_ += 反向 -= 否
TxnLog 日志条目 write 无(已落盘) 是(disk full)

这张表一画出来,就发现 log->write 是唯一会抛的——但它抛在 withdraw 之后、deposit 之前,正好让两个账户处于不一致状态。问题瞬间清晰。

# 10.2 选择保证等级

这个函数对用户承诺什么?
├── 失败时账户不能错乱 → 必须强保证或 noexcept
├── 失败时可以多扣但不能少收 → 基本保证够
└── 失败时啥都不发生最好  → 强保证
1
2
3
4

主线一的业务场景"双账户转账"——业务方一定要求强保证(事务回滚)。所以:

void transfer(Account& from, Account& to, Money m) /* 强保证 */ {
    // ...
}
1
2
3

先把保证等级写在注释里、再写实现——这是最简单的"设计先于编码"。

# 10.3 RAII 包装资源

清单上每个资源都该有 RAII 包装:

  • 内存 → unique_ptr / shared_ptr;
  • fd / FILE* → 自定义 RAII 类或 unique_ptr<T, Deleter>;
  • 锁 → lock_guard / unique_lock / scoped_lock;
  • 业务事务 → 自定义 Scope Guard 风格的 Transaction 类。

金标准:整个项目里 grep "new " 应该出来 0 个结果(除了 new 在 make_unique/make_shared 实现内部)。grep "delete " 同理。

# 10.4 验证异常路径

异常注入测试 —— 给关键函数注入"必抛"分支:

class FakeTxnLog : public TxnLog {
    int count_ = 0;
    int throw_after_ = -1;
public:
    void write(const std::string& op, Money m) override {
        if (++count_ == throw_after_) throw std::runtime_error("inject");
    }
    void set_throw_at(int n) { throw_after_ = n; }
};

TEST(Transfer, StrongGuarantee) {
    Account a(100), b(0);
    auto log = std::make_shared<FakeTxnLog>();
    a.set_log(log); b.set_log(log);

    // 在第 1 次 write 时抛(withdraw 阶段)
    log->set_throw_at(1);
    EXPECT_THROW(transfer(a, b, 50), std::runtime_error);
    EXPECT_EQ(a.balance(), 100);    // 原值
    EXPECT_EQ(b.balance(), 0);      // 原值

    // 在第 2 次 write 时抛(deposit 阶段)
    log->set_throw_at(2);
    EXPECT_THROW(transfer(a, b, 50), std::runtime_error);
    EXPECT_EQ(a.balance(), 100);    // ★ 强保证关键:仍为原值
    EXPECT_EQ(b.balance(), 0);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

关键认知:只跑 happy path 的测试,永远测不出异常安全 bug——必须主动注入异常。

# 10.5 CI 自动化保证

异常安全的 CI 流水线:

  1. -Wall -Wextra -Werror —— 编译期最便宜的保证;
  2. ASan + UBSan —— 跑所有测试,包括异常注入;
  3. -fsanitize=leak —— 单独跑一遍,确保异常路径不漏;
  4. -D_GLIBCXX_DEBUG —— 容器调试模式,迭代器失效立刻抓;
  5. valgrind --error-exitcode=1 —— 对关键场景跑一遍 valgrind;
  6. clang-tidy 检查:bugprone-throw-keyword-missing、misc-misplaced-const、modernize-make-unique。

配套 lint 规则(前 7 篇沉淀的内容):

# .clang-tidy
Checks: >
  -*,
  bugprone-throw-keyword-missing,
  bugprone-unhandled-exception-at-new,
  cert-err58-cpp,
  cppcoreguidelines-owning-memory,
  cppcoreguidelines-no-malloc,
  modernize-make-unique,
  modernize-make-shared,
  modernize-use-noexcept,
  performance-noexcept-move-constructor,
  performance-noexcept-swap,
1
2
3
4
5
6
7
8
9
10
11
12
13

这些 lint 加上后,团队代码质量会显著上升——任何不符合 RAII / noexcept 规范的代码 PR 阶段就被拦截。

# 11. 典型场景速查

把第 1~10 章的方法论,落到 7 个最高频的资源场景。

# 11.1 文件 / socket / fd

// 包 C 风格 FILE*
auto file = std::unique_ptr<FILE, decltype(&std::fclose)>(
    std::fopen("data", "r"), &std::fclose);
if (!file) throw std::system_error(errno, std::generic_category());

// 包 fd
class FdGuard {
    int fd_ = -1;
public:
    explicit FdGuard(int fd) noexcept : fd_(fd) {}
    ~FdGuard() noexcept { if (fd_ >= 0) ::close(fd_); }
    FdGuard(FdGuard&& o) noexcept : fd_(o.fd_) { o.fd_ = -1; }
    FdGuard& operator=(FdGuard&& o) noexcept { std::swap(fd_, o.fd_); return *this; }
    int get() const noexcept { return fd_; }
    int release() noexcept { int t = fd_; fd_ = -1; return t; }

    FdGuard(const FdGuard&) = delete;
    FdGuard& operator=(const FdGuard&) = delete;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

优先 std::fstream / std::ofstream——天然 RAII,析构自动关。直接用 fd 仅在性能敏感或必须用系统 API 时。

# 11.2 锁与临界区

std::mutex m;
void critical_section() {
    std::lock_guard<std::mutex> lock(m);     // ① 加锁
    might_throw();                            // ② 抛
    // ③ lock 析构自动解锁
}

// 多锁,C++17:自动按一致顺序加锁避免死锁
std::mutex m1, m2;
{
    std::scoped_lock lock(m1, m2);            // 一次锁两个
    // ...
}

// 需要手动控制的:unique_lock
{
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk, []{ return ready; });         // wait 内部会释放并重新获取
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

铁律:绝不在持锁状态下调可能抛的复杂操作(如分配大内存、调用未知 callback)——抛了虽然锁会自动解,但状态可能损坏。

# 11.3 数据库事务

class Transaction {
    DbConn& conn_;
    bool committed_ = false;
public:
    explicit Transaction(DbConn& c) : conn_(c) { conn_.exec("BEGIN"); }
    ~Transaction() noexcept {
        if (!committed_) {
            try { conn_.exec("ROLLBACK"); } catch (...) {}
        }
    }
    void commit() { conn_.exec("COMMIT"); committed_ = true; }

    Transaction(const Transaction&) = delete;
    Transaction& operator=(const Transaction&) = delete;
};

void create_user(DbConn& db, const User& u) {
    Transaction tx(db);                       // ① BEGIN
    db.exec("INSERT INTO users ...", u);      // 抛 → ~Transaction ROLLBACK
    db.exec("INSERT INTO profiles ...", u);   // 抛 → ROLLBACK
    tx.commit();                              // ② 都成 → COMMIT
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

这就是主线一应该用的设计——Transaction 把 BEGIN/COMMIT/ROLLBACK 配对成 RAII。

# 11.4 容器修改的回滚

// 反例:往容器加多个对象,中间抛了部分已加
void add_users(std::vector<User>& v, const std::vector<User>& src) {
    for (auto& u : src) {
        v.push_back(u);                       // 中间抛 → v 已部分修改
    }
}

// 正解:插入完整副本后 swap
void add_users_strong(std::vector<User>& v, const std::vector<User>& src) {
    std::vector<User> copy = v;               // 拷贝可能抛,v 不变
    for (auto& u : src) {
        copy.push_back(u);                    // 抛 → copy 析构,v 不变
    }
    using std::swap;
    swap(v, copy);                            // ★ noexcept
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

这就是 vector 在多个元素插入场景下的强保证套路——和 Copy-and-Swap 同构。

# 11.5 工厂返回所有权

// ✅ 返回 unique_ptr:所有权清晰,强保证
std::unique_ptr<Widget> make_widget(int x) {
    auto w = std::make_unique<Widget>(x);
    w->init();                                 // 抛 → w 自动析构
    return w;                                  // 移动给调用方
}

// ❌ 反例:返回裸指针
Widget* make_widget(int x) {
    auto* w = new Widget(x);
    w->init();                                 // 抛 → w 永远泄漏
    return w;
}

// ❌ 反例:通过参数返回
void make_widget(int x, Widget** out) {
    *out = new Widget(x);
    (*out)->init();                            // 抛 → out 永远泄漏
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

铁律:新分配的资源永远用智能指针返回,永远不要返回裸指针然后让用户 delete。

# 11.6 C API 句柄包装

// 通用模板
template <typename T, auto Deleter>
class CHandle {
    T* h_ = nullptr;
public:
    explicit CHandle(T* h) noexcept : h_(h) {}
    ~CHandle() noexcept { if (h_) Deleter(h_); }
    CHandle(CHandle&& o) noexcept : h_(o.h_) { o.h_ = nullptr; }
    CHandle& operator=(CHandle&& o) noexcept { std::swap(h_, o.h_); return *this; }
    T* get() const noexcept { return h_; }
    T* release() noexcept { T* t = h_; h_ = nullptr; return t; }

    CHandle(const CHandle&) = delete;
    CHandle& operator=(const CHandle&) = delete;
};

// 用法:sqlite3
using SqliteDb   = CHandle<sqlite3,      sqlite3_close>;
using SqliteStmt = CHandle<sqlite3_stmt, sqlite3_finalize>;

// curl
using CurlHandle = CHandle<CURL, curl_easy_cleanup>;

// LibXML2
using XmlDoc = CHandle<xmlDoc, xmlFreeDoc>;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

一个模板搞定所有 C 库——只要它有 T* create(...) 和 void destroy(T*) 的对偶 API。

# 11.7 内存池与对象池

class ObjectPool {
    std::vector<std::unique_ptr<Widget>> idle_;
    std::mutex mu_;
public:
    // 借出去一个对象(自动归还)
    auto acquire() {
        std::lock_guard lk(mu_);
        if (idle_.empty()) {
            return std::shared_ptr<Widget>(
                new Widget(),
                [this](Widget* p) { release(p); }    // 自定义删除器:归还到池
            );
        }
        auto p = std::move(idle_.back());
        idle_.pop_back();
        return std::shared_ptr<Widget>(
            p.release(),
            [this](Widget* p) { release(p); }
        );
    }
private:
    void release(Widget* p) noexcept {
        std::lock_guard lk(mu_);
        idle_.emplace_back(p);
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

核心思路:shared_ptr 的自定义删除器让"归还到池"看起来像"释放"——使用者不感知池的存在,异常路径自动归还。

# 12. 工程化最佳实践

知识到位 ≠ 团队落地。本章讲怎么把异常安全做成团队规范和自动化保证。

# 12.1 默认基本保证

所有公有 API 的默认承诺是基本保证——这是 ISO C++ 标准库的做法,也是社区共识。

class MyService {
public:
    // 默认:基本保证(不泄漏、状态合法)
    void process(const Request& req);

    // 显式强保证:注释里说明
    /// @brief 强保证:失败时账户不变
    void transfer(Account& from, Account& to, Money m);

    // noexcept:API 契约
    void close() noexcept;
    bool is_open() const noexcept;
};
1
2
3
4
5
6
7
8
9
10
11
12
13

注释中明确写出保证等级——这是 API 设计的一部分,和"返回值含义"同等重要。

# 12.2 noexcept 是承诺

// ❌ 反例:随便标 noexcept
class Buffer {
public:
    void append(const char* s) noexcept {     // ❌ 内部 vector 扩容会抛 bad_alloc
        data_.insert(data_.end(), s, s + strlen(s));
    }
};
1
2
3
4
5
6
7

noexcept 是 API 契约——一旦标了,必须真做到。否则用户依赖你的 noexcept 做强保证 → 你违约 → 用户拿到的是 terminate。

判断函数是否能 noexcept 的清单:

  • ✅ 只做指针 / int / POD 的 swap;
  • ✅ 只读取已存在的成员变量;
  • ✅ 调用其他 noexcept 函数;
  • ❌ 调用 new / make_xxx / 容器扩容;
  • ❌ 调用可能抛的拷贝构造(如 std::string 复制大数据);
  • ❌ 调用未知的回调 / 虚函数(除非接口约定 noexcept)。

# 12.3 异常 vs 错误码

何时用异常、何时用错误码——团队应当有明确规则。

维度 异常 错误码(expected / optional / tl::expected)
适用 罕见故障、构造失败 高频可恢复错误
性能 不抛时 0 开销,抛时贵 永远有小开销(分支判断)
调用方 必须 catch 或继续上传 必须显式检查
错误信息 异常对象携带(what()) 通常仅 enum 码
多层调用 自动上传、跨层简洁 每层都要传

实务规则:

  • 构造失败:用异常(构造函数没有返回值);
  • 配置加载、I/O、解析:用 expected<T, Error>;
  • 业务逻辑错误:用 expected<T, Error> 或自定义返回类型;
  • 不可恢复的系统错误:用异常(OOM、栈溢出);
  • 罕见的、跨多层的故障:用异常(避免每层都判错误码)。
// 现代 C++23 的 expected 用法
#include <expected>

std::expected<Config, ParseError> load_config(const std::string& path) {
    auto file = open(path);
    if (!file) return std::unexpected(ParseError::NoFile);
    auto text = file.read_all();
    if (!validate(text)) return std::unexpected(ParseError::BadFormat);
    return Config::parse(text);
}
1
2
3
4
5
6
7
8
9
10

# 12.4 团队规范六条

把本篇浓缩成 6 条上墙规范:

  1. 永远 make_unique / make_shared——绝不裸 new / delete(除非自定义删除器);
  2. 所有 RAII 类必须:拷贝 = delete / 移动 = noexcept / 析构 = noexcept;
  3. 构造函数失败用异常,业务错误用 expected;
  4. 析构函数绝不抛(默认 noexcept,捕获后吞掉);
  5. 多资源用智能指针成员或拆分子 RAII 类,禁止裸 T* a; T* b;;
  6. 强保证函数标注在注释:/// @brief 强保证:...,并写异常注入测试。

# 12.5 lint 与 CI 兜底

编译期:

g++ -Wall -Wextra -Werror -Wnon-virtual-dtor -Weffc++ \
    -fsanitize=address,undefined -fno-omit-frame-pointer -g
1
2

clang-tidy 规则(重点):

modernize-make-unique
modernize-make-shared
modernize-use-noexcept
performance-noexcept-move-constructor
performance-noexcept-swap
performance-noexcept-destructor
bugprone-throw-keyword-missing
bugprone-unhandled-exception-at-new
cppcoreguidelines-owning-memory
cppcoreguidelines-no-malloc
cppcoreguidelines-special-member-functions
hicpp-exception-baseclass
hicpp-noexcept-move
1
2
3
4
5
6
7
8
9
10
11
12
13

CI 强制:

  1. ASan + UBSan + leak 跑所有单测;
  2. 异常注入测试覆盖率 > 80%(每个公有方法至少一条"主线异常路径"测试);
  3. valgrind 跑关键场景(每日构建一次);
  4. Coverity / SonarQube 静态扫描扫"未处理异常"。

# 12.6 成熟度模型

阶段 能力 典型团队
Level 1 知道有 try/catch,但还在用 new/delete 初级团队
Level 2 全员用 RAII / make_xxx 有 review 文化
Level 3 API 明确标注异常等级 中级团队
Level 4 系统性写异常注入测试 中后台/平台
Level 5 CI 强制 lint + ASan + 异常注入 基础设施

绝大多数团队卡在 Level 2-3。升到 Level 4-5 的关键是把"异常路径"作为代码评审的独立 checklist——而不是"看看 happy path 测试通过就行"。

# 13. 综合案例串讲

回到第 1 章列出的两条主线,把整本指南的知识点串起来给出最终答案。

# 13.1 案例真相揭晓

# 主线一:转账崩盘

疑问回顾(第 1 章 1.3 节列出的 5 个问题):

  1. 代码看起来"两行而已",为什么不是原子的?
  2. withdraw 内部 balance_ -= m 不抛——log->write 抛了为什么会破坏一致性?
  3. 为什么测试环境永远过、只有生产偶发?
  4. 业务方 catch 后重试,为什么 A 账户会被扣 198?
  5. 怎么改才能做到"要么都成、要么都不成"?

根因:transfer 函数仅满足无保证(甚至没达到基本保证)——withdraw 已副作用 + log->write 抛异常 + deposit 未执行 = 状态损坏。

五个疑问的精确解答:

# 疑问 真相
1 两行不是原子的吗? C++ 里"几行代码"和"事务"无关。语句之间任何一行都能抛——除非显式用 RAII + Copy-and-Swap 保证强承诺。
2 为啥 log 抛了会损坏? withdraw 是"扣款 + 写日志"两步——扣款已发生且不抛,写日志抛了,副作用已留下。这就是经典的"半成功"。
3 测试为啥过? 测试环境没有 IO 故障——log->write 从不抛——所以异常路径从未被触发。没测过的路径就是有 bug 的路径。
4 重试为啥扣 198? 业务方 catch 后以为"什么都没发生",重新调 transfer——第一次的 99 已经扣了,第二次又扣 99。只有强保证才能让业务方安全重试。
5 怎么改? 三层修复方案,见下。

修复方案(三层):

A. 立即止血(5 分钟):调整 withdraw 的实现,让"写日志"在"改余额"之前:

void withdraw(Money m) {
    log_->write("-", m);     // 先写日志(可能抛)
    balance_ -= m;           // 再改余额(不抛)
}
1
2
3
4

这样 log->write 抛了余额没变,至少从"无保证"升到"基本保证"。但 transfer 整体仍不是强保证——from.withdraw 成功后 to.deposit 抛了仍会半成功。

B. 设计加固(1 天):用 Copy-and-Swap 风格的 Transaction:

class Transfer {
    Account& from_;
    Account& to_;
    Money m_;
    bool committed_ = false;
public:
    Transfer(Account& f, Account& t, Money m) : from_(f), to_(t), m_(m) {}

    void execute() {
        // 阶段 1:可能抛,但都在副本
        auto from_copy = from_;
        auto to_copy   = to_;
        from_copy.withdraw(m_);    // 抛?from_ 不变
        to_copy.deposit(m_);       // 抛?from_、to_ 不变

        // 阶段 2:原子 swap
        using std::swap;
        swap(from_, from_copy);
        swap(to_, to_copy);
        committed_ = true;
    }
};

void transfer_strong(Account& from, Account& to, Money m) {
    Transfer(from, to, m).execute();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

任何阶段抛——from、to 都不变。业务方可以安全重试。

C. 系统兜底(1 周):

  1. CI 加异常注入测试,每个公有方法注入 IO 异常验证强保证;
  2. lint 检查 noexcept 的使用规范;
  3. 监控埋点:扣款成功率 vs 到账成功率,差值即为对账不平规模;
  4. 压测增加"50% IO 失败率"场景,观察账务一致性。

# 主线二:32 行泄漏

疑问回顾:

  1. new 完了 delete,为什么 valgrind 报泄漏?
  2. ~Compound 写了释放代码,为什么没跑?
  3. ASan 为什么直接报到了 Compound::Compound() 第 18 行?
  4. 怎么改才能"构造失败也不漏"?

根因:构造函数抛异常的对象不调析构——a 已分配但 ~Compound 永不会跑。

# 疑问 真相
1 delete 都写了为啥漏? delete 在 ~Compound 里——但 Compound 构造抛了,C++ 标准明确"未完整构造的对象不调析构"。
2 ~Compound 为啥不跑? 同上。只有完整构造的对象才有析构。
3 ASan 怎么定位到第 18 行? ASan 的 leak detector 在退出时扫描堆,找出"已分配但无指针指向"的内存,记录它们的分配点——18 行是 new Resource(1) 的位置。
4 怎么改? 把裸指针成员改成 unique_ptr,已构造成员会自动析构。

修复方案:

A. 立即修复——智能指针成员:

struct Compound {
    std::unique_ptr<Resource> a;
    std::unique_ptr<Resource> b;
    Compound()
        : a(std::make_unique<Resource>(1))   // a 是已构造成员
        , b(std::make_unique<Resource>(2))   // b 抛 → a 自动析构
    {}
    // ~Compound 编译器生成的就够了
};
1
2
3
4
5
6
7
8
9

B. 全局排查:

# 找出所有"裸指针成员 + 构造里 new"的模式
grep -rE '^\s*[A-Z][a-zA-Z]*\*\s+[a-z_]+\s*;' src/ --include='*.h'
# 一一改成 unique_ptr / shared_ptr 成员
1
2
3

C. 编码规范固化:第 12.4 节六条军规,CI 强制 lint。

# 13.2 一次异常的一生

把主线一的整个异常路径画成 ASCII 知识树:

                  [transfer 入口]
                       │
                       │ from.withdraw(99)
                       ▼
            ┌──────────────────────┐
            │ withdraw 内部:        │
            │   balance_ -= 99     │ ← 这一步 noexcept
            │   log_->write("-",99)│ ← 这一步可能抛
            └──────────┬───────────┘
                       │
                       │ log->write 抛 IoError
                       ▼
            ┌──────────────────────┐
            │ 异常对象在堆上构造     │
            │ std::runtime_error    │
            │   what="disk full"   │
            └──────────┬───────────┘
                       │
                       │ 栈展开开始
                       ▼
            ┌──────────────────────┐
            │ withdraw 帧析构       │
            │   (无栈对象需析构)    │
            └──────────┬───────────┘
                       │
                       ▼
            ┌──────────────────────┐
            │ transfer 帧析构       │
            │   (无栈对象需析构)    │
            └──────────┬───────────┘
                       │
                       │ 继续上抛
                       ▼
            ┌──────────────────────┐
            │ 业务方 catch:         │
            │   "transfer failed"  │
            │   → 重试            │
            └──────────┬───────────┘
                       │
                       │ ⚠️ 状态损坏:
                       │    A.balance = 901  (扣过了!)
                       │    B.balance = 0    (没到账)
                       ▼
            ┌──────────────────────┐
            │ 第二次 transfer       │
            │   再扣 99            │
            │   A.balance = 802    │  ← 扣了 198!
            │   B.balance = 99     │
            └──────────────────────┘
                       │
                       │ 修复:Copy-and-Swap
                       ▼
            ┌──────────────────────┐
            │ Transfer::execute    │
            │   1. from_copy/to_copy 拷贝│
            │   2. 副本上操作(可能抛)  │
            │   3. swap 原子切换        │
            │                          │
            │ 抛了? from/to 完全不变  │
            │ 成了? 一次性切换         │
            │ 业务方安全重试 ✅         │
            └──────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

关键认知:

  • 异常路径不是"出错",而是"程序在另一条轨道上继续走";
  • 这条轨道上的状态是否合法、是否一致——完全由开发者设计决定;
  • 设计异常路径 = 设计程序的"另一半灵魂"。

# 13.3 设计哲学回扣

整理本篇四条跨篇适用的设计哲学:

哲学 1:资源的生命周期 = 对象的生命周期

C++ 没有 finally 关键字,因为它有更强的工具:栈对象的确定性析构。把"获取-释放对"绑到"构造-析构对"——编译器替你写 try/finally,比手写更可靠、更优雅、零运行时开销。这是 C++ 相对其他语言最独特的设计胜利。

哲学 2:异常路径是 API 的一部分

不写注释说明"基本保证 / 强保证 / noexcept"的函数,等于不告诉调用方"返回值含义"——是接口设计的硬伤。API 文档至少要包含两部分:返回值含义 + 异常承诺。Java/Python 用 throws 子句或文档约定,C++ 用注释 + noexcept 标记,本质相同。

哲学 3:默认基本保证,关键点强保证

强保证不便宜(Copy-and-Swap 多一次拷贝,Transaction 类多一层封装)。所以——默认目标是基本保证(不泄漏、状态合法),只有"业务上必须事务"的地方做强保证(支付、订单、文件覆盖、配置切换)。滥用强保证 = 性能崩盘,反过来该强保证而做基本保证 = 业务事故。

哲学 4:noexcept 是契约,不是装饰

noexcept 不是"我希望不抛"——它是告诉编译器和调用方"我保证不抛,可以做相关优化"。vector::push_back 看到 noexcept move 就启用 move 扩容、Copy-and-Swap 依赖 noexcept swap 实现强保证——这些都是基于契约的优化链路。一旦契约被违反,整条链路崩塌:terminate。所以 noexcept 比注释更严肃,标了就必须做到。

# 13.4 异常安全速查表

一张图保存以备查:

等级 含义 实现套路 典型场景
无保证 抛了泄漏、损坏 ❌ 不允许 不应出现
基本保证 抛了不泄漏、状态合法 RAII 默认目标
强保证 抛了状态完全不变 Copy-and-Swap / Transaction 支付/事务/不可重试
不抛保证 永不抛 noexcept + 仅 POD/指针操作 析构/swap/move

60 秒诊断命令清单:

# 1. 编译期最便宜的保证
g++ -Wall -Wextra -Werror -Wnon-virtual-dtor a.cpp

# 2. 找出所有裸 new/delete(必须 ≈ 0 个)
grep -rE '\bnew\s+[A-Za-z_]' src/ --include='*.{h,cpp,cc}' | grep -v make_
grep -rE '\bdelete\s+[a-z_]' src/ --include='*.{h,cpp,cc}'

# 3. 找出未标 noexcept 的移动/swap
grep -rE 'operator=\([^)]*&&[^)]*\)\s*\{' src/ | grep -v noexcept
grep -rE 'void swap\(' src/ | grep -v noexcept

# 4. ASan + leak 跑测试
g++ -fsanitize=address,leak,undefined -fno-omit-frame-pointer -g a.cpp
ASAN_OPTIONS=detect_leaks=1 ./a.out

# 5. clang-tidy 异常安全检查
clang-tidy --checks='modernize-make-unique,modernize-make-shared,\
performance-noexcept-move-constructor,performance-noexcept-swap,\
cppcoreguidelines-owning-memory'

# 6. 异常注入测试
# 在 mock 对象的关键方法里加 throw_after_ 计数器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

等级 → 实现速查:

要做到什么? → 实现套路 → 验证方式
─────────────────────────────────────────────
基本保证   → RAII(unique_ptr / lock_guard)  → ASan + leak
强保证     → Copy-and-Swap 或 Transaction     → 异常注入测试
不抛保证   → noexcept + 仅 POD 操作            → static_assert(noexcept(...))
1
2
3
4
5

RAII 速查模板:

// 1. 独占资源(首选)
class Guard {
    Resource r_;
public:
    Guard(...) : r_(acquire()) {}
    ~Guard() noexcept { release(r_); }

    Guard(const Guard&) = delete;
    Guard& operator=(const Guard&) = delete;
    Guard(Guard&&) noexcept = default;
    Guard& operator=(Guard&&) noexcept = default;
};

// 2. 共享资源
class Shared {
    std::shared_ptr<Resource> r_;
public:
    Shared() : r_(std::make_shared<Resource>()) {}
};

// 3. Scope Guard 一次性清理
auto guard = make_guard([&]{ cleanup(); });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

异常等级决策树:

我的函数要承诺什么?
├── 没有副作用 / 已经回滚 → 基本保证(用 RAII)
├── 失败后状态必须不变  → 强保证(Copy-and-Swap)
├── 我自己绝对不抛       → noexcept
└── 我不知道             → 这是设计缺陷
1
2
3
4
5

# 13.5 思考题

  1. 你写了一个 Logger 类,析构里要把缓冲区刷盘——但磁盘满了 fwrite 返回错误。如果你 throw 会发生什么?如果你不 throw,错误怎么报告?正确做法是什么?

  2. std::vector<std::unique_ptr<Widget>> 的 push_back 是强保证吗?为什么?如果不是,怎么改才能强保证?

  3. 你的 Transaction 类析构里调 ROLLBACK,但 ROLLBACK 内部又抛 DbError 怎么办?标准实践是吞掉还是 terminate?为什么?

  4. 写一个 std::scoped_lock 的 RAII 等价物(不要看实现)。注意:要支持死锁避免(两把锁按地址序加锁)。这个类的移动构造能 noexcept 吗?拷贝呢?

  5. 函数 void f(std::shared_ptr<A> a, std::shared_ptr<A> b),你写 f(std::make_shared<A>(), std::make_shared<A>())。C++17 之前这是异常安全的吗?C++17 之后呢?为什么?

  6. 你的 MyVector::push_back 想做强保证。你设计了"先 reserve(容量翻倍)+ 再 construct"两阶段。如果 construct 抛了怎么办?reserve 抛了呢?画出异常路径决策树。

  7. noexcept 标记会传染——f() noexcept 调用 g()(g 没标 noexcept)。这种情况下编译器会做什么?运行时会做什么?怎么避免"看似 noexcept 但实际能抛"?

  8. 你的类有一个 std::function<void()> 成员存放回调。析构时调一下这个回调——但回调可能抛任意异常。怎么设计析构函数才能在"调回调"和"析构 noexcept"之间平衡?

  9. C++ 标准库的 <filesystem> API 大多有"异常版"和"错误码版"两套(copy vs copy(..., ec))。这种双 API 设计的好处和坏处分别是什么?什么时候你应该提供错误码版?

  10. 假设你要给一个团队 30 分钟做"异常安全培训"——只能讲三个要点,你会讲哪三个?为什么?(提示:不是讲三等级,是讲"日常代码里最容易踩什么坑"+"怎么自动化兜底")


异常安全不是"会用 try/catch",而是"对每条异常路径都有明确承诺"。 RAII 不是"用智能指针包一下",而是"把生命周期作为类型系统的一部分"。 noexcept 不是"写起来更短",而是"接受一份契约的代价"。

下一篇:到此 01.信号崩溃 → 02.ASan内存 → 03.GDB速查 → 04.CoreDump → 05.perf火焰 → 06.迭代器失效 → 07.智能指针选型 → 08.异常安全与RAII,C++ 工程排查与设计八件套形成完整闭环:从"怎么从信号定位行号"到"怎么从所有权设计杜绝泄漏"再到"怎么用 RAII 让异常路径也正确"。配套阅读:01.进程地址空间布局(理解栈、堆的真实形状,所有 RAII 与异常展开都要落到这张图上)。

上次更新: 2026/06/16, 21:22:30
智能指针选型
多线程锁选型

← 智能指针选型 多线程锁选型→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式