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