智能指针基本原理,简单实现,常见问题

Posted 石中火本火

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了智能指针基本原理,简单实现,常见问题相关的知识,希望对你有一定的参考价值。

基本概念

  • 智能指针是一个模板;
  • shared_ptr允许多个指针指向同一个对象,unique指针则独占指向的对象;

基本使用

  • shared_ptr<T> ptr; //默认初始化保存着一个空指针

  • shared_ptr<int> ptr = make_shared<int>(42);

  • 拷贝与赋值,会有一个引用计数

    引用计数增加的情况:

    • 拷贝初始化:shared_ptr<T>q(p);
    • 参数传递及函数返回值:void function(shared_ptr<T> ptr);因为这也是一种拷贝

基本实现

  • 两个基本成员ptr与ref_count,即指针与引用计数,关于引用计数是一个数值还是一个稍复杂的类,要看库的具体实现,此处为了简便使用一个数值来统计引用计数;

    ptr
    ref_count
  • 需实现函数:

    • 显式初始化构造函数
    • 拷贝构造函数
    • 析构函数
#include<iostream>
#include<mutex>
#include<thread>
using namespace std;

template<class T>
class Shared_Ptr
public:
	Shared_Ptr(T* ptr = nullptr) // 默认构造函数
		:_pPtr(ptr)
		, _pRefCount(new int(1))
		, _pMutex(new mutex)
	
	~Shared_Ptr()  // 定制析构函数
	
		Release();
	
	Shared_Ptr(const Shared_Ptr<T>& sp)  // 拷贝构造函数,增加引用计数
		:_pPtr(sp._pPtr)
		, _pRefCount(sp._pRefCount)
		, _pMutex(sp._pMutex)
	
		AddRefCount();
	
	Shared_Ptr<T>& operator=(const Shared_Ptr<T>& sp) // 重载赋值运算符
	
		//if (this != &sp)
		if (_pPtr != sp._pPtr)
		
			// 释放管理的旧资源
			Release();
			// 共享管理新对象的资源,并增加引用计数
			_pPtr = sp._pPtr;
			_pRefCount = sp._pRefCount;
			_pMutex = sp._pMutex;
			AddRefCount();
		
		return *this;
	
	T& operator*()
		return *_pPtr;
	
	T* operator->()
		return _pPtr;
	
	int UseCount()  return *_pRefCount; 
	T* Get()  return _pPtr; 
	void AddRefCount()
	
		_pMutex->lock();
		++(*_pRefCount);
		_pMutex->unlock();
	
private:
	void Release()
	
		bool deleteflag = false;
		_pMutex->lock();
		if (--(*_pRefCount) == 0)
		
			delete _pRefCount;
			delete _pPtr;
			deleteflag = true;
		
		_pMutex->unlock();
		if (deleteflag == true)
			delete _pMutex;
	
private:
	int *_pRefCount; // 计数器
	T* _pPtr;        // 指针成员
	mutex* _pMutex;  
;

线程安全问题

  • 引用计数是多个智能指针对象共享,若智能指针处于不同的线程内,则线程并行操作有可能引起技术混乱或指针空悬问题;
  • 计数混乱:为了解决计数混乱问题,可以加互斥锁在计数变量上,这样每次只会有一个线程执行变量的加减操作;即在实现中引用计数的操作是线程安全的
  • 指针空悬:对指针的操作不是线程安全的。如对于多线程中的两个智能指针a,b,若在某线程1中想要执行赋值操作a=b,分两步执行,1)先执行指针的复制,2)再执行引用计数的复制并加1,这两步操作不是原子的. 如果执行完第一步后,转到了线程2执行b = (new ClassA),即对于智能指针b来说,他被赋予了新值,原计数减一,由于线程一中的增加引用计数操作还未来得及实施,所以现在引用计数变成了0,原指针被释放。如果此时再回到线程1,a的引用计数指针将指向新的b的引用计数,这里就产生了错误,并且a的指针空悬,产生安全问题。
  • 具体图示可见link:https://blog.csdn.net/liang19890820/article/details/120465794

循环引用

可以假设一个双向链表Node的结构体,其中的next与prev都设置为智能指针,然后创建两个智能指针node1, ndoe2指向两个新new的Node,那么这时候两者的引用计数都是1.此时再分别设置node1->next = node2; node2->next = node1,两者的引用计数都变为2,若此时程序结束,则两node引用值都变为1,无法析构,。想要node1析构得先析构node2,反之亦然,这就导致了循环。

  • 解决方法:shared_ptr改为weak_ptr,弱指针不会增加引用计数,就不会循环引用了

C++智能指针

文章目录

智能指针的使用及原理

智能指针的使用

内存泄露问题

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

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函数中先对div函数中抛出的异常进行捕获,捕获后先将之前申请的内存资源释放,然后再将异常重新抛出。比如:

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;

利用智能指针解决

上述问题也可以使用智能指针进行解决。比如:

template<class T>
class SmartPtr

public:
	SmartPtr(T* ptr)
		:_ptr(ptr)
	
	~SmartPtr()
	
		cout << "delete: " << _ptr << endl;
		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> sp(new int);
	//...
	cout << div() << endl;
	//...

int main()

	try
	
		func();
	
	catch (exception& e)
	
		cout << e.what() << endl;
	
	return 0;

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

  • 在构造SmartPtr对象时,SmartPtr将传入的需要被管理的内存空间保存起来。
  • 在SmartPtr对象析构时,SmartPtr的析构函数中会自动将管理的内存空间进行释放。
  • 此外,为了让SmartPtr对象能够像原生指针一样使用,还需要对*->运算符进行重载。

这样一来,无论程序是正常执行完毕返回了,还是因为某些原因中途返回了,或是因为抛异常返回了,只要SmartPtr对象的生命周期结束就会调用其对应的析构函数,进而完成内存资源的释放。

智能指针的原理

智能指针的原理

实现智能指针时需要考虑以下三个方面的问题:

  1. 在对象构造时获取资源,在对象析构的时候释放资源,利用对象的生命周期来控制程序资源,即RAII特性。
  2. *->运算符进行重载,使得该对象具有像指针一样的行为。
  3. 智能指针对象的拷贝问题。

概念说明: RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、互斥量等等)的简单技术。

为什么要解决智能指针对象的拷贝问题

对于当前实现的SmartPtr类,如果用一个SmartPtr对象来拷贝构造另一个SmartPtr对象,或是将一个SmartPtr对象赋值给另一个SmartPtr对象,都会导致程序崩溃。比如:

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;

原因如下:

  • 编译器默认生成的拷贝构造函数对内置类型完成值拷贝(浅拷贝),因此用sp1拷贝构造sp2后,相当于这sp1和sp2管理了同一块内存空间,当sp1和sp2析构时就会导致这块空间被释放两次。
  • 编译器默认生成的拷贝赋值函数对内置类型也是完成值拷贝(浅拷贝),因此将sp4赋值给sp3后,相当于sp3和sp4管理的都是原来sp3管理的空间,当sp3和sp4析构时就会导致这块空间被释放两次,并且还会导致sp4原来管理的空间没有得到释放。

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

C++中的智能指针

std::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;

但一个对象的管理权转移后也就意味着,该对象不能再用对原来管理的资源进行访问了,否则程序就会崩溃,因此使用auto_ptr之前必须先了解它的机制,否则程序很容易出问题,很多公司也都明确规定了禁止使用auto_ptr。

auto_ptr的模拟实现

简易版的auto_ptr的实现步骤如下:

  1. 在构造函数中获取资源,在析构函数中释放资源,利用对象的生命周期来控制资源。
  2. *->运算符进行重载,使auto_ptr对象具有指针一样的行为。
  3. 在拷贝构造函数中,用传入对象管理的资源来构造当前对象,并将传入对象管理资源的指针置空。
  4. 在拷贝赋值函数中,先将当前对象管理的资源释放,然后再接管传入对象管理的资源,最后将传入对象管理资源的指针置空。

代码如下:

namespace cl

	template<class T>
	class auto_ptr
	
	public:
		//RAII
		auto_ptr(T* ptr = nullptr)
			:_ptr(ptr)
		
		~auto_ptr()
		
			if (_ptr != nullptr)
			
				cout << "delete: " << _ptr << endl;
				delete _ptr;
				_ptr = nullptr;
			
		
		auto_ptr(auto_ptr<T>& ap)
			:_ptr(ap._ptr)
		
			ap._ptr = nullptr; //管理权转移后ap被置空
		
		auto_ptr& operator=(auto_ptr<T>& ap)
		
			if (this != &ap)
			
				delete _ptr;       //释放自己管理的资源
				_ptr = ap._ptr;    //接管ap对象的资源
				ap._ptr = nullptr; //管理权转移后ap被置空
			
			return *this;
		
		//可以像指针一样使用
		T& operator*()
		
			return *_ptr;
		
		T* operator->()
		
			return _ptr;
		
	private:
		T* _ptr; //管理的资源
	;

std::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;

但防拷贝其实也不是一个很好的办法,因为总有一些场景需要进行拷贝。

unique_ptr的模拟实现

简易版的unique_ptr的实现步骤如下:

  1. 在构造函数中获取资源,在析构函数中释放资源,利用对象的生命周期来控制资源。
  2. *->运算符进行重载,使unique_ptr对象具有指针一样的行为。
  3. 用C++98的方式将拷贝构造函数和拷贝赋值函数声明为私有,或者用C++11的方式在这两个函数后面加上=delete,防止外部调用。

代码如下:

namespace cl

	template<class T>
	class unique_ptr
	
	public:
		//RAII
		unique_ptr(T* ptr = nullptr)
			:_ptr(ptr)
		
		~unique_ptr()
		
			if (_ptr != nullptr)
			
				cout << "delete: " << _ptr << endl;
				delete _ptr;
				_ptr = nullptr;
			
		
		//可以像指针一样使用
		T& operator*()
		
			return *_ptr;
		
		T* operator->()
		
			return _ptr;
		
		//防拷贝
		unique_ptr(unique_ptr<T>& up) = delete;
		unique_ptr& operator=(unique_ptr<T>& up) = delete;
	private:
		T* _ptr; //管理的资源
	;

std::shared_ptr

std::shared_ptr的基本设计

引用计数

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

  • 每一个被管理的资源都有一个对应的引用计数,通过这个引用计数记录着当前有多少个对象在管理着这块资源。
  • 当新增一个对象管理这块资源时则将该资源对应的引用计数进行++,当一个对象不再管理这块资源或该对象被析构时则将该资源对应的引用计数进行--
  • 当一个资源的引用计数减为0时说明已经没有对象在管理这块资源了,这时就可以将该资源进行释放了。

通过这种引用计数的方式就能支持多个对象一起管理某一个资源,也就是支持了智能指针的拷贝,并且只有当一个资源对应的引用计数减为0时才会释放资源,因此保证了同一个资源不会被释放多次。比如:

int main()

	cl::shared_ptr<int> sp1(new int(1));
	cl::shared_ptr<int> sp2(sp1);
	*sp1 = 10;
	*sp2 = 20;
	cout << sp1.use_count() << endl; //2

	cl::shared_ptr<int> sp3(new int(1));
	cl::shared_ptr<int> sp4(new int(2));
	sp3 = sp4;
	cout << sp3.use_count() << endl; //2
	return 0;

说明一下: use_count成员函数,用于获取当前对象管理的资源对应的引用计数。

shared_ptr的模拟实现

简易版的shared_ptr的实现步骤如下:

  1. 在shared_ptr类中增加一个成员变量count,表示智能指针对象管理的资源对应的引用计数。
  2. 在构造函数中获取资源,并将该资源对应的引用计数设置为1,表示当前只有一个对象在管理这个资源。
  3. 在拷贝构造函数中,与传入对象一起管理它管理的资源,同时将该资源对应的引用计数++
  4. 在拷贝赋值函数中,先将当前对象管理的资源对应的引用计数--(如果减为0则需要释放),然后再与传入对象一起管理它管理的资源,同时需要将该资源对应的引用计数++
  5. 在析构函数中,将管理资源对应的引用计数--,如果减为0则需要将该资源释放。
  6. *->运算符进行重载,使shared_ptr对象具有指针一样的行为。

代码如下:

namespace cl

	template<class T>
	class shared_ptr
	
	public:
		//RAII
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			, _pcount(new int(1))
		
		~shared_ptr()
		
			if (--(*_pcount) == 0)
			
				if (_ptr != nullptr)
				
					cout << "delete: " << _ptr << endl;
					delete _ptr;
					_ptr = nullptr;
				
				delete _pcount;
				_pcount = nullptr;
			
		
		shared_ptr(shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			, _pcount(sp._pcount)
		
			(*_pcount)++;
		
		shared_ptr& operator=(shared_ptr<T>& sp)
		
			if (_ptr != sp._ptr) //管理同一块空间的对象之间无需进行赋值操作
			
				if (--(*_pcount) == 0) //将管理的资源对应的引用计数--
				
					cout << "delete: " << _ptr << endl;
					delete _ptr;
					delete _pcount;
				
				_ptr = sp._ptr;       //与sp对象一同管理它的资源
				_pcount = sp._pcount; //获取sp对象管理的资源对应的引用计数
				(*_pcount)++;         //新增一个对象来管理该资源,引用计数++
			
			return *this;
		
		//获取引用计数
		int use_count()
		
			return *_pcount;
		
		//可以像指针一样使用
		T& operator*()
		
			return *_ptr;
		
		T* operator->()
		
			return _ptr;
		
	private:
		T* _ptr;      //管理的资源
		int* _pcount; //管理的资源对应的引用计数
	;

为什么引用计数需要存放在堆区?

首先,shared_ptr中的引用计数count不能单纯的定义成一个int类型的成员变量,因为这就意味着每个shared_ptr对象都有一个自己的count成员变量,而当多个对象要管理同一个资源时,这几个对象应该用到的是同一个引用计数。

如下图:

其次,shared_ptr中的引用计数count也不能定义成一个静态的成员变量,因为静态成员变量是所有类型对象共享的,这会导致管理相同资源的对象和管理不同资源的对象用到的都是同一个引用计数。

如下图:

而如果将shared_ptr中的引用计数count定义成一个指针,当一个资源第一次被管理时就在堆区开辟一块空间用于存储其对应的引用计数,如果有其他对象也想要管理这个资源,那么除了将这个资源给它之外,还需要把这个引用计数也给它。

这时管理同一个资源的多个对象访问到的就是同一个引用计数,而管理不同资源的对象访问到的就是不同的引用计数了,相当于将各个资源与其对应的引用计数进行了绑定。

如下图:


但同时需要注意,由于引用计数的内存空间也是在堆上开辟的,因此当一个资源对应的引用计数减为0时,除了需要将该资源释放,还需要将该资源对应的引用计数的内存空间进行释放。

std::shared_ptr的线程安全问题

shared_ptr的线程安全问题

当前模拟实现的shared_ptr还存在线程安全的问题,由于管理同一个资源的多个对象的引用计数是共享的,因此多个线程可能会同时对同一个引用计数进行自增或自减操作,而自增和自减操作都不是原子操作,因此需要通过加锁来对引用计数进行保护,否则就会导致线程安全问题。

比如下面代码中用一个shared_ptr管理一个整型变量,然后用两个线程分别对这个shared_ptr对象进行1000次拷贝操作,这些对象被拷贝出来后又会立即被销毁。比如:

void func(cl::shared_ptr<int>& sp, size_t n)

	for (size_t i = 0; i < n; i++)
	
		cl::shared_ptr<int> copy(sp);
	

int main()

	cl::shared_ptr<int> p(new int(0));

	const size_t n = 1000;
	thread t1(func, p, n);
	thread t2(func, p, n);

	t1.join();
	t2.join();

	cout << p.use_count() << endl; //预期:1

	return 0;

在这个过程中两个线程会不断对引用计数进行自增和自减操作,理论上最终两个线程执行完毕后引用计数的值应该是1,因为拷贝出来的对象都被销毁了,只剩下最初的shared_ptr对象还在管理这个整型变量,但每次运行程序得到引用计数的值可能都是不一样的,根本原因就是因为对引用计数的自增和自减不是原子操作。

加锁解决线程安全问题

要解决引用计数的线程安全问题,本质就是要让对引用计数的自增和自减操作变成一个原子操作,因此可以对引用计数的操作进行加锁保护,也可以用原子类atomic对引用计数进行封装,这里以加锁为例。

  • 在shared_ptr类中新增互斥锁成员变量,为了让管理同一个资源的多个线程访问到的是同一个互斥锁,管理不同资源的线程访问到的是不同的互斥锁,因此互斥锁也需要在堆区创建。
  • 在调用拷贝构造函数和拷贝赋值函数时,除了需要将对应的资源和引用计数交给当前对象管理之外,还需要将对应的互斥锁也交给当前对象。
  • 当一个资源对应的引用计数减为0时,除了需要将对应的资源和引用计数进行释放,由于互斥锁也是在堆区创建的,因此还需要将对应的互斥锁进行释放。
  • 为了简化代码逻辑,可以将拷贝构造函数和拷贝赋值函数中引用计数的自增操作提取出来,封装成AddRef函数,将拷贝赋值函数和析构函数中引用计数的自减操作提取出来,封装成ReleaseRef函数,这样就只需要对AddRef和ReleaseRef函数进行加锁保护即可。

代码如下:

namespace cl

	template<class T>
	class shared_ptr
	
	private:
		//++引用计数
		void AddRef()
		
			_pmutex->lock();
			(*_pcount)++;
			_pmutex->unlock();
		
		//--引用计数
		void ReleaseRef()
		
			_pmutex->lock();
			bool flag = false;
			if (--(*_pcount) == 0) //将管理的资源对应的引用计数--
			
				if (_ptr != nullptr)
				
					cout << "delete: " << _ptr << endl;
					delete _ptr;
					_ptr = nullptr;
				
				delete _pcount;
				_pcount = nullptr;
				flag = true;
			
			_pmutex->unlock();
			if (flag == true)
			
				delete _pmutex;
			
		
	public:
		//RAII
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			, _pcount(new int(1))
			, _pmutex(new mutex)
		以上是关于智能指针基本原理,简单实现,常见问题的主要内容,如果未能解决你的问题,请参考以下文章

智能指针的简单实现

智能指针的原理和实现

智能指针的原理和实现

C++智能指针原理与实现

Android系统的智能指针(轻量级指针强指针和弱指针)的实现原理分析

shared_ptr智能指针模板类的简单实现(c++11)