本文包含《Effective Modern C++》中第4章内容的个人笔记,讨论现代C++中智能指针使用问题。
第4章 智能指针
c++11标准增加了新版的智能指针unique_ptr、shared_ptr、weak_ptr,同时废弃了旧标准中的auto_ptr,本章着重介绍的就是这些新增智能指针需要注意的地方。
条款18: 使用unique_ptr管理具备专属所有权的资源
unique_ptr是高效、小巧、只移型别的智能指针,描述专属所有权语义。
在使用默认析构器时,可以认为std::unique_ptr和裸指针尺寸相同。在使用自定义析构器时,如果析构器是函数指针,unique_ptr的尺寸一般会增加一到两个字长,如果析构器是函数对象,则带来的尺寸变化取决于函数对象中存储了多少状态,无状态的函数对象不会浪费任何存储尺寸。
由于unique_ptr可以方便高效的转换成share_ptr,使得其十分适合作为工厂函数的返回值。
存在两种形式的unique_ptr,即unique_ptr<T>和unique_ptr<T[]>,第二种形式用于管理C风格数组。
条款19: 使用std::shared_ptr管理具备共享所有权的资源
shared_ptr的尺寸是裸指针的两倍,其包含一个指涉到实际资源的裸指针,和一个指涉到堆上控制块的裸指针,该控制块包含引用计数、弱计数、自定义删除器和分配器等数据。
一个控制块由创建首个指涉到该对象的std::share_ptr的函数来确定,控制块的创建遵循以下规则:
- std::make_shared总是创建一个控制块
- 从具备专属所有权的指针(unique_ptr或auto_ptr)出发构造一个std::shared_ptr时,会创建一个指针
- 当std::shared_ptr构造函数使用裸指针作为实参来调用时,它会创建一个控制块
所以从单一裸指针出发构造两个shared_ptr的情况会构造两个不同控制块的shared_ptr,从而在析构时出现double free的问题。
此外还需要注意的是成员函数中需要使用shared_ptr的场景,从this出发构造shared_ptr会构造一个新控制块的shared_ptr,这显然与外部使用的shared_ptr会造成上面所说的double free问题,这里如果想要正常获取指向当前对象的shared_ptr需要用到enable_shared_from_this:
1 | std::vector<std::shared_ptr<Widget>> proessedWidgets; |
当然如果没有指涉到当前对象的shared_ptr,shared_from_this的行为未定义,通常结果是抛出异常(书中说法)。
shared_ptr从空间上只增加了一个控制块,性能上只比裸指针增加了引用计数的原子化操作,得到的却是动态分配资源的自动生存期管理。
与unique_ptr不同的是,不存在shared_ptr<T[]>用于兼容c风格数组。
条款20: 对于类似std::shared_ptr但有可能空悬的指针使用std::weak_ptr
在shared_ptr之上构造的weak_ptr不会引起引用计数的增长,weak_ptr修改的是控制块中的弱引用计数。
如需确认weak_ptr是否失效了通过expired方法,如需确认weak_ptr是否失效的同时访问指涉对象可通过lock或者从weak_ptr构造shared_ptr的方法来获得shared_ptr,两种方法的区别是如果已经空悬后者会抛异常而lock不会。
weak_ptr常用于缓存、观察者列表等指针可空悬的场景,也可用于避免shared_ptr指针环路。
条款21: 优先选用std::make_unique和std::make_shared,而非直接使用new
11标准中加入了make_shared和allocate_shared,14标准中加入了make_unique,使用make系列函数通常可以减少代码量、提高异常安全性;对于shared_ptr,由于make_shared和allocate_shared在实现上做了优化(使用一块内存分配对象和控制块)使得生成的目标代码更小且速度更快。但同时也由于这两个函数做了如此优化,使得该块内存直到最后一个使用该控制块的weak_ptr析构后才能被释放。这就造成如果weak_ptr和shared_ptr析构间隔较久,内存将延迟这个间隔时间才会被释放。而改为使用shared_ptr构造则不会出现该问题,所以该场景下不适宜使用make系列函数。
make系列函数不能指定删除器,所以需要制定删除器的场景下只能调用智能指针构造来完成;make系列函数内部使用圆括号来传递对象构造实参,所以直接传递大括号初始化不会被解释为initializer_list。
自定义operator new、operator delete的类型不建议使用make系列函数,由于如上所说的make系列函数在实现上做了优化使用同一块内存。
条款22: 使用Pimpl习惯用法时,将特殊成员函数的定义放到实现文件中
pimpl作为常用的减少模块间依赖的方法,在换成使用智能指针的情况时会存在一个小问题,即形如以下的代码在编译时将会报错:
1 | class Widget { |
原因其实也简单,编译器编译以上代码时会在头文件处生成一个inline析构函数,这个析构函数会调用pImpl这个unique_ptr的析构,unique_ptr的析构会对所包含的型别做一次static_assert检查,如果是incomplete型别则会出错。也就是说以上代码编译出错的原因和以下代码类似:
1 | class Widget { |
该代码在编译时同样会报对不完全类型使用delete非法问题。这是由于c++规定,对于void*或者不完全型别(前置声明的型别)使用delete将会导致未定义行为。
所以,解决如上问题的方式即为在Widget::Impl的定义之后声明Widget的析构函数,而根据新标准中的“大五律”,如果自行声明了析构函数,最好是声明全部五个特殊成员函数,即复制构造、复制操作符、移动构造、移动操作符,即使编译器自行生成的特殊函数拥有正确的行为,也依然需要如此声明:
1 | // Widget.h |
1 | // Widget.cpp |