关注

深入理解C++智能指针:从RAII到shared_ptr

在上一篇博客中,我们了解到由于C++异常机制的特性,可能会在我们无意之间就是造成内存泄漏的问题,就比如我们通过new和delete申请和释放内存的时候,new和delete在申请和释放失败的时候就会抛异常,就会导致我们在这之前正常申请的内存,由于执行流的跳跃,导致没有执行释放内存的代码,进而使得程序造成内存泄漏。

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

void func()
{
	int* n = new int(1);
	int x, y;
	std::cin >> x >> y;
	std::cout << division(x, y) << std::endl;

	std::cout << "delete : " << n << std::endl;
	delete n;
}

int main()
{
	try
	{
		func();
	}
	catch (const char* s)
	{
		std::cout << s << std::endl;
	}
	catch (...)
	{
		std::cout << "未知异常" << std::endl;
	}

	return 0;
}

这样就会导致我们向系统申请的arr的内存空间没有得到释放,就会造成内存泄漏,所以我们必须想办法解决这个问题,这个办法就是智能指针

智能指针的使用和原理

RAII思想

RAII(Resource Acquisition Is Initialization)就是一种利用对象生命周期来控制程序的资源的简单技术。

就是在对象构造的时候获取资源,接着控制对资源的访问使其在对象的生命周期中始终保持有效,最后在对象析构的时候释放资源。简而言之,就是我们将一份资源的管理交给一个对象。这样就会有两个好处:

  1. 不需要显示的释放资源
  2. 采用这种方式,对象所需的资源在其生命周期中始终保持有效。

通过这样的方式就可以解决上面的问题,我们通过代码就可以一探究竟。

template<class T>
class SmartPtr
{
public:
	SmartPtr(T* ptr = nullptr)
		:_ptr(ptr)
	{

	}

	~SmartPtr()
	{
		if (_ptr)
		{
			std::cout << "delete : " << _ptr << std::endl;
			delete _ptr;
		}
	}
private:
	T* _ptr;
};

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

void func()
{
	//int* num = new int(1);
	SmartPtr<int> num(new int);

	int x, y;
	std::cin >> x >> y;
	std::cout << division(x, y) << std::endl;
}

int main()
{
	try
	{
		func();
	}
	catch (const char* s)
	{
		std::cout << s << std::endl;
	}
	catch (...)
	{
		std::cout << "未知异常" << std::endl;
	}

	return 0;
}

智能指针的原理

上面的SmartPtr还不算完整的智能指针,因为它还不具备指针的行为,指针是可以解引用,也可以通过->去访问其所指空间的内容,所以它在类内必须对*、->进行重载,这样才可以像指针一样使用。

template<class T>
class SmartPtr
{
public:
	SmartPtr(T* ptr = nullptr)
		:_ptr(ptr)
	{

	}

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

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

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


private:
	T* _ptr;
};

int main()
{
	SmartPtr<int> s1(new int);
	*s1 = 2;

	SmartPtr<std::pair<std::string, int>> s2(new std::pair<std::string, int>("xxxxxxxx", 2));
	s2->first = "yyyyyy";
	s2->second = 3;
	std::cout << "s2->first = " << s2->first << std::endl;
	std::cout << "s2->second = " << s2->second << std::endl;

	return 0;
}

看似这样挺好的,但是智能指针这里有一个问题就是进行赋值的时候,会有很大的问题。

int main()
{
	SmartPtr<int> s1(new int);
	SmartPtr<int> s2 = s1;

	return 0;
}

通过结论我们就可以知道,这里出错的原因就是因为同一个指针进行了两次析构,所以程序出错,许多人看到这个可能会想到,由于我们并没有实现赋值运算符重载,这样就会使得编译器给我们生成一个默认的赋值运算符重载,默认的赋值运算符重载是浅拷贝,所以我们需要使用深拷贝???

真的是这样吗?其实不是,这里恰恰需要的就是浅拷贝,不需要深拷贝,因为智能指针本来就是资源的托管,我们对智能指针进行赋值的话,本质上其实就是我们想让另一个对象和我一起管理这个资源,没必要向系统重新申请一段空间来对其进行深拷贝,这样的话就不是对资源的管理,而是对资源的复制了。

我们可以通过原生指针来感受一下。

int main()
{
	int* p1 = new int;
	int* p2 = p1;

	*p1 = 2;
	*p2 = 1;

	std::cout << "p1 = " << p1 << std::endl;
	std::cout << "p2 = " << p2 << std::endl;

	delete p1;

	return 0;
}

我们可以看到p1和p2两个指针都是指向同一份资源,p1和p2都可以对资源中的内容进行修改,所以我们在最后也只需要对这段空间资源释放一次即可。

而我们上面出现问题则是因为我们利用对象生命周期来控制程序的资源,最后都会自动释放资源,这就会导致当两个对象管理同一份资源的时候,就是析构两次的情况出现,所以会出错。

找到了问题的根本所在,现在我们可以想到如何让两个对象在管理同一份资源的时候最后只析构一次就可以了,我们可以想到的就是利用引用计数的方法就可以解决这个问题。

但是C++在解决这个问题的时候,并没有一步到位使用引用计数的方法,而是在刚开始想到了管理权转移的思想(std::auto_ptr)

std::auto_ptr

std::auto_ptr是C++98版本库中实现的智能指针的方式。实现原理就是:管理权转移,下面我们来简单模拟一个buluo::auto_ptr来了解一下它的实现原理即可。

namespace buluo
{
	template<class T>
	class auto_ptr
	{
	public:
		auto_ptr(T* ptr = nullptr)
			:_ptr(ptr)
		{

		}

		auto_ptr(auto_ptr<T>& ap)
			:_ptr(ap._ptr)
		{
			ap._ptr = nullptr;
		}

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

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

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

		auto_ptr<T>& operator=(auto_ptr<T>& ap)
		{
			if (_ptr)
			{
				delete _ptr;
			}
			_ptr = ap._ptr;
			ap._ptr = nullptr;
			return *this;
		}

	private:
		T* _ptr;
	};
}

int main()
{
	buluo::auto_ptr<int> sp1(new int);
	buluo::auto_ptr<int> sp2 = sp1;
	return 0;
}

auto_ptr解决上述问题的时候,采用的就是管理权转移的思想,就像上面的代码一样,将sp1的_ptr赋值给sp2的_ptr,然后将sp1自己的_ptr置为NULL,这样就相当于将自己的管理权限交给了sp2,这就是所谓的管理权转移。

但是这样的思想还是有问题的,就比如下面这种情况:

int main()
{
	buluo::auto_ptr<int> sp1(new int);
	buluo::auto_ptr<int> sp2 = sp1;

	(*sp1) = 2;
	(*sp2) = 3;

	return 0;
}

当我们对所管理的指针进行赋值的时候,由于管理权的转移,就导致sp1悬空,它变为了一个空指针,这个时候对其进行赋值,是错误的。

int main()
{
	int* p1 = new int;
	int* p2 = p1;

	*p1 = 2;
	*p2 = 1;

	std::cout << "p1 = " << p1 << std::endl;
	std::cout << "p2 = " << p2 << std::endl;

	delete p1;

	return 0;
}

对比一下原生指针,及时赋值之后,两个指针都可以对这部分空间的内容进行操作,所以auto_ptr这样的设计是不合理的。所以为了解决这个问题,在C++11版本中提供了更为靠谱的unique_ptr。

std::unique_ptr

C++11中提供了更为靠谱的unique_ptr,它的实现原理:使用了简单除暴的方式就是防止拷贝,方法就是:

  1. 只声明不实现(因为如果不声明编译器会自动生成一个)
  2. 限定为私有(如果设置为公有的话,用户可能会为此自己在类外面生成一份自己写的)
    namespace buluo
    {
    	template<class T>
    	class auto_ptr
    	{
    	public:
    		auto_ptr(T* ptr = nullptr)
    			:_ptr(ptr)
    		{
    
    		}
    
    		auto_ptr(auto_ptr<T>& ap)
    			:_ptr(ap._ptr)
    		{
    			ap._ptr = nullptr;
    		}
    
    		~auto_ptr()
    		{
    			if (_ptr)
    			{
    				std::cout << "delete : " << _ptr << std::endl;
    				delete _ptr;
    				_ptr = nullptr;
    			}
    		}
    
    		T& operator*()
    		{
    			return *_ptr;
    		}
    
    		T* operator->()
    		{
    			return _ptr;
    		}
    
    		auto_ptr<T>& operator=(auto_ptr<T>& ap)
    		{
    			if (_ptr)
    			{
    				delete _ptr;
    			}
    			_ptr = ap._ptr;
    			ap._ptr = nullptr;
    			return *this;
    		}
    
    	private:
    		T* _ptr;
    	};
    
    	template<class T>
    	class unique_ptr
    	{
    	public:
    		unique_ptr(T* ptr = nullptr)
    			:_ptr(ptr)
    		{
    
    		}
    
    
    		~unique_ptr()
    		{
    			if (_ptr)
    			{
    				std::cout << "delete : " << _ptr << std::endl;
    				delete _ptr;
    				_ptr = nullptr;
    			}
    		}
    
    		T& operator*()
    		{
    			return *_ptr;
    		}
    
    		T* operator->()
    		{
    			return _ptr;
    		}
    		unique_ptr(const unique_ptr<T>& up);
    		unique_ptr<T>& operator=(const unique_ptr<T>& ap);
    
    	private:
    		T* _ptr;
    	};
    }
    
    template<class T>
    buluo::unique_ptr<T>::unique_ptr(const unique_ptr& up)
    	:_ptr(up._ptr)
    {
    
    }

    就有可能通过这样的方式,用户自己在类外面实现自己的方法,所以要设置为私有,这样用户在类外面是无法对类内成员函数进行修改的。

namespace buluo
{
	template<class T>
	class auto_ptr
	{
	public:
		auto_ptr(T* ptr = nullptr)
			:_ptr(ptr)
		{

		}

		auto_ptr(auto_ptr<T>& ap)
			:_ptr(ap._ptr)
		{
			ap._ptr = nullptr;
		}

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

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

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

		auto_ptr<T>& operator=(auto_ptr<T>& ap)
		{
			if (_ptr)
			{
				delete _ptr;
			}
			_ptr = ap._ptr;
			ap._ptr = nullptr;
			return *this;
		}

	private:
		T* _ptr;
	};

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

		}


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

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

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

	private:
		unique_ptr(const unique_ptr<T>& up);
		unique_ptr<T>& operator=(const unique_ptr<T>& ap);

	private:
		T* _ptr;
	};
}

int main()
{
	buluo::unique_ptr<int> sp1(new int);
	buluo::unique_ptr<int> sp2 = sp1;


	return 0;
}

通过这样的方式就可以简单粗暴的解决赋值的问题,但是这样的方式依旧还是治标不治本,所以C++11中提供了引用计数的方式就是shared_ptr来解决这个问题。

std::shared_ptr

shared_ptr的原理:是通过引用计数的方式来实现多个shared_ptr对象之间共享资源。

  • 在shared_ptr中,给每一个资源都维护了一份计数,用来记录该份资源被几个对象共享。
  • 在对象进行析构的时候,表示这个对象不再管理这份资源,直接将引用计数减一即可。
  • 如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放资源;
  • 如果不是0,就说明除了自己还有其它对象正在使用这份资源,不可以释放该资源。

那么现在我们知道要使用引用计数了,但是我们应该如何定义shared_ptr的成员函数呢?

	template<class T>
	class shared_ptr
	{

	private:
		T* _ptr;
		int _count = 1;
	};

如果使用这样的方式进行成员函数的定义的话,是完全没有用的,因为_count对于每一个对象都有一个_count成员变量,当有两个对象都要指向同一个资源的时候,这两个的_count都独属于自己,没有效果。

所以大家可能会想到我定义一个全局的静态成员变量,两个对象指向同一个资源的时候,这个静态的成员变量就会受到影响。就比如下面这种情况:

	template<class T>
	class shared_ptr
	{

	private:
		T* _ptr;
		static int _count = 1;
	};

貌似通过这样全局的静态成员变量,可以让两个对象的_count相互受到影响,但是这样其实还是有问题的,就是一旦新定义一个对象,这个对象指向另一个新的资源,这个对象在进行初始化的时候,新对象也可以使用这个_count,所以就会将这个_count置为1,这样就会导致问题出错。

问题的根源在于“计数没有真正共享”。解决办法就是把计数独立出来,在堆上专门开辟一块空间。这样,同一资源的所有管理者都会指向同一份计数,实现真正的共享;而不同资源之间则各自维护自己的计数,彼此互不干扰,逻辑也更加清晰。办法如下:

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

		}

		~shared_ptr()
		{
			if (--(*_pcount) == 0)
			{
				delete _ptr;
				delete _pcount;
			}
		}
	private:
		T* _ptr;
		int* _pcount;
	};
}

通过这样的方式就可以定义shared_ptr的成员函数,接下来我们简单补充一下它的成员方法。

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

		}

		shared_ptr(const shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			,_pcount(sp._pcount)
		{
			++(*_pcount);
		}

		shared_ptr<T>& operator=(const shared_ptr<T>& sp)
		{
			if (--(*_pcount) == 0)
			{
				delete _ptr;
				delete _pcount;
			}
			_ptr = sp._ptr;
			_pcount = sp._pcount;
			++(*_pcount);

			return *this;
		}

		~shared_ptr()
		{
			if (--(*_pcount) == 0)
			{
				//std::cout << _ptr << std::endl;
				delete _ptr;
				delete _pcount;
			}
		}

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

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



	private:
		T* _ptr;
		int* _pcount;
	};

int main()
{
	buluo::shared_ptr<std::string> sp1(new std::string("buluo"));
	buluo::shared_ptr<std::string> sp2 = sp1;

	buluo::shared_ptr<std::string> sp3(new std::string("hello"));

	return 0;
}

这样看上去是没有问题了,但是我们还要注意在赋值运算符重载这里需要判断自己给自己赋值的情况。

int main()
{
	buluo::shared_ptr<std::string> sp1(new std::string("buluo"));
	buluo::shared_ptr<std::string> sp2 = sp1;

	buluo::shared_ptr<std::string> sp3(new std::string("hello"));

	sp3 = sp3;
	std::cout << *sp3 << std::endl;

	return 0;
}

可以看到我们的程序挂掉了,这是因为我们在写赋值运算符重载的时候,没有考虑自己给自己赋值的情况,所以之后我们在写赋值运算符重载的时候切记要考虑自己给自己赋值的情况。

		shared_ptr<T>& operator=(const shared_ptr<T>& sp)
		{
			if (_ptr != sp._ptr)
			{
				if (--(*_pcount) == 0)
				{
					delete _ptr;
					delete _pcount;
				}
				_ptr = sp._ptr;
				_pcount = sp._pcount;
				++(*_pcount);
			}
			return *this;
		}

通过引用计数的方式,就可以很好的支持了拷贝,非常完美的解决了拷贝的问题,但是shared_ptr还有一个问题就是循环引用的问题。

std::shared_ptr的循环引用

struct ListNode
{
	int val;
	buluo::shared_ptr<ListNode> next;
	buluo::shared_ptr<ListNode> prev;

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

int main()
{
	buluo::shared_ptr<ListNode> n1(new ListNode);
	buluo::shared_ptr<ListNode> n2(new ListNode);

	n1->next = n2;
	n2->prev = n1;

	return 0;
}

就是通过这样一段简单的代码,我们就可以看到程序的结果并没有打印我们的语句,这就表明这两个指针没有得到释放,证明在这段程序中存在内存泄漏的问题。这就是循环引用的场景,现在我们就来分析一下,为什么这段代码会造成内存泄漏的问题。

当程序结束时:

  1. n2 开始析构
    → 引用计数减 1,但不为 0(因为 n1->next 还在引用它)
    → ❌ 不会释放
  2. n1 开始析构
    → 引用计数减 1,但不为 0(因为 n2->prev 还在引用它)
    → ❌ 也不会释放

两个对象互相“拉住对方”:

  • 你等我释放
  • 我等你释放

最终结果:

❌ 谁也等不到谁
❌ 引用计数永远不为 0
❌ 内存无法释放(内存泄漏)

所以shared_ptr 通过引用计数解决了资源共享问题,但一旦出现“互相引用”,就会形成闭环,导致计数无法归零,从而引发内存泄漏。

那么这种情况如何解决呢?在引用计数的场景下,把节点中的_prev和_next改成weak_ptr就可以了。

原理就是,node1->_next = node2;和node2->_prev = node1;时weak_ptr的_next和 _prev不会增加node1和node2的引用计数。

namespace buluo
{
	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			, _pcount(new int(1))
		{

		}

		shared_ptr(const shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			,_pcount(sp._pcount)
		{
			++(*_pcount);
		}

		shared_ptr<T>& operator=(const shared_ptr<T>& sp)
		{
			if (_ptr != sp._ptr)
			{
				if (--(*_pcount) == 0)
				{
					delete _ptr;
					delete _pcount;
				}
				_ptr = sp._ptr;
				_pcount = sp._pcount;
				++(*_pcount);
			}
			return *this;
		}

		~shared_ptr()
		{
			if (--(*_pcount) == 0)
			{
				//std::cout << _ptr << std::endl;
				delete _ptr;
				delete _pcount;
			}
		}

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

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

		T* get() const
		{
			return _ptr;
		}

	private:
		T* _ptr;
		int* _pcount;
	};

	template<class T>
	class weak_ptr
	{
	public:
		weak_ptr(T* ptr = nullptr)
			:_ptr(ptr)
		{

		}

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

		}

		weak_ptr<T>& operator=(const shared_ptr<T>& sp)
		{
			_ptr = sp.get();
			return *this;
		}

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

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

	private:
		T* _ptr;
	};
}

struct ListNode
{
	int val;
	buluo::weak_ptr<ListNode> next;
	buluo::weak_ptr<ListNode> prev;

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

int main()
{
	buluo::shared_ptr<ListNode> n1(new ListNode);
	buluo::shared_ptr<ListNode> n2(new ListNode);

	n1->next = n2;
	n2->prev = n1;

	return 0;
}

通过这种简化实现的 weak_ptr,就可以解决循环引用的问题。不过,在 C++ 标准库中,weak_ptr 的实现并不是简单地使用裸指针。

实际上,它仍然依赖引用计数机制,只是 weak_ptr 本身不会增加引用计数,而是通过“观察”引用计数的变化来判断资源是否已经被释放。因此,weak_ptr 的本质作用并不是管理资源,而是作为一种“观察者”存在。

定制删除器

对于shared_ptr还有最后一个问题就是定制删除器,这是什么呢?其实通过一段代码大家就明白了。

这里主要是我们在实现析构函数的时候,进行释放_ptr的时候,直接写死成delete,这就导致在释放数组的时候无法释放,应该使用delete[],在C++的库中也是这样实现的,我们可以看一看。

那么C++库是如何解决这个问题的呢?就是通过定制删除其解决的,可以通过仿函数来解决这个问题。

template<class T>
struct DelArray
{
	void operator()(T* ptr)
	{
		delete[] ptr;
	}
};

int main()
{
	std::shared_ptr<ListNode> sp(new ListNode[10], DelArray<ListNode>()); 

	return 0;
}

通过这样的方式就可以解决管理对象是数组的情况了。现在我们来简单模拟一下删除器的实现。

	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr)
			: _ptr(ptr)
			, _pcount(new int(1))
			, _del([](T* p) { delete p; })
		{}

		template<class D>
		shared_ptr(T* ptr, D del)
			: _ptr(ptr)
			, _pcount(new int(1))
			, _del(del)
		{}

		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 (this != &sp)
			{
				if (--(*_pcount) == 0)
				{
					_del(_ptr);
					delete _pcount;
				}
				_ptr = sp._ptr;
				_pcount = sp._pcount;
				_del = sp._del;
				++(*_pcount);
			}
			return *this;
		}

		~shared_ptr()
		{
			if (--(*_pcount) == 0)
			{
				_del(_ptr);
				delete _pcount;
			}
		}

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

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

		T* get() const
		{
			return _ptr;
		}

	private:
		T* _ptr;
		int* _pcount;
		std::function<void(T*)> _del;
	};

这样通过我们自己实现的shared_ptr也成功解决了删除数组的问题。

说到底,智能指针解决的其实就一件事:让资源的释放变得“自动且安全”。

auto_ptrunique_ptr,再到 shared_ptr,本质都是在不断优化资源管理方式,让代码更可靠、更不容易出错。

当你习惯用智能指针之后,你会发现:
👉 手动 new/delete,反而成了不太优雅的写法。

写 C++,不仅是把代码写对,更是把资源管好。

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2302_77620024/article/details/160901389

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--