21.以make_unique或make_shared取代直接使用new

三大make函数

 
C++11加入了make_shared,C++14加入了make_unique,如果你处于C++11环境也不必担心,因为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只是将其参数完美转发给待创建对象的构造函数,并返回一个由原始指针初始化得到的unique_ptr。 这种实现形式并不支持数组或自定义删除器,但它至少表明实现make_unique并不困难。

std::make_unique和std::make_shared是三个make函数中的两个:接受任意参数集,并将它们完美转发给动态分配对象的构造函数,并返回指向该对象的智能指针。第三个make函数是std::allocate_shared,其作用类似于std::make_shared,不过它的第一个参数是一个用于动态内存分配的allocator对象。


make函数的优越性

代码明晰程度

就算不看性能,光看代码也能看出make函数较为优越:

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

显然,使用new的版本需要陈述两次对象类型,这违背了软件工程的基本原则:应尽量避免代码重复。源代码中的重复会增加编译时间,从而导致生成庞大的目标码,并且通常会使代码难以使用。它经常会生成不一致的代码,而这种不一致性是某些bug的起源所在。最后,使用make函数打字也省力一些。


异常安全性(可见Effective C++ Item 17)

make函数的优越性也与异常安全有关。假设我们当前有一个函数负责处理某些具备优先级的Widget:

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

值传递std::shared_ptr看起来似乎很奇怪,但是Item41中解释说,如果processWidget总是复制std::shared_ptr(例如将其存储在某个跟踪已处理Widget的数据结构中),这是一个合理的选择。
更进一步地假设我们有一个计算优先级的函数:
1
int computePriority();

我们将其返回值与new操作得到的shared_ptr作为参数传递给processWidget:
1
processWidget(std::shared_ptr<Widget>(new Widget),computePriority()); // potential resource leak

为何可能会发生资源泄露与编译器将源代码转换为目标代码的过程有关。在运行期必须保证一个函数的参数在调用该函数前被明确evaluate,因此在调用processWidget之前,必须执行以下操作:

  1. new Widget必然会执行,届时会在堆上创建一个Widget对象。
  2. shared_ptr的构造函数必然会执行,其以new的返回值为初始值。
  3. computePriority必然会执行。

编译器并不保证按照上述顺序执行程序。 显然,必须在调用std::shared_ptr构造函数之前执行”new Widget”,但是computePriority可以发生在任何时期,比如说在步骤1之前,又或者步骤1、2之间。假设编译器按照以下顺序执行程序:

  1. 执行new Widget
  2. 执行computePriority
  3. 执行std::shared_ptr构造函数

如果在运行时,computePriority抛出了异常,则步骤1中动态分配的Widget将被泄漏,因此它将永远无法被访问到。
若使用std::make_shared则可以完美避开上述问题:

1
processWidget(std::make_shared<Widget>(),computePriority()); // no potential resource leak

在上述代码运行时,将首先调用make_shared或computePriority。如果首先调用了make_shared,则动态分配的资源必然已被置入智能指针,此时computePriority抛出异常会导致shared_ptr发生析构,资源被合理释放。若是先调用computePriority,则make_shared不会执行,资源泄露无从谈起。make_unique亦是同理。


效率

std::make_shared相较于new而言效率更高,使用std::make_shared允许编译器生成更小,更快的代码,使用更精简的数据结构。若我们直接使用new operator:

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

显然,上述程序需要执行内存分配操作,但实际上它执行了两次内存分配。在Item19中我们曾经提及每个std::shared_ptr都指向一个control block,其中包含指向对象的引用计数。 此control block的内存在std::shared_ptr构造函数中分配。此外,new operator将为Widget对象分配一块内存,因此一共执行了两次内存分配。
我们现以make_shared函数替换new:
1
auto spw = std::make_shared<Widget>();

如此一来,一次内存分配足矣。std::make_shared一次性分配了Widget与control block的内存,这种优化减少了程序的静态大小(因为代码只包含一个内存分配调用),并且它增加了可执行代码的速度,因为内存只分配了一次。此外,使用std::make_shared不需要control block中的某些bookkeeping information,可能会减少程序的总内存占用量。std::allocate_shared效率分析亦类似于std::make_shared。


make函数的不足

 
尽管make函数无论是代码可读性、异常安全性、效率均优于new,但其存在使用条件,在某些情况下make函数无法使用。

自定义删除器

举例而言,make函数不支持自定义删除器,若当前存在删除器如下所示:

1
auto widgetDeleter = [](Widget* pw) { … };

使用new来创建一个具备自定义删除器的智能指针很容易:
1
2
std::unique_ptr<Widget, decltype(widgetDeleter)> upw(new Widget, widgetDeleter);
std::shared_ptr<Widget> spw(new Widget, widgetDeleter);

make函数却不支持这种行为。

大括号初始化器

make函数的第二个限制源于其实现的语法细节。在Item7中我们曾经明确说明,若一个对象存在参数为initializer_list类型的构造函数,那么当构造实参带有大括号时几乎必然会调用该函数,而带有小括号的构造实参则会调用其他构造函数。make函数负责将实参完美转发给智能指针的构造函数,但这些实参是带有大括号还是小括号make函数却一无所知。对于某些类型而言,构造时使用大括号还是小括号将导致完全不同的结果,例如:

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

到底应该是生成一个内含10个元素,其值均为20的vector,还是生成内含10、20两个元素的vector?又或者说结果是不确定的?有一个好消息和一个坏消息在等着我们。
好消息是答案并非不确定:两个调用都将创建一个智能指针指向一个内含10个元素的vector,这意味着在make函数中完美转发使用小括号而不是大括号。坏消息是如果你要使用大括号实参构造指向对象,则必须直接使用new opertaor来完成。由此看来,使用make函数需要支持完美转发的大括号初始化,但Item30中将会提及,大括号初始化并不支持完美转发。但Item30描述了一种解决方案:使用auto类型推衍利用大括号初始化器创建std::initializer_list对象,然后通过make函数传递auto对象(显式地以initializer_list作为构造实参):
1
2
3
4
// create std::initializer_list
auto initList = { 10, 20 };
// create std::vector using std::initializer_list ctor
auto spv = std::make_shared<std::vector<int>>(initList);

对于unique_str而言,make函数仅在上述两个场景下存在些许问题,但对于shared_ptr而言,还有两个问题需要解决。

自定义内存管理

对于某些自定义了opertaor new与operator delete的class而言,使用全局new与delete似乎不太合适。一般而言,new与delete负责分配或释放指定大小(通常为sizeof(Widget))的内存,但shared_ptr并非如此,它所需要分配与释放的不仅仅是对象的大小,还需要加上一个control block的大小。因此,使用make函数创建具备自定义内存管理的对象是一个十分糟糕的想法。

std::make_shared的速度优势来源于在分配对象所需要的内存时control block所需要的内存也被一并放置,但我们所需要明确的是,当对象被析构时其所占内存并未立即释放,因为此时control block所占用的内存尚未被释放。

内存释放延迟

正如之前指出,control block中含有两个引用计数相关信息,其中第二个被称为弱引用计数,当一个weak_ptr检查其是否处于空悬状态时,它通过检查第一个引用计数(而非若引用计数)来确保正确性,如果引用计数为0,weak_ptr即为空悬,否则不是。但control block所占用内存的释放与弱引用计数有关,也就是说,即使对象已经析构,但只要仍然存在一个weak_ptr指向它(弱引用计数不为0),该control block的内存便需要一直存在,直到最后一个指向shared_ptr的weak_ptr被析构。如果对象非常大,销毁最后一个shared_ptr与weak_ptr之间又存在着大量时间,那么可能在内存释放期间会出现延迟:

1
2
3
4
5
6
class ReallyBigType { … };
auto pBigObj =std::make_shared<ReallyBigType>();
// create std::shared_ptrs and std::weak_ptrs to large object, use them to work with it
// final std::shared_ptr to object destroyed here,but std::weak_ptrs to it remain
// during this period, memory formerly occupied by large object remains allocated
// final std::weak_ptr to object destroyed here;memory for control block and object is released

使用new则可以完成瞬间内存释放。


在无法使用make函数时的替代

 
如果当前并不适合使用std::make_shared,我们需要考虑异常安全性问题。最好的解决方案方法是确保直接使用new时,立即将结果传递给智能指针构造函数,该语句不执行任何其他操作(即Effective C++ Item17所述)。仍以具备自定义删除其的Widget为例:

1
2
void processWidget(std::shared_ptr<Widget> spw,int priority);
void cusDel(Widget *ptr); // custom deleter

以下为一个不具备异常安全性的调用:
1
processWidget(std::shared_ptr<Widget>(new Widget, cusDel),computePriority());

如果我们将其改写为:
1
2
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(spw, computePriority()); // correct, but not optimal; see below

这段代码具备异常安全性,因为即使spw构造失败(例如无法为control block分配内存),仍然能够通过自定义删除器析构刚刚构造的widget对象,只是其性能还可以被改善。

在刚才不具备异常安全性的程序中,我们试图将一个右值传递给ProcessWidget,但在异常安全的版本中,我们传递的是一个左值。因为processWidget中的参数pass-by-value,因此右值的构造只需要移动,而来自左值的构造需要复制,这一点对于std::shared_ptr可能会带来很大的差异,因为复制std::shared_ptr时其引用计数自增是原子操作,而移动std::shared_ptr则根本不需要关心引用计数。我们将原先的左值转为右值可以大幅度提升性能:

1
processWidget(std::move(spw),computePriority());


总结

  1. 与直接使用new相比,make函数不存在代码重复且具备异常安全性,std::make_shared和std::allocate_shared可以生成更快更短的目标码。
  2. 在需要自定义删除器及以大括号初始化时make函数无能为力。
  3. 对于shared_ptr,具备自定义内存管理与weak_ptr生存期比shared_ptr长的情况亦不可使用make函数。