C++中,我们需要手动管理资源。最常见的资源就是动态分配的内存,但注意,它不是唯一的资源。其他资源还有:file descriptors, mutex locks, fonts and brushes in graphical user interfaces.

Item13: Use objects to manage resouces

核心思想:把资源放进对象里,用对象来管理资源,让对象的构造和析构来保证资源的获取与释放。

其实,这就是 RAII。

这是因为,C++没有垃圾回收机制,手动 new / delete 十分容易出错(比如 delete 可能因为一些异常情况而没有被执行),所以我们需要一个规范化的获取和释放资源的流程。

手动管理资源的做法

例如,下面的代码使用一个 factory function 来为所有继承自 Investment 的类创建统一的调用接口(一个基类指针)。

如果你手动管理资源(new / deletefopen / fcloselock / unlock),很容易出错:

1
2
3
4
5
void f() {
    Investment* pInv = createInvestment();
    // ... 中间可能有 return、异常、goto 等各种出口
    delete pInv;  // 万一没走到这里?内存泄漏
}

当然,你可以说:啊我 coding 的时候很仔细的,不会犯这样的低级错误。

但是,随着工程越来越大,代码越来越长,不规范的写法维护的成本也会越来越高。所以,为了确保资源一定能够安全释放,我们还是使用 RAII 的做法。

RAII 的做法

用一个对象来持有资源,对象的析构函数负责释放资源。

auto_ptr 就是这样一个对象。它是一个智能指针,管理着一个裸指针;在自身析构时,会自动对自己管理的裸指针调用 delete

再次回顾:<code>delete</code> 实际做了两件事情

  • 第一件,调用对象的析构函数,清理对象管理的资源(如对象管理的指针指向的资源、文件、锁等等)。(调用析构函数清理资源)
  • 第二件,释放对象本身占用的堆上内存。(释放内存)
1
2
3
4
5
void f() {
    std::auto_ptr<Investment> pInv(createInvestment());  // 对象持有资源
    // ... 无论怎么退出(return、异常),pInv 都会析构
    // 析构时自动 delete pInv
}

不管控制流怎么走,析构函数一定会被调用——这就是对象管理的优势。

过时内容说明

当然,书里用来举例子的 auto_ptr 事实上已经过时了,它在 C++11 中已经被废弃,C++17 中已删除。现在我们应该用:

  • std::unique_ptr — 独占所有权
  • std::shared_ptr — 共享所有权

然后,针对 auto_ptr 的论述,还有一些已经过时的内容,我们这里做一个对比表格:

书里写的现在的替代
std::auto_ptrC++11 废弃,C++17 删除,用 std::unique_ptr 替代
std::tr1::shared_ptrstd::shared_ptr(C++11 直接进入 std
没有数组智能指针std::unique_ptr<T[]> 可以管理数组(但还是建议用 vector
boost::scoped_array不需要了,std::unique_ptr<T[]> 就行
“用 vectorstring 代替动态数组”仍然正确,这是最好的建议

但核心思想完全没变,只是工具升级了。

auto_ptr 的拷贝问题

书里还提到,直接拷贝 auto_ptr 会将原本的 auto_ptr 置空,这是因为 C++ 希望只有一个指针管理当前的资源,所以拷贝相当于“转移所有权”,需要将原本的内容置空。但这是危险的,因为程序可能意识不到原本的指针已经变成了 null。

这个问题在 auto_ptrunique_ptr 替代之后得到了解决。unique_ptr 根本不允许拷贝,只能移动,直接实现了语言层面的“所有权唯一”规定:

1
2
3
unique_ptr<int> a(new int(1));
unique_ptr<int> b = a;     // 错误!不能拷贝
unique_ptr<int> c = std::move(a);  // OK,显式移动

shared_ptr 介绍

允许多个指针指向同一份资源。拷贝时,不会将原本的指针置空,而是让两个指针都指向那份资源。

用引用计数来追踪有多少个指针指向同一份资源

动态分配对象的函数应该直接返回智能指针

书里最后提到 createInvestment 返回裸指针是泄漏隐患。现在的做法:

1
std::unique_ptr<Investment> createInvestment();  // 不再返回裸指针

总结

Things to Remember:

  • To prevent resource leaks, use RAII objects that acquire resources in their constructors and release them in their destructors.
  • Two commonly useful RAII classes are tr1::shared_ptr and auto_ptr. tr1::shared_ptr is usually the better choice, because its behavior when copied is intuitive. Copying an auto_ptr sets it to null. (这条已经过时,现在使用 shared_ptrunique_ptr

【补充】现在 C++ 社区有一个共识:永远不要出现裸 delete。所有资源都应该被某个对象持有。这条规则比书出版时更严格。

以及,尽量别用智能指针管理数组,除非需要和 C 语言分配的数组交互。

Item14: Think carefully about copying behavior in resour-managing classes

前一个 Item 里面说,需要用对象来管理资源,但是当我们拷贝一个 RAII 对象的时候,编译器的默认行为往往是不对的(哪里不对?),需要我们自己选择对象的拷贝行为。

通常,我们有四种选择。以下面这个 Lock 类为例子。

1
2
3
4
5
6
class Lock {
    Mutex* mutexPtr;
public:
    Lock(Mutex* pm) : mutexPtr(pm) { lock(mutexPtr); }
    ~Lock() { unlock(mutexPtr); }
};

这个 Lock 类是对互斥锁的包装,希望在对象创建时能自动获取锁,而在对象析构时能自动释放锁。

如果有人拷贝 Lock 对象,你希望发生什么?是禁止对应的拷贝,还是增加引用计数,抑或是直接把底层资源也复制一份,也可以直接转移资源的所有权?不论如何,单纯复制指针值的拷贝不应该出现,否则这份资源的管理是混乱的,容易造成资源泄漏或者使用已经释放的资源。

Prohibit copying 禁止拷贝

1
2
Lock(const Lock&) = delete;				// 禁用copying constructor
Lock& operator=(const Lock&) = delete;	// 禁用copying assignment operator

当然,上面的实现需要在 C++11 之后才有,本书(C++03)中不是这样的做法,而是将对应的特殊类型函数声明为 private 且不实现这个函数来防止外部调用(可以参考 item6)。

大多数 RAII 对象(锁、文件句柄、socket)都应该禁止拷贝,因为允许多个对象管理同一个锁或者文件没有意义。

Reference-count the underlying resouce 引用计数 (类似 shared_ptr)

拷贝时,计数+1,最后一个对象析构时才释放资源。

实际上,我们自定义的 RAII 类通常就会使用 std::shared_ptr 对象作为成员,来实现引用计数的功能。(因为自己实现引用计数就是重复造轮子,而且很麻烦)

注意:析构函数(不论是 compiler-generated 还是 user-defined)在执行完函数体之后,都会自动调用每个成员的析构函数,我们不需要也不应该显式调用。所以,如果使用 shared_ptr 来管理资源,那么析构函数其实可以不用自定义,而是使用编译器自动生成的版本。

Copy the underlying resource 深拷贝

拷贝时,连同底层资源一起复制(而不是只复制指针值),每个对象管理自己的独立副本。就像 string 拷贝时复制底层字符数组:

1
2
3
4
5
6
std::string a = "hello";
std::string b = a;  // 深拷贝:b 拥有自己独立的字符数组

b[0] = 'H';         // 只改了 b,a 不受影响
std::cout << a;     // 输出 "hello"
std::cout << b;     // 输出 "Hello"

拷贝后,两个对象各自管理各自的内存,互不影响。

Transfer ownership of the underlying resource 转移资源所有权

某些情况下,我们希望拷贝时将资源的所有权转移到新的对象上,同时废弃原有对象的资源使用权。

在以前,auto_ptr 是这样做的,不过它的资源所有权转移是隐式转移。

auto_ptr 被废弃之后,unique_ptr 接过了这一职责并做了优化:unique_ptr 不允许隐式拷贝,只允许显式移动,例如:

1
2
3
unique_ptr<int> a(new int(1));
unique_ptr<int> b = a;           // 错误!编译禁止
unique_ptr<int> c = std::move(a); // OK,显式转移所有权,a 变 null

这比 auto_ptr 的隐式拷贝更加安全,语义也更加清晰——因为 std::move() 本身就标记着某个资源是可窃取的。

总结

Things to Remember:

  • Copying an RAII object entails copying the resource it manages, so the copying behavior of the resource determines the copying behavior of the RAII object.
  • Common RAII class copying behaviors are disallowing copying and performing reference counting, but other behaviors are possible.

总之,RAII 类不是单纯的类,而是管理资源的类。所以,在拷贝 RAII 类的对象时,最重要的是确定如何拷贝它管理的资源——这取决于程序员的意图。

Item15: Provide access to raw resources in resource-managing classes

尽管我们建议使用 RAII 类来管理资源,而不是直接处理裸指针,但在实际生产中,有许多 API 是直接操作裸指针的。这时候,我们需要找到一种方法,直接返回 RAII 类中的裸指针。

也就是说,需要将 RAII 类(比如智能指针)转成对应的原始资源(比如裸指针)。通常有两种方法:显式转换(explicit conversion)和隐式转换(implicit conversion)。

Explicit Conversion 显式转换

显式转换,就是通过显式调用 RAII 类提供的接口,来获取其管理的底层资源(通常来说就是获取裸指针)。

一般来说,这个接口就是 get() 函数。比如:

1
2
std::shared_ptr<Investment> p_inv(createInvestment());
Investment* raw_p = p_inv.get();	// 通过get函数直接获取裸指针

当然,现在的智能指针已经重载了 operator*()operator->() 等运算符,我们几乎就可以把智能指针当作普通的裸指针来直接使用了。

Implicit Conversion 隐式转换

隐式转换,就是通过在 RAII 类中定义一个特殊的类型转换函数,使得在需要使用底层资源时,将封装的 RAII 类自动转换成底层类型。

举个例子,有一个原始类 FontHandle,我们将其包装成 RAII 类 Font

1
2
3
4
5
6
7
class Font {
public:
	explicit Font(FontHandle fh): f(fh) {}
	~Font() {releaseFont(f);}
private:
	FontHandle f;
};

如果要用 get 来获取显式转换,那么需要定义 get() 函数:

1
2
3
4
5
6
class Font {
public:
	...
	FontHandle get() const {return f;}
	...
};

如果要实现隐式转换,让编译器自己在需要的时候调用转换函数,则需要这样定义:

1
2
3
4
5
6
class Font {
public:
	...
	operator FontHandle() const {return f;}
	...
};

不过,隐式转换实际上打破了 RAII 的约定:我们本想自动化管理资源,但是却用隐式转换的方式,在我们不了解风险的情况下,将原始资源交出去了(相对地,显式转换的 get() 函数则是在已知使用原始资源的风险下调用的,属于风险自甘)。所以,隐式转换很可能导致一些问题,但在一些情况下,为了方便,我们还是会使用隐式转换。

总结

Things to Remember:

  • APIs often require access to raw resources, so each RAII class should offer a way to get at the resource it manages.
  • Access may be via explicit conversion or implicit conversion. In general, explicit conversion is safer, but implicit conversion is more convenient for clients.

总之,优先使用显式转换(因为你也不想 RAII 的保护在不被注意时绕过);隐式转换只在频繁调用老 API 并且明确安全的地方使用。

Item16: Use the same form in corresponding uses of new and delete

今天的这个 item 比较简单,概括来说就下面两句话:

newdelete 必须配对使用,new[]delete[] 必须配对使用。 混用则会导致未定义行为。

我们必须先理解 newdelete 事实上做了什么。

new 的行为:

  • 首先,为对象(在堆上)分配一块内存。
  • 其次,调用一次或者多次构造函数,来初始化对象。

delete 的行为:

  • 首先,调用一次或者多次析构函数,来正确释放对象管理的资源。
  • 其次,释放对象自身(在堆上)的内存。

而如何确定调用构造函数/析构函数的次数呢?这就是靠 new / delete 后面的 [] 了。

  • 如果 new / delete 后面没有括号,那么默认调用一次。
  • 如果 new / delete 后面有括号,那么默认有多个对象需要构造/析构,也就是构造/析构对象组成的数组。new / delete 会先读取数组的大小(通常保存在第一个对象的内存之前),然后按照大小决定调用构造函数/析构函数的次数。

这也是为什么“newdelete 必须配对使用,new[]delete[] 必须配对使用”。

  • 如果 new 搭配了 delete[],那么 delete[] 读取到的数组大小可能是一个无意义的值,导致对象析构混乱。
  • 如果 new[] 搭配了 delete,那么 delete 会认为自己在析构单个对象,从而使得后续对象的资源全部没有被正确释放,导致内存泄漏。

补充:内置类型没有构造函数和析构函数,它们的 newdelete 行为是怎么样的呢?其实和上面说的基本一致,只是略去了调用构造函数/析构函数的步骤,所以通常内置类型混用 deletedelete[] 通常不会出错,但仍然是不推荐的未定义行为。

有时候,这些问题不会立刻导致程序崩溃,可能运行很久才会暴露问题——这就更难排查了。

当我们定义别名时,则特别容易出现忽略实际类型而混用 new[]delete 的问题。例如:

1
2
3
4
using AddressLines = std::string[4];
std::string* pal = new AddressLines;	// 看起来没有[],但实际上还是new了一个数组
// delete pal;							// 未定义行为!
delete[] pal;							// 合法的行为。

注:书里用 typedef 来举例,但它在 C++ 中几乎已经被 using 完全替代,所以这里改成用 using 来举例。

当然,modern c++ 中已经基本不会手动 new / delete,而是几乎全部使用智能指针。但理解底层原理仍然是必要且有益的。

Things to Remember:

  • If you use [] in a new expression, you must use [] in the corresponding delete expression.
  • If you don’t use [] in a new expression, you mustn’t use [] in the corresponding delete expression.

Item17: Store newed objects in smart pointers in standalone statements

我们来看下面这段代码,猜猜有什么风险:(其中 priority() 是用于求优先级的函数。)

1
processWidget(std::shared_ptr<Widget>(new Widget), priority());

这行代码看似无害,但编译器执行顺序可能导致内存泄漏

C++ 没有规定函数参数的求值顺序,编译器实际上有一定的“自由裁量权”。所以,编译器可能按以下步骤执行:

1
2
3
1. new Widget           // 堆上对象创建好了
2. priority()           // 但这步抛异常了!
3. shared_ptr<Widget>()  // 还没来得及包装,对象已经没人管了 → 泄漏

new 出来的对象已经存在,但 shared_ptr 还没来得及接管它,中间 priority() 抛了异常,代码就会立刻跳转到 catch 的部分,shared_ptr 再没有机会去接管这个 new 出来的对象了,因而资源就泄漏了。

解决方法也很简单,就是把 new 和智能指针的构造放在一个独立的语句里:

1
2
std::shared_ptr<Widget> pw(new Widget);  // 一步完成,不会被打断
processWidget(pw, priority());           // 现在安全了

pw 构造完成后,资源就已经被智能指针管理了。之后 priority() 怎么抛异常都无所谓。

这是一个非常隐蔽的 bug——代码看起来完全正常,编译也不报错,运行时大多数情况也不出问题,只有在特定异常路径下才泄漏。这种 bug 最难排查,所以我们需要小心。

Things to Remember:

  • Store newed objects in smart pointers in standalone statements. Failure to do this can lead to subtle resource leaks when exceptions are thrown.