智能指针的使用及原理

文章目录

(一)为什么要有智能指针

对于为什么要有智能指针,以及在什么场景下使用智能指针、如何使用的问题,我们可以通过下面这段程序来进行分析,代码如下:

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

double Divide(int a, int b)
{
	if (b == 0)
	{
		throw "Divide zero by condition";
	}
	else
	{
		return (double)a / (double)b;
	}
}
void func()
{
	int* arr1 = new int[10];
	int* arr2 = new int[10];

	try
	{
		int len, time;
		cin >> len >> time;
		cout << Divide(len, time) << endl;
	}
	catch (...)
	{
		cout << "delete []arr1:" << arr1 << endl;
		delete[]arr1;
		cout << "delete []arr2:" << arr2 << endl;
		delete[]arr2;
		throw; //异常重新抛出,捕到什么抛什么
	}

	cout << "delete []arr1:" << arr1 << endl;
	delete[]arr1;
	cout << "delete []arr2:" << arr2 << endl;
	delete[]arr2;
}
int main()
{
	try
	{
		func();
	}
	catch (const char* errmasg)
	{
		cout << errmasg << endl;
	}
	catch (const exception& e)
	{
		cout << e.what() << endl;
	}
	catch (...)
	{
		cout << "未知异常" << endl;
	}
	return 0;
}

这段代码的功能就是一个除法运算,但是在调用func函数时,动态开辟了两段空间arr1和arr2,当除数不为0时,就正常执行代码,并正常释放arr1和arr2;当除数为0时,就抛出异常通过栈展开的方式,捕获异常,代码中先用catch(...)的方式对异常进行捕获,就是为了先释放arr1和arr2,然后再对异常进行抛出,最后在main函数中被捕获。

从结果来看,代码好像并没有什么问题,抛出的异常都能被捕获到,且动态开辟的空间,也能够被正常的释放,但是,在new arr2时,抛出异常了呢,这种情况就会导致arr1没有被正常的释放,就会导致内存泄漏的问题。因为new的底层会调用operator new,它又会去调用malloc,就有可能会抛异常,若是arr1抛异常没有什么问题,因为抛异常了说明没有动态开辟空间,也就不存在内存泄漏的问题,但是arr2抛异常就会去寻找catch,程序就会跳转,因为func函数是由main函数中的try调用的,当寻找catch时就会直接跳转到main函数中寻找,终止了func函数后面的程序,这就导致arr1没有能被正常的释放,虽说代码中func函数有一个catch,但是它捕获的是Divide抛出的异常。

对于这样的情况,就可以用智能指针来解决。

(二)RAII和智能指针的设计思路

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

一个简单的智能指针

如下图:

图片中,动态开辟了两个资源arr1和arr2,不过这两个资源被委托给了智能指针的对象p1和p2进行管理,当该p1和p2的生命周期结束时,这两个资源也能够被自动释放。其本质就是获取到的资源不自己管理,而是交由其他"对象"来管理,当"对象"的生命周期结束,资源一定就会被释放,就一定不会存在内存泄漏的问题。所以使用RAII设计思路的智能指针来管理资源,就会方便得多。

再回过头来谈前面留下的问题,内存泄漏的现象,有了智能指针,就可以解决这样的问题了,代码如下:

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

template<class T>
class smartPtr
{
public:
	smartPtr(T* ptr)
		:_ptr(ptr)
	{ }
	~smartPtr()
	{
		cout << "delete[] ptr:" << _ptr << endl;
		delete[]_ptr;
	}
private:
	T* _ptr;
};
double Divide(int a, int b)
{
	if (b == 0)
	{
		throw "Divide zero by condition";
	}
	else
	{
		return (double)a / (double)b;
	}
}
void func()
{
	smartPtr<int> sp1(new int [10] );
	smartPtr<int> sp2(new int [10] );

	int len, time;
	cin >> len >> time;
	cout << Divide(len, time) << endl;
}
int main()
{
	try
	{
		func();
	}
	catch (const char* errmasg)
	{
		cout << errmasg << endl;
	}
	catch (const exception& e)
	{
		cout << e.what() << endl;
	}
	catch (...)
	{
		cout << "未知异常" << endl;
	}
	return 0;
}

所以当有了智能指针后,就可以借助对象的析构函数,来对资源进行自动释放,也不用先捕获异常,释放资源,再对异常重新抛出了,就算sp2抛异常,sp1的资源也会随着生命周期的结束而被释放掉。

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

(三)C++标准库智能指针的使用

3.1 关于几种智能指针

C++标准库中的智能指针都在memory这个文件里面,包含以下就可以使用。智能指针有好几种,除了weak_ptr其他都符合RAII和像指针一样的访问行为,它们最大的区别就是智能指针在拷贝时的思路不同

  • 第一种智能指针,auto_ptr是C++98时设计出来的,该智能指针在拷贝时的设计思路是,将对资源的管理权转移给拷贝对象,虽说实现了智能指针的拷贝,但是这种思路会导致智能指针悬空,当再次访问被拷贝的对象时,就会出现访问报错的问题
cpp 复制代码
#include <memory>
int main()
{
	auto_ptr<int> ap1(new int[10]);
	auto_ptr<int> ap2(ap1);
	return 0;
}
  • 第二种智能指针,unique_ptr是C++11设计出来的智能指针,该指针拷贝的设计思路是,只支持资源移动,不支持资源拷贝,也就是当该资源为临时资源时,就对该资源进行移动,将资源给拷贝对象,因为该资源为临时资源,所以不会存在"悬空"的问题。若不需要拷贝的场景就非常建议使用该智能指针

    从图片中可以看出,unique_ptr不支持拷贝,它的拷贝构造已经被delete禁掉了,所以只支持移动构造,也就是移动拷贝

  • 第三种智能指针,shared_ptr也是C++11设计出来的,该指针支持拷贝构造和移动(相当于包含了unique_ptr的功能),底层实现的思路就是引用了计数器,也就是对于一个资源来说,若该资源被多个对象管理,就用一个计数器来记录有多少个管理对象,当管理对象的生命周期结束时,计数器就减1,直到计数器为0,就代表最后一个管理对象的生命周期也结束了,才对资源进行释放。

cpp 复制代码
#include <memory>
#include <iostream>
using namespace std;
int main()
{
	shared_ptr<int> sp1(new int(10));
	shared_ptr<int> sp2(sp1);
	shared_ptr<int> sp3(sp2);
	
	//打印计数器的个数
	cout << sp1.use_count() << endl;

	*sp2 = 5;
	cout << *sp1 << endl;
	cout << *sp2 << endl;
	cout << *sp3 << endl;
	return 0;
}

use_count接口就是获取管理对象的个数,由于sp1、sp2和sp3管理的是同一份资源,所以改变sp2的资源就相当于改变了sp1和sp3的资源

3.2 智能指针的删除器

为更好的让大家理解,我将用一个简单的日期类的样例来讲解如何使用删除器

cpp 复制代码
//一个简单的日期类
class Date
{
public:
	Date(int year = 1,int month = 1,int day = 1)
		:_year(year)
		,_month(month)
		,_day(day)
	{ }
	~Date()
	{
		cout << "~Date()" << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

智能指针析构时默认是进行delete释放资源,这也就意味着如果不是new出来的资源,交给智能指针管理,析构时就会崩溃。

为解决这类问题,智能指针支持在构造时给一个删除器,所谓删除器本质就是一个可调用对象,在这个可调用对象中实现你想要释放资源的方式,当构造智能指针时,给了定制的删除器,在智能指针析构时就会调用删除器去释放资源。

所以我们可以自行实现删除器来解决这类问题,但是unique_ptr和shared_ptr这两个智能指针使用删除器的方式又有所不同,区别如下:

  1. 仿函数作删除器

当仿函数作删除器时,unique_ptr的删除器是在类模板参数支持的,只需要传仿函数的类型饭后调用即可,而shared_ptr的删除器是在构造函数参数支持的,需要传一个仿函数对象

cpp 复制代码
#include <iostream>
#include <memory>
//仿函数
template<class T>
struct DelArray
{
	void operator()(T* ptr)
	{
		delete[]ptr;
	}
};
int main()
{
	//unique_ptr
	unique_ptr<Date,DelArray<Date>> up1(new Date[10]);
	
	//shared_ptr
	shared_ptr<Date> sp1(new Date[10],DelArray<Date>());
	return 0;
}

所以对于unique_ptr来说,仿函数可以不在构造函数时传递,因为仿函数类型构造的对象可以直接调用仿函数;对于shared_ptr来说,它的构造函数是一个模板,直接传一个对象过去,然后编译器会自动推导出对象的类型

  1. 函数指针作删除器
cpp 复制代码
template<class T>
void DelArrayFunc(T* ptr)
{
	delete[]ptr;
}
int main()
{

	unique_ptr<Date,void(*)(Date*)> up1(new Date[5],DelArrayFunc<Date>);

	shared_ptr<Date> sp1(new Date[5],DelArrayFunc<Date>);
	return 0;
}

对于unique_ptr而言,只传函数指针类型是不够的,若只有函数指针类型,定义出来的指针就是空指针,该指针指向谁是不明确的,所以在构造时还要传实例化的函数指针;但对于shared_ptr而言,只需要传实例化的函数指针即可。

  1. lambda作删除器
cpp 复制代码
int main()
{
	//lambda表达式
	auto Delobj = [](Date* ptr) {delete[]ptr; };

	unique_ptr<Date,decltype(Delobj)> up1(new Date[5], Delobj);

	shared_ptr<Date> sp1(new Date[5], Delobj);
	return 0;
}

shared_ptr还是直接传对象过去即可;unique_ptr还需要传lambda的类型且在构造时还需要传对象,但lambda的类型是不明确的,只有编译器知道,所以可以使用decltype函数推导一个对象的类型。

4.实现其他资源管理的删除器

cpp 复制代码
class Fclose
{
public:
	void operator()(FILE* ptr)
	{
		fclose(ptr);
		ptr = nullptr;
	}
};
int main()
{
	unique_ptr<FILE, Fclose> up1(fopen("test,cpp", "r"));
	shared_ptr<FILE> sp1(fopen("test.cpp", "r"),Fclose());

	auto Delfile = [](FILE* ptr) {fclose(ptr); ptr = nullptr; };
	unique_ptr<FILE,decltype(Delfile)> up2(fopen("test.cpp", "r"), Delfile);
	shared_ptr<FILE> sp2(fopen("test.cpp", "r"), Delfile);
	return 0;
}

总的来说,shared_ptr的删除器更好用,只需要在构造时传对象即可,而unique_ptr的删除器不仅需要在类模板参数中传类型,还要在构造时传对象,除了仿函数之外。

由于new数组的会经常被使用到,unique_ptr和shared_ptr特化了一个数组删除器,用法如下:

cpp 复制代码
int main()
{
	unique_ptr<Date[]> up1(new Date[5]);
	shared_ptr<Date[]> sp1(new Date[5]);
	return 0;
}

3.3 make_shared

shared_ptr除了支持用指向资源的指针进行构造,还支持make_shared用初始化资源的值直接构造,该make_shared类似以前说过的make_pair

make_shared的声明如下:

cpp 复制代码
template<class T class ...Args>
make_shared<T>(Args ...args);

用法如下:

cpp 复制代码
#include<iostream>
#include<memory>
using namespace std;
int main()
{
	shared_ptr<Date> sp1(new Date(2025, 5, 26));
	shared_ptr<Date> sp2 = make_shared<Date>(2024, 12, 19);
	auto sp3 = make_shared<Date>(2025, 11, 12);
	return 0;
}

3.4 operator bool

智能指针都实现了operator bool,其主要原因就是将自定义类型转换成内置类型,方便这些对象能像指针一样进行条件逻辑判断

3.5 被explicit修饰的智能指针

shared_ptr和unique_ptr的构造函数都使用了explicit修饰,防止普通指针隐式类型转换成智能指针对象

cpp 复制代码
#include<iostream>
#include<memory>
int main()
{
	shared_ptr<Date> sp1 = new Date(2025, 5, 26);
	return 0;
}

代码中上面的写法是不支持的,这种写法的意思是,返回一个Date*,构造一个Date对象,再去拷贝构造,编译器会直接优化成直接构造,但这种构造是不支持的,因为不允许在有些场景下,这种指针被转换成智能指针,避免发生一些问题

(四)智能指针的原理

下面我将通过模拟实现智能指针的方式来让大家了解智能指针的原理。C++中的这些智能指针最大的区别就是拷贝的问题,auto_ptr的拷贝就是资源转移,然后将被拷贝对象置空;unique_ptr的拷贝只支持移动构造和移动赋值,与auto_ptr类似,但被拷贝对象指向的资源是将亡资源,不会存在"悬空"问题;shared_ptr支持拷贝和移动,采用引用计数的设计来实现。通过模拟实现shared_ptr可以更加深入理解智能指针的原理,下面我将模拟实现shared_ptr(无删除器版)。

模拟实现shared_ptr最关键的就是它采用引用计数来实现拷贝构造和移动,首先我们得知道,引用计数是记录有多少个智能指针对象在管理同一份资源,所以一份资源就需要对应一个引用计数,不是每个智能指针对象都有一个引用计数,所以引用计数的实现应该使用堆上动态开辟的方式,让多个管理对象指向同一份资源的同时,也让它们指向同一个引用计数,这样才能达到指向同一个资源的多个管理对象在析构时,只将资源析构一次。

4.1 模拟实现shared_ptr(无删除器版)

cpp 复制代码
namespace li
{
	template<class T>
	class shared_ptr
	{
	public:
		explicit shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			, _pcount(new int(1)) //若指向构造函数,说明出现了一份新的资源,引用计数置为1
		{ }
		~shared_ptr()
		{
			//当引用计数为0时,说明该资源已经没有被管理了,所以可析构
			if (--(*_pcount) == 0)
			{
				delete _ptr;
				delete _pcount;
				_ptr  = nullptr;
				_pcount = nullptr;
			}
		}

		shared_ptr(const shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			, _pcount(sp._pcount)
		{
			//拷贝之后说明,又多了一个管理对象,所以引用计数++
			++(*_pcount);
		}
	private:
		T* _ptr;
		int* _pcount; //动态开辟引用计数
	};
}

上面的代码实现的就是一个简单的智能指针

再对shared"_ptr的代码做进一步的完善,首先先完善赋值重载,代码如下:

cpp 复制代码
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
	if (_ptr != sp._ptr)
	{
		//在将sp的资源赋值给_ptr之前需要先对_ptr指向的资源以及引用计数先进行处理
		//以免出现内存泄漏的问题
		if (--(*_pcount) == 0)
		{
			delete _ptr;
			delete _pcount;
			_ptr = nullptr;
			_pcount = nullptr;
		}
		//再进行赋值
		_ptr = sp._ptr;
		_pcount = sp._pcount;
		++(*_pcount);
	}
	return *this;
}

再实现补充一些其他的接口,代码如下:

cpp 复制代码
T& operator*()
{
	return *_ptr;
}
T* operator->()
{
	return _ptr;
}
T& operator[](size_t i)
{
	return _ptr[i];
}
//获取引用计数
int use_count()const
{
	return *_pcount;
}
//获取原生指针
T* get()const
{
	return _ptr;
}
//使智能指针像"指针"一样进行逻辑判断
operator bool()
{
	return _ptr != nullptr;
}

模拟实现shared_ptr(有删除器版)

cpp 复制代码
namespace li
{
	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			,_pcount(new int(1))
		{ }

		//加入定制删除器,即构造函数得写成模板
		template<class D>
		shared_ptr(T* ptr,D del)
			:_ptr(ptr)
			,_pcount(new int(1))
			,_del(del)
		{ }

		~shared_ptr()
		{
			if (--(*_pcount) == 0)
			{
				_del(_ptr);
				delete _pcount;
				_ptr = nullptr;
				_pcount = nullptr;
			}
		}
		shared_ptr(const shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			,_pcount(sp._pcount)
			,_del(sp._del)
		{ 
			++(*_pcount);
		}
		shared_ptr<T>& operator=(const shared_ptr<T>& sp)
		{
			if (_ptr != sp._ptr)
			{
				if (--(*_pcount) == 0)
				{
					_del(_ptr);
					delete _pcount;
				}
				_ptr = sp._ptr;
				_pcount = sp._pcount;
				_del = sp._del;
				++(*_pcount);
			}
			return *this;
		}
		
	private:
		T* _ptr;
		int* _pcount;

		//需要用到function来包装各种类型的删除器
		function<void(T*)> _del = [](T* ptr) {delete ptr; };
	};
}

该有删除器版本只实现了与无删除器版本有区别的部分

(五)shared_ptr与weak_ptr

5.1 循环引用

shared_ptr大多数情况下管理资源非常合适,支持RAII,也支持拷贝,但在循环引用的场景下会导致资源泄漏的问题,这时候就可以使用weak_ptr来解决。

通过下面的场景来解释循环引用的现象:

cpp 复制代码
#include<iostream>
#include<memory>

struct ListNode
{
	int data;
	
	//指向前后结点的指针又智能指针来充当
	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);
	
	return 0;
}

首先我们来看,将new出来的两个链表结点交给智能指针管理是没有问题的,n1和n2的生命周期结束时,资源也会被释放,但是如果是下面这种情况呢

cpp 复制代码
struct ListNode
{
	int data;

	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);

	n1->_next = n2;
	n2->_prev = n1;
	return 0;
}

当让两个结点同时指向对方,就出现了内存泄漏的问题,这是为什么呢,这是由于shared_ptr的循环引用的问题,导致了该种现象,通过下面的图画来进行进一步的分析

通过图片的分析过程来看,n1和n2析构后,引用计数从2变成了1,没有减到0,导致两个结点没有被释放掉,左结点若想要释放就得右结点先释放,因为右结点的_prev管着左结点,右结点释放了,左结点的引用计数才会减到0,资源才会被释放,那右结点什么时候才释放呢,只有左结点释放了又结点才会释放,因为左结点的_next管着右结点,左结点释放了,右结点的引用计数才会减到0,资源才会释放,那左结点什么时候释放呢,如此反复下来,就会导致一个循环的现象,这就是shared_ptr存在的缺陷,循环引用。

为解决这样的场景,就要用到weak_ptr,将_next和_prev交给weak_ptr

cpp 复制代码
struct ListNode
{
	int data;

	std::weak_ptr<ListNode> _next;
	std::weak_ptr<ListNode> _prev;

	~ListNode()
	{
		cout << "~ListNode()" << endl;
	}
};


当weak_ptr绑定到shared_ptr时,不会增加shared_ptr的引用计数,_next和_prev不会参与资源释放管理逻辑,打破了循环引用。

可以看到n1和n2的引用计数还是1

5.2 weak_ptr

weak_ptr不支持RAII,在构造时也不支持绑定资源,只支持绑定shared_ptr,绑定到shared_ptr时,不会增加shared_ptr的引用计数,也不走释放资源的逻辑。

weak_ptr支持expired检查指向的资源是否过期,use_count也可以获得shared_ptr的引用计数,若weak_ptr想访问资源时,可以调用lock,此时会返回一个管理资源的shared_ptr,若资源已被释放,则返回一个空对象,反之则可以通过返回的shared_ptr的对象访问资源。

使用方式如下:

通俗来说,weak_ptr相当于具有备份shared_ptr的功能,但不对shared_ptr里面的数据做任何改动。

相关推荐
UpUpUp……2 小时前
C++复习
开发语言·c++·笔记
BC的小新2 小时前
C++ Stack&Queue
c++
charlie1145141912 小时前
从C++编程入手设计模式1——单例模式
c++·单例模式·设计模式·架构·线程安全
菠萝013 小时前
分布式CAP理论
数据库·c++·分布式·后端
1白天的黑夜16 小时前
动态规划-152.乘积最大子数组-力扣(LeetCode)
c++·算法·leetcode·动态规划
?!7146 小时前
网络编程之网络编程预备知识
linux·网络·c++
理论最高的吻7 小时前
1614. 括号的最大嵌套深度【 力扣(LeetCode) 】
c++·算法·leetcode·职场和发展·字符串··字符匹配
Daking-8 小时前
「动态规划::状压DP」网格图递推 / AcWing 292|327(C++)
c++·算法·动态规划
hjjdebug8 小时前
c/c++怎样编写可变参数函数.
c++·args...·可变参数函数
John_ToDebug9 小时前
Chrome 开发中的任务调度与线程模型实战指南
c++·chrome·性能优化