《Effective C++》第三部分:资源管理

所谓资源就是,我们可以向系统申请并使用的东西,但是将来必须归还给系统。说到这类东西,我们最容易想到的就是动态内存了,除此之外还包括文件描述器(file descriptors)、互斥锁(mutex locks)、数据库连接以及网络套接字。另外提到内存,不仅仅是 new 的基本对象,一些 new 出来的对象或者通过工厂方法得到的对象指针都是属于“内存资源”的范畴。

条款13:以对象管理资源

提要

  1. 为防止资源泄露,请使用RAII对象,他们在构造函数中获得资源并在析构函数中释放资源。
  2. 两个被常使用的RAII类是tr1::shared_ptrauto_ptr。前者通常是较佳选择,因为他们的复制行为更正常,也可以和标准库容器更好地结合。后者的复制行为会导致被复制者指向null。

解释

假设一个常见的场景,我们有一个基类和一系列的派生类,并有一个工厂方法动态创建派生类并返回其指针(但是我们通常用基类类型接受这个指针)。注意,工厂函数的调用者本质上是动态申请了资源,根据“谁申请谁释放”的原则,调用者要负责把这个指针 delete 掉。我们又会想,“这个我肯定不会忘啊!”。是这样的,但是问题往往发生在我们不注意的时候,比如我们接到需求修改代码或者接受别人的代码的时候,不注意在 delete 前加上了其他的控制流比如return或者break等,或者加上了可能导致异常的语句,这样,最后那个我们觉得万无一失的delete可能就不会执行了。

避免问题总是比解决问题更好。

1
2
3
4
5
void this_is_a_func {
std::auto_ptr<MyClass> p(MyClassFactory());
...
// 不需要在最后手动 delete
}

这便是本条款的核心思想:“以对象管理资源”,这个思想还有一个名字是“资源取得时机就是初始化时机”(Resource Acquisition Is Initialization; RAII)。该思想包含两个关键成分。

  1. 获得资源后立刻放进管理对象。 具体表现为通过工厂方法得到的动态对象指针,我们立刻用其作为管理对象的初值。(也有一些场景用于对管理对象赋值而不是初始化。)这里的立刻,是指在同一个语句内。
  2. 管理对象运用析构函数确保资源被释放。 不管控制流如果,只要离开作用域,析构函数就会自动执行并释放资源。很少数的情况是资源释放动作导致异常,这个我们前面讨论过了。【$$$\Rightarrow Item8$$$】

auto_ptr自身有一个问题是,由于它会自动销毁所指的对象,因此,当多个智能指针指向同一个对象时就会有重复 delete 同一块内存的问题。C++ 为了预防这个事情,使得auto_ptr的拷贝构造和拷贝赋值运算符执行时会让被复制者指向null。这个奇怪的特点使得他不能和 STL 兼容,也就是auto_ptr放到容器中可能会有奇怪的表现。一个更好的方案是reference-counting smart pointer; RCSP。RCSP 提供了引用计数的功能,tr1::shared_ptr就是 RSCP。(RSCP不能自动解决循环引用。)他们对于复制行为有着合乎常识的表现,因此可以用于 STL 容器。【$$$\Rightarrow Item14; 18; 54$$$】

上面提到的两个智能指针都是在析构函数内执行delete而不是delete[],因此,千万不要用他们管理动态分配得到的数组(编译器不会对此报错)。如果你想使用动态分配的数组,那么考虑一下 vector 和 string 吧,如果这两个都不合意,那可以参考 Boost 中的boost::scoped_arrayboost::shared_array。【$$$\Rightarrow Item55$$$】

通常而言这两个智能指针都可以较好的管理资源,但是一旦有他们不能应对的情况出现时,我们就要自己管理的,这就要参考到后两个条款了。

最后,本文最开始示例代码中的工厂方法是非常容易出错的接口,我们会在条款18中对其进行改良。

条款14:在资源管理类中小心 coping 行为

提要

  1. 复制 RAII 对象必须一并复制它所管理的资源,所以资源的 copying 行为决定了 RAII 对象的 copying 行为。
  2. 常见的 RAII 类的 copying 行为有:禁止复制、引用计数。

解释

条款 13 中我们提到有的时候我们可能需要自己写管理资源的类,但是却没说是什么情况,这里给出一个可能的场景,就是互斥锁。当我们管理的资源需要 delete 时,可以用上文提到的两种智能指针作为资源管理类(这里其实也可以,不过只可以用一种,本条款后半部分会介绍),因为这两种智能指针默认在析构函数中对资源进行 delete。但是对于互斥锁,可能是使用lock(Mutex* p)unlock(Mutex* p)两个函数来进行资源的获取和释放。此时就要手动实现资源管理类,并在析构函数中对资源进行释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Lock{
public:
explicit Lock(Mutex* pm): mutexPtr(pm)
{ lock(mutexPtr); } // 获得资源
~Lock() { unlock(mutexPtr); } // 释放资源
private:
Mutex *mutexPtr;
}


// 使用资源管理类
Mutex m;
{
Lock l(&m);
...

} // 退出作用域时自动释放资源

截至到这里都没有问题,但是,我们要考虑一下:Lock类对于复制要表现出怎样的行为?常见的方法有二。

  1. 禁止复制。 许多时候允许 RAII 对象被复制是不合理的。具体做法我们之前已经见过了。【$$$\Rightarrow Item6$$$】
  2. 引用计数。 我们不需要手动实现引用计数功能,我们要做的只是让 RAII 类含有tr1::shared_ptr类型的成员即可。我们也不用写Lock类的拷贝构造函数和析构函数,因为tr1::shared_ptr作为 non-static 成员,其默认版本会被自动调用,这已经足够了。但是注意,我们不希望对资源执行delete而是unlock,我们要把这个事情在初始化时,告诉tr1::shared_ptr
1
2
3
4
5
6
7
8
class Lock{
public:
explicit Lock(Mutex* pm): mutexPtr(pm, unlock)
{ lock(mutexPtr.get()); } // 【$$$\Rightarrow Item15$$$】
// 注意不需要手动写析构函数了
private:
std::tr1::shared_ptr<Mutex> mutexPtr;
}

除了上面两种常见的方法,还有一些其他的方法也是可选的方案,比如:

  1. 复制底层资源。
  2. 转移资源所有权。 这实际上是auto_ptr的实现策略。

条款15:在资源管理类中提供对原始资源的访问

提要

  1. APIs 往往要求访问原始资源,所以每一个 RAII 类都应该提供一个“取得其所管理之资源”的办法。
  2. 对原始资源的访问可能经由显式转换或隐式转换。一般而言显式转换比较安全,但是隐式转换对客户比较方便。

解释

该条款的动机是,资源管理类和原始资源的类型是不同的,对于某些只接受原始资源的函数,我们无法传入资源管理类,这会导致编译器报错。比如条款14中,unlock函数只接受Mutex*类型的参数,我们无法传入Lock或者Lock*类型的对象。

解决方法有两种,一种是像auto_ptrshared_ptr那样,提供一个主动暴露内部资源的get方法。而且,这些智能指针还重载了指针取值操作符operator->operator*,这意味着,这两个操作符都是直接作用在内部资源上的。

但是,可能接受原始的资源类型的函数太多了,如果每次都手动执行一个get可能太麻烦了。因此另外一种方法是,提供一个隐式转换函数。比如:

1
2
3
4
5
6
class RAII{
operator SomeHandle() const //隐式转换函数
{ return h; }
private:
SomeHandle h;
}

但是这种隐式转换可能会被 C++ 以我们不希望的方式使用,最终导致问题。这两种方式哪个更好没有定论,隐式方法更方便却更容易出错,显式方法虽麻烦却更安全。从 C++ 的智能指针来看,似乎显式方法更受到青睐。最后提一句,可能有人会奇怪直接暴露内部资源的get方法是不是破坏了类的封装性?我们要这么考虑,RAII 类的根本目的不是封装资源,而是安全释放资源。【$$$\Rightarrow Item18$$$】

条款16:成对的使用 new 和 delete 时要采取相同形式

提要

  1. 如果在 new 表达式中使用[], 必须在相应的 delete 表达式中也使用[]。如果在 new 表达式中不使用 [],一定不要在相应的 delete 表达式中使用 []。

解释

new 的时候会完成两个动作,第一是申请一块合适大小的内存,第二是对这片内存使用构造函数。使用 delete是执行相反的步骤,即先进行析构再归还内存。【$$$\Rightarrow Item49;51$$$】

但是对于 delete,最大的问题在于要弄清楚即将被删除的内存之内究竟有多少对象。为了对这二者进行区分,new创建单一对象和new[]创建对象数组所得到的的内存布局是不同的,后者在这段连续内存中的最开始会保存“数组大小”的记录。这两种行为的不同,使得我们必须成对的调用相同形式的newdelete

在使用的时候有几个场景要注意。第一,这意味着我们如果在一个类中动态分配内存,那么所有的构造函数都要以同样的方式申请内存,因为构造函数只有一个。第二,对于typedef,尽量不要对数组形式做typedef,如果有这种需求,尽可能使用vector以及string等标准程序库。

条款17:以独立语句将 new 出来的对象放入 RAII 类

提要

  1. 以独立语句将 new 出来的对象存储在智能指针中,如果不这样做,一旦异常发生,有可能导致难以察觉的资源泄露。

解释

假设我们使用tr1::shared_ptr作为 RAII 类,并用它管理动态分配动态分配得到的Widget类。因为 RAII 是指“资源获得即初始化”,我们记住了这一点,把刚获得的对象直接放进tr1::shared_ptr中。但是我们甚至还多做了一步,把获得资源后的tr1::shared_ptr还直接放进了后续的函数中。如下。

1
process(std::tr1::shared_ptr<Widget>(new Widget), someMethod());

上面代码中把someMethod的返回值作为process的第二个参数,这么做会产生一个问题。编译器对这一行代码要做三个事情:一、执行new Widget;二、调用tr1::shared_ptr的构造函数;三、调用someMethod。但是调用someMethod这件事情的执行位置是不确定的(在Java中是确定的顺序),如果编译器将它放在这三件事的中间做,并且someMethod的调用发生了异常,那么new出来的对象的指针就遗失了,因为发生异常时他还没有被放入资源管理对象。为了避免这个问题,方法很简单,确保 RAII 是一个整体即可。

1
2
std::tr1::shared_ptr<Widget> pw<new Widget>;
process(pw, someMethod());

因为编译器对于“跨语句的操作”没有重新排列的自由,因此我们就可以保证资源管理类可以稳妥的拿到被管理的资源。

本站总访问量