再说智能指针

  在之前的文章已经介绍过了现代C++最具价值的开发组件——智能指针了,这东西使用起来比较简单,而且大多数情况下也不容易用错,所以也不会去深究他。但是,最近看了Sutter和Meyer的相关文章和书籍后,对C++智能指针的使用有了更进一步的了解和认识。C++是一个讲求效能和务实的语言,如果本着这个观念去做事,那么C++的很多特性和组件都很有讲究,所以看见这些大神们的经验、总结和套路,虽然也有看不懂焦头烂额得时候,但一旦领略其中的奥妙,那种拨云见日,豁然开朗的感觉真的不可言喻。虽然相比于Python以及新秀Go,C++的复杂和深奥是出了名的,不过也正因为此,先比其他语言而言更具有折腾的潜质,其中的乐趣也只有体验过才会真正有所感受。

一、智能指针解析

  智能指针是用来管理堆上动态分配对象的利器,当然通过定制化析构的技巧,也可以管理其他类型的资源。

1.1 unique_ptr

  unique_ptr是一个独占类型的智能指针,其不允许其他的智能指针共享其内部管理的指针,所以该指针对象不允许被拷贝,但是允许通过函数被返回、通过std::move进行移动来转移其控制权。比较可惜的是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)...));
}

  unique_ptr在指定删除器的时候需要在创建unique_ptr对象的时候,指定删除器的类型信息作为模板参数(所以删除器类型是整个unique_ptr类型的一部分),而不能像shared_ptr那样直接指定删除器对象而不用指定删除器类型信息,甚至推迟在reset()的时候指定删除器,这是为了unique_ptr的高效而这样做的,可以产生更快的运行时代码。
  unique_ptr支持数组形式,但是应当避免使用它们,用容器来存储智能指针,而不要让智能指针指向数组。

1.2 shared_ptr

  shared_ptr是基于引用计数实现的智能指针,使用简单方便,但是在实际使用的时候出于效率和安全性考虑,还是有些东西需要注意考量的。比如不要使用一个原始指针创建多个智能指针对象、不要在函数实参中创建shared_ptr对象、当需要this指针的时候通过shared_from_this返回、避免循环引用导致对象无法被释放……
  shared_ptr有一个control block,因为保留有引用计数等信息,他必须是动态分配的,所以其相对于unique_ptr已经是一个重量级的智能指针,其额外的开销后面会介绍之。
shared_ptr

1.3 weak_ptr

  weak_ptr本身不能够独立使用,是寄生于shared_ptr行驶对对象的监测权的指针。
  weak_ptr一方面可以避免shared_ptr循环引用导致对象无法被正常释放,同时也是enable_shared_from_this()的内部实现构件:shared_from_this类中有一个weak_ptr,通过其观察this的智能指针,调用shared_from_this()的时候,会调用内部weak_ptr的lock()方法,将其所观测的this的智能指针返回。所以如果需要使用enable_shared_from_this,则必须事先建立这个类型的shared_ptr,直接创建这个类型的对象或者普通指针是无法编译链接的。

二、智能指针使用注意

2.1 默认优先使用unique_ptr

  如果unique_ptr满足要求,优先使用之,而且后续需要的时候也可以快速的move-convert至shared_ptr。
  unique_ptr内部不会像shared_ptr一样维护引用计数和control block,没有这些额外的负担其性能几乎和原始指针一样的迅速高效;如果不需要共享引用计数,使用unique_ptr会让你的意图更加的明确,而且C++11支持移动语义,所以unique_ptr也可以放到标准容器中,而在使用的时候直接借助原始指针就可以了,对象的生命周期还是完全可控的。
  unique_ptr可以方便的转换成shared_ptr,所以他几乎是工厂函数的标配!

2.2 尽量使用make_shared和make_unique

  使用make_shared和make_unique(C++14才开始支持)而避免使用原始的new创建指针后再创建智能指针对象,首先是为了异常安全考虑,因为new一个对象和创建智能指针对象是两个独立的操作,因为在某些情况下出现异常,会导致资源无法被释放:

1
foo(std::shared_ptr<Foo>{ new Foo{} }, bar() );

  上面的操作分别有new Foo、bar()、创建shared_ptr三个步骤,C++的特性是参数的求值顺序是没有保障的,如果他们是按照new Foo、bar()、创建shared_ptr这个顺序执行,而恰巧bar()调用抛出异常,那么new Foo创建的资源就会泄露。
  对于shared_ptr类型的智能指针,除了上面的异常安全考量之外,使用make_shared除了语法优美之外,还会有性能加成。前面说过shared_ptr会有control block部分,他包含强引用计数、弱引用计数组成,当强引用计数为0的时候其所控制的对象会被删除,而弱引用计数为0的时候control block本身才会被删除。如果不使用make_shared,对象的创建和control block会分两个步骤创建,涉及到两次内存的分配负担;而如果直接使用make_shared,系统就只有一次的资源分配的负担,而且创建的对象和control block是在一起的,这无论对于避免内存碎片和数据的局部预取、缓存都是更有裨益的。
control-block
  有得也有失,上面的control block和对象一次申请,带来的副作用就是两者只能一次被释放,这就意味着即使强引用计数已经为0了,但是对象的内存也无法被回收,只有在弱引用计数为0的时候,对象才能和control block一同被释放掉。关于这个问题,一个哥们已经做了相关的实验了。
  不能使用make_shared的情况有:如果需要制定一个定制的删除器,则无法使用make_shared,因为该操作不支持;如果使用的是和旧代码或者C代码交互的,在只能提供指针的时候,就无法使用make_shared创建对象了,这在封装C库的时候很常见。

2.3 智能指针和函数返回

  函数返回指针有时候需要特别小心,因为指针和资源是联系很紧密的,尤其对于工厂函数的情形来说,这种原始指针的返回常常让人抓狂,因为原始指针没有明确对象的生命周期由谁来负责,不看仔细文档还真不敢乱用。
  智能指针能够解决上述的问题,他不仅保证资源可以得到正确的释放,而且函数声明也具有明确的语义信息:如果你想表达函数不持有资源,则返回unique_ptr;如果函数持有该对象(使用shared_ptr或者weak_ptr方式持有),则应该返回一个shared_ptr。使用智能指针返回的额外好处还包含可以在函数内部指定指定智能指针的删除器,资源释放的操作很好的被封装起来而不需要调用者去关注。
  像前面说的,尽量使用unique_ptr,如果需要也可以方便的转换为shared_ptr:

1
auto sp = shared_ptr<widget>{ load_widget(2) };

  从上面的代码可以看出,C++11的auto此时就大显身手了,因为auto可以接收unique_ptr、shared_ptr类型的返回,即使修改了函数返回类型,客户端的调用代码也不需要做对应的变更!同时大家可以推测出,unique_ptr也可以同时接受unique_ptr和shared_ptr的返回类型,而unique_ptr只能接受unique_ptr的返回类型。

2.4 智能指针和函数参数

  对于shared_ptr by-value的传参,我们必须心知其代价:

1
void foo(shared_ptr<Widget> bar);

  之前我们知道,一个shared_ptr对象就需要在调用时候拷贝构造并在返回时候析构,同时control block的引用计数也需要对应的更新(很少情况会使用移动构造,比如智能指针是函数的返回值)。拷贝可能还好,但是引用计数通常使用原子共享变量(atomic)来实现的,在更新其值的时候需要进行内存同步操作,而且在现在多核处理器情况下会影响到程序的伸缩性(并行能力,因为同一时刻只能有一个core修改引用计数)和缓存的效率。
  所以只有在必要的情况下才考虑拷贝shared_ptr,进行引用计数的必要更新。但是这种方式相对于智能指针的引用传递有一个不可比拟的优势:他是安全的,因为传值调用的时候增加了引用计数,确保在函数执行的整个过程中指针所指向的对象不会被意外的释放掉。
  避开cv修饰符不谈,我们可以考虑以下四种智能指针的传参形式:

1
2
3
4
void foo( unique_ptr<Widget> );  // (1)
void foo( unique_ptr<Widget>& ); // (2)
void foo( shared_ptr<Widget> ); // (3)
void foo( shared_ptr<Widget> & ); // (4)

  (1). 这种情况常常用于comsuimg-func,也就是常说的”sink”。因为通过by-value传递一个unique_ptr,必定意味着控制权从caller转移给callee,此时callee可以决定是将这个对象销毁掉,还是转移到其他的地方,而caller已经无法控制它了。注意通常这种调用是肯定没有const来修饰的。
  而且这种语义的调用也是很安全的,因为其他形式的指针要想作为参数调用这个函数,必须采用explicit显示转换的方式才能通过编译(C++11中原始指针是不允许隐式转换成智能指针的,除非使用reset显式转换或者构造),这无疑提醒了调用者确认他们正在执行的操作:

1
2
3
4
Widget* pw = new Widget();
foo( unique_ptr<Widget>{pw});
unique_ptr<Widget> up_w = std::make_unique<Widget>();
foo(std::move(up_w));

  (2). 这种通过引用方式传递unique_ptr通常是作为in/out输出输出参数来使用的,比如可以传回一个指针、或者修改这个指针所指向的对象。
  这种调用一般不会用const unique_ptr&的形式,因为如果不修改unique_ptr,那么它的生命周期就至少和callee一样长,这还不如使用原始指针作为参数,再或者不考虑nullptr的情况,那么直接传递对象的引用更好。
  (3). 这种形式的调用是确保callee需要持有一份shared_ptr的拷贝,并且共享所管理对象的时候,通过拷贝增加引用计数的方式延长对象的生命周期,比如shared_from_this()这种情况。
  (4). 这种情况通常也在in/out的时候使用。而const shared_ptr&和原始指针相比,我也不知道到底哪个更好了!

本文完!

参考