Std::shared_ptr
本文最后更新于:April 20, 2022 am
本篇为智能指针系列第二篇,介绍std::shared_ptr
为了便于理解,阅读者最好对模板编程有一定的了解
系列导航:智能指针
shared_ptr:共享所有权
使用有垃圾回收机制的语言编程的开发者有时嘲笑C++
开发者因为极力避免资源泄露所经历的痛苦。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
实例共同维护一个叫做引用计数reference count
的变量,这个变量记录了当前有多少个std::shared_ptr
实例指向此资源。
std::shared_ptr
的构造器一般情况下会让引用计数+1,析构器则是让引用计数-1,拷贝复制运算符则两者都做(sp1 = sp2
会引起左侧原先指向的资源的引用计数减少,右侧指向的资源的引用计数增加)。
因此,一旦在一次递减操作之后,std::shared_ptr
实例发现引用计数减为0了,也就意味着没有其它std::shared_ptr
实例指向这个资源了,因此也就到了释放资源的时刻。
引用计数的存在表明:
std::shared_ptr
实例的大小至少是裸指针的两倍,因为引用计数是也是通过指针维护。[1]引用计数的变量必须在堆上。
从概念上来讲,引用计数和在堆上的资源不应该被关联在一起吗?是的,但问题是,被指向的资源对引用计数的存在和作用是一无所知的,同时被指向的资源也没有多余的空间来存这个变量。
虽然这比较令人头疼,但是这也意味着任何原始数据类型也都可以被
std::shared_ptr
所管理,比如int
。Item21表明,当使用std::make_shared
创建std::shared_ptr
实例时,可以避免因为动态分配内存导致的代价,但仍有无法使用std::make_shared
函数的情况。对于引用计数的递增和递减操作必须是原子的。
对于一个被管理的资源而言,可能在线程A中
std::shared_ptr
实例正在执行析构,发试图将引用计数减少1,而在线程B中被执行了拷贝构造。两个线程引起对引用计数的竞争条件。因此,即使引用计数不过一个字的大小,也应该认为对于其的读写是代价较高的。
上文也提到,std::shared_ptr
的构造器也只是通常引起引用计数的自增。有一个例外——移动构造。当我们将一个std::shared_ptr
实例移动到另一个实例时,我们会让左侧的std::shared_ptr
实例指向堆上的资源,而将右侧的std::shared_ptr
实例置空,因此不需要修改引用计数。减少了两次引用计数访问也使得移动构造比拷贝构造更快。显然,移动赋值也会比拷贝复制更快。
自定义deleter
和std::unique_ptr
类似,std::shared_ptr
也使用delete
操作符作为默认的deleter
与std::unique_ptr的不同
虽然std::shared_ptr
也支持自定义deleter
,但是它的设计和std::unique_ptr
是不一样的。对于std::unique_ptr
而言,deleter
的类别是std::unique_ptr
实例自身的一部分——模板实例化时,deleter
自身的类别作为了实例化参数的一部分——而对于std::shared_ptr
则不是,请看下面一段代码:
1 |
|
显然,std::shared_ptr
的设计更灵活。考虑有两个std::shared_ptr
实例,它们管理同一类型的资源,但是构造时,传入了两个内容不同的lamda
表达式。
1 |
|
因为pw1和pw2的类型是相同的,它们可以被放到同一个泛型容器中:
1 |
|
它们也可以被相互赋值,也可以被当成参数传给一个需要std::shared_ptr<Widget>
类型的函数中。以上这些操作,对于std::unique_ptr
来说都是无法实现的,因为其自身的类型已经和deleter
的类型绑定了。
空间开销
另一个和std::unique_ptr
不同的点是,为std::shared_ptr
实例传入自定义的deleter
不会引起std::shared_ptr
实例额外的空间开销,不论有没有自定义的deleter
,std::shared_ptr
的实例都是2个裸指针的大小。这不免使人隐隐感到不安,std::shared_ptr
如何才能屏蔽deleter
对其自身大小的影响?
答案是,std::shared_ptr
并没有这种神通,它可能也会使用更多的内存,但是这部分东西并不是存储在std::shared_ptr
实例自己内部的(栈上),而是在堆上,更准确地来说,如果std::shared_ptr
实例的创建者利用std::shared_ptr
支持自定义内存分配器的特性,内存分配器分配内存到哪里,它就在哪里。
上文提到,std::shared_ptr
实例会通过指针去访问引用计数,但是其实这有一些误导,因为引用计数这个变量其实是一个更大的数据结构的一部分——控制块(control block)。对于每一个被std::shared_ptr
管理的资源来说,它都有一个自己的控制块,这个数据结构包含了引用计数,一份自定义deleter
的拷贝,还有一份自定义的内存分配器的拷贝(如果声明了的话)。此外,可能还有一些其它的数据,比如Item21会提到的,一个被称为弱引用计数weak count
的第二个引用计数,不过在这里暂时不讨论它。std::shared_ptr
实例和与之相关联的内存格局可以用下图来概括。
控制块
一个对象的控制块被第一个指向它的std::shared_ptr
实例的函数所设置。一般而言,一个正在创建一个std::shared_ptr
实例,并且使之指向某个资源的函数,是不可能知道是否存在某个其它的std::shared_ptr
实例,并且这个实例已经指向了那个资源(也就是说这个实例已经有一份控制块与被管理的资源对应了)。为什么无法知道呢?因为此时还在考虑指向控制块的指针是已经存在了,还是待创建。因此,有了以下的控制块创建规则:
std:: make_shared()
总是创建一个新的控制块,再创建一个std::shared_ptr
实例指向它,然后返回此std::shared_ptr
实例。当从独占式指针(
std::auto_ptr
,std::unique_ptr
)转换到std::shared_ptr
时,创建一个新的控制块。顺带一提的是,从std::unique_ptr
到std::shared_ptr
的转换,会置空前者。当使用一个裸指针创建
std::shared_ptr
实例时,创建一个新的控制块。反之,如果希望从一个已经创建过控制块的对象构造
std::shared_ptr
实例,应该将std::shared_ptr
实例传入,或者传入一个std::weak_ptr
实例。
这些规则导致的一个直接结果就是,从一个同裸指针多次构造std::shared_ptr实例会导致同一份动态资源有多个与之对应的控制块,也就是多个引用计数,也就是多次裸指针的delete操作,最终这会让你光速体验什么叫做undefined behavior
千万不要写出这样的代码:
1 |
|
这样的操作和智能指针的设计初衷背道而驰,明明希望用RAII
式的类替代裸指针,却偏偏还要用裸指针来多次创建智能指针。
从此处我们可以吸取两个教训:
尽量不要往
std:: shared_ptr
的构造函数里扔裸指针,相反,应该是往std:: make_shared
函数里面扔裸指针,而往std::shared_ptr
构造函数扔其它的std::shared_ptr
实例。但是此处我们使用了自定义的deleter
,所以必须直接调用std:: shared_ptr
的构造函数。即使要往
std::shared_ptr
的构造函数里扔裸指针,扔new
的返回值就够了,不要额外用一个裸指针的变量去接new
的返回值。比如这样:1
2std::shared_ptr<Widget>spw1 (new Widget(),loggingDel);
std::shared_ptr<Widget>spw2 (spw1);
安全构造一个指向this的实例
从上面的讨论中,我们知道了将裸指针传到std::shared_ptr
的构造函数中去是十分糟糕的行为。
一种令人极为吃惊的典型犯错方式,是将对象的this
指针传进去。
假设我们的程序使用了std::shared_ptr
来管理Widget
对象,并且我们有一个数据结构用于追踪已经处理过的Widget
对象:std::vector<std::shared_ptr<Widget>> processedWidgets;
进一步假设Widget
有一个成员函数用于处理,以下代码给出一种看起来很合理的处理流程:
1 |
|
上述代码将一个裸的指针传入了一个std::shared_ptr
的容器,会引起隐式构造一个新的std::shared_ptr
对象,并且为this
创建一个新的控制块。不要忘了此时main()
里已经有一个ptr
拥有这个对象的控制块了,因此undefined behavior
已经离你不远了。
所幸,std::shared_ptr
提供的API中有一个解决这种场景的成员——可能是在Standard C++ Library
库中最老的名字——std::enable_shared_from_this
,一个模板类。如果你希望你的类可以在成员函数中安全地生成指向this
的std::shared_ptr
对象,你应该这么做:
1 |
|
可能乍一看会让人觉得疑惑。That’s fine,这种编程模式就是会让人感到疑惑,但是这种代码是完全合法的(可以通过编译,也肯定能跑),只要其基类内部满足一定的条件。
这种编程模式也已经很成熟,它也有一个标准的名字,并且它几乎和std::enable_shared_from_this
一样古老——它叫做奇异模板递归编程(The Curiously Recurring Template Pattern, CRTP)。
在理解CRTP上,可以先看这一段代码,更多内容还需网友自行去了解
1 |
|
上文提到了std::enable_shared_from_this<>
是一个模板类,继承它时,模板的实例化参数永远都是继承它的类型。这样做以后,派生类Widget
获得一个成员函数shared_from_this
,此函数会安全地创建一个指向this
的std::shared_ptr
实例——并且保证不会重复创建控制块
在内部,shared_from_this
函数查看当前对象的控制块,并且创建一个新的std::shared_ptr
实例,使之指向上述控制块。
整个设计的合理性都完全依赖于,在调用shared_from_this函数之前,当前对象this已经对应有一个控制块了,也就是至少有一个std::shared_ptr实例已经指向了this
如果不满足此条件,则对shared_from_this的调用是未定义的,尽管shared_from_this通常抛出一个异常
为了避免这一情况的发生,std::enable_shared_from_this
类通常将其构造函数声明为私有的,并且让使用者通过调用工厂函数的方法创建std::shared_ptr
,比如
1 |
|
尽管定制的deleter
和allocator
会撑大空间,一个控制块通常只有几个字的大小。
常见的控制块的实现可能比你想象得要更加精巧,这包含继承和虚函数(用于确保被指向的对象被正确销毁了)。于是,使用std::shared_ptr
也会引起 控制块调用虚函数伴随的开销。
性能评价
读了关于std::shared_ptr
这么多的内容,可能你对它的热情有所减少,又是虚函数,又是控制块,还有令人头疼的CRTP
That’s fine,尽管std::shared_ptr
并不是一种对所有种类资源的管理通用最佳解决方案,但是std::shared_ptr
的所提供的功能,无法否认地只用了合理的代价,就达到了目的。在通常的情况下,使用默认的deleter
和默认的allocator
,std::shared_ptr
也被std::make_shared
创建,这种情况下,控制块就仅仅只有大概3个字的大小,并且控制块的内存分配是无成本的(见Item21)。
对std::shared_ptr
进行解引用,代价和对裸指针解引用差不多。当操作导致对引用计数进行修改时,会引起原子操作,但是这些操作通常都会被映射到宿主机的指令上去。即使原子操作会比较费时费力,但是它们也就是一条指令而已。
而控制块中的虚函数,在整个资源的生命周期中,通常情况下也仅仅会被调用一次——用于确保资源已经被释放。
这些并不昂贵的性能代价,所换来的是,全自动的针对动态资源的生命周期管理。绝大多数情况下,使用std::shared_ptr
总是比手动地来管理某个资源的生命周期要好得多,这也是被推荐的。如果发现自己在怀疑是否能接受使用std::shared_ptr
带来的代价,可能需要先考虑是否真的需要共享的资源拥有权。如果独占式管理权更好,或者可能更好,std::unique_ptr
都是一个更优的选择——因为std::unique_ptr
的性能和裸指针非常接近,并且如果要将之转换成std::shared_ptr
,也非常简单。相反的情况则不成立,如果已经使用std::shared_ptr
来管理资源的生命周期,就无法再改变了。即使引用计数为1,也无法创建一个std::unique_ptr
实例来取代最后一个std::shared_ptr
实例。std::shared_ptr
和资源之间的联系,是“至死方休”的。
std::shared_ptr
和std::unique_ptr
的另一个区别是,std::shared_ptr
不支持数组类型,也就是说,没有std::shared_ptr<T[]>
这种用法。
时不时会有开发者在试图使用std::shared_ptr
去指向一个数组上摔跟头。这些开发者通过使用std::shared_ptr<T>
让其指向一个数组头,然后自定义deleter
为delete[]
这样能够通过编译,但是却是极其糟糕的。第一,std::shared_ptr<T>
没有重载[]
运算符,因此通过索引取元素时要笨拙地使用指针的加法;第二,std::shared_ptr
支持从派生类到基类的指针转换,这只对单一对象有意义,对于数组来说是没有意义的(因此std::unique_ptr<T[]>
直接禁止了这种转换);更重要的是,我们已经有了C++
对内建数组类型的上位替代,比如std::array
, std::vector
, std::string
等,为啥还要非要玩原始数组嘞。
总结
std::shared_ptr
可以方便地对任意资源进行共享式的生命周期管理,从而达到垃圾回收的效果。比起
std::unique_ptr
,std::shared_ptr
一般都会大一倍,还会带来控制块的时间代价和空间代价,和引用计数上的原子操作默认通过
delete
操作符销毁资源,也可以自定义deleter
函数。deleter
的类型对std::shared_ptr
实例化后的类型没有影响,这一点和std::unique_ptr
不一样。不要,不要,不要从一个裸指针变量来创建std::shared_ptr的实例
尾注
- 原书作者注:虽然这不是标准的一部分,但是我所熟知的每一个标准库都是如此实现的 ↩
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!