条款19:使用std::shared_ptr来进行共享所有权的资源管理
使用垃圾回收的程序员会嘲笑c++程序员阻止资源泄漏的方法,“好原始啊!”他们嘲笑道,“你们没有在1960年代从Lisp里获取备忘录吗?机器应该管理资源的生存期,而不是人”。C++开发者则翻着眼睛说,“你的意思是备忘录里说的资源只是内存,并且资源的回收时间非确定?我们更偏爱普遍意义的且可预测的析构,谢谢!” 但是我们的勇猛有些夸张。垃圾回收其实很方便,手工的生存期管理有些类似用石刀和熊皮来构建内存卡。为什么我们不能拥有这两个世界的最好部分:一个自动工作的系统(比如垃圾回收),而且应用于所有的资源并且可以预测时间点(比如析构)?
std::shared_ptr是c++11中用来绑定两个世界到一起的方法。一个通过std::shared_ptr来访问的对象通过共享所有权的指针来管理生命周期。std::shared_ptr一般不会拥有对象,相反,所有指向对象的std::shared_ptr指针合作起来确保在对象不需要时析构被调用。当最后一个指向对象的std::shared_ptr不再指向该对象时(比如该std::shared_ptr被销毁或者指向别的对象),这个std::shared_ptr会销毁它指向的对象。就像垃圾回收一样,用户自己不用关心被指向对象的生存周期,同时对析构来说,对象的析构时间是确定的。
一个std::shared_ptr可以通过查询指向对象的引用计数来得到它是否是最后一个指针,引用计数是一个和资源关联的值,它记录了有多少std::shared_ptr指向该资源。std::shared_ptr构造函数中会增加引用计数,析构函数汇总减小引用计数(通常是这样---见下面),拷贝复制操作两者都执行。(假如sp1和sp2指向不同的对象,操作“sp1=sp2;”会修改sp1,指向sp2,结果就是原来sp1指向的对象的引用计数减少了,同时sp2指向的对象的引用计数增加了。)当一个std::shared_ptr 指针看到对象的引用计数在指向完减一操作后变为0时,那么没有std::shared_ptr指向该对象了,于是std::shared_ptr将会销毁它。
引用计数的存在会有一些性能问题:
1.std::shared_ptr的大小是原始指针的两倍,因为它的内部有一个原始指针指向资源,同时有个指针指向引用计数。
2.引用计数的内存必须动态分配。理论上,引用计数是和被指向的对象关联在一起的,但被指向的对象对此毫不知情的。因此他们也不会有空间保存引用计数。(一个好消息是任何对象--包括内建类型--都可以被std::shared_ptr管理)。条款21解释了当使用时std::make_shared创建std::shared_ptr时,动态内存分配的消耗会被避免,但是也有一些情况下std::make_shared无法使用。另外,引用计数是作为动态分配的内存数据保存的。
3.增加和减小引用计数必须是原子操作,因为可能会有读写操作在不同的线程中同时发生。比如在一个线程里有一个指向一块资源的std::shared_ptr可能调用了析构(因此所指向的资源的引用计数减一),同时,在另一线程里,指向相同对象的一个std::shared_ptr可能执行了拷贝操作(因此,引用计数加一)。原子操作一般会比非原子操作慢,因此,即使引用计数通常在尺寸上只有1个word,你也应该认为其读写操作是有消耗的。
当我写道,std::shared_ptr构造函数仅仅在“通常”情况下会引起被指向的资源的引用计数增加,我有没有引起你的好奇?创建一个指向某对象的std::shared_ptr时,会引起更多一个std::shared_ptr指向该对象,那为什么不是总会引起引用计数的增加呢?
移动构造,这就是原因。通过一个std::shared_ptr移动构造另一个std::shared_ptr会设置源指针为空,这意味着老的指针不再指向那块资源,而同时新的指针指向了这块资源。所以结果就是引用计数不用变化。移动一个std::shared_ptr因此要比拷贝要快:拷贝需要增加引用计数,而移动不需要。赋值操作也是同样的。因此,移动构造比拷贝构造要快,移动赋值比拷贝赋值也快。
类似std::unique_ptr(见条款18),std::shared_ptr使用delete操作作为默认析构函数,但也可以用定制的删除器。然而对std::shared_ptr如此设计的原因不同于std::unique_ptr。对std::unique_ptr,删除器的类型是灵巧指针类型的一部分,而对std::shared_ptr来说则不是:
auto loggingDel = [](Widget *pw) // custom deleter
{ // (as in Item 18)
makeLogEntry(pw);
delete pw;
} ;
std::unique_ptr< // deleter type is
Widget, decltype(loggingDel) // part of ptr type
> upw(new Widget, loggingDel);
std::shared_ptr<Widget> // deleter type is not
spw(new Widget, loggingDel); // part of ptr type
std::shared_ptr的设计更灵活些。考虑两个std::shared_ptr<Widget>指针,每个都有个不同类型的定制删除器(比如是因为使用了lamda表达式来定制的删除器):
auto customDeleter1 = [](Widget *pw) { … }; // custom deleters,
auto customDeleter2 = [](Widget *pw) { … }; // each with a
// different type
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管理的对象都有一个控制块。除了引用计数外,每个控制块还包含了定制删除器的拷贝(如果有定义的话)。如果有指定定制分配器,那控制块也会包含一个定制分配器的拷贝。控制块也会包含其他数据,比如我们在条款21中将会讲到的第二个引用计数(称为弱计数),但是本条款中我们先忽略之。我们可以把带有std::shared_ptr<T>对象的内存想象成如下样子:
一个对象的控制块是由创建第一个指向该对象的std::shared_ptr指针的函数来建立的。至少这是我们预料中的。一般不可能在一个函数创建指向某对象的std::shared_ptr时会知道是否有其他的std::shared_ptr已经指向该对象,所以会用到下面的关于控制块创建的规矩:
1.std::make_shared(见条款21)总是会创建控制块。该函数创建了一个新对象并指向它,所以当std::make_shared被调用时,当然没有该对象的控制块存在。
2.当一个std::share_ptr是从一个独享所有权的指针(比如std::unique_ptr或者std::auto_ptr)创建时,会创建控制块。独享所有权的指针不使用控制块,因此被指向的对象也不会有控制块。(作为构建的一部分,这个std::shared_ptr会认为拥有了被指对象的所有权,因此,独享所有权的指针会被置空)。
3.当std::shared_ptr是通过一个原始指针构造时,它会创建控制块。假如你想通过一个已有控制块的的对象去构造一个std::shared_ptr,你最好传一个std::shared_ptr或者std::weak_ptr(见条款20)作为构造函数的参数,而不是原始指针。std::shared_ptr的构造函数接受std::shared_ptr或者std::weak_ptr作为参数时,并不创建新的控制块,因为它依赖传递进来的灵巧指针已经指向的控制块。
这些规则产生的后果就是如果从单个原始指针构造了多个std::shared_ptr,会让你( a complimentary ride on the particle accelerator of undefined behavior),因为被指向的对象会产生多个控制块。多个控制块意味着多个引用计数,意味着会被销毁多次(每个引用计数会销毁一次)。像下面这段代码就是非常糟糕的:
auto pw = new Widget; // pw is raw ptr
…
std::shared_ptr<Widget> spw1(pw, loggingDel); // create control
// block for *pw
…
std::shared_ptr<Widget> spw2(pw, loggingDel); // create 2nd
// control block
// for *pw!
创建一个指向动态内存对象的原始指针pw是不好的,因为它和整个本章节的建议是相背的:使用灵巧指针而不是原始指针。(假如你已经忘了这条建议的动机,可以翻到本书的115页)。抛开这点,这行代码只是形式上不好,但至少不会引起未定义的行为。
好,那spw1是通过原始指针来构造的,因此创建了一个指向目标对象(在这里是*pw,pw指针指向的内容)的控制块(因此也有个引用计数)。本身来说,这个是OK的,但是用相同的原始指针pw来创建了第二个spw2,于是又创建了一个指向*pw对象的控制块,这样*pw有了两个控制块,每个最终都会变成0,这会使得最终销毁*pw两次,而第二次的销毁会引起未定义的行为。
这里关于std::shared_ptr的使用知识可以让我学到两点教训。其一,尽量避免使用原始指针来构造std::shared_ptr,通常替代方案是使用std::make_shared(见条款21),但是在上面例子中,我们使用了定制删除器,这样不能使用std::make_shared。第二,如果你必须传递一个原始指针来构造std::shared_ptr,那就直接传递new出来的而不要传递一个原始指针变量。假如上面的代码这样写,
std::shared_ptr<Widget> spw1(new Widget, // direct use of new loggingDel);
那就不太会通过相同的原始指针来创建第二个std::shared_ptr。创建spw2的作者会很自然的使用spw1来作为初始化参数去创建spw2(那样会调用std::shared_ptr的拷贝构造),那就无论如果都没问题。
std::shared_ptr<Widget> spw2(spw1); // spw2 uses same
// control block as spw1
使用原始指针变量作为参数来构造std::shared_ptr会引起多个控制块甚至让我感到意外的包括了this指针。假如我们的程序是用std::shared_ptr来管理Widget对象,我们还有一个数据结构来记录已经处理过的Widget对象:
std::vector<std::shared_ptr<Widget>> processedWidgets;
再假设Widget类有一个成员函数来做处理的事情:
class Widget {
public:
…
void process();
…
};
这里有个看上去很合理的Widget::process实现:
void Widget::process()
{
… // process the Widget
processedWidgets.emplace_back(this); // add it to list of
} // processed Widgets;
// this is wrong!
上面注释里的的描述说明了一切--或者说出了大部分(错误部分不是因为使用了emplace_back,而是传递了this指针。假如你不熟悉emplace_back,见条款42.)这段代码可以编译,但它传递了一个原始指针(this)给了一个std::shared_ptr的容器。这样创建的std::shared_ptr会为所指的对象Widget(*this)创建一个新的控制块。听起来没什么坏处,直到你意识到假如在成员函数之外还存在着std::shared_ptr已经指向了那个Widget,那就产生未定义的行为了。
std::shared_ptr的API提供了这种情况的解决办法。它包含了一个可能在c++标准库里最奇怪的名字:std::enable_shared_from_this。它是个基类模板,你如果想一个被std::shared_ptr管理的类能够安全的从this指针来创建成std::shared_ptr,那你可以继承它,Widget继承std::enable_shared_from_this如下:
class Widget: public std::enable_shared_from_this<Widget> {
public:
…
void process();
…
};
我说过,enable_shared_from_this是一个基类模板。模板类型总是被继承的类名,因此这里是std::enable_shared_from_this<Widget>。假如你觉得一个基类模板用被继承类的类名来模板化难于理解,先别想它,这段代码是合法的。隐藏在之后的设计模式也是被创立良好的,有个标准化的名字,尽管名字和std::enable_shared_from_this一样奇怪。这个名字就是 The
Curiously Recurring Template
Pattern (CRTP),你要想了解更多的这方面的知识,可以去搜索引擎上了解。我们现在转回来看看std::enable_shared_from_this。
std::enable_shared_from_this定义了一个成员函数,它创建了一个std::shared_ptr来指向当前对象,但它却没有复制控制块。这个成员函数就是shared_from_this。在成员函数中,只要你想要一个和this指针指向相同的对象,你就可以使用这个函数。这里就有一个Widget::process的安全实现方法:
void Widget::process()
{
// as before, process the Widget
…
// add std::shared_ptr to current object to processedWidgets
processedWidgets.emplace_back(shared_from_this());
}
在内部,shared_from_this会查找当前对象的的控制块,并且创建一个新的std::share_ptr关联到控制块上。这个设计要依赖于当前对象已经有了一个相应的控制块。为此,必须已经存在一个指向当前对象的std::shared_ptr(比如在调用过shared_from_this成员函数之外已经有了一个)。假如没有这样一个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:
// factory function that perfect-forwards args
// to a private ctor
template<typename... Ts>
static std::shared_ptr<Widget> create(Ts&&... params);
…
void process(); // as before
…
private:
… // ctors
};
到现在,你也许仅仅朦胧的记得我们当初讨论控制块的动机是想了解关联了多个std::shared_ptr指针的消耗有多大。现在既然我们已经明白了如何避免太多的控制块,让我们回到最初的主题上。
一个控制块一般只有几个word大小,如果有定制删除器和定制分配器,尺寸会大一些。通常控制块的设计比你想的要复杂一些。它使用了继承,甚至虚函数(它用来保证被指向的对象能正确销毁)。这意味着使用std::shared_ptr会因为控制块使用了虚函数而引起一些机器开销。
知道了动态分配内存的控制块,任意大小的删除器和分配器,虚函数机制,引用计数的原子操作,你对使用std::shared_ptr的热情也许会有点下降。这很好,它们不是解决任意资源管理问题的最好方法。但对std::shared_ptr提供的功能来讲,它的消耗还是很合理的。典型情况下,使用默认的删除器,默认的分配器,并且std::shared_ptr是通过std::make_shared来创建的话,那么控制块只有大约3个word,并且器内存分配也是很方便的(被合并到其指向的对象的内存分配中了,见条款21)。解引用一个std::shared_ptr也不会比解引用一个原始指针更耗时。执行一个需要操纵引用计数的动作(比如拷贝构造,赋值构造,析构)会触发一两个原子操作,但这些操作影射到机器指令中,所以尽管比非原子操作指令昂贵一些,但也依旧是一个单指令。控制块中的虚拟函数机制仅仅在每个对象被析构时执行一次。
用这些相对适度的开销换来的是,你可以自动控制动态分配的资源的生存期管理。多数情况下,使用std::shared_ptr比你试图手工来管理一个有着共享所有权的对象的生存周期要更加方便。假如你觉得承受不了std::shared_ptr,重新考虑下你是否真的需要共享所有权。如果独享所有权足够或者可以做到,那std::unique_ptr的确是个更好的选择。其效率接近原始指针,而且从“std::unique_ptr”升级到“std::shared_ptr”很容易,因为一个std::shared_ptr可以通过一个std::unique_ptr来构造。
反之不行。你一旦决定把资源的生命周期管理交给std::shared_ptr,就不要改主意了。即使引用计数是1,你也不能重新声明资源为独享的,比方说有一个std::unque_ptr来管理它。 在一个资源和指向它的std::shared_ptr所有指针间,定下了“至死不分”的合约,永不分离!
此外std::shared_ptr不能做的工作是管理数组,这也是它和std::unique_ptr又一个不同之处。std::share_ptr的API是设计来管理单个对象的,没有std::shared_ptr<T[]>。不时的有一些“聪明”的程序员使用std::shared_ptr<T>来管理一个数组,然后用定制删除器来处理数组的释放(比如delete[])。这样可以通过编译,但却是个糟糕的主意。其中之一就是std::shared_ptr没有提供operator[],因此根据索引来操作数组需要使用笨拙的表达式通过指针来计算下标。另外std::shared_ptr提供继承类到基类的指针转换来给我们管理相同的对象的感觉。但是如果这个应用在数组上就不行了(为此,std::unique_ptr<T[]> 的API禁止了这样的转换)。更为重要的是,不去用c++内置的数组(std::array, std::vector,std::string),而去声明一个灵巧指针去指向一个数组,几乎都是不好的设计。
需要记住的事情:
1.std::shared_ptr提供了对共享所有权的任意资源的生命周期的很方便的管理。
2.和std::unique_ptr相比,std::shared_ptr尺寸增加了一倍,同时因为控制块增加了负担,并且需要原子操作的引用计数。
3.默认的资源释放是通过delete,但是也支持定制删除器。删除器的类型对std::shared_ptr本身的类型无影响。
4.避免从原始指针变量去构造std::shared_ptr。