【C++11】学习C++就看这篇--->智能指针详解(RAII思想&循环引用)
本篇博客详细介绍C++11中智能指针等相关知识,对智能指针的发展历程进行了逐步的解析,看完你对这部分知识会有非常深层次的理解,你一定会有非常大的收获!

🎬 个人主页:HABuo
📖 个人专栏:《C++系列》《Linux系列》《数据结构》《C语言系列》《Python系列》《YOLO系列》
⛰️ 如果再也不能见到你,祝你早安,午安,晚安

目录
前言:
C++11中还有个相当重要的知识点,就是智能指针,所以我们单独用一篇博客来介绍它。本篇博客结束,我们就即将迎来曙光,C++的学习也将告一段落,也希望大家做好复习,接下来博主也将继续更新Linux相关知识的介绍和总结,让我们继续一起加油努力!
本章重点:
本篇文章着重讲解智能指针的发展历史中出现过的auto_ptr,unique_ptr以及主角shared_ptr,并且会介绍什么是RAII思想,以及为什么要有智能指针这一话题。最后会给大家分析shared_ptr的循环引用问题以及定制删除器的基本概念
📚一、为什么需要智能指针?
在写代码时,我们经常在堆上申请空间但是偶尔会忘记释放空间,会造成内存泄漏问题。当然,这不是最重要,在某些场景下即使你释放了也会有问题:
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;
}
在上面代码的这种场景中,不管是使用new还是调用div函数都有抛异常的风险并且程序一旦抛异常就会直接跳到catch处,所以上面的代码一旦抛异常就代表着delete p1和p2并不会执行,也就会出现内存泄漏的问题!这个问题不使用智能指针是很难解决的!!!
三大内存问题:
内存泄漏:忘记释放内存
悬空指针:释放后再次访问
双重释放:多次释放同一内存
📚二、智能指针的设计与RAII思想
📖2.1 RAII思想
RAII思想是一种 利用对象生命周期来控制程序资源 (如内存、文件句柄、网络连接、互斥量等等)的简单技术。在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象
概括为以下三点:
-
资源在构造函数中获取
-
资源在析构函数中释放
-
对象生命周期 = 资源持有期
这种做法有两种好处:
- 不需要显式地释放资源
- 对象所需的资源在其生命期内始终有效
// RAII 简单示例
class FileHandler {
FILE* file;
public:
FileHandler(const char* filename) : file(fopen(filename, "r")) {}
~FileHandler() { if(file) fclose(file); }
// 使用编译器生成的拷贝控制成员可能有问题,需要正确处理
};
上述代码的意思就是,利用构造打开文件,对象生命周期结束,利用析构直接将文件关闭,不需要手动,这就是RAII思想
📖2.2 智能指针的基本设计
现在我们来写一个类,构造函数的时候创造资源,析构函数的时候释放资源,当对象出了作用域会自动调用析构!
// 使用RAII思想设计的SmartPtr类
template<class T>
class SmartPtr {
public:
SmartPtr(T* ptr = nullptr)
: _ptr(ptr)
{}
~SmartPtr()
{
if (_ptr != nullptr)
delete _ptr;
}
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
private:
T* _ptr;
};
这样设计之后,即使出现最下面的问题,但是在对象结束之后会自动调用析构函数来进行释放资源,从而不导致内存泄漏的问题。
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void Func()
{
SmartPtr<int> sp1(new int);
SmartPtr<int> sp2(new int);
cout << div() << endl;
}
int main()
{
try {
Func();
}
catch (const exception& e){
cout << e.what() << endl;
}
return 0;
}
📖2.3 智能指针的发展历程
首先,我们要清楚智能指针的一个大坑,那就是当一个指针赋值给另外一个指针时,我们需要的是浅拷贝,因为我们就是想让两个指针指向同一块空间,但是指向了同一块空间就会有析构函数调用两次的风险由于这一个大坑,智能指针进行了很多次迭代。所以,这也告诉了我们一件道理,就是当一件事情初始就是错的,那么就要直接抛弃,如果为了弥补错误,只会错上加错。
- 在C++98的时候就已经在库中实现了智能指针了,它就是
auto_ptr

既然智能指针是随着历史不断发展的就证明它前面的版本写的不咋滴,事实也是如此,auto_ptr是这样实现的,既然有析构两次的风险,那么我就管理权转移,当我把A指针赋值给B指针后,A指针就置空不能用了,这就导致了上述说的悬空指针的问题,对于不了解auto_ptr的人来说这无疑是一个巨大的风险!因此公司一般会命令禁止使用auto_ptr
auto_ptr<int> ap1(new int(10));
auto_ptr<int> ap2(ap1);
//此时ap1已经失效了!

- 有了这一大坑后,C++11推出了全新的智能指针:
unique_ptr

unique_ptr的做法相比auto_ptr有过之而无不及,智能指针不是拷贝有问题吗?那么unique_ptr就直接独占资源所有权,不能拷贝和赋值,只能移动,很显然这也是一个坑,但是在实际场景下,unique_ptr至少还能被用到但auto_ptr是很多公司明令禁止使用的!
unique_ptr(const unique_ptr<T>& sp) = delete; unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;


- 经过两次失败的智能指针后,C++11还推出了今天的主角:
shared_ptr

std::shared_ptr - 共享所有权
特点:
-
多个指针共享同一对象
-
使用引用计数管理生命周期
-
当最后一个 shared_ptr 被销毁时释放内存
shared_ptr可堪称完美的智能指针,也是实际中使用的最多的智能指针,它采用的是引用计数的思想,当指向这份空间的计数是1时才析构,大于1时就将计数减一,非常的优雅!
📚三、shared_ptr的模拟实现
上面我们说shared_ptr是较完美的智能指针,因此使用的也是比较广泛,因此我们来模拟实现一下它,以防止在面试的时候被问到:我们使用引用计数的方式来实现shared_ptr,也就是在原先代码的基础上增加一个int*成员变量来保存,还有几个指针指向当前空间!
template<class T>
class Smart_Ptr //实现的C++11的shared_ptr版本
{
public:
Smart_Ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pcount(new int(1))
{}
~Smart_Ptr()
{
Release();
}
Smart_Ptr(const Smart_Ptr<T>& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
{
Addcount();
}
Smart_Ptr<T>& operator=(const Smart_Ptr<T>& sp)
{
if (_ptr != sp._ptr)
{
Release();
_ptr = sp._ptr;
_pcount = sp._pcount;
Addcount();
}
return *this;
}
void Release()
{
if (--(*_pcount) == 0)//销毁最后一个变量时才释放资源
{
delete _ptr;
delete _pcount;
delete _pmtx;
}
}
void Addcount() {
(*_pcount)++;
}
void Subcount() {
Release();
}
private:
T* _ptr;
int* _pcount;
};
类似这样,并且对于计数的变量,我们采用一个指针指向它,也就是意味着每个资源都维护着一份计数,用来记录该份资源被几个对象共享。

📚四、循环引用问题
struct ListNode
{
int _data;
shared_ptr<ListNode> prev;
shared_ptr<ListNode> next;
~ListNode(){ cout << "~ListNode()" << endl; }
};
int main()
{
shared_ptr<ListNode> node1(new ListNode);
shared_ptr<ListNode> node2(new ListNode);
node1->next = node2;
node2->prev = node1;
return 0;
}
上面代码运行的话会导致崩溃,这与原因请看下图:

现在来进一步分析:当main函数调用完,node2会先析构,但是此时引用计数是2所以不会释放空间而是将计数变为1。然后node1再析构,同上,它的引用计数也减为一,但是这两份空间并不会释放,因为要node2的prev释放后,node1的空间才会释放,那node2的prev什么时候释放?答案是node2这份空间释放了才会释放prev,那么node2这份空间什么时候释放?答案是node1的next释放了它才释放,这就形成了一个死循环,我等你释放了我才能释放,对方也在等我释放了对方才能释放,这就是"循环引用问题"

std::weak_ptr - 弱引用
-
不增加引用计数
-
用于解决 shared_ptr 的循环引用问题
-
必须转换为 shared_ptr 才能访问数据
struct ListNode
{
int _data;
weak_ptr<ListNode> _prev;
weak_ptr<ListNode> _next;
~ListNode() { cout << "~ListNode()" << endl; }
};
int main()
{
shared_ptr<ListNode> node1(new ListNode);
shared_ptr<ListNode> node2(new ListNode);
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
node1->_next = node2;
node2->_prev = node1;
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
return 0;
}
📚五、定制删除器
使用智能指针时可能会遇见下面的问题:
shared_ptr<int> sp1(new int[10]);
当变量出作用域销毁时即报错因为new []对应的是delete []。然而库中写法并不能识别有没有[]
还有下面这样的问题:
shared_ptr<FILE> sp3(fopen("Test.cpp", "r"));
此时智能指针管理的对象并不是堆上开辟的空间,delete完全没法用,此时需要使用fclose,所以定制删除器非常重要

在构造函数的地方可以传入一个定制删除器,也就是一个函数对象,也就是传一个实现释放方式仿函数对象进去给智能指针,此函数中有对应的删除方法,请看下面的代码:
shared_ptr<int> sp2(new int[10], [](int* ptr) {delete[] ptr; });
shared_ptr<FILE> sp3(fopen("Test.cpp", "r"), [](FILE* ptr) {fclose(ptr); });
📚六、总结
整个智能指针的发展历程,真可谓是“夫鸡肋,弃之如可惜,食之无所得”产生一个坑,挖坑填坑,C++标准委员会这帮人,真真是好吃懒做不作为,宇宙区长实名了。
玩笑归玩笑,面试还是要面的,工作还是要找的,从C语言内存泄漏问题,我们引入了基于RAII思想的智能指针,auto_ptr-->unique_ptr-->shared_ptr-->weak_ptr,以及shared_ptr所引出的循环引用问题、包括定制删除器。对于这部分内容重点是shared_ptr及其所引出的循环引用问题,希望大家认真了解!
有关weak_ptr的拓展阅读:

更多推荐


所有评论(0)