前往小程序,Get更优阅读体验!
立即前往
发布
社区首页 >专栏 >C++智能指针

C++智能指针

作者头像
用户11029129
发布2024-10-18 09:48:51
发布2024-10-18 09:48:51
9000
代码可运行
举报
文章被收录于专栏:编程学习编程学习
运行总次数:0
代码可运行

🚀内存泄漏

  • 什么是内存泄漏内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费
  • 内存泄漏的危害长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死
✈️内存泄漏分类

程序中一般我们关心两种方面的内存泄漏:

  • 堆内存泄漏(Heap leak) 堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak
  • 系统资源泄漏 指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定
✈️如何避免内存泄漏
  1. 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证
  2. 采用RAII思想或者智能指针来管理资源
  3. 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项
  4. 出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵
  • 总结一下内存泄漏非常常见,解决方案分为两种:1、事前预防型。如智能指针等。2、事后查错型。如泄漏检测工具
🚀智能指针的使用及原理
✈️RAII

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

  在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:

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

以下是一个简单智能指针示例:

代码语言:javascript
代码运行次数:0
复制
#include <iostream>
#include <string>

using namespace std;

// RAII
template<class T>
class SmartPtr {
public:
	SmartPtr(T* ptr)
		:_ptr(ptr)
	{}

	~SmartPtr()
	{
		cout << "delete smart point success... " << endl;
		delete _ptr;
	}

private:
	T* _ptr;
};

int Test()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除零错误");

	return a / b;
}

void Func()
{
	SmartPtr<int> sp1(new int);
	SmartPtr<int> sp2(new int);

	cout << Test() << endl;
}

int main()
{
	try {
		Func();
	}
	catch (const exception& e)
	{
		cout << e.what() << endl;
	}

	return 0;
}

  实际上的智能指针就是一个类的对象,这个类可以帮我们自动析构智能指针,有效的避免了指针释放问题。而我们不仅仅需要智能指针帮我们自动释放资源,我们更需要其要像普通指针那样的作用,所以少不了 -> 与 &操作:

代码语言:javascript
代码运行次数:0
复制
template<class T>
class SmartPtr {
public:
	SmartPtr(T* ptr)
		:_ptr(ptr)
	{}

	~SmartPtr()
	{
		cout << "delete smart point success... " << endl;
		delete _ptr;
	}
	T& operator*()
	{
		return _ptr;
	}

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

private:
	T* _ptr;
};
✈️智能指针拷贝问题
🚩auto_ptr指针

  上述代码依旧存在问题,当我们用一个指针来构造另一个指针的时候:

代码语言:javascript
代码运行次数:0
复制
int main()
{
	SmartPtr<int> sp1(new int());
	SmartPtr<int> sp2(sp1);

	return 0;
}

  而这部分也有一些C++发展历史因素,在C++std库中,有一个指针叫做 auto_ptr指针,我们调用试一试:

代码语言:javascript
代码运行次数:0
复制
#include <memory>

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

  看到这里,你可能会很惊讶,C++标准库里居然会发生这样的事情,实际上在我第一看到的时候也是很惊讶,这也说明了世界上没有什么真正完美的东西。

  而auto_ptr指针拷贝的问题,通过管理权转移,被拷贝对象把资源管理权转移给拷贝对象。虽然有些编译器不会引发错误,但是如果在后续代码中,我们需要使用sp1指针做其他事情,这个时候不就发生了内存泄漏了吗?所以有些公司是禁止使用auto_ptr指针的。

  auto_ptr指针底层实现(相似):

代码语言:javascript
代码运行次数:0
复制
namespace SmartPoint
{
	template<class T>
	class auto_ptr
	{
	public:
		auto_ptr(T *ptr)
			:_ptr(ptr)
		{}

		T& operator*()
		{
			return _ptr;
		}

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

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

		~auto_ptr()
		{
			delete _ptr;
		}
	private:
		T* _ptr;
	};
}

  不过呢这里也是因为年代比较久远,属于C++98标准的,而C++11过后,对智能指针进行了大更新,新增了许多实用性指针。而为什么不会像auto_ptr这样犯糊涂了,这是因为这次有了先锋者,C++委员会一部分人作为先锋者,对C++一些语法做了很多的尝试,最终形成的产物就是boost库,而C++11就是吸取boost库中精华的部分。

🚩unique_ptr指针

  既然智能指针存在拷贝这种问题,那么就有一种简单粗暴的方法,直接禁止拷贝,而C++11中确实存在这种指针,叫做unique_ptr指针,其实现如下(相似):

代码语言:javascript
代码运行次数:0
复制
template<class T>
	class unique_ptr
	{
	public:
		unique_ptr(T* ptr)
			:_ptr(ptr)
		{}

		T& operator*()
		{
			return _ptr;
		}

		unique_ptr(unique_ptr<T>& ap) = delete;// 禁用拷贝

		unique_ptr<T>& operator=(const unique_ptr<T>& ap) = delete;// 禁用赋值

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

		~unique_ptr()
		{
			delete _ptr;
		}
	private:
		T* _ptr;
	};

  对其做的改动仅仅是将拷贝构造函数给delete(禁用)掉了,同时,如果将拷贝构造禁用了,那么不可避免,赋值也一定需要禁用,如果不禁用赋值,那么编译器会默认生成一个赋值重载,但是却是浅拷贝,依旧不可解决问题,而一般我们禁用拷贝构造,都会将赋值重载也禁用。

  综上所述,unique_ptr智能指针简单粗暴,适用于不拷贝不赋值的场景。

🚩shared_ptr指针

  如果有些场景刚好需要用得到拷贝与赋值重载的智能指针,C++11提供了shared_ptr指针,其具体使用的方式是 使用引用计数来解决多次释放的问题

  1. shared_ptr在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享
  2. 在对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减一
  3. 如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源
  4. 如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了

这里的引用计数在类内实现起来不能直接使用int类型,因为如果直接使用一个局部变量这样每一个对象都会拿到一份引用计数,而我们的 目的是 每个资源都配有一个引用计数

  那么又有人说,我们将引用计数变为静态的不就行了吗,引用计数也不能使用静态成员变量来实现,静态成员确实让全局看得到,但是当我们新构造了一个对象,也就是新生成了一个对象,它会默认初始化引用计数,将全局的引用计数改为1。如下图所示:

  那能有什么好办法来解决这种问题呢?C++11中,shared_ptr的做法是 将每个对象存一个指向引用计数的指针

代码语言:javascript
代码运行次数:0
复制
template<class T>
class shared_ptr
{
public:
	shared_ptr(T* ptr)
		:_ptr(ptr)
		,_pcount(new int(1))
	{}

	T& operator*()
	{
		return _ptr;
	}

	shared_ptr(shared_ptr<T>& sp)
	{
		_ptr = sp._ptr;
		_pcount = sp._pcount;

		++(*_pcount);// 引用计数++
	}

	int use_count()
	{
		return *_pcount;
	}

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

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

	T* _ptr;
	int* _pcount;
};

  这里就很好的解决了引用计数的问题,当我们使用引用计数的时候,直接通过指针进行操作即可。前面我们说了,如果要禁用拷贝构造,那么通常需要禁用赋值重载。相反,既然我们需要拷贝构造,同样也需要赋值重载:

代码语言:javascript
代码运行次数:0
复制
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
	_ptr = sp._ptr;
	_pcount = sp._pcount;

	++(*pcount);
}

  这种写法是很多人经常写出来的,实际上这种写法是一定不对的,我们来模拟一个场景,sp1, sp2, sp3三个智能指针指向同一份资源,然后在创建一个新指针:

代码语言:javascript
代码运行次数:0
复制
int main()
{
	SmartPoint::shared_ptr<int> sp1(new int(1));
	cout << sp1.use_count() << endl;

	SmartPoint::shared_ptr<int> sp2(sp1);
	cout << sp2.use_count() << endl;

	SmartPoint::shared_ptr<int> sp3(sp2);
	cout << sp3.use_count() << endl;

	SmartPoint::shared_ptr<int> sp4(new int(5));
	sp1 = sp4;

	return 0;
}

  这里sp4已经赋值给了sp1,所以原本sp1, sp2, sp3共用同一份资源,现在只有sp2, sp3共用同一份资源,而sp1与sp4共用同一块资源,那么如此,sp2与sp3的引用计数应与sp1与sp2的引用计数相同,都为2。很明显,我们在赋值引用计数需要先将之前的资源减去,再添加新资源。

  这就会造成原本资源的引用计数多了一个,所以我们在赋值重载之前,需要先将原来资源的引用计数自减, 同时如果是第一个创建的元素,引用计数为1,这时候赋值就不能自减了:

代码语言:javascript
代码运行次数:0
复制
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
	release();
	_ptr = sp._ptr;
	_pcount = sp._pcount;

	++(*_pcount);
	return *this;
}

void release()
{
	if (--(*_pcount) == 0)
	{
		std::cout << "delete shared_ptr success ..." << std::endl;
		delete _ptr;
		delete _pcount;
	}
}

  已经很完善了,但是我们还有一种情况没有考虑到,自己给自己赋值,以及不同对象但是同一资源之间相互赋值。这两种情况都会导致上面步骤白做了。为了避免这种无意义开销,我们可以:

代码语言:javascript
代码运行次数:0
复制
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
	if (_ptr != sp._ptr)
	{
		release();
		_ptr = sp._ptr;
		_pcount = sp._pcount;

		++(*_pcount);
		return *this;
	}	
}

void release()
{
	if (--(*_pcount) == 0)
	{
		std::cout << "delete shared_ptr success ..." << std::endl;
		delete _ptr;
		delete _pcount;
	}
}

  到此,shared_ptr看起来非常完美了,该有的功能也都有了,但是实际上shared_ptr还有一个致命的缺陷,循环引用

代码语言:javascript
代码运行次数:0
复制
#include<iostream>

using namespace std;

struct ListNode {
	ListNode(int val = 0)
		:_val(val)
		, _next(nullptr)
		, _prev(nullptr)
	{}

	int _val;
	std::shared_ptr<ListNode> _next;
	std::shared_ptr<ListNode> _prev;
};

int main()
{
	std::shared_ptr<ListNode> n1(new ListNode(10));
	std::shared_ptr<ListNode> n2(new ListNode(20));
	
	n1->_next = n2;
	n2->_prev = n1;

	return 0;
}

  最后两句将n1指向n2,n2的prev指向n1,这样就会导致循环引用,最终内存泄漏。我们来逐步分析一下为什么会导致循环引用,以及其是什么:

  如果这两条语句只有任何一条语句执行,都不会报错,就如上图,当n1指向n2时,n2的引用计数++, 假设n2生命周期先到,那么n2 先析构,引用计数-1,然后n1的生命周期结束,n1调用自己的析构函数,引用计数减为零,清理n1资源,而n1的_next指向n2, 所以同时会调用n2的析构函数,那么n2引用计数也减为零,n2清理资源。

  但是两条语句放在一起,就会造成内存泄漏:

  如果是上图中的代码,那么都会有一次赋值操作,又因为赋值时自己的引用计数本身就是1,所以不会被减去,而对方的引用计数又会增加,所以他们的引用计数都是2。那么我们就来分析一下,这个代码为什么会内存泄漏:

首先,假设n1的生命周期先到,那么n1调用析构函数,n1的引用计数减一,剩余1。然后n2的生命周期也到了,调用n2的析构函数,n2的引用计数也自减,剩余1。我们知道,只有当引用计数为0的时候才会delete, 否则只减去引用计数此时双方的引用计数都为1,n1要想析构,需要由n2先析构(因为n2._prev管理着n1,当n2 delete时,会自动调用n1的析构函数),而n2要想析构,需要n1先析构(n1._next管理着n2,当n1被delete时,会自动调用n2的析构函数)。所以这个时候双方都没办法调用对方的析构。这就是叫做循环引用的原因右边节点释放 —> _prev析构 —> 左边节点释放 —> _next析构—> 右边节点释放

  这就是shared_ptr在特定场景下的缺陷。虽然引用计数能解决多次释放的问题,但是这种遍历也恰恰是一些场景的坑。

如果不是new出来的对象,我们该如何管理呢?实际上,shared_ptr提供了 删除器功能

  删除器实际上就是使用 仿函数,或者 lambda表达式 来释放指针,比如底层使用malloc建立指针,我们仿函数就需要实现一个free,可用shared_ptr指针进行调用从而清理资源。

代码语言:javascript
代码运行次数:0
复制
template<class T>
struct Free
{
	void operator()(T* ptr)
	{
		std::cout << "free: " << std::endl;
		free(ptr);
	}
};

int main()
{
	std::shared_ptr<int> sp1((int*)malloc(4), Free<int>());
	return 0;
}
🚩weak_ptr(弱)指针

  因为shared_ptr在特定场景下会发生循环引用导致内存泄漏,所以C++11准备了weak_ptr可以避免这个场景,weak_ptr不支持RAII。也就是说weak_ptr不参与资源的管理,不支持T*指针去初始化,也没有析构函数,其作用就像是对shared_ptr特殊场景做特殊管理的智能指针类。

代码语言:javascript
代码运行次数:0
复制
struct ListNode {
	ListNode(int val = 0)
		:_val(val)
	{}

	int _val;
	std::weak_ptr<ListNode> _next;
	std::weak_ptr<ListNode> _prev;
};

int main()
{
	std::shared_ptr<ListNode> n1(new ListNode(10));
	std::shared_ptr<ListNode> n2(new ListNode(20));
	
	n1->_next = n2;
	n2->_prev = n1;

	return 0;
}

  如果ListNode内部使用weak_ptr作为智能指针,就不会导致循环引用的问题了,这不代表我们不使用shared_ptr来处理问题,导致循环引用计数的主因就是shared_ptr的拷贝构造与赋值重载,所以我们在底层其他事情依旧交给shared_ptr指针去干,只不过在赋值与拷贝时,交给weak_ptr来做,可以有效避免循环引用计数。

  以下是weak_ptr实现(类似):

代码语言:javascript
代码运行次数:0
复制
template<class T>
class weak_ptr
{
public:
	weak_ptr()// 不支持指针初始化,所以有空参构造
		:_ptr(nullptr)
	{}

	weak_ptr(shared_ptr<T>& sp)// weak_ptr作为旁观者,不参与资源管理, 这里目的是为了不走shared_ptr的拷贝构造,本质是不让shared_ptr的引用计数增加
	{
		_ptr = sp.get();
	}

	weak_ptr<T>& operator=(const shared_ptr<T>& sp)// 同理,weak_ptr的赋值重载本质也是不让特殊场景的指针走shared_ptr的赋值,从而实现赋值不会增加引用计数
	{
		_ptr = sp.get();
		return *this;
	}

	T& operator*()
	{
		return _ptr;
	}

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

private:
	T* _ptr;
};

  虽然我们说weak_ptr不参与资源的管理,实际上在C++标准库当中,weak_ptr是拥有引用计数的,以便能够跟踪有多少个 weak_ptr 实例指向同一个资源。这是因为 weak_ptr 需要在确定对象是否仍然有效时与 shared_ptr 交互。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2024-10-17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 🚀内存泄漏
    • ✈️内存泄漏分类
    • ✈️如何避免内存泄漏
    • 🚀智能指针的使用及原理
      • ✈️RAII
      • ✈️智能指针拷贝问题
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档