更新时间: 2026-08-27
54 篇讲异常传播时提了一句"栈展开",42 篇讲 RAII 时也说"析构必然执行"——这两个机制是怎么配合的?异常从 throw 到 catch 之间的那段路,路上发生了什么?本篇把这条路径完整拆开。
本文要回答:栈展开到底是什么?展开过程中对象的析构顺序怎样?RAII 为什么在异常路径下依然可靠?
一、什么是栈展开
"栈展开(stack unwinding)"指的是:异常抛出后,控制权沿调用链向上跳,每退出一层函数,就把这一层栈帧上的局部对象全部销毁。
先看一个三层调用的例子:
#include <stdexcept>
struct Guard {
int id;
~Guard() { std::printf("析构 #%d\n", id); }
};
void c() { throw std::runtime_error("boom"); }
void b() {
Guard g(2);
c(); // 抛异常
std::printf("b 的收尾\n"); // 不会执行
}
void a() {
Guard g(1);
b();
}
int main() {
try {
a();
} catch (const std::exception& e) {
std::printf("捕获: %s\n", e.what());
}
}输出顺序:
析构 #2 (退出 b 的栈帧,销毁 g(2))
析构 #1 (退出 a 的栈帧,销毁 g(1))
捕获: boom (回到 main 的 catch)注意 b 里 c() 之后的 printf 根本没执行——异常一跳,b 的剩余代码全被跳过,但 b 的局部对象 g(2) 照常析构。这就是"展开"的含义:像剥洋葱一样,一层层退出调用栈,每层栈帧的局部对象依次析构,直到找到 catch。
@startuml
top to bottom direction
skinparam nodeFontSize 13
skinparam backgroundColor #FFFFFF
card "main → try { a() } → catch" as main #D9FFE2
card "a() → 局部 g(1)" as a #E8F1FF
card "b() → 局部 g(2)" as b #E8F1FF
card "c() → throw" as c #FFD9D9
main -down-> a
a -down-> b
b -down-> c
c -right-> b : "① 抛异常,b 退出\n② g(2) 析构"
b -right-> a : "③ a 退出\n④ g(1) 析构"
a -right-> main : "⑤ 到 catch\n⑥ 处理异常"
@enduml二、析构顺序与"必然执行"
栈展开的两条硬规则:
- 析构顺序 = 构造逆序:同一作用域内后构造的先析构(和正常离开作用域一样)
- 析构必然执行:只要对象在该栈帧上(且还没析构完),展开路径上一定会析构它
第二条就是 RAII 的基石。回想 42 篇的话:无论正常 return、中间 return、还是抛异常,局部对象都会析构。栈展开把"异常"这个最容易让人忘记清理的路径,也变得和正常路径一样可靠。
一个容易忽略的细节:异常抛出的那一刻,还没构造完的"半成品"怎么办? C++ 保证:构造函数抛异常时,已经构造好的成员会被逐个析构,但对象本身的析构函数不会执行(因为对象没构造完)。这条规则防止了"构造一半的对象被错误地完整析构"。
三、析构函数里不能再抛异常
栈展开期间正在析构对象,如果这个析构函数自己又抛异常——两个异常同时活跃,C++ 直接调用 std::terminate 终止程序。这就是 19 篇"析构不抛异常"约定的根源:
struct Bad {
~Bad() {
throw std::runtime_error("析构里抛异常"); // 危险!
}
};如果 Bad 的析构在栈展开过程中执行(因为别的异常),又抛一个异常,程序直接崩。所以工程铁律:析构函数必须 noexcept(默认就是),有错也吞掉或记录,绝不抛出。57 篇会细讲 noexcept 的机制。
四、RAII + 栈展开:异常安全的完整拼图
把 42 篇的 RAII 和本篇的栈展开拼起来,就是 C++ 异常安全的完整图景:
#include <mutex>
#include <vector>
#include <stdexcept>
void process_all(std::mutex& mtx, const std::vector<int>& data) {
std::lock_guard<std::mutex> lock(mtx); // 上锁(RAII)
for (int x : data) {
if (x < 0) {
throw std::runtime_error("负数"); // 异常!
}
// 处理 x……
}
} // 无论异常还是正常结束:lock_guard 析构 → 自动解锁throw 之后栈展开,lock_guard 的析构自动 unlock()——锁不会因为异常而卡死。这比手写 lock()/unlock() 强在哪?手写版本遇到异常直接跳过 unlock(),锁永远不释放,其它线程全部阻塞——比崩溃还难排查的 bug。RAII 把这条路径也堵死了。
@startuml
left to right direction
skinparam nodeFontSize 13
skinparam backgroundColor #FFFFFF
rectangle "try { ... }" as tryb #E8F1FF {
node "lock_guard 构造 → 上锁" as l1 #DCE9FF
node "业务代码抛异常" as ex #FFD9D9
node "栈展开 → lock_guard 析构 → 解锁" as l2 #D9FFE2
}
l1 --> ex
ex --> l2
@enduml五、异常安全级别的概念
由栈展开 + RAII 引出一个重要概念——异常安全保证的等级。函数面对异常时能做到什么程度:
| 级别 | 含义 | 例子 |
|---|---|---|
| 不保证 | 异常后对象可能处于任意状态 | 手写裸指针管理 |
| 基本保证 | 对象状态合法但不明确 | 普通容器操作 |
| 强保证 | 操作要么成功要么回滚 | std::vector 的 push_back(拷贝版本) |
| no-throw | 绝不抛异常 | 析构、swap、move |
工程目标通常是"基本保证"起步,关键路径追求"强保证"(如容器插入用拷贝构造保证回滚)。这个主题在高手层 exception-safety.md 有完整展开,本篇先建立"级别"这个心智框架。
六、C 对照
C 语言没有异常,但它有类似的"提前退出"路径——goto cleanup 模式模拟了栈展开:
int process(void) {
FILE* f = fopen("a.txt", "r");
if (!f) return -1;
int* buf = malloc(1024);
if (!buf) {
fclose(f); // 清理路径 1
return -2;
}
if (parse(f, buf) < 0) {
free(buf); // 清理路径 2
fclose(f);
return -3;
}
free(buf);
fclose(f);
return 0;
}| 维度 | C(goto cleanup / 逐路径清理) | C++(栈展开 + RAII) |
|---|---|---|
| 错误传播 | 返回码逐层传 | 异常自动沿栈传播 |
| 中途退出清理 | 每个分支手动写 | 自动析构 |
| 新增清理点 | 每加一个资源都要改所有分支 | 加一个 RAII 对象即可 |
| 漏掉分支 | 泄漏(编译不报错) | 不可能漏(编译器保证) |
| 多重资源 | 清理顺序手动控制 | 析构顺序自动(逆序) |
C 的 goto cleanup 在"错误路径不多、资源少"时还能应付;一旦函数有四五种资源、七八条错误路径,"每个分支都要清理"就变成了维护噩梦。栈展开把这件事从"手工枚举"变成"自动执行"。
七、与本站主线衔接
- 42 篇(RAII):思想;本篇是它的机制基础
- 54/55 篇:异常的抛出与捕获、类型
- 57 篇(noexcept):栈展开与 noexcept 的交互
- 19 篇(析构函数):析构不抛异常的约定
crash/崩溃排查:未捕获异常 → terminate → abort,栈展开失败也会终止- 高手层
exception-safety.md:异常安全级别的完整讨论
八、一句话总结
栈展开是异常沿调用链传播时"一层层退出函数并逆序析构局部对象"的机制,它让析构在异常路径下也必然执行——RAII 因此成为异常安全的基石;代价是析构函数绝不能抛异常,否则 std::terminate 直接终止程序。