我们不爱裸指针的原因:
- 裸指针的声明没办法告诉我们它指向的是单个对象还是数组。
- 没办法知道用完这个裸指针后要不要销毁它指向的对象。
- 没办法知道怎么销毁这个裸指针,是用operator delete还是什么其它自定义的途径。
- 参照原因1,没办法知道该用delete还是delete[],如果用错了,结果未定义。
- 很难保证调用路径上恰好销毁这个指针一次,可能内存泄露,也可能double free。
- 通常没办法知道裸指针是否是空悬指针,即是否指向已销毁的对象。
智能指针就是来解这些问题的,它们用起来像裸指针,但能避免以上的很多陷阱。C++11中有4种智能指针:std::auto_ptr、std::unique_ptr、std::shared_ptr、std::weak_ptr。其中std::auto_ptr已经过时了,C++11中可以被std::unique_ptr取代了。
Item18: 需要显式所有权的资源管理时,用std::unique_ptr
首先要知道:默认情况下,std::unique_ptr与裸指针一样大,且对于绝大多数操作来说(包括解引用),它们编译后的指令都是完全一样的(参见为什么unique_ptr的Deleter是模板类型参数,而shared_ptr的Deleter不是),所有裸指针的空间和性能开销能满足要求的场景,std::unique_ptr一样能满足。
std::unique_ptr体现了显式所有权的语义:非空的std::unique_ptr总是拥有它指向的对象;移动一个std::unique_ptr会将源指针持有的所有权移交给目标指针;不允许复制std::unique_ptr;非空的std::unique_ptr总是销毁它持有的资源,默认是通过delete。
一个例子是工厂函数。假设有一个基类和三个派生类,通过一个工厂函数来返回某个派生类的std::unique_ptr,这样调用方就不需要费心什么时候销毁返回的对象了:std::unique_ptr会负责这件事。
| 1 | class Investment {...}; | 
注意这里实际上有个所有权的转移:工厂函数通过std::unique_ptr将Investment对象的所有权转移给了调用者。
在构造std::unique_ptr时,我们还可以传入一个自定义的销毁器,它会在std::unique_ptr析构时被调用,来销毁对应的资源。比如我们可能不想只是delete obj,还想输出一条日志:
| 1 | auto delInvmt = [](Investment* pInvestment) { | 
从调用者的角度,你可以放心的处理std::unique_ptr,你可以相信在调用过程中资源只会销毁一次,且按恰当的方式销毁。理解以下几点能帮助你理解这种实现有多漂亮:
- delInvmt是自定义的销毁器,在- std::unique_ptr析构时,自定义的销毁器会来完成释放资源必需的操作。这里用lambda表达式来实现- delInvmt,不仅更方便,性能还更好。
- 自定义的销毁器的类型必须与std::unique_ptr的第二个模板参数相同,因此我们要用decltype(delInvmt)来声明std::unique_ptr。
- makeInvestment的基本策略是创建一个空的- std::unique_ptr,再令它指向合适的类型,再返回。其中我们把- delInvmt作为第二个构造参数传给- std::unique_ptr,从而将销毁器与- pInv关联起来。
- 无法将裸指针隐式转换为std::unique_ptr,需要用reset来修改std::unique_ptr持有的裸指针。
- 我们在创建具体的对象时,使用了std::forward将makeInvestment的所有参数完美转发给对应的构造函数。
- 注意delInvmt的参数是Investment*,而它的实际类型可能是派生类,因此需要基类Investment有一个虚的析构函数:
| 1 | class Investment { | 
C++14中我们可以做两件事来让makeInvestment更简单,封装更好:
- 返回类型可以为auto(参见Item3)。
- delInvmt的定义可以放到- makeInvestment函数体中。
前文我们说过在不提供自定义的销毁器时,std::unique_ptr的大小与裸指针相同。但在有了自定义的销毁器后,这个假设不成立了。销毁器的大小取决于它内部保存了多少状态。对于无状态的函数对象(例如捕获列表为空的lambda表达式),销毁器实际不占用任何空间,这就意味着当你需要一个无状态的销毁器时,在lambda表达式和函数间做选择,lambda表达式更好:
| 1 | auto delInvmt1 = [](Investment* pInvestment) { | 
std::unique_ptr另一个广泛应用的场景是pImpl模式。
std::unique_ptr的两种形式分别是std::unique_ptr<T>和std::unique_ptr<T[]>,其中前者没有定义operator[],后者在默认析构时会调用delete[],且没有定义operator*和operator->。但在用到std::unique_ptr<T[]>的地方,你可能需要想一下是不是std::vector、std::array、std::string更合适。唯一一个用std::unique_ptr<T[]>更好的场合就是当你需要与C API交互时。
std::unique_ptr另一个吸引人的地方在于,它可以作为std::shared_ptr的构造参数,因此上面的工厂函数返回std::unique_ptr就再正确不过了:调用者可以根据自己对所有权的需求来决定用std::unique_ptr还是std::shared_ptr,反正都支持。
Item19: 需要共享所有权的资源管理,用std::shared_ptr
垃圾回收的好处:不用手动管理资源的生命期。坏处:资源回收的时间无法确定。
手动管理资源的好处:确定的资源回收时间,不只可以回收内存,还能回收任何其它资源。坏处:复杂,容易写出bug。
C++11中结合以上两者的方式是使用std::shared_ptr。使用std::shared_ptr管理的对象的所有权是共享的,没有哪个std::shared_ptr特别拥有这个对象,而是最后一个std::shared_ptr析构时,销毁这个对象。与垃圾回收类似,调用者不需要手动管理std::shared_ptr管理的对象;与析构函数类似,对象的析构时间是确定的。
std::shared_ptr内部有引用计数,被复制时,引用计数+1,有std::shared_ptr析构时,引用计数-1,当引用计数为0时,析构持有的对象。
引用计数的存在有以下性能影响:
- std::shared_ptr的大小是裸指针的两倍:一个指针指向持有的对象,一个指针指向引用计数。
- 引用计数使用的内存必须动态分配,原因是std::shared_ptr的引用计数是非侵入式的,必须要独立在对象外面。用std::make_shared能避免这次单独的内存分配。
- 引用计数的加减必须是原子的,因此你必须假设读写引用计数是有成本的。
注意,不是所有std::shared_ptr的构造都会增加引用计数,移动构造就不会。因此移动构造一个std::shared_ptr要比复制一个更快。
与std::unique_ptr类似,std::shared_ptr的默认销毁动作也是delete,且也可以接受自定义的销毁器。但与std::unique_ptr不同的是,std::shared_ptr的销毁器类型不必作为它的模板参数之一:
| 1 | auto loggingDel = [](Widget* pw) { | 
因此std::shared_ptr要比std::unique_ptr使用更灵活,比如不同销毁器的std::shared_ptr可以放到同一个容器中,而std::unique_ptr则不可以。
另外,不同的销毁器不会改变std::shared_ptr的大小。std::shared_ptr内部需要为引用计数单独开辟一块内存,那么这块内存中再放一个销毁器也没什么额外开销。实际上这块内存被称为”控制块”,它里面包含以下元素:
- 引用计数
- 弱引用计数
- 其它数据,包括:- 自定义销毁器
- 内存分配器
- 等等
 
控制块的创建规则为:
- std::make_shared总会创建一个控制块。
- 通过一个独享所有权的指针(如std::unique_ptr或std::auto_ptr)创建出的std::shared_ptr总会创建一个控制块。
- 通过裸指针创建的std::shared_ptr会创建控制块。
一个推论就是:通过一个裸指针创建两个std::shared_ptr,会创建两个控制块,进而导致这个裸指针会被析构两次!
从中我们可以得到两个教训:
- 不要直接用裸指针构造std::shared_ptr,尽量用std::make_shared。当然在需要自定义的销毁器时不能用std::make_shared。
- 非要用裸指针构造std::shared_ptr的话,尽量直接new,不要传入已有的裸指针变量。
有一种场景下,我们可能无意间创建了对应同一指针的两个控制块。
| 1 | std::vector<std::shared_ptr<Widget>> processedWidgets; | 
processedWidgets表示所有处理过的Widget。进一步假设Widget有一个成员函数process:
| 1 | class Widget { | 
如果被调用process的Widget对象本身就被std::shared_ptr所管理,上面那行代码会导致它又创建了一个新的控制块。这种情况下我们应该令Widget继承自std::enable_shared_from_this,它允许创建一个指向自身控制块的std::shared_ptr:
| 1 | class Widget: public std::enable_shared_from_this<Widget> { | 
这种基类是用派生类特化的模板的模式,称为“奇异递归模板模式”(The Curiously Recurring Template Pattern, CRTP)。
在调用shared_from_this,它会寻找指向自身的控制块。如果此时这个对象没有被任何一个std::shared_ptr持有,也就没有控制块,那么shared_from_this的行为是未定义的。因此往往继承自std::enable_shared_from_this的类都会把构造函数设为private,再提供一个静态方法来得到该类型的std::shared_ptr:
| 1 | class Widget: public std::enable_shared_from_this<Widget> { | 
控制块会带来哪些开销呢?一个控制块通常只有几个word大,但其中会用到继承,甚至还有虚函数。这也意味着使用std::shared_ptr也会有调用虚函数的开销。
但通常来说std::shared_ptr的额外开销是很小的。对于std::make_shared创建的std::shared_ptr,它的控制块只有3个word大,且内存分配上无额外成本。解引用一个std::shared_ptr也不会比解引用一个裸指针开销大。操作引用计数会带来一两次原子操作的开销,但通常也不大。
但你用这些开销换来的是自动的资源管理。std::shared_ptr能满足大多数的共享所有权的资源管理需求。如果你还犹豫的话就想想共享所有权和额外开销,哪个对你而言更重要。
std::shared_ptr的一个缺点是它不支持数组,但在C++11已经提供了std::array、std::vector、std::string这些容器类的前提下,还要用std::shared_ptr去管理一个数组,本身就是不好设计的信号。
Item20: 在需要共享语义且可能空悬的地方用std::weak_ptr
有时候我们需要一种类似std::shared_ptr,但又不参与这个共享对象的所有权的智能指针。这样它就需要能知道共享对象是否已经销毁了。这就是std::weak_ptr。std::weak_ptr不是单独存在的,它不能解引用,也不能检测是否为空,它就是配合std::shared_ptr使用的。
通常std::weak_ptr都是通过std::shared_ptr构造的,但它不会影响std::shared_ptr的引用计数:
| 1 | auto spw = std::make_shared<Widget>(); // ref count is 1 | 
可以用expired()来检测std::weak_ptr指向的对象是否有效:
| 1 | if (wpw.expired()) ... | 
另一个常用的操作是lock(),它能原子地检测对象是否有效,以及返回这个对象的std::shared_ptr:
| 1 | std::shared_ptr<Widget> spw = wpw.lock(); // if wpw's expired, spw is null | 
与之类似的操作是用std::weak_ptr构造一个std::shared_ptr:
| 1 | std::shared_ptr<Widget> spw(wpw); // | 
区别在于,如果wpw已经失效了,这次构造会抛std::bad_weak_ptr的异常。
下面我们用一个例子来说明std::weak_ptr的必要性。
想象我们要实现一个cache,希望其中的元素在无人使用后被销毁。这里我们用std::unique_ptr并不合适,因为cache天然需要共享的语义。这样每个调用者都可以获得一个cache中元素的std::shared_ptr,它的生命期由调用者控制。cache内还需要保存一份元素的指针,且有能力检测它是不是失效了。这里我们需要的就是std::weak_ptr:
| 1 | std::shared_ptr<const Widget> fastLoadWidget(WidgetID id) { | 
请不用在意上面的static,这只是个示意。
第二个例子是设计模式中的“观察者模式”。它的一种典型实现是每个主题对象持有一组观察者的指针,每当主题对象有状态变化时依次通知每个观察者。这里主题对象不需要控制观察者的生命期,但需要知道观察者的指针是否还有效。用std::weak_ptr就可以非常自然的实现出这样的特性。
第三个例子是,当A和C都持有B的std::shared_ptr时,如果B也需要持有A的某种指针,该持有什么?
- 裸指针:如果A析构了,但C还在,B也就还在,此时B持有的A的裸指针就成了空悬指针,不好。
- std::shared_ptr:这样A与B就形成了循环依赖,永远不可能析构了。
- std::weak_ptr:唯一的好选择。
但要注意的是,用std::weak_ptr来解std::shared_ptr可能造成的循环依赖,这种特性本身并没有价值。设计良好的数据结构,比如树,父节点控制子节点的生命期,但子节点也需要持有父节点的指针,这里最好的方案是父节点用std::unique_ptr来持有子节点,而子节点直接持有父节点的裸指针。即,严格层次结构,明确生命期的场景,不需要使用std::weak_ptr。std::weak_ptr的价值在于:在生命期不明确的场景,可以知道对象是否还有效。
在效率方面,std::weak_ptr的大小与std::shared_ptr是相同的,它们使用相同的控制块,区别在于std::weak_ptr不会影响控制块中的引用计数,只会影响其中的弱引用计数。
Item21: 优先用std::make_unique和std::make_shared而不是直接new
先做一下介绍,std::make_shared是在C++11中增加的,但std::make_unique却是在C++14中增加的。如果你想在C++11中就用上std::make_unique,自己写一个简单版的也不难:
| 1 | template <typename T, typename... Ts> | 
这个版本不支持数组,不支持自定义的销毁器,但这些都不重要,它足够用了。但要记住的是,不要把它放到namespace std下面。
这两个make函数的功能就不解释了,和它们类似的还有一个std::allocate_shared。
| 1 | auto upw1(std::make_unique<Widget>()); | 
上面这个例子说明了用make函数的第一个好处:不需要重复写一遍类型。所有程序员都知道:不要重复代码。代码越少,bug越少。
第二个好处:异常安全性。想象我们有两个函数:
| 1 | void processWidget(std::shared_ptr<Widget> spw, int priority); | 
调用代码很可能长成这个样子:
| 1 | processWidget(std::shared_ptr<Widget>(new Widget), computePriority()); // potential resource leak! | 
上面这行代码有内存泄漏的风险,为什么?根据C++标准,在processWidget的参数求值过程中,我们只能确定下面几点:
- new Widget一定会执行,即一定会有一个- Widget对象在堆上被创建。
- std::shared_ptr<Widget>的构造函数一定会执行。
- computePriority一定会执行。
new Widget的结果是std::shared_ptr<Widget>构造函数的参数,因此前者一定早于后者执行。除此之外,编译器不保证其它操作的顺序,即有可能执行顺序为:
- new Widget
- 执行computePriority
- 构造std::shared_ptr<Widget>
如果第2步抛异常,第1步创建的对象还没有被std::shared_ptr<Widget>管理,就会发生内存泄漏。
如果这里我们用std::make_shared,就能保证new Widget和std::shared_ptr<Widget>是一起完成的,中间不会有其它操作插进来,即不会有不受智能指针保护的裸指针出现:
| 1 | processWidget(std::make_shared<Widget>(), computePriority()); // no potential resource leak | 
第三个好处:更高效。
| 1 | std:shared_ptr<Widget> spw(new Widget); | 
这行代码中,我们以为只有一次内存分配,实际发生了两次,第二次是在分配std::shared_ptr控制块。如果用std::make_shared,它会把Widget对象和控制块合并为一次内存分配。
但是make函数也有一些缺点。
第一个缺点:无法传入自定义的销毁器。
第二个缺点:make函数初始化时使用了括号初始化,而不是花括号初始化,比如std::make_unique<std::vector<int>>(10, 20)创建了一个有着20个值为10的元素的vector,而不是创建了{10, 20}这么两个元素的vector(参见Item7)。
第三个缺点:对象和控制块分配在一块内存上,减少了内存分配的次数,但也导致对象和控制块占用的内存也要一次回收掉。即,如果还有std::weak_ptr存在,控制块就要在,对象占用的内存也没办法回收。如果对象比较大,且std::weak_ptr在对象析构后还可能长期存在,那么这种开销是不可忽视的。
如果我们因为前面这三个缺点而不能使用std::make_shared,那么我们要保证,智能指针的构造一定要单独一个语句。回到之前processWidget的例子中,假设我们有个自定义的销毁器void cusDel(Widget* ptr);,因此不能使用std::make_shared,那么我们要这么写来保证异常安全性:
| 1 | std::shared_ptr<Widget> spw(new Widget, cusDel); | 
但这么写还不够高效,这里我们明确知道spw就是给processWidget用的,那么可以使用std::move,将其转为右值,来避免对引用计数的修改:
| 1 | std::shared_ptr<Widget> spw(new Widget, cusDel); | 
Item22: 在用到Pimpl惯用法时,在实现文件中定义特殊成员函数
我们经常用名为Pimpl的方法来实现接口与实现分离,进而大大降低程序构建的时间。Pimpl是指把类A中的所有数据成员都移到一个impl类中,A中只留下一个impl类型的指针。一个例子:
| 1 | class Widget { | 
Widget的数据成员的类型为std::string、std::vector<double>、Gadget,这样就至少要include三个头文件,这也意味着每个需要include了这个包含Widget定义的头文件的地方,都被动引入了三个头文件。如果有一天我们修改了Widget的实现,比如增加或删除了一个成员,即使它们都是private的,即使接口完全没有变化,所有include它的用户文件都要重新编译。我们不想污染用户文件,也不想用户文件因为我们的实现修改而重新编译,我们就可以用Pimpl:
| 1 | class Widget { | 
注意这里出现的Impl类型只是声明,没有定义,称为“不完整类型”,这样的类型只支持很少的操作,其中包括了我们需要的:声明一个不完整类型的指针。
对应的实现文件内容为:
| 1 | struct Widget::Impl { | 
有了智能指针后,我们觉得直接new和delete不好,需要用std::unique_ptr:
| 1 | class Widget { | 
因为不需要手动的delete,我们没有自己实现Widget的析构函数。
看起来都很美好,编译也没问题,但在用户要用时,出事了:
| 1 | Widget w; // error! | 
编译时出错(参见delete不完整类型的指针):
究其原因,是因为我们没有给Widget实现自定义的析构函数,因此编译器为Widget准备了一个。这个析构函数会被放到Widget的定义体内,默认是内联的,因此会有一份实现在用户文件中。~Widget中只做一件事:析构pImpl,即析构一个std::unique_ptr<Impl>。注意,我们隐藏了Impl的实现,在析构std::unique_ptr<Impl>时编译器发现Impl还是个不完整类型,此时对它调用delete是危险的,因此编译器用static_cast禁止了这种行为。
解决方案很简单:自己实现Widget的析构函数:
| 1 | // widget.h | 
参考Item17,更好的方法是将析构函数定义为= default:
| 1 | Widget::~Widget() = default; | 
根据Item17,自定义的析构函数会阻止编译器生成移动构造函数和移动赋值函数,因此如果你想要Widget有移动的能力,就要自己实现:
| 1 | class Widget { | 
注意不要在这些特殊成员函数的声明后面加= default,这样会重复上面析构函数的问题:会被内联,因此在用户代码中有一份实现,遇到不完整类型,game over。我们要做的就是在.cpp中将它们的实现定义为= default。
接下来就是复制构造函数和复制赋值函数了。我们用std::unique_ptr是为了更好的实现Pimpl方法,这也导致了Widget无法自动生成复制函数(std::unique_ptr不支持),但这并不意味着Widget就不能支持复制了,我们还可以自己定义两个复制函数:
| 1 | // widget.h | 
有意思的是,如果你把pImpl的类型改为std::shared_ptr<Impl>,你会发现上面所有这些注意事项,都不见了。你不需要手动实现析构函数、移动函数、构造函数,程序编译仍然是好的。
这种差异来自于std::unique_ptr和std::shared_ptr对自定义销毁器的支持方式不同(参见为什么unique_ptr的Deleter是模板类型参数,而shared_ptr的Deleter不是)。std::unique_ptr的目标是从体积到性能上尽可能与裸指针相同,因此它将销毁器类型作为模板参数的一部分,这样实现起来更高效,代价是各种特殊函数在编译时就要知道元素的完整类型。而std::shared_ptr没有这种性能上的要求,因此它的销毁器不是模板参数的一部分,性能会有一点点影响,但好处是不需要在编译特殊函数时知道元素的完整类型。
std::shared_ptr在构造时就把销毁器保存在了控制块中,之后即使传递到了不知道元素完整类型的地方,它仍然能调用正确的销毁器来销毁元素指针。而std::unique_ptr是依靠模板参数提供的类型信息来进行销毁,因此必须要知道元素的完整类型。
目录
- Chapter1 类型推断 (Item 1-4)
- Chapter2 auto (Item 5-6)
- Chapter3 现代C++(Item 7-10)
- Chapter3 现代C++(Item 11-14)
- Chapter3 现代C++(Item 15-17)
- Chapter4 智能指针 (Item 18-22)
- Chapter5 右值引用、移动语义、完美转发(Item 23-26)
- Chapter5 右值引用、移动语义、完美转发(Item 27-30)
- Chapter6: Lamba表达式 (Item 31-34)
- Chapter7: 并发API (Item 35-37)
- Chapter7: 并发API (Item 38-40)
- Chapter8: 杂项 (Item 41-42)