Effective C++ 阅读笔记(三)
每当用完一个资源后,你必须把它释放掉,否则情况就很糟糕,比如C++
中通常使用动态分配的内存,如果你分配了内存又没有释放掉,就会发生内存泄漏。除了内存之外,类似的资源还有很多,比如文件描述符、锁、GUI中的字体和笔刷、数据库的连接、网络 socket 等等。
通常来说,手动进行资源的申请和分配是很困难的,特别是考虑到程序可能在某个地方抛异常、一个函数可能有多个返回位置,包括当一个不熟悉完整程序的人来维护代码的时候。这些场景下尤其容易出问题。由经验可知,更好的方法是用面向对象的方式进行资源管理,这个方法基本能解决所有资源管理相关的问题。
Item 13: Use objects to manage resources.
这里的核心就是用对象管理资源的思想,也就是常说的 RAII ( Resource Acquisition Is Initialization )。
比如看下面这个没有用对象管理,而是手动管理的例子:
class Investment { ... };
Investment* createInvestment();
void f()
{
Investment *pInv = createInvestment(); // call factory function
... // use pInv
delete pInv; // release object
}
有很多种可能性导致函数不能正常执行到最后这句delete
,比如:
- 提前
return
,类似的是在循环中的break
或continue
- 中间的部分抛异常
一旦发生这种情况就会产生内存溢出。比较好的解决方式是利用作用域,通过初始化对象的方式获取资源,通过对象的析构函数释放资源,最常见的方法是用auto_ptr
:
void f()
{
std::auto_ptr<Investment> pInv(createInvestment()); // call factory
// function use pInv as before
...
}
这里涉及到两个用对象管理资源的重要原则:
- 任何资源被获取后,立刻将其和一个对象关联起来。这种思想通常被称为
RAII
,有时可能并不是对资源的初始化,而是分配,但无论如何都是将资源和对象进行关联,使用对象的生命周期来管理资源。 - 通过资源管理对象的析构函数来释放资源。也就是利用对象生命周期来管理资源的方式,当离开作用域时析构函数会被自动调用(无论通过何种方式离开作用域)。当然,这个释放资源的析构函数最好不要抛异常,否则可能会有同时抛多个异常无法处理的问题,这在
Item 9
中讨论过。
auto_ptr 和 shared_ptr
在C++
中,auto_ptr
和shared_ptr
都可以被用来作为资源管理对象,但相对来说auto_ptr
有一些奇怪的特性,进行拷贝的结果非常反直觉。因为auto_ptr
要求,一个对象同时只能被一个auto_ptr
指向(否则会导致对象被重复delete
),所以auto_ptr
的拷贝函数会导致原对象失效置为null
,比如下面的例子:
std::auto_ptr<Investment> // pInv1 points to the
pInv1(createInvestment()); // object returned from createInvestment
std::auto_ptr<Investment> pInv2(pInv1); // pInv2 now points to the object; pInv1 is now null
pInv1 = pInv2; // now pInv1 points to the object, and pInv2 is null
这个特性会导致STL
拒绝模板类型为auto_ptr
的容器,因为STL
模板类型要求使用的拷贝行为是正常的,例如std::vector<std::auto_ptr<int>> vc;
这样的定义会报 warning。
shared_ptr
相对来说更好一些,会通过引用计数的方式决定何时释放资源,它是一个 reference-counting smart pointer (RCSP),和垃圾回收的机制很像。
但是auto_ptr
和shared_ptr
有同样的缺点,就是释放资源使用的是delete
而不是delete []
,这导致它们无法释放数组资源。比如下面的写法是有问题的:
std::auto_ptr<std::string> aps(new std::string[10]) // bad idea! the wrong delete form will be used
std::tr1::shared_ptr<int> spi(new int[1024]); // same problem
一般来说,可以用vector
和string
作为数据的替换对象,如果一定要用数组,则可以用boost::scoped_array
和boost::shared_array
。
另外,auto_ptr
和shared_ptr
只是两种常见的实现方式,用对象管理资源的思想和这两个对象本身无关。当你需要一些自定义的管理方式时,也可以自己实现一个数据管理类。
Item 14: Think carefully about copying behavior in resource-managing classes.
对资源管理类进行拷贝行为,可能有以下几种处理:
禁止
如果对一个 RAII 类拷贝行为是没有意义的,就应该明确禁止。在
C++11
中通过delete
关键字来禁止生成拷贝函数。引用计数
典型的是
shared_ptr
,每次拷贝会增加引用计数器,直到没有一个类指向资源时释放资源。类似的,也可以通过在我们自己的 RAII 类中增加一个
shared_ptr
成员来实现类似的功能。shared_ptr
允许修改引用计数器变为 0 时进行的操作,比如可以不是释放内存空间,而是释放锁:class Lock { public: explicit Lock(Mutex *pm) // init shared_ptr with the Mutex : mutexPtr(pm, unlock) // to point to and the unlock func as the deleter† { lock(mutexPtr.get()); // see Item 15 for info on “get” } private: std::tr1::shared_ptr<Mutex> mutexPtr; // use shared_ptr };
拷贝所有资源
比如 STL 中的
string
,会同时拷贝指针和指针指向的内存空间,也就是进行一次深拷贝。这种情况下,资源管理类的作用仅仅是为了保证每一份拷贝都能够在不需要的时候被释放掉。转移所有权
当你希望同时只有一个 RAII 对象指向资源时可以使用,进行拷贝时,将资源所有权从被拷贝的对象转移给拷贝者。这是比较奇怪的用法,典型是
auto_ptr
。
Item 15: Provide access to raw resources in resourcemanaging classes.
RAII 类通常要提供直接对资源进行访问的方法,也就是要能够将 RAII 对象转化为资源对象,通常有隐式和显式两种转换方法,以shared_ptr
为例,定义std::shared_ptr<Investment> pInv(createInvestment())
:
- 显式转换:
pInv.get()
获得Invectment *
指针 - 隐式转换又包含两类:
- 重载
->
和*
操作符,通过pInt->isTaxFree()
调用指向对象的isTaxFree
方法、*pInt
获取指向的对象。 - 提供隐式转换函数,例如
operator int() const { return f; }
。当这种转换非常频繁时,隐式转换函数能提高开发效率,但相应的可能会导致意料之外的转换。例如用户原本可以通过函数传递的参数类型,让编译器来帮助它们找出一些错误,但提供隐式转换函数后编译器不再报错,可能导致一些问题更难发现。
- 重载
是否提供隐式转换涉及到了一些接口的设计原则,同时又和类的封装性相关。从设计上来说,RAII 类的功能并不是为了封装什么,而仅仅是为了保证资源泄露不会发生。但这不意味着 RAII 类就不能进行封装,而且很多 RAII 类可以同时做到,自身结构封装良好,同时提供方便的访问资源的形式,典型的就是shared_ptr
,其中关于引用计数的部分是封装的,对用户不可见,但其指向的指针又通过重载->
和*
等操作符,能够方便地使用。关于如何设计类和接口的问题将在之后深入讨论。
Item 16: Use the same form in corresponding uses of new and delete.
要使用形式相同的new
和delete
。
简单来说就是有两种形式:非数组形式new
和delete
,以及数组形式new[]
和delete[]
。在使用非数组形式的delete
时,只会调用指针所指向的一个对象的析构函数,而使用数组形式的delete[]
时,会根据数组长度调用数组中每个对象的析构函数。如果使用数组形式的new[]
却使用非数组形式的delete
,显然会造成内存泄漏。
编译器如何知晓数组长度
这里自然引入了一个问题:在使用数组形式的delete[]
时,编译器是如何知晓数组长度的?首先这个问题并没有标准答案,这取决于编译器自己的实现方法,通常来说的方案是这样的,当程序中调用new T[N]
时,除了要申请一个大小为N * sizeof T
的空间之外,还需要额外申请一块cookie
,即实际申请的内存大小是(N * sizeof T) + (cookie size)
,这个cookie
通常放在数组之前,用于记录数组的长度,结构示意如下:
这样一来,每当编译器需要知道数组长度时,只要读取cookie
中的数据即可。我自己给出一个示例如下:
#include <iostream>
#define debug(x) std::cerr << #x << ": " << x << std::endl
const int N = 34;
int main() {
int *a = new int[N], *b = new int[N];
debug((char *)b - (char *)a);
debug(((char *)b - (char *)a) - (N * sizeof(int)));
debug(a[-2]);
debug(b[-2]);
return 0;
}
output:
(char *)b - (char *)a: 144
((char *)b - (char *)a) - (N * sizeof(int)): 8
a[-2]: 145
b[-2]: 145
编译器为g++
,当我使用new int[N]
创建int
数组时,可以发现申请的内存字节大小首先保证不小于N * sizeof int + 8(cookie size)
,接着进行16字节对齐。而数组长度则存储在数组[-2]
下标,即从数组[0]
下标往前找8个字节所对应的内存中。(读取值实际上为数组占用内存大小 +1,+1 的原因还不太清楚)
可以在文档中找到编译器对于数组长度存储方式的定义:https://github.com/gcc-mirror/gcc/blob/16e2427f50c208dfe07d07f18009969502c25dc8/gcc/cp/init.c#L3319-L3325
我们可以讨论一下这种设计的缺陷:
- 缺少标准定义,不同编译器可能对记录数组长度的方式有不同的实现
- 缺少封装,把数组长度记录在一个不受保护的内存空间中,很可能被修改导致未定义的行为
- 数组失去灵活性,从内存访问的角度讲,我们完全可以从数组的任何一个地方开始访问,或者把数组的某个片段取出来作为一个新数组来使用,但由于这个性质,新数组的一些操作就无法进行了,比如无法正常进行资源释放
这些问题很难解决(可以说是 C/C++ 语言本身的一种缺陷?),只能尽力避免,比如用自己封装好的类去管理数组的创建和释放(vector),或者在编程的时候更加小心谨慎一些(…)。
该部分参考:
Item 17: Store newed objects in smart pointers in standalone statements.
用单独的语句来定义智能指针并初始化。
这里的意思是尽量不要和其他的调用混在同一句中,比如调用函数时可能会导致内存泄漏:
int priority();
void processWidget(std::shared_ptr<Widget> pw, int priority);
processWidget(std::shared_ptr<Widget>(new Widget), priority());
调用processWidget
函数时需要先构造参数,这里有三个步骤要完成:
new Widget
priority()
std::shared_ptr<Widget>()
问题是这三个步骤的顺序是不确定的(除了std::shared_ptr<Widget>()
一定在new Widget
之后),也就是说,可能会出现先调用new Widget
,再调用priority()
,结果priority()
抛出异常,那么new Widget
生成的指针就丢失了,发生了内存泄漏。
所以更好的方法是把new
和shared_ptr
的初始化放在一句中:
std::shared_ptr<Widget> pw(new Widget); // store newed object in a smart pointer in a standalone statement
processWidget(pw, priority()); // this call won’t leak
这里涉及到 C++ 对于执行顺序的调整,通常来说,在同一句中执行的顺序是不确定的(在 C#、JAVA 等语言中可能顺序是固定的),但不同句之间很少进行顺序的调整。