Item 19: Use std::shared_ptr for shared-ownership resource management

利用std::shared_ptr访问的对象是通过共享所有权来管理对象生命周期的。没有特定的std::shared_ptr拥有该对象,相反,所有指向该对象的std::shared_ptrs通力协作,在确保不再需要该对象的时候销毁它。当最后一个指向对象的std::shared_ptr不再指向这个对象(比如,因为std::shared_ptr被销毁了或者指向了别的对象)std::shared_ptr会销毁它指向的对象。就像垃圾回收机制一样,客户不需要管理被指向的对象的生命周期了,但是和析构函数一样,对象的销毁的时间是确定的。

std::shared_ptr通过查看引用计数来确定自己是否是最后一个指向所维护的资源,引用计数是一个与维护的资源相关联的值,这个值用来记录指向该资源的std::share_ptr数量。std::shared_ptr的构造函数会递增引用计数(通常是这样的),析构函数会递减,而拷贝赋值运算符既会递增也会递减(假设sp1和sp2是指向不同对象的std::shared_ptr,赋值操作“sp1 = sp2;”会修改sp1,使其指向sp2所指向的对象。这会导致sp1之前指向的对象的引用计数递减,而sp2所指向对象的引用计数将递增),如果std::shared_ptr在执行递减操作后发现引用计数为零,那么std::shared_ptrs将不再指向该资源,这时std::shared_ptr将销毁该资源。

引用计数的存在会带来一些性能影响:

  • std::shared_ptr的大小是原始指针的两倍,因为它的内部包含一个指向资源的指针以及一个指向资源引用计数的指针【标准库并没有规定实现方式,但几乎每个标准库都是这么做的】;
  • 引用计数的内存必须动态分配。从概念上讲,引用计数和被指向的资源相关联,但是被指向的对象对此一无所知。被管理的对象肯定没有存储引用计数的位置(这里隐含一个令人愉快的提示:任何对象,即使是内建类型的对象都可以被std::shared_ptr管理)。Item 21会解释,当利用std::make_shared创建std::shared_ptr时,可以避免动态分配的开销,但也有一些情况下不能使用std::make_shared。不管怎样,引用计数都是存储在动态分配的数据中的;
  • 引用计数的增减必须是原子的,因为会涉及到多线程的读写问题。原子操作通常比非原子操作慢一些,所以即使引用计数只有一个字的大小,也应该假设对它们的读写是相对耗时的;

前面提到过,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::unique_ptr类似,std::shared_ptr利用delete操作符作为默认的资源析构机制,但也支持自定义的析构器。不同的是,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>,各有一个不同类型的自定义析构器:

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::unique_ptr无法做到的。
另一个不同是,采用用户自定义的析构器不会改变std::shared_ptr对象的大小,无论是什么样的析构器,std::shared_ptr都是两个指针的大小(即指针的二倍)。自定义deleter可以是函数对象,而函数对象可以包含任意数量的数据。这意味着它们可以任意大。那么,一个std::shared_ptr如何引用任意大小的删除器而不使用更多的内存?
答案是不能。它一定会使用更多的内存。但是这些内存并不是std::shared_ptr的一部分。它要么在堆上,要么在std::shared_ptr自定义的分配器所维护的内存上。前面曾提到过,每个std::shared_ptr对象都包含一个指向引用计数的指针,但这种说法并不严谨,因为引用计数其实是一个更大数据结构中的一部分,这个数据结构被称为控制块(control block)。每个由std::shared_ptrs管理的对象都有一个对应的控制块。除了引用计数,control block还包含用户自定义的deleter的一个副本,如果自定义的deleter被指定的话。同样,如果用户指定了自定义的分配器,control block也会包含自定义分配器的一个副本。除此之外,control block也可能会包含Item 21中将要提到的弱引用计数。我们可以想象与std::shared_ptr对象相关的内存如下:
在这里插入图片描述
对象的control block是通过为对象创建第一个std::shared_ptr的函数来设置的。至少应该是这样运作的。一般来说,创建std::shared_ptr对象的函数不可能知道其他std::shared_ptr是否已经指向该对象,因此control block的创建遵循了以下规则:

  • std::make_shared (see Item 21)总是创建一个control block。 它创建一个新的对象,所以当std::make_shared被调用的时候,这个对象肯定没有control block;
  • 通过std::unique_ptr构建一个std::shared_ptr时,会创建一个control block。
  • 当使用原始指针调用std::shared_ptr构造函数时,它会创建一个控制块。如果想从一个已经包含control block的对象中创建一个std::shared_ptr,你可能会传递一个std::shared_ptr或std::weak_ptr(Item 20)作为构造函数参数,而不是一个原始指针。
    将Std::shared_ptrs或Std::weak_ptrs作为构造函数参数的Std::shared_ptr构造函数不会创建新的control block,因为它们可以依赖传递给它们的智能指针来指向任何所需的control block;

这些规则导致的一个结果就是:用一个原始指针来构造超过一个的std::shared_ptr对象会让你免费坐上通往未定义行为的列车。因为被管理的对象会拥有多个控制块,多个控制块意味着多个引用计数,多个引用计数又意味着对象会被销毁多次(一个引用计数一次)。也就是说,像下面这样的代码是非常糟糕的:

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是不好的,因为它违背了本章背后的建议:“更多的使用智能指针而不是原始指针。”
现在,spw1的构造函数是用一个原始指针调用的,因此它为所指向的对象创建一个控制块(以及引用计数)。在本例中,它是*pw
(即pw所指向的对象)。这本身没有问题,但是spw2的构造函数是用相同的原始指针调用的,因此它也为*pw创建了一个控制块。此时,*pw有两个引用计数,每一个都将最终变为零,这将最终导致*pw被两次析构。而第二次析构将引发未定义行为。

至此,关于std::shared_ptr的使用,有两条经验教训:1,避免通过一个原始指针直接构建std::shared_ptr。最好用std::make_shared (Item 21)替代,但在上面的例子中,由于应用了自定义的deleter,就无法使用std::make_shared了。2,如果不得不将原始指针传递给std::shared_ptr的构造函数,则直接传递new的结果,而非传递一个原始指针变量。如果上面的第一部分代码被这样重写:

std::shared_ptr<Widget> spw1(new Widget, loggingDel); // direct use of new

这样就大大降低利用相同原始指针构建第二个std::shared_ptr的可能了。取而代之的是,创建spw2代码的作者自然会使用spw1作为初始化参数(即调用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。这段代码是可以通过编译的,但是传递了一个原始指针(this)给std::shared_ptr的容器,由此构造的std::shared_ptr将为指向的Widget(*this)创建一个新的control block。这看起来没什么问题,直到你意识到,如果在成员函数之外已经有std::shared_ptrs指向那个Widget,这会可能会导致未定义行为。

std::shared_ptr的API为上述场景提供了解决方案: std::enable_shared_from_this。如果你想让std::shared_ptrs管理的类能够安全地从this指针创建std::shared_ptr,那么它就是你应该继承的一个模板基类。在我们的例子中,代码应该如下:

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()
{
    // as before, process the Widget// add std::shared_ptr to current object to processedWidgets
    processedWidgets.emplace_back(shared_from_this());
}

在内部,shared_from_this查询当前对象的控制块,并创建一个指向该控制块的新std::shared_ptr。这样的设计依赖于当前对象已有一个与其关联的控制块。为了实现这一点,就必须有一个已存在的指向当前对象的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:
    // factory function that perfect-forwards args
    // to a private ctor
    template<typename... Ts>
    static std::shared_ptr<Widget> create(Ts&&... params);void process(); // as beforeprivate:// ctors
};

关于控制块的讨论是为了理解std::shared_ptr创建时相关的成本引起的。现在,我们已经了解了如何避免创建过多的控制块,那么就让我们回归最初的话题吧。

控制块的大小通常只有几个字,尽管自定义删除器和分配器可能使其更大。实际的control block的实现要比想象中的复杂,它用到了继承,甚至虚函数技术(为了确保所指的对象被适当的析构)。这意味着,使用std::shared_ptr也会有一部分调用虚函数的成本。

在了解了动态分配控制块、任意大的删除器和分配器、虚函数机制和原子引用计数操作之后,你对std::shared_ptrs的热情可能会有所消减。这没关系。它们并不是所有资源管理问题的最佳解决方案。但对于其提供的功能,std::shared_ptr要求的成本非常合理。典型情况下,使用默认的删除程序和默认的分配器,并且std::shared_ptr是由std::make_shared创建的,控制块的大小只有三个字,它的分配基本上没有任何成本(这些成本被并入到所指向的对象的内存分配中去了。更多细节,参见Item 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管理,就不能改变主意了。即使引用计数是1,也不能为了让std::unique_ptr管理它而收回资源的所有权。std::shared_ptr和资源之间的所有权契约指出,它是“死前永远在一起”型的,没有分离,没有取消,没有分配。

另一些std::shared_ptr不能做的事情,就包括处理数组。std::shared_ptr与std::unique_ptr的另一个区别是,std::shared_ptr的API被用来处理指向单个对象的指针。并没有所谓的std::shared_ptr<T[]>。有时“聪明”的程序员会使用std::shared_ptr指向一个数组,并通过指定一个自定义的deleter来完成数组的删除操作(即delete[])。这种做法可以通过编译,但却是个糟糕的主意。一方面,std::shared_ptr并没有提供operator[],如果想要取得数组的下标,就得写一个很笨拙的基于指针运算的表达式。另一方面,std::shared_ptr支持从派生类到基类的指针类型转换,这对单个对象而言是有意义的,但应用到数组上,就会出现漏洞(因此,std::unique_ptr<T[]> 的API禁止类型转换)。更重要的是,c++ 11已经对内置数组提供了多个选择(例如,std::array, std::vector, std::string), 如果还用智能指针去指向一个非智能的数组,那说明设计就有问题。

Things to Remember

  • std::shared_ptr提供方便的手段,实现了任意资源在共享所有权语义下进行生命周期管理的垃圾回收;
  • 与std::unique_ptr相比,std::shared_ptr的大小通常是原始指针的两倍,它会带来control block的开销,需要引用计数原子化的操作;
  • 默认的资源析构是通过delete运算符进行,但也支持自定义的deleter。deleter的类型不影响std::shared_ptr的类型;
  • 避免通过原始指针变量来创建std::shared_ptr;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值