智能指针
基础概念
为什么要有智能指针
首先先看一段程序,看看这段程序可能会出现什么问题。
1 | #include <iostream> |
首先这个代码中我们由于可能因为代码过于复杂而忘记释放空间,导致内存泄露,其次如果我们在这段代码中抛出异常,导致函数终止也会导致无法释空间,内存泄露,这种问题也被称为异常安全问题。作为Cpp程序猿,我们最烦也是最应该提防的一个问题就是内存泄露。那么针对这种情况我们象让我们动态分配的内存空间可以在生命周期结束后自动释放该怎么办呢?
什么是智能指针
智能指针是一个用于管理和存储指针的类,它利用对象出了作用域自动调用析构函数的特性帮助我们释放空间。接下来模拟实现一个简单的智能指针类。
1 | #include <iostream> |
以上这个智能指针类就已经基本实现了帮助我们在指针声明周期结束后自动释放内存空间的功能,这种与之类似的功能实现在Cpp中称之为RAII(Resource Acquisition Is Initialization),资源获取即初始化
,是Cpp中常用的用于管理资源的方法,由这套方法产生了智能指针,当然还有智能锁,几乎所有我们在使用完必须释放资源的类型都可以使用这套方法进行资源管理。这套资源管理的方法使得我们不需要显示的释放资源,并且使资源在其生命周期内长期有效,出了作用域后自动帮助我们释放资源防止出现内存泄露的问题。
我们之前实现的只能发指针类并没有实现完全,因为作为智能指针我们必须要求它能够像指针一样进行使用。
1 | #include <iostream> |
这样就可以称为一个较为完整的智能指针了,但是单单这样还不够,因为这个类中最重要的也是最难处理的两个默认函数还没有书写,即拷贝构造函数和赋值运算符重载函数,这两个函数的处理也苦恼了Cpp标准库中的多种智能指针,接下来我们就要着重讨论标准库中对这两个函数的处理。
库中的智能指针
早在C++98
的版本中就已经出现了智能指针,它叫auto_ptr
,但是由于十分不好用于是后面在C++11
中出现了unique_ptr
和shared_ptr
。不同的智能指针除了拷贝构造和赋值运算符重载函数的实现外其他都是大同小异,我们这里着重讨论他们在拷贝构造函数和赋值运算符重载函数上的不同。
为什么不同的智能指针在这两个默认成员函数上实现区别会很大呢?我们稍微思索即可发现,指针的拷贝和赋值往往和资源何时释放而挂钩。如果一个智能指针A拷贝了另一个智能指针B,而当B出了声明周期此时要不要释放资源呢?如果释放资源那么我们再次使用指针A就会有未定义行为的发生。但如果不是放那么何时又该释放资源呢?这些问题的处理上使得智能指针有了差别。
auto_ptr
auto_ptr
诞生在C++98
的标准库中,但是这也是问题最多的智能指针,他对拷贝构造即赋值运算符重载的处理我们可以用一句话总结,即管理权限转移,我们以下作个简单的例子。注意:auto_ptr
在C++11
中已经被删除,因此我们可以依照文档自己实现一个进行试验。
1 | #include <iostream> |
在这个模拟实验中可以看出auto_ptr
管理权限转移的意义,它会在赋值或者拷贝后将原本的智能指针中所管理指针置空,如果我们再去访问原来的智能指针,结果就是未定义行为,程序崩溃,这指针真的太恶劣了。因此强烈不建议使用auto_ptr
,不过好在库中已经删除了他,想用也没的用了,皆大欢喜。
unique_ptr
unique_ptr
是C++11
中智能指针的改进版本,它解决了auto_ptr
可能会导致程序崩溃的问题,但是他解决的方法略为粗暴,即禁用拷贝构造和赋值。不让拷贝不就不会出现问题了,简单粗暴。
1 | #include <iostream> |
防拷贝很好的解决了auto_ptr
的残留问题,我们也模拟实现一个unique_ptr
。
1 | #include <iostream> |
这个版本的智能指针十分好用,因为简单方便因此很少出问题,而且我们本来就不建议让智能指针出现拷贝,因为可能会引发一系列问题,所以这个版本的智能指针也是最为推荐使用的版本,如果要求必须可以进行拷贝那么还得考虑接下来的版本。
shared_ptr
shared_ptr
是标准库中的支持拷贝和赋值的智能指针。
1 | #include <iostream> |
以上例子可以看出三个通过通过不同方式得来的智能指针都可以正常使用,虽然他们都指向相同的资源但是也可以很好的进行资源管理。shared_ptr
之所以可以支持拷贝和赋值,是因为它对智能指针拷贝的处理是通过一个引用计数,引用计数用来保存当前资源被几个智能指针共享。
当新增一个智能指针指向某个资源时则引用计数+1,一个智能指针不再指向某个资源则引用计数-1,当引用计数为0时则说明已经没有智能指针指向这个资源则释放资源,否则不释放资源。
但是由于可能会有好几个智能指针共同维护同一份引用计数的情况,于是如果在多线程中引用计数可能会同时被多个线程进行操作,称为临界资源,于是就要考虑线程安全问题,为了解决这些问题就需要在适当的地方加锁,或者让其成为原子操作。
同样的这里模拟实现一份shared_ptr
了解原理。
1 | #include <iostream> |
shared_ptr
在何种情况下乃至多线程的情况下都可以很好的管理和释放资源。
shared_ptr的线程安全问题
shared_ptr
的线程安全要从两方面说起。
1、shared_ptr
自身的线程安全。这里的线程安全指的是指针内部的引用计数这个临界资源的访问的安全问题,为了让指针知道何时该释放资源我们不得不在其中开辟一块供多个shared_ptr
访问的引用计数,但如果在多线程中,就会发生线程安全问题。我们可以试着把上面模拟实现的代码中的增加和减少引用计数部分的锁删除掉,然后利用多线程多创建几个shared_ptr
指向同一块资源,就会发现如果引用计数的改变不是原子操作,就会发生多个线程同时更改引用计数但由于资源不同步导致部分更改丢失从而使得智能指针管理的资源要么没有释放,要么提前释放,不过这点我们已经通过给临界资源的访问加锁从而得以解决,因此我们可以说 shared_ptr
自身是线程安全的。
2、shared_ptr
管理的资源的线程安全问题。不得不说如果智能指针所管理的资源本身就是临界资源的话,那么所有的智能指针在对这块资源的访问上都是线程不安全的,就像我们使用普通的指针访问临界资源一样,并不会因为我们使用了智能指针而使得原本临界资源的访问就受到了保护。这里的解决方法只能是从外部或者所管理的资源上添加访问保护才能解决,这与智能指针本身无关。但也不得不提 shared_ptr
所管理的资源是有线程安全问题存在的。
shared_ptr的循环引用问题
shared_ptr
的循环引用问题是一类非常经典的问题,它通常会出现在shared_ptr
的使用中,导致资源无法正确被释放,看以下这个例子。
1 | #include <iostream> |
程序运行结束我们发现资源并没有被释放,为什么呢。
1 | #include <iostream> |
以上我们屏蔽掉了两行互相指向,也就是链表节点连接起来的代码,发现又可以正常释放资源了,为什么呢?以上这种现象就是循环引用,接下来图解以上过程说明为什么资源无法释放以及如何造成了循环引用。
循环引用的典型场景就是一个shared_ptr
指向一块内存空间A,空间A中还有一个shared_ptr
指向另一块空间B,而空间B也由一个shared_ptr
管理并且其中又有一个shared_ptr
指向空间A,这样在释放时就会发生循环引用的问题,导致空间无法释放。那么如何避免循环引用呢?
造成循环引用的根结问题就是一个两个使用shared_ptr
互相指向的空间使得对方空间的引用计数多加了一次,才造成了循环引用,只要我们让由shared_ptr
管理的空间中的shared_ptr
指向另一块由shared_ptr
管理的空间时另一块空间的引用计数不增加就好了,简单来说就是避免一次引用计数的增加,就可以避免卡死。但是shared_ptr
指向一块空间时此空间的引用计数必然会+1,这该怎么办呢?
在标准库中未我们提供了另一个智能指针weak_ptr
,这个指针是专门提供给我们解决循环引用问题的,我们将代码修改一下先看效果。
1 | #include <iostream> |
资源完美的释放了。那么是如何解决的呢?
首先可以将shared_ptr
赋值给weak_ptr
,因为weak_ptr
就是专门为解决shared_ptr
的问题产生的,并且weak_ptr
指向某一shared_ptr
管理的资源时它并不会增加该资源的引用计数,这完全符合我们解决循环引用的要求,避免了多增加的一次引用计数,于是问题解决了。
智能指针自定制deleter
之前我们的智能指针都是在单一的管理一块new
出来的对象,那么如下产生的空间智能指针是否能进行管理呢?
1 | #include <iostream> |
智能指针无关版本默认释放资源都会去delete
资源,然而我们new[]
和malloc
的资源必须通过delete[]
和free
才能完全释放或者不造成程序崩溃(malloc
没有对对象初始化,如果对象内含有指针,使用delete
释放资源调用析构函数可能会造成程序崩溃),因此我们想要正确释放资源就需要正确的deleter
,也就是释放资源的方法,这样的方法被称为*删除器,当然智能指针也为我们留下了这样的接口,
我们可以通过传递一个仿函数去更改默认的delete
方法。
1 | #include <iostream> |
这样就可以完成删除器的自定义。那么删除器是如何作用于智能指针内的资源的呢?在在智能指针准备释放资源时,他会调用仿函数删除器来释放资源,因此删除器也是一个回调函数,通过对删除器的自定义即可完成自定义释放资源。
RAII的扩展
RAII技术不光能使用在指针的资源管理上,一切需要我们手动释放的资源都可以使用RAII管理,比如锁。在库中有一套量身设计的用RAII来管理锁的类,叫做锁守卫,它可以帮助我们在锁超出作用域的时候自动解锁,防止我们中间抛出异常却没有解锁导致临界资源再也无法访问。
模拟实现一个锁守卫。
1 | #include <iostream> |
库中的锁守卫。
1 | #include <iostream> |
库中还有另一个锁守卫unique_lock
,用法和lock_guard
一致,那么两者有什么区别呢?
lock_guard
只实现了RAII,因此加锁和解锁都由lock_guard
控制,而unique_lock
实现了更多的功能,它可以允许我们自己手动加锁,也可以手动解锁,也可以不阻塞地加锁。