Std::make_unique and Std::make_shared
本文最后更新于:April 20, 2022 am
本篇为智能指针系列第四篇,介绍std::make_shared
和std::make_unique
系列导航:智能指针
使用std::make_unique和std::make_shared,而不是直接使用new
在开始讨论之前,需要提到std::make_unique
是自C++14之后才被加入到标准库的,如果正在使用C++11,下面一小段代码就可以达到std::make_unique
的作用:
1 |
|
代码中,make_unique
将传入的参数完美转发到std::unique_ptr
的构造函数中,返回了一个std::unique_ptr
的实例。
虽然这段代码并不适用于数组形式的std::unique_ptr
,也不能支持自定义deleter,但这表明,如果需要自己写一份代码,也并不麻烦[1]。
只是不要将它放到std
的命名空间中,免得升级到C++14时起冲突。
make函数的优势
std::make_unique
,std::make_shared
和std::allocate_shared
共同构成了三大make函数,它们接受任意数量的参数,然后将它们完美转发给一个被动态分配的对象的构造器,最后再返回一个用于管理对象的智能指针。对于最后一个函数std::allocate_shared
,除了它的第一个参数是一个用于动态分配内存的分配器对象,在其它方面表现得和std::make_shared
一致。
源码优势
首先,在使用它们来获取智能指针对象上,make系列函数就要简单不少
1 |
|
可以看到,直接调用智能指针的构造函数的方式,需要我们重复写明Widget
类别。
重复书写类型参数和软件工程中的一个原则相抵触:应当避免重复代码。
源文件的重复导致编译时间变长,还会导致目标代码膨胀,最终导致生成更难以使用的代码存根(code base)。进而,它会演化成不一致的代码,这种存根中的不一致最终会导致bug。
此外,谁不想少写一次类型参数呢?
异常安全
推荐make系列函数的原因,和异常安全有关。假设我们有一个处理Widget
对象的函数,此函数和某种优先级搭配使用:
1 |
|
可能你会奇怪,为什么此处要使用值传递的方式?Item41解释了,如果processWidget
总是拷贝一份std::shared_ptr
(比如通过将它存储到一个追踪已经处理过的Widget
对象的数据结构中)会是一种合理的设计选择。
现在假设有一个负责计算优先级的函数int computePriority();
,如果在process
中不使用std::make_shared
,而是使用new
:
1 |
|
这里的潜在内存泄漏就是由new
导致的,看了代码中的注释我们可能会疑惑,此处怎么可能导致内存泄漏呢?毕竟我们使用了智能指针,而智能指针就是被设计来避免内存泄漏的,此处也不会存在什么循环引用啊?
这和编译器将源代码编译成目标代码的过程有关。在运行时,一个函数的参数必须先被计算出来,然后才能发生函数调用,也就是说,在调用processWidget
之前,这些事情必须先发生:
new Widget
必须先执行完毕,一个Widget
对象在堆上被创建好std::shared_ptr<Widget>
的构造器必须要执行- 得到
computePriority
的返回值
恩,乍一看没什么,但是编译器并不需要保证生成的代码按照上述的顺序执行。显然,new
操作符必须在std::shared_ptr<Widget>
的构造器调用之前执行,因为有参数依赖关系。但是computePriority
函数的调用时刻就比较随意,它的调用可以发生在new
之前,或者Widget
的构造函数之后,但是,万万不能在两者之间。
如果执行顺序是
- 执行
new
computePriority
- 构造
std::shared_ptr
那么,一旦computePriority
函数中抛出了异常,new
出来的资源就必定泄露了——因为没有用一个裸指针变量取接住new
的结果,没有任何办法再去拿到new
的返回值了,而此时std::shared_ptr
也没有接管这个资源。
通过调用std::make_shared
就可以避免这个问题,在运行时,computePriority
要么在std::make_shared
函数之前调用,要么在其之后,只要保证了std::make_shared
函数本身处理好了异常和潜在内存泄漏,即使computePriority
抛了异常,也不会造成内存泄漏。computePriority
如果在std::make_shared
函数调用之后执行,抛出异常会让std::shared_ptr
对象析构;如果在其之前执行就抛出异常,则不会向堆分配内存。
1 |
|
显然,如果上述代码中使用的是std::unique_ptr
和std::make_unique
,那么也同理,调用后者可以确保不发生内存泄漏。
性能提升
比起直接使用new
,使用std::make_shared
的另一个好处是,后者在性能上有所提升。调用后者让编译器使用线性数据结构,生成更小更快速的代码。考虑这一段代码:
1 |
|
这一段代码实际上会引起两次内存分配。Item19已经说明了,std::shared_ptr
所管理的资源必须有一个与之对应的控制块。并且这一个控制块的内存申请时机是在std::shared_ptr
的构造函数中,如此便有了两次内存分配,第一次是分配一个Widget
对象,第二次是分配控制块。
如果我们直接使用std::make_shared
,只需要分配一次内存,因为只需要分配一块内存,并且保证其空间大小是Widget
对象大小和控制块对象大小之和即可。
这种优化减少了程序的静态尺寸,因为逻辑上只进行了一次内存分配申请,运行时速度也相应提升了。使用std::make_shared
更进一步免除了在控制块中部分记账信息(bookkeeping information
)的需要,从而减小了程序的总的内存痕迹。
以上对于std::shared_ptr
的性能分析对于std::allocate_shared
也同样适用。
不适用的场景
虽然上述的三个优点都强有力地支撑了应该尽量使用make
系列函数的观点。但本文的目仅仅是让读者了解make
系列函数的优点并且优先调用它们,这是因为,有的场景下不能或者不应该使用make系列函数。
自定义deleter
Item18和Item19中提到了,智能指针类还支持自定义deleter
的操作,这便是make系列函数无能为力的,而std::shared_ptr
和std::unique_ptr
的构造器却有相应的重载版本。
1 |
|
小括号还是大括号?
make系列函数的又一个局限性来自于它们在实现上的语法细节。从Item7中我们知道,如果一个类既重载了带有std::initializer_list
类型参数的构造函数,又重载了不带std::initializer_list
类型参数的构造函数,那么在创建实例时,通过大括号{}
来创建实例时,会优先调用前一个构造函数,而通过小括号()
来创建实例则会优先调用后一个重载版本。make系列函数通过完美转发将参数一股脑扔给了类的构造器,但扔的时候,是用的大括号还是小括号呢?对于一些类来说,这两种方式有本质的区别。
1 |
|
构造完成后,std::vector
对象是含有10个被初始化为20的int
呢,还是只含有10和20两个int
元素呢?又或者答案是不固定的?
好消息是,答案是固定的,make系列函数只通过小括号()
来传递参数。因此两个std::vector
对象都是只含有2个元素。
坏消息就是,如果我们希望使用大括号{}
来构造std::vector
,那还是直接调用new
吧。如果希望使用make系列函数来创建使用大括号{}
初始化的对象,那么就需要使用到大括号{}
的完美转发,但是正如Item30所说,完美转发不支持转发大括号初始化物。但同时,Item30也提到了,可以先用auto
推导一个用大括号初始化的std::initializer_list
的对象,然后再将之扔到make系列函数中去。
1 |
|
对于std::unique_ptr
来说,只有在以上两种场景下使用make函数会出问题,但是对于std::shared_ptr
来说,还有另外两种情况。
虽然都是很少数的情况,但开发者往往一听就来劲。
重载new和delete
某些类定义了它们自己的new
操作符和delete
操作符,这意味着全局的的内存分配和释放机制对于这种类的对象来说不起作用。
通常情况下,这种一类一份的函数只是用于分配和释放实例自身所占用的空间的,一字节都不会多。比如,Widget
类自己的new
操作符一般就只分配一片大小和sizeof(Widget)
相同的内存空间。
这和std::shared_ptr
的所提供的自定义内存分配(通过std::allocate_shared
)和释放(通过自定义deleter)格格不入。因为std::allocate_shared
所需要的内存数量比sizeof(Widget)
还要多一些,多的部分用于存储控制块。因此,不推荐使用make系列函数来创建自己重载了new
和delete
操作符的类的智能指针。
内存释放的滞后性
使用std::make_shared
比起使用std::shared_ptr
构造器的优势在于,std::shared_ptr
的控制块被连同对象被分配到了一起。当对象的共享引用计数降到了0,这个对象会被销毁,但是这一片内存区域还会暂时存在,直到其控制块也被销毁,因为两者所占据的那一片空间是一次性分配的。
上文已经提到,控制块维护一些除了共享引用计数之外的记账信息,比如控制块还包含一个弱引用计数(weak count
[2])——用于统计有多少个std:weak_ptr
指向它。当std::weak_ptr
检查自己是否已经失效时,它通过检查控制块中的共享引用计数是否为0来确定。
只要还有std::weak_ptr
的实例指向这一片控制块, 或者还有std::shared_ptr
实例管理这块内存,控制块的空间就会一直存在而不会被释放。因此被std::make_shared
所分配的内存就会一直被占用,直到最后一个指向控制块的std::weak_ptr
实例和std::shared_ptr
都失效。如果对象占用空间太大,再加上共享引用计数减为0和弱引用计数减为0之间时间拉得太长,就会导致在对象实例被析构之后很久才发生内存释放。
1 |
|
如果使用new
,又是另一幅光景:
1 |
|
如何应对
综上,一旦发现当前的处境并不能或者不适合使用std::make_shared
函数时,就用该尽量确保上文所述的异常安全。
最好的方法就是,当直接使用new
时,立刻将new
的结果传到智能指针的构造函数中去,并且在这一句话中不要干其它操作。这样编译器就不会在new
和智能指针的构造器之间插入其它的操作了。
作为举例,回想我们上文提到的类型不安全的Widget
例子,这次我们要自定义一个deleter:
1 |
|
以下是不满足异常安全的函数调用:
1 |
|
此处因为要自定义deleter,我们不能使用std::make_shared
,所以需要将构造智能指针的操作单独移出来。
1 |
|
这行得通,因为即使std::shared_ptr
的构造函数抛异常,比如无法分配到控制块的内存了,它也会保证cusDel
的调用。
不过这不是最优的实现方案,注意到我们在不安全的调用版本中,往processWidget
中传递的是一个std::shared_ptr<Widget>
类型的右值,而改进版变成了一个左值。
拷贝构造std::shared_ptr
需要涉及对共享引用计数进行原子操作,而移动构造并不需要,因此,此处需要使用std::move
将表达式变成右值(参见条款23)
1 |
|
这个技巧值得注意,但是通常情况下也用不到,因为极少会遇到不能使用make函数的情况。除非迫不得已,就应该使用make函数来创建智能指针的实例。
总结
make函数比起直接调用new操作符,减少了源代码的重复,保证了异常安全,并且std::make_shared和std::allocate_shared还能生成更小更快的代码
不能使用make函数的情形:要自定义deleter;希望使用大括号{}来初始化对象
对于std::shared_ptr,以下两种情况下也不应该使用std::make_shared:
1. 自身重载了new和delete操作符的类
2. 系统有内存隐患,分配的对象特别大,并且有指向此对象的std::weak_ptr实例存活时间远远长于对象的最后一个std::shared_ptr
尾注
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!