带你读懂Effective C++(三):资源管理
资源管理章节核心总结核心思想:RAII 是资源管理的基石,将资源生命周期与对象生命周期绑定,利用析构函数自动释放资源;拷贝行为:RAII 对象的拷贝由底层资源决定,常用方案为 “禁止拷贝” 和 “引用计数”;原始资源访问:RAII 类需提供显式 / 隐式访问接口,平衡安全性和便捷性,其核心是 “管理资源” 而非 “封装资源”;基础规则:new/delete的[]必须一一对应,避免内存布局不匹配导致
目录
13. Use objects to manage resources 以对象管理资源(RAII 核心思想)
15. Provide access to raw resources in resource-managing classes 在资源管理类中提供对原始资源的访问
条款 16:Use the same form in corresponding uses of new and delete 成对使用 new 和 delete 时要采取相同形式
条款 17:Store newed objects in smart pointers in standalone statements 以独立语句将 newed 对象置入智能指针
三 Resource Management 资源管理
C++程序中最常使用的资源就是动态分配内存(如果你分配内存却从不归还它,会导致内存泄漏),但内存只是你要管理的资源之一,其他常见的还有mutex,网络sockets以及数据库连接等,不再使用它们时,必须将它还给系统
13. Use objects to manage resources 以对象管理资源(RAII 核心思想)
核心解释
手动管理资源的最大问题:控制流异常跳转、提前返回时,资源释放代码(如delete)可能无法执行,导致泄漏。解决思想:RAII(Resource Acquisition Is Initialization,资源取得时机便是初始化时机) —— 获得资源后立刻存入管理对象,利用 C++ 对象离开作用域自动调用析构函数的特性,确保资源无论如何都会被释放(析构函数异常处理见条款 8)。
- RAII 对象构造函数:获取资源(内存、锁、连接等);
- RAII 对象析构函数:释放资源(无需手动调用,编译器自动执行);
- 典型 RAII 对象:智能指针(
std::shared_ptr/std::unique_ptr)、std::lock_guard(互斥锁管理)。
示例:手动管理 vsRAII 管理(避免内存泄漏)
#include <iostream>
#include <memory> // 智能指针头文件
using namespace std;
class Widget {};
// 反例:手动管理资源,异常导致泄漏
void func1() {
Widget* pw = new Widget(); // 获取资源
// 若此处抛出异常(如调用其他函数抛异常),delete永远不会执行
delete pw; // 释放资源(可能无法执行)
}
// 正例:RAII智能指针管理资源,无泄漏风险
void func2() {
shared_ptr<Widget> pw(new Widget()); // 获取资源即初始化RAII对象
// 即使此处抛异常,pw离开作用域时析构函数自动调用delete
}
int main() {
func2(); // 函数结束,pw析构,资源释放
return 0;
}
关键要点
- RAII 的核心是将资源生命周期与 RAII 对象生命周期绑定,对象生则资源在,对象死则资源释;
- 无需关注控制流如何离开作用域,编译器保证析构函数执行,从根本上解决资源泄漏。
请记住:
- 为防止资源泄露,请使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源
14. 在资源管理类中小心 copying 行为
核心解释
RAII 对象的核心是管理底层资源,因此RAII 对象的拷贝行为由底层资源的拷贝语义决定 —— 复制 RAII 对象必须同步处理其管理的资源,不存在统一的拷贝规则,需根据资源特性选择合适的拷贝行为,共 4 种典型方案。
4 种典型拷贝行为及示例
1. 禁止拷贝(最直接)
适用场景:底层资源不支持拷贝(如互斥锁、单例资源、数据库唯一连接),允许 RAII 对象拷贝无任何意义。实现方式:参考条款 6,要么将拷贝构造 / 赋值声明为private不实现,要么继承Uncopyable基类。
#include <mutex>
using namespace std;
// 模拟互斥锁管理类(互斥锁不支持拷贝,禁止RAII对象拷贝)
class MutexLock : private Uncopyable { // 继承Uncopyable禁用拷贝
public:
MutexLock(mutex& m) : mtx(m) { mtx.lock(); } // 构造:获取资源(加锁)
~MutexLock() { mtx.unlock(); } // 析构:释放资源(解锁)
private:
mutex& mtx;
};
mutex m;
void func() {
MutexLock lock(m);
// MutexLock lock2 = lock; // 编译错误:拷贝被禁用
}
2. 对底层资源使用引用计数(最常用)
适用场景:希望多个 RAII 对象共享一份底层资源,直到最后一个 RAII 对象被销毁时,才释放资源。实现方式:使用std::shared_ptr(原生支持引用计数),支持自定义删除器(不仅限于释放内存,可适配任意资源的释放逻辑)。
#include <memory>
#include <iostream>
using namespace std;
// 自定义删除器:适配非内存资源的释放(示例为内存,可替换为关闭连接、释放句柄等)
void custom_deleter(Widget* ptr) {
cout << "自定义删除器释放资源:" << ptr << endl;
delete ptr;
}
int main() {
// shared_ptr构造:传入资源指针+自定义删除器
shared_ptr<Widget> p1(new Widget(), custom_deleter);
shared_ptr<Widget> p2 = p1; // 拷贝:引用计数+1(此时计数=2)
p1.reset(); // p1释放管理权,计数=1
p2.reset(); // p2释放管理权,计数=0 → 调用自定义删除器释放资源
return 0;
}
关键特性:shared_ptr的引用计数是线程安全的,自定义删除器不影响 RAII 对象的拷贝语义。
3. 复制底层资源(深拷贝)
适用场景:希望每个 RAII 对象拥有独立的底层资源副本,拷贝 RAII 对象时,需完整复制其管理的资源(避免多个对象共享同一份资源导致的冲突)。实现方式:为 RAII 类实现深拷贝版的拷贝构造和拷贝赋值运算符,复制资源而非仅复制资源指针。
#include <cstring>
using namespace std;
// 模拟字符串资源管理类(深拷贝)
class MyStringRAII {
public:
MyStringRAII(const char* s) { // 构造:获取资源(分配内存)
len = strlen(s);
data = new char[len+1];
strcpy(data, s);
}
// 拷贝构造:深拷贝底层资源(每个对象有独立内存)
MyStringRAII(const MyStringRAII& rhs) {
len = rhs.len;
data = new char[len+1];
strcpy(data, rhs.data);
}
// 拷贝赋值:深拷贝底层资源
MyStringRAII& operator=(const MyStringRAII& rhs) {
if (this == &rhs) return *this;
// 释放当前资源
delete[] data;
// 复制新资源
len = rhs.len;
data = new char[len+1];
strcpy(data, rhs.data);
return *this;
}
~MyStringRAII() { delete[] data; } // 析构:释放资源
private:
char* data;
size_t len;
};
int main() {
MyStringRAII s1("hello");
MyStringRAII s2 = s1; // 拷贝:s2拥有独立的"hello"内存
return 0;
}
4. 转移底层资源的所有权
适用场景:希望仅有一个 RAII 对象管理底层资源,拷贝时将资源所有权从源对象转移给目标对象,源对象变为 “空状态”(不再管理任何资源)。实现方式:使用std::unique_ptr(原生支持独占所有权,C++11 后支持移动语义),或为自定义 RAII 类实现移动构造 / 移动赋值。
#include <memory>
using namespace std;
int main() {
unique_ptr<Widget> p1(new Widget()); // p1拥有资源所有权
// unique_ptr<Widget> p2 = p1; // 编译错误:禁止普通拷贝
unique_ptr<Widget> p2 = move(p1); // 移动拷贝:所有权从p1转移到p2
// p1现在为nullptr,不再管理任何资源;p2拥有原资源
return 0;
}
当一个RAII对象被复制,会发生什么事?
-
禁止复制:许多时候允许RAII对象被复制并不合理.如果复制行为对RAII class不合理,应该禁止(条款6)
-
对底层资源使用引用计数法:有时候我们希望保有资源,直到它的最后一个使用者被销毁(std::shared_ptr就是这个方法)
// 补充:自己可以选择"销毁"方式,不一定是释放内存 void custom_deleter(int* ptr){ std::cout << "custom_deleter" << ptr << std::endl; delete ptr; } std::shared_ptr<int> ptr(new int(5), custom_deleter); -
复制底层资源:有时候,只要你喜欢,可以针对一份资源拥有其任何数量的副本.而你需要资源管理类的唯一理由是:当你不再需要某个资源时确保它被释放.在此情况下复制资源管理对象,应该复制其所包覆的所有资源(深拷贝)
-
转移底层资源的拥有权
请记住:
- 复制RAII对象必须一并复制它所管理的资源,所以资源的copying行为决定了RAII对象的copying行为
- 普遍而常见的RAII class copying行为是:抑制copying,实行引用计数法.不过其他行为也都可能被实现
15. Provide access to raw resources in resource-managing classes 在资源管理类中提供对原始资源的访问
核心解释
RAII 类封装了底层资源,但实际开发中大量 API 要求直接传入原始资源(raw resource)(如系统调用、第三方库接口仅接受指针 / 句柄 / 连接符),因此 RAII 类必须提供原始资源访问接口,否则无法适配现有 API,失去实用价值。常见实现方式有两种,各有优劣,需根据场景选择。
两种原始资源访问方式及利弊
1. 显式转换(安全优先)
通过专用成员函数(如get())获取原始资源,调用者需显式调用,意图明确,避免意外的资源访问。
- 典型示例:
std::shared_ptr/std::unique_ptr的get()方法(返回管理的原始指针); - 优势:安全,无隐式转换的意外风险;
- 劣势:稍显繁琐,需手动调用
get()。
2. 隐式转换(便捷优先)
通过类型转换运算符实现,调用者可直接将 RAII 对象当作原始资源使用,无需显式调用函数。
- 优势:便捷,对调用者透明,降低使用成本;
- 劣势:存在隐式转换风险,可能导致原始资源 “虚吊”(dangle)—— 资源被 RAII 对象释放后,隐式获取的原始资源仍被使用。
示例:显式转换 vs 隐式转换(以字体资源管理为例)
#include <iostream>
using namespace std;
// 模拟原始字体资源句柄
typedef void* FontHandle;
// 模拟系统API:仅接受原始FontHandle
void useFont(FontHandle fh) { cout << "使用字体句柄:" << fh << endl; }
// 字体资源RAII管理类
class Font {
public:
// 构造:获取字体资源(模拟)
Font(FontHandle fh) : f(fh) {}
// 析构:释放字体资源(模拟)
~Font() { cout << "释放字体资源:" << f << endl; }
// 方式1:显式转换 —— 专用get()方法
FontHandle get() const { return f; }
// 方式2:隐式转换 —— 类型转换运算符(注释后为仅显式转换)
operator FontHandle() const { return f; }
private:
FontHandle f; // 管理的原始字体资源
};
int main() {
FontHandle rawFh = (FontHandle)0x123456;
Font f(rawFh);
// 显式转换调用API:意图明确,安全
useFont(f.get());
// 隐式转换调用API:便捷,无需get()
useFont(f);
// 隐式转换的风险:意外获取原始资源,导致虚吊
FontHandle danglingFh = f; // 隐式转换为原始句柄
// f离开作用域,析构函数释放rawFh → danglingFh成为虚吊句柄
// 此时使用danglingFh会导致未定义行为
return 0;
}
// 某些程序员认为到处要求显式转换会使客户倒尽胃口,不再愿意使用这个类,从而增加了泄漏字体的可能性,而Font class的主要设计就是为了防止资源泄露
// 但隐式转换也有一定风险
Font f1;
FontHandle f2 = f1;
// 原意是拷贝一个Font对象,却反而将f1隐式转换为了FontHandle,而后才复制它
// 当你释放f1后,f2成为了"虚吊的"(dangle)
// 这与"封装"并不冲突,RAII classes并不是为了封装某物而存在的,它们的存在是为了确保一个特殊行为-资源释放会发生
关键误区澄清
RAII 类的核心设计目标是确保资源释放,而非封装资源 —— 提供原始资源访问并非破坏封装,而是为了适配实际 API 需求,这是 RAII 类实用化的必要条件。
请记住:
- APIs往往要求访问原始资源(raw resources),所以每一个RAII class应该提供一个"取得其所管理之资源"的办法
- 对原始资源的访问可能经由显式转换或隐式转换.一般而言显示转换比较安全,但隐式转换对客户比较方便
条款 16:Use the same form in corresponding uses of new and delete 成对使用 new 和 delete 时要采取相同形式
核心解释
C++ 中new/delete的内存布局和处理逻辑与是否使用数组标识[]强绑定,核心规则:new带[]则delete必须带[],new不带[]则delete也必须不带[],违反则导致未定义行为(内存泄漏、堆破坏、程序崩溃)。
new T:分配单个 T 对象的内存,调用一次 T 的构造函数,内存布局为 “单个对象信息”;new T[]:分配 T 数组的内存,调用 N 次 T 的构造函数,内存布局包含 “数组长度信息(cookie)”+“多个对象信息”;delete p:释放单个对象内存,调用一次析构函数,仅处理 “单个对象信息”;delete[] p:释放数组内存,先根据 “数组长度信息” 调用 N 次析构函数,再释放整体内存。
示例 1:基础错误示例
#include <iostream>
using namespace std;
class Widget {
public:
Widget() { cout << "Widget构造" << endl; }
~Widget() { cout << "Widget析构" << endl; }
};
int main() {
// 单个对象:new不带[]
Widget* p1 = new Widget();
delete p1; // 正确:delete不带[] → 调用1次析构
// delete[] p1; // 错误:布局不匹配 → 未定义行为
// 数组对象:new带[]
Widget* p2 = new Widget[3];
delete[] p2; // 正确:delete带[] → 调用3次析构
// delete p2; // 错误:布局不匹配 → 未定义行为(仅调用1次析构,内存泄漏)
return 0;
}
示例 2:typedef 的隐藏坑(极易忽略)
当typedef定义数组类型时,使用new创建该类型对象,本质是数组分配(new []),释放时必须使用delete[],即使代码中未显式写[],这是高频错误点。
#include <string>
using namespace std;
// typedef定义数组类型:AddressLine是"4个string的数组"
typedef string AddressLine[4];
int main() {
// 看似是单个对象分配,实际是数组分配(new string[4])
AddressLine* pal = new AddressLine;
// 释放时必须带[],否则未定义行为
delete[] pal; // 正确
// delete pal; // 错误:未调用4个string的析构,内存泄漏
return 0;
}
请记住:
- new和delete的使用中,[]要一一对应
条款 17:Store newed objects in smart pointers in standalone statements 以独立语句将 newed 对象置入智能指针
核心解释
将new创建的对象直接作为智能指针构造函数的参数(合写语句),可能因编译器执行顺序优化导致隐蔽的资源泄漏,解决办法是将 “创建对象” 和 “置入智能指针” 拆分为两个独立语句,确保智能指针能及时接管资源。
核心问题:编译器的执行顺序重排
C++ 标准仅规定函数参数的求值顺序未定义,编译器会为了优化性能调整参数的计算顺序,以以下代码为例:
// 函数声明
int priority(); // 可能抛出异常的函数
void processWidget(shared_ptr<Widget>, int);
// 合写语句:new对象直接传入智能指针构造
processWidget(shared_ptr<Widget>(new Widget()), priority());
编译器可能的执行顺序(非唯一):
- 执行
new Widget()—— 分配内存,创建 Widget 对象(资源已获取); - 执行
priority()—— 计算第二个参数的值; - 执行
shared_ptr<Widget>(...)—— 智能指针构造,接管资源。
泄漏风险:若步骤 2 的priority()抛出异常,步骤 3 的智能指针构造永远不会执行,而步骤 1 已获取的 Widget 资源无人接管,成为 “孤儿资源”,导致内存泄漏。
解决办法:独立语句拆分
将合写语句拆分为两步,编译器不会重排跨语句的执行顺序,确保智能指针在可能抛异常的函数调用前就接管资源:
- 第一步:创建对象并置入智能指针(RAII 对象初始化,资源已被管理);
- 第二步:调用目标函数,传入智能指针。
即使后续函数调用抛异常,智能指针已存在,离开作用域时会自动析构释放资源,无泄漏风险。
示例:合写语句(有风险)vs 独立语句(无风险)
#include <memory>
#include <iostream>
using namespace std;
class Widget {};
int priority() { throw runtime_error("优先级计算失败"); } // 必抛异常
void processWidget(shared_ptr<Widget>, int) {}
int main() {
// 错误:合写语句,存在泄漏风险
// processWidget(shared_ptr<Widget>(new Widget()), priority());
// 执行顺序:new Widget() → priority()抛异常 → 智能指针未构造 → 资源泄漏
// 正确:独立语句,无泄漏风险
shared_ptr<Widget> pw(new Widget()); // 步骤1:智能指针接管资源
processWidget(pw, priority()); // 步骤2:调用函数,即使抛异常,pw已管理资源
// 即使priority()抛异常,pw离开作用域时析构,释放Widget资源
return 0;
}
额外说明
shared_ptr的构造函数是explicit的,因此processWidget(new Widget(), priority())会直接编译报错(无法隐式转换),但这并不解决合写语句的泄漏问题,仅能避免语法错误,核心解决方案仍是独立语句拆分。
请记住:
- 以独立语句将newed对象存储入智能指针内.如果不这么做,一旦异常被抛出,有可能导致难以察觉的资源泄露
更多推荐



所有评论(0)