跳到主要内容

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*。

八、检查清单​

  1. 所有裸 new 是否已在 RAII 类内?函数体里是否还残留 new/delete 配对?
  2. 每个"加锁"是否有对应的 lock_guard?
  3. 析构函数是否全部 noexcept?
  4. 移动构造与移动赋值是否 noexcept?
  5. 构造失败时,已构造成功的成员是否会自动清理?
  6. 有"必须成对调用"的 API 是否都包了守卫?

RAII 不是技巧,是习惯。一旦所有资源都跟着对象走,delete 这个词就可以从代码里消失了。