[c++进阶(二)] 智能指针详细剖析--RAII思想

发布于:2024-12-22 ⋅ 阅读:(14) ⋅ 点赞:(0)

1.前言

相信学c++的同学或多或少都听说过智能指针这个词语,博主刚开始听说这个时,感觉肯定很难,但是学完之后仔细总结一番,发现没有特别难。

本章重点:

着重讲解智能指针的使用和原理以及RAII思想,以及智能指针的发展历史,及发展过程中的一些产物的模拟实现。

2.智能指针的使用场景

2.1 什么是智能指针

首先在学习智能指针的使用和原理之前我们要明白什么是智能指针。

智能指针不是指针,是一个管理指针的类用来存储指向动态分配对象的指针,负责自动释放动态分配的对象,防止堆内存泄漏
  动态分配的资源,交给一个类对象去管理,当类对象生命周期结束时,自动调用析构函数释放资源。

那了解了什么是智能指针之后,为什么要有智能指针呢?

目的是为了防止资源被泄露。

例如:

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

 在上述代码中,只要div进行抛异常的话,那么p1和p2就没有被释放,这样就导致了内存泄漏的问题。而智能指针可以完美的解决这个问题。

2.2  智能指针的使用

1.内存泄漏的问题

内存泄漏是指因为疏忽或错误,造成程序未能释放已经不再使用的内存的情况。

int div()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除0错误");
	return a / b;
}
void func()
{
	int* ptr = new int;
	//...
	cout << div() << endl;
	//...
	delete ptr;
}
int main()
{
	try
	{
		func();
	}
	catch (exception& e)
	{
		cout << e.what() << endl;
	}
	return 0;
}

 执行上述代码时,如果用户输入的除数为0,那么div函数中就会抛出异常,这时程序的执行流会直接跳转到主函数中的catch块中执行,最终导致func函数中申请的内存资源没有得到释放。

对于上述问题可以有两种解决方案,一种是在func里面释放资源,然后重新抛异常。

int div()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除0错误");
	return a / b;
}
void func()
{
	int* ptr = new int;
	try
	{
		cout << div() << endl;
	}
	catch (...)
	{
		delete ptr;
		throw;
	}
	delete ptr;
}
int main()
{
	try
	{
		func();
	}
	catch (exception& e)
	{
		cout << e.what() << endl;
	}
	return 0;
}

但是这种解决方案对于函数少的还能适用,一旦包含的函数过多,每一个函数都写一份相同的代码的话,浪费空间。

用智能指针解决 


int div()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除0错误");
	return a / b;
}
void func()
{
	shared_ptr<int> sp(new int);
	//...
	cout << div() << endl;
	//...
}
int main()
{
	try
	{
		func();
	}
	catch (exception& e)
	{
		cout << e.what() << endl;
	}
	return 0;
}

 代码中将申请到的内存空间交给了一个shared_Ptr对象进行管理。

在构造shared_Ptr对象时,shared_Ptr将传入的需要被管理的内存空间保存起来。
在shared_Ptr对象析构时,shared_Ptr的析构函数中会自动将管理的内存空间进行释放。
  这样一来,无论程序是正常执行完毕返回了,还是因为某些原因中途返回了,或是因为抛异常返回了,只要SmartPtr对象的生命周期结束就会调用其对应的析构函数,进而完成内存资源的释放。

这样的处理方法才是比较好且比较简洁的。

3.RAII思想和以及智能指针的设计

1.RAII思想

RAII思想是一种 利用对象生命周期来控制程序资源 (如内存、文件句柄、网络连接、互斥量等等)的简单技术。在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象

这种做法有两种好处:

不需要显式地释放资源
对象所需的资源在其生命期内始终有效

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 main()
{
	SmartPtr<int> sp1(new int);
	SmartPtr<int> sp2(sp1); //拷贝构造

	SmartPtr<int> sp3(new int);
	SmartPtr<int> sp4(new int);
	sp3 = sp4; //拷贝赋值
	
	return 0;
}

由于没有自己显示的写构造函数,因此他会生成一个构造函数,但是当出现了拷贝构造时,他是一个浅拷贝,这样在析构时,一个空间就被析构了两次,这是不合法的。

但需要注意的是,智能指针就是要模拟原生指针的行为,当我们将一个指针赋值给另一个指针时,目的就是让这两个指针指向同一块内存空间,所以这里本就应该进行浅拷贝,但单纯的浅拷贝又会导致空间被多次释放,因此根据解决智能指针拷贝问题方式的不同,从而衍生出了不同版本的智能指针。

4.智能指针的发展历史及模拟实现

4.1 auto_ptr

auto_ptr是C++98中引入的智能指针,auto_ptr通过管理权转移的方式解决智能指针的拷贝问题,保证一个资源在任何时刻都只有一个对象在对其进行管理,这时同一个资源就不会被多次释放了。

int main()
{
	std::auto_ptr<int> ap1(new int(1));
	std::auto_ptr<int> ap2(ap1);
	*ap2 = 10;
	//*ap1 = 20; //error

	std::auto_ptr<int> ap3(new int(1));
	std::auto_ptr<int> ap4(new int(2));
	ap3 = ap4;
	return 0;
}

 简单来说,就是把自己的东西给别人,那么自己就没有这个东西了。

模拟实现如下:

namespace zl
{
	template<class T>
	class auto_ptr
	{
	public:
		auto_ptr(T* ptr)
			:_ptr(ptr)
		{}

		~auto_ptr()
		{
			if (_ptr)
			{
				cout << "delete:" << _ptr << endl;
				delete _ptr;
			}
		}

		//管理权转移
		auto_ptr(auto_ptr<T>& ap)
			:_ptr(ap._ptr)
		{
			ap._ptr = nullptr;
		}

		T& operator*()
		{
			return *_ptr;
		}

		T* operator->()
		{
			return _ptr;
		}

	private:
		T* _ptr;
	};


	void test_auto()
	{
		auto_ptr<int> ap1(new int(1));
		auto_ptr<int> ap2(ap1);

		*ap1 = 1;    //管理权转移以后导致ap1悬空,不能访问(存在被拷贝对象悬空的问题)
		*ap2 = 1;
	}
}

4.2 unique_ptr

unique_ptr是C++11中引入的智能指针,unique_ptr通过防拷贝的方式解决智能指针的拷贝问题,也就是简单粗暴的防止对智能指针对象进行拷贝,这样也能保证资源不会被多次释放。

int main()
{
	std::unique_ptr<int> up1(new int(0));
	//std::unique_ptr<int> up2(up1); //error
	return 0;
}

 这种方式解决了个寂寞,有时候我就是需要拷贝,你直接不允许我拷贝,属于鸡肋。

模拟实现:

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

	~unique_ptr()
	{
		if (_ptr)
		{
			cout << "delete:" << _ptr << endl;
			delete _ptr;
		}
	}

	T& operator*()
	{
		return *_ptr;
	}

	T* operator->()
	{
		return _ptr;
	}

	// C++11思路:语法直接支持
	unique_ptr(const unique_ptr<T>& up) = delete;
	unique_ptr<T>& operator=(const unique_ptr<T>& up) = delete;


	// 防拷贝
	// 拷贝构造和赋值是默认成员函数,我们不写会自动生成,所以我们不需写
	// C++98思路:只声明不实现,但是用的人可能会在外面强行定义,所以再加一条,声明为私有
private:
	//unique_ptr(const unique_ptr<T>& up);
	// unique_ptr<T>& operator=(const unique_ptr<T>& up);
private:
	T* _ptr;
};

4.3 shared_ptr

shared_ptr是C++11中引入的智能指针,shared_ptr通过引用计数的方式解决智能指针的拷贝问题。

shared_ptr在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享。
在对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减一。
如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源。
如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了。
  通过这种引用计数的方式就能支持多个对象一起管理某一个资源,也就是支持了智能指针的拷贝,并且只有当一个资源对应的引用计数减为0时才会释放资源,因此保证了同一个资源不会被释放多次。

模拟实现:

	template <class T>
	class Shard_ptr
	{
	public:
		Shard_ptr(T* ptr=nullptr)
			:_ptr(ptr)
			, _pcount(new int(1))
		{}
		template<class D>
		Shard_ptr(T* ptr,D del) 
			:_ptr(ptr)
			,_pcount(new int(1))
		{}
		~Shard_ptr()
		{
			Release();
		}

		void Addcount()
		{
			++(*(_pcount));
		}
		void Release()
		{
			if (--(*_pcount) == 0)
			{
				cout << "delete: " << _ptr << endl;
				delete _ptr;
				delete _pcount;
			}
		}
		
		Shard_ptr(Shard_ptr<T>& sp)//拷贝构造
			:_ptr(sp._ptr)
			, _pcount(sp._pcount)
		{
			Addcount();
		}
		Shard_ptr<T>& operator=(const Shard_ptr<T>& sp)
		{
			//先释放原来的--但是有可能原来有好几个对象指向同一个位置,如何处理呢?
			//新赋值一个,那么是否就相当于引用计数多了1呢?不是,而是原来的计数要--
			//新的计数要++或者原来的++,新的计数--
			if (_ptr != sp._ptr)
			{
				Release();
				_ptr = sp._ptr;
				_pcount = sp._pcount;
				Addcount();
			}
			return *this;
		}
		T& operator*()
		{
			return *_ptr;
		}
		T& operator->()
		{
			return _ptr;
		}
		T* get()
		{
			return _ptr;
		}
		int use_count()
		{
			return *_pcount;
		}
	private:
		T* _ptr;
		//期望有一个公共的计数
		//int _count;//不行,这会导致每个对象都有一个引用计数
		//static int _count;也不行,会导致所有的对象公用一个引用计数
		int* _pcount;//每个资源对应一个引用计数,不仅同时指向_ptr,也指向_pcount
	};

 解决方式如下图所示:sp2=sp1,sp3=sp2

 但是上述代码是存在着一定的问题的。--如线程安全问题,删除问题。

线程安全的问题:由于管理同一个资源的多个对象的引用计数是共享的,因此多个线程可能会同时对同一个引用计数进行自增或自减操作,而自增和自减操作都不是原子操作,因此需要通过加锁来对引用计数进行保护,否则就会导致线程安全问题。
  要解决引用计数的线程安全问题,本质就是要让对引用计数的自增和自减操作变成一个原子操作,因此可以对引用计数的操作进行加锁保护,也可以用原子类atomic对引用计数进行封装,这里以加锁为例。

	template <class T>
	class Shard_ptr
	{
	public:
		Shard_ptr(T* ptr=nullptr)
			:_ptr(ptr)
			, _pcount(new int(1))
			, _pmtx(new mutex)//拷贝构造
		{}
		template<class D>
		Shard_ptr(T* ptr) 
			:_ptr(ptr)
			,_pcount(new int(1))
			,_pmtx(new mutex)//拷贝构造
			,_del(del)
		{}
		~Shard_ptr()
		{
			Release();
		}

		void Addcount()
		{
			_pmtx->lock();
			++(*(_pcount));
			_pmtx->unlock();
		}
		void Release()
		{
			_pmtx->lock();
			bool deleteFlag = false;
			if (--(*_pcount) == 0)
			{
				cout << "delete: " << _ptr << endl;
				delete _ptr;
				delete _pcount;
				deleteFlag = true;
				//_pmtx->unlock();//先释放锁,然后再删除锁。可以解决这个问题
				//delete _pmtx;
			}
			_pmtx->unlock();//这里有个问题,你都把锁删除了,那怎么还能使用呢?
			if (deleteFlag) delete _pmtx;
		}
		
		Shard_ptr(Shard_ptr<T>& sp)//拷贝构造
			:_ptr(sp._ptr)
			, _pcount(sp._pcount)
			,_pmtx(sp._pmtx)
		{
			Addcount();
		}
		Shard_ptr<T>& operator=(const Shard_ptr<T>& sp)
		{
			//先释放原来的--但是有可能原来有好几个对象指向同一个位置,如何处理呢?
			//新赋值一个,那么是否就相当于引用计数多了1呢?不是,而是原来的计数要--
			//新的计数要++或者原来的++,新的计数--
			if (_ptr != sp._ptr)
			{
				Release();
				_ptr = sp._ptr;
				_pcount = sp._pcount;
				_pmtx = sp._pmtx;
				Addcount();
			}
			return *this;
		}
		T& operator*()
		{
			return *_ptr;
		}
		T& operator->()
		{
			return _ptr;
		}
		T* get()
		{
			return _ptr;
		}
		int use_count()
		{
			return *_pcount;
		}
	private:
		T* _ptr;
		//期望有一个公共的计数
		//int _count;//不行,这会导致每个对象都有一个引用计数
		//static int _count;也不行,会导致所有的对象公用一个引用计数
		int* _pcount;//每个资源对应一个引用计数,不仅同时指向_ptr,也指向_pcount
		mutex* _pmtx;
	};

删除问题,上面是delete ptr,那么如果要删除的是delete []ptr呢?又该怎么办呢? 

写一个简单的定制删除器

private:
	T* _ptr;
	//期望有一个公共的计数
	//int _count;//不行,这会导致每个对象都有一个引用计数
	//static int _count;也不行,会导致所有的对象公用一个引用计数
	int* _pcount;//每个资源对应一个引用计数,不仅同时指向_ptr,也指向_pcount
	mutex* _pmtx;
	function<void(T*)> _del = [](T* ptr) {delete ptr; };

在私有里面用function包装器写几个删除器,然后再 删除的时候调用del函数来删除即可。

shared_ptr还是有缺馅的

看如下代码:

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

 这里是会造成崩溃的,想想为什么会造成崩溃。这里用图来进行理解上述代码的过程:

 循环引用导致资源未被释放的原因:

当资源对应的引用计数减为0时对应的资源才会被释放,因此资源1的释放取决于资源2当中的prev成员,而资源2的释放取决于资源1当中的next成员。
而资源1当中的next成员的释放又取决于资源1,资源2当中的prev成员的释放又取决于资源2,于是这就变成了一个死循环,最终导致资源无法释放。
  而如果连接结点时只进行一个连接操作,那么当node1和node2的生命周期结束时,就会有一个资源对应的引用计数被减为0,此时这个资源就会被释放,这个释放后另一个资源的引用计数也会被减为0,最终两个资源就都被释放了,这就是为什么只进行一个连接操作时这两个结点就都能够正确释放的原因。
右边的节点什么时候delete->左边节点里面的next->左边节点的next什么时候析构呢?->左边节点delete时调用析构函数,next成员就析构了->左边节点什么时候delete呢?->右边节点中的prev析构时,左边节点就delete->prev什么时候析构呢》->prev是右边节点的成员,右边节点delete时,prev就析构了


所以为了解决shared_ptr中循环引用的问题,又提出了weak_ptr的概念。

4.4 weak_ptr

weak_ptr是C++11中引入的智能指针,weak_ptr不是用来管理资源的释放的,它主要是用来解决shared_ptr的循环引用问题的。

weak_ptr支持用shared_ptr对象来构造weak_ptr对象,构造出来的weak_ptr对象与shared_ptr对象管理同一个资源,但不会增加这块资源对应的引用计数。
  将ListNode中的next和prev成员的类型换成weak_ptr就不会导致循环引用问题了,此时当node1和node2生命周期结束时两个资源对应的引用计数就都会被减为0,进而释放这两个结点的资源。

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);
	node1->next = node2;
	node2->prev = node1;
	return 0;
}

weak_ptr的模拟实现: 

namespace wzz
{
	template<class T>
	class weak_ptr
	{
	public:
		weak_ptr()
			:_ptr(nullptr)
		{}

		weak_ptr(const shared_ptr<T>& sp)
			:_ptr(sp.get())
		{}

		T& operator*()
		{
			return *_ptr;
		}

		T* operator->()
		{
			return _ptr;
		}

		T* get() 
		{
			return _ptr;
		}

	private:
		T* _ptr;
	};
}

5.c++11和boost中智能指针的关系

 1.C++98中产生了第一个智能指针auto_ptr。
 2.C++boost给出了更实用的scoped_ptr、shared_ptr和weak_ptr。
 3.C++TR1,引入了boost中的shared_ptr等。不过注意的是TR1并不是标准版。
 4.C++11,引入了boost中的unique_ptr、shared_ptr和weak_ptr。需要注意的是,unique_ptr对应的就是boost中的scoped_ptr,并且这些智能指针的实现原理是参考boost中实现的。

说明:
  boost库是为C++语言标准库提供扩展的一些C++程序库的总称,boost库社区建立的初衷之一就是为C++的标准化工作提供可供参考的实现,比如在送审C++标准库TR1中,就有十个boost库成为标准库的候选方案。

6.总结与拓展

到这里智能指针就结束了,希望我们一起共同进步。

拓展:

1.禁止用任何类型智能指针get 函数返回的指针去初始化另外一个智能指针!

理由如下:

  • 如果两个智能指针都试图管理同一个裸指针,那么当它们被销毁时,可能会导致两次释放相同的内存,这会导致未定义行为(Undefined Behavior)。
  • 使用get()函数获取的裸指针失去了智能指针的生命周期管理和资源自动释放功能。这可能导致内存泄露或者不恰当的资源管理。

2.禁止delete 智能指针get 函数返回的指针

如果我们主动释放掉get 函数获得的指针,那么智能 指针内部的指针就变成野指针了,析构时造成重复释放,带来严重后果!

auto_ptr、unique_ptr、shared_ptr、weak_ptr这四个在库中都直接有,他们的头文件是memory

有兴趣阅读的可以去如下网站查找相关指针

Reference - C++ Reference (cplusplus.com)