RAII 与异常安全等级
C++ 没有垃圾回收,但有比它更确定的东西:析构函数一定会在作用域结束时执行。RAII 就是把"清理"绑到"生命周期"上的技术。
一、RAII 的基本形态
class FileHandle {
public:
explicit FileHandle(const char* path) : fp_(std::fopen(path, "rb")) {
if (!fp_) throw std::runtime_error("open failed");
}
~FileHandle() { if (fp_) std::fclose(fp_); }
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
std::size_t read(void* buf, std::size_t n) { return std::fread(buf, 1, n, fp_); }
private:
std::FILE* fp_;
};
好处:
- 构造失败抛异常,不会留下半初始化对象;
- 任何路径(正常返回、异常、提前
return)都会析构; - 使用方零心智负担,不再需要"记得关文件"。
二、异常安全三个等级
| 等级 | 含义 | 典型场景 |
|---|---|---|
| 不抛保证(noexcept) | 保证不抛出 | 析构、移动构造、swap |
| 强保证 | 操作失败则对象回到操作前状态 | vector::push_back、std::string 赋值 |
| 基本保证 | 失败后对象仍有效但状态未知 | 大多数容器操作 |
| 无保证 | 失败后对象不可用 | 应避免 |
工程实践:析构函数与移动操作必须 noexcept;核心写入路径尽量达到强保证;其余至少保证基本保证。
三、强保证的实现手法:copy-and-swap
class Widget {
public:
Widget& operator=(const Widget& other) {
Widget tmp(other); // 可能抛,但 *this 未被改动
swap(tmp); // noexcept
return *this; // tmp 析构释放旧资源
}
void swap(Widget& other) noexcept {
std::swap(name_, other.name_);
std::swap(data_, other.data_);
}
private:
std::string name_;
std::vector<int> data_;
};
原理:所有可能失败的步骤都在临时对象上完成,最后用一个不抛的 swap 提交。这是实现强异常安全的通用模式,代价是一次额外拷贝(配合移动语义可降为移动)。
四、资源泄漏的典型场景
场景 1:手动管理 + 提前返回
void bad() {
int* p = new int[100];
if (cond) return; // ❌ 泄漏
delete[] p;
}
void good() {
std::vector<int> v(100);
if (cond) return; // ✅ 自动释放
}
场景 2:lock/unlock 成对出现
// ❌ 中间抛异常则死锁
mtx.lock();
doWork();
mtx.unlock();
// ✅
std::lock_guard<std::mutex> lk(mtx);
doWork();
C++17 起 std::lock_guard、std::unique_lock 还支持类模板实参推导与 scoped_lock(多锁防死锁)。
场景 3:多个资源的构造顺序
Foo::Foo() : a_(new A), b_(new B) {}
若 new B 抛异常,a_ 已是裸指针,会泄漏。用智能指针直接解决:
Foo::Foo() : a_(std::make_unique<A>()), b_(std::make_unique<B>()) {}
构造过程中已构造完成的成员会被正确析构,这就是"成员自己管资源"的价值。
五、析构函数中的禁忌
~Widget() {
// ❌ 不要抛异常
// ❌ 不要调用可能抛异常的虚函数(此时派生部分已析构)
// ❌ 不要依赖外部对象仍然存在
}
需要"可能失败"的清理逻辑时,提供显式的 close() 方法 + noexcept 析构兜底:
class Conn {
public:
void close() { // 可抛异常,调用方处理
if (fd_ >= 0) { ::close(fd_); fd_ = -1; }
}
~Conn() noexcept {
try { close(); } catch (...) { /* 记日志,不传播 */ }
}
private:
int fd_ = -1;
};
六、作用域守卫(Scope Guard)
有时清理逻辑不是资源释放,而是"回滚"。可以写一个通用的守卫:
class ScopeExit {
public:
explicit ScopeExit(std::function<void()> f) : f_(std::move(f)) {}
~ScopeExit() { if (f_) f_(); }
void dismiss() { f_ = nullptr; }
private:
std::function<void()> f_;
};
// 用法
{
ScopeExit guard([&] { rollback(); });
doTransactionalWork(); // 抛异常时自动回滚
guard.dismiss(); // 正常完成则取消
}
C++98 环境下可用函数指针 + void* 实现轻量版本,或封装成宏。
七、RAII 与 C 接口共存
调用 C API 时最容易漏清理。模式是"包一层薄壳":
auto closeCurl = [](CURL* h) { if (h) curl_easy_cleanup(h); };
using CurlPtr = std::unique_ptr<CURL, decltype(closeCurl)>;
CurlPtr h(curl_easy_init(), closeCurl);
同一套思路适用于 sqlite3*、SSL*、fd、DIR*、X509*。
八、检查清单
- 所有裸
new是否已在 RAII 类内?函数体里是否还残留new/delete配对? - 每个"加锁"是否有对应的
lock_guard? - 析构函数是否全部
noexcept? - 移动构造与移动赋值是否
noexcept? - 构造失败时,已构造成功的成员是否会自动清理?
- 有"必须成对调用"的 API 是否都包了守卫?
RAII 不是技巧,是习惯。一旦所有资源都跟着对象走,delete 这个词就可以从代码里消失了。