条款19使用shared_ptr管理共享所有权的资源

使用带垃圾回收语言的编程者会笑话c++程序员小心谨慎防止内存泄漏。“真原始” 他们笑道,“你们不看一下1960年Lisp的备忘录吗?应该机器而不是人类去管理资源“ “你说的备忘录里唯一的资源是内存而且释放的时间不确定。我们喜欢更通用以及可预测的析构函数,谢谢你“ 但是垃圾回收语言真的很方便,管理资源生命周期很像用石刀和兽皮去建造一座记忆内存电路。为什么不能同时集中两种优点自动管理(像垃圾收集器)以及可预测的存活时间像(析构函数)?

   在c++11使用 std::shared_ptr完成这些绑定。通过shared_ptr访问的对象,生命周期由共享所有权模块来管理。没有一个shared_ptr拥有对象。当对象不再使用时,所有指向对象的shared_ptr协商完成对象的释放。当对象的最后一个shared_ptr不再指向对象(析构或者指向了另外一个对象),shared_ptr销毁指向的对象。如同垃圾回收机制,用户不用自己操心去管理对象的生命周期。也如同析构函数,对象销毁时间是确定的。

     访问引用计数,一个shared_ptr可以告诉你它是否是最后一个指向对象的智能指针。引用计数是记录了关联资源的shared_ptr数目。shared_ptr的构造函数可以增加计数(通常下面形式),析构函数会减少计数,拷贝和赋值构造函数会增加减少两样都做。(比如sp1=sp2, 此时修改sp1指向sp2关联的对象,sp1之前的引用计数会减少,而sp2的引用计数会增加)。当shared_ptr做完减1操作后,发现引用计数为0,那么将销毁关联的对象。

    引用计数的存在,可能会在下面方面影响到效率:

shared_ptr大小为原始指针的两倍。这是因为其内部包含了两个指针,一个指向了资源对象,一个指向了引用计数对象。

引用计数对象管理的内存必须动态申请。从概念上讲,引用计数关联指向的对象,但所指向的对象对此一无所知。对象也不会为此准备额外空间(这令人愉快的意味着固有类型也可以由shared_ptr来管理)条款21解释了使用make_shared可以避免创建shared_ptr动态分配带来的开销.。但也有一些场景无法使用make_shared。任何一种创建过程中,引用计数都是保存在动态分配的内存中。

增加引用计数必须是原子性操作,因为会有读写操作在不同的线程里同时发生。比如一个线程里发生shared_ptr的析构,(引用计数减1),而此时在另外一个线程里指向同一个对象的shared_ptr可能正在被拷贝(增加同一个引用计数)。原子操作通常比较慢,即使读写一个字的大小,可以认为开销比较大。

当我说shared_ptr只是通常会增加引用计数时,有没有激发你的好奇心?既然创建一个shared_ptr总是使得另外一个shared_ptr指向对象,那么为什么不总是增加引用计数呢?

因为存在move操作。 通过mvoe,从一个shared_ptr构建另外一个shared_ptr的时候,会将源shared_ptr置空。意思是新的shared_ptr一指向了资源后老shared_ptr马上就不再指向该资源了。结果是没有引用计数的增减。move操作要比拷贝操作快,因为拷贝需要改变引用计数,而move不需要。这在拷贝和赋值构造都成立,所以move构造和move赋值,要快于拷贝构造和拷贝赋值。

   和unique_ptr一样, shared_ptr默认使用delete删除资源,也支持用户自定义删除器。但是这种设计却不同于unique_ptr.

对于unique_ptr,删除器的类型是智能指针类型的一部分。对于shared_ptr 不是这样的:

auto loggingDel = [](Widget *pw) {

    makeLogEntry(pw);

    delete pw;

}

std::unique_ptr<Widget,decltype(loggingDel)>

      upw(new Widget(), loggingDel);   //删除器类型是ptr类型一部分

 

std::shared_ptr<Widget>

    spw(new Widget(), loggingDel); // 删除器类型不是ptr类型一部分

shared_ptr的设计更为灵活。考虑两个shared_ptr<Widget> ,各自关联一种类型的删除器(通过lamda表达式定义)。

auto customDeleter1 = [](Widget*wd) { ...}         //每一个类型都不同

auto customDeleter2 = [](Widget*wd) { ...}

 

std::shared_ptr<Widget> pw1(new Widget, customDeleter1)

std::shared_ptr<Widget> pw2(new Widget, customDeleter2)

因为pw1 和pw2 类型相同,故可以放到同一个容器中。

std::vector<std::shaared_ptr<Widget>>  vpw{pw1, pw2}

 

它们可以互相赋值,也可以被std::shared<Widget> 类型参数的函数所调用。这些都不适用于删除器类型不同的unique_ptr,因为删除器类型会影响到unique_ptr的类型。

另外一个区别是定义一个删除器并不额外增加shared_ptr占用空间。不管有没有自定义删除器,shared_ptr都是2个指针大小。这是好消息,但可能会让你困惑。用户删除器可以是函数指针和函数对象,而函数对象是大小不定的。这意味着函数对象可以任意大,但为什么关联一个任意大小的删除器的shared_ptr没有使用更多的空间呢?

它做不到。它必须要使用更多内存空间。只是这个内存不是shared_ptr对象一部分。这个内存在堆上,如果shared_ptr使用用户分配器,则可以在分配器指定的任何位置。我早先提过shared_ptr有一个指向引用计数的指针。这是对的,但有一点误导,因为引用计数对象只是一个更大的叫做控制块的一部分。每个shared_ptr都包含一个控制块,控制块则包含用引用计数,如果定义了用户删除器,也包含一个用户删除器的拷贝。控制块也可能包含一些额外数据,比如像条款21介绍的称为弱引用的第二个引用计数,现在先忽略这些。shared_ptr 相关的内存示意图如下:

创建第一个shared_ptr指向对象时,控制块就已经创建了。这至少时预期发生的。通常来说,用一个对象来创建shared_ptr

时不可能知道是否有别的shared_ptr已经指向了该对象。所以应考虑下面关于控制块的行为:

std::make_shared总是新创建一个控制块来管理对象。所以调用make_shared时候没有控制块。

从unque_ptr来构造shared_ptr时候,也会产生控制块。因为unique_ptr不使用控制块,它也就没有控制块。(构建中,shared_ptr接管对象所有权,而unique_ptr会被置空。)

当从原始指针来创建shared_ptr时,生成一个控制块。当你想从一个已经有控制块的对象来创建shared_ptr时候,最好使用shared_prt或者weak_ptr作为构造函数参数,而不是传递原始指针。shared_ptr或者weak_ptr作为shared_ptr构造函数的参数时,不睡再创建一个控制块,因为可以依靠传入的shared_ptr或者weak_ptr来指向所需的控制块。

这些规则的结果是:如果你从单一原始指针创建多个shared_ptr,那么你就上了未定义行为这个粒子加速器的免费车了。这是因为这个被指向的对象关联多个控制块。多个控制块意味着多个引用计数,而多个引用计数意味着对象会销毁多次。(每个引用计数一次)也就是说这样的代码是坏的,坏的,坏的:

auto pw = new Widget

std::shared_ptr<Widget> pw1(pw, loggingDel);

std::shared_ptr<Widget> pw2(pw, loggingDel);

创建原始指针pw指向动态创建的对象是坏的。因为这违背了本章的忠告:使用智能指针而不用原始指针(如果你忘了动机,回到115页)先不管它。创建pw的那行代码令人生厌,但至少它不导致程序的未定义行为。

现在创建sp1的构造函数为指向对象创建了控制块(以及引用计数),在这个例子里对象指的是*pw(pw所指对象)。这没问题,但创建spw2时,使用了相同的原始指针,就又创建了一个控制块。*pw有了两个引用计数,它们最终都会导致释放两次*pw。第二次的释放导致了未定义行为。

从这里shared_ptr的使用中,至少学到两条教训。第一避免向shared_ptr构造函数传递原始指针,通常更好的方式是使用std::make_shared, 但在上面例子中我们使用了自定义删除器,所以除了向构造函数传递原始指针外别无选择。第二如果必须传递给构造函数传递原始指针,直接传递new的结果,而不是另外定义一个变量再传递。代码可以这样写:

std::shared_ptr<Widget> pw1(new Widget, loggingDel);

从相同的原始指针,再创建一个shared_ptr太没有吸引力了。相反,应该使用pw1作为参数传递给pw2的构造函数(会调用shared_ptr的拷贝构造函数。这样就没问题了:

std::shared_ptr<Widget> pw2(pw1); // pw2使用和pw1相同的控制块

还有一种特殊奇怪的this指针使用,向shared_ptr构造函数传递原始指针导致多个控制块问题。设想为了管理很多Widget对象,我们的程序定义了一个数据结构来保存处理的Widget:

std::vector<std::shared_ptr<Widget>> processedWidgets;

假设Widget有一个成员函数来完成处理工作:

class Widget {

  public:

    void process();

     ...

};

 

下面是一种合理的处理方法:

void Widget::process() {

...                                                       //处理过程

processedWidgets.emplace_back(this); // 添加到完成列表中,这是错误的!

}

注释说错了是全错,至少大部分错了。(错是因为传递this,而不是使用了emplace_back, 如果不熟悉emplace_back用法看条款。42)代码可以编译,但向shared_ptr容器传递了原始指针,shared

_ptr就又为关联的对象创建了一个控制块。你觉得这是没有害处的,直到你意识到在成员函数之外,有已经指向该对象的指针。这又成为了未定义行为的游乐场。

为处理这种情况,shared_ptrAPI提供了一种便利手段。它可能是标准库里最奇怪的名字std::enable_shared_from_this. 它是一个基类模板,继承这个类,你就可以安全的从this创建一个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<Widget>继承. 如果派生类从基类继承又特化到派生类让你头大,那就先不要去想它。这是完全合法的。背后的设计模式有一个标准的名字,这个名字几乎和std::enable_shared_from_this一样古怪,它的名字是:奇异递归模式。如果你想了解更多,就放开你的搜索引擎,因为我们此刻要回到std::enable_shared_form_this.

std::enable_shared_form_this定义了一个成员函数shared_from_this,这个函数可以从当前对象创建一个shared_ptr又不会复制控制块。你可以在你想从this创建一个shared_ptr的任何地方调用它。下面是一个安全的Widget::process实现。

void Widget::process {

     //像之前一样处理Widget

     processedWidgets.emplace_back(shared_from_this());

};

在内部实现上,shared_from_this会找到当前对象的控制块,创建一个shared_ptr与控制块关联。这种设计依赖于存在一个控制块与当前对象关联。为了如此,就必须在函数外部存在一个shared_ptr指向对象。如果不存在一个这样的shared _ptr,(当前对戏那个不关联控制块),那么shared_from_this行为是未定义的,通常会抛一个异常。

为了阻止客户在创建shared_ptr指向对象之前,就在对象成员函数中调用shared_from_this, 继承std::enable_shared_from_tjhis的类的构造函数都被声明为私有的,只能从工厂方法中返回shared_ptr<Widget>.例如可以这样:

class Widget : public std::enable_shared_from_this<Widget> {

  public:

      //工厂方法,完美向私有构造函数转发参数

     template<typename ... Ts>

         static std::shared_ptr<Widget> create(Ts && ...param) ;

         void process();

     private:

        ...                // ctors

}

 

到现在,你可能会模糊的记起,我们是为了讨论shared_ptr的开销,而开始讨论控制块。为了避免创建太多的控制块,我们回到最初话题。

一个典型的控制块只有几个字,在用户自定义删除器和分配器情况下,会增大一些。控制器的实现一般比你预想的复杂一些。它涌动啊了继承,甚至用到了一个虚函数(只是确保指向对象正确删除)。这意味着使用shared_ptr也要承担控制块引入的虚函数的开销。

 

读了动态创建控制块,不确定大小的删除器和分配器,虚函数机制以及原子操作的引用计数器,你对shared_ptr的热情会减退一些。这很好,因为shared_ptr并不是在所有的资源管理问题中都是最佳选择。但相对其提供的功能而言,shared_ptr带来的开销是合理的。在典型的应用中,shared_ptr是通过make_shared完成创建。控制块只有3个字,分配器基本不占用空间(它并入了对象分配器见条款21)对一个shared_ptr解引用开销不比对原始指针的大。对引用计数的访问(拷贝,赋值和析构函数)需要1-2个院子操作。而这些原子操作通常映射成单独机器指令。所以尽管与非原子操作比会昂贵一些,但它们仍然是简单的指令。而虚函数只有在管理的对象析构时才会被调用。

拿这些适当的开销,你交换到了对动态创建对象生命周期的自动管理。多数情况下,使用shared_ptr来管理存在共享所有权对象的生命周期比手工管理更为可取。当你为这些开销而犹豫是否使用shared_ptr,你首先应该再考虑下是否真的需要共享所有权。如果排他的所有权可以或者可能可以,那么unique_ptr会是更好的选择。它的表现几乎和原始指针一样好,并且升级到shared_ptr也十分的方便, 因为可以从unique_ptr创建shared_ptr。

但是反过来可不行,一旦你使用shared_ptr管理资源生命周期,就最好不好改变主意。即使引用计数到了1,你也无法将所有权转给一个unique_ptr。资源和哪些指向它的shared_ptr的合约是一种到死才分开的性质。没有离婚,没有豁免,没有特许。

shared_ptr也无法处理数组相关的工作。与unique_ptr不同的是shared_ptr的API都是处理单个对象的。没有shared_ptr<T[]>.

时间久了,“聪明”的编程者可能会使用shared_ptr<T> 来指向一个数组,并且自己定义删除器来完成数组释放。这样做编译通过,但却是个可怕念头。一方面shared_ptr没有[]操作符,因此索引数组就需要基于指针运算的笨拙的表达式。另外一方面shared_ptr提供的派生类到基类转换只对单个对象有意义。当用于数组时,就给类型系统打了个洞。(基于这个原因,unique_ptr<T[]> 禁止这样的转换)更重要的是,c++11提供了大量固有的数组(array,vector, string),使用智能指针指向一个愚蠢的数组就不是好的做法。

注意事项:

shared_ptr为共享所有权对象生命周期管理提供了垃圾回收功能。

与unique_ptr相比,shared_ptr通常占用了2倍的大小空间。在控制块和原子操作方面会有额外开销。

默认的删除器是delete,但允许用户自定义删除器。删除器类型和shared_ptr类型无关。

避免从原始指针创建shared_ptr

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值