目录
一、智能指针的概念
二、RAII
三、 智能指针的拷贝构造
1. 智能指针的拷贝构造问题
2. C++库中的智能指针
2.1 auto_ptr
2.2 unique_ptr
2.4 weak_ptr
五、 定制删除器
一、智能指针的概念
在了解智能指针的概念前,先写出如下程序:
在这个程序里面,Div函数会抛出一个异常,func函数会捕获这个异常并将这个异常继续抛出给main函数。注意,在这个程序里面的func函数new了一个空间,在catch中释放了这块空间。此时只new了一块空间。
但是,如果在这个程序中的func函数中再new一个空间。此时就需要释放两个空间。有人可能会认为多开一块空间没什么,只需要在catch中新增一条delete语句即可:
但是,这里大家忽略了一个问题,那就是new其实也是可能抛异常的。比如当new的空间过大,内存无法满足的情况下, new就会抛出一个异常。因此,在新创建了一个空间后,也需要对这块空间进行捕获:
这种写法首先看起来很难看。并且最重要的是,由于new可能抛异常,所以如果再增加一个p3,就需要再套一层try catch。随着new的空间越多,就需要套越多层的try catch。这种写法无疑是非常麻烦且难看的。
面对这种情况时,就可以使用智能指针。提供如下一个类:
有了这个类后,就可以不再需要在捕获中释放空间了。修改func函数:
在这里,将p1和p2交给SmartPtr对象,让这个类中的_ptr指向对应的空间。这两个类在这里属于临时变量,一旦出了作用域就会结束。因此,当Div函数出现异常时,它会直接跳转到main函数的catch中,此时func函数的生命周期结束,也就带着这两个SmartPtr对象销毁了。而这两个对象在销毁时会调用析构函数,用delete销毁new出来的空间。
通过上面这种方式,就可以“将new出来的空间的生命周期与一个局部对象的生命周期相绑定”,实现自动释放的功能。也就无需再为了防止Div函数出错而使用catch捕获异常以便于在catch中释放对应的空间。
当然,为了方便,也可以将func中的代码简写:
既然这个类叫做只能指针,当然也需要有像指针一样的功能,所以在类中添加如下内容:
然后写下测试代码并运行测试:
运行正常。
上面的这种将资源生命周期与对象生命周期相绑定的方法,其实就是RAII。总结起来,智能指针的原理其实就是使用RAII的特性和重载了*与->,具有像指针一样的行为。
二、RAII
RAII(Resource Acquisition is Initialization),翻译过来就是“资源申请即初始化”。就是一种“利用对象声明周期来控制程序资源”(如内存、文件句柄、网络连接、互斥量等)的简单技术。
简单来讲,RAII就是“在对象构造时获取资源”,控制资源的访问并使之在这个对象的声明周期内始终保持有效。最后“在对象析构时释放资源”。因此,这种技术实际上就是将一份资源交给了一个对象管理。
这种做法有两个好处:
(1)不需要显式地释放资源。因为创建出来的对象都是局部对象,出了作用域自动调用析构函数销毁,也就将管理的资源一并销毁了。
(2)这种方式,将申请的空间的生命周期与对象的生命周期绑定,便于用户更好的管理资源。
三、 智能指针的拷贝构造
1. 智能指针的拷贝构造问题
现在有如下一个我们自己写的智能指针:
写出如代码:
运行上面的代码:
此时就出现了报错。原因很简单。在这个智能指针的类中并没有写拷贝构造,因此编译器会自动生成一个进行浅拷贝的拷贝构造函数,此时sp1和sp2指向同一块空间,在程序结束析构的时候就析构了两次,导致程序错误。
其实智能指针中的RAII和“像指针一样”这两个特性都是非常简单的,并没有什么难度。智能指针真正的问题之一,就在于这个拷贝构造上。
智能指针的行为其实就是在模拟原生指针的行为,所以这里的拷贝构造其实就是要让两个指针指向同一个位置。
2. C++库中的智能指针
2.1 auto_ptr
在C++98中,其实就已经提出了智能指针的概念。第一个智能指针是“auto_ptr”:
C++98中的auto_ptr针对拷贝构造提出的解决方案就是“资源管理权转移”。简单来讲就是在一个智能指针在拷贝另一个智能指针后,会将原指针指针的资源转移给新的智能指针,然后将自己置空。写如下代码进行测试:
运行该程序:
可以看到,sp1被置为了空,而sp1的资源被转移到了sp2中。这个智能指针的解决方案有一个很大的问题,就是会导致“悬空”问题:
例如如上代码,如果是一个不清楚这个特性,或者说在使用时没注意到这个问题的人就可能错误的使用sp1这个已经被置空的指针,进而出现错误。
如果我们想实现这一特性,也非常的简单,就是将资源交换然后将原指针置空即可:
一般来讲,在实际使用中是非常不推荐使用auto_ptr的。
2.2 unique_ptr
unique_ptr其实是C++委员会从boost库中抄来的,包括下面的shared_ptr和weak_ptr也是如此。boost库可以看做C++标准库的一个预备库,是由C++委员会发起建立的,里面的很多内容在未来都可能进入C++标准库。
unique_ptr解决拷贝构造的方法就很粗暴,从名字“唯一指针”上就可以看出来,它的解决方案就是“禁止拷贝构造”。写入如下代码进行测试:
运行该程序后,可以看到如上报错。表示使用了已经删除的函数。这就可以证明,unique_ptr其实就是禁止了对智能指针的拷贝构造。实现方式也很简单,直接使用delete关键字即可:
带有这个关键字的类中的默认成员函数会被禁止生成和使用。
2.3 shared_pt
上面的unique_ptr是禁止拷贝,但如果我们就是想让两个不同的指针指针指向同一块空间呢?此时就可以使用shared_ptr。从名字“共享指针”就可以看出来,这个智能指针是允许不同的智能指针指向同一块空间的。写出如下代码测试:
运行程序查看监视窗口:
可以看到,sp1和sp2指向的是同一块空间。
shared_ptr对拷贝构造的解决方案就是“计数器”。
shared_ptr通过计数器的方式记录某块空间有几个智能指针指向,每多一个就增加计数器,在析构时,先--计数器,如果计数器不为0,则不释放空间;如果计数器为0,则释放空间。
那么如何实现这个计数器呢?有的人可能就想,既然要让不同的智能指针看到同一块空间,就可以定义一个静态成员变量,这样就可以解决问题:
但是要知道,static成员是整个类(类所实例化的所有对象)共享的。这也就是说,确实指向同一块空间的智能指针能看到同一块空间。但是,指向不同空间的智能指针也是看到的同一个计数器。如果出现有三个智能指针指向同一块空间,此时计数器为3;但是此时又出现一个智能指针指向其他空间,由于看到的是同一个计数器,所以此时计数器++,变为4。很明显不满足需要。
因此,智能指针的计数器必须让指向同一个空间的智能指针看到同一个计数器;指向不同空间的智能指针看到不同的计数器。
要实现这一方法也很简单。首先定义一个计数器变量,在构造函数中单独为这个计数器new一块空间。此时这个变量的值就存在于堆上。不会因为某个对象结束而被销毁。当要进行拷贝构造时,首先++被拷贝对象的计数器。再让要拷贝的对象的计数器指向被拷贝的计数器,此时它们看到的就是同一个计数器。实现起来也非常简单:
实现了拷贝构造后,再来实现赋值。如果是指向同一块空间的指针赋值,就什么都不需要做。但如果是指向不同空间的指针赋值,首先就需要--原智能指针的计数器;如果计数器为0,还需要释放空间。如果不为0,就要将被赋值的智能指针指向的空间和计数器指向赋值的智能指针,最后再++被赋值智能指针的计数器:
要实现起来,就比拷贝构造复杂一点:
2.4 weak_ptr
weak_ptr并不是单独使用的,它需要配合shared_ptr,主要用于解决shared_ptr的循环引用问题。这个智能指针主要用于提供对shared_ptr的拷贝构造,甚至不允许带参构造:
要实现起来也是比较简单的:
至于这个weak_ptr如何解决循环引用的问题, 就放在下面讲。
四、shared_ptr的循环引用问题
shared_ptr是一个支持多个智能指针指向同一块空间的类。这个智能指针的多方面都很好用,但有一个很严重的问题存在,就是“循环引用”问题。
写出如下程序:
该程序可以看成一个简化版的链表,每个数据块中只有两个链接上下数据块的节点。创建两个节点,让这两个节点链接起来。运行程序:
此时可以发现,当这个程序结束后,什么都没有打印。但是我们自己写的析构函数中是加了一句话的。既然这里没有打印,也就说明在这个程序结束后,没有调用析构函数释放空间。
我们屏蔽掉一个节点指向后再运行程序:
可以看到,当对一个节点指向屏蔽后,就可以正常调用析构函数了。但是,如果是向上面那样两个节点互相指向,却无法析构。
上面的代码中所用的是我们自己写的shared_ptr,那么这是不是我们自己写的代码有问题呢?换成库中的shared_ptr试试:
要注意,库中的构造函数是加了explicit关键字的,禁止隐式类型转换。所以这里不能使用=创建n1和n2。运行该程序:
可以看到,在两个节点互相指向的情况下,库中shared_ptr也无能为力,无法调用析构函数。同样的,隐藏一个节点指向后运行程序:
同样的,此时又可以正常调用析构函数了。
这种节点互相指向导致无法析构的情况,就叫做“循环引用”问题。
原理很简单,假设有n1和n2两个节点,这两个节点互相指向。而shared_ptr中是存在计数器的,这也就意味着当这两个节点互相指向的时候,n1和n2的计数器都会++变为2。当要析构时,首先析构n2,将n2的计数器--为1,但是此时并没有释放空间,因为n1中还有一个shared_ptr,即_next指向n2;于是接着释放n1,--n1的计数器为1,此时n1也没有释放,因为n2中的有一个shared_ptr,即_prev指向n1。此时就会出现要释放n1,就必须释放n2中的_prev;要释放n2,就要释放n1中的_next的情况。两个节点互相等待对方的释放,导致双方都无法释放。
那么如何解决这个问题呢?很简单,只需要在指向空间时不要++计数器即可。但是shared_ptr是无法自行做到这件事的,所以,库中便提供了weak_ptr来专门处理这种情况:
修改程序如下:
可以看到,此时依然是存在两个指针互相指向的情况。运行该程序:
可以看到,程序可以正常析构。
至于这个weak_ptr如何模拟实现,在上文中已经讲解过,这里就不再赘述。换成我们自己写的weak_ptr来测试程序:
同样可以正常析构。当然,库中的实现还考虑了很多问题,实现的复杂程度要比我们自己实现的复杂的多,但单个智能指针的实现思想是一样的。
五、 定制删除器
大家知道,在C++中提供了new来申请空间。而new申请空间时,有两种申请方式。一种是不带[]申请,只有一块固定空间;带[],则可以指定申请对应大小的空间。这两种空间的删除方式并不一样。错误使用可能会带来严重后果
如果是内置类型,使用错误的删除方式可能还没有问题:
但如果是自定义类型,使用错误的方式就可能出现问题:
此时就有一个问题了,在智能指针中,如何得知应该使用哪种方式释放空间呢?
此时,就需要使用定制删除器,指定释放空间的方式。
库中的shared_ptr中也是有定制删除器的,其实就是提供仿函数:
例如下图:
在这里,不仅可以正常传仿函数,也可以传lambda表达式。这里大家可能就会比较奇怪了,在以前传仿函数时,都是在类型名处传仿函数名,为什么库中的却是在构造对象的地方,即构造函数中传可调用对象呢?这其实就和C++库中shared_ptr的底层实现有关。
在这里,我们是无法实现像库中这样实现定制删除器的。因为库中其实套了很多个类,通过这些类的嵌套来实现让shared_ptr拿到这个可调用对象。如果单单对构造函数进行修改是没有用的:
在构造函数中单独加一个模板,虽然可以将函数对象传进去,但是要知道,在这里我们并不是要让构造函数使用这个删除器,而是要让shared_ptr使用这个删除器。更准确点,是让shared_ptr的析构函数使用。如果单单给类中的构造函数加一个参数模板,如何让整个类拿到呢?很明显,是无法实现的。库中为了实现这一方法,就采用了多个类的嵌套实现这一操作。实现起来是非常复杂的,这里就不过多讲解。
但是我们要使用定制删除器也是有方法的,那就是给整个类加上一个参数模板即可。
通过传仿函数的方式,就可以让智能指针内部拿到对应的释放空间的方式。但是这种方式有一个缺点,那就是无法使用lambda表达式:
因为lambda表达式是一个可调用对象,但是新增参数模板的方式是要在模板中填入类型,所以无法使用lambda表达式。有人可能就会想到使用decltype来声明这个表达式是一个类型,同样是无效的。因为decltype是运行时推导,而这里传入的类型是要在编译时就传入,所以decltype失效:
当然,不仅shared_ptr是这样,unique_ptr其实也是一样的,都是经过多个类的嵌套实现了在构造函数中传入释放资源的方法。