智能指针详解

1.使用场景

智能指针一般经常用于预防可能会有资源泄漏的地方。资源泄漏是指所有申请的系统资源,使用完成之后(程序进程结束之前)没有归还系统。其中内存泄漏经常出现,指的是堆上动态分配的内存的泄露。对于内存泄漏作者仅简单举一个例子,在工作的时候敲代码会经常用到捕获异常来防止出现意外纰漏,而我们在try之前new了变量,在catch里面delete,但是程序如果没有走catch和里面的delete,那么new出来的变量无法delete释放空间,这个时候就会造成内存泄漏。内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。

在实际工作中,资源泄漏是一件非常恐怖的事情,如果是内存泄漏之外的其他泄露,可能导致用户数据丢失或者无法打开文件等问题。而对于内存泄漏,看起来只是失去内存的控制,但是实际上每次我们运行程序一般都会开辟一个空间用于存放那些申请出来的资源,可能一次失去几个KB显得无伤大雅,一旦程序结束就可以归还。但是在工作时,存放数据的往往是那些大型的服务器,并且一旦开启就是特别漫长的,基本上很长时间才会关一次,而一个人的代码只要会造成内存泄漏,那么对于海量用户而言,内存泄漏积累起来是非常恐怖的大小,这些泄露的空间占据着服务器的内存不能被释放掉。势必会拖慢服务器运行效率甚至于让服务器停机,属于非常严重的事故。

而智能指针之所以能预防这个问题,在于智能指针是一种模板类,利用了类变量在使用完成,生命周期结束后自动调用析构函数的功能。这里需要注意的是智能指针本身的变量并不是new出来的,否则还得delete智能指针变量,直接创建即可。

2.RAII与智能指针的思路

RAII是Resource Acquisition Is Initialization的缩写,他是⼀种管理资源的类的设计思想,本质是一种利用对象生命周期来管理获取到的动态资源,避免资源泄漏,这里的资源可以是内存、文件指针、网络连接、互斥锁等等。RAII在获取资源时把资源委托给一个对象,接着控制对资源的访问, 资源在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源,这样保障了资源的正常释放,避免资源泄漏问题。

智能指针类除了满足RAII的设计思路,还要方便资源的访问,所以智能指针类还会想迭代器类一样,重载 operator*/operator->/operator\[\] 等运算符,方便访问资源。

3. C++标准库智能指针的使用

C++标准库中的智能指针都在<memory>这个头文件下面,我们包含就可以是使用了, 智能指针有好几种,除了weak_ptr他们都符合RAII和像指针一样访问的行为,原理上而言主要是解决智能指针拷贝时的思路不同。

auto_ptr是C++98时设计出来的智能指针,特点是拷贝时把被拷贝对象的资源的管理权转移给拷贝对象,我们也可以想象成移动构造时说到的"资源掠夺",这是一个非常糟糕的设计,因为他会使被拷贝对象悬空,产生访问报错的问题,C++11设计出新的智能指针后,强烈建议不要使用auto_ptr。其他C++11出来之前很多公司也是明令禁止使用这个智能指针的。

unique_ptr是C++11设计出来的智能指针,他的名字翻译出来是唯一指针,特点是不支持拷贝,只支持移动。如果不需要拷贝的场景就非常建议使用他。

shared_ptr是C++11设计出来的智能指针,他的名字翻译出来是共享指针,特点是支持拷贝, 也支持移动。如果需要拷贝的场景就需要使用他了。底层是用引用计数的方式实现的。这个指针也是后面作者给出简易实现代码的一个。

weak_ptr是C++11设计出来的智能指针,他的名字翻译出来是弱指针,他完全不同于上面的智能指针,他不支持RAII,也就意味着不能用它直接管理资源,weak_ptr的产生本质是要解决shared_ptr 的一个循环引用导致内存泄漏的问题。具体细节后面再说。

智能指针析构时默认是进行delete释放资源,这也就意味着如果不是new出来的资源,交给智能指针管理,析构时就会崩溃。智能指针支持在构造时给一个删除器,所谓删除器本质就是一个可调用对象,这个可调用对象中实现你想要的释放资源的方式,当构造智能指针时,给了定制的删除器, 在智能指针析构时就会调用删除器去释放资源。因为new 经常使用,所以为了简洁一点, unique_ptr和shared_ptr都特化了一份 的版本,使用时 unique_ptr up1(new Date5);shared_ptr sp1(new Date5); 就可以管理new 的资源。

shared_ptr 除了支持用指向资源的指针构造,还支持 make_shared 用初始化资源对象的值 直接构造。

4.shared_ptr的模拟

首先我们需要知道shared_ptr通过引用计数来实现"共享指针",如下图所示:

就像这样子,shared_ptr有两个成员,这两个成员都是指针,分别指向两处空间,一处是所要指向的数据内容,一处则是存放引用计数的空间。第二处空间也是需要我们new出来的。

cpp 复制代码
#include<iostream>
using namespace std;

template<class T>
class myshared_ptr
{
private:
	T* _node;      //数据
	int* _count;   //引用计数
};

然后我们再来想构造函数,在这之前我们需要了解,不同的共享指针之所以能确定指向的数据是否是同一处,靠的是在构造共享指针时传递其他的已经指向指定数据的共享指针,而不是多个共享指针都传指定数据,那样无法计算引用计数。简单来说,我们想要先对一个指定数据有一个共享指针,那么这第一个共享指针构造时就传递该数据的地址或指针,然后想要再创建多个共享指针指向这个地址,就要用拷贝构造了。所以我们写构造函数只需要写拷贝构造和传指针构造。如下:

cpp 复制代码
#include<iostream>
using namespace std;

template<class T>
class myshared_ptr
{
public:
	myshared_ptr(T* newnode)
		:_node(newnode)
		,_count(new int(1))
	{}
	myshared_ptr(myshared_ptr<T>& newnode)
		:_node(newnode._node)
		,_count(newnode._count)
	{
		(*_count)++;
	}
private:
	T* _node;      //数据
	int* _count;   //引用计数
};

接下来是析构函数,析构函数在调用时肯定不可能调用一次就把空间释放掉,应该确定没有其他共享指针指向这块地址了再释放,这个时候就需要靠引用计数来实现,我们先让引用计数--,再判断引用计数的大小,当引用计数为0时,就释放掉空间,否则就只需要把指针置空即可。

cpp 复制代码
	~myshared_ptr()
	{
		(*this->_count)--;
		if ((*this->_count) == 0)
		{
			delete _node;
			delete _count;
		}
		_node = nullptr;
		_count = nullptr;
	}

然后是赋值问题,这里我们先要区分清楚拷贝构造和赋值重载,拷贝构造是创建一个新的变量时的,赋值是给一个已经存在的变量的:

cpp 复制代码
myshared_ptr<int> a1(new int(1));
myshared_ptr<int> a2(new int(2));
myshared_ptr<int> a3 = a2;   //创建一个新的变量,这里是拷贝构造
a1 = a3;     //两个已经存在的变量,这是赋值,不是构造。   

然后我们接着思考,赋值存在两种情况,情况一是相同指向的赋值,情况二则是不同指向的赋值,不同指向的需要把原来的计数--并判断是否需要析构,然后++新的指向的计数:

cpp 复制代码
	void operator=(myshared_ptr<T> node)//自己赋给自己,别的赋给自己
	{
		if (this->_node == node._node)
			return;
		(*this->_count)--;
		if ((*this->_count) == 0)
		{
			delete _node;
			delete _count;
		}
		_node = node._node;
		_count = node._count;
		(*_count)++;
	}

还有前面提到的对于*,->, 等的模拟,这里作者仅模拟了*,->

cpp 复制代码
	T* operator->()
	{
		return _node;
	}
	T& operator*()
	{
		return _node;
	}

最后整个的代码如下:

cpp 复制代码
#include<iostream>
using namespace std;

template<class T>
class myshared_ptr
{
public:
	myshared_ptr(T* newnode)
		:_node(newnode)
		,_count(new int(1))
	{}
	myshared_ptr(myshared_ptr<T>& newnode)
		:_node(newnode._node)
		,_count(newnode._count)
	{
		(*_count)++;
	}
	void operator=(myshared_ptr<T> node)//自己赋给自己,别的赋给自己
	{
		if (this->_node == node._node)
			return;
		(*this->_count)--;
		if ((*this->_count) == 0)
		{
			delete _node;
			delete _count;
		}
		_node = node._node;
		_count = node._count;
		(*_count)++;
	}
	~myshared_ptr()
	{
		(*this->_count)--;
		if ((*this->_count) == 0)
		{
			delete _node;
			delete _count;
		}
		_node = nullptr;
		_count = nullptr;
	}
	T* operator->()
	{
		return _node;
	}
	T& operator*()
	{
		return _node;
	}
private:
	T* _node;      //数据
	int* _count;   //引用计数
};

5.shared_ptr和weak_ptr

前面说到weak_ptr是用于解决shared_ptr的循环引用导致内存泄露的问题。对于循环引用导致内存泄露,其实并不是太常见,但是也是存在的。我们假设创建一个双向循环链表,并用共享指针指向节点,同时节点之间next与prev也用共享节点来指向,那么就会造成如下情况:

当我们删除前面的节点时,发现该节点的计数并不为一,后面的prev和前面的next两者也是指向这块资源,然后我们想删先其他的来让计数变为1,就又循环到其他的节点上去,一直往下,一直循环,一直往下,一直循环。根本删除不了。

weak_ptr就是解决这种情况的,把原来是shared_ptr类型的next和prev指针改成weak_ptr类型。weak_ptr在指向新的资源时并不会引起计数的改变,他只是单纯的指向那块资源。这样shared_ptr就可以只按照shared_ptr的计数不考虑weak_ptr的指向问题。

但是这个时候可能就有读者发现一个问题,shared_ptr并不知道weak_ptr是否指向资源,当shared_ptr指向资源销毁,那么weak_ptr的指针就成了野指针了。所以weak_ptr支持expired检查指向的资源是否过期,use_count也可获取weak_ptr绑定的shared_ptr的引用计数,weak_ptr想访问资源时,可以调用 lock返回一个管理资源的shared_ptr,如果资源已经被释放,返回的shared_ptr是一个空对象,如果资源没有释放,则通过返回的shared_ptr访问资源是安全的。

weak_ptr不支持RAII,也不支持访问资源,所以weak_ptr构造时不支持绑定到资源,只支持绑定到shared_ptr,绑定到shared_ptr时,不增加shared_ptr的引用计数。

相关推荐
ShineWinsu1 小时前
对于Git:基础操作的超详细保姆级解析
linux·c++·git·面试·备份·管理·版本控制器
小小de风呀2 小时前
de风——【从零开始学C++】(二十)红黑树封装map和set
c++·学习
青梅橘子皮2 小时前
Blue---枚举
c++
一木 之林3 小时前
第 8 节 C++ 设计模式在 AI 推理 SDK 和 Agent 工具运行时中如何落地
c++·人工智能·设计模式
神仙别闹3 小时前
基于 C++ 实现(控制台)考试报名系统
c++
weixin_307779134 小时前
C++代码实现MATLAB中的ode23t函数功能
开发语言·c++·算法·matlab
wuminyu4 小时前
Markword在紧凑对象头上的实现原理剖析
java·linux·c语言·jvm·c++
hansang_IR4 小时前
【代数与组合数学 | 那忘算 5】生成函数 & 例题 & 卷积
c++·算法·多项式·生成函数·母函数
无忧.芙桃4 小时前
数据结构之排序算法(上):从评价指标到插入、希尔、选择与堆排序
c语言·c++·排序算法