1. 从一段“看起来没什么问题”的代码开始:异常抛出后到底发生了什么
先看一段很普通的代码。我经常拿它当面试的暖场题,也是很多C++初学者第一次意识到“异常机制并不简单”的起点。你可以在自己的开发环境里原样跑一遍,看输出是否和你预期一致:
cpp复制#include <iostream>
#include <stdexcept>
class Trace {
public:
explicit Trace(const char* name) : name_(name) {
std::cout << "构造 " << name_ << std::endl;
}
~Trace() {
std::cout << "析构 " << name_ << std::endl;
}
private:
const char* name_;
};
void f3() {
Trace t("f3 内局部对象");
throw std::runtime_error("来自 f3 的异常");
}
void f2() {
Trace t("f2 内局部对象");
f3();
std::cout << "这行不会执行" << std::endl;
}
void f1() {
Trace t("f1 内局部对象");
f2();
}
int main() {
try {
Trace t("main 的 try 块内对象");
f1();
} catch (const std::exception& e) {
std::cout << "捕获: " << e.what() << std::endl;
}
}
我让不少朋友先猜输出顺序。很多人能猜到“main 的 try 块内对象”也会在捕获前被析构,但说不清为什么;还有不少人以为异常抛出后,会先跳到 catch 块,然后才一个个析构。真实输出是这样的:
text复制构造 main 的 try 块内对象
构造 f1 内局部对象
构造 f2 内局部对象
构造 f3 内局部对象
析构 f3 内局部对象
析构 f2 内局部对象
析构 f1 内局部对象
析构 main 的 try 块内对象
捕获: 来自 f3 的异常
关键点在后面几行:所有栈上的局部对象,在异常被 catch 之前就已经全部析构完了。这就是C++异常机制里最核心的环节——栈展开(stack unwinding)。它要回答的问题是:当一个异常被抛出时,从抛出点一路向外查找合适的 catch 处理器的过程中,那些被跨越的函数栈帧里的局部对象,该怎么处理?
答案是:编译器负责替你把它们全部以“构造顺序的严格逆序”析构干净,然后再把控制权交给匹配的 catch 块。这个机制让“异常安全”成为可能,也正是它区分了一个语言层面的异常处理方案和一个简单的 setjmp/longjmp 跳转方案。
本文不打算停留在语法层面。我会把栈展开的底层机制、析构顺序、匹配规则、性能代价和实际开发中的坑一起梳理清楚。适合正在写C++服务端、嵌入式、客户端代码的人,也适合准备C++面试、翻“八股文”的人。
1.1 栈展开时发生了什么:从抛出点到 catch 块之间的一场清扫
从上面的例子可以提炼出一个标准流程:
- throw 表达式创建异常对象,编译器开始在当前函数内查找是否有匹配的 catch。
- 如果当前函数没有可以处理该异常的 catch 块,函数栈帧里的所有局部对象开始逆序析构,然后函数返回一个特殊状态,告诉调用者“这里有异常在传播”。
- 调用者重复同样的动作:先看自己的 catch 能否处理,不能就析构自己的局部对象,继续向外传播。
- 直到某个函数中存在匹配的 catch 块,控制权转到该 catch 块,异常对象被绑定到 catch 参数。
- 如果绕了一圈到达 main 之外还没有 catch,程序调用 std::terminate,直接终止。
这个过程的专业术语就是“栈展开”。网上很多解释喜欢把“函数退出时析构局部对象”和“为异常查找处理器”当成两个独立阶段,实际标准语义里两者是交织在一起的。编译器从抛出点开始,一层一层往外找,每退出一层栈帧就清扫一次。并不是先全局扫描一遍找到 catch 再回来做析构。
1.2 一个容易被忽略的细节:异常对象本身放哪
既然局部对象都被析构了,那 throw 出去的异常对象为什么还能活到 catch 块?
因为异常对象不是活在某个函数的栈空间里的。标准要求编译器在一个“不由任何栈帧管理的独立存储”中创建异常对象。大多数主流实现
