这篇是C++ 11系列文章的第五篇,我们这次谈谈C++ 11的神兵利器--智能指针。
1 概述
C/C++的程序员几乎不可能没掉进过指针(或者说动态内存)的坑,一方面,指针给C/C++程序员提供了直接访问内存的途径让他们“为所欲为”,另一方面,空悬指针、野指针、各种越界导致的致命错误又让他们欲哭无泪,基本上要吃很多亏,程序员才能形成一套自己的(或者大家公认的)编程规约,绕过常见的指针错误,稍不小心又在某些不常见的阴沟里翻船。对于初学者,指针是个难题,对于老司机,指针也要小心应付,特别是在多线程环境下,要想处理好指针的问题真的不是一件容易的事情。
C++中通过new创建一个对象,通过delete销毁一个对象;在合适的时机销毁一个对象是及其困难的(特别是在多线程环境下),有时候我们会忘记delete而造成内存泄漏,有时候我们又会在尚有使用者的情况下销毁一个对象,造成使用者对内存的非法访问;在多线程程序中,存在了太多的竞态条件,当你把一个原始指针暴露给别的线程,问题就来了,安全的销毁它并通知其它线程的使用者该指针失效是非常困难的,虽然极端情况的竞态条件发生概率不高,但对于长期运行的程序或是频繁操作的场景,如果处理不好,销毁了一个正在被其它线程使用的对象是必然要发生的(这种情况下最好的结果是程序崩溃)。
C++ 11提供的智能指针,使上面诸多问题迎刃而解,可以说智能指针是C++ 11提供的最为有用的特性之一,它让C++程序员可以像提供GC或ARC机制的语言(java等对象语义的语言都属此类)那样管理动态创建的对象,以对象语义(复制引用而非值)复制和传递对象,当且仅当没有任何使用者时(引用计数为零时),对象才会被销毁(并且立即销毁),大大简化了手动管理动态内存的困难。
2 shared_ptr
我们首先介绍智能指针中最常用的一类shared_ptr,它是类模板,在memory头文件中提供。与很多C++ 11特性一样,它先在boost库和tr1中提供。声明shared_ptr必须指明它包含的裸指针的类型,和其它类模板一样,在尖括号中给出类型,比如:
智能指针未被初始化之前,它只包含一个空指针(nullptr),可以通过bool运算符判断一个智能指针是否为空,如果为空,bool运算符返回false,可以这样写,把智能指针直接写在if的条件表达式里:
智能指针的初始化主要有两种方式:
1.把原生指针作为参数传给智能指针的构造函数,也可以通过其他shared_ptr拷贝构造,比如:
2.使用std::make_shared函数模板,这是最为安全的方式,下面的写法和上面是等效的:
make_shared定义于memory头文件,它会用接受的参数调用相应的构造函数创建新的对象。
shared_ptr可以像普通指针一样使用,解引用可以返回它所包含的对象,也可以通过->访问它所包含的对象的成员函数或变量。
shared_ptr通过引用计数的方式管理它所包裹的动态对象,当它被复制的时候(拷贝构造或拷贝赋值),引用计数会加1,当它析构、reset()或被其他智能指针赋值时,引用计数会减1,当引用计数为0时,智能指针会自动释放它所管理的对象。它的工作原理可以参考本系列的第3篇里一个简单的模拟实现;
由此可见,只要一个智能指针对象不为空,你就不用担心它所包裹的对象在别处被释放(因为引用计数至少为1),可以放心的使用;另外,程序员也再也不用纠结在哪里释放一个动态资源(特别是这个资源被多处使用时),智能指针会在“最后的时刻”自动释放它;包裹同一个动态对象的智能指针都是平权的,最初的智能指针也没有更多的特权,谁最后释放对它的持有,谁就负责销毁它,这就是shared_ptr的名字的来历。
我们举个例子,看看智能指针使用时,引用计数的变化情况,引用计数可以通过shared_ptr的成员函数use_count读取,代码如下:
运行输出为:
第一行,只有初始化的智能指针一个引用;
第二行,lambda对象对智能指针进行了值捕获,引用计数加1;
第三行,lambda对象销毁,引用计数减一,同时refFunction形参类型是引用类型,不会发生拷贝,所以引用计数还是1;
第四行,valFunction形参类型是值类型,智能指针发生了复制,引用计数加1;
当倒数第二个大括号结束时,strPtr对象析构,析构函数把引用计数减一,同时发现引用计数已经为0,因此销毁string对象。
3 uniq_ptr
顾名思义,uniq_ptr持有对对象的独有权——两个unique_ptr不能指向一个对象,不能进行复制操作只能进行移动操作。它也定义于memory头文件,如果某个动态对象的从属非常明确,可以用uniq_ptr代替shared_ptr(很显然,它比shared_ptr轻量,也不会被意外共享)。uniq_ptr析构、reset()或者被赋值时,它当前持有的对象就会被销毁。比如:
即使动态对象的作用域非常明确,可以直接new和delete,使用智能指针也是有额外好处的,比如:
这两段代码原理上是等效的,但是,如果第一段在new和delete之间的程序抛出了异常,执行路径发生变化,delete语句没有机会执行,就会发生内存泄漏;而第二段程序就没有这种危险,只要脱离tmp的作用域,uniq_ptr的析构函数就会处理好内存的释放。显然使用智能指针达到了更安全的目的。这种通过对象的生命周期管理资源的方式被称为RAII(Resource Acquisition Is Initialization),是C++中非常好的编程思想,智能指针本质上都是RAII思想的体现,RAII以后我们还会遇到。
4 定制删除器
智能指针默认的资源销毁操作是调用delete操作符,我们也可以定制自己的deleter,智能指针创建时作为最后一个参数传入,智能指针将在销毁资源是调用我们定制的deleter。deleter是一个可调用对象(参见第2篇),传入的参数是该智能指针包裹的动态资源。定制deleter一般用于如下的方面:
1. 动态内存是数组资源时,应该使用delete []销毁,这时使用delete操作符会出问题,而shared_ptr没有针对数组的特化版本(unique_ptr有),所以使用shared_ptr管理数组资源时,要自定义deleter,比如:
(最新公布的C++17标准,shared_ptr也可以管理数组资源了)
2.上一条推而广之,只要智能指针管理的资源不是new创建的,我们就需要指定一个deleter。比如一个文件指针,通过fopen创建,通过fclose关闭,如果用智能指针管理就需要定制deleter,如下:
3.如果想在释放动态资源时,再做一些额外的操作时,也需要通过自定义deleter实现,这里不再举例了。
5 几个要注意的问题
永远不要把智能指针包裹的资源直接暴露给第三方使用,而应该通过智能指针传递;
智能指针的复制效率肯定不如裸指针,但也相当不低(不涉及大量的内存操作),如果非常注重效率,在线程内部可以用智能指针的“常引用”调用函数(不发生拷贝),跨线程的地方,复制一份传给其他线程使用(不同的线程访问不同的shared_ptr对象(它包含的资源的线程安全性是另外的问题))。
lambda表达式内部需要捕获智能指针时,用值捕获;unique_ptr无法被lambda表达式值捕获,可以用std::bind,move到std::bind生成的可调用对象里面;
如果一个类决定采用智能指针管理,请注意不要对外暴露该类的this指针(参见第1条),特别是使用lambda表达式时要注意this指针的捕获问题,相关的解决方案下篇介绍;由此可知,有些智能指针管理的类要有特殊的写法(需要传递this指针的地方),所以,所有要实例化这样的类的地方,都要用智能指针管理,混用是危险的;
智能指针的类型转换与裸指针类似,对应static_cast和dynamic_cast,标准库分别提供static_pointer_cast和dynamic_pointer_cast两个函数模板执行智能指针间的类型转换;容易明白,智能指针可以像原生指针那样支持虚函数的多态,可以放心使用;
auto_ptr已经被unique_ptr代替,已废弃,本文也不再介绍;
智能指针需要注意的坑最著名的就是循环引用,我们在下一篇介绍;
能用unique_ptr就不用shared_ptr;
尽量用std::make_shared而不用裸指针创建智能指针,前者更高效和安全;
本文暂时没有评论,来添加一个吧(●'◡'●)