1. 智能指针 RAII
RAII (Resource Acquisition Is Initialization) 是一种利用对象声明周期来控制程序资源的简单技术,也被形象化的称为的智能指针。
智能指针在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期结束前有效,最后在对象析构的时候释放资源。借此我们实际上把一份资源的责任托管给了一个对象,使得我们不再需要显示的释放资源,同时还可以避免了乱抛异常导致的内存泄露问题。
举个例子,我们假设A类是一个需要开空间的类。Division函数中抛异常没关系,我们将收集到异常并清理好资源再抛出去。但是如果array2申请空间时抛异常了array1就内存泄漏了。
这时我们就可以使用智能指针将array1和array2管理起来
建立一个SmartPtr类模板,在这个类的对象析构的时候就可以连带着把它管理的资源统统清理掉,右边是最正宗的写法,就是直接把对象new在智能指针里头。
2. 智能指针的原理
上述的SmartPtr还不能完全称之为智能指针,因为它还不具有指针的行为。指针可以解引用,也可以通过 -> 去访问所指空间中的内容,因此我们写的这个模板中还要添加 * -> 重载才能让其像指针一样使用。
当然,如果这个智能指针管理的是内核为数组的内容时也需要重载一下方括号。
但仅仅这样还没有完全解决问题,如果在智能指针之间拷贝就会出现问题。智能指针之间的拷贝如果是浅拷贝的话,两个智能指针共同管理同一块指针空间,那在析构的时候就会有重复析构同一块空间的问题。但是智指针的拷贝又不能使用深拷贝实现,因为他这个拷贝的功能就是想让两个指针共同管理一块空间的,如果深拷贝了就不符合这个思想了。
3. 标准库中的智能指针
智能指针在C++标准库中有专门的接口的,放在memory库当中,也就是说使用库中的智能指针包装的话需要包一下这个库函数。
auto_ptr是c++98时期的产物,它在处理拷贝的时候使用管理权限转移的方案,比如,把智能指针sp1要拷贝给sp2,那就直接把sp1中的所有内容都交换给sp2,此时sp1就空了,不可用了,但是在代码中却表现不出来,很容易让人误用。因此auto_ptr的设计是失败的,在更新C++11后就可以说废弃了。
unique_ptr是常用的智能指针之一,它对于拷贝的处理方案就是是禁止拷贝,用delete关键字直接将拷贝相关的默认成员函数禁用掉。
shared_ptr是是常用的智能指针之一,它对于拷贝的处理方案是拷贝计数,就是说每拷贝一次让这两个指针都共同管理一块空间,但是增加一次计数,到了某个指针要析构的时候并不是真正的析构这块空间,而是把计数减一次,直到只剩一次计数了还要析构,这时才释放管理的这块空间。
weak_ptr是设计用来解决shared_ptr的循环托管的缺陷用的,它不属于RAII,也不接受托管资源的任务。
3.1 unique_ptr的一些接口
官网资料:unique_ptr - C++ Reference
可以看到这个智能指针有两个模板参数,第一个就是要放入这里的内容的类型,第二个是定制删除器。我们先不看第二个特化模板类型。
默认的定制删除器就是 delete 这个T*指针内容,那对于 new 出来的数组默认的定制删除器就没法正常使用了,因此我们要自己写一份对应的定制删除器出来。
或者是fopen打开文件后,这种FILE*指针也需要对应fclose操作来关闭文件,这也是需要手动写定制删除器的例子。
不过因为我们看第二个模板,库中已经特化出了new数组的删除情况了,也就是说如果我们new数组的话就不需要写数组的定制删除器了,根据它这个特化的模板参数写法传参就好。
总之,如果放到智能指针中托管的内容不能简单的 delete 或 delete[ ] 掉我们就需要手动的写定制删除器。
get()可以拿到智能指针中存放的原生指针,但是不要拿get出来的指针再交给新的智能指针托管了,不然又会产生同一块空间析构多次的情况了。
get_deleter() 拿到定制删除器的对象的引用,这个接口没啥用。
operator bool重载了bool,可以用来判断智能指针下托管的指针是否不为空,也就是说不为空返回true、为空返回false
release() 释放该智能指针目前的托管责任,注意,这里只是释放托管责任而不是清理被托管内容。
reset() 清理当前托管内容,并取得下一个要托管内容的指针,如果下一个被托管内容的指针为空,那就托管空指针。也就是说,不用等智能指针的声明周期结束再清理它托管的内容了,我们可以reset()手动清理掉。或者可以将reset和release搭配到一起使用。
release在释放托管责任时会将被托管的指针抛出,此时我们可以用reset接住换一个智能指针继续托管。
3.2 shared_ptr的一些接口
shared_ptr于unique_ptr在逻辑上的不同就是一个允许拷贝一个不允许拷贝,但是它们用起来还是优点不一样的,最明显的就是定制删除器的传参位置。
官网资料:shared_ptr - C++ Reference
shared_ptr的定制删除器传参位置不在模板参数中,而是在构造对象的参数中,具体情况可以查看它的构造函数,这里我直接展示一下用法
share_ptr的智能指针在生命周期结束时一定会调用这个定制删除器,也就是说,如果打开文件失败了,那在生命周期结束时,调用fclose函数就会报错崩溃。但是unique_ptr就比较智能,如果发现它托管的指针是空的话声明周期结束的时候就根本不会去调用定制删除器。
shared_ptr因为支持了拷贝计数拷贝,因此它也提供了可供查看拷贝计数的接口,还提供了make_shared接口将内容构造在堆区,相当于取代了new的功能,但是它能减少内存碎片的问题,因此比直接new好一点,具体怎么回事我们一会实现的时候就知道了。具体代码我们直接展示。
可以感觉到这个 shared_ptr 和 make_shared 中的接口们的书写风格都完全不一样,这个具体是怎样还是要单独记忆一下的。
3.3 shared_ptr的实现
我们先把智能指针的框架画好
3.3.1 拷贝构造
我们前面分析过了,拷贝构造需要使用计数拷贝否则会导致多次析构同一块空间,那么我们分析一下计数这个功能该如何实现。
第一思路是在类中添加count变量,但是这个方法明显不行,如果这么做会导致每个对象中都有不同的count计数,这是不对的。那如果用static修饰这个count呢,答案也是否定的,因为这么做,任何从该类中实例化出的对象都共用这一个count,就会导致不同的智能指针共用一个count而不是一组拷贝的智能指针用一个count。
那么最终的思路就是为count新开一块空间,所有拷贝过来的同组智能指针都共用这一块空间,不同组的智能指针另开一块空间管理count。
3.3.2 定制删除器
shared_ptr在库中的定制删除器是在函数参数中传递,而不是函数模板参数中传递的,因此我们实现时也要满足这一特点。
因此我们要在成员函数中重载一个类模板函数,用于构造带有定制删除器的对象。然后还要给定制删除器在成员变量中找一个位置存起来,因为定制删除器是一个可调用对象,因此一般的类型都不能存下它,但是我们可以选择使用包装器。此时要记得修改析构函数,清理工作就不能像之前那样只用delete了要用定制删除器去清理。最后为了照顾构造是不传定制删除器的对象,我们用lambda给_del写一个缺省参数。
3.3.3 循环引用的缺陷
前面提到过shared_ptr在循环托管的情况下是具有缺陷的,因此库中加入了weak_ptr弱指针来解决这一问题。那什么是循环托管,其缺陷又是什么,我们看下面这段代码。
这段代码跑起来之后就会发现并不会调用任何的析构函数,下面我们分析一下原因
在完成指针连接后n1和n2的情况如图,并且它们的引用计数均为2
当它们的生命周期结束,开始析构。首先n2析构,引用计数减一,但不delete空间,接着n1析构,引用计数减一,但不delete空间。那么最后的情况就变成如下
n1、n2被析构了,但是析构的不完整,因为还有一次引用计数,但此时已经没有任何办法将这两块空间释放了,此时发生了内存泄露。这就是所谓的shared_ptr循环托管,或者说循环引用的缺陷。
那么解决的办法就是把造成问题地方的指针改成交给弱指针托管
弱指针解决这个地方的问题的原理就是它不会增加shared_ptr的引用计数。
这里我们要看一下weak_ptr的构造函数
官网资料:weak_ptr::weak_ptr - C++ Reference
可以看到它不支持给指针的构造方案,也就是说它不接受托管空间的任务。只接受使用shared_ptr去构造的方案,也就是第三点。n1->_next = n2; 这句话就是将一个shared_ptr交给了weak_ptr去构造。那如果就是想让它托管一块空间也不是不行,就是要make_shared一块空间,然后给它,这样让它完成一个间接的托管,这个也是有用的,比如想给我们这个做尾插接口的时候就要这么做。