【C++】智能指针

目录

智能指针的使用及原理

RAII

std::auto_ptr

std::unique_ptr

std::shared_ptr


正如我们在异常那篇博客里写到的,

cpp 复制代码
void Func()
{
	int* p1 = new int[10];
    int* p2 = new int[10];//如果p2开辟空间失败,就抛异常,p1内存泄漏
    ...
 
}

我们在Func里刚开始new了一块空间,如果这块空间没有开辟成功,也不影响,直接抛异常到main函数。但是,如果在Func里刚开始开辟了两块空间p1、p2,如果p1开辟成功而p2开辟失败,这样p2抛异常就直接到main函数,p1开好的空间相当于内存泄漏了。

为了解决这样的问题,需要在p1下面再加一层try catch,但是如果我开辟了很多块空间,那就需要加多组try catch,其实,这样的问题可以用智能指针来解决。

智能指针的使用及原理

RAII

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

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

1.不需要显式地释放资源。

2.采用这种方式,对象所需的资源在其生命期内始终保持有效。

我们使用RAII思想去实现一个智能指针:

cpp 复制代码
template<class T>
class SmartPtr
{
public:
	SmartPtr(T* ptr)
		:_ptr(ptr)
	{
	}

	~SmartPtr()
	{
		delete[] _ptr;
		cout << "delete:" << _ptr << endl;
	}

	T* Get()
	{
		return _ptr;
	}

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

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

	T& operator[](size_t i)
	{
		return _ptr[i];
	}
private:
	T* _ptr;
};

int main()
{
	SmartPtr<int> sp1(new int[10]);
	
	return 0;
}

但是,当我们用sp1去拷贝构造sp2时,就会出现问题,因为这里是浅拷贝,sp1和sp2指向同一块空间,当程序结束时,会对同一块空间调用2次析构函数,导致程序崩溃。那么我们当用sp1去拷贝构造sp2,可以深拷贝吗?当然不行!我们的智能指针模拟的是指针的行为,本身就是应该浅拷贝,sp1和sp2应该指向同一块空间啊,那如何解决智能指针拷贝的问题呢?

std::auto_ptr

在C++98中,有auto_ptr,但是这是一个失败的设计,它的思想是管理权转移,

当拷贝之后,如果想再对sp1做一些动作,如赋值,这会导致程序崩溃。

由于这是一个失败的设计,因此很多公司明确禁止使用!

std::unique_ptr

为了解决auto_ptr的问题,unique_ptr的处理方式其实也很简单:

既然auto_ptr那样拷贝会导致悬空,那我直接禁止拷贝构造,所以,当需要用智能指针时,尽量用unique_ptr,没有风险。

但是,不支持拷贝也不能应对所有场景,在有些场景下,还是想拷贝智能指针,所以,又提出了shared_ptr。

关于unique_ptr我们可以参考网站unique_ptr

std::shared_ptr

shared_ptr支持拷贝,它引入了引用计数,之所以叫shared_ptr,是因为可以有多个指针指向同一个对象,

  1. shared_ptr在其内部,给每个资源都维护了着一份计数 ,用来记录该份资源被几个对象共享

  2. 对象被销毁时 (也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减1

  3. 如果引用计数是0 ,就说明自己是最后一个使用该资源的对象,必须释放该资源

  4. 如果不是0 ,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了。

我们可以使用use_count函数得到某份资源的引用计数:

关于shared_ptr其他功能请参考网站shared_ptr

我们除了直接构造一个shared_ptr,还可以使用make_shared,这其实有点类似make_pair,这样的好处是能够减少内存碎片。

在面试时,经常需要手撕一个shared_ptr,那么就来看一下如何实现它:

每份资源应该对应一个引用计数,每个shared_ptr对象都会包含两个指针,一个指针指向这份资源,另一个指针指向这份资源的计数。

cpp 复制代码
namespace ghs
{
	template <class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr)
			:_ptr(ptr)
			,_pcount(new int(1))
		{
		}
		//sp2(sp1)
		shared_ptr(const shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			, _pcount(sp._pcount)
		{
			(*_pcount)++;
		}

		int use_count()
		{
			return *_pcount;
		}
		//sp3 = sp1
		shared_ptr<T>& operator=(const shared_ptr<T>& sp)
		{
			//if (this != &sp) // 不建议这样写,如果sp1和sp2指向同一块空间,
			//这时sp1=sp2,会进去,但实际上没必要
			if(_ptr != sp._ptr)//这样比肯定没问题,不是指向同一块空间,才进去
			{
				this->release();
				_ptr = sp._ptr;
				_pcount = sp._pcount;
				(*_pcount)++;
			}

			return *this;
		}

		void release()
		{
			if (--(*_pcount) == 0)
			{
				//最后一个管理的对象,释放资源
				delete _ptr;
				delete _pcount;
			}
		}

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

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

		~shared_ptr()
		{
			release();
		}

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

从上面的shared_ptr模拟实现中,我们看到每份资源都要有一个引用计数,是在堆上开的一小块内存,当在堆上开大量小块内存时,会出现内存碎片的问题,所以库里的shared_ptr可以传内存池解决内存碎片的问题。

所以,就像上面所说,使用make_shared后,可以将资源和引用计数的空间开在一起。给make_shared一个A(模版参数),在把A的初始化给我,用这个初始化列表构造这个A,然后在A的头上多开4个字节放引用计数,也就是把资源和计数绑到一起,从而减少碎片化。

std::shared_ptr的线程安全问题

我们先来看这样一段代码:

cpp 复制代码
void func(ghs::shared_ptr<std::list<int>> sp, int n)
{
	for (int i = 0; i < n; i++)
	{
		sp->push_back(i);
	}
}

int main()
{
	ghs::shared_ptr<std::list<int>> sp1(new std::list<int>);

	std::thread t1(func, sp1, 10000);
	std::thread t2(func, sp1, 20000);

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

	std::cout << sp1->size();

	return 0;
}

创建一个智能指针指向一个链表,并创建两个线程都调用func函数,func函数负责向链表里插入数据,当我们运行这个程序时,发现程序崩溃了,

所以,多线程在访问sp所指向的链表这份公共资源的时候不是线程安全的,因此,我们可以在访问链表时加锁:

这样就不会崩溃了,每次都能正常运行。

我们再来看这样一个问题:

在func里对sp进行拷贝,这时运行结果就会出现异常,这是什么原因呢?明明我们在插入数据时进行了加锁,但是运行结果还会异常,那这可能就说明上面多调的拷贝构造不是线程安全的!实际上,我们来观察一下各个位置的引用计数,发现引用计数这里已经出现了异常,

这是因为,线程1和线程2都在调用自己的拷贝构造,在各自调用拷贝构造时,会让引用计数+1,声明周期到了引用计数-1,但是多线程对这个引用计数+-不是线程安全的,两个线程拷贝智能指针要++计数,智能指针析构要--计数,因此,要保证引用计数的线程安全。

那么为了保证引用计数是线程安全的,又两种方法:

1)给每个资源加一个锁指针,在构造函数中初始化这把锁,在引用计数++--时调用这把锁。

2)直接把引用计数设为atomic(包含atomic头文件)

因此,我们需要知道,智能指针(库中的)对象本身拷贝是线程安全的,底层引用计数加减是线程安全的,但是智能指针指向的资源(如链表)访问不是线程安全的

std::shared_ptr循环引用

我们来看这样一组对比:

下面把注释放开:

发现并没有调用到Node的析构函数,也就是造成了内存泄漏。我们来看这样几种情况:

1)两个节点没有相互指向。

2)前一个节点的_next指向后一个节点。

p2后定义先析构,引用计数由2变成1,然后p1再析构,引用计数由1变成2,p1指向Node中的_next在p1析构后就释放了,这就导致p2的引用计数-1,变成0,就把p2释放了,然后回头把p1也释放了。

3)前一个节点的_next指向后一个节点,后一个节点的_prev指向前一个节点。

p2后定义先析构,p2先析构,其引用计数-1,变成1,p1再析构,p1的引用计数也减到1。然后,右边节点在在左边节点析构后,_next指向的后一个节点引用计数才减到0;左边节点在右边节点析构后,_prev指向的前一个节点的引用计数才减到0,相当于前后两个节点互相制约着,谁都不撒手,这就是循环引用 !循环引用的释放逻辑是一个死循环!

为了解决循环引用这样的问题,引入了weak_ptr,

weak_ptr没有采用RAII的思想,

C++中要求,在出现循环引用时,需要把_next和_prev改成weak_ptr,改成weak_ptr就可以解决循环引用问题的原因是赋值或拷贝时,只指向资源,但不增加shared_ptr的引用计数,但是weak_ptr可以访问到shared_ptr的引用计数,如下图:

定制删除器

我们来看这样一个例子:

但是如果把<>参数换成A[]就没问题,

但如果我们让shared_ptr指向一个文件,那就会崩溃了:

所以我们需要搞一个定制删除器,这个定制删除器通过构造函数的参数传导,

如果不传D del这个参数,就用delete去删,如果传了del,就用del去删,

相关推荐
软件开发技术局26 分钟前
撕碎QT面具(8):对控件采用自动增加函数(转到槽)的方式,发现函数不能被调用的解决方案
开发语言·qt
周杰伦fans2 小时前
C#中修饰符
开发语言·c#
yngsqq2 小时前
c# —— StringBuilder 类
java·开发语言
赔罪2 小时前
Python 高级特性-切片
开发语言·python
专注VB编程开发20年2 小时前
除了 EasyXLS,加载和显示.xlsx 格式的excel表格,并支持单元格背景色、边框线颜色和粗细等格式化特性
c++·windows·excel·mfc·xlsx
子豪-中国机器人3 小时前
2月17日c语言框架
c语言·开发语言
夏天的阳光吖3 小时前
C++蓝桥杯基础篇(四)
开发语言·c++·蓝桥杯
oioihoii4 小时前
C++17 中的 std::to_chars 和 std::from_chars:高效且安全的字符串转换工具
开发语言·c++
张胤尘4 小时前
C/C++ | 每日一练 (2)
c语言·c++·面试
秋窗74 小时前
Mac下Python版本管理,适用于pyenv不起作用的情况
开发语言·python·macos