Std::make_unique and Std::make_shared

本文最后更新于:April 20, 2022 am

本篇为智能指针系列第四篇,介绍std::make_sharedstd::make_unique

系列导航:智能指针

使用std::make_unique和std::make_shared,而不是直接使用new

在开始讨论之前,需要提到std::make_unique是自C++14之后才被加入到标准库的,如果正在使用C++11,下面一小段代码就可以达到std::make_unique的作用:

1
2
3
4
template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&&... params){
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}

代码中,make_unique将传入的参数完美转发到std::unique_ptr的构造函数中,返回了一个std::unique_ptr的实例。

虽然这段代码并不适用于数组形式的std::unique_ptr,也不能支持自定义deleter,但这表明,如果需要自己写一份代码,也并不麻烦[1]

只是不要将它放到std的命名空间中,免得升级到C++14时起冲突。

make函数的优势

std::make_uniquestd::make_sharedstd::allocate_shared共同构成了三大make函数,它们接受任意数量的参数,然后将它们完美转发给一个被动态分配的对象的构造器,最后再返回一个用于管理对象的智能指针。对于最后一个函数std::allocate_shared,除了它的第一个参数是一个用于动态分配内存的分配器对象,在其它方面表现得和std::make_shared一致。

源码优势

首先,在使用它们来获取智能指针对象上,make系列函数就要简单不少

1
2
3
4
auto upw1(std::make_unique<Widget>());
auto spw1(std::make_shared<Widget>());
std::unique_ptr<Widget> upw2(new Widget);
std::shared_ptr<Widget> spw2(new Widget);

可以看到,直接调用智能指针的构造函数的方式,需要我们重复写明Widget类别。

重复书写类型参数和软件工程中的一个原则相抵触:应当避免重复代码。

源文件的重复导致编译时间变长,还会导致目标代码膨胀,最终导致生成更难以使用的代码存根(code base)。进而,它会演化成不一致的代码,这种存根中的不一致最终会导致bug。

此外,谁不想少写一次类型参数呢?

异常安全

推荐make系列函数的原因,和异常安全有关。假设我们有一个处理Widget对象的函数,此函数和某种优先级搭配使用:

1
void processWidget(std::shared_ptr<Widget>spw, int priority);

可能你会奇怪,为什么此处要使用值传递的方式?Item41解释了,如果processWidget总是拷贝一份std::shared_ptr(比如通过将它存储到一个追踪已经处理过的Widget对象的数据结构中)会是一种合理的设计选择。

现在假设有一个负责计算优先级的函数int computePriority();,如果在process中不使用std::make_shared,而是使用new

1
processWidget(std::shared_ptr<Widget>(new Widget),computePriority());//潜在的内存泄漏!

这里的潜在内存泄漏就是由new导致的,看了代码中的注释我们可能会疑惑,此处怎么可能导致内存泄漏呢?毕竟我们使用了智能指针,而智能指针就是被设计来避免内存泄漏的,此处也不会存在什么循环引用啊?

这和编译器将源代码编译成目标代码的过程有关。在运行时,一个函数的参数必须先被计算出来,然后才能发生函数调用,也就是说,在调用processWidget之前,这些事情必须先发生:

  • new Widget必须先执行完毕,一个Widget对象在堆上被创建好
  • std::shared_ptr<Widget>的构造器必须要执行
  • 得到computePriority的返回值

恩,乍一看没什么,但是编译器并不需要保证生成的代码按照上述的顺序执行。显然,new操作符必须在std::shared_ptr<Widget>的构造器调用之前执行,因为有参数依赖关系。但是computePriority函数的调用时刻就比较随意,它的调用可以发生在new之前,或者Widget的构造函数之后,但是,万万不能在两者之间。

如果执行顺序是

  1. 执行new
  2. computePriority
  3. 构造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
processWidget(std::make_shared<Widget>(), computePriority());

显然,如果上述代码中使用的是std::unique_ptrstd::make_unique,那么也同理,调用后者可以确保不发生内存泄漏。

性能提升

比起直接使用new,使用std::make_shared的另一个好处是,后者在性能上有所提升。调用后者让编译器使用线性数据结构,生成更小更快速的代码。考虑这一段代码:

1
std::shard_ptr<Widget> spw(new Widget);

这一段代码实际上会引起两次内存分配。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_ptrstd::unique_ptr的构造器却有相应的重载版本。

1
2
3
4
5
auto myDeleter=[](Widget* ptr){
//do sth else
delete ptr;
}
std::shared_ptr<Widget> ptr2Widget(new Widget,myDeleter);

小括号还是大括号?

make系列函数的又一个局限性来自于它们在实现上的语法细节。从Item7中我们知道,如果一个类既重载了带有std::initializer_list类型参数的构造函数,又重载了不带std::initializer_list类型参数的构造函数,那么在创建实例时,通过大括号{}来创建实例时,会优先调用前一个构造函数,而通过小括号()来创建实例则会优先调用后一个重载版本。make系列函数通过完美转发将参数一股脑扔给了类的构造器,但扔的时候,是用的大括号还是小括号呢?对于一些类来说,这两种方式有本质的区别。

1
2
auto upv = std::make_unique<std::vector<int>>(10,20);
auto spv = std::make_shared<std::vector<int>>(10,20);

构造完成后,std::vector对象是含有10个被初始化为20的int呢,还是只含有10和20两个int元素呢?又或者答案是不固定的?

好消息是,答案是固定的,make系列函数只通过小括号() 来传递参数。因此两个std::vector对象都是只含有2个元素。

坏消息就是,如果我们希望使用大括号{}来构造std::vector,那还是直接调用new吧。如果希望使用make系列函数来创建使用大括号{}初始化的对象,那么就需要使用到大括号{}的完美转发,但是正如Item30所说,完美转发不支持转发大括号初始化物。但同时,Item30也提到了,可以先用auto推导一个用大括号初始化的std::initializer_list的对象,然后再将之扔到make系列函数中去。

1
2
auto initList={10,20};
auto spv = std::make_shared<std::vector<int>>(initList);

对于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系列函数来创建自己重载了newdelete操作符的类的智能指针。

内存释放的滞后性

使用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
2
3
4
5
6
7
8
9
10
11
12
class ReallyBigType{
//...
};
auto pBigObj= std::make_shared<ReallyBigType>();//通过make函数创建一个非常大的对象

...//创建一堆std::shared_ptr和std::weak_ptr干活

...//最后一个std::shared_ptr被析构,ReallyBigType对象被析构,但是仍有std::weak_ptr实例指向它

...//这段时间内,原来ReallyBigType对象占据的空间仍然被占用

...//最后一个std::weak_ptr被析构,最终释放std::make_shared分配的内存

如果使用new,又是另一幅光景:

1
2
3
4
5
6
7
8
9
10
11
12
class ReallyBigType{
//...
};
auto pBigObj= std::shared_ptr<ReallyBigType>(new ReallyBigType);//通过std::shared_ptr和new创建一个非常大的对象

...//创建一堆std::shared_ptr和std::weak_ptr干活

...//最后一个std::shared_ptr被释放,ReallyBigType对象被析构,其所占据的内存也被释放

...//这段时间内,只有控制块的内存仍被占用

...//最后一个std::weak_ptr被析构,最终释放控制块的内存

如何应对

综上,一旦发现当前的处境并不能或者不适合使用std::make_shared函数时,就用该尽量确保上文所述的异常安全。

最好的方法就是,当直接使用new时,立刻将new的结果传到智能指针的构造函数中去,并且在这一句话中不要干其它操作。这样编译器就不会在new和智能指针的构造器之间插入其它的操作了。

作为举例,回想我们上文提到的类型不安全的Widget例子,这次我们要自定义一个deleter:

1
2
3
void processWidget(std::shared_ptr<Widget> spw, int priority);

void cusDel(Widget *ptr);

以下是不满足异常安全的函数调用:

1
processWidget(std::shared_ptr<Widget>(new Widget, cusDel),computePriority());//不安全

此处因为要自定义deleter,我们不能使用std::make_shared,所以需要将构造智能指针的操作单独移出来。

1
2
auto ptr=std::shared_ptr<Widget>(new Widget, cusDel);
processWidget(ptr,computePriority());

这行得通,因为即使std::shared_ptr的构造函数抛异常,比如无法分配到控制块的内存了,它也会保证cusDel的调用。

不过这不是最优的实现方案,注意到我们在不安全的调用版本中,往processWidget中传递的是一个std::shared_ptr<Widget>类型的右值,而改进版变成了一个左值。

拷贝构造std::shared_ptr需要涉及对共享引用计数进行原子操作,而移动构造并不需要,因此,此处需要使用std::move将表达式变成右值(参见条款23)

1
2
auto ptr=std::shared_ptr<Widget>(new Widget, cusDel);
processWidget(std::move(ptr),computePriority());

这个技巧值得注意,但是通常情况下也用不到,因为极少会遇到不能使用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


尾注

  1. 如果要完整版本的std::make_unique实现, 请上网查找标准化文档,并且拷贝文档内的实现。文件是 N3656 by Stephan T.Lavavej, 2013-04-18
  2. 实际上,弱引用计数的值不总是等于指向控制块的std::weak_ptr的数量,因为在实现标准库时,有人发现通过给弱引用计数加上一点额外的信息,可以帮助生成更好的代码。但在这一条款中,我们会忽略这一点,并且假设两者就是相等的

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!