C++ 异常安全与错误处理
更新时间:2026-08-25。本文是
languages/cpp/主题高手层第 3 篇。异常是 C++ 处理错误的机制,但"能用异常"和"异常安全"是两回事。异常安全三级别是 C++ 工程质量的试金石,它和 RAII 一起,决定你的代码在出错时是"优雅回退"还是"崩溃损坏"。
本文要回答的问题
- 异常是怎么工作的?
throw之后发生了什么? - 什么是"异常安全三级别"?为什么
vector::push_back提供强保证? - 什么时候该用异常,什么时候该用错误码?
noexcept该不该乱加?
一、异常机制基础
1.1 throw / try / catch
cpp
#include <stdexcept>
int divide(int a, int b) {
if (b == 0) throw std::invalid_argument("div by zero"); // 抛出异常
return a / b;
}
try {
int r = divide(10, 0);
} catch (const std::invalid_argument &e) { // 捕获特定异常
std::cerr << e.what() << "\n";
} catch (...) { // 捕获所有
std::cerr << "unknown error\n";
}1.2 throw 之后发生了什么:栈展开
抛出异常后,程序沿调用栈向上"展开"(stack unwinding):
- 当前函数立即停止执行
- 沿途每个作用域中的局部对象被析构(RAII 生效!)
- 直到找到匹配的
catch,或程序terminate
cpp
void f() {
File file("/tmp/a"); // RAII 对象
risky_call(); // 这里抛异常
// file 的析构会自动执行,文件被正确关闭
}这就是 RAII 与异常安全的结合点:只要资源用 RAII 管理,异常展开时就不会泄漏。
二、异常安全三级别
这是 C++ 异常处理的核心概念,由 Dave Abrahams 提出:
| 级别 | 保证 | 含义 |
|---|---|---|
| 无异常保证 | 无 | 抛异常后对象状态可能损坏 |
| 基本保证 | 对象仍可用 | 状态有效但可能已改变,无泄漏 |
| 强保证 | 操作要么成功要么无效果 | 失败时回滚到操作前(原子性) |
| 无抛保证(nothrow) | 绝不抛异常 | 如析构、swap |
2.1 基本保证 vs 强保证
cpp
// 基本保证:失败后 vec 仍然有效,但可能被部分修改
void weak_insert(std::vector<int> &v, int x) {
v.push_back(x); // 可能抛异常,但 vector 仍是有效状态
}
// 强保证:失败后 v 完全不变
void strong_insert(std::vector<int> &v, int x) {
auto tmp = v; // 先拷贝到临时
tmp.push_back(x); // 在临时上操作
v.swap(tmp); // 成功才交换(swap 不抛异常)
}std::vector::push_back 本身提供强保证(C++ 标准要求):要么插入成功,要么抛出异常且 vector 不变。
2.2 为什么强保证要靠 copy-and-swap
copy-and-swap 惯用法:先构造临时副本 → 在副本上修改 → 用不抛异常的 swap 交换。这是实现强异常安全的标准技巧。
三、noexcept 的正确用法
3.1 noexcept 是什么
cpp
void safe() noexcept; // 声明:此函数不抛异常
void maybe_throwing(); // 可能抛异常noexcept 函数如果内部抛了异常,会直接 std::terminate(崩溃)。
3.2 该不该加 noexcept
| 函数类型 | 是否 noexcept | 原因 |
|---|---|---|
| 析构函数 | 默认是 | 析构抛异常是灾难 |
| 移动构造/赋值 | 应加 | vector 扩容时依赖它 |
| swap | 应加 | copy-and-swap 的基础 |
| 普通业务函数 | 视情况 | 别乱加,加了就不能抛异常 |
铁律:
- 析构函数绝不抛异常(默认 noexcept)。
- 移动构造/赋值尽量 noexcept——否则
vector扩容时不敢用移动、退回拷贝(性能损失),见 移动语义。
四、异常 vs 错误码
| 维度 | 异常 | 错误码(返回值/std::expected) |
|---|---|---|
| 性能 | 正常路径零开销,抛出时昂贵 | 每次调用都要检查 |
| 可读性 | 主逻辑干净,错误处理分离 | 错误检查混在主逻辑 |
| 适用 | 罕见的、非预期的错误 | 高频的、可预期的错误 |
| 强制处理 | 不 catch 就向上传播 | 可被忽略 |
经验法则:
- 预期内、高频的错误(如"文件可能不存在")→ 错误码或
std::optional/std::expected(C++23) - 罕见、致命的错误(如"内存耗尽")→ 异常
五、常见异常安全陷阱
cpp
// 陷阱 1:在析构函数里抛异常
~Foo() {
throw std::runtime_error("bad"); // 危险!析构抛异常会 terminate
}
// 陷阱 2:裸指针 + 异常 = 泄漏
void bad() {
int *p = new int;
risky_call(); // 抛异常,p 泄漏了!
delete p;
}
// 正确:用 unique_ptr(RAII)
void good() {
auto p = std::make_unique<int>();
risky_call(); // 抛异常,p 自动释放
}六、与本站主线衔接
- RAII:异常安全的根基是 RAII,见 RAII 与智能指针。
- 异常与信号:异常是语言层错误处理,信号是 OS 层,见 crash 异常与信号。
- 异常开销:异常的表驱动实现(Itanium ABI)对正常路径零开销,见 对象内存布局。
- 崩溃排查:未捕获异常导致 terminate,用 gdb 抓调用栈,见 crash 排查线。
一句话总结
异常安全 = 记住三级别(基本/强/无抛)+ 靠 RAII 保证栈展开不泄漏 + 用 copy-and-swap 实现强保证 + 析构和移动务必 noexcept;掌握它,你的代码在出错时才能"优雅回退"而不是崩溃。
上一篇:STL 容器与迭代器 下一篇:并发与线程安全