异常安全RAII
# 第29章:异常安全与 RAII
# 目录介绍
- 1. 案例引入:两条主线
- 2. 异常机制总图
- 3. 异常安全三等级
- 4. RAII 核心机制
- 5. 智能指针武器库
- 6. Copy-and-Swap 强保证
- 7. 移动语义与 noexcept
- 8. Scope Guard 终极清理
- 9. 多资源协同管理
- 10. 五步设计方法论
- 11. 典型场景速查
- 12. 工程化最佳实践
- 13. 综合案例串讲
# 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 步:到账
}
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
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
}
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
2
3
4
5
6
7
8
9
10
11
两个现象非常扎眼:
- alloc 1 打出来了,free 1 没打 ——
Resource(1)分配了但没析构; ~Compound完全没调 —— 构造抛异常的对象不会调析构。
好的调试,第一步永远是把问题简化到 MCVE。复杂工程里的资源泄漏无非三种:
- 构造途中抛——本案例,析构永远不跑;
- 多资源中间抛——第 9 章主题;
- 析构里抛——直接
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 章
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");
throw 一条表达式做了三件事:
- 拷贝构造异常对象:在异常专用堆区(不是用户堆)分配空间,把表达式值拷过去;
- 查找匹配的 catch:从当前栈帧开始,沿
.gcc_except_table表往上找匹配 catch 块; - 栈展开:从抛出点开始,一帧一帧析构每个栈帧里的自动对象,直到走到 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,丢失子类信息
}
2
3
4
5
6
# 2.2 栈展开过程
throw 到 catch 之间的栈帧,会按 LIFO 顺序销毁。每销毁一帧:
┌─────────────────────────────────────────────────┐
│ 1. 标记该帧为"展开中" │
│ 2. 找到该帧所有自动对象(局部变量) │
│ 3. 按构造逆序调用析构函数 │
│ 4. 弹出该帧,跳到上一帧 │
└─────────────────────────────────────────────────┘
2
3
4
5
6
ASCII 图示:
throw 抛出点
│
▼
┌──────────────────────────────────┐
│ 当前帧 │
│ 局部对象 z (析构 z) │ ← LIFO 析构
│ 局部对象 y (析构 y) │
│ 局部对象 x (析构 x) │
└──────────────────────────────────┘
│ 上溯
▼
┌──────────────────────────────────┐
│ 调用者帧 │
│ 局部 RAII 对象 (析构) │
│ ... │
└──────────────────────────────────┘
│
▼ 找到匹配的 try/catch
┌──────────────────────────────────┐
│ catch (...) { │
│ 处理异常 │
│ } │
└──────────────────────────────────┘
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
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()
}
2
3
4
5
6
7
8
9
机制原因:展开器在调用析构函数时设了"已在展开中"标记。如果析构函数再抛,相当于"两个异常对象同时活着"——C++ 标准没法定义"该传播哪个",干脆 terminate 了事。
判断"现在是否在展开中":
struct Logger {
~Logger() {
if (std::uncaught_exceptions() > 0) {
// 正在展开中——不要再抛了!
try { flush(); } catch (...) {}
} else {
flush();
}
}
};
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
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;
}
2
3
4
5
特征:
- 抛异常后资源泄漏、状态损坏、不变量破坏;
- 现代 C++ 代码里不应该出现这一级——主线一和主线二都是这一级的反例。
# 3.2 基本保证:不泄漏
void basic_guarantee() {
auto p = std::make_unique<Resource>();
do_something_that_might_throw(); // 抛 → p 自动 delete
} // 资源不漏,但程序状态可能改变
2
3
4
承诺:
- 不泄漏资源——所有 RAII 对象的析构都会跑;
- 不变量保持——对象处于"可以析构、可以赋新值"的合法状态;
- 副作用可能已发生——但程序整体没崩。
典型场景:std::vector::insert 在容器已经满时抛 bad_alloc——容器仍然合法,但旧值可能已经在中间步骤被移动过。
主线一改造为基本保证:
void transfer_basic(Account& from, Account& to, Money m) {
from.withdraw(m); // ← 这一行抛了,from 仍合法(余额未变,因为 += 不抛)
to.deposit(m); // ← 这一行抛了,from 已经扣了,to 没收到
} // 状态不一致,但没崩
2
3
4
这就是真实的"基本保证"——不崩,但业务半成功。对支付场景而言,基本保证远远不够。
# 3.3 强保证:事务回滚
void strong_guarantee() {
// 要么全成功(状态改变),要么全失败(状态完全不变)
// 不会出现"半成功"
}
2
3
4
承诺:
- 不泄漏(自动含基本保证);
- 状态原子性——如果抛异常,程序状态与调用前完全相同(事务回滚)。
实现套路:
- Copy-and-Swap(第 6 章主题):先在临时变量里做所有可能抛的操作,最后用
noexcept swap一步切换; - 两阶段提交:分"准备"和"提交"两段,准备阶段可抛但不修改状态,提交阶段不抛。
主线一改造为强保证:
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
}
2
3
4
5
6
7
8
9
10
11
12
任何阶段抛异常,from 和 to 的原值都没动——这就是事务。
# 3.4 不抛保证:noexcept
void no_throw() noexcept { /* 永远不抛 */ }
承诺:函数绝不抛出异常。如果违反(实际抛了),程序立即 std::terminate()——比"基本保证泄漏"还严重。
适用场景(也是必须的):
- 析构函数(默认 noexcept);
- 移动构造 / 移动赋值(第 7.2 节会解释为什么必须);
swap函数(强保证的基础);- 内存释放函数(
operator delete)。
// 标准库的强承诺
~std::vector() noexcept; // 必须
std::vector(vector&&) noexcept; // 必须(不然 push_back 不能强保证)
void swap(vector& other) noexcept; // 必须
2
3
4
# 3.5 等级对照与抉择
┌─────────────────────────────────────────────────────────────┐
│ 等级 │ 泄漏? │ 状态? │ 典型 API │
├─────────────────────────────────────────────────────────────┤
│ 无保证 │ 会 │ 可能损坏 │ ❌ 不应出现 │
│ 基本保证 │ 不会 │ 合法但改变│ ★ 默认目标 │
│ 强保证 │ 不会 │ 完全回滚 │ 事务/支付/不可重试操作 │
│ 不抛保证 │ 不会 │ 完全不变 │ 析构/swap/move/key 函数 │
└─────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
选型决策树:
这个函数对外承诺什么?
├── "我自己保证不抛" → noexcept
├── "失败时状态回到调用前" → 强保证(Copy-and-Swap / 两阶段)
├── "失败时不泄漏、可继续" → 基本保证(默认)
└── "我也不知道" → 这是个 bug
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
} // ③ 释放
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 三大铁律:
- 构造函数获取资源——失败立即抛异常,永不出现"半构造"对象;
- 析构函数释放资源——
noexcept,不抛; - 拷贝/移动语义明确——独占类资源禁拷贝、允许移动(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;
};
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;
};
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 析构即释放
析构函数三铁律:
- noexcept(C++11 起,析构默认 noexcept);
- 释放所有资源——内存、fd、锁、引用计数;
- 不依赖外部状态——析构时可能在异常展开中、可能在程序结束阶段,全局对象/静态对象状态未知。
反模式:
class Logger {
~Logger() {
// ❌ 析构时调用其他全局对象
GlobalLog::get().write("destroyed"); // 全局对象可能已析构!
// ❌ 析构时抛异常
if (!flushed_) throw std::runtime_error("unflushed!");
// ❌ 析构时做耗时操作
for (int i = 0; i < 1e8; ++i) ...;
}
};
2
3
4
5
6
7
8
9
10
# 4.4 栈上对象的力量
RAII 起作用的关键是栈对象的"确定性析构"——编译器保证每个栈对象的析构会被调用,无论函数怎么退出。
函数有 4 种退出路径
│
┌──────────┬──────┼──────┬──────────┐
▼ ▼ ▼ ▼ ▼
正常 return goto 异常 longjmp exit/abort
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
析构 ✅ 析构 ✅ 析构 ✅ ❌不调 ❌不调
(UB) (除非全局对象)
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
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 不调
{}
};
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
2
3
4
5
异常安全特性:
- 构造抛异常 ——
make_shared内部 catch 后释放控制块; - 拷贝(
sp2 = sp)抛 —— 控制块原子操作 noexcept,实际拷贝构造从不抛; - 析构 —— 引用计数原子减一,到 0 才真正 delete。
多线程下的异常安全:
// 线程 A
std::shared_ptr<Widget> sp = get_shared();
// 线程 B 同时
sp.reset(); // 控制块的引用计数操作是原子的,不会撕裂
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);
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 的内存永远泄漏
2
3
4
5
6
7
8
9
make_shared 把"分配 + 构造 + 包装"打包成一个 noexcept 的原子操作——参数求值顺序问题完全消失:
process(std::make_shared<Widget>(), // 异常安全
std::make_shared<Widget>()); // 异常安全
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); }
);
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>;
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 崩溃
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(); });
}
};
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;
}
};
2
3
4
5
6
7
8
9
10
11
12
13
漏洞:
- ② 抛异常 →
data_已经被delete,但还指向野指针,析构会 double free; - 即使把
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 析构掉旧数据
};
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
关键三步:
- 按值接收参数
Buffer rhs—— 拷贝在参数构造时发生。这一步可能抛bad_alloc,但只在rhs这个局部副本上发生,*this完全没动; swap(*this, rhs)noexcept —— 把副本(含完整数据)与*this(含旧数据)原子交换;- 函数返回,
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
}
2
3
4
5
swap 不抛的方法:
- 只交换指针 / int / 小 POD——这些类型
std::swap默认 noexcept; - 不要 swap 容器值——
std::swap(vector, vector)通常 noexcept(指针交换),但std::swap(small_buf_vec, small_buf_vec)在 SSO 触发时可能涉及拷贝; - 自定义类的 swap 显式 noexcept——并确保实现真的不抛。
反模式:
friend void swap(BigObject& a, BigObject& b) { // ❌ 没标 noexcept
BigObject tmp = a; // ❌ 拷贝可能抛 bad_alloc
a = b;
b = tmp;
}
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
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;
}
};
2
3
4
5
6
7
8
9
10
11
核心规则:
- 窃取资源——只拷贝指针/句柄,不拷贝数据;
- 置空源对象——保证源对象处于"可析构、可赋新值"的状态(不要求与默认构造的对象相同,但必须合法);
- 不抛异常——通常只做指针操作 + 置空,天然不会抛。
# 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_;
}
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,快
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
};
2
3
4
5
6
7
8
9
10
五大 noexcept 必选:
- 析构函数;
- 移动构造函数;
- 移动赋值运算符;
- swap 函数;
- 默认构造函数(如果不分配内存)。
默认行为:
- 编译器生成的析构、移动、swap 会自动推导 noexcept(如果所有成员都满足);
- 但自己写的版本默认不 noexcept——必须显式标注。
# 7.4 析构函数禁抛
struct Bad {
~Bad() { // ❌ 没标 noexcept
if (some_condition) throw std::runtime_error("oops");
// → 一旦在栈展开中析构抛 → std::terminate()
}
};
2
3
4
5
6
C++11 起,析构函数默认 noexcept——即使你不标,编译器也按 noexcept 处理。如果你真的想让析构抛(强烈不推荐),要显式 noexcept(false):
struct UnsafeDestructor {
~UnsafeDestructor() noexcept(false) { // 显式告诉编译器"我可能抛"
if (!flushed) throw std::runtime_error("not flushed!");
}
};
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()
}
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)));
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
}
}
};
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);
}
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)); }
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
}
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(); // ④ 都成了,不回滚
}
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(); }}; // 仅正常退出时跑
}
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; }
};
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 自动析构
{}
};
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; }
};
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 永远泄漏
2
3
4
5
6
7
正解:用 make_unique 把"new + 包装"绑死:
process(std::make_unique<Widget>(),
std::make_unique<Widget>());
// 求值序:①.make_unique(含 new + 包装,原子)→ ②.make_unique
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_ 自动析构
}
};
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 已完整定义
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 + 异常注入 │
└─────────────────────────────────────────┘
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
├── 失败时可以多扣但不能少收 → 基本保证够
└── 失败时啥都不发生最好 → 强保证
2
3
4
主线一的业务场景"双账户转账"——业务方一定要求强保证(事务回滚)。所以:
void transfer(Account& from, Account& to, Money m) /* 强保证 */ {
// ...
}
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);
}
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 流水线:
-Wall -Wextra -Werror—— 编译期最便宜的保证;- ASan + UBSan —— 跑所有测试,包括异常注入;
-fsanitize=leak—— 单独跑一遍,确保异常路径不漏;-D_GLIBCXX_DEBUG—— 容器调试模式,迭代器失效立刻抓;- valgrind --error-exitcode=1 —— 对关键场景跑一遍 valgrind;
- 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,
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;
};
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 内部会释放并重新获取
}
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
}
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
}
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 永远泄漏
}
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>;
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);
}
};
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;
};
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));
}
};
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);
}
2
3
4
5
6
7
8
9
10
# 12.4 团队规范六条
把本篇浓缩成 6 条上墙规范:
- 永远
make_unique/make_shared——绝不裸new/delete(除非自定义删除器); - 所有 RAII 类必须:拷贝 = delete / 移动 = noexcept / 析构 = noexcept;
- 构造函数失败用异常,业务错误用
expected; - 析构函数绝不抛(默认 noexcept,捕获后吞掉);
- 多资源用智能指针成员或拆分子 RAII 类,禁止裸
T* a; T* b;; - 强保证函数标注在注释:
/// @brief 强保证:...,并写异常注入测试。
# 12.5 lint 与 CI 兜底
编译期:
g++ -Wall -Wextra -Werror -Wnon-virtual-dtor -Weffc++ \
-fsanitize=address,undefined -fno-omit-frame-pointer -g
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
2
3
4
5
6
7
8
9
10
11
12
13
CI 强制:
- ASan + UBSan + leak 跑所有单测;
- 异常注入测试覆盖率 > 80%(每个公有方法至少一条"主线异常路径"测试);
- valgrind 跑关键场景(每日构建一次);
- 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 个问题):
- 代码看起来"两行而已",为什么不是原子的?
withdraw内部balance_ -= m不抛——log->write抛了为什么会破坏一致性?- 为什么测试环境永远过、只有生产偶发?
- 业务方 catch 后重试,为什么 A 账户会被扣 198?
- 怎么改才能做到"要么都成、要么都不成"?
根因: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; // 再改余额(不抛)
}
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();
}
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 周):
- CI 加异常注入测试,每个公有方法注入 IO 异常验证强保证;
- lint 检查
noexcept的使用规范; - 监控埋点:扣款成功率 vs 到账成功率,差值即为对账不平规模;
- 压测增加"50% IO 失败率"场景,观察账务一致性。
# 主线二:32 行泄漏
疑问回顾:
new完了delete,为什么 valgrind 报泄漏?~Compound写了释放代码,为什么没跑?- ASan 为什么直接报到了
Compound::Compound()第 18 行? - 怎么改才能"构造失败也不漏"?
根因:构造函数抛异常的对象不调析构——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 编译器生成的就够了
};
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 成员
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 完全不变 │
│ 成了? 一次性切换 │
│ 业务方安全重试 ✅ │
└──────────────────────┘
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_ 计数器
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(...))
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(); });
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
└── 我不知道 → 这是设计缺陷
2
3
4
5
# 13.5 思考题
你写了一个
Logger类,析构里要把缓冲区刷盘——但磁盘满了 fwrite 返回错误。如果你 throw 会发生什么?如果你不 throw,错误怎么报告?正确做法是什么?std::vector<std::unique_ptr<Widget>>的push_back是强保证吗?为什么?如果不是,怎么改才能强保证?你的
Transaction类析构里调ROLLBACK,但 ROLLBACK 内部又抛DbError怎么办?标准实践是吞掉还是 terminate?为什么?写一个
std::scoped_lock的 RAII 等价物(不要看实现)。注意:要支持死锁避免(两把锁按地址序加锁)。这个类的移动构造能 noexcept 吗?拷贝呢?函数
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 之后呢?为什么?你的
MyVector::push_back想做强保证。你设计了"先 reserve(容量翻倍)+ 再 construct"两阶段。如果 construct 抛了怎么办?reserve 抛了呢?画出异常路径决策树。noexcept标记会传染——f() noexcept调用g()(g 没标 noexcept)。这种情况下编译器会做什么?运行时会做什么?怎么避免"看似 noexcept 但实际能抛"?你的类有一个
std::function<void()>成员存放回调。析构时调一下这个回调——但回调可能抛任意异常。怎么设计析构函数才能在"调回调"和"析构 noexcept"之间平衡?C++ 标准库的
<filesystem>API 大多有"异常版"和"错误码版"两套(copyvscopy(..., ec))。这种双 API 设计的好处和坏处分别是什么?什么时候你应该提供错误码版?假设你要给一个团队 30 分钟做"异常安全培训"——只能讲三个要点,你会讲哪三个?为什么?(提示:不是讲三等级,是讲"日常代码里最容易踩什么坑"+"怎么自动化兜底")
异常安全不是"会用 try/catch",而是"对每条异常路径都有明确承诺"。 RAII 不是"用智能指针包一下",而是"把生命周期作为类型系统的一部分"。 noexcept 不是"写起来更短",而是"接受一份契约的代价"。
下一篇:到此 01.信号崩溃 → 02.ASan内存 → 03.GDB速查 → 04.CoreDump → 05.perf火焰 → 06.迭代器失效 → 07.智能指针选型 → 08.异常安全与RAII,C++ 工程排查与设计八件套形成完整闭环:从"怎么从信号定位行号"到"怎么从所有权设计杜绝泄漏"再到"怎么用 RAII 让异常路径也正确"。配套阅读:01.进程地址空间布局(理解栈、堆的真实形状,所有 RAII 与异常展开都要落到这张图上)。