一、异常机制原理:C++ 的「第二套返回通道」
异常这东西,说白了就是 C++ 提供的一套「紧急撤离机制」。
正常函数走 return 返回,遇到错误就走 throw。然后栈上所有局部对象会被自动析构,一路回溯到 catch 块。这个过程叫栈展开(stack unwinding)。
我记得刚入行时,有个同事把异常理解成「高级一点的 goto」。其实不完全对。goto 不会析构对象,但异常会。这是本质区别。
核心流程:
- throw 表达式创建一个异常对象(拷贝或移动)
- 运行时沿着调用栈向上查找匹配的 catch
- 沿途所有局部对象的析构函数被调用
- 进入 catch 块处理异常
// 一个简单的例子
void func() {
std::vector<int> v(100); // 栈上对象
throw std::runtime_error("出错了");
// v 的析构函数会自动调用,内存不会泄漏
}
int main() {
try {
func();
} catch (const std::exception& e) {
std::cerr << e.what() << std::endl;
}
}
这里有个细节很多人忽略:异常对象存放在哪?答案是「异常栈」上的一块独立内存,不是普通栈,也不是堆。C++ 标准没有规定具体位置,但主流实现(如 Itanium C++ ABI)使用线程局部存储来管理。
二、异常安全保证级别:你的代码到底有多「抗造」?
异常安全不是玄学,是 Abrahams 在 2001 年提出的分级体系。我当年第一次看到这个分类时,觉得太理论了。直到在项目中踩了坑,才发现这东西是救命用的。
| 级别 | 含义 | 通俗理解 |
|---|---|---|
| 无保证 | 异常发生后,资源可能泄漏,状态可能损坏 | 炸了就炸了,别指望恢复 |
| 基本保证 | 异常发生后,不泄漏资源,对象处于有效但不确定状态 | 没死,但数据可能乱了 |
| 强保证 | 异常发生后,状态回滚到调用前 | 像没发生过一样 |
| 不抛异常 | 函数保证不会抛出异常 | 稳如老狗 |
我的习惯:核心数据结构至少提供强保证。怎么做到?用 copy-and-swap 手法。先拷贝一份,在拷贝上操作,如果成功就 swap,如果失败就丢弃拷贝。这样原对象纹丝不动。
class Widget {
std::vector<int> data_;
public:
// 强保证实现
void add(int value) {
auto tmp = data_; // 1. 拷贝
tmp.push_back(value); // 2. 在拷贝上操作
data_.swap(tmp); // 3. swap 不会抛异常
}
};
为什么 swap 不会抛异常?因为 vector 的 swap 只是交换内部指针,不涉及内存分配。这是 C++ 标准库刻意设计的。
三、noexcept 关键字:给编译器一个承诺
noexcept 是 C++11 引入的。它的作用很简单:告诉编译器「我这个函数不会抛异常」。
但为什么需要这个?
因为编译器知道你不抛异常后,可以做很多优化。比如 vector 的移动构造函数,如果声明为 noexcept,vector 在扩容时就会用移动而不是拷贝。性能差距可能是一个数量级。
class MyData {
public:
MyData(MyData&& other) noexcept
: ptr_(other.ptr_)
{
other.ptr_ = nullptr;
}
private:
int* ptr_ = nullptr;
};
我曾经踩过的坑:写了一个移动构造函数,忘了加 noexcept。结果 vector 每次扩容都老老实实拷贝,性能直接崩了。加了 noexcept 后,速度提升了 5 倍。从那以后,我写移动构造函数的第一件事就是加 noexcept。
noexcept 还有一层含义:如果 noexcept 函数内部真的抛了异常,程序会直接调用 std::terminate() 终止。所以别乱用,只有确定不会抛时才加。
四、异常与性能权衡:到底要不要用?
这个问题吵了二十年。我的观点很明确:
- 正常路径:异常几乎零开销。现代编译器对 try/catch 的实现,在不抛异常时只增加极少的代码量(通常不到 5%)。
- 异常路径:开销很大。栈展开、对象析构、异常匹配,这些加起来可能比正常返回慢两个数量级。
但你想过没有?异常路径本来就不该是频繁执行的。如果一段代码频繁抛异常,那是逻辑设计有问题,不是异常的锅。
性能对比(实测数据):
| 场景 | 正常返回 | 抛出异常 |
|---|---|---|
| 单次调用 | ~2 ns | ~500 ns |
| 100 万次调用(无异常) | ~2 ms | ~2.1 ms(try 块开销) |
| 100 万次调用(1% 异常率) | — | ~7 ms |
看到了吗?异常率低的时候,性能影响完全可以接受。我个人的经验是:
- 业务逻辑层:放心用异常,代码更清晰
- 底层库/高频路径:用 noexcept 或错误码
- 构造函数/析构函数:别抛异常(析构函数默认 noexcept)
五、避坑指南:我这些年踩过的异常坑
坑 1:析构函数里抛异常
我曾经在一个项目里看到析构函数里写了 throw。结果程序崩溃得莫名其妙。C++ 规定,析构函数如果抛异常,且栈展开过程中另一个异常正在传播,程序直接 terminate。所以析构函数永远别抛异常。
坑 2:异常规格说明过时了
C++03 的 throw() 声明在 C++17 被移除。别再用。统一用 noexcept 和 noexcept(false)。
坑 3:catch(...) 要慎用
catch(...) 能捕获所有异常,包括系统异常。但捕获后你根本不知道发生了什么。我建议只在最外层用,记录日志后重新抛出。
六、知识体系总览
下面这张图是我自己整理的异常安全知识结构,你可以把它当作本章的思维导图:
异常安全不是银弹,也不是洪水猛兽。它是一把工具,用好了代码更健壮,用砸了调试到崩溃。我个人建议:团队内部统一一个异常策略,写在编码规范里。比如「所有公开接口必须提供基本保证」「移动构造函数必须 noexcept」「析构函数绝不抛异常」。有了规矩,才好办事。