前言:
先来个素质三连问:
- 什么是智能指针?
- 为什么要用智能指针?
- 智能指针能干啥?
1️⃣. 什么是智能指针呢?
首先要知道,智能指针是针对动态内存用的,那什么是动态内存呢?:动态内存中的对象的生存期与它们在哪里创建时无关的,只有当显式地被释放时,这些对象才被销毁。程序用堆(或叫自由空间,关于堆与自由空间的讨论可以参考这里)来储存动态分配的对象。其实C++里,动态内存就是new/delet这一对带恶人使用的内存。(其实动态内存我还没摸透,mark一下,等摸透了再回来填坑)
为什么要用动态内存呢?书上说程序使用动态内存出于如下三个原因之一:
a. 程序不知道自己需要使用多少对象(这个原因,vector就是典型的例子)
b. 程序不知道所需对象的准确类型(书上说第15章填坑,我看完再来填坑)
c. 程序需要在多个对象间共享数据(书上就是按照这个原因展开讲的。我觉得这样说似乎更合适也更好理解:程序需要多个作用域内的对象共享数据,这样就抹杀了我想用静态内存或栈内存的指针来实现这一要求的心思)
这里给智能指针一个大概的定义:智能指针是标准库提供的、行为类似于常规指针的、负责自动释放所指向对象的、让动态内存的使用更安全更容易的类,定义在头文件< memory >中。在C++11中我们要使用的有如下几种:shared_ptr、weak_ptr、unique_ptr。
2️⃣. 为什么要用智能指针呢
首先,我们是想用动态内存来着(不要忘了我们的初心,这对理解智能指针存在的意义很重要)。为什么我们要用动态内存看上面的三个原因。
然后,我们用动态内存的时候,new/delete这一对cp属实带恶人,使用它们会带来两个问题:内存泄漏[P400]和空悬指针[P411]。(内存泄漏是忘记释放内存或被迫无法释放内存带来的后果;空悬指针就是指针还在但指向的内存已经被释放了。打个比方:内存泄露就是房子还在,钥匙没了;空悬指针就是钥匙搁手里呢,房没了。)具体代码中为什么会产生这些问题,可以参考这里。
最后,为了更安全更便捷的使用动态内存,就发明了智能指针类。
3️⃣. 智能指针能干啥?
动态内存能干啥,它就能干啥,而且干起来更顺心省心放心。
而且就算程序发生异常终止,都会很安全的把动态内存给你释放喽。
具体的接着往下看吧。
正文
十分推荐大家看看这位博主写的智能指针介绍
https://blog.csdn.net/weixin_34112030/article/details/91383878
下文中所说的“对象”“指针”等内容,无特殊说明,则均以在动态内存中为前提。
1. unique_ptr
一个unique_ptr独享他所指向对象的控制权。某个时刻只能有一个unique_ptr指向一个给定的对象,当unique_ptr被销毁时,它所指向的对象也被销毁。不支持拷贝或赋值操作,但可以将控制权转移。
-
初始化:只有用new返回的指针初始化这一种方式;必须采用直接初始化[P76]
unique_ptr<string> p1(new string("hellow")); // 必须采用直接初始化的形式初始化 unique_ptr<string> p2(p1); // error 不支持拷贝 unique_ptr<string> p3;// 默认初始化的智能指针中保存着空指针 p3 = p2; // error 不支持赋值
-
控制权转移
release()与reset()的使用unique_ptr<string> p1(new string("hellow")); unique_ptr<string> p2(p1.release()); // 将p1置为空,返回指针并用该指针初始化p2 unique_ptr<string> p3(new string("hello,world")); p2.reset(p3.release()); // reset释放了p2原来指向的内存 然后令p2指向p3所指向的对象,然后release()将p3置为空
reset() 还能好一点,可以释放内存,但是release()就不行了,release() 必须有 接盘侠,接了要么可以自动负责释放,要么负责手动释放。
-
不能拷贝的例外情况:做函数参数或返回值类型
做函数参数或返回值时,unique_ptr不能拷贝的性质有一个例外:可以拷贝或赋值一个将要被销毁的unique_ptr。此时执行的是特殊的“拷贝”——对象移动[P470]。 -
删除器
unique_ptr 保存一个指针,当他自身被销毁时(例如线程控制流离开unique_ptr的作用域),使用关联的删除器(deleter)释放所指向的对象。
默认调用delete释放unique_ptr指向的对象。
我们可以自己定义删除器,并让我们创建的unique_ptr使用自定义的删除器进行我们自己的释放操作。怎么实现?在后面与shared_ptr的一起讲解。 -
使用unique_ptr,不要使用老版本的auto_ptr,因为有坑
2. shared_ptr
shared_ptr 共享指向对象的控制权,允许多个指针指向同一对象。我们可以认为shared_ptr就是套上了计数器(引用计数)的指针。无论何时拷贝(包括拷贝赋值、函数传参、函数返回)一个shared_ptr都会使其所指对象的引用计数递增;赋予新值或shared_ptr被销毁,原被指对象的引用计数递减;引用计数变为0时,才会销毁被指对象,释放内存。
shared_ptr就很好了解决了出于原因3而使用动态内存但new/delete不好用的情况。
- shared_ptr的初始化
两种方式:
1️⃣make_shared函数;
2️⃣使用new的返回值初始化(必须是直接初始化)shared_ptr<int> p1 = make_shared<int>(42); auto p2 = make_shared<vector<string> >();// 通常用auto保存make_shared的结果,方便。不传递参数给make_shared就进行值初始化
shared_ptr<string> p1;//默认初始化的智能指针中保存着空指针 shared_ptr<string> p2(new string("hehehe")); int* p3=new int(10); shared_ptr<int> p4(p3);// 用于初始化智能指针的内置指针必须指向动态内存,因为智能指针默认使用delete释放对象。 // 但我们可以绑定智能指针到指向其他类型资源的指针上,通过提供自己的删除器代替delete
- 别的操作就看表吧,没啥说的
- 删除器
shared_ptr 保存一个指针,当他自身被销毁时(例如离开shared_ptr的作用域),其引用计数递减,当指向对象的最后一个shared_ptr被销毁时,shared_ptr类会通过析构函数销毁此对象,其中使用关联的删除器(deleter)释放资源。
默认调用delete释放shared_ptr指向的对象。
我们可以自己定义删除器,并让我们创建的shared_ptr使用自定义的删除器进行我们自己的释放操作。怎么实现?在后面与unique_ptr的一起讲解。
3. weak_ptr
weak_ptr是不控制所指向对象生存周期的智能指针,它指向一个由shared_ptr管理的对象。有以下两个特点:1️⃣weak_ptr绑定到一个shared_ptr时,不会改变其引用计数;2️⃣最后一个指向对象的shared_ptr被销毁后,对象就会释放,即使还有weak_ptr指向对象。
- weak_ptr的初始化
weak_ptr需要用shared_ptr来初始化auto sp=make_shared<int>(10); weak_ptr<int> wp(sp);
- weak_ptr的使用
由于对象可能不存在,故不能使用weak_ptr直接访问对象,必须调用lock()。lock()会调用expired()来检查对象是否还存在。
lock()使用时,若有变量接受了返回的智能指针,则引用计数+1,没有则不加。测试如下:if(shared_ptr<int> np = wp.lock() ) { //在if中,np与sp共享对象(np是会导致引用计数+1的) }
/*测试weak_ptr.lock()*/ shared_ptr<int> sp(new int(2)); weak_ptr<int> wp(sp); cout << sp.use_count() << endl;// 1 if (wp.lock()) { cout << sp.use_count() << endl;// 1 } if (shared_ptr<int> sp2 = wp.lock()) { cout << sp.use_count() << endl;// 2 } cout << sp.use_count() << endl;// 1
4. 智能指针使用禁忌
不要混合使用普通指针和智能指针(普通指针包括new返回的与get()函数得到的)
因为智能指针会对内存进行释放,所以普通指针与智能指针混用时会带来两个问题:
1️⃣智能指针与普通指针指向同一内存,智能指针可能会释放指向的内存=>导致普通指针成为空悬指针;
2️⃣通过普通指针(包括使用get()函数),使多个智能指针独立地指向同一内存=>导致一个智能指针释放内存后,其余智能指针成为空悬指针。
看下面两个例子:(以shared_ptr为例)
-
第一个问题
void process(shared_ptr<int> ptr){ cout << ptr.use_count() << endl; }// ptr离开作用域,被销毁 void mian() { int *x(new int(9));// 危险!x是一个普通指针,不是一个智能指针 process(shared_ptr<int>(x));//合法的,但内存会被释放 int j = *x;//未定义的:x是一个空悬指针!! }
函数process中传入x用于初始化局部对象ptr,出函数->ptr销毁->引用计数降为0->内存被释放,但是原来的普通指针x还在,但指向的内存已被释放,此时再解引用x是未定义的行为,会发生未知的事情。
-
第二个问题
shared_ptr<int> p(new int(10)); int *q=p.get(); { shared_ptr<int> np(q); } int j=*p;// 未定义的:p指向的内存已经被释放了
int *q = new int(10); shared_ptr<int> p(q); { shared_ptr<int> np(q); } int j=*p;// 未定义的:p指向的内存已经被释放了
上面的两段代码都是一个问题:用普通指针初始化了多个智能指针。他们都不知道彼此的存在(引用计数都是1),一个出了作用域就将内存释放了,别的智能指针就成了空悬指针。
-
总结:代码中避免这样的问题
这俩问题其实很好避免:通过上面的例子,应该很容易发现,罪魁祸首就是普通指针。其实在使用智能指针时,普通指针基本用不着,不是真的需要就不要让普通指针出现了。
5. 自定义删除器
shared_ptr和unique_ptr销毁管理的对象时,析构函数默认使用delete释放内存。
但是,我们可以给shared_ptr和unique_ptr定义我们自己的删除器,让它销毁对象时,执行我们想要的释放操作。
删除器又分为在运行时绑定和在编译时绑定,这两个区别适用于区别shared_ptr和unique_ptr的。shared_ptr的删除器是在运行时调用指定的可调用对象,故是在运行时绑定。给unique_ptr重载一个删除器会影响到unique_ptr的类型以及如何构造该类型的对象(可以理解为删除器是unique_ptr类型的一部分),故是在编译时就绑定了的。
可调用对象简单介绍一下:有四种[P346]:1.函数、2.函数指针、3.重载了调用运算符“()”的类、4.lambda表达式。与数组类似,函数无法直接做函数参数,得用函数指针[P222]。
- 自定义shared_ptr的删除器
基本形式如下:(记住一句话:告诉shared_ptr用哪个删除器就行了)
给定示例1或3的删除器后,当sp管理的对象被释放时,能看到打印出来的消息。可以自己测试一下看看效果shared_ptr<T> sp(q , d); // q为指向动态对象的指针,d为删除器(可调用对象;由于是做参数,若是函数得用函数指针) /*示例1*/ auto deleter = [](int* p) { cout << "[deleter called]\n"; delete p; };//这是lambda表达式P346 shared_ptr<int> sp(new int(2), deleter ); /*示例2*/ shared_ptr<int> sp(new int(2), [](int* p){delete p} );// 删除器是lambda表达式 /*示例3*/ void deleter(int* p) { cout << "[deleter called]\n"; delete p; } void main() { shared_ptr<int> sp(new int(2), deleter); }
通过这种自定义删除器的方式,可以让share_ptr也能管理动态数组。这会在后面动态数组中统一讲解。 - 自定义unique_ptr的删除器
基本形式如下:(记住一句话:unique_ptr还需要删除器的类型)unique_ptr<T,D> up(p, d);//T为动态对象的类型,D为删除器的类型,p为动态对象的指针,d为删除器(可调用对象;由于是做参数,若是函数得用函数指针) /*示例*/ unique_ptr< int, decltype(func)* > up(new int(2), func); //func为我们自己定义的删除器函数(函数指针)。 //decltype(fun)返回一个函数类型,故必须添加一个*来指出我们正在使用该类型的一个指针[P62,P223]
6. 动态数组
new[]/delete[] 和 allocator类
new/delete运算符一次分配/释放一个对象,但有时候我们需要一次为很多对象分配内存。例如vector、string的实现。C++提供了两种方式来完成这个任务。
- new分配数组对象,需要在类型名后面跟上一对方括号[],在其中指明要分配对象的数目,该数目可以是变量。
怎么用直接看代码吧:
需要注意的是,new[]的返回值是指向第一个对象的指针(与一般的数组类似)。int *pia = new int[get_size()]; /*使用类型别名的便捷方法*/ typedef int arrT[42];//很独特的类型别名定义,不符合常规思维,需要注意。这里arrT表示42个int的数组 int* p = new arrT;//分配一个42个int的数组,p指向第一个int
用new分配的对象,不管是单个对象还是数组中的,都是默认初始化的[P40]。对于数组中的元素,我们可以用值初始化[P88] 或 列表初始化[P102]:int *pia = new int[10];//10个未初始化的int int *pia2 = new int[10]();//10个值初始化的为0的int int *pia3 = new int[3]{1,2,3};//列表初始化 string *psa = new string[10]{"a","an"};//前两个用给定的初始化器初始化,后面剩下的进行值初始化
- 使用delete[]释放动态数组
没啥说的,看代码:typedef int arrT[42]; int *p = new arrT; delete [] p;
- 用new[]/delete[],那我们当然就得考虑一下安全性。有没有智能指针来管管?有的有的。
1️⃣最方便的:unique_ptr提供了另一个版本来管理new分配的数组。也是在类型名后面加上一对方括号;
unique_ptr指向数组时,可以用下标运算符访问数组中的元素。unique_ptr<int[]> up(new int[10]()); cout<< up[0] << endl;// unique_ptr可以用下标运算符访问数组中的元素 up.reset();//自动用delete[]销毁指针释放对象
2️⃣比较麻烦的:shared_ptr不直接支持管理动态数组,想要它完成这个任务,我们得提供自己定义的删除器,能让他完成对动态数组的释放。
而且shared_ptr管理动态数组不止这一个不方便,它还不能使用下标运算符访问数组元素,而且智能指针类型不支持指针算数运算(不能*(sp+1)访问下个元素);为了访问数组中的元素,只能用get()获得普通指针再来操作。//简单的给它提供一个lambda表达式,让他能使用delete[]释放动态数组 shared_ptr<int> sp(new int[10](), [](int *p){delete[] p;}); for(size_t i=0; i!=10; ++i){ *(sp.get()+i) = i;//只能使用get()访问数组中的元素 }
-
allocator为什么存在:
new将内存分配和对象构造组合在了一起,delete将对象析构和内存释放组合在了一起。
当我们分配单个对象时,通常希望内存分配和对象初始化组合在一起,因为这种情况下我们肯定知道对象应该是什么值。
当分配一大块内存时,我们通常不会一开始就全给他初始化了,而是想在后续按计划在这块内存上按需构造对象。这时候,allocator应运而生,它可以分配大块原始的、未构造的内存,但是只在真正需要的时候才执行对象的创建。 -
allocator类的使用
见上表。使用示例如下allocator<string> alloc;//实例化allocator对象 int n=10; string* const p = alloc.allocate(n);//分配原始内存,用于保存n个string类型的对象,返回指向内存开始地址的指针p auto q=p;//将p指针拷贝给q,auto一般会忽略的顶层const[P61],故q为string*类型 alloc.construct(q++);//第一个对象构造为空字符串 alloc.construct(q++,"gyh");//第二个构造为"gyh" alloc.construct(q++,10,'c');//第三个对象构造为"cccccccccc" //cout<<*q<<endl; //灾难!!q指向未构造的内存,未构造对象的内存不能用!! /* 使用这些构造的对象 ... 使用这些构造的对象 */ while(q!=p) alloc.destroy(--q); //释放我们真正构造的对象 alloc.deallocate(p,n); //释放内存
-
使用技巧
1️⃣将 allocate() 返回的指针p定义为顶层 const,以便于后面释放内存 deallocate() 使用
2️⃣使用循环 destroy() 来析构、释放构造好的对象
3️⃣若不用该内存块了,将元素都销毁后,释放分配的内存
7. 智能指针的循环引用
学完前面的内容,就可以综合前面的知识来解决问题了,故特意将这个最重要的问题放在最后。
-
为什么会发生循环引用?有什么后果?
首先先定义一个数据类型Node。再定义一个删除器,以便更好地观察对象的析构与内存释放的发生。(不知道怎么自己定义删除器的,往上拉拉再复习一下)
好了,接下来我们一步一步来看。记得看的时候,将智能指针理解为一个变量,这个变量指向一个内存块,内存块里存放着一些变量。struct Node { shared_ptr<Node> pPre; shared_ptr<Node> pNext; }; void deleter(Node* p) { cout << "[deleter called]\n"; delete p; }
-
循环引用探究1
//shared_ptr<Node> p1 = make_shared<Node>(); shared_ptr<Node> p1(new Node(), deleter); { //shared_ptr<Node> p2 = make_shared<Node>(); shared_ptr<Node> p2(new Node(), deleter); cout << p1.use_count() << endl;//1 cout << p2.use_count() << endl;//1 p2->pNext = p1; cout << p1.use_count() << endl;//2 cout << p2.use_count() << endl;//1 // 这里会打印出[deleter called] } cout << p1.use_count() << endl;//1 // 这里程序结束,会打印出[deleter called]
观察这个代码的输出,我们可以得出这样一个结论:当智能指针被销毁时,若其释放了内存,则内存中存放的变量(智能指针)也会被销毁(内存中这些智能指针指向的内存会不会被释放取决于其引用计数)。
这个代码还是没问题的,过程如下:
退出p2作用域->对象p2被销毁->其指向的内存块2的引用计数变为0被释放->内存块2中的对象也被销毁->对象next指向的内存块1由于还有p1指着故引用计数减为1,内存块1未被释放。
出了p2作用域后状态如下图:
-
循环引用探究2
//shared_ptr<Node> p1 = make_shared<Node>(); shared_ptr<Node> p1(new Node(), deleter); { //shared_ptr<Node> p2 = make_shared<Node>(); shared_ptr<Node> p2(new Node(), deleter); cout << p1.use_count() << endl;//1 cout << p2.use_count() << endl;//1 p2->pNext = p1; p1->pPre = p2; cout << p1.use_count() << endl;//2 cout << p2.use_count() << endl;//2 } cout << p1.use_count() << endl;//2 ;引用块1的引用计数 cout << p1->pPre.use_count() << endl;//1 ;内存块2的引用计数 cout << p1->pPre->pNext.use_count() << endl;//2 ; 引用块1的引用计数 // 到程序结束也没有打印一个[deleter called]
观察这个代码的输出:就已经出问题了。
过程如下:
p2出作用域,被销毁,内存块2引用计数递减 -> 但由于其指向的内存还有p1.pre在指着,故内存块2不会释放 -> p2.next也不会被销毁 -> 内存块1引用计数不会减少。
出了p2作用域后状态如下图:
这是出了什么问题呢:循环引用。你想把内存块1释放了,就得先把内存块2释放了,那怎么释放内存块2呢?把内存块1释放了呀!
这就很尴尬了,就像你找工作似的:“你没有工作经验就找不到工作,那怎么获得工作经验呢?去工作呀!” -
循环引用探究3
{ shared_ptr<Node> p1(new Node(),deleter); shared_ptr<Node> p2(new Node(),deleter); cout << p1.use_count() << endl;//1 cout << p2.use_count() << endl;//1 p1->pNext = p2; p2->pPre = p1; cout << p1.use_count() << endl;//2 cout << p2.use_count() << endl;//2 } //出来作用域俩指针都销毁了,但两个内存都还在,妥了,内存泄漏(房子还在,钥匙没了) //到程序结束也没有打印一个[deleter called]
观察这个代码的输出:问题很直观——内存泄漏的很直观,而且这块内存它永远的回不来了…。
我们假设p1先被销毁,p2后被销毁(实际应该是倒序销毁,p2先p1后,是同理的),过程如下:
p1、p2出作用域,p1先被销毁,内存块1的引用计数递减 -> 但由于内存块1还有p2.next在指着,故内存块1不会释放 -> p1.pre也不会被销毁 ->接着p2被销毁,内存块2的引用计数递减->但由于内存块2还有p1.pre在指着,故内存块2不会释放->p2.next也不会销毁。
出了作用域后状态如下图:
最后两块内存的引用计数都是1,但是这两块内存已经把门锁上钥匙砸了,再也访问不到了,所以我们也没法得到他们的引用计数。
-
怎么解决循环引用呢?用weak_ptr呀
把上面的数据类型Node改成存放weak_ptr,就不会有这个问题了。
把Node结构体改成下面这样,再试一遍上面三个探究测试。发现输出都正常了。
想想为啥呢?懒得写了,自己想吧。告辞~struct Node { weak_ptr<Node> pPre; weak_ptr<Node> pNext; };