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 / delete、fopen / fclose、lock / unlock),很容易出错:
| |
当然,你可以说:啊我 coding 的时候很仔细的,不会犯这样的低级错误。
但是,随着工程越来越大,代码越来越长,不规范的写法维护的成本也会越来越高。所以,为了确保资源一定能够安全释放,我们还是使用 RAII 的做法。
RAII 的做法
用一个对象来持有资源,对象的析构函数负责释放资源。
auto_ptr 就是这样一个对象。它是一个智能指针,管理着一个裸指针;在自身析构时,会自动对自己管理的裸指针调用 delete。
再次回顾:<code>delete</code> 实际做了两件事情
- 第一件,调用对象的析构函数,清理对象管理的资源(如对象管理的指针指向的资源、文件、锁等等)。(调用析构函数清理资源)
- 第二件,释放对象本身占用的堆上内存。(释放内存)
| |
不管控制流怎么走,析构函数一定会被调用——这就是对象管理的优势。
过时内容说明
当然,书里用来举例子的 auto_ptr 事实上已经过时了,它在 C++11 中已经被废弃,C++17 中已删除。现在我们应该用:
std::unique_ptr— 独占所有权std::shared_ptr— 共享所有权
然后,针对 auto_ptr 的论述,还有一些已经过时的内容,我们这里做一个对比表格:
| 书里写的 | 现在的替代 |
|---|---|
std::auto_ptr | C++11 废弃,C++17 删除,用 std::unique_ptr 替代 |
std::tr1::shared_ptr | std::shared_ptr(C++11 直接进入 std) |
| 没有数组智能指针 | std::unique_ptr<T[]> 可以管理数组(但还是建议用 vector) |
用 boost::scoped_array | 不需要了,std::unique_ptr<T[]> 就行 |
“用 vector 和 string 代替动态数组” | 仍然正确,这是最好的建议 |
但核心思想完全没变,只是工具升级了。
auto_ptr 的拷贝问题
书里还提到,直接拷贝 auto_ptr 会将原本的 auto_ptr 置空,这是因为 C++ 希望只有一个指针管理当前的资源,所以拷贝相当于“转移所有权”,需要将原本的内容置空。但这是危险的,因为程序可能意识不到原本的指针已经变成了 null。
这个问题在 auto_ptr 被 unique_ptr 替代之后得到了解决。unique_ptr 根本不允许拷贝,只能移动,直接实现了语言层面的“所有权唯一”规定:
| |
shared_ptr 介绍
允许多个指针指向同一份资源。拷贝时,不会将原本的指针置空,而是让两个指针都指向那份资源。
用引用计数来追踪有多少个指针指向同一份资源
动态分配对象的函数应该直接返回智能指针
书里最后提到 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_ptrandauto_ptr.tr1::shared_ptris usually the better choice, because its behavior when copied is intuitive. Copying anauto_ptrsets it to null. (这条已经过时,现在使用shared_ptr和unique_ptr)
【补充】现在 C++ 社区有一个共识:永远不要出现裸 delete。所有资源都应该被某个对象持有。这条规则比书出版时更严格。
以及,尽量别用智能指针管理数组,除非需要和 C 语言分配的数组交互。
Item14: Think carefully about copying behavior in resour-managing classes
前一个 Item 里面说,需要用对象来管理资源,但是当我们拷贝一个 RAII 对象的时候,编译器的默认行为往往是不对的(哪里不对?),需要我们自己选择对象的拷贝行为。
通常,我们有四种选择。以下面这个 Lock 类为例子。
| |
这个 Lock 类是对互斥锁的包装,希望在对象创建时能自动获取锁,而在对象析构时能自动释放锁。
如果有人拷贝 Lock 对象,你希望发生什么?是禁止对应的拷贝,还是增加引用计数,抑或是直接把底层资源也复制一份,也可以直接转移资源的所有权?不论如何,单纯复制指针值的拷贝不应该出现,否则这份资源的管理是混乱的,容易造成资源泄漏或者使用已经释放的资源。
Prohibit copying 禁止拷贝
| |
当然,上面的实现需要在 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 拷贝时复制底层字符数组:
| |
拷贝后,两个对象各自管理各自的内存,互不影响。
Transfer ownership of the underlying resource 转移资源所有权
某些情况下,我们希望拷贝时将资源的所有权转移到新的对象上,同时废弃原有对象的资源使用权。
在以前,auto_ptr 是这样做的,不过它的资源所有权转移是隐式转移。
在 auto_ptr 被废弃之后,unique_ptr 接过了这一职责并做了优化:unique_ptr 不允许隐式拷贝,只允许显式移动,例如:
| |
这比 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() 函数。比如:
| |
当然,现在的智能指针已经重载了 operator*() 和 operator->() 等运算符,我们几乎就可以把智能指针当作普通的裸指针来直接使用了。
Implicit Conversion 隐式转换
隐式转换,就是通过在 RAII 类中定义一个特殊的类型转换函数,使得在需要使用底层资源时,将封装的 RAII 类自动转换成底层类型。
举个例子,有一个原始类 FontHandle,我们将其包装成 RAII 类 Font:
| |
如果要用 get 来获取显式转换,那么需要定义 get() 函数:
| |
如果要实现隐式转换,让编译器自己在需要的时候调用转换函数,则需要这样定义:
| |
不过,隐式转换实际上打破了 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 比较简单,概括来说就下面两句话:
new和delete必须配对使用,new[]和delete[]必须配对使用。 混用则会导致未定义行为。
我们必须先理解 new 和 delete 事实上做了什么。
new 的行为:
- 首先,为对象(在堆上)分配一块内存。
- 其次,调用一次或者多次构造函数,来初始化对象。
delete 的行为:
- 首先,调用一次或者多次析构函数,来正确释放对象管理的资源。
- 其次,释放对象自身(在堆上)的内存。
而如何确定调用构造函数/析构函数的次数呢?这就是靠 new / delete 后面的 [] 了。
- 如果
new/delete后面没有括号,那么默认调用一次。 - 如果
new/delete后面有括号,那么默认有多个对象需要构造/析构,也就是构造/析构对象组成的数组。new/delete会先读取数组的大小(通常保存在第一个对象的内存之前),然后按照大小决定调用构造函数/析构函数的次数。
这也是为什么“new 和 delete 必须配对使用,new[] 和 delete[] 必须配对使用”。
- 如果
new搭配了delete[],那么delete[]读取到的数组大小可能是一个无意义的值,导致对象析构混乱。 - 如果
new[]搭配了delete,那么delete会认为自己在析构单个对象,从而使得后续对象的资源全部没有被正确释放,导致内存泄漏。
补充:内置类型没有构造函数和析构函数,它们的 new 和 delete 行为是怎么样的呢?其实和上面说的基本一致,只是略去了调用构造函数/析构函数的步骤,所以通常内置类型混用 delete 和 delete[] 通常不会出错,但仍然是不推荐的未定义行为。
有时候,这些问题不会立刻导致程序崩溃,可能运行很久才会暴露问题——这就更难排查了。
当我们定义别名时,则特别容易出现忽略实际类型而混用 new[] 和 delete 的问题。例如:
| |
注:书里用 typedef 来举例,但它在 C++ 中几乎已经被 using 完全替代,所以这里改成用 using 来举例。
当然,modern c++ 中已经基本不会手动 new / delete,而是几乎全部使用智能指针。但理解底层原理仍然是必要且有益的。
Things to Remember:
- If you use
[]in anewexpression, you must use[]in the correspondingdeleteexpression. - If you don’t use
[]in anewexpression, you mustn’t use[]in the correspondingdeleteexpression.
Item17: Store newed objects in smart pointers in standalone statements
我们来看下面这段代码,猜猜有什么风险:(其中 priority() 是用于求优先级的函数。)
| |
这行代码看似无害,但编译器执行顺序可能导致内存泄漏。
C++ 没有规定函数参数的求值顺序,编译器实际上有一定的“自由裁量权”。所以,编译器可能按以下步骤执行:
| |
new 出来的对象已经存在,但 shared_ptr 还没来得及接管它,中间 priority() 抛了异常,代码就会立刻跳转到 catch 的部分,shared_ptr 再没有机会去接管这个 new 出来的对象了,因而资源就泄漏了。
解决方法也很简单,就是把 new 和智能指针的构造放在一个独立的语句里:
| |
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.