文章目录
- C++进阶--智能指针(重点)
- 智能指针使用的场景
- RAII和智能指针的设计思路
- C++标准库智能指针的使用
- 定制删除器
- 智能指针的原理
- shared_ptr和weak_ptr
- 循环引用(容易考)
- weak_ptr
- 其他
- 知识扩展(类型转换)
- 总结
- 个人学习心得
- 结语
很高兴和大家见面,给生活加点impetus!!开启今天的编程之路!!
今天我们进一步c++11中常见的新增表达
作者:٩( ‘ω’ )و260
我的专栏:Linux,C++进阶,C++初阶,数据结构初阶,题海探骊,c语言
欢迎点赞,关注!!
C++进阶–智能指针(重点)
智能指针使用的场景
前面我们学习了异常的相关知识,明白了异常的使用语法,异常的底层原理,学习了异常的一些细节,但是我们还有一种情况,使用异常代码可视化非常低,而且代码也不好看。
来看示例:
void Divide(int a,int b)
{
if(b == 0)
{
throw string("error: Divided by zero!");
}
else {
cout << double(a)/double(b) << endl;
}
}
int main()
{
int arr1 = new int[10];
int arr2 = new int[10];
try{
int a,b;cin >> a >> b;
Divide(a,b);
}
catch(...)//捕获任意类型的错误
{
//相关操作,释放arr1,arr2
}
return 0;
}
我们下方写catch(…)的目的就是为了来释放前面申请到的arr1和arr2资源。
但是new本身可能抛异常啊,如果说此时申请arr1抛异常,无伤大雅,因为资源还没有申请下来,但是如果是申请arr2抛异常,那么我的arr1肯定是已经申请下来了,不然走不到申请arr2资源的这句代码中去。
所以:如果使用异常解决这个问题的话,需要在申请arr2资源的时候再来套一个try,catch,缺点就是代码可视化降低
我们来尝试使用智能指针解决这个问题
RAII和智能指针的设计思路
首先:这里的问题就是需要析构的东西抛异常可能会导致无法执行正确的析构,进而导致内存泄漏。
如果说我们将申请到的资源托管给一个对象,那就可以解决这个问题,为什么呢?因为对象不论是局部还是全局的,函数执行结束或者代码执行结束这个对象会调用自己的析构,即:对象定义和销毁的时候构造函数和析构函数是不用用户自己调用的,编译器自己帮我们调用
RALL(资源申请立即初始化):本质是⼀种利用对象生命周期来管理获取到的动态资源,避免内存泄漏。RAII在获取资源时把资源委托给一个对象,接着控制对资源的访问,资源在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。
智能指针除了需要满足RALL思想,还需要设计一系列接口还管理这个资源
我们可以写一个简单的项目来描述智能指针需要设计的内容:
namespace Mrzeng
{
template<class T>//可能接收到各种类型的指针
class Smart_ptr
{
public:
Smart_ptr(T* ptr)
:_ptr(ptr)
{}
~Smart_ptr()
{
delete _ptr;//对象调用析构的时候将申请到的资源释放掉
}
//一系列简单接口
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T* get()
{
return _ptr;
}
public:
T* _ptr;
}
}
反正记住:operator->一定是返回一个地址,因为到时候使用的时候实际上有两个->,前者是operator->(),后者单纯是->操作符。->操作符对指针操作,所以operator->()必须返回一个地址。
学习了RALL思想,上述代码应该这样修改
int main()
{
Mrzeng::Smart_ptr<int> sp1(new int[10]);//这种情况下,不会存在内存泄漏的问题
Mrzeng::Smart_ptr<int> sp2(new int[10]);//这种情况下,不会存在内存泄漏的问题
try{
int a,b;cin >> a >> b;
Divide(a,b);
}
catch(...)//捕获任意类型的错误
{
//相关操作,释放arr1,arr2
}
return 0;
}
C++标准库智能指针的使用
头文件:< memory >
智能指针主要分为两种,在此之前,我们先介绍c++98中的智能指针。
auto_ptr:
首先先说明结论:auto_ptr在很多企业中的使用条例被禁止使用,原因为底层实际上是管理权转移。
我们主要使用日期类来演示,来看代码:
class Date
{
public:
Date(int year, int month, int day)
:_year(year), _month(month), _day(day)
{}
~Date()
{
cout << "~Date" << endl;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
auto_ptr<Date> ap1(new Date(2025, 6, 8));
auto_ptr<Date> ap2(ap1);//使用ap1拷贝ap2
cout << ap2.get() << endl;
cout << ap1.get() << endl;
return 0;
}
我们发现,这个时候ap1(被拷贝对象)资源全部清空了,本质上就是ap2(拷贝对象)拷贝之后内部重置了ap1,造成ap1的悬空
正是因为这个原因,而且ap1的悬空还是内部自己实现的,造成很多程序员书写的时候可能不会注意到这点。
c++11中的智能指针:unique_ptr和shared_ptr
unique_ptr:特点是只能够对单一资源进行管理,即1对1,。所以内部没有实现拷贝构造,只有移动构造,移动构造是对资源进行转移,肯定能够保证1对1的规则
我们发现:为什么unique_ptr和auto_ptr效果一样,可后者被禁用了呢?因为后者是底层搞得,上层使用者没学习的话根本不知道,但是前后调用移动构造,学习了右值引用章节知识肯定知道也不会再对资源进行访问
shared_ptr:特点是可以多个shared_ptr管理一个资源,即多对1,也可以一个shared_ptr管理一个资源,即1对1。内部实现了拷贝构造。既然可以由多个对象管理一块资源,为了防止一块资源被delete多次(同一块资源被delete多次,直接报错),使用了引用计数的方法。引用计数的目的就是防止同一块空间被析构多次
shared_ptr和unique_ptr区别:前者可以拷贝构造,后者不能够拷贝构造。
思考:shared_ptr能够管理一块资源,即1对1,unique_ptr也能够管理一块资源,即1对1,为什么还要有unique_ptr呢?
shared_ptr引用计数底层有性能消耗,所以如果管理对象一不涉及拷贝,更适合使用unique_ptr。反之更适合使用shared_ptr。
c++第三个智能指针:weak_ptr
特点:不符合RALL规则,所以不能够来使用管理对象。weak_ptr的产生是为了解决shared_ptr循环引用的问题,这点我们稍后讲解
定制删除器
我们再来看,前面我们举例都是使用delete删除资源的,确实,在标准库中,智能指针的默认析构资源使用的是delete,如果我是申请的数组呢?是不是需要使用delete [ ]呢?但是后面c++更新中模版特化了这种情况
那如果申请的资源是fopen使用的文件的,释放这个资源应该使用fclose而非delete。
为了处理这种情况,需要定制删除器。
删除器的本质就是一个可调用对象。
这里设计的复杂了,同样都是只能指针,但是传递删除器的方式却不相同。
先来看如何使用:
shared_ptr:
因为是函数模版,所以需要在构造shared_ptr的时候传递一个可调用对象过去:
struct FClose
{
void operator()(FILE* ptr)
{
cout << "fclose:ptt" << endl;
fclose(ptr);
}
};
int main()
{
//使用仿函数执行删除器
shared_ptr<FILE> sp1(fopen("Test1.cpp", "r"),FClose());//需要传一个对象过去,是传给的函数模版,函数指针和仿函数相差不大,这里不再演示
shared_ptr<FILE> sp2(fopen("Test2.cpp", "r"), [](FILE* ptr) {//传lambda表达式过去
cout << "fclose:ptr" << endl;
fclose(ptr);
});
return 0;
}
unique_ptr
因为删除器是作为模版参数传过去的,所以传递的应该是一个类型
struct FClose
{
void operator()(FILE* ptr)
{
cout << "fclose:ptt" << endl;
fclose(ptr);
}
};
int main()
{
//使用仿函数执行删除器
unique_ptr<FILE,FClose> sp1(fopen("Test1.cpp", "r"));//unique_ptr删除器是在模版位置传递的,所以传一个类型,函数指针与仿函数相差不大,不在演示
//演示传递lambda表达式,比较复杂,先来看结果
auto FClose = [](FILE* ptr) {
cout << "fclose:ptr" << endl;
fclose(ptr);
};
unique_ptr<FILE, decltype(FClose)> sp2(fopen("Test1.cpp", "r"),FClose);
return 0;
}
解释:decltype(类型推导关键字):在编译时进行类型推导。
前面已经提到,unique_ptr的定制删除器需要使用类型传递给模版,但是lambda表达式是一个匿名函数对象,我传递的需要是类型,所以使用decltype关键字。
其次:后面为什么要传一个FClose呢?
因为lambda表达式无法实例化出对象,需要使用拷贝构造拷贝出一个对象,调用这个对象,执行完删除资源的操作之后就需要将这个对象析构。不可能我去调用一个类型吧。
其实,调用了这个unique_ptr中的构造函数的这个接口:
记住:如果头铁就要使用unique_ptr来定制删除器,如果是lambda表达式作为模版参数的话,记得构造时添加一个可调用对象,左值或右值都可以
总结:定制删除器尽量都是用shared_ptr,而且尽量都是用lambda表达式
细节:上述定制删除器unique_ptr因为传的是模版,所以在整个类中都是可以直接使用这个对象的。但是,shared_ptr中发现,定制删除器是作为一个函数模版有函数模版推导而来的。
那么我们如果在需要析构资源的时候使用到这个定制删除器呢?
使用包装器,目的是将删除器保存下来,想用的时候就用,而不限于只有构造函数能够用这个删除器。而且,最好包装器需要给一个缺省值走初始化列表,因为包装器(这里使用function)的默认构造是空的,但是去取的话,如果包装器是空,就会抛异常,这里跟库一样,将delete设置为缺省值,还要注意包装器的类型,因为什么事情也不干,返回值是void,传递的参数是T*
智能指针的原理
1:首先需要包含定制删除器的细节
2:析构函数的细节:
只有当引用计数为1的时候,我们才需要析构这块资源,否则只是引用计数减少1
3:拷贝构造的细节:
前面我们提到unique_ptr不支持拷贝构造,只支持移动构造,所以,如果我们创建一个对象并初始化调用构造函数只能够这样写:
shared_ptr<Date> sp1(new Date(2025,6,8));//能够这样写
shared_ptr<Date> sp1 = {2025,6,8};//不能够这样写。
想一下问什么不能够这样写呢?
这需要了解类型转换相关的知识,这点我会在后面讲解。
首先我们使用sp1来管理资源为{2025,6,8}的对象。
会执行的操作如下:构造+拷贝构造(会被直接优化成直接构造)
问题:构造+拷贝构造优化为直接构造在c++11中c++委员会规定还没有规定!即有些编译器优化了,有些编译器没有优化。但是unique_ptr是不支持拷贝构造的,即有些编译器会出错,有些编译器可能不会报错!
为了杜绝这种情况发生,使用了explicit修饰,目的是:避免类的单参数构造函数被用于隐式类型转换
其实这里还有一种写法,使用make_shared+传递参数返回一个shared_ptr,作用是:它能够减少代码冗余,用户不用显式地书写 new 表达式,局限是:没办法自定义删除器(deleter)。如果确实需要自定义删除器,那就得直接使用 new 来创建 std::shared_ptr(依靠构造函数)
4:赋值重载细节:注意防止地址重复赋值和相同地址的赋值
来看案例:sp1 = sp2
将sp2赋值给sp1,需要考虑sp1的引用计数是否为1,如果为1,则直接析构,然后修改为sp2,如果sp1的引用计数不为1,只用修改sp1管理资源的引用计数。然后修改成sp2,最后sp2的引用计数需要加1,因为多了一个sp1来管sp2管的资源了。
而且:如果说sp1和sp2地址相同,就啥也不做,做了也是白做。
最后代码部分已经绑定到这篇文章了。
shared_ptr和weak_ptr
循环引用(容易考)
shared_ptr看似非常完美,既支持拷贝构造,也能多个shared_ptr管理一个资源。但是还有不足,来看下面案例:
如果此时需要创建一个链表,这个链表中只有两个结点,这两个结点相互指向,同时,我还需要智能指针来管理这两个结点。
来看代码:
struct ListNode
{
int _data;
/*ListNode* _next;
ListNode* _prev;*/
std::shared_ptr<ListNode> _next;
std::shared_ptr<ListNode> _prev;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
int main()
{
std::shared_ptr<ListNode> n1(new ListNode);
std::shared_ptr<ListNode> n2(new ListNode);
cout << n1.use_count() << endl;
cout << n2.use_count() << endl;
n1->_next = n2;
n2->_prev = n1;
cout << n1.use_count() << endl;
cout << n2.use_count() << endl;
return 0;
}
先说明一下为什么_next指针的类型是这个,下方我们使用了share_ptr来管理结点,可以理解为此时这个结点的类型是一个智能指针了,只不过真正的结点被这个share_ptr管理着。所以此时_next和_prev都给换了一个类型。不换的话是会报错的-类型不匹配。
结果:
代码执行结束,管理的对象销毁,但是,并没有调用析构函数,违背了RALL规则,为什么呢?
来看图示:
先明白,为什么引用计数变成2了?
因为_next和_prev指针都是一个智能指针,管理一个资源之后这个资源引用计数肯定需要加1
循环引用的逻辑:左边结点引用计数为1,想要销毁,必须让管理左结点的对象prev销毁,对象prev在右边结点中,右边结点销毁,其中的内容销毁,右边结点想要销毁,必须让管理右边结点的对象next销毁,但是next在左边结点,左边结点销毁,其中的内容销毁,要想让左边结点销毁,必须让管理左边结点的对象prev销毁。我们发现,是不是回到了第一句!!即左结点想要销毁,左结点才能够销毁!!
并且,如果不是相互指向,都不会右循环引用,即单个方向指向不存在循环引用。
再来想一下是什么原因造成这一个现象的?
原因是不是next和prev指针增加了对应资源的引用计数!!
所以,为了不增加对应资源的引用计数,同时还要链接两个节点,weak_ptr运应而生。
weak_ptr不遵循RALL规则,不管理资源,也就不会增加引用计数
上述代码只用将_next和_prev的类型修改为weak_ptr< ListNode >,就能够杜绝循环引用的现象
来看结果:
析构正常执行!!
weak_ptr
weak_ptr主要解决shared_ptr的循环引用的问题,不遵循RALL,不会增加引用计数,相应的,其中没有operator*和operator->等接口,因为weak_ptr不管理资源。因为如果weak_ptr绑定的shared_ptr已经释放了资源,那么weak_ptr再去访问资源十分危险。
weak_ptr支持expired检查指向的资源是否过期,use_count也可获取shared_ptr的引用计数,weak_ptr想访问资源时,可以调用lock返回一个管理资源的shared_ptr,如果资源已经被释放,返回的shared_ptr是一个空对象,如果资源没有释放,则通过返回的shared_ptr访问资源是安全的。
是否过期的判断条件 :shared_ptr对象管理的资源,如果这些对象都不管理这个资源了,就过期,反之只要还有一个对象管理着这个资源,就没有过期,呈现的结果为为真即过期,为假不过期。
来看示例代码:
int main()
{
std::shared_ptr<string> sp1(new string("111111"));
std::shared_ptr<string> sp2(sp1);
std::weak_ptr<string> wp = sp1;
cout << wp.expired() << endl;
cout << wp.use_count() << endl;
// sp1和sp2都指向了其他资源,则weak_ptr就过期了
sp1 = make_shared<string>("222222");
cout << wp.expired() << endl;
cout << wp.use_count() << endl;
sp2 = make_shared<string>("333333");
cout << wp.expired() << endl;
cout << wp.use_count() << endl;
wp = sp1;
//std::shared_ptr<string> sp3 = wp.lock();
auto sp3 = wp.lock();//会返回一个shared_ptr
cout << wp.expired() << endl;
cout << wp.use_count() << endl;
*sp3 += "###";
cout << *sp1 << endl;
return 0;
}
来看结果:
其他
剩下的还有线程安全问题,之后我们再讲解。
知识扩展(类型转换)
上面我们提到了类型转换,我们可以浅浅讲解一点。
这里直接引出结论:
其他类型a(自定义类型,内置类型)转换成自定义类型b:支持自定义类型的构造函数可以使用a作为参数,然后分为单参数和多参数的区别。
自定义类型a转换成内置类型:使用重载函数,operator 内置类型(参数),返回类型为该内置类型
为什么是这样呢?首先强制类型转换操作符是(),如int a = 1;(double)a;
但是()已经被仿函数占用了,所以只能够定义成如此了。
来看示例:
场景:判断sp1是否管理了资源,可以使用
if(sp1)
{
//操作
}
//等价于
if(sp1.operator bool())
{
//操作
}
当然在流插入中也有operator bool()也有所涉及,使用场景如下
while(cin >> x)
{
//...
}
总结
今天我们学习了智能指针,分析了使用智能指针的场景,智能指针满足的规则,底层,定制删除器(语法,总结规律,什么场景下需要使用),循环引用(重点),以及如何来手撕unique_ptr,shared_ptr,weak_ptr这三种智能指针和这三类智能指针的特点区别,学习了类型转换和一些细节。
这些知识点记得复习哦
个人学习心得
在c++11之后,走隐式类型转换的途径一般都是通过给的形参形成一个initializer_list,看这个initializer_list能不能来初始化我想形成的类。
例如有一个日期类:
shared_ptr<Date> sp1 = {2025,6,8};//不能够这样写。
如果说此时我的日期类里面书写了initializer_list这个构造方式,就能够来构造一个Date,并且此时拷贝构造没有explicit关键字修饰(但是库中是有这个修饰的哈),就能够转换为一个Date*
结语
感谢大家阅读我的博客,不足之处欢迎留言指出,感谢大家支持!!
路漫漫其修远兮,吾将上下而求索