【Effective Modern C++】第4章 智能指针
文章目录
原始指针的缺点:
- 它的声明不能指示所指到底是单个对象还是数组。
- 它的声明没有告诉你用完后是否应该销毁它,即指针是否拥有所指之物。
- 如果你决定你应该销毁指针所指对象,没人告诉你该用
delete
还是其他析构机制(比如将指针传给专门的销毁函数)。 - 如果你发现该用
delete
。 原因1说了可能不知道该用单个对象形式(“delete
”)还是数组形式(“delete[]
”)。如果用错了结果是未定义的。 - 假设你确定了指针所指,知道销毁机制,也很难确定你在所有执行路径上都执行了恰为一次销毁操作(包括异常产生后的路径)。少一条路径就会产生资源泄漏,销毁多次还会导致未定义行为。
- 一般来说没有办法告诉你指针是否变成了悬空指针(dangling pointers),即内存中不再存在指针所指之物。在对象销毁后指针仍指向它们就会产生悬空指针。
原始指针是强大的工具,当然,另一方面几十年的经验证明,只要注意力稍有疏忽,这个强大的工具就会攻击它的主人。
智能指针(smart pointers)是解决这些问题的一种办法。智能指针包裹原始指针,它们的行为看起来像被包裹的原始指针,但避免了原始指针的很多陷阱。你应该更倾向于智能指针而不是原始指针。几乎原始指针能做的所有事情智能指针都能做,而且出错的机会更少。
在C++11中存在四种智能指针:std::auto_ptr
,std::unique_ptr
,std::shared_ptr
, std::weak_ptr
。都是被设计用来帮助管理动态对象的生命周期,在适当的时间通过适当的方式来销毁对象,以避免出现资源泄露或者异常行为。
std::auto_ptr
是来自C++98的已废弃遗留物,它是一次标准化的尝试,后来变成了C++11的std::unique_ptr
,所以现在应该弃用std::auto_ptr。要正确的模拟原生指针需要移动语义,但是C++98没有这个东西。取而代之,std::auto_ptr
拉拢拷贝操作来达到自己的移动意图。这导致了令人奇怪的代码(拷贝一个std::auto_ptr
会将它本身设置为null!)和令人沮丧的使用限制(比如不能将std::auto_ptr
放入容器)。
条款18:使用std::unique_ptr
管理具备专属所有权的资源
- 当你需要一个智能指针时,
std::unique_ptr
通常是最合适的。可以认为默认情况下,std::unique_ptr
大小等同于原始指针,而且对于大多数操作(包括取消引用),他们执行的指令完全相同。这意味着你甚至可以在内存和时间都比较紧张的情况下使用它。如果原始指针够小够快,那么std::unique_ptr
一样可以。
std::unique_ptr
体现了专有所有权(exclusive ownership)语义。一个non-null std::unique_ptr
始终拥有其指向的内容。移动一个std::unique_ptr
将所有权从源指针转移到目的指针。(源指针被设为null。)拷贝一个std::unique_ptr
是不允许的,std::unique_ptr
是一种只可移动类型(move-only type)。当析构时,一个non-null std::unique_ptr
销毁它指向的资源。默认情况下,资源析构通过对std::unique_ptr
里原始指针调用delete
来实现。
std::unique_ptr
的常见用法是作为继承层次结构中对象的工厂函数返回类型。假设我们有一个投资类型(比如股票、债券、房地产等)的继承结构,使用基类Investment
。
class Investment { … };
class Stock: public Investment { … };
class Bond: public Investment { … };
class RealEstate: public Investment { … };
这种继承关系的工厂函数在堆上分配一个对象然后返回指针,调用方在不需要的时候有责任销毁对象。这使用场景完美匹配std::unique_ptr
,因为调用者对工厂返回的资源负责(即对该资源的专有所有权),并且std::unique_ptr
在自己被销毁时会自动销毁指向的内容。Investment
继承关系的工厂函数可以这样声明:
template<typename... Ts> //返回指向对象的std::unique_ptr,
std::unique_ptr<Investment> //对象使用给定实参创建
makeInvestment(Ts&&... params);
调用者应该在单独的作用域中使用返回的std::unique_ptr
智能指针:
{
…
auto pInvestment = //pInvestment是
makeInvestment( arguments ); //std::unique_ptr<Investment>类型
…
} //销毁 *pInvestment
即使所有权不断流转或是所有权链由于异常或其他非典型控制流而中断,其托管的资源终将被析构。
默认情况下,销毁将通过delete
进行,但是在构造过程中,std::unique_ptr
对象可以被设置为使用(对资源的)自定义删除器:当资源需要销毁时可调用的任意函数(或者函数对象,包括lambda表达式)。如果通过makeInvestment
创建的对象不应仅仅被delete
,而应该先写一条日志,makeInvestment
可以以如下方式实现。
auto delInvmt = [](Investment* pInvestment) //自定义删除器
{ //(lambda表达式)
makeLogEntry(pInvestment);
delete pInvestment;
};
template<typename... Ts>
auto makeInvestment(Ts&&... params)
{
std::unique_ptr<Investment, decltype(delInvmt)> //应返回的指针
pInv(nullptr, delInvmt);
if (/*一个Stock对象应被创建*/)
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if ( /*一个Bond对象应被创建*/ )
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( /*一个RealEstate对象应被创建*/ )
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv;
}
自定义删除器的一个形参,类型是Investment*
,不管在makeInvestment
内部创建的对象的真实类型(如Stock
,Bond
,或RealEstate
)是什么,它最终在lambda表达式中,作为Investment*
对象被删除。这意味着我们通过基类指针删除派生类实例,为此,基类Investment
必须有虚析构函数:
class Investment {
public:
…
virtual ~Investment(); //关键设计部分!
…
};
-
之前说过,当使用默认删除器时(如
delete
),你可以合理假设std::unique_ptr
对象和原始指针大小相同。当自定义删除器时,情况可能不再如此。函数指针形式的删除器,通常会使std::unique_ptr
的大小从一个字(word)增加到两个。对于函数对象形式的删除器来说,变化的大小取决于函数对象中存储的状态多少,无状态函数(stateless function)对象(比如不捕获变量的lambda表达式)对大小没有影响,这意味当自定义删除器可以实现为函数或者lambda时,尽量使用lambda。 -
std::unique_ptr
有两种形式,一种用于单个对象(std::unique_ptr<T>
),一种用于数组(std::unique_ptr<T[]>
)。结果就是,指向哪种形式没有歧义。std::unique_ptr
的API设计会自动匹配你的用法,比如operator[]
就是数组对象,解引用操作符(operator*
和operator->
)就是单个对象专有。你应该对数组的std::unique_ptr
的存在兴趣泛泛,因为std::array
,std::vector
,std::string
这些更好用的数据容器应该取代原始数组。std::unique_ptr<T[]>
有用的唯一情况是你使用类似C的API返回一个指向堆数组的原始指针,而你想接管这个数组的所有权。 -
std::unique_ptr
是C++11中表示专有所有权的方法,但是其最吸引人的功能之一是它可以轻松高效的转换为std::shared_ptr
:std::shared_ptr<Investment> sp = //将std::unique_ptr makeInvestment(arguments); //转为std::shared_ptr
这就是
std::unique_ptr
非常适合用作工厂函数返回类型的原因的关键部分。 工厂函数无法知道调用者是否要对它们返回的对象使用专有所有权语义,或者共享所有权(即std::shared_ptr
)是否更合适。 通过返回std::unique_ptr
,工厂为调用者提供了最有效的智能指针,但它们并不妨碍调用者用其更灵活的兄弟替换它。
速记要点
std::unique_ptr
是轻量级、快速的、只可移动(move-only)的管理专有所有权语义资源的智能指针- 默认情况,资源销毁通过
delete
实现,但是支持自定义删除器。有状态的删除器和函数指针会增加std::unique_ptr
对象的大小 - 将
std::unique_ptr
转化为std::shared_ptr
非常简单
条款19:使用std::shared_ptr
管理具备共享所有权的资源
- 一个通过
std::shared_ptr
访问的对象其生命周期由指向它的有共享所有权(shared ownership)的指针们来管理。没有特定的std::shared_ptr
拥有该对象。相反,所有指向它的std::shared_ptr
都能相互合作确保在它不再使用的那个点进行析构。 std::shared_ptr
通过引用计数(reference count)来确保它是否是最后一个指向某种资源的指针,引用计数关联资源并跟踪有多少std::shared_ptr
指向该资源。std::shared_ptr
构造函数递增引用计数值(注意是通常——原因参见下面),析构函数递减值,拷贝赋值运算符做前面这两个工作。(如果sp1
和sp2
是std::shared_ptr
并且指向不同对象,赋值“sp1 = sp2;
”会使sp1
指向sp2
指向的对象。直接效果就是sp1
引用计数减一,sp2
引用计数加一。)如果std::shared_ptr
在计数值递减后发现引用计数值为零,没有其他std::shared_ptr
指向该资源,它就会销毁资源。
引用计数的存在会带来一些性能影响:
std::shared_ptr
大小是原始指针的两倍,因为它内部包含一个指向资源的原始指针,还包含一个指向资源的引用计数值的原始指针。- 引用计数的内存必须动态分配。 概念上,引用计数与所指对象关联起来,但是实际上被指向的对象不知道这件事情(译注:不知道有一个关联到自己的计数值)。因此它们没有办法存放一个引用计数值。(一个好消息是任何对象——甚至是内置类型的——都可以由
std::shared_ptr
管理。)条款21会解释使用std::make_shared
创建std::shared_ptr
可以避免引用计数的动态分配,但是还存在一些std::make_shared
不能使用的场景,这时候引用计数就会动态分配。 - 递增递减引用计数必须是原子性的,原子操作通常比非原子操作要慢,所以即使引用计数通常只有一个word大小,你也应该假定读写它们是存在开销的。
前面写道std::shared_ptr
构造函数只是“通常”递增指向对象的引用计数会不会让你有点好奇?创建一个指向对象的std::shared_ptr
就产生了又一个指向那个对象的std::shared_ptr
,为什么我没说总是增加引用计数值?
- 原因是移动构造函数的存在。从另一个
std::shared_ptr
移动构造新std::shared_ptr
会将原来的std::shared_ptr
设置为null,那意味着老的std::shared_ptr
不再指向资源,同时新的std::shared_ptr
指向资源。这样的结果就是不需要修改引用计数值。所以移动操作要比复制操作快。
类似std::unique_ptr
,std::shared_ptr
使用delete
作为资源的默认销毁机制,但是它也支持自定义的删除器。这种支持有别于std::unique_ptr
。对于std::unique_ptr
来说,删除器类型是智能指针类型的一部分。对于std::shared_ptr
则不是:
auto loggingDel = [](Widget *pw) //自定义删除器
{ //(和条款18一样)
makeLogEntry(pw);
delete pw;
};
std::unique_ptr< //删除器类型是
Widget, decltype(loggingDel) //指针类型的一部分
> upw(new Widget, loggingDel);
std::shared_ptr<Widget> //删除器类型不是
spw(new Widget, loggingDel); //指针类型的一部分
std::shared_ptr
的设计更为灵活。考虑有两个std::shared_ptr<Widget>
,每个自带不同的删除器(比如通过lambda表达式自定义删除器):
auto customDeleter1 = [](Widget *pw) { … }; //自定义删除器,
auto customDeleter2 = [](Widget *pw) { … }; //每种类型不同
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);
因为pw1
和pw2
有相同的类型,所以它们都可以放到存放那个类型的对象的容器中:
std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };
它们也能相互赋值,也可以传入一个形参为std::shared_ptr<Widget>
的函数。但是自定义删除器类型不同的std::unique_ptr
就不行,因为std::unique_ptr
把删除器视作类型的一部分。
另一个不同于std::unique_ptr
的地方是,指定自定义删除器不会改变std::shared_ptr
对象的大小。不管删除器是什么,一个std::shared_ptr
对象都是两个指针大小。这是个好消息,但是它应该让你隐隐约约不安。自定义删除器可以是函数对象,函数对象可以包含任意多的数据。它意味着函数对象是任意大的。std::shared_ptr
怎么能引用一个任意大的删除器而不使用更多的内存?
它不能。它必须使用更多的内存。然而,那部分内存不是std::shared_ptr
对象的一部分。那部分在堆上面,或者std::shared_ptr
创建者利用std::shared_ptr
对自定义分配器的支持能力,那部分内存随便在哪都行。我前面提到了std::shared_ptr
对象包含了所指对象的引用计数的指针。没错,但是有点误导人。因为引用计数是另一个更大的数据结构的一部分,那个数据结构通常叫做控制块(control block)。每个std::shared_ptr
管理的对象都有个相应的控制块。控制块除了包含引用计数值外还有一个自定义删除器的拷贝,当然前提是存在自定义删除器。如果用户还指定了自定义分配器,控制块也会包含一个分配器的拷贝。控制块可能还包含一些额外的数据,正如条款21提到的,一个次级引用计数weak count,但是目前我们先忽略它。我们可以想象std::shared_ptr
对象在内存中是这样:
控制块的创建遵循以下规则:
std::make_shared
(参见条款21)总是创建一个控制块。它创建一个要指向的新对象,所以可以肯定std::make_shared
调用时对象不存在其他控制块。- 当从独占指针(即
std::unique_ptr
或者std::auto_ptr
)上构造出std::shared_ptr
时会创建控制块。独占指针没有使用控制块,所以指针指向的对象没有关联控制块。(作为构造的一部分,std::shared_ptr
侵占独占指针所指向的对象的独占权,所以独占指针被设置为null) - 当从原始指针上构造出
std::shared_ptr
时会创建控制块。如果你想从一个早已存在控制块的对象上创建std::shared_ptr
,你将假定传递一个std::shared_ptr
或者std::weak_ptr
(参见条款20)作为构造函数实参,而不是原始指针。用std::shared_ptr
或者std::weak_ptr
作为构造函数实参创建std::shared_ptr
不会创建新控制块,因为它可以依赖传递来的智能指针指向控制块。
这些规则造成的后果就是从原始指针上构造超过一个std::shared_ptr
就会让你走上未定义行为的快车道,因为指向的对象有多个控制块关联。我们可以遵循两条规范以避免这种问题:第一,避免传给std::shared_ptr
构造函数原始指针。通常替代方案是使用std::make_shared
(参见条款21),不过上面例子中,我们使用了自定义删除器,用std::make_shared
就没办法做到。第二,如果你必须传给std::shared_ptr
构造函数原始指针,直接传new
出来的结果,不要传指针变量:
std::shared_ptr<Widget> spw1(new Widget, //直接使用new的结果
loggingDel);
一个尤其令人意外的地方是使用this
指针作为std::shared_ptr
构造函数实参的时候可能导致创建多个控制块。假设我们的程序使用std::shared_ptr
管理Widget
对象,我们有一个数据结构用于跟踪已经处理过的Widget
对象:
std::vector<std::shared_ptr<Widget>> processedWidgets;
继续,假设Widget
有一个用于处理的成员函数:
class Widget {
public:
…
void process();
…
};
对于Widget::process
看起来合理的代码如下:
void Widget::process()
{
… //处理Widget
processedWidgets.emplace_back(this); //然后将它加到已处理过的Widget
} //的列表中,这是错的!
但此时,若已经存在指向Widget对象的指针,如Widget对象由shared_ptr管理,那么再调用process,那么就会导致未定义行为了。
std::shared_ptr
API已有处理这种情况的设施。它的名字可能是C++标准库中最奇怪的一个:std::enable_shared_from_this
。如果你想创建一个用std::shared_ptr
管理的类,这个类能够用this
指针安全地创建一个std::shared_ptr
,std::enable_shared_from_this
就可作为基类的模板类。在我们的例子中,Widget
将会继承自std::enable_shared_from_this
:
class Widget: public std::enable_shared_from_this<Widget> {
public:
…
void process();
…
};
std::enable_shared_from_this
定义了一个成员函数,成员函数会创建指向当前对象的std::shared_ptr
却不创建多余控制块。这个成员函数就是shared_from_this
,无论在哪当你想在成员函数中使用std::shared_ptr
指向this
所指对象时都请使用它。这里有个Widget::process
的安全实现:
void Widget::process()
{
//和之前一样,处理Widget
…
//把指向当前对象的std::shared_ptr加入processedWidgets
processedWidgets.emplace_back(shared_from_this());
}
从内部来说,shared_from_this
查找当前对象控制块,然后创建一个新的std::shared_ptr
关联这个控制块。设计的依据是当前对象已经存在一个关联的控制块。要想符合设计依据的情况,必须已经存在一个指向当前对象的std::shared_ptr
(比如调用shared_from_this
的成员函数外面已经存在一个std::shared_ptr
)。如果没有std::shared_ptr
指向当前对象(即当前对象没有关联控制块),行为是未定义的,shared_from_this
通常抛出一个异常。
要想防止客户端在存在一个指向对象的std::shared_ptr
前先调用含有shared_from_this
的成员函数,继承自std::enable_shared_from_this
的类通常将它们的构造函数声明为private
,并且让客户端通过返回std::shared_ptr
的工厂函数创建对象。以Widget
为例,代码可以是这样:
class Widget: public std::enable_shared_from_this<Widget> {
public:
//完美转发参数给private构造函数的工厂函数
template<typename... Ts>
static std::shared_ptr<Widget> create(Ts&&... params);
…
void process(); //和前面一样
…
private:
… //构造函数
};
让我们回到原来的主题。控制块通常只占几个word大小,自定义删除器和分配器可能会让它变大一点。通常控制块的实现比你想的更复杂一些。它使用继承,甚至里面还有一个虚函数(用来确保指向的对象被正确销毁)。这意味着使用std::shared_ptr
还会招致控制块使用虚函数带来的成本。然而,不用担心,就它提供的功能来说,std::shared_ptr
的开销是非常合理的。
在通常情况下,使用默认删除器和默认分配器,使用std::make_shared
创建std::shared_ptr
,产生的控制块只需三个word大小。它的分配基本上是无开销的(开销被并入了指向的对象的分配成本里。细节参见条款21)。
std::shared_ptr
不能处理的另一个东西是数组。和std::unique_ptr
不同的是,std::shared_ptr
的API设计之初就是针对单个对象的,没有所谓的std::shared_ptr<T[]>
。
要点速记
std::shared_ptr
为有共享所有权的任意资源提供一种自动垃圾回收的便捷方式。- 较之于
std::unique_ptr
,std::shared_ptr
对象通常是裸指针尺寸的两边,且控制块会产生开销,需要原子性的引用计数修改操作。 - 默认资源销毁是通过
delete
,但是也支持自定义删除器。删除器的类型是什么对于std::shared_ptr
的类型没有影响。 - 避免从原始指针变量上创建
std::shared_ptr
。
条款20:对于类似std::shared_ptr
但有可能空悬的指针使用std::weak_ptr
自相矛盾的是,如果有一个像std::shared_ptr
(条款19)的但是不参与资源所有权共享的指针是很方便的。换句话说,是一个类似std::shared_ptr
但不影响对象引用计数的指针。这种类型的智能指针必须要解决一个std::shared_ptr
不存在的问题:可能指向已经销毁的对象。一个真正的智能指针应该跟踪所指对象,在悬空时知晓,悬空(dangle)就是指针指向的对象不再存在。这就是对std::weak_ptr
最精确的描述。
std::weak_ptr
不能解引用,也不能检查是否为空值。因为std::weak_ptr
不是一个独立的智能指针。它是std::shared_ptr
的增强。
std::weak_ptr
通常从std::shared_ptr
上创建。当从std::shared_ptr
上创建std::weak_ptr
时两者指向相同的对象,但是std::weak_ptr
不会影响所指对象的引用计数:
auto spw = //spw创建之后,指向的Widget的
std::make_shared<Widget>(); //引用计数(ref count,RC)为1。
//std::make_shared的信息参见条款21
…
std::weak_ptr<Widget> wpw(spw); //wpw指向与spw所指相同的Widget。RC仍为1
…
spw = nullptr; //RC变为0,Widget被销毁。
//wpw现在悬空
悬空的std::weak_ptr
被称作已经expired(过期)。你可以用它直接做测试:
if (wpw.expired()) … //如果wpw没有指向对象…
但是通常你期望的是检查std::weak_ptr
是否已经过期,如果没有过期则访问其指向的对象。这做起来可不是想着那么简单。因为缺少解引用操作,没有办法写这样的代码。所以需要的是一个原子操作检查std::weak_ptr
是否已经过期,如果没有过期就访问所指对象。这可以通过从std::weak_ptr
创建std::shared_ptr
来实现,具体有两种形式可以从std::weak_ptr
上创建std::shared_ptr
,具体用哪种取决于std::weak_ptr
过期时你希望std::shared_ptr
表现出什么行为。一种形式是std::weak_ptr::lock
,它返回一个std::shared_ptr
,如果std::weak_ptr
过期这个std::shared_ptr
为空:
std::shared_ptr<Widget> spw1 = wpw.lock(); //如果wpw过期,spw1就为空
auto spw2 = wpw.lock(); //同上,但是使用auto
另一种形式是以std::weak_ptr
为实参构造std::shared_ptr
。这种情况中,如果std::weak_ptr
过期,会抛出一个异常:
std::shared_ptr<Widget> spw3(wpw); //如果wpw过期,抛出std::bad_weak_ptr异常
但是你可能还想知道为什么std::weak_ptr
就有用了。考虑一个工厂函数,它基于一个唯一ID从只读对象上产出智能指针。根据条款18的描述,工厂函数会返回一个该对象类型的std::unique_ptr
:
std::unique_ptr<const Widget> loadWidget(WidgetID id);
如果调用loadWidget
是一个昂贵的操作(比如它操作文件或者数据库I/O)并且重复使用ID很常见,一个合理的优化是再写一个函数除了完成loadWidget
做的事情之外再缓存它的结果。然而缓存所有用过的Widget造成缓存拥塞,可能本身就会引起性能问题,所以另一个合理的优化可以是当Widget
不再使用的时候销毁它的缓存。
对于可缓存的工厂函数,返回std::unique_ptr
不是好的选择。调用者应该接收缓存对象的智能指针,调用者也应该确定这些对象的生命周期,但是缓存本身也需要一个指针指向它所缓存的对象。缓存对象的指针需要知道它是否已经悬空,因为当工厂客户端使用完工厂产生的对象后,对象将被销毁,关联的缓存条目会悬空。所以缓存应该使用std::weak_ptr
,这可以知道是否已经悬空。这意味着工厂函数返回值类型应该是std::shared_ptr
,因为只有当对象的生命周期由std::shared_ptr
管理时,std::weak_ptr
才能检测到悬空。
下面是一个临时凑合的loadWidget
的缓存版本的实现:
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
static std::unordered_map<WidgetID,
std::weak_ptr<const Widget>> cache;
//译者注:这里std::weak_ptr<const Widget>是高亮
auto objPtr = cache[id].lock(); //objPtr是去缓存对象的
//std::shared_ptr(或
//当对象不在缓存中时为null)
if (!objPtr) { //如果不在缓存中
objPtr = loadWidget(id); //加载它
cache[id] = objPtr; //缓存它
}
return objPtr;
}
fastLoadWidget
的实现忽略了以下事实:缓存可能会累积过期的std::weak_ptr
,这些指针对应了不再使用的Widget
(也已经被销毁了)。其实可以改进实现方式(如定时器定期清理),但是花时间在这个问题上不会让我们对std::weak_ptr
有更深入的理解,让我们考虑第二个用例:观察者设计模式(Observer design pattern)。此模式的主要组件是subjects(主题,状态可能会更改的对象)和observers(观察者,状态发生更改时要通知的对象)。在大多数实现中,每个subject都包含一个数据成员,该成员持有指向其observers的指针。这使subjects很容易发布状态更改通知。subjects对控制observers的生命周期(即它们什么时候被销毁)没有兴趣,但是subjects对确保另一件事具有极大的兴趣,那事就是一个observer被销毁时,不再尝试访问它。一个合理的设计是每个subject持有一个std::weak_ptr
s容器指向observers,因此可以在使用前检查是否已经悬空。
作为最后一个使用std::weak_ptr
的例子,考虑一个持有三个对象A
、B
、C
的数据结构,A
和C
共享B
的所有权,因此两者各持有std::shared_ptr
。假定从B指向A的指针也很有用。应该使用哪种指针?
答案是**std::weak_ptr
**,如果A
被销毁,B
指向它的指针悬空,但是B
可以检测到这件事。尤其是,尽管A
和B
互相指向对方,B
的指针不会影响A
的引用计数,因此在没有std::shared_ptr
指向A
时不会导致A
无法被销毁。但是,需要注意使用std::weak_ptr
打破std::shared_ptr
循环并不常见。例如严格分层的树结构使用std::unique_ptr
就很好,当然,不是所有的使用指针的数据结构都是严格分层的,所以当发生这种情况时,比如上面所述缓存和观察者列表的实现之类的,知道std::weak_ptr
随时待命也是不错的。
要点速记
- 用
std::weak_ptr
替代可能会悬空的std::shared_ptr
。 std::weak_ptr
的潜在使用场景包括:缓存、观察者列表、打破std::shared_ptr
环状结构。
条款21:优先使用std::make_unique
和std::make_shared
,而非直接使用new
std::make_unique
和std::make_shared
是三个make函数 中的两个:接收任意的多参数集合,完美转发到构造函数去动态分配一个对象,然后返回这个指向这个对象的指针。第三个make
函数是std::allocate_shared
。它行为和std::make_shared
一样,只不过第一个参数是用来动态分配内存的allocator对象。
即使通过用和不用make
函数来创建智能指针的一个小小比较,也揭示了为何使用make
函数更好的第一个原因。例如:
auto upw1(std::make_unique<Widget>()); //使用make函数
std::unique_ptr<Widget> upw2(new Widget); //不使用make函数
auto spw1(std::make_shared<Widget>()); //使用make函数
std::shared_ptr<Widget> spw2(new Widget); //不使用make函数
我高亮了关键区别:使用new
的版本重复了类型,但是make
函数的版本没有。重复写类型和软件工程里面一个关键原则相冲突:应该避免重复代码。源代码中的重复增加了编译的时间,会导致目标代码冗余,并且通常会让代码库使用更加困难。它经常演变成不一致的代码,而代码库中的不一致常常导致bug。此外,打两次字比一次更费力,而且没人不喜欢少打字吧?
第二个使用make
函数的原因和异常安全有关。假设我们有个函数按照某种优先级处理Widget
:
void processWidget(std::shared_ptr<Widget> spw, int priority);
值传递std::shared_ptr
可能看起来很可疑,但是条款41解释了,如果processWidget
总是复制std::shared_ptr
(例如,通过将其存储在已处理的Widget
的一个数据结构中),那么这可能是一个合理的设计选择。
现在假设我们有一个函数来计算相关的优先级,
int computePriority();
并且我们在调用processWidget
时使用了new
而不是std::make_shared
:
processWidget(std::shared_ptr<Widget>(new Widget), //潜在的资源泄漏!
computePriority());
如注释所说,这段代码可能在new
一个Widget
时发生泄漏。为何?调用的代码和被调用的函数都用std::shared_ptr
,且std::shared_ptr
就是设计出来防止泄漏的。它们会在最后一个std::shared_ptr
销毁时自动释放所指向的内存。如果每个人在每个地方都用std::shared_ptr
,这段代码怎么会泄漏呢?
答案和编译器将源码转换为目标代码有关。在运行时,一个函数的实参必须先被计算,这个函数再被调用,所以在调用processWidget
之前,必须执行以下操作,processWidget
才开始执行:
- 表达式“
new Widget
”必须计算,例如,一个Widget
对象必须在堆上被创建 - 负责管理
new
出来指针的std::shared_ptr<Widget>
构造函数必须被执行 computePriority
必须运行
编译器不需要按照执行顺序生成代码。“new Widget
”必须在std::shared_ptr
的构造函数被调用前执行,因为new
出来的结果作为构造函数的实参,但computePriority
可能在这之前,之后,或者之间执行。也就是说,编译器可能按照这个执行顺序生成代码:
- 执行“
new Widget
” - 执行
computePriority
- 运行
std::shared_ptr
构造函数
如果按照这样生成代码,并且在运行时computePriority
产生了异常,那么第一步动态分配的Widget
就会泄漏。因为它永远都不会被第三步的std::shared_ptr
所管理了。
使用std::make_shared
可以防止这种问题。调用代码看起来像是这样:
processWidget(std::make_shared<Widget>(), //没有潜在的资源泄漏
computePriority());
在运行时,std::make_shared
和computePriority
其中一个会先被调用。如果是std::make_shared
先被调用,在computePriority
调用前,动态分配Widget
的原始指针会安全的保存在作为返回值的std::shared_ptr
中。如果computePriority
产生一个异常,那么std::shared_ptr
析构函数将确保管理的Widget
被销毁。如果首先调用computePriority
并产生一个异常,那么std::make_shared
将不会被调用,因此也就不需要担心动态分配Widget
(会泄漏)。
如果我们将std::shared_ptr
,std::make_shared
替换成std::unique_ptr
,std::make_unique
,同样的道理也适用。因此,在编写异常安全代码时,使用std::make_unique
而不是new
与使用std::make_shared
(而不是new
)同样重要。
std::make_shared
的一个特性(与直接使用new
相比)是效率提升。使用std::make_shared
允许编译器生成更小,更快的代码,并使用更简洁的数据结构。考虑以下对new的直接使用:
std::shared_ptr<Widget> spw(new Widget);
显然,这段代码需要进行内存分配,但它实际上执行了两次。条款19解释了每个std::shared_ptr
指向一个控制块,其中包含被指向对象的引用计数,还有其他东西。这个控制块的内存在std::shared_ptr
构造函数中分配。因此,直接使用new
需要为Widget
进行一次内存分配,为控制块再进行一次内存分配。
如果使用std::make_shared
代替:
auto spw = std::make_shared<Widget>();
一次分配足矣。这是因为std::make_shared
分配一块内存,同时容纳了Widget
对象和控制块。这种优化减少了程序的静态大小,因为代码只包含一个内存分配调用,并且它提高了可执行代码的速度,因为内存只分配一次。此外,使用std::make_shared
避免了对控制块中的某些簿记信息的需要,潜在地减少了程序的总内存占用。
对于std::make_shared
的效率分析同样适用于std::allocate_shared
,因此std::make_shared
的性能优势也扩展到了该函数。
更倾向于使用make
函数而不是直接使用new
的争论非常激烈。尽管它们在软件工程、异常安全和效率方面具有优势,但本条款的建议是,更倾向于使用make
函数,而不是完全依赖于它们。这是因为有些情况下它们不能或不应该被使用。
例如,make
函数都不允许指定自定义删除器(见条款18和19),但是std::unique_ptr
和std::shared_ptr
的构造函数可以接收一个删除器参数。有个Widget
的自定义删除器:
auto widgetDeleter = [](Widget* pw) { … };
创建一个使用它的智能指针只能直接使用new
:
std::unique_ptr<Widget, decltype(widgetDeleter)>
upw(new Widget, widgetDeleter);
std::shared_ptr<Widget> spw(new Widget, widgetDeleter);
对于make
函数,没有办法做同样的事情。
make
函数第二个限制来自于其实现中的语法细节。条款7解释了,当构造函数重载,有使用std::initializer_list
作为参数的重载形式和不用其作为参数的的重载形式,用花括号创建的对象更倾向于使用std::initializer_list
作为形参的重载形式,而用小括号创建对象将调用不用std::initializer_list
作为参数的的重载形式。make
函数会将它们的参数完美转发给对象构造函数,但是它们是使用小括号还是花括号?
答案是在make
函数中,完美转发使用小括号,而不是花括号。坏消息是如果你想用花括号初始化指向的对象,你必须直接使用new
。使用make
函数会需要能够完美转发花括号初始化的能力,但是,正如(条款30)所说,花括号初始化无法完美转发。但是,条款30介绍了一个变通的方法:使用auto
类型推导从花括号初始化创建std::initializer_list
对象,然后将auto
创建的对象传递给make
函数。
//创建std::initializer_list
auto initList = { 10, 20 };
//使用std::initializer_list为形参的构造函数创建std::vector
auto spv = std::make_shared<std::vector<int>>(initList);
对于std::unique_ptr
,只有这两种情景(自定义删除器和花括号初始化)使用make
函数有点问题。
对于std::shared_ptr
和它的make
函数,还有2个问题。都属于边缘情况,但是一些开发者常碰到,你也可能是其中之一。
一些类定义了自身版本的operator new
和operator delete
。这些函数的存在意味着对这些类型的对象的全局内存分配和释放是不合常规的。设计这种定制操作往往只会精确的分配、释放对象大小的内存。例如,Widget
类的operator new
和operator delete
只会处理sizeof(Widget)
大小的内存块的分配和释放。这种系列行为不太适用于std::shared_ptr
对自定义分配(通过std::allocate_shared
)和释放(通过自定义删除器)的支持,因为std::allocate_shared
需要的内存总大小不等于动态分配的对象大小,还需要再加上控制块大小。因此,使用make
函数去创建重载了operator new
和operator delete
类的对象是个典型的糟糕想法。
与直接使用new
相比,std::make_shared
在大小和速度上的优势源于std::shared_ptr
的控制块与指向的对象放在同一块内存中。当对象的引用计数降为0,对象被销毁(即析构函数被调用)。但是,因为控制块和对象被放在同一块分配的内存块中,直到控制块的内存也被销毁,对象占用的内存才被释放。
正如我说,控制块除了引用计数,还包含簿记信息。引用计数追踪有多少std::shared_ptr
指向控制块,但控制块还有第二个计数,记录多少个std::weak_ptr
s指向控制块。第二个引用计数就是weak count。(实际上,weak count的值不总是等于指向控制块的std::weak_ptr
的数目,因为库的实现者找到一些方法在weak count中添加附加信息,促进更好的代码产生。为了本条款的目的,我们会忽略这一点,假定weak count的值等于指向控制块的std::weak_ptr
的数目。)当一个std::weak_ptr
检测它是否过期时(见条款19),它会检测指向的控制块中的引用计数(而不是weak count)。如果引用计数是0(即对象没有std::shared_ptr
再指向它,已经被销毁了),std::weak_ptr
就已经过期。否则就没过期。
只要std::weak_ptr
引用一个控制块(即weak count大于零),该控制块必须继续存在。只要控制块存在,包含它的内存就必须保持分配。通过std::shared_ptr
的make
函数分配的内存,直到最后一个std::shared_ptr
和最后一个指向它的std::weak_ptr
已被销毁,才会释放。
如果对象类型非常大,而且销毁最后一个std::shared_ptr
和销毁最后一个std::weak_ptr
之间的时间很长,那么在销毁对象和释放它所占用的内存之间可能会出现延迟。
class ReallyBigType { … };
auto pBigObj = //通过std::make_shared
std::make_shared<ReallyBigType>(); //创建一个大对象
… //创建std::shared_ptrs和std::weak_ptrs
//指向这个对象,使用它们
… //最后一个std::shared_ptr在这销毁,
//但std::weak_ptrs还在
… //在这个阶段,原来分配给大对象的内存还分配着
… //最后一个std::weak_ptr在这里销毁;
//控制块和对象的内存被释放
直接只用new
,一旦最后一个std::shared_ptr
被销毁,ReallyBigType
对象的内存就会被释放:
class ReallyBigType { … }; //和之前一样
std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType);
//通过new创建大对象
… //像之前一样,创建std::shared_ptrs和std::weak_ptrs
//指向这个对象,使用它们
… //最后一个std::shared_ptr在这销毁,
//但std::weak_ptrs还在;
//对象的内存被释放
… //在这阶段,只有控制块的内存仍然保持分配
… //最后一个std::weak_ptr在这里销毁;
//控制块内存被释放
如果你发现自己处于不可能或不合适使用std::make_shared
的情况下,你将想要保证自己不受我们之前看到的异常安全问题的影响。最好的方法是确保在直接使用new
时,在一个不做其他事情的语句中,立即将结果传递到智能指针构造函数。这可以防止编译器生成的代码在使用new
和调用管理new
出来对象的智能指针的构造函数之间发生异常。
例如,考虑我们前面讨论过的processWidget
函数,对其非异常安全调用的一个小修改。这一次,我们将指定一个自定义删除器:
void processWidget(std::shared_ptr<Widget> spw, //和之前一样
int priority);
void cusDel(Widget *ptr); //自定义删除器
这是非异常安全调用:
processWidget( //和之前一样,
std::shared_ptr<Widget>(new Widget, cusDel), //潜在的内存泄漏!
computePriority()
);
回想一下:如果computePriority
在“new Widget
”之后,而在std::shared_ptr
构造函数之前调用,并且如果computePriority
产生一个异常,那么动态分配的Widget
将会泄漏。
这里使用自定义删除排除了对std::make_shared
的使用,因此避免出现问题的方法是将Widget
的分配和std::shared_ptr
的构造放入它们自己的语句中,然后使用得到的std::shared_ptr
调用processWidget
。这是该技术的本质,不过,正如我们稍后将看到的,我们可以对其进行调整以提高其性能:
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(spw, computePriority()); // 正确,但是没优化,见下
processWidget(std::move(spw), computePriority()); //高效且异常安全
这是可行的,因为std::shared_ptr
获取了传递给它的构造函数的原始指针的所有权,即使构造函数产生了一个异常。此例中,如果spw
的构造函数抛出异常(比如无法为控制块动态分配内存),仍然能够保证cusDel
会在“new Widget
”产生的指针上调用。
要点速记
- 和直接使用
new
相比,make
函数消除了代码重复,提高了异常安全性。对于std::make_shared
和std::allocate_shared
,生成的代码更小更快。 - 不适合使用
make
函数的情况包括需要指定自定义删除器和希望用花括号初始化。 - 对于
std::shared_ptr
s,其他不建议使用make
函数的情况包括(1)有自定义内存管理的类;(2)特别关注内存的系统,非常大的对象,以及std::weak_ptr
比对应的std::shared_ptr
活得更久。
条款22:使用Pimpl习惯用法时,将特殊成员函数的定义放到实现文件中
如果你曾经与过多的编译次数斗争过,你会对Pimpl(pointer to implementation)惯用法很熟悉。 凭借这样一种技巧,你可以将类数据成员替换成一个指向包含具体实现的类(或结构体)的指针,并将放在主类(primary class)的数据成员们移动到实现类(implementation class)去,而这些数据成员的访问将通过指针间接访问。 举个例子,假如有一个类Widget
看起来如下:
class Widget() { //定义在头文件“widget.h”
public:
Widget();
…
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; //Gadget是用户自定义的类型
};
因为类Widget
的数据成员包含有类型std::string
,std::vector
和Gadget
, 定义有这些类型的头文件在类Widget
编译的时候,必须被包含进来,这意味着类Widget
的使用者必须要#include <string>
,<vector>
以及gadget.h
。 这些头文件将会增加类Widget
使用者的编译时间,并且让这些使用者依赖于这些头文件。 如果一个头文件的内容变了,类Widget
使用者也必须要重新编译。 标准库文件<string>
和<vector>
不是很常变,但是gadget.h
可能会经常修订。
C++98的实现方法是把Widget
的数据成员替换成一个原始指针,指向一个已经被声明过却还未被定义的结构体。我们直接探讨智能指针的实现方法。如果我们想要的只是在类Widget
的构造函数动态分配Widget::impl
对象,在Widget
对象销毁时一并销毁它, std::unique_ptr
(见条款18)是最合适的工具。在头文件中用std::unique_ptr
替代原始指针,就有了头文件中如下代码:
class Widget { //在“widget.h”中
public:
Widget();
…
private:
struct Impl;
std::unique_ptr<Impl> pImpl; //使用智能指针而不是原始指针
};
实现文件如下:
#include "widget.h" //在“widget.cpp”中
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl { //实现
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
};
Widget::Widget() //根据条款21,通过std::make_unique
: pImpl(std::make_unique<Impl>()) //来创建std::unique_ptr
{}
std::unique_ptr
在自身析构时,会自动销毁它所指向的对象,所以我们自己无需手动销毁任何东西。这就是智能指针的众多优点之一:它使我们从手动资源释放中解放出来。
以上的代码能编译,但是,最普通的Widget
用法却会导致编译出错:
#include "widget.h"
Widget w; //错误!
你所看到的错误信息根据编译器不同会有所不同,但是其文本一般会提到一些有关于“把sizeof
或delete
应用到不完整类型上”的信息。对于不完整类型,使用以上操作是禁止的。
在Pimpl惯用法中使用std::unique_ptr
会抛出错误,有点惊悚,因为第一std::unique_ptr
宣称它支持不完整类型,第二Pimpl惯用法是std::unique_ptr
的最常见的使用情况之一。 幸运的是,让这段代码能正常运行很简单。 只需要对上面出现的问题的原因有一个基础的认识就可以了。
在对象w
被析构时(例如离开了作用域),问题出现了。在这个时候,它的析构函数被调用。我们在类的定义里使用了std::unique_ptr
,所以我们没有声明一个析构函数,因为我们并没有任何代码需要写在里面。根据编译器自动生成的特殊成员函数的规则(见条款17),编译器会自动为我们生成一个析构函数。 在这个析构函数里,编译器会插入一些代码来调用类Widget
的数据成员pImpl
的析构函数。 pImpl
是一个std::unique_ptr<Widget::Impl>
,也就是说,一个使用默认删除器的std::unique_ptr
。 默认删除器是一个函数,它使用delete
来销毁内置于std::unique_ptr
的原始指针。然而,在使用delete
之前,通常会使默认删除器使用C++11的特性static_assert
来确保原始指针指向的类型不是一个不完整类型。 当编译器为Widget w
的析构生成代码时,它会遇到static_assert
检查并且失败,这通常是错误信息的来源。 这些错误信息只在对象w
销毁的地方出现,因为类Widget
的析构函数,正如其他的编译器生成的特殊成员函数一样,是暗含inline
属性的。 错误信息自身往往指向对象w
被创建的那行,因为这行代码明确地构造了这个对象,导致了后面潜在的析构。
为了解决这个问题,你只需要确保在编译器生成销毁std::unique_ptr<Widget::Impl>
的代码之前, Widget::Impl
已经是一个完整类型(complete type)。 当编译器“看到”它的定义的时候,该类型就成为完整类型了。 但是 Widget::Impl
的定义在widget.cpp
里。成功编译的关键,就是在widget.cpp
文件内,让编译器在“看到” Widget
的析构函数实现之前(也即编译器插入的,用来销毁std::unique_ptr
这个数据成员的代码的,那个位置),先定义Widget::Impl
。做出这样的调整很容易。只需要先在widget.h
里,只声明类Widget
的析构函数,但不要在这里定义它:
class Widget { //跟之前一样,在“widget.h”中
public:
Widget();
~Widget(); //只有声明语句
…
private: //跟之前一样
struct Impl;
std::unique_ptr<Impl> pImpl;
};
在widget.cpp
文件中,在结构体Widget::Impl
被定义之后,再定义析构函数:
#include "widget.h" //跟之前一样,在“widget.cpp”中
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl { //跟之前一样,定义Widget::Impl
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
}
Widget::Widget() //跟之前一样
: pImpl(std::make_unique<Impl>())
{}
Widget::~Widget() //析构函数的定义(译者注:这里高亮)
{}
这样就可以了,并且这样增加的代码也最少,你声明Widget
析构函数只是为了在 Widget 的实现文件中(译者注:指widget.cpp
)写出它的定义,但是如果你想强调编译器自动生成的析构函数会做和你一样正确的事情,你可以直接使用“= default
”定义析构函数体
Widget::~Widget() = default; //同上述代码效果一致
使用了Pimpl惯用法的类自然适合支持移动操作,因为编译器自动生成的移动操作正合我们所意:对其中的std::unique_ptr
进行移动。 正如条款17解释的那样,声明一个类Widget
的析构函数会阻止编译器生成移动操作,所以如果你想要支持移动操作,你必须自己声明相关的函数。考虑到编译器自动生成的版本会正常运行,你可能会很想按如下方式实现它们:
class Widget { //仍然在“widget.h”中
public:
Widget();
~Widget();
Widget(Widget&& rhs) = default; //思路正确,
Widget& operator=(Widget&& rhs) = default; //但代码错误
…
private: //跟之前一样
struct Impl;
std::unique_ptr<Impl> pImpl;
};
这样的做法会导致同样的错误,和之前的声明一个不带析构函数的类的错误一样,并且是因为同样的原因。 编译器生成的移动赋值操作符,在重新赋值之前,需要先销毁指针pImpl
指向的对象。然而在Widget
的头文件里,pImpl
指针指向的是一个不完整类型。移动构造函数的情况有所不同。 移动构造函数的问题是编译器自动生成的代码里,包含有抛出异常的事件,在这个事件里会生成销毁pImpl
的代码。然而,销毁pImpl
需要Impl
是一个完整类型。
因为这个问题同上面一致,所以解决方案也一样——把移动操作的定义移动到实现文件里:
class Widget { //仍然在“widget.h”中
public:
Widget();
~Widget();
Widget(Widget&& rhs); //只有声明
Widget& operator=(Widget&& rhs);
…
private: //跟之前一样
struct Impl;
std::unique_ptr<Impl> pImpl;
};
#include <string> //跟之前一样,仍然在“widget.cpp”中
…
struct Widget::Impl { … }; //跟之前一样
Widget::Widget() //跟之前一样
: pImpl(std::make_unique<Impl>())
{}
Widget::~Widget() = default; //跟之前一样
Widget::Widget(Widget&& rhs) = default; //这里定义
Widget& Widget::operator=(Widget&& rhs) = default;
Pimpl惯用法是用来减少类的实现和类使用者之间的编译依赖的一种方法,但是,从概念而言,使用这种惯用法并不改变这个类的表现。 原来的类Widget
包含有std::string
,std::vector
和Gadget
数据成员,并且,假设类型Gadget
,如同std::string
和std::vector
一样,允许复制操作,所以类Widget
支持复制操作也很合理。 我们必须要自己来写这些函数,因为第一,对包含有只可移动(move-only)类型,如std::unique_ptr
的类,编译器不会生成复制操作;第二,即使编译器帮我们生成了,生成的复制操作也只会复制std::unique_ptr
(也即浅拷贝(shallow copy)),而实际上我们需要复制指针所指向的对象(也即深拷贝(deep copy))。
使用我们已经熟悉的方法,我们在头文件里声明函数,而在实现文件里去实现他们:
class Widget { //仍然在“widget.h”中
public:
…
Widget(const Widget& rhs); //只有声明
Widget& operator=(const Widget& rhs);
private: //跟之前一样
struct Impl;
std::unique_ptr<Impl> pImpl;
};
#include <string> //跟之前一样,仍然在“widget.cpp”中
…
struct Widget::Impl { … }; //跟之前一样
Widget::~Widget() = default; //其他函数,跟之前一样
Widget::Widget(const Widget& rhs) //拷贝构造函数
: pImpl(std::make_unique<Impl>(*rhs.pImpl))
{}
Widget& Widget::operator=(const Widget& rhs) //拷贝operator=
{
*pImpl = *rhs.pImpl;
return *this;
}
两个函数的实现都比较中规中矩。 在每个情况中,我们都只从源对象(rhs
)中,复制了结构体Impl
的内容到目标对象中(*this
)。我们利用了编译器会为我们自动生成结构体Impl
的复制操作函数的机制,而不是逐一复制结构体Impl
的成员,自动生成的复制操作能自动复制每一个成员。 因此我们通过调用编译器生成的Widget::Impl
的复制操作函数来实现了类Widget
的复制操作。 在复制构造函数中,注意,我们仍然遵从了条款21的建议,使用std::make_unique
而非直接使用new
。
为了实现Pimpl惯用法,std::unique_ptr
是我们使用的智能指针,因为位于对象内部的pImpl
指针(例如,在类Widget
内部),对所指向的对应实现的对象的享有独占所有权。然而,有趣的是,如果我们使用std::shared_ptr
而不是std::unique_ptr
来做pImpl
指针, 我们会发现本条款的建议不再适用。 我们不需要在类Widget
里声明析构函数,没有了用户定义析构函数,编译器将会愉快地生成移动操作,并且将会如我们所期望般工作。widget.h
里的代码如下,
class Widget { //在“widget.h”中
public:
Widget();
… //没有析构函数和移动操作的声明
private:
struct Impl;
std::shared_ptr<Impl> pImpl; //用std::shared_ptr
}; //而不是std::unique_ptr
这是#include
了widget.h
的客户代码,
Widget w1;
auto w2(std::move(w1)); //移动构造w2
w1 = std::move(w2); //移动赋值w1
这些都能编译,并且工作地如我们所望:w1
将会被默认构造,它的值会被移动进w2
,随后值将会被移动回w1
,然后两者都会被销毁(因此导致指向的Widget::Impl
对象一并也被销毁)。
std::unique_ptr
和std::shared_ptr
在pImpl
指针上的表现上的区别的深层原因在于,他们支持自定义删除器的方式不同。 对std::unique_ptr
而言,删除器的类型是这个智能指针的一部分,这让编译器有可能生成更小的运行时数据结构和更快的运行代码。 这种更高效率的后果之一就是std::unique_ptr
指向的类型,在编译器的生成特殊成员函数(如析构函数,移动操作)被调用时,必须已经是一个完整类型。 而对std::shared_ptr
而言,删除器的类型不是该智能指针的一部分,这让它会生成更大的运行时数据结构和稍微慢点的代码,但是当编译器生成的特殊成员函数被使用的时候,指向的对象不必是一个完整类型。(译者注:知道std::unique_ptr
和std::shared_ptr
的实现,这一段才比较容易理解。)
对于Pimpl惯用法而言,在std::unique_ptr
和std::shared_ptr
的特性之间,没有一个比较好的折中。 因为对于像Widget
的类以及像Widget::Impl
的类之间的关系而言,他们是独享占有权关系,这让std::unique_ptr
使用起来很合适。 然而,有必要知道,在其他情况中,当共享所有权存在时,std::shared_ptr
是很适用的选择的时候,就没有std::unique_ptr
所必需的声明——定义(function-definition)这样的麻烦事了。
要点速记
- Pimpl惯用法通过减少在类实现和类使用者之间的编译依赖来减少编译时间。
- 对于
std::unique_ptr
类型的pImpl
指针,需要在头文件的类里声明特殊的成员函数,但是在实现文件里面来实现他们。即使是编译器自动生成的代码可以工作,也要这么做。 - 以上的建议只适用于
std::unique_ptr
,不适用于std::shared_ptr
。
参考:Effective Modern C++(中文版)和这里。