🐱作者:一只大喵咪1201
🐱专栏:《C++学习》
🔥格言:你只管努力,剩下的交给时间!
智能指针
- 🥮智能指针
- 🍢为什么需要智能指针
- 🍢RAII
- 🥮auto_ptr
- 🥮unique_ptr
- 🥮shared_ptr
- 🍢智能指针的线程安全
- 🍢operator=()
- 🍢循环引用
- 🥮weak_ptr
- 🥮定制删除器
- 🥮总结
🥮智能指针
🍢为什么需要智能指针
如上图代码所示,在Func中开辟动态空间,在调用完Division函数后释放该空间。
- 如果Division没有抛异常,那么动态空间会被正常释放。
- 如果Division抛了异常,就会去匹配对应的catch,而Func中没有catch来捕获异常,所以执行流就直接跳到了main函数中,Func的栈帧被销毁。
- 此时Func中开辟的动态空间还没有释放,就会导致内存泄漏。
- 可以在Func中捕获Division的异常,在捕获到以后将动态空间释放,然后再将异常重新抛出,让mian中再次捕获并进行具体的处理。
- 如果Division没有抛异常,就会执行叉掉的那个delete来释放动态空间。
使用上诉办法就可以避免内存泄漏了。
此时又在堆区上开辟了一个数组。如果是Division抛出的异常,只需要在捕获该异常的时候将p1和p2都释放。
- 但是new也有可能抛异常,此时就需要再捕获new的异常,进行相应的处理。
如上图所示,这样虽然能解决办法,但是代码看起来很不美观,可读性非常差劲,而且逻辑比较复杂。
🍢RAII
- RAII:是英文Resource Acquisition Is Initialization(请求即初始化)的首字母,是一种利用对象生命周期来控制程序资源的简单技术。
- 这些资源可以是内存,文件句柄,网络连接,互斥量等等。
创建一个类模板SmartPtr(智能指针),如上图代码所示。
- 成员变量只有一个,是一个指针变量,通过构造函数进行初始化。
- 在析构函数中释放成员指针变量指向的资源。
- 重载指针常用的操作符,使该类可以像指针一样操作。
在Func中,使用两个new以后返回的指针初始化智能指针,分别为sp1和sp2。由于运算符重载,智能指针可以像内置指针一样操作,进行解引用,下标访问等。
- 当指向Divsion抛出异常后,Func的栈帧也会被销毁,执行流跳转到main中去匹配catch。
- 在Func栈帧销毁的时候,智能指针对象sp1和sp2的生命周期也会结束,会自动调用析构函数。
*而我们在析构函数中对new出来的资源进行了释放,所以此时就不存在内存泄漏的问题。
所谓RAII,就是将资源的生命周期和对象的生命周期绑定。从构造函数开始,到析构函数结束。
智能指针就是使用了RAII技术,并且利用对象生命周期结束时,编译器会自动调用对象的析构函数来释放资源。
- 智能指针的智能就在于资源会被自动释放,不需要显式地释放资源。
- 采用智能指针,对象所需的资源在其生命周期内始终保持有效。
智能指针包括两大部分:
- RAII(将资源绑定在智能指针对象上)
- 具有像指针一样的行为(重载操作符)
🥮auto_ptr
将前面的智能指针该名为auto_ptr,仿真wxf命名空间中。
使用编译器自动生成的拷贝构造函数。
上面代码在运行时会报错。
- 智能指针ap2拷贝复制了ap1,此时ap1和ap2都指向同一块动态内存空间。
- 当程序执行结束以后,ap1对象和ap2对象都会销毁,并且会执行各自的析构函数,所以那份动态空间就会被释放两次,所以报错了。
所以需要自己显式定义一个拷贝构造函数,不能让两个智能指针指向同一份动态内存空间。
- 让原本的智能指针置空,不再管理这份动态内存,只让新拷贝出来的智能指针来管理。
C++98就提供了这样一个智能指针,同样在拷贝的时候,原本指针会被置空,在使用库里的智能指针时,要包头文件< memory >。
上图代码中使用的是std中的auto_ptr,通过调试窗口可以看到,执行完拷贝后,ap2指向了原本ap1管理的动态内存空间,而ap1被置空了。
- auto_ptr会发生管理权的转移,在拷贝构造后,管理权从ap1转移到了ap2.
- 而且原本的ap1会被悬空,就不能再使用了。
对于不清除auto_ptr这个特点的人来说,拷贝后再次使用ap1就会出问题。
- auto_ptr是一个失败的设计,很多公司明确要求不能使用auto_ptr。
🥮unique_ptr
在C++11中提供了更加靠谱的unique_ptr智能指针:
- unique_ptr直接禁止使用拷贝构造函数,即使编译器也不能生成默认的拷贝构造函数,因为使用了delete关键字。
unique_ptr采用的策略就是,既然拷贝有问题,那么就禁止拷贝,这确实解决了悬空等问题,使得unique_ptr是一个独一无二的智能指针。
继续在wxf命名空间中写一个unique_ptr智能指针的类模板。同样包括RAII和像指针一样的操作符重载,和之前的auto_ptr一样。
- 但是要禁止使用拷贝构造函数,使用delete禁止编译器自动生成。
此时unique_ptr就不能被拷贝了,一个智能指针对应一份动态内存空间。
可以看到,在拷贝unique_ptr的时候,直接报错"尝试引用已删除的函数",因为拷贝构造使用了delete禁止了拷贝构造。
- 标准库中的unique_ptr在拷贝构造时同样也会报错"尝试引用已删除函数"。
可以看到,在标准库中,unique_ptr同样不可以进行赋值,也是使用了delete。
我们也要做到和库中一样。
但是如果就想拷贝智能指针呢?
🥮shared_ptr
C++11提供了更加可靠的智能指针,并且支持shared_ptr:
- sp2拷贝了sp1,sp1指向一个动态内存空间。
- sp1将其管理的内容加1,然后使用sp2将这个空间中的数值打印了出来。
拷贝出来的shared_ptr和原本的shared_ptr共同管理着一份动态内存空间,如下图所示:
但是在sp1和sp2生命周期结束的时候,这块空间并不会被多次释放而发生错误。
在shared_ptr中,除了指向的动态内存空间之外,还维护着一个变量,用来计数,这种方式称为引用计数。
- 只有一个shared_ptr指向这份动态内存空间时,引用计数值就是1。
- 每拷贝一份引用计数值就会加1。
- 当某个智能指针被销毁时,说明该指针不使用该资源了,引用计数值就会减一。
- 如果引用计数值是0,说明当前的智能指针是最后一个使用该资源的对象,必须释放该资源。
- 如果引用计数不是0,说明还有其他智能指针对象在使用该资源,所以不能释放。
上面描述的就是shared_ptr的原理,下面来看看代码实现:
- 在创建智能指针的时候,同时再创建一个引用计数,而且需要放在堆区上。
- 每当拷贝一个智能指针的时候,引用计数值就加一,并且新的智能指针也指向那份动态内存空间。
- 在智能指针析构的时候,将该指针的计数值先减一,然后判断是否为0,如果为0则释放动态内存空间以及引用计数,不为0则直接结束析构。
我们自己实现的shared_ptr同样可以实现库里的效果。
🍢智能指针的线程安全
C++11提供了多线程的库,可以直接以C++11的方式实现多线程并发。
可以通过创建thread对象来创建线程。
可以通过mutex对象来加锁和解锁。
C++11多线程的详细内容之后本喵会详细讲解,这里只是简单介绍一下。
在shared_ptr中增加一个获取引用计数的接口。
先创建一个shared_ptr智能指针。
- 创建线程1,在线程1中拷贝n = 50000次智能指针,每次进入for循环作用域拷贝,出作用域销毁。
- 再创建线程2,做和线程1同样的事情。
在主线程中等待线程成功后,打印引用计数的值。
- 理论上,线程1和线程2一共拷贝了100000次智能指针,引用计数值也加减了10000次。
- 最终在获取引用计数值的时候应该是1,因为两个从线程中拷贝的智能指针最终都释放了,只剩下了主线程中的智能指针。
但是运行多次,每次的结果都不一样,而且都不是1。
- 这是因为发生了数据不一致问题,也就是此时的智能指针不是线程安全的。
两个线程及主线程中的所有智能指针都共享引用计数,又因为拷贝构造以及析构都不是原子的,所以导致线程不安全问题。
解决办法和Linux中一样,需要加锁:
- 在shared_ptr中增加互斥锁的指针成员变量。
- 在创建智能指针的构造函数中,在堆区创建一把互斥锁。
互斥锁同样需要放在堆区,此时不同线程才能共享这把锁。并且每创建一个指向新动态空间的智能指针都需要创建一把锁。
- 在拷贝构造函数中,引用计数值加1时,需要让其成为原子操作,所以在加1前加锁,在加1后解锁,让多线程串行访问引用计数值。
- 在析构函数中,引用计数值减1时,同样需要让其成为原子操作,所以在减1前加锁,在减1后解锁。并且当析构最后一个智能指针时,不仅要释放管理的动态内存空间,也要释放互斥锁。
通过加锁和解锁操作,就让多线程互斥访问引用计数值,就不会发生数据不一致的线程不安全问题。
此时即使多次运行,最后打印的引用计数值都是1,此时就对于引用计数的访问就成了线程安全的了。
再增加一个接口,用来获取管理空间的地址,如上图所示。
- 两个线程中,在拷贝完主线程的智能指针后,都对共同管理的内容加一。
- 理论上,两个线程各对动态内存空间的值加50000次,最终主线程中输出的值应该是100000。
多次运行,只有一次出现理论值100000,其他都不是,而且值不相同。
可以看到,库中的智能指针同样会发生这个问题。
- 这是因为这些智能指针共同管理的动态内存空间是线程不安全的。
- 创建一把锁,在两个线程访问临界资源的位置进行加锁和解锁,让多线程串行访问共同管理的动态内存空间。
- 这里的锁和引用计数时加的锁不是一把锁,因为管理的动态内存空间和引用计数是两个不同的临界资源,所以需要两把锁。
此时无论运行多少次,最终的结果和我们的理论相符,所以此时的智能支持才完全线程安全。
结论:
- 智能指针本身是线程安全的,因为对引用计数的访问是互斥访问。
- 智能指针管理的资源是线程不安全的,必须再加锁才能线程安全。
🍢operator=()
unique_ptr是不可以赋值的,shared_ptr作为改进版,必然是可以赋值的:
通过调试可以看到sp1成功赋值给了sp2,并且两个智能指针都指向同一块动态内存空间。
那么这是如何实现的呢?
- 先判断两个智能指针是否管理同一块资源,如果是则没有必要赋值,直接返回当前指针。
- 再该智能指针管理的空间释放,如果是多个指针管理,则引用计数减一,如果只有该指针管理,则直接释放资源,使用前面实现relase()就可以做到。
- 让两个指针共享同一份资源,包括管理的空间,引用计数,互斥锁。
- 最后让引用计数值加1。
调试可以看到,我们自己实现shared_ptr可以实现和库中一样的效果。
🍢循环引用
shared_ptr就完美了吗?并不是,它有一个死穴——循环引用。
创建一个链表节点,如上图所示,在该节点的析构函数中打印提示信息。
- 将node1和node2互相指向,形成循环引用。
执行该程序后,节点析构函数中的打印信息并没有打印,说明析构出了问题。
node1和node2刚创建的时候,它两的引用计数值都是1。
- 当两个节点循环引用后,它们的引用计数值都变成了2。
如果node1释放,还有node2的prev指向node1,所以node1不会被释放,也就不会执行析构函数。
如果node2释放,还有node1的next指向node2,所以node2也不会被释放,也不会执行析构函数。
- next属于node1的成员,node1释放了,next才会释放,不再指向node2。
- prev属于node2的成员,node2释放了,prev才会释放,不再指向node1。
- 但是node1和node2在释放自己后,仅仅是让各自的引用计数值减1,两个节点还是存在,由next和prev管理着。
在循环引用中,节点得不到真正的释放,就会造成内存泄漏。
循环引用的根本原因在于,next和prev也参与了资源的管理。
所以解决办法就是让节点中的next和prev仅指向对方,而不参与资源管理,也就是计数值不增加。
🥮weak_ptr
weak_ptr是为解决循环引用问题而产生的,所以它的拷贝构造以及赋值都不会让引用计数值加1,仅仅是指向资源。
weak_ptr中只有一个成员变量_ptr,用来指向动态内存空间,在默认构造函数中,仅仅指向动态内存空间。
- 拷贝构造函数和赋值运算符重载函数中,拷贝和赋值的对象都是shared_ptr指针。
weak_ptr就是用来解决循环引用问题的,所以拷贝和赋值的智能指针必须是shared_ptr。
- weak_ptr和shared_ptr并不是同一个类,所以获取shared_ptr中的_ptr时,不能直接访问,需要通过shared_ptr的接口get()来获取。
将节点中的prev和next使用weak_ptr智能指针。
可以看到,此时循环引用就可以正常析构了。
- 使用了weak_ptr以后,循环引用时,各个节点的引用计数值不增加,如上图所示。
- 所以node1和node2释放时节点也就真的释放了。
库中同样有weak_ptr,也是用来解决循环引用的:
标准库中智能指针的使用方法和我们自己实现的是一样的。
🥮定制删除器
前面我们自己实现的所有智能指针中,在释放动态内存资源的时候,都只用了delete,也就是所有new出来的资源都是单个的。
- 如果new int[20],或者malloc(40)呢?
- 再或者是句柄呢?如FILE* fp。
当需要释放的资源是其他类型的呢?delete肯定就不能满足了,对于不同类型的资源,需要定制删除器。
先来看库中是如何实现的,这里仅拿shared_ptr为例,unique_ptr也是一样的。
- 在构造智能指针的时候,可以传入定制的删除器。
- 可以采用仿函数的方式,lambda的方式,以及函数指针的方式,只要是可调用对象都可以。
此时的智能指针指向的是动态数组,我们传入的定制删除器也是释放数组的,通过打印信息可以看到成功执行了。
- 写一个默认删除方式的仿函数,执行的是delete ptr。
- 在shared_ptr类模板的模板参数中增加一个定制删除器的模板参数,缺省值默认删除方式。
- 在释放资源的时候,在Release()中调用定制的删除器仿函数对象。
- 成员变量中增加一个删除器的仿函数对象。
在创建智能指针对象的时候,实例化时传入定制的删除器类型,这里只能是仿函数,不能是lambda表达式,因为实例化时传入的是类型,不是对象。
即使使用decltype来生命lambda表达式类型也不可以,如上图所示。
- decltype是在执行时推演类型,而这里是实例化是在编译时实例化。
还可以定制释放文件指针的删除器,如上图所示。
- 以写方式打开文件后返回的文件指针初始化智能指针。
- 在智能指针生命周期结束的时候,在析构函数中调用定制的删除器关闭了文件。
- 标准库中的智能指针,在使用定制删除器的时候,是在构造对象时传入函数对象来实现的。
- 我们自己实现的智能指针,是在实例化时,传入仿函数类型实现的。
这是因为,C++11标准库实现的方式和我们不一样,它的更加复杂,专门封装了几个类管理引用计数以及定制删除器等内容。
🥮总结
智能指针的发展经过:
- C++98中的auto_ptr,存在非常大的缺陷,在拷贝构造或者赋值的时候,原本的auto_ptr会被置空,所以这个智能指针存在非常大的缺陷,很多地方都禁止使用。
- C++11中的unique_ptr,禁止了拷贝和赋值,直接避免了auto_ptr可能存在的缺陷,是一个独一无二的智能指针,但是它不能拷贝和赋值。
- C++11又提供了shared_ptr,通过引用计数的方式解决了不能拷贝和赋值的缺陷,并且通过互斥锁保证了shared_ptr本身的线程安全,但是它的死穴是循环引用。
- C++11为了解决shared_ptr的循环引用问题,又提供了weak_ptr智能指针,通过仅指向不管理的方式解决了这个问题。
在使用的时候要根据具体情况选择合适的智能指针,切记最好不要使用auto_ptr。
其实C++委员会还发起了一个库,叫boost库,这个库可以理解为C++标准库的先行版,boost库中好用的东西会被C++标准库收录,标准库中的智能指针就是参照boost库中的智能指针再加以修改定义出来的。