Effective Modern C++ 条款19 用std::shared_ptr管理共享所有权的资源

用std::shared_ptr管理共享所有权的资源

那些用带垃圾回收器语言的程序员指出并取笑C++程序员还要去防止内存泄漏。MDZZ,他们笑道,“你没有从上世纪六十年代的Listp中得到启示录吗?管理资源的应该是机器本身,而不是程序员。”C++开发者不以为然,“你指的启示录是——只有内存算是资源和不确定的资源回收时间吗?我们更喜欢析构函数的概述性和可预测性,谢谢。”不过我们吹牛有点过了,垃圾回收器是挺方便的,相比之下手动管理资源生命期就像是用石刀和熊毛片制作记忆存储器电路。为什么我们不得同时得到鱼和熊掌呢:系统即可以自动工作(就像垃圾回收器),又可预知资源回收的时间(就像析构函数)。

在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可以告诉我们:它是否是最后一个指向该资源的指针,这是通过咨询资源的引用计数(reference count)——一个负责监控有多少std::shared_ptr指向资源的值。std::shared_ptr的构造函数会增加这个计数(通常会,看下面的内容),拷贝赋值运算也会。(如果shared_ptr对象sp1和sp2指向不同的对象,那么赋值语句“sp1 = sp2”会导致sp1指向sp2指向的对象。赋值语句还会导致sp1原来指向的对象的引用计数减一,sp2指向的对象的引用计数加一。)如果一个std::shared_ptr在递减后发现引用计数为0,意味着没有其他的std::shared_ptr指向这资源,所以销毁资源。

引用计数是默默工作的:

  • std::shared_ptr的大小是原生指针的两倍,因为它包含一个指向资源的原生指针,还有资源的引用计数。
  • 引用计数所用的内存一定是动态分配的。概念上,引用计数是与指向的对象相关联的,但是指向的对象对引用计数一无所知,因此它们没有存储引用计数的地方。(让人愉快的是一些内置类型也可以用std::shared_ptr管理。)条款21会说明用std::make_shared创建std::shared_ptr可以避免动态分配的开销,不过有些情况下std::make_shared是不能用的。总之,引用计数是用动态分配的数据。
  • 增加和减少引用计数一定是原子操作,因为在不同的线程中会同时存在读者和写者。例如,在一个线程中,std::shared_ptr正在析构(因此会减少引用计数),同时在另一个线程,指向相同资源的std::shared_ptr正在被拷贝(因此后增加引用计数)。原子操作通常会比非原子操作慢,所以尽管引用计数通常只有一字(word)的尺寸,你也应该认定读写引用计数是比较昂贵的。

当我说std::shared_ptr的构造函数“通常”会增加引用计数时,有激起你的好奇心吗?创建一个指向某对象的std::shared_ptr,总是会产生多一个指向该对象std::shared_pt的啊,那为什么不是总是增加引用计数呢?

移动构造,这就是原因。用一个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::unique_ptr不同。对于std::unique_ptr,自定义删除器的类型是智能指针类型的一部分,但对于std::shared_pt,却不是这样:

auto loggingDel = [](Widget *pw)
                  {
                      makeLogEntry(pw);
                      delete pw;
                  };
                           `
std::unique_ptr<Widget, decltype(loggingDel)>            // 删除器的类型是
                     upw(new Widget, loggingDel);     // 指针类型的一部分

std::shared_ptr<Widget>              // 删除器的类型不是指针类型的一部分
            spw(new Wiget, loggingDel); 

std::shared_ptr的设计更加灵活。想象一下我们有两个std::shared_ptr<Widget>,而指针的删除器的类型不同:

auto customDelete1 = [](Widget *pw) { ... };   // 自定义删除器
auto customDelete2 = [](Widget *pw) { ... };  // 两个类型不同

std::shared_ptr<Widget> pw1(new Widget, customDelete1);
std::shared_ptr<Widget> pw2(new Widget, customDelete2);

因为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(控制块)。每个shared_ptr管理的对象都有一个控制块,这个控制卡除了包含引用计数外,还有一份自定义删除器的拷贝(如果指定的话)。如果指定了自定义分配器,控制卡也包含它的拷贝。控制块可能还有其他数据——就像条款21解释那样,间接引用计数(作为弱引用),不过在本条款,我们先忽视这数据。我们可以将std::shared_ptr<T>管理的内存视图化,就像这样:

这里写图片描述

当指向某对象的第一个std::shared_ptr创建时,该对象的控制块就建立的。至少我们可以这样假定。通常情况下,创建std::shared_ptr的函数不可能知道是否已经有其它的std::shared_ptr指向该对象,所以控制块创建要服从以下规则:

  • std::make_shared(看条款21)总是会创建控制块。它是加工一个刚new出来的对象,所以这个对象一定不会有控制块。
  • std::shared_ptr由独占所有权指针(即std::unique_ptrstd::auto_ptr)构造时,控制块会被创建。独占所有权的指针不会使用控制块,所以它指向的对象应该没有控制块。(这种构造函数呢,std::shared_ptr会承担指向对象的所有权,然后独占所有权指针被设置为空。)
  • 当以原生指针为参数调用std::shared_ptr的构造函数时,会创建控制块。如果你想从已有控制块的对象创建一个std::shared_ptr,你可能要传递一个std::shared_ptrstd::weak_ptr(看条款20)作为构造函数的参数,而不是原生指针。接受std::shared_ptrstd::weak_ptr为参数的构造函数不会创建新的控制块,因为它们可以依赖于传进来的智能指针的控制块。

这样的规则会导致一个问题:由单一的原生指针构造std::shared_ptr构造多次会导致未定义行为,因为指向的对象会有多个控制块。多个控制块意味着多个引用计数,多个引用计数意味着多次被销毁。那意味着像下面这样的代码是糟糕的,很糟糕的,非常糟糕的:

auto pw = new Widget;   // pw 是原生指针
...
std::shared_ptr<Widget> spw1(pw, loggingDel);  // 为*pw创建控制块
...
std::shared_ptr<Widget> spw2(pw, loggingDel); //  为*pw创建第二个控制块

通过指向动态分配的对象的原生指针创建std::shared_ptr是糟糕的,因为它与本章的建议背道而驰了:用智能指针代替原生指针。不过先把它放到一边,创建原生指针pw只是一种文体上的憎恶,但至少不会导致未定义行为。

现在呢,spw1以原生指针进行构造,因为它为指向对象创建了一个控制块(也因此有一份引用计数),在这例子中,它指向的对象是*pw。就其本身而言,这是ok的,但spw2以相同的原生指针进行构造,它也为*pw创建了一个控制块(和一份引用计数)。因此*pw有两份引用计数,两份最终都会变成0,这也最终造成了销毁*pw两次,第二次销毁就会造成未定义行为。

关于std::shared_ptr的使用至少两点要讲。第一,避免用原生指针构造std::shared_ptr。通常的选择是使用std::make_shared(看条款21),但在某些例子中,我们需要用自定义删除器,这时没办法使用make_shared。第二,如果你一定要用原生指针构造std::shared_ptr,那么直接把new出来的结果传递过去,而不是传递原生指针变量。如果第一部分的代码是这样写的:
std::shared_ptr<Widget> spw1(new Widget, loggingDel); // 直接用new

这就让用相同的原生指针创建第二个std::shared_ptr变得不那么诱人。与之代替的是,代码的作者会很自然的用spw1来初始化spw2(调用的是std::shared_ptr的拷贝构造),这样就没什么问题了:
std::shared_ptr<Widget> spw2(spw1); // spw2和spw1使用相同的控制块

令人特别惊讶的是使用原生指针变量作为std::shared_ptr的构造函数的参数,会导致这个指针涉及多个控制块。假如我们的程序使用std::shared_ptr管理Widget对象,然后我们有个数据结构来记录已被加工的Widget对象:
std::vector<std::shared_ptr<Widget>> processdWidgets;

进一步假设Widget有个成员函数来进行加工:

class Widget {
public:
    ...
    void process();
    ...
};

这里的Widget::process实现看起来情有可原:

void Widget::process()
{
    ...                   // 加工Widget
    processedWidgets.emplace_back(this);  // 把该对象添加到已加工链表中                                                                                   // 这样做是错的
}

注释已经说明了一切,或者说明了大部分。(错的部分是传递this,而不是使用emplace_back,如果你不熟悉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();
    ...
};

就像我说的那样,std::enable_shared_from_this是一个基类模板,它的类型参数总是继承它的类的名字(即派生类的名字),所以Widget继承std::enable_shared_from_this。如果派生类继承用派生类特例化的基类这个想法让你心疼,那么不要去想伤心事了。这代码是完全合法的,背后的设计模式也是被大家接收的,它有个标准名字,虽然是个和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指向对象相同的std::shared_ptr时,你就可以使用它了。这里是Widget::process的安全实现:

void Widget::process()
{
     // 像以前一样,加工Widget 
    ...  
     // 把指向当前对象的shared_ptr加入processedWidgets
    processedWidgets.emplace_back(shared_from_this());
};

本质上,shared_from_this通过查看当前对象的控制块,然后参考那个控制块创建一个新的std::shared_ptr。这个设计取决于当前对象已经有个关联的控制块,对于这种状况,一定要有个std::shared_ptr(例如,在调用shared_from_this的成员函数外面)指向当前对象。如果没有这样的std::shared_ptr存在(即当前对象没有关联的控制块),行为是未定义的,即使shared_from_this通常会抛异常。

为了防止成员函数使用shared_from_this之前没有std::shared_ptr指向当前对象,继承std::enable_shared_from_this的类常常把构造函数私有,然后让用户调用返回类型是std::shared_ptr工的厂函数,从而创建对象。例如,Widget的代码是这样的:

class Widget : public std::enable_shared_from_this<Widget> {
public:
    // 工厂函数把参数完美转发给私有的构造函数
    template<typename... Ts>
    static std::shared_ptr<Widget> create(Ts&&... params);
    ...
    void process();    // 如前
    ...
private:
    ...      // 构造函数`
};

现在呢,你可能只是隐约想起关于控制块的讨论是——因我们要理解std::shared_ptr关联控制块的开销而引起的。既然我们已经知道如何避免创建多个控制块了,之后我们就回归主题吧。

一个控制块通常只有几个字(word)的大小,即使自定义删除器和分配器会让它变大。通常控制块的实现远比你想象中复杂。然后它还有虚函数,(这用来确保指向的对象使用合适的析构,即使用默认还是自定义)那意味着std::shared_ptr使用的控制块也要承担虚函数装置的开销。

知道了动态分配的控制块、任意大的删除器和分配、虚函数的装置、原子操作的引用计数后, 你对std::shared_ptr的热情可能在某种程度上衰落了。这是正常的,它们不是解决资源管理问题的最好方法,但鉴于它提供的功能,std::shared_ptr要求的开销是情有可原的。典型情况下,std::shared_ptr是通过std::make_shared创建的,使用的是默认的删除器和默认的分配器,控制块的大小只有两道三个字(word),然后动态分配的开销基本没有。(这包含分配给指向对象的内存,具体细节看条款21。)解引用一个std::shared_ptr的开销不比解引用原生指针大,引用计数操作(例如,拷贝构造,拷贝赋值,析构)涉及到一或两个原子操作,这些操作通常会转化为一条机器指令,所以尽管它们可能比非原子指令开销大,但是它们依旧是单一机器指令。通常情况下,管理对象的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管理资源的生命期,尽管引用计数是一,你也不能收回资源的所有权,然后用std::unique_ptr来管理资源。std::shared_ptr和它指向的资源之间的所有权协议是——到死都要一起,不分离,不废除,不分配。(意思是不能用赋值的方法来把shared_ptr转换为unique_ptr?)

还有点东西,就是std::shared_ptr不能用于数组。与std::unique_ptr不同,std::shared_ptr的API只为单独的对象设计,没有std::shared_ptr<T[]>。偶尔,一些“聪明”的开发者无意中发现可以使用一个std::shared_ptr<T>指向数组,然后指定自定义删除器(delete[])。这样是可以通过编译的,但这是个可怕的想法,首先,std::shared_ptr没有提供operator[]成员函数,所以索引数组的值需要尴尬的指针算数表达式。其次呢,std::shared_ptr对于单独的对象,支持派生类到基类的指针转换,那是数组却不行。(因为这个原因,std::unique_ptr<T[]>的API禁止这样的转换。)最重要的是,C++11中,那么多种替代内置数组的选择(例如,std::arraystd::vectorstd::string),声明一个指向原生数组的智能指针几乎一定是烂设计的表现。


总结

需要记住的4点:

  • std::shared_ptr提供了一种方便的垃圾回收方法,针对于任意的共享生命期的资源。
  • std::unique_ptr相比,std::shared_ptr对象通常是它的两倍大,需要控制块和原子操作的引用计数。
  • 默认销毁资源的方式是delete,但支持自定义删除器。删除器的类型不影响std::shared_ptr的类型。
  • 避免用原生指针变量来创建std::shared_ptr
阅读更多

没有更多推荐了,返回首页