Item 21: Prefer std::make_unique and std::make_shared to direct use of new

  • Post author:
  • Post category:其他


C++ 11已经提供了std::make_shared,但遗憾的是没有提供std::make_unique。直到C++ 14才加入了std::make_unique。如果想在C++ 11中就用make_unique,我们可以自己写一个基础的版本:

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。这个函数不支持数组和自定义deleter(see Item 18)。

std::make_unique和std::make_shared是三个make系列函数中的两个。make系列函数会把一个任意实参集合完美转发给动态分配内存的对象的构造函数。make系列函数的第三个是std::allocate_shared,它的行为和std::make_shared一样,只不过它的第一个参数是用来做动态分配内存的分配器。

建议使用make系列函数来构建智能指针的第一个原因是会少写点代码:

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

第二个原因与异常安全有关。假设有一个根据某个优先级来处理Widget的函数:

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


对std::shared_ptr进行按值传递看起来可能可疑,但Item 41解释了如果processWidget总是生成std::shared_ptr的副本(例如,将其存储在跟踪已处理的widget的数据结构中),这可能是一个合理的设计选择。

现在假设有一个用来计算优先级的函数:

    int computePriority();

将它用在processWidget的调用中,并且用new操作符来构建智能指针:

    processWidget(std::shared_ptr<Widget>(new Widget),    // potential
                                    computePriority());   // resource
                                                          // leak!

注释中已经提到了,这段代码可能因为使用了new而产生内存泄漏。我们在调用processWidget()和其内部都使用的std::shared_ptr,为什么还会可能存在内存泄露呢?

这与编译器将源码翻译成目标代码有关。在运行期,传递给函数的实参必须在调用函数之前完成求值,因此,在调用processWidget时,下面的事情必须在processWidget执行前完成:

  • 表达式“new Widget”必须被计算出来,也就是说Widget对象必须在堆上创建出来;
  • std::shared_ptr的构造函数必须被执行;
  • computePriority必须被执行;

    而编译器不保证按照上面的顺序生成代码。“new Widget”一定在std::shared_ptr构造函数调用之前执行,因为new的结果要被用作该构造函数的参数,但是computePriority却可能在上面所说的两个调用之前、之后,甚至中间执行。也就是说,编译器可能翻译出如下执行顺序的代码:
  1. 执行”new Widget”;
  2. 执行 computePriority;
  3. 执行 std::shared_ptr构造函数;

如果生成了这样的代码,且在运行时computePriority产生了异常,那么由第一步动态分配的Widget将会泄露,因为它永远不能被放到第三步的std::shared_ptr中管理。但是应用std::make_shared就会避免这个问题,调用代码如下:

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

运行期,std::make_shared和computePriority总一个会先被调用。如果std::make_shared先被调用,指向被动态创建Widget的指针会在computePriority被调用前安全的存储在std::shared_ptr中,就算computePriority产生了异常,std::shared_ptr的析构函数也会完成资源的销毁。而如果computePriority先被调用,并且抛出异常,那么std::make_shared压根就不会执行,也就不需要担心Widget的分配和释放问题。

std::unique_ptr和std::make_unique与std::shared_ptr和std::make_shared的情况完全一样。

std::make_shared的另一个特色(与直接使用new相比)是性能的提升。std::make_shared的使用会让编译器有机会利用更简洁的数据结构生成更小更快的代码。考虑如下直接使用new的代码:

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

很明显这段代码会引发一次内存分配,但实际上会引发两次。Item 19解释过,每个std::shared_ptr都包含一个control block,而这个control block是在std::shared_ptr的构造函数中被动态创建的。所以,像上面的代码直接使用new,会为Widget的创建分配一次内存,还会为control block的创建分配一次内存。而如果使用std::make_shared,一次分配足矣:

    auto spw = std::make_shared<Widget>();

这是因为std::make_shared会分配一个单块内存(single chunk),用来同时存放Widget和control block。这种优化会减小程序的静态尺寸,因为它只包含一次分配调用,同时还会增加可执行代码的运行速度,因为内存是被一次性分配出来的。Furthermore, using std::make_shared obviates the need for some of the bookkeeping information in the control block, potentially reducing the total memory footprint for the program.(翻译不出来,只能贴原文了)

std::make_shared的性能分析也同样适用于std::allocate_shared。

虽然std::make_shared有着异常安全和效率方面的优势,但在有些场景下,不能或者不应该使用make系列函数。比如,make系列函数不支持自定义析构器(deleter),但是std::unique_ptr 和 std::shared_ptr却有着支持自定义析构器的构造函数。给定一个Widget的自定义析构器:

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

创建一个使用自定义析构器的智能指针:

    std::unique_ptr<Widget, decltype(widgetDeleter)>
      upw(new Widget, widgetDeleter);
      
    std::shared_ptr<Widget> spw(new Widget, widgetDeleter);

使用make系列函数,就没法完成上面的事情。

make系列函数的第二个限制来自于其实现的语法细节。Item 7解释过,如果在创建对象时,想要根据是否使用了std::initializer_list类型参数这一点,来决议调用构造函数的哪一个版本,将会导致这样的行为:在创建对象时使用的是大括号,会优先匹配形参类型为std::initializer_list的构造函数;而创建对象时使用的是圆括号,则会优先匹配参数类型为非std::initializer_list的构造函数。make系列函数会向对象的构造函数完美转发其形参,但这时它们到底是用圆括号这样做,还是用花括号呢?对于某些类型,这个问题的答案会有很大的不同。如下调用:

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

智能指针指向的std::vector,到底是10个元素、每个元素是20呢,还是包含两个元素,两个元素分别是10和20呢?抑或是结果不确定呢?

好消息是,结果并非不确定:两个调用都会创建一个包含10个元素,每个元素是20的std::vector。这说明,在make系列函数里,对形参进行完美转发的代码使用的是圆括号而非大括号。坏消息是,如果想使用大括号的初始化物来构建一个指向对象的智能指针,你就必须直接用new表达式。Item 30中解释了花括号初始化物不能被完美转发,但如果还是想make系列函数具备完美转发带花括号的初始化物,Item 30也给出了变通方法:使用auto类型推导通过花括号初始化物创建一个std::initializer_list对象,然后将auto创建的对象传给make系列函数:

    // 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);

对于std::unique_ptr,其make系列函数仅在这两种场景下(自定义析构器和花括号初始化物)会产生问题。而对于std::shared_ptr的make系列函数,还有另外两种场景,虽然是少见情况,但就是有人会游走在这些个少见情况下。

有些类会定义自己版本的operator new和operator delete,这些函数的存在表明全局的内存分配和释放函数不适用于这个类型的对象。通常,类自定义的这两个函数被设计成仅用来分配和释放刚好与类对象大小相同的内存块,例如,类Widget的operator new和operator delete通常被设计成只处理大小正好为sizeof(Widget)的内存块的分配和释放。这样的场景就不适合std::shared_ptr自定义分配器(通过std::allocate_shared)和自定义析构器的情况,因为std::aoolcate_shared所要求的内存大小并不等于动态分配对象的尺寸,而是要加上控制块的尺寸。因此,使用make系列函数去为带有自定义operator new 和operator delete的类创造对象是个坏主意。

之所以使用std::make_shared相比于直接使用new在尺寸和速度上更具优势,是因为std::make_shared使得std::shared_ptr的控制块和托管对象在同一块内存上。当托管对象的引用计数为零时,托管对象将被销毁(由其析构函数完成)然而,托管对象所占用的内存要等到control block也被销毁时才能释放,因为这个被动态分配出的内存同时包含了托管对象和control block。

control block中除了引用计数还包括一些其他信息,其中就还有两外一个引用计数,记录了指向该控制块的std::weak_ptr个数,这个引用计数被称为弱计数(weak count)【】。std::weak_ptr通过检查控制块里的引用计数(而非弱计数)来校验自己是否失效(see Item 19),如果引用计数为零,则std::weak_ptr就已失效,否则没有失效。

只要std::weak_ptr还指向某个控制块(即,弱计数大于零),该控制块就必须继续存在。只要控制块存在,包含它的内存肯定也会持续存在。如此一来,通过std::shared_ptr的maike系列函数所分配的内存就不会释放,直到最后一个指向它std::shared_ptr和std::weak_ptr销毁为止。

假如类对象特别大,且最后一个std::shared_ptr析构和最后一个std::weak_ptr析构之间的时间间隔较长,那么对象的析构和内存的释放之间就会产生延迟:

    class ReallyBigType {};
    
    auto pBigObj =                           // create very large
        std::make_shared<ReallyBigType>();   // object via
                                             // std::make_shared// 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表达式,那么只要最后一个指向ReallyBigType的std::shared_ptr被销毁,存放ReallyBigType对象的内存就可以立即被释放:

    class ReallyBigType {};              // as before
    
    std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType);
                                            // create very large
                                            // object via new// as before, create std::shared_ptrs and
                 // std::weak_ptrs to object, use them with it// final std::shared_ptr to object destroyed here,
                 // but std::weak_ptrs to it remain;
                 // memory for object is deallocated// during this period, only memory for the
                 // control block remains allocated// final std::weak_ptr to object destroyed here;
                 // memory for control block is released

无法应用std::make_shared的场景就介绍完了!

倘若在实际开发过程中,真遇到了不适用std::make_shared,而不得不用new的情况时,就需要额外关注前面提到过的异常安全问题。最好的办法是将new返回的结果立即传递给智能指针的构造函数,并且在这条与距离不要做其他事。举个我们前面用过的例子,processWidget,对其稍加改动,指定一个自定义的析构器:

    void processWidget(std::shared_ptr<Widget> spw,   // as before
                       int priority);
                       
    void cusDel(Widget *ptr);                         // custom
                                                      // deleter
    // !!! 如下调用是不安全的 !!!                                                  
    processWidget(                                    // as before,
        std::shared_ptr<Widget>(new Widget, cusDel),  // potential
        computePriority()                             // resource
);                                                 

上面的代码,我们分析过,是非异常安全的。由于我们使用了自定义的析构器,也就无法使用std::make_shared,而只能使用new了:

    std::shared_ptr<Widget> spw(new Widget, cusDel);
    
    processWidget(spw, computePriority());      // correct, but not
                                                // optimal; see below

这样就不会出现前面提到的指令重排引起的异常安全问题了。

这里有一个小小的性能问题需要注意:下面这个代码,传递给processWidget的是个右值

    processWidget(
        std::shared_ptr<Widget>(new Widget, cusDel), // arg is rvalue
        computePriority()
);

而下面的异常安全代码,传递的是个左值:

    std::shared_ptr<Widget> spw(new Widget, cusDel);
    processWidget(spw, computePriority());           // arg is lvalue
);

由于processWidget的形参std::shared_ptr是按值传递的,通过右值来构造它仅仅需要一次移动,而从左值构造则需要一次拷贝。对于std::shared_ptr而言,差别可能很大,因为拷贝std::shared_ptr需要对其引用计数进行原子增量,而移动std::shared_ptr则完全不需要对引用计数进行操作。为了让异常安全代码达到异常不安全代码的性能级别,我们需要将std::move应用到spw上,将其转换为右值(参见Item 23):

    processWidget(std::move(spw),          // both efficient and
                   computePriority());     // exception safe

这一点很有趣,而且值得了解,但通常无关紧要,因为我们很少有不适用make系列函数的理由。除非有充分的理由,否则应该尽量使用它们。

Things to Remember

  • 相比于直接使用new,make系列函数减少代码量,提高了一场安全性,并且对于std::make_shared 和 std::allocate_shared而言,能生成更小、更快的代码;
  • 不适用于make系列函数场景包括:需要自定义析构器(deleter),以及期望直接传递花括号初始化物;
  • 对于std::shared_ptr,不建议使用make系列函数的额外场景包括:(1) 自定义内存管理的类; (2) 内存紧张的系统,非常大的对象,以及存在比std::shared_ptr生存期更久的std::weak_ptr;



版权声明:本文为heusunduo88原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。