#面向对象程序设计05
RAII = 析构函数的确定性 + 所有权的清晰归属
析构函数的确定性解决“什么时候释放”的问题,所有权解决“谁负责释放”的问题
二者合在一起,构成现代 C++ 资源管理的基础
为什么手动管理资源总是出错
手动管理资源的结构性缺陷在于:资源获取和资源释放分离在代码的两个位置,中间隔着任意多的逻辑、分支和异常路径,程序员必须保证每条路径都能走到释放语句,一旦遗漏,就会泄漏
1 | void process(size_t size) { |
与泄漏相对的是悬空指针
它发生在资源已经释放,但指针或引用还被继续使用时:
1 | char* buf = new char[1024]; |
悬空指针危险在于结果不可预测
程序可能立刻崩溃,也可能暂时看起来正常,最后在完全不相关的位置出错
还有一种更隐蔽的悬空引用:
1 | std::string* p = nullptr; |
这类问题的共同根源都是生命周期没有被类型系统表达出来
指针本身只表示“这里有个地址”,不表示“谁拥有这个对象”,也不表示“这个对象还能活多久”
RAII
RAII 是 Resource Acquisition Is Initialization,即“资源获取即初始化”
它的基本规则是:
- 构造函数中获取资源
- 析构函数中释放资源
- 对象生命周期绑定资源生命周期。
这样一来,释放资源不再依赖程序员在每个分支末尾手写清理语句,而是依赖 C++ 对局部对象析构的语言保证
栈展开是 RAII 成立的底层保证
当 C++ 程序抛出异常时,运行时会沿调用栈向外寻找匹配的 catch 块
在向外传播的过程中,已经构造完成的局部对象会按照构造的逆序依次析构,这个过程叫栈展开
RAII 利用这个机制:只要清理逻辑写在析构函数中,无论函数正常返回、提前返回,还是因为异常退出,只要对象已经构造完成,它的析构函数就会被调用
1 | class FileGuard { |
第一,FileGuard 构造成功之后,file_ 就一定由这个对象负责关闭,调用者不需要记住 fclose
第二,如果 processFile 抛出异常,file 仍然会在栈展开时析构,所以文件句柄不会泄漏
第三,拷贝被禁止。因为两个 FileGuard 如果持有同一个 FILE*,离开作用域时就会关闭两次同一个句柄,这是未定义行为
析构函数不能抛异常
析构函数是清理资源的最后防线
它的职责是尽力释放资源,而不是向外报告失败,尤其在栈展开过程中,如果析构函数再次抛出异常,C++ 运行时会调用 std::terminate 终止程序
因此资源类的析构函数应当写成 noexcept,内部处理所有失败情况:
1 | class Connection { |
如果释放操作本身可能失败,可以提供显式的 close() 让调用者主动处理错误;析构函数只负责兜底清理,并且不能抛异常
轻量级 RAII:一次性清理
有时资源清理只出现一次,不值得专门写一个完整资源类,可以使用类似 gsl::finally 的作用域守卫,在离开作用域时执行一个 lambda:
1 | void process(const char* path) { |
这本质上还是 RAII:cleanup 是一个局部对象,它在析构时执行保存的 callable
适合 C API 对接、临时代码、一次性清理。若资源类型会反复出现,仍应封装成完整 RAII 类
所有权归属
RAII 解决“释放动作怎么自动发生”,所有权解决“谁有资格释放”
没有所有权语义,RAII 类也可能被错误复制,最后导致双重释放
所谓所有权,就是谁负责在正确时机销毁资源
现代 C++ 中常见的所有权关系有三种:唯一所有权、共享所有权、非拥有访问
唯一所有权:unique_ptr
唯一所有权表示同一时刻只有一个对象拥有资源,资源可以转移,但不能复制
std::unique_ptr 是这种语义的标准工具
1 | auto p1 = std::make_unique<Widget>(42); |
unique_ptr 重点是用类型系统禁止意外共享
不能写 auto p2 = p1;,因为这会要求复制所有权,必须显式写出 std::move,让所有权流动在代码中可见
默认情况下应优先选择 unique_ptr,它表达清晰,运行时代价接近裸指针,不需要引用计数,也不会产生循环引用问题
共享所有权:shared_ptr
共享所有权表示多个对象共同决定资源生命周期,最后一个拥有者离开时,资源才会释放std::shared_ptr 通过引用计数实现这一语义:
1 | auto s1 = std::make_shared<Widget>(42); // 引用计数 = 1 |
shared_ptr 解决问题:当一个资源没有自然的单一所有者时,共享生命周期是合理的
但它不是默认选择,引用计数有成本,通常还涉及原子操作;更重要的是,它会让所有权关系变得模糊
如果一个类里到处都是 shared_ptr,通常说明设计还没有回答“谁真正拥有这些对象”:
1 | class Engine { |
这并不一定错误,但应该追问:这些对象真的都需要和外部共享生命周期吗?如果只是借用,使用引用或裸指针更诚实;如果有单一所有者,使用 unique_ptr 更清楚
非拥有访问:weak_ptr
weak_ptr 观察一个由 shared_ptr 管理的对象,但不拥有它,也不增加引用计数
典型用途是表达“我可能需要访问它,但我不负责延长它的生命周期”
1 | std::weak_ptr<Widget> weak = s1; |
使用 weak_ptr 时,应该通过 lock() 一步完成“检查并获取”,不要先 expired() 再访问,因为检查和使用之间对象可能已经被其他线程释放
1 | if (auto locked = weak.lock()) { |
weak_ptr 还可以打破循环引用:
1 | struct Node { |
如果 parent 也是 shared_ptr,父子互相持有,引用计数永远无法归零
改成 weak_ptr 后,父节点拥有子节点,子节点只观察父节点,生命周期关系就恢复为单向
make_unique 与 make_shared
创建智能指针时,优先使用 make_unique 和 make_shared,不要直接写 new:
1 | auto p = std::make_unique<Widget>(42); |
原因:
第一,语义更集中。资源的分配和智能指针的构造写在同一个表达式中,不暴露裸指针
第二,异常安全更好。在 C++17 之前,函数实参的求值顺序不完全确定,直接 new 可能出现资源已经分配但智能指针还没构造,另一个实参先抛异常的窗口:
1 | func(std::unique_ptr<Widget>(new Widget()), getAnotherArg()); |
若 new Widget() 已经执行,getAnotherArg() 随后抛出异常,而 unique_ptr 尚未构造,就会泄漏。make_unique 把分配和托管封装为一个步骤,避免这种窗口。
对 shared_ptr 来说,make_shared 通常还能把对象和控制块放在一次分配中,减少分配次数。不过如果需要自定义删除器、特殊内存布局或弱引用释放时机控制,就要根据具体场景判断。
非内存资源同样需要 RAII
RAII 不只是内存管理技术,凡是需要成对操作的资源,都应该让对象生命周期托管
文件句柄
1 | class File { |
文件句柄适合“不可复制、可移动”。复制没有意义,因为两个对象关闭同一个句柄会出错;移动有意义,因为可以把句柄的责任转交出去
锁
互斥锁也必须用 RAII 管理,手动 lock() / unlock() 和手动 new / delete 有同样的问题:中间任何异常都会跳过解锁
1 | std::mutex m; |
std::lock_guard 的作用是让“离开作用域必定解锁”成为保证
异常安全与资源类边界
对象一旦构造成功,就应该始终保持有效状态。成员函数不能因为异常把对象破坏成半残状态
一个典型反例是朴素的拷贝赋值:
1 | Buffer& Buffer::operator=(const Buffer& rhs) { |
这段代码先删除旧资源,再申请新资源。如果 new 抛出异常,对象原来的数据已经丢失,data_ 还可能悬空,对象不再有效
更稳妥的做法是 copy-and-swap:
1 | class Buffer { |
这个顺序体现了异常安全的基本判断:危险操作先在临时对象上完成,成功之后再用不抛异常的操作替换当前对象,若构造 temp 失败,当前对象完全不变
移动构造和移动赋值也应该尽量 noexcept
移动的本质是接管已有资源,不应该重新分配大块内存
如果移动操作会抛异常,标准容器在扩容时可能退回到复制,影响性能和语义
三法则、五法则与零法则
当一个类手动管理资源时,特殊成员函数必须一起考虑
三法则:如果你自定义了析构函数、拷贝构造函数、拷贝赋值运算符中的任意一个,通常需要同时考虑另外两个
原因:既然需要手动析构,说明类里有资源;有资源就必须定义复制时到底是深拷贝、禁止拷贝,还是共享
C++11 之后还要考虑移动构造和移动赋值,于是变成五法则:
1 | class Resource { |
但现代 C++ 更推荐零法则:业务类不要直接管理裸资源,而是把资源交给已经正确实现 RAII 的成员
1 | class Person { |
std::string 已经正确处理构造、析构、复制和移动,所以 Person 不需要写任何特殊成员函数,让成员自己管理自己的资源,组合出来的类也自然安全
资源类应该尽量少,边界应该清楚:底层少数类负责封装 FILE*、socket、mutex、裸内存等资源;上层业务类使用 std::string、std::vector、unique_ptr、shared_ptr 等 RAII 类型
这样才能把复杂性限制在边界处,而不是扩散到整个程序
通过接口表达所有权意图
函数签名应该诚实表达所有权关系
调用者不应该靠猜测或注释判断一个函数会不会接管资源
| 参数或返回类型 | 所有权语义 | 适用场景 |
|---|---|---|
const T& | 只读借用 | 查询、打印、不修改对象 |
T& | 可写借用 | 修改对象,但不接管生命周期 |
T | 拥有一份值 | 需要副本,或通过移动接收值 |
std::unique_ptr<T> | 转移唯一所有权 | 函数接管资源 |
std::shared_ptr<T> | 共享所有权 | 函数需要延长对象生命周期 |
std::weak_ptr<T> | 非拥有观察 | 对象可能失效,需要检查 |
例如:
1 | void print(const Buffer& buf); // 借用 |
如果函数只是读取对象,却要求调用者传入 unique_ptr,就是过度要求;如果函数会把对象保存到异步任务里,却只接受 const T&,就是隐藏生命周期依赖
好的接口应该让所有权流动从类型上读得出来
设计判断
面对资源管理问题,可以按下面的顺序判断:
第一,能不用裸资源就不用裸资源。优先使用标准库 RAII 类型,例如 std::vector、std::string、std::fstream、std::lock_guard、std::unique_ptr
第二,若必须封装裸资源,先判断资源是否可复制。文件句柄、锁、socket 往往不可复制;堆上 buffer 如果要表现为值,则需要深拷贝;现实世界中的实体对象,如宠物、窗口、连接,通常不应该复制
第三,默认使用唯一所有权。只有当生命周期确实由多个拥有者共同决定时,才使用 shared_ptr
第四,借用就写引用或裸指针,不要为了“安全感”滥用 shared_ptr。安全来自清楚的生命周期设计,而不是把所有东西都共享
第五,析构只负责兜底清理。需要报告失败的关闭操作应通过显式成员函数完成