异常、RAII 与 noexcept:失败路径也要清理 / Exceptions, RAII, and noexcept
现代 C++ 讨论异常时,不能只问“要不要用异常”。
更重要的是:一旦失败发生,已经获得的资源会不会正确释放?对象会不会保持可用状态?接口有没有承诺不抛?
1. 异常会跳过普通语句
看代码:
#include <iostream>
#include <stdexcept>
void work() {
std::cout << "open\n";
throw std::runtime_error("failed");
std::cout << "close\n";
}
int main() {
try {
work();
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n';
}
}close 不会打印。
因为 throw 之后,当前函数不会继续按普通顺序往下执行。
流程是:
enter work()
|
v
print open
|
v
throw exception
|
v
leave normal control flow
|
v
search matching catch所以如果资源清理只是写在后面的普通语句里,它可能被异常跳过。
这就是异常和资源管理必须一起讨论的原因。
2. 栈展开是什么
异常从抛出点向外传播时,会离开当前函数,继续离开调用者函数,直到找到匹配的 catch。
这个过程叫栈展开。
void c() {
throw std::runtime_error("failed");
}
void b() {
c();
}
void a() {
b();
}调用栈:
main
|
v
a
|
v
b
|
v
c throws异常传播时:
c cannot handle
|
v
leave c
|
v
b cannot handle
|
v
leave b
|
v
a cannot handle
|
v
leave a
|
v
main catches关键点是:离开每一层函数时,已经构造成功的局部对象会析构。
3. RAII 为什么能覆盖异常路径
看代码:
#include <iostream>
#include <stdexcept>
struct Guard {
Guard() {
std::cout << "acquire\n";
}
~Guard() {
std::cout << "release\n";
}
};
void work() {
Guard guard;
throw std::runtime_error("failed");
}当异常离开 work():
Guard constructed
|
v
throw exception
|
v
stack unwinding begins
|
v
guard destructor runs
|
v
resource released这就是 RAII 与异常天然配合的地方。
你不用在每个失败出口手写释放。
只要资源被一个局部对象正确拥有,析构就会覆盖正常返回、提前返回和异常路径。
4. 析构函数不要抛异常
析构函数通常不应该抛异常。
原因是:如果栈展开过程中正在处理一个异常,析构函数又抛出另一个异常,程序很可能调用 std::terminate。
资源清理应该尽量做到:
析构函数完成必要清理
不把错误继续抛出
如果必须报告错误,用显式 close/commit 接口提前处理例如文件写入时,关闭或刷新也可能失败。
如果你需要检查这类错误,可以设计显式操作:
class Writer {
public:
void close(); // 可以返回错误或抛异常
~Writer(); // 尽量只做兜底清理,不抛
};5. 异常安全的三个层次
异常安全不是“完全没有异常”。
它说的是:异常发生后,程序和对象处于什么状态。
常见层次:
基本保证
出错后对象仍然有效,可以析构,可以继续满足基本不变量
强保证
出错后像操作没有发生过一样
不抛保证
操作承诺不会抛异常例如:
std::vector<int> values{1, 2, 3};
values.push_back(4);如果 push_back 扩容时分配失败,标准库不能让 values 变成内部结构损坏的对象。
至少要保持基本保证。
某些条件下,它还能提供强保证。
6. noexcept 是接口承诺
看代码:
void f() noexcept {
throw 1;
}这不是“编译器会帮你忽略异常”。
如果 f() 真的抛出异常,程序会调用 std::terminate。
所以 noexcept 是承诺:
这个函数不会让异常逃出去。它也会影响标准库行为。
例如 vector 扩容时,元素需要搬到新缓冲区。
如果元素的移动构造是 noexcept,vector 更容易选择移动。
如果移动可能抛,为了保持异常安全,vector 可能选择复制。
struct Item {
Item() = default;
Item(Item&&) noexcept = default;
};这里 noexcept 不是装饰。
它是给使用者和标准库的承诺。
7. 什么时候写 noexcept
适合写:
移动构造和移动赋值确实不会抛
swap 确实不会抛
析构函数
底层释放函数
性能敏感且语义上不允许失败的操作不适合乱写:
函数内部会分配内存
函数内部会调用可能抛异常的用户代码
函数内部错误需要向上报告
只是为了“看起来现代”写 noexcept 前要问:
如果这里真的失败,我能不能在函数内部处理完?
如果不能,是否应该允许异常传播?8. 本篇总结
异常和 RAII 的关系:
异常改变控制流
|
v
普通清理语句可能被跳过
|
v
栈展开会析构局部对象
|
v
RAII 把清理放进析构
|
v
失败路径也能释放资源noexcept 的关系:
noexcept
不是建议
是承诺
违反后会 terminate
会影响标准库选择移动还是复制写现代 C++ 资源类型时,要同时考虑:
正常路径
提前返回
异常路径
析构是否抛
移动是否 noexcept
对象失败后是否仍然有效