【C++】智能指针

1. 为什么需要智能指针

程序员自己控制的内存,在一些特殊场景之下,有可能发生内存泄漏,智能指针则可以帮助对内存进行管理比如程序员忘记释放内存,发生异常安全问题(因为抛异常会影响执行流),可能会导致内存泄漏


2.内存泄露

什么是内存泄漏

内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况,内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费

例如:

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

内存泄漏的危害

内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死

void MemoryLeaks()
{
    // 1.内存申请了忘记释放
    int* p1 = (int*)malloc(sizeof(int));
    int* p2 = new int;
    
    // 2.可能存在异常安全问题
    int* p3 = new int[10];
    Func(); // 如果这里Func函数抛异常,会导致delete[] p3未执行,导致p3没被释放.
    delete[] p3;
}

内存泄漏分类

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

1)堆内存泄漏(Heap leak)

  • 堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉
  • 假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak

2)系统资源泄漏

  • 指程序使用系统分配的资源,比如: 套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定

如何避免内存泄漏

1)工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放

  • ps:这个是理想状态 但是如果碰上异常时,就算注意释放了还是可能会出问题,需要智能指针来管理才有保证

2)采用RAII思想或者智能指针来管理资源

3)有些公司内部规范使用内部实现的私有内存管理库,这套库自带内存泄漏检测的功能选项

4)出问题了使用内存泄漏工具检测 ps:不过很多工具都不够靠谱,或者收费昂贵


总结一下:内存泄漏非常常见,解决方案分为两种:

1、事前预防型,如智能指针等

2、事后查错型,如泄漏检测工具


3.智能指针的使用及原理

RAII

RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术, 在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源

借此,我们实际上把管理一份资源的责任托管给了一个对象,这种做法有两大好处:

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

模拟实现

使用RAII思想设计的SmartPtr类

// 使用RAII思想设计的SmartPtr类
template<class T>
class SmartPtr
{
public:
	SmartPtr(T* ptr = nullptr)
		:_ptr(ptr)
	{}
    //重载operator->和operator* 像指针一样使用
	T* operator->()
	{
		return _ptr;
	}
	T& operator*()
	{
		return *_ptr;
	}
	~SmartPtr()
	{
		if (_ptr) 
        {
            cout << "delete" << _ptr << endl;
            delete _ptr;
        }
	}
private:
	T* _ptr;	//管理一个指针
};

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

struct Date
{
	int _year;
	int _month;
	int _day;
};
int div()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除0错误");	//invalid_argument是标准库的异常,用exception捕获

	return a / b;
}
void func()
{
	SmartPtr<int> sp1(new int);
	SmartPtr<Date> sp2(new Date);
	
    //像指针一样使用
	*sp1 = 10;
	cout << *sp1 << endl;//10
	sp2->_day = 1;
	sp2->_year = 1;
	sp2->_month= 1;
	cout << sp2->_year << "-" << sp2->_month << "-" << sp2->_day << endl;

	cout << div() << endl;//无论是否抛异常,都能正确释放空间资源
}
int main()
{
	try
	{
		func();//func可能抛异常
	}
	catch(const exception& e)
	{
		cout << e.what() << endl; //打印抛了什么异常  what:发生了什么
	}
	return 0;
}

image-20220801203155874

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

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

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


总结一下智能指针的原理:

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

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

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

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

int main()
{
	SmartPtr<int> sp1(new int);
	//拷贝构造
	SmartPtr<int> sp3;
	sp3 = sp1;
	//赋值重载
	SmartPtr<int> sp2  = sp1; 

	return 0;
}

image-20220801203437016

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

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


C++库中的智能指针都定义在memory头文件中

std::auto_ptr

管理权转移

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

#include<memory>
int main()
{
	auto_ptr<int> sp1(new int);
    //auto_ptr的问题:当对象拷贝或者赋值后,前面的对象就悬空了
	auto_ptr<int> sp2(sp1); // 管理权转移,sp1就变成空指针了
	*sp2 = 10;
	cout << *sp2 << endl;//10
	cout << *sp1 << endl;//崩溃,sp1已经变成空指针了!
	return 0;
}

由于拷贝和赋值会导致资源管理权转移,一个对象的管理权转移后也就意味着该对象不能再用对原来管理的资源进行访问了,再次解引用访问这个资源就会造成对空指针的访问,程序就会崩溃

由于auto_ptr 的拷贝和赋值拷贝都会出现资源转移的情况,所以用的时候很容易出错,因此实际之中,一般不允许使用


模拟实现

auto_ptr的模拟实现 实现原理:管理权转移的思想

  1. 在构造函数中获取资源,在析构函数中释放资源,利用对象的生命周期来控制资源.
  2. *->运算符进行重载,使auto_ptr对象具有指针一样的行为.
  3. 在拷贝构造函数中,用传入对象管理的资源来构造当前对象,并将传入对象管理资源的指针置空.
  4. 在拷贝赋值函数中,先将当前对象管理的资源释放.然后再接管传入对象管理的资源.最后将传入对象管理资源的指针置空.
namespace Mango	//为了和库里面的做区分
{
template<class T>
class auto_ptr
{
public:
	auto_ptr(T* ptr =nullptr)
		_ptr(ptr)
	{}
	
	//sp1(sp2)
    // 一旦发生拷贝,就将sp中资源转移到当前对象中,然后另sp与其所管理资源断开联系
    //这样就解决了一块空间被多个对象使用而造成程序奔溃问题,一块空间只关联一个对象
	auto_ptr(const auto_ptr<T>& sp)
		:_ptr(sp._ptr)
	{
		sp._ptr = nullptr;//管理权转移,原来的对象悬空
	}

	//sp1 = sp2
	auto_ptr<T>& operator=(const auto_ptr<T>& sp)
	{
		if (this != &sp)//防止sp1 = sp1自己给自己赋值的情况
		{
			//1.释放当前对象中资源
			if (_ptr) delete _ptr;
			//2.转移是sp中资源到当前对象中
			_ptr = sp._ptr;
			//3.原来的对象悬空
			sp._ptr = nullptr;
		}
		return *this;
	}

	// 像指针一样使用
	T& operator*()
	{
		return *_ptr;
	}

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

	~auto_ptr()
	{
		if (_ptr)
		{
			cout << "delete" << _ptr << endl;
			delete _ptr;
		}
	}
private:
	T* _ptr;//管理一个原生指针
};
}

std::unique_ptr

防拷贝

既然auto_ptr的拷贝和赋值会发生资源转移,那么unique_ptr:就禁止拷贝和赋值的发生,即在拷贝和赋值成员函数后面加上delete(C++11的处理方式)

看库里面的:

image-20220801211810967


模拟实现

unique_ptr的模拟实现 实现原理:简单粗暴的防拷贝

  1. 在构造函数中获取资源,在析构函数中释放资源,利用对象的生命周期来控制资源.
  2. *->运算符进行重载,使unique_ptr对象具有指针一样的行为.
  3. 用C++98的方式将拷贝构造函数和拷贝赋值函数声明为私有,或者用C++11的方式在这两个函数后面加上=delete, 防止外部进行拷贝和赋值
// unique_ptr/scoped_ptr   守卫指针!
// 原理:简单粗暴 -- 防拷贝
namespace Mango
{
	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;
		}

		//方法1: C++98防拷贝的方式:只声明不实现+声明成私有 
		//方法2:用delete修饰	-C++11
		unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;
		unique_ptr(const unique_ptr<T>& sp) = delete; //防止拷贝!

	private:
		T* _ptr;	//管理的资源
	};
}

缺陷: 如果有需要拷贝的场景,就无法使用了 需要拷贝场景怎么办?使用shared_ptr


std::shared_ptr

引用计数

auto_ptr 的拷贝和赋值存在不安全因素,unnique_ptr直接禁止了拷贝和赋值shared_ptr的原理:是通过引用计数的方式来实现多个shared_ptr对象之间共享资源,解决智能指针的拷贝问题

看看库里面的:

int main()
{
	shared_ptr<int> sp1(new int(0));
	shared_ptr<int> sp2 = sp1;//拷贝构造
	*sp2 = 101;
	cout << *sp1 << endl;//101

	shared_ptr<int> sp3(new int(20));
	sp1 = sp3;//赋值
	cout << *sp1 << " " << *sp2 << " " << *sp3;//20 101 20
	return 0;
}
  • 每一个被管理的资源都有一个对应的引用计数,通过这个引用计数记录着当前有多少个对象在管理着这块资源,
  • 当新增一个对象管理这块资源时则将该资源对应的引用计数进行++,当一个对象不再管理这块资源或该对象被析构时则将该资源对应的引用计数进行--
  • 当一个资源的引用计数减为0时说明已经没有对象在管理这块资源了,这时就可以将该资源进行释放了

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


模拟实现

shared_ptr的模拟实现

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

总结起来就是:

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

namespace Mango
{
template<class T>
class shared_ptr
{
public:
	shared_ptr(T* ptr = nullptr)	//不能加const,否则赋值给_ptr相当于权限放大,报错
		:_ptr(ptr)
		,_pRefCount(new int(1)) //构造的时候,该资源只被一个对象共享
	{}

	//拷贝构造 sp1(sp2)
	shared_ptr(const shared_ptr<T>& sp)	
		:_ptr(sp._ptr)//指向同一块资源
		,_pRefCount(sp._pRefCount)//指向同一个计数空间
	{
		//_pRefCount:计数空间		*_pRefCount: 多少个对象共享这块空间(资源)

		(*_pRefCount)++; //多了一个共享该资源的对象,计数++, 
	}

	//赋值 sp1 = sp2
	shared_ptr<T>& operator=(const shared_ptr<T>& sp)
	{
		if (_ptr != sp._ptr)//判断是不是管理的同一份资源
		{
			//1.现在_ptr要管理另外一份资源了,先处理原来资源的计数问题
			//如果--之后为0,(注意是前置--) 说明_ptr是最后一个指向原来的资源的对象
			//现在_ptr要指向新资源了,释放原来的资源和计数空间
			if (--(*_pRefCount) == 0)
			{
				delete _ptr;
				delete _pRefCount;
			}
			//2.赋值  共享管理新对象的资源,并增加引用计数
			_ptr = sp._ptr;//指向新资源
			_pRefCount = sp._pRefCount;//指向同一个计数空间
			(*_pRefCount)++;//多了一个指向该资源的对象, 计数++
		}
		return *this;
	}
    //获取引用计数
    int use_count()
    {
        return *_pRefCount;
    }
	~shared_ptr()
	{
		//引用计数减1,如果减到0,说明自己是最后一个管理该资源的对象
		//就释放这块资源和计数空间
		if (--(*_pRefCount) == 0 && _ptr)
		{
			cout << "delete:" << _ptr << endl;
			delete _ptr;
			delete _pRefCount;

			_ptr = nullptr;
			_pRefCount = nullptr;
		}
	}
	//像指针一样使用
	T& operator*()
	{
		return *_ptr;
	}

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

private:
	T* _ptr;	//管理的资源
	//引用计数,多少个指针指向这块空间,就说明该份资源被几个对象共享
	int* _pRefCount;	//管理的资源对应的引用计数
};
}

关于上面operator=的另类写法:

image-20221020202603070

析构函数是可以显示调用的!所以可以写成下述的形式:

shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
    if (_ptr != sp._ptr)//判断是不是管理的同一份资源
    {
		this->~shared_ptr();//显示调用析构函数
        _ptr = sp._ptr;//指向新资源
        _pRefCount = sp._pRefCount;//指向同一个计数空间
        (*_pRefCount)++;//多了一个指向该资源的对象, 计数++
    }
    return *this;
}

由于很多地方都用到这个引用计数++和–的逻辑,所以我们可以封装成两个函数:ReleaseRef和AddRef


关于引用变量的位置

问题:为什么引用计数需要存放在堆区,为什么不能是静态的成员变量,普通的成员变量?

case1: shared_ptr中的引用计数count不能单纯的定义成一个int类型的成员变量,因为这就意味着每个shared_ptr对象都有一个自己的count成员变量,是不可行的!

image-20221020161145901

当多个对象要管理同一个资源时,这几个对象应该用到的是同一个引用计数


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

image-20221020161404834


case3:如果将shared_ptr中的引用计数count定义成一个指针

  • 当一个资源第一次被管理时就在堆区开辟一块空间用于存储其对应的引用计数
  • 如果有其他对象也想要管理这个资源,那么除了将这个资源给它之外,还需要把这个引用计数也给它, 这时管理同一个资源的多个对象访问到的就是同一个引用计数
  • 而管理不同资源的对象访问到的就是不同的引用计数了,相当于将各个资源与其对应的引用计数进行了绑定

image-20221020161723735

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


线程安全问题

shared_ptr的线程安全问题

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


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

void func(Mango::shared_ptr<int>& sp, size_t n) //对同一个shared_ptr对象进行n次拷贝,所以传引用
{
	for (size_t i = 0; i < n; i++)
	{
		Mango::shared_ptr<int> copy(sp);//拷贝构造sp对象
	}
}
int main()
{
	Mango::shared_ptr<int> p(new int(0));

	const size_t n = 1000;
	thread t1(func, std::ref(p), n); //注意:线程中的传引用要加ref!!!
	thread t2(func, std::ref(p), n);

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

	cout << p.use_count() << endl; //获取引用计数 预期:1

	return 0;
}

如果share_ptr不加锁在多线程的情况下是不安全的, 在这个过程中两个线程会不断对引用计数进行自增和自减操作,理论上最终两个线程执行完毕后引用计数的值应该是1,因为拷贝出来的对象都被销毁了,只剩下最初的shared_ptr对象还在管理这个资源

但每次运行程序得到引用计数的值可能都是不一样的,甚至可能出现崩溃,根本原因就是**因为对引用计数的自增和自减不是原子操作,**可能两个线程对引用计数空间进行了两次自减,但是最终只减了1次或者引用计数原来是1,++了两次,可能还是2.这样引用计数就错乱了,会导致资源未释放或者程序崩溃的问题

  • 总结起来就是: 多个线程多同一个智能指针对象

如何解决呢?

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

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

模拟实现改进

// 引用计数支持多个拷贝管理同一个资源,最后一个析构对象释放资源
namespace Mango
{
	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr) //第一次指向新资源,计数为1 配一把锁
			:_ptr(ptr), _pRefCount(new int(1)), _pmtx(new mutex)   
		{}

		shared_ptr(const shared_ptr<T>& sp) //指向同一个计数空间,同一个锁的指针
			:_ptr(sp._ptr), _pRefCount(sp._pRefCount), _pmtx(sp._pmtx)
		{
			AddRef();
		}

		void ReleaseRef() //释放资源+计数空间
		{
			_pmtx->lock(); //加锁
			bool flag = false;
			//减减计数 如果减到0就释放这块资源和计数空间
			if (--(*_pRefCount) == 0 && _ptr)
			{
				cout << "delete:" << _ptr << endl;
				delete _ptr;
				delete _pRefCount;
 
				flag = true; //当前线程是最后一个管理这个资源的对象 
			}
 
			_pmtx->unlock(); //解锁

			if (flag == true)
			{
				delete _pmtx; //释放这把锁
			}
		}

		void AddRef() //增加引用计数
		{
			_pmtx->lock(); //加锁
			++(*_pRefCount);
			_pmtx->unlock();//解锁
		}

		shared_ptr<T>& operator=(const shared_ptr<T>& sp)
		{
			//if (this != &sp)  //判断是否是自己给自己赋值
			if (_ptr != sp._ptr) //优化:判断二者是否指向同一块资源
			{
				ReleaseRef();

				_ptr = sp._ptr;//指向新资源
				_pRefCount = sp._pRefCount;//指向新资源对应的引用空间
				_pmtx = sp._pmtx;//指向同一把锁
				AddRef();
			}

			return *this;
		}

		int use_count() //查看这个计数是多少
		{
			return *_pRefCount;
		}

		~shared_ptr()
		{
			ReleaseRef();
		}

		// 像指针一样使用
		T& operator*()
		{
			return *_ptr;
		}

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

		T* get() const //返回原生指针,用于获取其管理的资源
		{
			return _ptr;
		}
	private:
		T* _ptr;
		int* _pRefCount;
		 //只要管理这个资源的对象都应该访问的是同一把锁  所以用指针!
		 //托管一个资源的时候,配一个引用计数,同时配一个锁
		mutex* _pmtx;
	};
}

需要注意的是:

  • 在ReleaseRef函数中,当引用计数被减为0时需要释放互斥锁资源,但不能在临界区中释放互斥锁,因为后面还需要进行解锁操作,而且引用计数没有为0的时候,不能是否这个互斥锁, 因此代码中借助了一个flag变量,通过flag变量来判断解锁后是否需要释放互斥锁资源

问:flag变量是线程安全的吗?

是的!因为flag是局部变量,在加锁逻辑的内部,在加锁期间,其他线程不能访问这个flag变量


上面为什么使用_ptr != sp._ptr更好,而不是this!=&s

因为可能存在这种情况:

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

针对sp1赋值给sp1,此时使用this!=&s可以防止它进入if内部执行,直接返回, 而针对sp1 = sp2这种情况是避免不了的, 二者已经指向的是同一块资源,但是如果使用this!=&s,此时会进入if内部,先–计数,最后又++回来, 多次一举! 所以更好的就是比较赋值的shared_ptr对象是否指向的是同一块资源


例子2: 在上面已经对计数变量加锁的基础上:

struct Date
{
    int _year = 0;
    int _month = 0;
    int _day = 0;
};

void SharePtrFunc(Mango::shared_ptr<Date>& sp, size_t n, mutex& mtx)
{
    cout << sp.get() << endl;

    for (size_t i = 0; i < n; ++i)
    {
        // 这里智能指针拷贝会++计数,智能指针析构会--计数,这里是线程安全的。
        Mango::shared_ptr<Date> copy(sp);//拷贝构造
        // 这里智能指针访问管理的资源,不是线程安全的
        //所以我们看看这些值两个线程++了2n次,但是最终看到的结果,并一定是加了2n
        copy->_year++;
        copy->_month++;
        copy->_day++;
    }
}
int main()
{
	Mango::shared_ptr<Date> p(new Date);
	cout << p.get() << endl;
	const size_t n = 100000;
	mutex mtx;
	thread t1(SharePtrFunc, std::ref(p), n, std::ref(mtx));
	thread t2(SharePtrFunc, std::ref(p), n, std::ref(mtx));

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

	cout << p->_year << endl;
	cout << p->_month << endl;
	cout << p->_day << endl;

	cout << p.use_count() << endl;

	return 0;
}

我们可以发现:对Date的成员++ 了2n次,但是最终看到的结果,并不是加了2n ,但是因为我们对引用计数加锁了,所以我们可以保证引用计数是正确的

shared_ptr只需要保证引用计数的线程安全问题,而不需要保证管理的资源的线程安全问题,就像原生指针管理一块内存空间一样,原生指针只需要指向这块空间,而这块空间的线程安全问题应该由这块空间的操作者来保证,

  • 指向的资源不归智能指针管理,是用的人自己控制的 但是要管引用计数的线程安全

image-20221020200534965

原因就是因为:这里智能指针访问管理的资源,不是线程安全的,为了保证线程安全,在访问智能指针管理的资源的时候还要手动加锁

void SharePtrFunc(bit::shared_ptr<Date>& sp, size_t n, mutex& mtx)
{
    cout << sp.get() << endl;

    for (size_t i = 0; i < n; ++i)
    {
        // 这里智能指针拷贝会++计数,智能指针析构会--计数,这里是线程安全的。
        Mango::shared_ptr<Date> copy(sp);//拷贝构造
        unique_lock<mutex> lk(mtx); //加锁保护
        copy->_year++;
        copy->_month++;
        copy->_day++;
    }
}

如果后面还有语句,但是我们指向保护这个访问这个资源的时候是安全的,怎么做呢?

  • 使用局部域!**锁只保护这一个局部域,**出了局部域就解锁了
for (size_t i = 0; i < n; ++i)
{
    // 这里智能指针拷贝会++计数,智能指针析构会--计数,这里是线程安全的。
    Mango::shared_ptr<Date> copy(sp);//拷贝构造
    {
        unique_lock<mutex> lk(mtx); //加锁保护,这个局部域
        copy->_year++;
        copy->_month++;
        copy->_day++;
    } //解锁
    
    //....其它语句
}

当然也可以手动解锁 lt.unlock()


shared_ptr智能指针是线程安全的吗

  • 是的,引用计数的加减是加锁保护的,但是智能指针管理的资源(,其指向的资源不是线程安全的),需要使用者管
  • 指向堆上资源的线程安全问题是访问的人处理的,智能指针不管,也管不了,引用计数的线程安全问题,是智能指针要处理的

shared_ptr的循环引用问题

shared_ptr的循环引用问题在一些特定的场景下才会产生,比如定义如下的结点类,并在结点类的析构函数中打印一句提示语句,便于判断结点是否正确释放

struct ListNode
{
	ListNode* _next;
	ListNode* _prev;
	int _val;
	~ListNode()
	{
		cout << "~ListNode()" << endl;
	}
};

现在以new的方式在堆上构建两个结点,并将这两个结点首尾连接起来,最后以delete的方式释放这两个结点

int main()
{
	ListNode* node1 = new ListNode;
	ListNode* node2 = new ListNode;

	node1->_next = node2;
	node2->_prev = node1;
	delete node1;
	delete node2;
	return 0;
}

上述程序是没有问题的,两个结点都能够正确释放,为了防止程序中途返回或抛异常等原因导致结点未被释放,我们将这两个结点分别交给两个shared_ptr对象进行管理, 这时为了让连接节点时的赋值操作能够执行,就需要把ListNode类中的next和prev成员变量的类型也改为shared_ptr类型,

  • 原因: shared_ptr不支持隐式类型转化

image-20221020165106142

struct ListNode
{
	std::shared_ptr<ListNode> _next;
	std::shared_ptr<ListNode> _prev;
	int _val;
	~ListNode()
	{
		cout << "~ListNode()" << endl;
	}
};
int main()
{
    //注意:shared_ptr不支持隐式类型转化  
	//std::shared_ptr<ListNode> n1 = new ListNode; 是不行滴!
	std::shared_ptr<ListNode> node1(new ListNode);
	std::shared_ptr<ListNode> node2(new ListNode);
	//循环引用问题
	node1->_next = node2;
	node2->_prev = node1;

	return 0;
}

这时程序运行结束后两个结点都没有被释放,但如果去掉连接结点时的两句代码中的任意一句,那么这两个结点就都能够正确释放**,根本原因就是因为这两句连接结点的代码导致了循环引用**


原因解释:

1)当以new的方式申请到两个ListNode结点并交给两个智能指针管理后,这两个资源对应的引用计数都被加到了1

image-20221020165635563

2)将这两个结点连接起来后,资源1当中的next成员与node2一同管理资源2.资源2中的prev成员与node1一同管理资源1.此时这两个资源对应的引用计数都被加到了2

image-20221020165758680

3)出了main函数的作用域后,node1和node2的生命周期就结束了,因此这两个资源对应的引用计数最终都减到了1

image-20221020165923293

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

  • 当资源对应的引用计数减为0时对应的资源才会被释放,因此资源1的释放取决于资源2当中的prev成员,而资源2的释放取决于资源1当中的next成员, 而资源1当中的next成员的释放又取决于资源1.资源2当中的prev成员的释放又取决于资源2.于是这就变成了一个死循环,最终导致资源无法释放,

为什么只有一条连接的时候,这两个节点就能正确释放呢?

如果连接结点时只进行一个连接操作,那么当node1和node2的生命周期结束时,就会有一个资源对应的引用计数被减为0,此时这个资源就会被释放,这个释放后另一个资源的引用计数也会被减为0,最终两个资源就都被释放了,这就是为什么只进行一个连接操作时这两个结点就都能够正确释放的原因

  • 例如: 只有node1->_next = node2的时候

image-20221020201443223

先定义node1然后node2后定义,所以node2先析构, node2这块空间的引用引用计数减到1 ,然后node1再析构,node1这块空间的引用计数减到0, 然后释放node1节点的空间,node1节点调用它的析构函数成员也要进行析构,然后把node2的引用计数减到0,node2的空间就被销毁了, node1的空间也被销毁了


std::weak_ptr

解决循环引用问题

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

image-20221020171355312

weak_ptr支持用shared_ptr对象来构造weak_ptr对象,构造出来的weak_ptr对象与shared_ptr对象管理同一个资源,但不会增加这块资源对应的引用计数

  • weak_ptr不是常规意义的智能指针,它没有接收一个原生指针的构造函数,也不符合RAII,它支持拷贝构造

其中weak_ptr也有use_count的成员函数:

image-20221020203358371


上述代码解决循环引用的方法:

将ListNode中的next和prev成员的类型换成weak_ptr就不会导致循环引用问题了,此时当node1和node2生命周期结束时两个资源对应的引用计数就都会被减为0,进而释放这两个结点的资源

struct ListNode
{
    //这里weak_ptr的next和prev对象可以访问指定节点资源,但是不参与节点资源释放管理,其实就是不增加计数
	std::weak_ptr<ListNode> _next;
	std::weak_ptr<ListNode> _prev;
	int _val;
	~ListNode()
	{
		cout << "~ListNode()" << endl;
	}
};
int main()
{
	std::shared_ptr<ListNode> node1(new ListNode);//会调用weak_ptr的无参的构造函数
	std::shared_ptr<ListNode> node2(new ListNode);//会调用weak_ptr的无参的构造函数

	cout << node1.use_count() << endl;//1
	cout << node2.use_count() << endl;//1
    
	node1->_next = node2;
	node2->_prev = node1;
    
	cout << node1.use_count() << endl;//1
	cout << node2.use_count() << endl;//1
	return 0;
}

通过use_count获取这两个资源对应的引用计数就会发现,在结点连接前后这两个资源对应的引用计数就是1,根本原因就是weak_ptr不会增加管理的资源对应的引用计数


模拟实现

weak_ptr的模拟实现

  1. 提供一个无参的构造函数,比如刚才new ListNode时就会调用weak_ptr的无参的构造函数
  2. 支持用shared_ptr对象拷贝构造weak_ptr对象,构造时获取shared_ptr对象管理的资源
  3. 支持用shared_ptr对象拷贝赋值给weak_ptr对象,赋值时获取shared_ptr对象管理的资源
  4. *->运算符进行重载,使weak_ptr对象具有指针一样的行为
  5. shared_ptr还会提供一个get函数,用于获取其管理的资源,
template<class T>
class weak_ptr
{
public:
    weak_ptr()	:_ptr(nullptr)  //无参构造
    {}
    
	//sp.get()返回原生指针
    weak_ptr(const shared_ptr<T>& sp)	:_ptr(sp.get())		//用shared_ptr去去构造
    {}
    
    weak_ptr<T>& operator=(const shared_ptr<T>& sp) 
    {
        _ptr = sp.get();//指向同一块资源,参与资源的访问,但是不参与资源管理(引用计数)
        return *this;
    }
    //像指针一样使用
    T& operator*()
    {
        return *_ptr;
    }
    T* operator->()
    {
        return _ptr;
    }
    T* get() const  //返回原生指针,用于获取其管理的资源
    {
        return _ptr;
    }
private:
    T* _ptr;//原生指针
};

4.定制删除器

关于delete和new的补充

class A
{
public:
	~A()
	{
		cout << "~A()" << endl;
	}
private:
	int _a1 = 0;
	int _a2 = 0;
};
int main()
{
	std::unique_ptr<A> sp1(new A);
	std::unique_ptr<A> sp2(new A[10]);//如果写了析构函数,就会崩溃!
	return 0;

  • 如果A的析构函数没有显示写,这里不会报错也不会有内存泄漏,原因: new底层是用malloc开辟空间,delete底层是free,而free不管你开辟多少个空间,开多少字节释放多少字节

    • A* p = new A[10];
      delete p; //A没有写析构函数,这里没报错!也没有内存泄漏!
      
  • 但是如果A的析构函数显示写,这里就会出问题,原因 : new的时候如果有析构函数的情况下,假设一个对象是4字节,10个对象是40个字节,它不会只开40个字节,它还要在头部多开4个字节去存对象的个数,delete的时候,delete[]没有指明delete几个对象,它去头部取那4个字节,发现是10,就会调用10次析构函数,如果没有显示写析构函数,就不会多开4个字节去存个数

定制删除器的用法

当智能指针对象的生命周期结束时,所有的智能指针默认都是以delete的方式将资源释放, 因为智能指针并不是只管理以new方式申请的内存空间,智能指针管理的也可能是以new[]的方式申请到的空间,或管理的是一个文件指针

例如:错误例子

struct ListNode
{
	ListNode* _next;
	ListNode* _prev;
	int _val;
	~ListNode()
	{
		cout << "~ListNode()" << endl;
	}
};
int main()
{
	std::shared_ptr<ListNode> sp(new ListNode);//没问题
	std::shared_ptr<ListNode> sp1(new ListNode[10]);   //error
	std::shared_ptr<FILE> sp2(fopen("test.cpp", "r")); //error
	return 0;
}

这时当智能指针对象的生命周期结束时,再以delete的方式释放管理的资源就会导致程序崩溃,因为以new[]的方式申请到的内存空间必须以delete[]的方式进行释放,而文件指针必须通过调用fclose函数进行释放


这时就需要用到定制删除器来控制释放资源的方式.C++标准库中的shared_ptr提供了如下构造函数:

template <class U, class D>
shared_ptr (U* p, D del);

参数说明

  • p:需要让智能指针管理的资源
  • del:删除器,这个删除器是一个可调用对象,比如函数指针,仿函数,lambda表达式以及被包装器包装后的可调用对象

当shared_ptr对象的生命周期结束时就会调用传入的删除器完成资源的释放,调用该删除器时会将shared_ptr管理的资源作为参数进行传入

因此当智能指针管理的资源不是以new的方式申请到的内存空间,就需要在构造智能指针对象时传入定制的删除器

template<class T>
struct DelArr //删除器
{
	void operator()(const T* ptr)
	{
		cout << "delete[]: " << ptr << endl;
		delete[] ptr;
	}
};
int main()
{
	std::shared_ptr<ListNode> sp1(new ListNode[10], DelArr<ListNode>());
    //lambda表达式作为删除器
	std::shared_ptr<FILE> sp2(fopen("test.cpp", "r"), [](FILE* ptr){
		cout << "fclose: " << ptr << endl;
		fclose(ptr);
	});
	return 0;
}

定制删除器的模拟实现

定制删除器的实现问题:

  • C++标准库中实现shared_ptr时是分成了很多个类的,因此C++标准库中可以将删除器的类型设置为构造函数的模板参数,然后将删除器的类型在各个类之间进行传递,
  • 但我们是直接用一个类来模拟实现shared_ptr的,因此不能将删除器的类型设置为构造函数的模板参数,因为删除器不是在构造函数中调用的,而是需要在ReleaseRef函数中进行调用,因此势必需要用一个成员变量将删除器保存下来,而在定义这个成员变量时就需要指定删除器的类型,因此这里模拟实现的时候不能将删除器的类型设置为构造函数的模板参数,
  • 要在当前模拟实现的shared_ptr的基础上支持定制删除器,就只能给shared_ptr类再增加一个模板参数,在构造shared_ptr对象时就需要指定删除器的类型,然后增加一个支持传入删除器的构造函数,在构造对象时将删除器保存下来,在需要释放资源的时候调用该删除器进行释放即可,最好在设置一个默认的删除器,如果用户定义shared_ptr对象时不传入删除器,则默认以delete的方式释放资源,
namespace cl
{
	//默认的删除器
	template<class T>
	struct Delete
	{
		void operator()(const T* ptr)
		{
			delete ptr;
		}
	};
	template<class T, class D = Delete<T>>
	class shared_ptr
	{
	private:
		void ReleaseRef()
		{
			_pmutex->lock();
			bool flag = false;
			if (--(*_pcount) == 0) //将管理的资源对应的引用计数--
			{
				if (_ptr != nullptr)
				{
					cout << "delete: " << _ptr << endl;
					_del(_ptr); //使用定制删除器释放资源
					_ptr = nullptr;
				}
				delete _pcount;
				_pcount = nullptr;
				flag = true;
			}
			_pmutex->unlock();
			if (flag == true)
			{
				delete _pmutex;
			}
		}
	public:
		shared_ptr(T* ptr, D del)
			: _ptr(ptr)
			, _pcount(new int(1))
			, _pmutex(new mutex)
			, _del(del)
		{}
	private:
		T* _ptr;        //管理的资源
		int* _pcount;   //管理的资源对应的引用计数
		mutex* _pmutex; //管理的资源对应的互斥锁
		D _del;         //管理的资源对应的删除器
	};
}

这时我们模拟实现的shared_ptr就支持定制删除器了,但是使用起来没有C++标准库中的那么方便,

  • 如果传入的删除器是一个仿函数,那么需要在构造shared_ptr对象时指明仿函数的类型,
  • 如果传入的删除器是一个lambda表达式就更麻烦了,因为lambda表达式的类型不太容易获取,这里可以将lambda表达式的类型指明为一个包装器类型,让编译器传参时自行进行推演,也可以先用auto接收lambda表达式,然后再用decltype来声明删除器的类型
template<class T>
struct DelArr
{
	void operator()(const T* ptr)
	{
		cout << "delete[]: " << ptr << endl;
		delete[] ptr;
	}
};
int main()
{
	//仿函数示例
	Mango::shared_ptr<ListNode, DelArr<ListNode>> sp1(new ListNode[10], DelArr<ListNode>());

	//lambda示例1
	Mango::shared_ptr<FILE, function<void(FILE*)>> sp2(fopen("test.cpp", "r"), [](FILE* ptr){
		cout << "fclose: " << ptr << endl;
		fclose(ptr);
	});

	//lambda示例2
	auto f = [](FILE* ptr){
		cout << "fclose: " << ptr << endl;
		fclose(ptr);
	};
	Mango::shared_ptr<FILE, decltype(f)> sp3(fopen("test.cpp", "r"), f);
    return 0;
}

关于unique_ptr的定制删除器

image-20221020203742270

它是在构造这个对象的时候传这个定制删除器

namespace Mango
{
	template<class T>
	class default_delete //定制删除器
	{
	public:
		void operator()(const T* ptr)
		{
			cout << "delete:" << ptr << endl;
			delete ptr;
		}
	};

	// 释放方式由删除器决定
	template<class T, class D = default_delete<T>> //D:默认删除器
	class unique_ptr
	{
	public:
		unique_ptr(T* ptr)
			:_ptr(ptr)
		{}

		~unique_ptr()
		{
			if (_ptr)
			{
				//cout << "delete:" << _ptr << endl;
				//delete _ptr;
				D del;	
				del(_ptr);//用一个仿函数去释放
			}
		}

		// 像指针一样使用
		T& operator*()
		{
			return *_ptr;
		}

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

		unique_ptr(const unique_ptr<T>& sp) = delete;
		unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;

	private:
		T* _ptr;
	};
}

测试:

class A
{
public:
	~A()
	{
		cout << "~A()" << endl;
	}
private:
	int _a1 = 0;
	int _a2 = 0;
};

template<class T>
struct DeleteArray
{
	void operator()(const T* ptr)
	{
		cout << "delete[]:" << ptr << endl;
		delete[] ptr;
	}
};


struct DeleteFile
{
	void operator()(FILE* ptr)
	{
		cout << "fclose:" << ptr << endl;
		fclose(ptr);
	}
};
//删除器是一个可调用对象
int main()
{
	// unique_ptr的删除器在类模板参数给 -- 所以传的是类型
	Mango::unique_ptr<A> up1(new A); //可以不传,使用默认的
    //可以显示的传删除器,注意:在unique_ptr中传的是类模板参数 是类型!
	Mango::unique_ptr<A, DeleteArray<A>> up2(new A[10]);
	Mango::unique_ptr<FILE, DeleteFile> up3(fopen("test.txt", "w"));

	//shared_ptr的删除器在构造函数的参数给 -- 所以传的是对象
	std::shared_ptr<A> sp1(new A);
	std::shared_ptr<A> sp2(new A[10], DeleteArray<A>());
	std::shared_ptr<FILE> sp3(fopen("test.txt", "w"), DeleteFile());//传仿函数作为删除器

	std::shared_ptr<A> sp4(new A[10], [](A* p){delete[] p; });//传lammbda表达式作为删除器
	std::shared_ptr<FILE> sp5(fopen("test.txt", "w"), [](FILE* p){fclose(p); });

	return 0;
}

总结

  • 定制删除器,实际在平时的工作中使用有价值
  • 定制删除器的意义 : 默认情况,智能指针底层都是delete资源 ,那么如果你的资源不是new出来的呢?比如:new[]、malloc、fopen ,定制删除器 – 传入可调用对象,自定义释放资源

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++程序库的总称


本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>