【C++】智能指针

1.RAII和智能指针的设计思路

RAII是Resource Acquisition Is Initialization的缩写,他是⼀种管理资源的类的设计思想,本质是⼀种利⽤ 对象⽣命周期管理获取到的动态资源 ,避免资源泄漏,这⾥的资源可以是内存、⽂件指针、⽹络连接、互斥锁等等。
RAII在获取资源时 把资源委托给⼀个对象 ,接着控制对资源的访问,资源在对象的⽣命周期内始终保持有效,最后 在对象析构的时候释放资源 ,这样保障了资源的正常释放,避免资源泄漏问题。

cpp 复制代码
template<class T>
class SmartPtr
{
public:
	// RAII
	SmartPtr(T* ptr) 
		:_ptr(ptr)
	{
	}
	~SmartPtr() //析构时释放资源
	{
		cout << "delete[] " << _ptr << endl;
		delete[] _ptr;
	}
private:
	T* _ptr;
};

如果我们还想访问一下指针指向的这些资源呢,所以智能指针类还会想迭代器类⼀样,重载 operator*/operator->/operator[] 等运算符,⽅便访问资源。

cpp 复制代码
template<class T>
class SmartPtr
{
public:
	// RAII
	SmartPtr(T* ptr) 
		:_ptr(ptr)
	{
	}
	~SmartPtr() //析构时释放资源
	{
		cout << "delete[] " << _ptr << endl;
		delete[] _ptr;
	}
	// 重载运算符,模拟指针的行为,方便访问资源
	T& operator*()
	{
		return *_ptr;
	}
	T* operator->()
	{
		return _ptr;
	}
	T& operator[](size_t i)
	{
		return _ptr[i];
	}
private:
	T* _ptr;
};

智能指针难搞的问题在于拷贝。如下面这个代码,我们前面没有写拷贝构造的函数,编译器就会默认生成,默认生成的拷贝构造函数是 浅拷贝,p1和p2都会指向同一份资源,指向同一份资源没有问题,有问题的是这个资源析构的时候会 被析构两次

cpp 复制代码
int main()
{
	SmartPtr<int> p1 = new int[10];
	SmartPtr<int> p2(p1);
	return 0;
}

这里用深拷贝就解决问题了吗?我们不需要深拷贝,智能指针模拟的就是原生指针的行为,p1赋值给p2并不是希望p2得到一份新的资源,是希望p2也指向p1指向的资源,p1和p2共同管理同一份资源

这里要的就是浅拷贝,但是又不想空间被析构多次,为了解决这个问题,就诞生了很多类型的智能指针。

2.C++标准库智能指针的使⽤

C++标准库中的智能指针都在 <memory>这个头⽂件 下⾯,我们包含 <memory> 就可以是使⽤了,
智能指针有好⼏种, 除了weak_ptr 他们都符合RAII和像指针⼀样访问的⾏为,原理上⽽⾔主要是解决智能指针 拷⻉时的思路不同

  • auto_ptr:是C++98时设计出来的智能指针,他的特点是拷⻉时把被拷⻉对象的资源的管理权转移给拷⻉对象,这是⼀个**⾮常糟糕的设计**,因为他会导致被拷⻉对象悬空,访问报错的问题,C++11设计出新的智能指针后,强烈建议不要使⽤auto_ptr。其他C++11出来之前很多公司也是明令禁⽌使⽤这个智能指针的。

比如下面这个代码。就是ap1将管理权转移给ap2,始终只有一个对象管理这个资源,此时就只会析构一次了,但是此时ap1就会悬空,访问到了就是访问空指针。

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

struct Date
{
	int _year;
	int _month;
	int _day;
	Date(int year = 1, int month = 1, int day = 1)
		:_year(year)
		, _month(month)
		, _day(day)
	{}
	~Date()
	{
		cout << "~Date()" << endl;
	}
};

int main()
{
	auto_ptr<Date> ap1(new Date);
	auto_ptr<Date> ap2(ap1); //管理权转移
	return 0;
}

但这并不是我们要的结果,我们要的是ap1和ap2都指向同一份资源。

  • unique_ptr:是C++11设计出来的智能指针,他的名字翻译出来是唯⼀指针,他的特点的不⽀持拷⻉,只⽀持移动。如果不需要拷⻉的场景就⾮常建议使⽤他。
cpp 复制代码
int main()
{
	unique_ptr<Date> up1(new Date);
	//unique_ptr<Date> up2(up1); // 不支持拷贝
	unique_ptr<Date> up3(move(up1)); //只支持移动构造
    return 0;
}

但是这里up1被move后也悬空了,不就跟auto_ptr一样了吗?意义不同,这里我们手动move使up1悬空,是为了演示支持移动构造的情况,这是我主动的操作,但是auto_ptr的ap1并不是是我想让她悬空的。

  • shared_ptr:是C++11设计出来的智能指针,他的名字翻译出来是共享指针,他的特点是⽀持拷⻉,也⽀持移动。如果需要拷⻉的场景就需要使⽤他了。底层是⽤引⽤计数的⽅式实现的。
cpp 复制代码
int main()
{
	shared_ptr<Date> sp1(new Date);
	shared_ptr<Date> sp2(sp1); // 支持拷贝
	shared_ptr<Date> sp3(sp2); // 支持移动构造
    return 0;
}

这里还可以查看引用计数的数量。

cpp 复制代码
cout << "sp1: " << sp1.use_count() << endl;
cout << "sp2: " << sp2.use_count() << endl;
cout << "sp3: " << sp3.use_count() << endl;

此时这三个指针都指向同一份资源,引用计数为3,并且能看到只析构了一次。
shared_ptr 除了⽀持⽤指向资源的指针构造,还⽀持 make_shared ⽤初始化资源对象的值直接构造。下面的sp4和sp5是一样的,写法不同。

cpp 复制代码
shared_ptr<Date> sp4(new Date(2025, 11));
shared_ptr<Date> sp5 = make_shared<Date>(2025, 11);
  • weak_ptr: 是C++11设计出来的智能指针,他的名字翻译出来是弱指针,他完全不同于上⾯的智能指针,他 不⽀持RAII ,也就意味着 不能⽤它直接管理资源 ,weak_ptr的产⽣本质是要 解决shared_ptr的⼀个环引⽤导致内存泄漏的问题 。具体细节在: 【C++】weak_ptr

shared_ptr 和 unique_ptr 都⽀持了operator bool的类型转换 ,如果智能指针对象是⼀个空对象没有管理资源,则返回false,否则返回true,意味着我们可以直接把智能指针对象给if判断是否为空

cpp 复制代码
int main()
{
	shared_ptr<Date> sp4(new Date(2025, 11));
	shared_ptr<Date> sp5 = make_shared<Date>(2025, 11);
	unique_ptr<Date> up6;
	if (sp4) cout << "sp4: not nullptr" << endl;
	if (!up6) cout << "up6: nullptr" << endl;
	return 0;
}


shared_ptr 和 unique_ptr 都得构造函数都使⽤ explicit 修饰,防⽌普通指针隐式类型转换成智能指针对象。
也就是如下写法不可以。

cpp 复制代码
shared_ptr<Date> sp4 = new Date(2025, 11); // error

但不是所有的都不可以,是加了explicit的不可以。

3.模拟实现智能指针

3.1 auto_ptr

auto_ptr的思路是拷⻉时转移资源管理权给被拷⻉对象,这种思路是不被认可的,也不建议使⽤。代码贴在下面看看即可。

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;
template<class T>
class auto_ptr
{
public:
	auto_ptr(T* ptr)
		:_ptr(ptr)
	{}
	auto_ptr(auto_ptr<T>& sp)
		:_ptr(sp._ptr) 
	{
		// 管理权转移
		sp._ptr = nullptr;
	}
	auto_ptr<T>& operator=(auto_ptr<T>& ap)
	{
		// 检测是否为自己给自己赋值
		if (this != &ap)
		{
			// 释放当前对象中资源
			if (_ptr)
				delete _ptr;
			// 转移ap中资源到当前对象中
			_ptr = ap._ptr;
			ap._ptr = NULL;
		}
		return *this;
	}
	~auto_ptr()
	{
		if (_ptr)
		{
			cout << "delete:" << _ptr << endl;
			delete _ptr;
		}
	}
	// 像指针⼀样使⽤
	T& operator*() { return *_ptr; }
	T* operator->() { return _ptr; }
private:
	T* _ptr;
};

3.2 unique_ptr

unique_ptr的思路是不⽀持拷⻉。代码贴在下面看看即可。

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

template<class T>
class unique_ptr
{
public:
	explicit unique_ptr(T* ptr)
		:_ptr(ptr)
	{}
	~unique_ptr()
	{
		if (_ptr)
		{
			cout << "delete:" << _ptr << endl;
			delete _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;
	// 移动构造
	unique_ptr(unique_ptr<T> && sp) 
		:_ptr(sp._ptr)
	{
		sp._ptr = nullptr;
	}
	unique_ptr<T>& operator=(unique_ptr<T> && sp)
	{
		delete _ptr;
		_ptr = sp._ptr;
		sp._ptr = nullptr;
	}
private:
	T* _ptr;
};

3.3 shared_ptr

3.3.1 引用计数

我们先来讨论shared_ptr的引用计数如何设计。首先如果把这个数放在类里当成员变量,各自加减各自的,还能起到计数的作用吗?肯定不可以了。

cpp 复制代码
namespace lyj
{
	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr)
			: _ptr(ptr)
		{
		}
	private:
		T* _ptr;
		int _count; // ?
	};
}

这时就有人动脑子了,把这个变量_count设为static呢?_count是全局的了,就可以用来计数了。

cpp 复制代码
private:
	T* _ptr;
	static int _count; // ?

但是如果此时又来了一个p3,但是这个p3并不想指向p1和p2指向的资源呢?计数依旧会增加。

这不是我们要的结果,我们要的引用计数是指的一块资源有多少个智能指针对象管理,所以应该是资源1有一个计数记录两个指针对象在管理,资源2有一个计数记录一个一个指针对象在管理。所以设置成static是不行的。
所以这个引用计数要使⽤ 堆上动态开辟 的⽅式,构造智能指针对象时来⼀份资源,就要new⼀个引⽤计数出来。

这个计数器在对象构造的时候,就可以new一个int并初始化为1,在对象析构的时候,就减一,减到0就证明没有指针再指向这份资源,此时再delete。

代码如下。

cpp 复制代码
namespace lyj
{
	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr)
			: _ptr(ptr),
			_count(new int(1)) // 构造的时候开空间,初始化为1
		{}

		~shared_ptr()
		{
			if (--(*_count) == 0) // 析构时对计数器减1
			{
				delete _ptr;
				delete _count;
			}
		}
	private:
		T* _ptr;
		int* _count; // 堆上开辟
	};
}

3.3.2 拷贝构造

拷贝构造函数就是先把被拷贝对象给自己,并对计数器做加1处理。

cpp 复制代码
shared_ptr(const shared_ptr<T>& sp) // 拷贝构造
	:_ptr(sp._ptr)
	,_count(sp._count)
{
	(*_count)++; // 让计数器++
}

我们来验证一下。

cpp 复制代码
int main()
{
	lyj::shared_ptr<Date> p1(new Date);
	lyj::shared_ptr<Date> p2(p1);
	lyj::shared_ptr<Date> p3(new Date(2, 2, 2));
	return 0;
}

p1和p2用的是一个计数,p3是另一个计数,并且只会析构两次,因为只有两个资源。

3.3.3 运算符重载

我们把指针修改的几个函数加进去。

cpp 复制代码
// 像指针⼀样使⽤
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
T& operator[](size_t i) { return _ptr[i]; }
cpp 复制代码
int main()
{
	lyj::shared_ptr<Date> p1(new Date);
	lyj::shared_ptr<Date> p2(p1);
	p1->_year = 2025;
	p2->_month = 11; 
	lyj::shared_ptr<Date> p3(new Date(2, 2, 2));
	p3->_year++;
	return 0;
}

p1和p2指向同一份资源,修改就同时修改了,p3是另外一份资源。

这里的赋值运算符重载里面要加上引用计数的修改。

cpp 复制代码
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
	if (--(*_count) == 0)
	{
		delete _ptr;
		delete _count;
	}
	_ptr = sp._ptr;
	_count = sp._count;
	(*_count)++;
	return *this;
}

当我们把p1=p2后,就会出现下面的现象。

cpp 复制代码
int main()
{
	lyj::shared_ptr<Date> p1(new Date);
	lyj::shared_ptr<Date> p2(p1);
	p1->_year = 2025;
	p2->_month = 11; 
	lyj::shared_ptr<Date> p3(new Date(2, 2, 2));
	p3->_year++;
	p1 = p3; // 赋值
	return 0;
}

但是这里要注意自己给自己赋值的情况,可以加一个判断,如果ptr不相等就不是自己给自己赋值,这里写成this != &sp也行,但是下面的写法更好。

cpp 复制代码
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
	if (_ptr != sp._ptr)
	{
		if (--(*_count) == 0)
		{
			delete _ptr;
			delete _count;
		}
		_ptr = sp._ptr;
		_count = sp._count;
		(*_count)++;
	}	
	return *this;
}
cpp 复制代码
int main()
{
	lyj::shared_ptr<Date> p1(new Date(2025,11));
	lyj::shared_ptr<Date> p2(p1);
	lyj::shared_ptr<Date> p3(new Date(2, 2, 2));
	p1 = p3; // 正常赋值
	p1 = p1; // 自己给自己赋值
	p1 = p2; // p1和p2指向同一个资源,这也算自己给自己赋值
	return 0;
}

能编译通过就证明代码没问题。

4.定制删除器

4.1 析构部分详解

通过前面的学习我们知道智能指针析构时默认是进⾏delete释放资源,这也就意味着如果不是new出来的资源,交给智能指针管理,它析构时就会崩溃。如下。

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;
int main()
{
    //new [] 需要用delete[]析构
    shared_ptr<Date> p1(new Date[10]); //析构时崩溃
}

**解决方法一:**模板参数传[],其实就是对应类型的指针,写法如下。

cpp 复制代码
int main()
{
	//shared_ptr<Date> p1(new Date[10]); //析构时崩溃
	shared_ptr<Date[]> p1(new Date[10]); 
	return 0;
}

这个写法unique_ptr也可以用。

cpp 复制代码
unique_ptr<Date[]> p2(new Date[10]);

因为new[]经常使⽤,所以为了简洁⼀点, unique_ptr和shared_ptr底层都特化了⼀份[]的版本。

但是这不能适用于所有场景,比如下面这个。

cpp 复制代码
shared_ptr<FILE> p3(fopen("Test.cpp", "r")); 

解决方法二:定制删除器 。智能指针⽀持在构造时给⼀个删除器,所谓删除器本质就是⼀个可调⽤对象,这个可调⽤对象中实现你想要的释放资源的⽅式,当构造智能指针时,给了定制的删除器,在智能指针析构时就会调⽤删除器去释放资源。

写法如下。

cpp 复制代码
class Fclose
{
public:
	void operator()(FILE* ptr)
	{
		cout << "fclose:" << ptr << endl;
		fclose(ptr);
	}
};

int main()
{
	shared_ptr<FILE> p3(fopen("Test.cpp", "r"), Fclose());
	return 0;
}

上面的p3调用析构的时候,就会执行Fclose里的operator()函数。

我们还可以直接用lambda表达式,写起来更方便,如下面的p4。

cpp 复制代码
int main()
{
	// 仿函数
	shared_ptr<FILE> p3(fopen("Test.cpp", "r"), Fclose()); 
	// lambda表达式
	shared_ptr<FILE> p4(fopen("Test.cpp", "r"), [](FILE* ptr) {
		cout << "fclose:" << ptr << endl;
		fclose(ptr);
		});
	return 0;
}

像前面需要delete[]析构的地方,如p1,也可以直接用一下删除器,传个lambda表达式。

cpp 复制代码
shared_ptr<Date> p1(new Date[10], [](Date* ptr) { delete[] ptr; });

但是unique_ptr和shared_ptr的定制删除器有点不一样。shared_ptr就像前面演示那样,是在构造函数处传一个可调用对象,但是unique_ptr是在类声明的时候传。

unique_ptr用法如下。

cpp 复制代码
//仿函数
unique_ptr<FILE, Fclose> p5(fopen("Test.cpp", "r"));

//lambda表达式
auto CloseFunc = [](FILE* ptr) { fclose(ptr); };
unique_ptr<FILE, decltype(CloseFunc)> p6(fopen("Test.cpp", "r")); //依旧有问题

这里只有仿函数好用,lambda表达式直接放在模板参数处是不可以的,因为lambda表达式是一个对象,不是一个类型,但模板参数处必须传一个类型,我们用auto CloseFunc来接收这个lambda表达式,decltype可以推导类型,我们在模板参数处传decltype(CloseFunc),他可以当作模板参数,但是这里是依旧编译不通过的,这里很复杂。

所以unique_ptr定制删除器的话建议就用仿函数,如果是shared_ptr的定制删除器用什么都行,建议用lambda会更方便。

4.2 模拟实现定制删除器

我们要对构造函数重载,加一个删除器的模板D。

cpp 复制代码
namespace lyj
{
	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr)
			: _ptr(ptr)
			,_count(new int(1)) // 构造的时候开空间,初始化为1
		{}

		template<class D>
		shared_ptr(T* ptr, D del)
			: _ptr(ptr)
			,_count(new int(1)) // 构造的时候开空间,初始化为1
            ,_del(del)

		{}

        //...

	private:
		T* _ptr;
		int* _count; // 堆上开辟
		D _del; // ?
	};
}

上面的写法这个模板参数D不是整个类的模板参数,只是构造函数的模板参数,而这个del应该要被保存吧,怎么保存它?类里面用不了这个D,咋整?如果加在整个类的模板参数处,类就可以用这个D类型,但是这不就成了unique_ptr的实现方法了,就要在类声明的时候传参数。

解决方法如下:用一个包装器

cpp 复制代码
public:
	template<class D>
	shared_ptr(T* ptr, D del)
		: _ptr(ptr)
		,_count(new int(1)) // 构造的时候开空间,初始化为1
		,_del(del)
	{}
    
    //...
private:
    //...
    function<void(T*)> _del; 

D这个可调用对象一定是**返回值为void参数为T***的,所以这里用了一个function<void(T*)>,用这个包装器把传过来的删除器_del保存起来。

然后在析构的时候,就直接用这个删除器析构,传的什么实现方式就怎么删除。

cpp 复制代码
~shared_ptr()
{
	if (--(*_count) == 0)
	{
		//delete _ptr;
		_del(_ptr); // 删除器
		delete _count;
	}
}

此时需要删除器的就不会有问题,但是正常new出来一个对象的就会出问题了。

cpp 复制代码
int main()
{
	lyj::shared_ptr<Date> p1(new Date); 
	lyj::shared_ptr<Date> p2(new Date[5], [](Date* ptr) { delete[] ptr; });
	return 0;
}

没有删除器的初始化时会走原始的构造函数,而这个构造函数并没有对_del做处理。

所以这里我们要给_del一个缺省值,就用lambda表达式写一个delete的就行。

cpp 复制代码
namespace lyj
{
	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr)
			: _ptr(ptr)
			,_count(new int(1)) // 构造的时候开空间,初始化为1
		{}

		template<class D>
		shared_ptr(T* ptr, D del)
			: _ptr(ptr)
			,_count(new int(1)) // 构造的时候开空间,初始化为1
			,_del(del)
		{}

		shared_ptr(const shared_ptr<T>& sp) // 拷贝构造
			:_ptr(sp._ptr)
			,_count(sp._count)
		{
			(*_count)++; // 让计数器++
		}

	    T* get() const { return _ptr; }

		// 像指针⼀样使⽤
		T& operator*() { return *_ptr; }
		T* operator->() { return _ptr; }
		T& operator[](size_t i) { return _ptr[i]; }
		shared_ptr<T>& operator=(const shared_ptr<T>& sp)
		{
			if (_ptr != sp._ptr)
			{
				if (--(*_count) == 0)
				{
					delete _ptr;
					delete _count;
				}
				_ptr = sp._ptr;
				_count = sp._count;
				(*_count)++;
			}	
			return *this;
		}

		~shared_ptr()
		{
			if (--(*_count) == 0)
			{
				//delete _ptr;
				_del(_ptr); // 删除器
				delete _count;
			}
		}
	private:
		T* _ptr;
		int* _count; 
		function<void(T*)> _del = [](T* ptr) {delete ptr; };
	};
}

5.shared_ptr的线程安全问题

  • shared_ptr的引⽤计数对象在堆上,如果多个shared_ptr对象在多个线程中,进⾏shared_ptr的拷⻉析构时会访问修改引⽤计数,就会存在线程安全问题,所以shared_ptr引⽤计数是需要加锁或者****原⼦操作保证线程安全的。

  • shared_ptr本身是线程安全的,但shared_ptr指向的资源不是线程安全的,这个对象的线程安全问题不归shared_ptr管,它也管不了,应该由外层使⽤shared_ptr的⼈进⾏线程安全的控制。

  • 所以引⽤计数要从int*改成**atomic<int>***就可以保证引⽤计数的线程安全问题,或者使⽤互斥锁加锁也可以。

cpp 复制代码
namespace lyj
{
	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr)
			: _ptr(ptr)
			,_count(new atomic<int>(1)) 
		{}

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

		shared_ptr(const shared_ptr<T>& sp) // 拷贝构造
			:_ptr(sp._ptr)
			,_count(sp._count)
		{
			(*_count)++; // 让计数器++
		}
		// 像指针⼀样使⽤
		T& operator*() { return *_ptr; }
		T* operator->() { return _ptr; }
		T& operator[](size_t i) { return _ptr[i]; }
		shared_ptr<T>& operator=(const shared_ptr<T>& sp)
		{
			if (_ptr != sp._ptr)
			{
				if (--(*_count) == 0)
				{
					delete _ptr;
					delete _count;
				}
				_ptr = sp._ptr;
				_count = sp._count;
				(*_count)++;
			}	
			return *this;
		}

		~shared_ptr()
		{
			if (--(*_count) == 0)
			{
				//delete _ptr;
				_del(_ptr); // 删除器
				delete _count;
			}
		}
	private:
		T* _ptr;
		//int* _count; // 非原子
		atomic<int>* _count; // 原子的
		function<void(T*)> _del = [](T* ptr) {delete ptr; };
	};
}

本次分享就到这里了,我们下篇见~

相关推荐
杜子不疼.1 小时前
【C++】哈希表基础:开放定址法 & 什么是哈希冲突?
c++·哈希算法·散列表
代码不停1 小时前
网络原理——初识
开发语言·网络·php
04aaaze1 小时前
C++(C转C++)
c语言·c++·算法
不会c嘎嘎2 小时前
C++ -- list
开发语言·c++
老鱼说AI2 小时前
BPE编码从零开始实现pytorch
开发语言·人工智能·python·机器学习·chatgpt·nlp·gpt-3
星释2 小时前
Rust 练习册 32:二分查找与算法实现艺术
开发语言·算法·rust
William_cl2 小时前
C# ASP.NET Controller 核心:ViewResult 实战指南(return View (model) 全解析)
开发语言·c#·asp.net
wtrees_松阳2 小时前
Flask数据加密实战:医疗系统安全指南
开发语言·python
皮影w3 小时前
Java SpringAOP入门
java·开发语言