C++ 智能指针

目录

[1 auto_ptr](#1 auto_ptr)

[2 unique_ptr](#2 unique_ptr)

[3 shared_ptr](#3 shared_ptr)

[4 循环引用与weak_ptr](#4 循环引用与weak_ptr)

[5 删除器](#5 删除器)


1 auto_ptr

在异常章节我们提到了,在可能抛出异常的函数中申请的堆空间,如果我们不注意,就有可能因为抛出异常执行流跳转,从而导致内存泄露。当时的解决方案是在当前栈帧捕获异常之后再重新抛出,但是这种方法其实还有一个问题。

如下:如果我们只申请一块空间还好说,只需要catch(...) 然后释放就行了

int division(int x,int y)
{
	if (y == 0)
		throw "除0错误";
	
	return x / y;
}

void func(int x , int y)
{
	int* pa = new int;

	try
	{
		int ret = division(x,y);
	}
	catch (...)
	{
		delete pa;
		throw;
	}
}

但是还有一种情况,如果我们连续使用new申请两块空间呢?

	int* pa = new int;
	int* pb = new int;

new申请堆空间是可能失败而抛异常的。如果是第一次申请堆空间就失败了,那么直接抛异常就跳转到了main函数中的捕获逻辑中。 但是如果第一次申请空间成功了,但是第二次申请失败了呢?那么第二次的 new 失败抛出的异常我们是需要捕获,然后需要释放掉第一个申请的空间再重新抛出的,也就需要将两个 try catch 块嵌套。

void func(int x , int y)
{
	int* pa = new int;
	try
	{
		int* pb = new int;
		try
		{
			int ret = division(x, y);
		}
		catch (...)
		{
			//delete pa;   //嵌套之后pa就不在这里释放了,否则会导致二次释放的问题
			delete pb;
			throw;
		}
	}
	catch (...)
	{
		delete pa;  //pa同一在这里释放
	}
}

而这样嵌套起来我们就需要很谨慎,要避免出现二次释放同一块空间的问题。

同时,当我们的try catch 嵌套多起来之后,我们的释放的时机就更不好把握了,那么有什么办法能解决这里的问题呢?

智能指针就是用来解决这里的问题的。

如果我们把申请到的资源的指针交给一个类来保存:

template<class T>
class Smart_Ptr
{
public:
	Smart_Ptr(T* ptr)
		:_ptr(ptr)
	{}

	~Smart_Ptr()
	{
		delete _ptr;
	}

private:
	T* _ptr = nullptr;
};

void func(int x , int y)
{
	int* pa = new int;
	int* pb = new int;
	//将申请到的资源交给一个类来保存
	Smart_Ptr<int> a(pa);
	Smart_Ptr<int> b(pb);

}

如上,我们将资源的指针交给一个局部的智能指针对象去进行管理,那么当函数栈帧正常销毁或者由于异常而销毁的时候,我们的局部对象就会调用析构函数,那么就会将资源给释放掉,也就不会出现内存泄露的问题了。

这种特性是智能指针的第一个特性,我们称之为RAII

RAII((Resource Acquisition ls Initialization) ,资源获得即初始化 ,什么意思呢?当我们获得一个资源的时候,我们就初始化一个对象来帮我们管理这个资源,当对象的生命周期结束时,析构函数会自动释放这份资源。 那么就将资源的生命周期跟对象的生命周期绑定了,这是一种利用对象生命周期来控制程序资源的简单技术。

这只是智能指针的第一个特性,既然智能指针叫做指针,那么他就必须具有 像指针一样 的特性。

那么怎么实现像指针一样呢?很简单,重载两个运算符就行了。

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& operator[](size_t pos)
	{
		return _ptr[pos];
	}
	// ..... ..... const 版本也是需要提供的,这里就直接偷个懒了。

private:
	T* _ptr = nullptr;
};

同时,由于我们的智能指针也能够像指针一样对资源进行操作,那么我们中间产生的临时的变量 pa和pb 就不需要了,同时也要防止我们进行误操作,那么我们就可以直接这样写:

	// 中间产生的pa和pb对我们而言就不需要了,直接使用这个对象就行了。
	Smart_Ptr<int> a(new int);
	Smart_Ptr<int> b(new int);

	*a = 10;
	*b = 20;

我们使用这样的对象来进行管理有两个优势:

1 不需要我们显式释放

2 对象的资源在他的生命周期内始终有效

但是我们上面设计的类还有一个问题没有考虑,就是 拷贝与赋值 该怎么处理?

如果我们不实现,那么编译器自动生成的就会发生浅拷贝,那么就会导致资源的二次释放。可是我们又无法实现深拷贝,因为我们不知道_ptr指向的空间到底有多大,他有可能是一个对象,也有可能是一个对象的数组。

同时,如果存在拷贝,那么我们也一定是要像指针一样,指针的拷贝和赋值都是指向同一块空间,而如果多个对象管理同一块资源的话,我们就不知道到底什么时候该释放这份资源。

遇到这里的问题,我们可以参考库里面的实现。

智能指针早在C++98的标准库中就有了,第一版的智能指针叫做 auto_ptr

auto_ptr的解决方案十分的荒唐,他是直接 转移资源的管理权

怎么说呢?就是进行拷贝构造或者拷贝赋值之后,被拷贝的对象就不再管理该资源,而是将管理权交给新的对象。

具体的代码就更简单了:

		auto_ptr(auto_ptr& sp) //拷贝构造
		{
			_ptr = sp._ptr; //转移管理权
			sp._ptr = nullptr;
		}

		auto_ptr& operator=(auto_ptr& sp)
		{
			//首先释放当前资源
			delete  _ptr;
			//还是转移资源管理权
			_ptr = sp._ptr;
			sp._ptr = nullptr;
		}

这种做法在当前看来是不可理解的,因为指针的拷贝和赋值都是要指向同一块空间的,而不是说赋值完自己就没了。 但是可能当时有些仓促或者想不到更好的办法了?

总之不管怎么样,这种做法的缺陷太大了,原对象拷贝或者赋值完就被悬空了。

所以很多地方明确规定不能够使用auto_ptr

2 unique_ptr

unique_ptr是C++1参考 boost 库里面的 scoped_ptr 设计的,从名字也能看出来,唯一指针,他的解决方案就十分暴力了,直接把拷贝构造和拷贝赋值delete了,禁止拷贝。

		unique_ptr(unique_ptr& sp) = delete;

		unique_ptr& operator=(unique_ptr&) = delete;

虽然确实禁止了拷贝,但是太过简单粗暴,不让拷贝有点令人无法接受。

3 shared_ptr

C++11还提供了 shared_ptr ,这是参考boost库中的 shared_ptr来设计的。

这个智能指针就允许拷贝,同时还允许多个对象管理一块空间,共享指针,也不会出现悬空的问题。

但是因为可以多个对象共享一个资源,那么析构又成了一个新的问题。因为对于每一份被管理的资源,都有可能被一个或者以上的对象所指向,那么其中一个对象析构的时候,不一定就要释放资源,除非他是当前唯一指向这块空间的对象,这时候才需要释放,而其他的情况下,对象的析构只是会引起指向该资源的对象的数量减减。

那么如何解决呢?我们需要为每一块管理的空间创建一个引用计数,用来表示该空间当前被多少对象所管理。当引用计数大于0时,该资源不能被释放,当引用计数等于 0 的时候,才需要释放该资源。

那么引用计数又要如何设计呢?

我们能想到的最简单的方法就是 使用一个静态的 map ,被该类所有对象都能访问到,map中存的就是对应的资源的地址 以及 引用计数 。那么每一次一个对象析构的时候,就是找到对应的指针让其引用计数减减,如果减到0了,就释放该空间。如果时拷贝赋值或者拷贝构造的话,那么该空间的引用计数需要加加。

这种方法能使肯定能解决引用计数的问题的,但是 map 的查找毕竟也是需要时间的,标准库采用的一个更简单的方法就是为每一个空间都额外开辟一个 整型 用来存储该空间的引用计数,用一个指针来指向该引用计数

这样一来,管理同一块资源的智能指针对象都能找到同一个引用计数,当调用析构函数的时候,首先需要减减该计数,计数为 0 ,就是放 _ptr 和 _cnt 。

那么shared_ptr的简单设计如下:

	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr)
			:_ptr(ptr)
			,_cnt(new int(1))
		{}

		~shared_ptr()
		{
			*_cnt--;
			if (*_cnt == 0)
			{
				delete _ptr;
				delete _cnt;
			}
		}
		shared_ptr(const shared_ptr& sp) //拷贝构造
		{
			_ptr = sp._ptr;
			_cnt = sp._cnt;
			*_cnt++;
		}

	private:
		T* _ptr = nullptr;
		int* _cnt;
	};

那么他还有一个重点就是拷贝赋值,拷贝赋值我们要考虑两个方面

1 是否是同一块空间的对象之间的赋值,如果是,则什么都不用做

2 原空间是否需要释放

那么代码如下:

		shared_ptr& operator=(const shared_ptr&sp)
		{
			//首先判断是否是指向同一块空间的对象
			if (_ptr == sp._ptr)
				return *this;

			//取消对于原空间的管理权
			*_cnt--;
			if (*_cnt == 0)
			{
				delete _ptr;
				delete _cnt;
			}
			_ptr = sp._ptr;
			_cnt = sp._cnt;
			*_cnt++;

			return *this;
		}

在这里,还有一个问题就是我们的引用计数的安全问题,我么需要保证引用计数必须是线程安全的,多线程必须互斥串行访问同一个引用计数,那么其实我们每一个对象中还需要一把锁来保证引用计数的安全。

所有对引用的操作都要保证其互斥。

同时,我们也只能保证引用计数的线程安全,对象所指向的资源在智能指针内是无法保证线程安全的,因为对数据的操作使用户通过 我们的运算符重载的返回值来进行操作,而不是调用成员函数进行操作。

我们一般认为 shared_ptr 是线程安全的,指的是他的引用计数的加加和减减是线程安全的。但是他所管理的资源不是线程安全的,这需要由用户自行保护。

4 循环引用与weak_ptr

shared_ptr 有一个死穴,就是循环引用。

什么是循环引用呢?其实就是shared_ptr对象之间互相管理对方的资源,导致双方都无法完成资源的释放,最终导致内存泄漏。

比如我们以前玩的双向循环链表,有了智能指针之后,我们就不再使用指针来自己释放节点资源了,而是使用智能指针,那么就会出现如下的状况:

	template<class T>
	class  ListNode
	{
	public:
		ListNode(T val =T())
			:_val(val),_next(nullptr),_prev(nullptr)
		{}
		T _val;
		ListNode* _prev;
		ListNode* _next;
	};


	void test()
	{
		shared_ptr<ListNode<int>> n1(new ListNode<int>(10));
		shared_ptr<ListNode<int>> n2(new ListNode<int>(20));
		
		n1->_next = n2;
		n2->_prev = n1;
	}

如果我们这样来定义 链表节点的话,那么我们会发现无法链接,因为 _next 和 _prev都是ListNode*类型的指针,而不是智能指针对象,那么我们就需要将 _next和_prev的类型换成智能指针的对象。

	template<class T>
	class  ListNode
	{
	public:
		ListNode(T val =T())
			:_val(val)
		{}

		T _val;
		shared_ptr<ListNode<T>> _prev;
		shared_ptr<ListNode<T>> _next;
	};

那么这样就可以了进行节点之间的链接了。

于是,我们就跳到了shared_ptr的大坑里,就拿两个节点来举例

n1 所指向的节点中的 next 对象指向 n2 的节点,而 n2 的节点的 prev 同样指向 n1 的节点 。那么最终在析构的时候,n1 和 n2 是能够正常析构的,但是由于 n1 和 n2 指向的 引用计数cnt 减减之后,不为0 ,而是1,所以 n1 和n2析构的时候并不会释放两个节点。

那么 这两个节点资源什么时候才会释放呢?当他们的引用计数减为0的时候,那么 n1 的引用计数什么时候会减为 0 呢? 只有在 n2 的 prev 对象销毁之后,也就是 n2 销毁之后,n1 才能销毁。那么 n2 什么时候销毁呢? 只有在 n1 的next 对象销毁之后,也就是 n1 销毁之后,n2才能销毁。那么他们两个就尬住了,谁也销毁不了,最终就造成内存泄漏了。 因为这两个节点互相引用,互相管理。

那么我们就不能在 节点中使用shared_ptr 来指向下一个节点和前一个节点,那要怎么链接呢?

使用 weak_ptr ,weak_ptr 即使专门用来解决 shared_ptr 的循环引用问题的。怎么解决呢? 很简单,就是只有指针的特性,而不需要RAII,也就是不参与资源的管理以及引用计数的加加和减减。

因为有指针的特性,所以可以通过它来访问前后节点,同时由于他不会参与前后节点的管理,所以不会导致互相管理而无法释放。

weak_ptr 设计起来就没shared_ptr 这么复杂了,只不过他要实现一个 shared_ptr 的构造函数,无非就是拷贝一下指针。

但是这样一来,他不参与资源的释放,所以他也无法得知资源是否已经被释放了,那么在去使用的话可能就会是野指针的范围。所以其实库里面的 weak_ptr 是会保存引用计数的,但是只有读的权限,用来判断资源是否有效。

5 删除器

我们上面所设计的智能指针针对new一个对象的,那么只需要 delete 就能是放了,但是有的时候我们是new了一批对象,比如 new int[10] ,这时候使用delete 就无法完成资源的释放了,那么如何解决删除的问题呢?定制删除器。

也就是智能指针提供的默认的删除器就是使用 delete 来释放资源,如果delete无法满足我们的资源的释放,那么就需要自己写一个可调用的对象来进行资源的释放。

不过库里面的删除器是要在构造函数的时候传的,他是怎么做到的呢?其实库里面的引用计数并不是一个简单的 int 的指针,而是一个类对象,因为库里面要考虑的东西比我们设计的更多更复杂,所以他的引用计数使用一个单独的类来实现的。

但是由于我们没必要像库里面一样去完整实现,只需要知道它的原理就行了,我们可以简单设置为模板的时候传参,传一个非类型的可调用对象就行了,然后这个删除器对象放在成员变量中保存。

	template<class T, function<void(void*)>Del=defaultDel()>

那么最终释放资源的时候就不能简单的使用delete来释放,而是需要调用删除器来释放。

那么有了定制删除器,我们甚至可以用智能指针来管理文件结构体或者一些其他特殊的对象。

	const char* filename = "log.txt";
	shared_ptr<FILE>pf(fopen(filename, "w+"), [](FILE* pf) {fclose(pf); });

当然,并不是只有我们模拟实现使用过模板来传这个删除器,实际上unique 也面临这个问题,因为unique是唯一指针,自然没有引用计数,他也需要通过模板来传删除器。

相关推荐
Antonio91516 分钟前
【CMake】使用CMake在Visual Studio内构建多文件夹工程
开发语言·c++·visual studio
LyaJpunov29 分钟前
C++中move和forword的区别
开发语言·c++
程序猿练习生34 分钟前
C++速通LeetCode中等第9题-合并区间
开发语言·c++·leetcode
z千鑫42 分钟前
【人工智能】如何利用AI轻松将java,c++等代码转换为Python语言?程序员必读
java·c++·人工智能·gpt·agent·ai编程·ai工具
一名路过的小码农44 分钟前
C/C++动态库函数导出 windows
c语言·开发语言·c++
Ddddddd_1581 小时前
C++ | Leetcode C++题解之第416题分割等和子集
c++·leetcode·题解
编程版小新2 小时前
C++初阶:STL详解(四)——vector迭代器失效问题
开发语言·c++·迭代器·vector·迭代器失效
AlexMercer10123 小时前
【C++】二、数据类型 (同C)
c语言·开发语言·数据结构·c++·笔记·算法
小灰灰爱代码4 小时前
C++——求3个数中最大的数(分别考虑整数、双精度数、长整数的情况),用函数模板来实现。
开发语言·c++·算法
BeyondESH5 小时前
Linux线程同步—竞态条件和互斥锁(C语言)
linux·服务器·c++