关闭

Effective Modern C++ 条款21 比起直接使用new,更偏爱使用std::make_unique和std::make_shared

标签: c++
863人阅读 评论(1) 收藏 举报
分类:

比起直接使用new,更偏爱使用std::make_unique和std::make_shared

让我们从std::make_uniquestd::make_shared之间的比较开始讲起吧。std::make_shared是C++11的一部分,可惜的是,std::make_unique不是,它在C++14才纳入标准库。如果你使用的是C++11,不用忧伤,因为std::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只是把参数完美转发给要创建对象的构造函数,再从new出来的原生指针构造std::unique_ptr,最后返回创建的std::unique_ptr。这种形式的函数不支持数组和自定义删除器(看条款18),但它说明了只要一点点工作,你就可以创造你需要的make_unique了。你要记住不要把你自己的版本放入命名空间std,因为当你提升到C++14标准库实现的时候,你不会想要它和标准库的版本冲突。

std::make_uniquestd::make_shared是三个make函数中的其中两个,而make函数是:把任意集合的参数完美转发给动态分配对象的构造函数,然后返回一个指向那对象的智能指针。第三个make函数是std::allocate_shared,它的行为与std::make_shared类似,除了它第一个参数是个分配器,指定动态分配对象的方式。

通过琐碎比较使用make函数和不使用make函数创建智能指针,揭露了使用make函数更可取的第一个原因。考虑以下:

auto upw1(std::make_unique<Widget>());  // 使用make函数

std::unique_ptr<Widget> upw2(new Widget);  // 不使用make函数

auto spw1(std::make_shared<Widget>());  // 使用make函数

std::shared_ptr<Widget> spw2(new Widget);  // 不使用make函数

它们本质上的不同是:使用new的版本重复着需要创建的类型(即出现了两次Widget),而使用make’函数不需要。重复出现类型和软件工程的关键原则产生冲突:应该避免代码重复。源码中重复的代码会增加编译时间,导致对象代码膨胀,并且通常会让代码库更难运行——这经常引发不合逻辑的代码,而不合逻辑的代码库一般会出现bug。除非是写两次比写一个更有效果,不然谁不喜欢少些点代码吗?


更偏爱使用make函数的第二个原因异常安全。假如我们有个函数,根据优先级来处理Widget:

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

值传递std::shared看起来有点奇怪,不过条款41会解释如果processWidget内部总是复制std::shared_ptr(例如,把它存储在一个数据结构中,这个数据结构会监测Widget是否被处理),这设计是个合理的选择。

现在呢,假如我们有个计算优先级的函数,

int computePriority();

然后我们用它和new创建的智能指针作为参数调用processWidget:

processWidget(std::shared_ptr<Widget>(new Widget), 
              computePriority());    // 可能会资源泄漏

就如注释所说,这代码中new出来的Widget可能会泄漏,但是为什么?std::shared_ptr是为了防止资源泄漏而设计的,当最后一个指向资源的std::shared_ptr对象消失,它们指向的资源也会被销毁。如果每个人无论什么地方都使用std::shared_ptr,C++还有内存泄漏这回事吗?

答案是在编译期间,源代码转换为目标码时(*.o文件)。在运行时间,函数的参数在函数运行前必须被求值,所以调用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产生了异常,步骤1中动态分配的Widget就泄漏了,因为它没有被步骤3中的std::shared_ptr保存。

使用std::make_shared_ptr可以避免这问题。这样调用代码:

processWidget(std::make_shared<Widget>(), computePriority())

在运行期间,std::make_sharedcomputePriority都有可能先被调用,如果先调用的是std::make_shared,那么指向动态分配Widget对象的原生指针会安全地存储在要返回的std::shared_ptr中,然后再调用computePriority。如果computePriority产出异常,std::shared_ptr的析构函数就会销毁持有的Widget。而如果先调用的是computePriority,并且产生异常,std::make_shared就不会被执行,因此没有动态分配的Widget对象让你担心。

如果我们把std::shared_ptrstd::make_shared替换成std::unique_ptrstd::make_unique,效果一样。使用std::make_unique替代new的重要性就像使用std::make_shared那样:写异常安全的代码。


std::make_shared的一个特点(相比于直接使用new)是提高效率。使用std::make_shared允许编译器生成更小、更快的代码。考虑当我们直接使用new时:

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

很明显这代码涉及一次内存分配,不过,它实际上分配两次。条款19说明每个std::shared_ptr内都含有一个指向控制块的指针,这控制块的内存是由std::shared_ptr的构造函数分配的,那么直接使用new,需要为Widget分配一次内存,还需要为控制块分配一次内存。

如果用std::make_shared呢,

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

一次分配就够了,因为std::make_shared会分配一大块内存来同时持有Widget对象和控制块。这种优化减少了程序的静态尺寸,因为代码只需要调用一次内存分配函数,然后它增加了代码执行的速度,因为只需要分配一次内存(说明是分配内存这个函数开销略大)。而且,使用std::make_shared能避免了一些控制块的簿记信息,潜在地减少了程序占用的内存空间。

std::allocate_shared的性能分析和std::make_shared一样,所以std::make_shared的性能优势也可以延伸到std::allocate_shared


比起直接使用new,更偏爱使用make函数,这个争论是很热烈的。虽有软件工程、异常安全、性能优势,不过,本条款的指导方针是更偏爱使用make函数,而不是单独依赖它们,这是因为在某些状况下它们不适用。

例如,没有一个make函数可以指定自定义删除器(看条款18和19),但是std::unique_ptrstd::shared_ptr都有这样的构造函数。给定一个Widget的自定义删除器,

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

我们可以直接使用new创建智能指针:

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

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

make函数就做不来这种事情。


make函数的第二个限制是来源于它们实现的句法细节。条款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);

指针指向的是带10个元素、每个值为20的std::vector呢,还是指向两个元素、一个10、一个20的std::vector呢?还是说结果不能确定吗?

好消息是结果是能确定的:上面两个都创建内含10个值为20的std::vector。那意味着在make函数内,完美转发使用的是圆括号,而不是大括号。坏消息是如果你想用大括号初始化来构造指向的对象,你只能直接使用new,如果你想使用make函数,就要求完美转发的能力支持大括号初始化,但是条款30说明,大括号初始化不能被完美转发。不过条款30也讲了一种能工作的方法:用auto推断大括号,从而创建一个std::initializer_list对象(条款2),然后把auto变量传递给make函数:

// 创建 std::initializer_list
auto initList = {10, 20};

// 使用std::initializer_list构造函数创建std::vector,容器中只有两个元素
auto spv = std::make_shared<std::vector<int>>(initList);

对于std::unique_ptr,只有两种情况(自定义删除器和大括号初始化)会让它的make函数出问题。对于std::shared_ptr和它的make函数,就多两种情况,这两种情况都是边缘情况,不过一些开发者就喜欢住在边缘,你可能就是他们中第一个。

一些类定义了自己的operator newoperator delete函数,这些函数的出现暗示着常规的全局内存分配和回收不适合这种类型的对象。通常情况下,设计这些函数只有为了精确分配和销毁对象,例如,Widget对象的operator newoperator delete只有为了精确分配和回收大小为sizeof(Widget)的内存块才会设计。这两个函数不适合std::shared_ptr的自定义分配(借助std::allocate_shared)和回收(借助自定义删除器),因为std::allocate_shared请求内存的大小不是对象的尺寸,而是对象尺寸加上控制块尺寸。结果就是,使用make函数为那些——定义自己版本的operator newoperator delete的——类创建对象是个糟糕的想法。


比起直接使用newstd::make_shared的占用内存大小和速度优势来源于:std::shared_ptr的控制块与它管理的对象放在同一块内存。当引用计数为0时,对象被销毁(即调用了析构函数),但是,它使用的内存不会释放,除非控制块也被销毁,因为对象和控制块在同一块动态分配的内存上。

就像我提起那样,控制块上除了引用计数还有别的薄记信息。引用计数记录的是有多少std::shared_ptr指向控制块,但是控制块还有第二种引用计数,记录有多少std::weak_ptr指向控制块。这种引用计数称为weak count。当std::weak_ptr检查它是否过期时(expired,看条款20),它通过检查控制块中的引用计数(不是weak count)来实现。如果引用计数为0(即没有std::shared_ptr指向这个对象,因此被销毁),std::weak_ptr就过期,否则就没有过期。

但是,只要有std::weak_ptr指向控制块(weak count大于0),控制块就必须继续存在,而只要控制块存在,容纳它的内存块也依旧存在。那么,通过make函数创建对象分配的内存,要直到最后一个指向它的std::shared_ptrstd::weak_ptr对象销毁,才能被回收。

如果对象的类型非常大,并且最后一个std::shared_ptr销毁和最后一个std::weak_ptr销毁之间的时间间隔很大,那么是对象销毁和内存被回收之间的会有延迟:

class ReallyBigType { ... };

auto pBigObj =                          // 借助std::make_shared
   std::make_shared<ReallyBigType>();   // 创建类型非常大的对象
...               // 创建std::shared_ptr和std::weak_ptr指向对象 
...               // 最后一个std::shared_ptr被销毁,那仍有std::weak_ptr存在
...               // 在这个期间,之前类型非常大的对象使用的内存仍然被占用
...               // 最后一个std::weak被销毁,控制块和对象共占的内存被释放

如果直接使用new,ReallyBigType对象的内存只要在最后一个std::shared_ptr被销毁就能被释放:

class ReallyBigType { ... };        // 如前

std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType); //借助new创建
...              // 如前,创建std::shared_ptr和std::weak_ptr指向对象
...              // 最后一个std::shared_ptr被销毁,仍有std::weak_ptr存在
                 // 对象的内存被回收
...              // 在这期间,只有控制块的内存被占用
...              // 最后一个指向对象的std::weak_ptr被销毁,控制块的内存被释放

当你发现某些情况不能使用或者不适合使用std::make_shared,却又想要防止容易发生的异常安全问题。最好的办法就是确保当你直接使用new时,用一条语句执行——把new的结果马上传递给智能指针的构造函数,并且该语句就做这一件事。这防止编译器生成newstd::shared_ptr构造之间发出异常。

作为例子,我们修改之前的异常不安全processWidget,并指定自定义删除器:

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

void cusDel(Widget *ptr);      //  自定义删除器

这里是异常不安全的调用:

processWidget(           // 如前,可能资源泄漏
   std::shared_ptr<Widget>(new Widget, cusDel),
   computePriority()
);

回忆:如果computePriority调用在“new Widget”之前,std::shared_ptr构造之后,然后computePriority产生异常,那么动态分配的Widget就会泄漏。

这里要使用自定义删除器,不能使用std::make_shared,所以避免泄漏的方法就是把分配Widget和std::shared_ptr构造放在只属于它们的语句,然后再用std::shared_ptr的结果调用processWidget。这是这项技术的本质部分,等下我们可见到,我们可以修改它从而提高性能:

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

processWidget(spw, computeWidget);  // 正确,但没有优化,看下面

这代码是可行的,因为std::shared_ptr得到了原生指针的所有权,尽管构造函数可能发出异常。在这个例子中,如果spw的构造期间抛出异常(例如,由于不能为控制块动态分配内存),也能保证cusDel被调用(以“new Widget”的结果为参数)。

有个小小的性能问题,在异常不安全的调用中,我们传给processWidget的是一个右值,

processWidget(
   std::shared_ptr<Widget>(new Widget, cusDel),  // 参数是右值
   computePriority()
);

但是在异常安全的调用中,我们传递的是个左值:

processWidget(spw, computePriority());   // 参数是左值

因为processWidget的std::shared_ptr参数是值传递,从一个右值构造使用的是移动,从一个左值构造使用的是拷贝。对于std::shared_ptr,这差别挺大的,因为拷贝一个std::shared_ptr需要增加它的引用计数,这是原子操作,而移动操作完全不用操作引用计数。针对于异常安全代码想要达到异常不安全代码的性能水平,我们需要使用std::move来把spw转化为右值(看条款23):

processWidget(std::move(spw), computePriority());  // 现在也一样高效

这是有趣的而且值得知道,但是通常也是不相干的,因为你很少有理由不用make函数,除非你有迫不得已的理由,否则,你应该使用make函数。

总结

需要记住的3点:

  • 相比于直接使用new,make函数可以消除代码重复,提高异常安全,而且std::make_sharedstd::allocate_shared生成的代码更小更快。
  • 不适合使用make函数的场合包括需要指定自定义删除器和想要传递大括号初始值。
  • 对于std::shared_ptr,使用make函数可能是不明智的额外场合包括(1)自定义内存管理函数的类和(2)内存紧张的系统中,有非常大的对象,然后std::weak_ptrstd::shared_ptr长寿。
0
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:30197次
    • 积分:841
    • 等级:
    • 排名:千里之外
    • 原创:17篇
    • 转载:0篇
    • 译文:38篇
    • 评论:11条
    博客专栏
    最新评论