Effective Modern c++ 智能指针

之前介绍过c++11智能指针,最近在看《Effective Modern c++》,又获取一些新知识,补充如下。(内容均摘抄或参考《Effective Modern c++》Chapter 4. Smart Pointers)

占用空间大小

当使用默认删除器时,可以合理假设std::unique_ptr和原始指针大小相同。当自定义删除器时,情况可能不再如此。删除器如是函数指针,通常会使std::unique_ptr的大小grow from one word to two。如果删除器是函数对象,大小取决于函数对象中存储的状态多少,无状态函数对象(比如没有状态捕获的lambda表达式)对大小没有影响。因此当一个自定义删除器可以实现为一个函数指针或无捕获状态的lambda表达式时,应优先选择后者。

std::shared_ptr指向一个控制块,控制块除了包含引用计数值,还有一个自定义删除器的拷贝,当然前提是存在自定义删除器。如果用户还指定了自定义分配器,控制器也会包含一个分配器的拷贝。控制块可能还包含一些额外的数据,比如一个次级引用计数weak count。我们可以想象std::shared_ptr对象在内存中是这样:
shared_ptr控制块
(图片另用base64编码冗余一份,放到文章最后了)

避免将一个原生指针,交由多个shared_ptr管理

问题类似这样:

auto pw = new Widget; // pw是原始指针
…
std::shared_ptr<Widget> spw1(pw, loggingDel); // 为*pw创建控制块
…
std::shared_ptr<Widget> spw2(pw, loggingDel); // 为*pw创建第二个控制块

通常替代方案是使用std::make_shared,不过上面例子中,我们使用了自定义删除器,用std::make_shared就没办法做到。可以这样修改:

std::shared_ptr<Widget> spw1(new Widget, loggingDel); // 直接使用new的结果
std::shared_ptr<Widget> spw2(spw1); // spw2使用spw1一样的控制块

优先考虑使用std::make_unique和std::make_shared而非new

c++11提供了make_shared但没提供make_unique(c++14才提供了make_unique),可能是因为make_shared比make_unique更有必要性,自己实现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)...));
}

防止异常

假设我们有个函数按照某种优先级处理Widget:

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

假设有一个函数来计算相关的优先级:

int computePriority();

并且我们在调用processWidget时使用了new而不是std::make_shared:

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

这段代码可能在new Widget时发生泄露。为何?答案和编译器将源码转换为目标代码有关。在运行时,一个函数的参数必须先被计算,才能被调用,所以在调用processWidget之前,必须执行以下操作,processWidget才开始执行:

  • 表达式new Widget必须计算
  • 负责管理new出来指针的std::shared_ptr<Widget>构造函数必须被执行
  • 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>(), computePriority());

另外,也可以提前创建好std::shared_ptr来避免异常:

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

一个小小的性能问题是,因为processWidget的std::shared_ptr参数是传值,传右值给构造函数只需要move,而传递左值需要拷贝。对std::shared_ptr而言,这种区别是有意义的,因为拷贝std::shared_ptr需要对引用计数原子加1,move则不需要对引用计数有操作。这样修改就行了:

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

std::make_shared效率提升

std::make_shared的一个特性(与直接使用new相比)是得到了效率提升。使用std::make_shared允许编译器生成更小,更快的代码,并使用更简洁的数据结构。考虑以下对new的直接使用:

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

显然,这段代码需要进行内存分配,但它实际上执行了两次内存分配。每个std::shared_ptr指向一个控制块,这个控制块的内存在std::shared_ptr构造函数中分配。因此,直接使用new需要为Widget分配一次内存,为控制块分配再分配一次内存。
如果使用std::make_shared代替:auto spw = std::make_shared_ptr<Widget>();一次分配足矣。这是因为std::make_shared分配一块内存,同时容纳了Widget对象和控制块。这种优化减少了程序的静态大小,因为代码只包含一个内存分配调用,并且它提高了可执行代码的速度,因为内存只分配一次。
对于std::make_shared的效率分析同样适用于std::allocate_shared,因此std::make_shared的性能优势也扩展到了该函数。

不适合使用std::make_shared和std::make_unique的情形

一 没有make函数允许指定定制的析构,但是std::unique_ptr和std::shared_ptr有构造函数支持。

二 make函数会将它们的参数完美转发给对象构造函数,但是它们是使用圆括号还是大括号?对某些类型,问题的答案会很不相同。例如:

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

生成的智能指针是否指向带有10个元素的std::vector,每个元素值为20,或指向带有两个元素的std::vector,其中一个元素值10,另一个为20 ?
答案是两种调用都创建了10个元素,每个值为20。这意味着在make函数中,完美转发使用圆括号,而不是大括号。
如果想用大括号初始化指向的对象,必须直接使用new。或者使用一种变通的方法:使用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);

原书说:使用make函数需要能够完美转发大括号初始化,但是大括号初始化无法完美转发。 所以并不能用std::make_shared<std::vector<int>>({10, 20});,试了下确实无法编译通过。

item30 中说:passing a braced initializer to a function template parameter that’s not declared to be a std::initializer_list is decreed to be, as the Standard puts it, a “non-deduced context.”
也就是说,函数模板参数无法推断大括号初始化列表的类型。
但auto可以推断,所以可先用auto声明一个局部变量,这个局部变量被推断为std::initializer_list类型,再将其传入函数模板。

对于std::make_shared,还有两点需要注意:
重载了operator new和operator delete的类不适合使用std::make_shared。这些函数的存在意味着对这些类型的对象的全局内存分配和释放是不合常规的。设计这种定制类往往只会精确的分配、释放对象的大小。例如,Widget类的operator new和operator delete只会处理sizeof(Widget)大小的内存块的分配和释放。std::make_shared需要的内存总大小不等于动态分配的对象大小,还需要再加上控制块大小。因此,适用make函数去创建重载了operator new 和 operator delete类的对象是个典型的糟糕想法。

与直接使用new相比,std::make_shared在大小和速度上的优势源于std::shared_ptr的控制块与指向的对象放在同一块内存中。当对象的引用计数降为0,对象被销毁(析构函数被调用)。控制块还有第二个计数,记录多少个std::weak_ptr指向控制块。只要std::weak_ptr引用一个控制块,该控制块必须继续存在。只要控制块存在,包含它的内存就不能释放。
所以如果对象本身所占内存比较大,且内存使用比较紧张,可以考虑直接new而不使用std::make_shared。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值