【C++】智能指针

智能指针

  • 前言
  • 正式开始
    • 引例
    • 内存泄漏
      • 概念
      • 危害
      • 内存泄漏的分类
      • 如何避免内存泄漏
    • 智能指针
      • RAII
    • auto_ptr
      • 智能指针拷贝问题
      • auto_ptr的拷贝
      • auto_ptr模拟实现
    • C++11中的智能指针
      • unique_ptr
        • 模拟实现
      • shared_ptr
        • 模拟实现
        • shared_ptr循环引用问题
      • weak_ptr
    • 定制删除器
      • vs下的new机制
      • shared_ptr中对new[ ]的释放
      • unique_ptr中对new[ ]的释放
      • 模拟实现释放
    • 总结

【C++】智能指针_第1张图片

前言

本篇是续着前一篇异常中知识点来讲的,各位不了解的可以先看一下:【C++】异常。

正式开始

引例

下面我们先分析一下下面这段程序有没有什么内存方面的问题?

int div()
{
	int a, b;
	cin >> a >> b;

	if (b == 0)
		throw invalid_argument("除0错误");

	return a / b;
}
void Func()
{
	// 1、如果p1这里new 抛异常会如何?
	// 2、如果p2这里new 抛异常会如何?
	// 3、如果div调用这里又会抛异常会如何?
	int* p1 = new int;
	int* p2 = new int;
	cout << div() << endl;
	delete p1;
	delete p2;
}
int main()
{
	try
	{
		Func();
	}
	catch (exception& e)
	{
		cout << e.what() << endl;
	}
	return 0;
}

上面的问题分析出来我们发现有什么问题?
如果是p1处的new抛异常了,问题不大。
如果是p2处的new抛异常了,直接跳走,p1的空间就得不到释放,导致内存泄漏。
如果是div()处抛异常了,直接调走,p1和p2的空间就得不到释放,导致内存泄漏。

异常中也是有一个跟此处很相似的例子,只不过那里空间开的是数组的空间。但也是抛异常导致内存泄漏。

内存泄漏,老生常谈的问题了。这里细讲一下:

内存泄漏

概念

什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。

危害

内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。

内存泄漏的分类

C/C++程序中一般我们关心两种方面的内存泄漏:

堆内存泄漏(Heap leak)

堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。

系统资源泄漏

指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定

如何避免内存泄漏

  1. 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
  2. 采用RAII思想或者智能指针来管理资源。
  3. 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
  4. 出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵。

总结一下,内存泄漏非常常见,解决方案分为两种:
a. 事前预防型。如智能指针等。
b. 事后查错型。如泄漏检测工具。

智能指针

上面避免内存泄漏中提到了RAII,这个就是智能指针中用到的技术。

RAII

RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。

Resource Acquisition Is Initialization翻译一下就是资源申请即初始化。

听起来比较晦涩,说人话就是把管理一份资源的责任托管给了一个对象,在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。

看例子:
【C++】智能指针_第2张图片
这就算是一个简单的智能指针,就是在类中搞一个指针而已,用这个指针来维护某一段地址。只是叫个智能指针听起来高大上了一点。

这样将空间托管给类对象,类对象只要生命周期一到就会自动调用析构函数将空间释放掉。不会说像内置类型指向某一块空间,生命周期结束之后不会自动释放所指向的空间,还得要在其生命周期结束前手动释放。

下面我在析构函数中添加一句话,方便等会查看:
【C++】智能指针_第3张图片

再将抛异常那里改一下:
【C++】智能指针_第4张图片

此时如果抛异常也能正常释放空间:
【C++】智能指针_第5张图片

不抛异常也可以:
【C++】智能指针_第6张图片

空间都会被释放。

或者说我们还可以直接用new来初始化对象:
【C++】智能指针_第7张图片
也是没事。

借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:

  1. 不需要显式地释放资源。
  2. 采用这种方式,对象所需的资源在其生命周期内始终保持有效

所以说,有了异常之后指针这些东西就不要自己随便搞了,你把握不住的,让类对象来把握。

再重载一下*和->,就和迭代器中的一样:
【C++】智能指针_第8张图片

然后我再写一个A类,方便我们观察:
【C++】智能指针_第9张图片

测试:

【C++】智能指针_第10张图片

我再把A的成员改成公有的测试一下smart_ptr的->:
【C++】智能指针_第11张图片

成功。

auto_ptr

其实库中也是有智能指针的,C++98中就有auto_ptr,但是实现的很不好,被吐槽了很多年。

我们来看看:
【C++】智能指针_第12张图片
这些功能和我前面写的都差不多。

但是智能指针最大的问题不在前面的->、*什么的,在于拷贝问题。

智能指针拷贝问题

智能指针能深拷贝吗?

答案是不能的,分两点:

  1. 智能指针中所维护的那块空间并不属于智能指针的,其只负责释放该空间,并不知道这块空间有多大,因为传过来的只是一个指针,你并不知道这个指针所指向的空间new了一个int还是new了[ ]个int,所以没法进行深拷贝。
  2. 智能指针只是帮助托管,就像迭代器那样根本就不管资源,只进行访问、遍历那样的操作,而智能指针就是只负责释放该空间,就算深拷贝了,新开的空间应该给谁来用?答案是没有人用,没有人用就不需要再浪费资源了。

所以这里智能指针需要的就是浅拷贝。

但又有问题了,既然是浅拷贝,那么当多个智能指针共同维护一段空间时,该什么时候释放呢?

ok,库中给出了4种不同的智能指针,一个就是刚刚说的C++98中的auto_ptr,剩下的三个是C++11之后提供的unique_ptr / shared_ptr / weak_ptr。先接着前面的auto_ptr来介绍。

auto_ptr的拷贝

C++98中提供的这个auto_ptr只能说非常的挫。。。

我们先来看一下测试:
【C++】智能指针_第13张图片

看起来没什么问题,但是如果我调试一下:

拷贝前
【C++】智能指针_第14张图片

拷贝后
【C++】智能指针_第15张图片

可以看到,sp1中的数据直接没了,其中的指针直接指向了空,而sp2接管了sp1原先维护的数据。

auto_ptr的拷贝是进行资源管理权的转移,是一种非常不负责任的做法, 若托管元空间的sp1给了sp2,sp1托管的就变成空了,所以就导致被拷贝对象(sp1)的悬空,不清楚底层的人一解引用sp1中的内容整个程序就崩了,所以前面说auto_ptr实现的很挫。

看我解引用一下:
【C++】智能指针_第16张图片

如果自己给自己赋值:
【C++】智能指针_第17张图片

没变:
【C++】智能指针_第18张图片

而且如果两个智能指针同时维护一块空间,会导致程序崩掉:
【C++】智能指针_第19张图片
因为两次析构了同一块空间:
【C++】智能指针_第20张图片

这种实现导致auto_ptr被骂了很多年。而且很多公司明确要求不能使用它,但有部分公司使用的都是和规矩使用就行,比如说不去解引用托管前的智能指针。

但虽说实现的很挫,我们还是要了解了解的,可以避免我们以后写出这种代码。

所以这里就模拟实现一下auto_ptr,其实就和前面smart_ptr差不了太多,主要就是拷贝构造和拷贝赋值重载。

auto_ptr模拟实现

先把smart_ptr的代码改改:
【C++】智能指针_第21张图片

其实就是把名字换了一下,然后再写拷贝构造:
【C++】智能指针_第22张图片
就这么简单,先托管给当前的智能指针,然后再将原托管的智能指针赋值为空。但要注意,不能用const auto_ptr& 来接收参数,不然改不了原来的智能指针。

再说赋值重载:

【C++】智能指针_第23张图片

这样基本上就写好了。我们来测试一下:
【C++】智能指针_第24张图片

拷贝没问题。再看复制重载,加上一句释放的:
【C++】智能指针_第25张图片

测试:

【C++】智能指针_第26张图片

成功。

调试:
【C++】智能指针_第27张图片
【C++】智能指针_第28张图片
【C++】智能指针_第29张图片

完全ok的。

C++11中的智能指针

因为auto_ptr用起来非常难受,C++11中又提供了unique_ptr / shared_ptr / weak_ptr这三种智能指针。

unique_ptr

这个比所有的智能指针都简单粗暴,直接不让拷贝:
【C++】智能指针_第30张图片

但是基本的*和->什么还是支持的:
【C++】智能指针_第31张图片

模拟实现

很简单,C++11后,直接在拷构和拷赋后面加上delete就行:

【C++】智能指针_第32张图片

但C++98的话,就得只声明不定义,而且还要将两个函数搞成私有的:
【C++】智能指针_第33张图片
这样做的目的是防止有老六在类外实现这两个函数。

可以看到,unique_ptr只适用于不需要拷贝的一些场景,比较局限。

shared_ptr

需要拷贝的话,就要用shared_ptr。

先看怎么用:
在这里插入图片描述
可以看到二者是公用一块空间的,而且最后还只释放了一次。

那么其底层用的就是引用计数,这个知识点在我前面的博客中是有的,不懂的同学点传送门:【C++】手把手教你模拟实现string类,只看最后那点就行。

给每个空间生成对应的count,count代表当前空间有多少个智能指针指向这里,在每个对象析构的时候--count,最后一个析构的对象再真正释放空间资源。

模拟实现

不变的:
【C++】智能指针_第34张图片

需要变的就是引用的计数,构造,拷贝构造,拷贝赋值了。

先说计数。
我们应该怎么设置这个计数?

可以让每个对象都生成对应的int count吗?
不可以,因为当多个对象指向同一空间的时候,++或--count加的是各自的count,而非针对某个空间的count。

可以生成一个static int count吗?
不可以,看图:
【C++】智能指针_第35张图片

再配合这个:

【C++】智能指针_第36张图片

请问图中sp1、sp2、sp3、sp4的引用计数_count各是几?
答案是都是4。

因为static成员属于整个类的,所有相同类型的类对象共享一个count,这里指针类型都为int,所以4个智能指针中的count都是同一个count。

那么这就出问题了。按照上面的顺序,析构的时候先析构sp4,但是sp4的count为4,- -后不为0,所以就不会释放掉p2的空间,导致内存泄漏。

所以这里用static是行不通的。

再换一种方法:我们可以对每个空间资源开一份单独的count空间,专门用来计数:
在这里插入图片描述

这样就好说了。

我们来实现一下:

构造:
【C++】智能指针_第37张图片
产生一个对象就让count为1就行。

析构:
【C++】智能指针_第38张图片

拷构:
【C++】智能指针_第39张图片

拷赋:
【C++】智能指针_第40张图片
这里赋值拷贝细节稍微多一点,赋值之前要判断当前被赋值的对象是否是和赋值对象相同,相同就不能赋值,不然就会出错。而且赋值的时候要看被赋值的对象所指的空间是否有多个对象维护,如果有多个,直接- -count就行了,如果只有当前一个,就要把那块空间释放掉。

测试:
【C++】智能指针_第41张图片

赋值前:

【C++】智能指针_第42张图片

赋值后:
【C++】智能指针_第43张图片

可以。

shared_ptr循环引用问题

写一个结构体:
【C++】智能指针_第44张图片

注意,库中的shared_ptr构造函数加了explicit关键字,不支持隐式类型转换:
在这里插入图片描述

但是我们这里实现的是可以的:
【C++】智能指针_第45张图片

如果我想直接让自定义类型shared_ptr赋值给原生指针:
【C++】智能指针_第46张图片
是不行的。

但是我们可以改一下Node:

【C++】智能指针_第47张图片

这样就行了:
【C++】智能指针_第48张图片

但是这里会出问题:
【C++】智能指针_第49张图片
Node的析构函数没有调用。

如果我们只留下一条赋值语句:
【C++】智能指针_第50张图片
是可以释放的。这里就是自定义类型成员析构函数的自动调用。

那么说一下为什么两个的时候就不能:
【C++】智能指针_第51张图片

上面的next和prev赋值时,会导致其资源空间对应的的引用计数++,从而两个节点引用计数都变为2,当释放空间是,n1,n2调用析构函数,但是因为计数为2,故二者的析构只会让两节点的引用计数- -,所以此时两节点的引用计数都变为了1,左侧节点1,来自右侧节点的prev,右侧节点的1来自左侧节点的next,当右prev释放的时候,就能够释放掉左侧节点,当左侧节点的next释放时就能释放掉右侧节点,但是Npde自定义类型空间释放的时候才会调用其中的shared_ptr,也就是说想要让左侧next释放,就要先让左侧节点释放,才能释放右侧节点,右侧的prev也同理,这样就陷入了死循环中。

这就是循环引用。

weak_ptr

我们可以用weak_ptr来解决,weak_ptr不是常规的指针,没有RAII,不支持直接管理资源,weak_ptr主要用shared_ptr构造,用来解决shared_ptr循环引用问题。

所以可将Node再改一下:

【C++】智能指针_第52张图片

weak_ptr中有一个构造函数专门用来用shared_ptr来构造weak_ptr对象的:
【C++】智能指针_第53张图片

所以就能这样:

【C++】智能指针_第54张图片

weak_ptr所指向的空间,不参与资源释放管理,但可以访问和修改资源,不会增加计数,不存在循环引用的问题了。

所以就算next和prev指向了之后,个节点的引用计数任然为1。此时析构就能析构成功。

有一个接口能够看到引用计数的个数,我们来看看:
【C++】智能指针_第55张图片

我再在模拟实现的shared_ptr中添加一个 use_count:
【C++】智能指针_第56张图片

Node:
【C++】智能指针_第57张图片

也会出现引用循环问题:
【C++】智能指针_第58张图片
没有调用析构。

但一个就行:
【C++】智能指针_第59张图片

我们这里Node用库中的weak_ptr接收不了我们实现的shared_ptr,所以这里我们就自己模拟实现一下weak_ptr:

再看一下构造:
【C++】智能指针_第60张图片

默认、拷贝、shared_ptr :

【C++】智能指针_第61张图片

shared_ptr要获得其中的_ptr,但是_ptr是一个私有的,所以要么友元,要么直接提供一个函数接口来返回这个_ptr,但是友元一般不用,会破坏封装性,所以这里就用函数接口了:
【C++】智能指针_第62张图片

【C++】智能指针_第63张图片

再搞一下*和->:
【C++】智能指针_第64张图片

还有赋值:
【C++】智能指针_第65张图片

这里就只实现一个shared_ptr:
在这里插入图片描述

因为weak_ptr不参与资源的释放,所以不需要考虑那么多,直接赋值就行。

但因为这里sp是const的,所以得要让getPtr改为const的,不然调不到。
【C++】智能指针_第66张图片

【C++】智能指针_第67张图片

再测试就没事了:
【C++】智能指针_第68张图片

几个指针就到这,但是注意库中的这几个可不是这么简单,比如说weak_ptr也会有引用计数,但是实现的比较复杂,这里就不搞了,weak_ptr的引用计数有线程安全问题,但由于我之前没讲过线程,没法讲,等我把线程的博客写了再把这块补上。

定制删除器

听起来挺牛的。

但是没那么难。

这里要解决的就是前面的new[ ]的问题,当new[ ]时,我们这里底层实现的是delete,而非delete[ ],所以new[ ]是删不干净的。

比如:
【C++】智能指针_第69张图片
会直接崩掉。

但是把Node的析构注释掉就没事了:
【C++】智能指针_第70张图片

库中的也是:
【C++】智能指针_第71张图片

但int就没事:
在这里插入图片描述

vs下的new机制

看这篇:浅谈 C++ 中的 new/delete 和 new[]/delete[]

【C++】智能指针_第72张图片

里面说了,当new [ ] 内置类型的时候,delete[ ]不会调用析构函数,所以在开空间时不会在空间前方开一个4字节的用来统计[ ]中的new的个数。而自定义类型因为要调用析构时,才会在前面开4个字节的空间来保存要析构次数。

所以说上面的代码,析构Node的时候,需要调用其构造函数,那么就会在new[ ]的时候再开4字节的空间。此时若调用delete,则只会调用一次析构,但是Node的析构并没有释放空间,而是delete再调用free的时候才会出问题,因为这里free不会去释放前面4个字节的空间。但是delete[ ]就不一样了,
delete[ ]时会调用free会释放(char*)ptr - 4,这就会释放掉那四个空间。

那么既然直接用delete不行,库中是怎么搞的呢?

shared_ptr中对new[ ]的释放

【C++】智能指针_第73张图片
上面我圈红色的,直接在构造函数中传一个仿函数对象,这个对象决定了用哪种方式来释放空间。

写一个专门释放[ ]的仿函数:

【C++】智能指针_第74张图片

测试:
【C++】智能指针_第75张图片

再比如说,用malloc和free:
在这里插入图片描述

测试:
【C++】智能指针_第76张图片

还可以传lambda:
【C++】智能指针_第77张图片

unique_ptr中对new[ ]的释放

同样的,unique_ptr也可以释放new[ ]出来的。

但是实现的方式和shared_ptr不太一样。

unique_ptr是在模版参数中再传了一个模版参数:
【C++】智能指针_第78张图片

用一下:
【C++】智能指针_第79张图片

这次传的是类型,shared_ptr中传的是对象。一个是模版参数,一个是函数参数。

同样也可以用文件:
【C++】智能指针_第80张图片
但是要写对应的仿函数:
【C++】智能指针_第81张图片
这里可以用特化,但是上面实现的两个类名跟这个没有啥关系,所以就直接再写一个仿函数了。

但是也演示一下特化:
【C++】智能指针_第82张图片
【C++】智能指针_第83张图片

模拟实现释放

这里就只模拟一下shared_ptr,但是用的方法是unique_ptr的。因为库中shared_ptr中是单独搞了一个成员变量(类对象)来搞释放功能的,实现起来的话前面的逻辑都要改,这里就偷个懒,直接用模版参数来搞。

先搞一个默认释放的:
【C++】智能指针_第84张图片

然后加到参数上:

在这里插入图片描述

再在类中专门搞一个函数用来释放:
【C++】智能指针_第85张图片

放在operator = 和析构中:

【C++】智能指针_第86张图片

【C++】智能指针_第87张图片

测试:
【C++】智能指针_第88张图片
成功。

总结

强调一下本篇中几个面试常问的问题:

  1. 为什么需要智能指针?
    忘记释放空间/异常安全 等会导致内存泄漏。

  2. 什么是RAII
    获得资源及初始化,将资源托管给对象释放。

  3. auto_ptr、unique_ptr、shared_ptr、weak_ptr的发展史

  4. 4个智能指针的区别是什么?

  5. 模拟实现简洁版的智能指针。

  6. 什么是循环引用?如何解决循环引用?解决的原理是什么?

前面两个给的是简答,后面四个本篇详细讲了,不再赘述。

这篇讲完了。

到此结束。。。

你可能感兴趣的:(C++,c++,智能指针,算法,开发语言,数据结构)