RAII 与资源管理实战
环境:GCC 13.1.0,
g++ -std=c++20 -O2 -Wall。上一节讲了移动与转发,这一节把”资源一定被释放”这件事做到底。
一、RAII 的核心承诺:构造成功 ⇒ 析构一定发生
struct Part { int n; Part(int i) : n(i) { ++ctor_done; } ~Part() { ++dtor_done; } };
struct Half {
Part a{1};
Part b{2};
Half() { throw std::runtime_error("构造失败"); }
};
try { Half h; } catch (...) { }
成员构造 2 个,异常后析构 2 个
标准的规则是:只有”已完整构造”的子对象会被析构。所以:
- 基类子对象、已构造的成员 → 一定会析构,这就是它们安全的原因。
- 构造函数函数体内用裸
new/fopen/CreateFile拿到的资源 → 不会自动释放,抛异常就是泄漏。 - 半构造对象里被虚函数调用到 → 上一节说过,那时 vptr 还指向当前正在构造的那一层的虚表。
推论:资源获取必须放进一个对象的构造里(成员或局部 RAII 对象),不要”先 new 再存进成员”。写 std::unique_ptr<T> p; 然后在构造函数体里 p = std::make_unique<T>() 是错的顺序——make_unique 抛异常时 p 还没构造,但更常见的问题是”两个资源之间有抛出点”。正确做法是全部走成员初始化列表 + 智能指针/RAII 类型。
二、Rule of Zero:默认才是最优解
三法则/五法则(第二节讲过)的真正结论是”尽量一个都别写”:
class Config { // ✅ rule of zero:0 个特殊函数
std::string path;
std::vector<int> values;
std::unique_ptr<Logger> logger; // 拥有
public:
Config(std::string p) : path(std::move(p)) {}
// 拷贝、移动、析构全部由编译器生成,且都正确
};
只要每个成员自己管好自己的资源,容器类的五个特殊函数就不需要写。要写它们,说明你的类里还有裸资源——那第一件事是把它换成 RAII 类型,而不是补拷贝构造。
必须手写的三种情况:① 封装平台句柄(HANDLE、int fd、SDL_Window*);② 需要 Pimpl 隐藏编译依赖;③ 自定义内存池/映射。
三、句柄封装:一个模板搞定所有 C API
template <class H, auto Close, H Invalid = nullptr>
class handle {
H h_ = Invalid;
public:
handle() = default;
explicit handle(H h) : h_(h) {}
~handle() { if (h_ != Invalid) Close(h_); }
handle(const handle &) = delete; // 句柄不共享
handle &operator=(const handle &) = delete;
handle(handle &&o) noexcept : h_(std::exchange(o.h_, Invalid)) {}
handle &operator=(handle &&o) noexcept {
if (this != &o) { if (h_ != Invalid) Close(h_); h_ = std::exchange(o.h_, Invalid); }
return *this;
}
H get() const { return h_; }
explicit operator bool() const { return h_ != Invalid; }
H release() { return std::exchange(h_, Invalid); }
void reset() { if (h_ != Invalid) { Close(h_); h_ = Invalid; } }
};
using file_handle = handle<FILE *, std::fclose>;
using fd_handle = handle<int, ::close, -1>; // POSIX
四个细节都必要:
std::exchange取走并置无效,保证移动后源对象析构时不重复关闭。- 移动赋值要自赋值检查(
this != &o),否则h = std::move(h)会先关掉自己再使用无效值。 release()交出所有权(对应unique_ptr::release的语义)。operator bool是explicit,避免if (h == true)之类的怪事。
Qt 里几乎用不上这个(QObject 树已经做了父子所有权),但Win32 句柄、COM 指针、平台 API 资源必须自己包,否则每个 return 都要记得 CloseHandle。
四、scope guard:把”退出时一定做某事”写成一行
template <class F> struct Final {
F f;
explicit Final(F fn) : f(std::move(fn)) {}
~Final() { f(); }
};
template <class F> Final(F) -> Final<F>; // C++17 CTAD 推导指引
用法:
{
Handle h(1);
Final guard([&] { h.close(); });
... // 无论正常退出、return 还是抛异常
}
离开作用域自动释放:acquired=1 released=1
Final(F) -> Final<F> 是推导指引,没有它就得写 Final<decltype(fn)>。这个模式等价于 gsl::finally、boost::scope_exit,也等价于 Rust 的 Drop。
典型用途:解锁、回滚事务、释放临时文件、恢复全局状态、错误路径统一清理——比 goto cleanup 更适合 C++(因为有异常路径)。
注意:guard 里的 lambda 默认不应抛异常(析构里抛 = terminate,上一节实测)。要记录失败就先捕获、写日志,别往外抛。
五、锁也是资源
std::mutex m;
m.lock(); // ❌ 中途 return/抛异常就永久死锁
do_work();
m.unlock();
std::lock_guard lk(m); // ✅ 作用域结束自动解锁
std::scoped_lock l2(m1, m2); // ✅ 多锁,内部用避免死锁的算法
{
std::lock_guard lk2(m);
cv.wait(lk2, pred); // wait 内部临时解锁再重新加锁
}
三条:
- 永远不要裸
lock()/unlock(),除非你在实现 RAII 类型本身。 - 多把锁一律
std::scoped_lock(C++17),它用std::lock的退避算法避免死锁顺序问题。 std::unique_lock比lock_guard灵活(可延迟加锁、可转移、可配合条件变量),代价是多一个 bool 状态。默认用lock_guard,需要灵活性再换。- C++20 还有
std::scoped_lock的std::adopt_lock/defer_lock变体,以及std::jthread(析构自动request_stop+join,解决std::thread析构不 join 就 terminate 的坑)。
六、非虚析构 + delete 基类指针:派生类析构次数为 0
struct PBaseNoVirt { ~PBaseNoVirt() { ++base_dtor; } };
struct PDer : PBaseNoVirt { ~PDer() { ++derived_dtor; } };
delete static_cast<PBaseNoVirt *>(new PDer());
基类析构 1 次,派生类析构 0 次 ← 派生类没被调用(UB)
派生类的资源完全没释放,而且这是未定义行为(不是”只漏一点”)。编译器只在有虚函数但析构非虚时警告:
warning: deleting object of polymorphic class type 'A2' which has non-virtual
destructor might cause undefined behavior [-Wdelete-non-virtual-dtor]
注意这条警告的盲区:基类完全没有虚函数时它不报(因为静态类型上 delete 派生指针是合法的)。所以规矩只能是:
-
打算被继承并多态使用 →
virtual ~Base() = default; -
不打算被多态删除 → 把析构设为
protected(或= delete),从设计上禁止delete basePtr:struct Base { protected: ~Base() = default; // 只能作为成员/栈对象/被派生类自己析构 };
七、这一节的自查清单
- 每个类先争取做到 rule of zero,写不出来说明还有裸资源没被封装
- 资源获取放进成员/RAII 对象的构造,构造函数体内不留”两个获取之间的抛出点”
- 句柄类:删除拷贝、移动用
std::exchange置无效、移动赋值检查自赋值 - 退出清理用 scope guard(
Final{[&]{...}}),别写一堆重复的cleanup() - 锁一律
lock_guard/scoped_lock,多锁必scoped_lock - 线程用
std::jthread,或确保所有路径都join/detach - 多态基类虚析构;不想被多态删除的基类把析构设
protected - 开
-Wdelete-non-virtual-dtor并当错误 - guard/析构里绝不抛异常
下一节讲常用标准库入门:容器与算法的实测性能对照、operator[] 与 find 的差别、ranges 管道为什么不该怕,以及 std::string/string_view/span 的边界。