【C++】智能指针

目录

一、智能指针的发展过程

二、unique_ptr

三、shared_ptr

1、引用计数

a.创建智能指针

b.拷贝构造(新指针和原指针共享一个对象)

c.赋值构造

d.函数返回一个智能指针

2、自定义删除器

a.管理malloc分配的内存

b.管理文件描述符

[3、new[ ]和delete[ ]](#3、new[ ]和delete[ ])

4、shared_ptr的简单实现

std::atomic

实现

四、weak_ptr

shared_ptr循环引用

weak_ptr


一、智能指针的发展过程

1.最开始的设计是想要以RAII(资源获得及初始化机制)的思想创造一个可以自动管理类的类,也就是auto_ptr。其大致思路是:

cpp 复制代码
template<class T>
class auto_ptr
{
public:
	auto_ptr(T* ptr)
		:_ptr(ptr)
	{
	}
	~auto_ptr()
	{
		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;
};

关于[ ]运算符重载,一个关于指针下表访问的小tip:

cpp 复制代码
void func(int* p){
	for (int i = 0; i < 10; i++){
		//指针可以像数组那样访问一个元素的下标
		std::cout << p[i] << std::endl;
	}
}
int main(){
	int p[10] = { 0 };
	func(p);//数组再传参的时候会退化为指针
	return 0;;
}

但是有个问题,因为auto_ptr的拷贝构造是浅拷贝,所以为了避免有两个auto_ptr拥有一个动态内存从而造成析构函数会被调用多次而引起报错,auto_ptr就设置了一个机制就是所有权转移机制,被拷贝对象会将对象的所有权全部转移给拷贝对象,而原来的被拷贝对象将会被置为空。

正是因为所有权转移机制,auto_ptr是不能拷贝构造,这样就会造成悬垂引用,悬垂引用的意思是我将被拷贝的对象的所有权全部交给拷贝对象,那么访问被拷贝对象的时候就会引用一个空的指针,发生报错。

例如:

cpp 复制代码
​
#include <iostream>
#include <memory>

class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)
		:_year(year),
		_month(month),
		_day(day)
	{
		std::cout << "Date()" << std::endl;
	}
	void Print()
	{
		std::cout << _year << "   " << _month << "   " << _day << std::endl;
	}
	~Date()
	{
		std::cout << "~Date()" << std::endl;
	}
	int _year;
	int _month;
	int _day;
};

int main()
{
	std::auto_ptr<Date> date1(new Date());
	std::auto_ptr<Date> date2(date1);
	date1->Print();
	return 0;
}

​

所以auto_ptr是一个非常失败的设计。

为了解决上述的问题,c++11引入了unique_ptr,shared_ptr,weak_ptr。

二、unique_ptr

unique_ptr相较于auto_ptr直接禁止了拷贝构造,但是支持移动构造。

cpp 复制代码
std::unique_ptr<Date> p1(new Date());
std::unique_ptr<Date> p2 = std::move(p1); // p1 所有权转移至 p2,p1 变为 nullptr
unique_ptr<Date> p3 = p1; // 编译错误:禁止拷贝构造

不过移动构造之后的p1也同样是会被悬垂引用的。虽然移动构造是显示的,但同样要谨慎使用。

三、shared_ptr

shared_ptr是支持拷贝的,例如:

cpp 复制代码
int main()
{
	std::shared_ptr<Date> date1 = std::make_shared<Date>();
	std::shared_ptr<Date> date2(date1);
	std::shared_ptr<Date> date3(date2);
	date1->_year++;
	date1->Print();
	date2->Print();
	date3->Print();
	return 0;
}

输出结果:

Date()

2 1 1

2 1 1

2 1 1

~Date()

这里我们不难看出shared_ptr和普通类的拷贝构造还是不一样的,shared_ptr中的一个拷贝对象的变化就会引起所有对象的数据的变化,我们抱着这样的问题来看shared_ptr的特性。

1、引用计数

shared_ptr内部维护一个引用计数器,记录当前有多少个shared_ptr指向同一对象。当创建一个智能指针之后,会将引用计数置为1,当引用计数被置为0的时候,智能指针会被销毁。

引用计数发生变化的情景:

a.创建智能指针

b.拷贝构造(新指针和原指针共享一个对象)

c.赋值构造

d.函数返回一个智能指针

这就要看编译器的优化程度:

  • 无优化:因为返回语句要进行拷贝构造,所以引用计数会+1,等到函数运行结束的时候,由于智能指针是一个局部变量,所以又要将智能指针的引用计数-1。
  • 有优化:函数内部在创建智能指针的时候会直接构造到调用者的接收变量中

因为函数内返回智能指针的引用而引发的悬垂引用:

那么,到这里,我又在想直接返回一个智能指针的引用的话,智能指针引用计数的变化是不是就和有编译器优化的引用计数的变化情况是一样的了?

这句话听起来很对,但是却犯了智能指针的一个大忌,我们要知道new出来的一个对象的析构机制和智能指针的析构机制是不一样的,new出来一个对象的析构,我们直接使用delete去析构就行了,但是智能指针的析构是通过引用计数归0从而析构的。当我返回一个智能指针的引用的时候,由于智能指针是一个局部变量的原因,引用计数会-1,从而造成智能指针的析构,而返回的指针就会指向一个已经被析构的智能指针对象,从而造成悬垂引用,所以不可以返回智能指针的引用。

cpp 复制代码
std::shared_ptr<int> get_shared_ref2() {
	std::shared_ptr<int> local_ptr = std::make_shared<int>(100);
	return local_ptr;  //返回局部变量的引用
}

std::shared_ptr<int>& get_shared_ref1() {
	std::shared_ptr<int> local_ptr = std::make_shared<int>(100);
	return local_ptr;  //返回局部变量的引用
}

int main() {
	std::shared_ptr<int>& ref = get_shared_ref1();  // 悬垂引用!
	// 此时 local_ptr 已析构,ref 成为悬垂引用
	std::cout << *ref << std::endl;  // 未定义行为

	std::shared_ptr<int> ref = get_shared_ref2();
	std::cout << *ref << std::endl;
	return 0;
}

输出结果:

-572662307

100

2、自定义删除器

智能指针(如unique_ptr或shared_ptr)默认用delete释放资源。如果资源不是new分配的,直接交给智能指针管理会导致析构时崩溃。

比如(此代码仅仅是为了验证上述情况,实际中栈数据会在超出作用域或当函数栈帧被释放时候自动销毁):

cpp 复制代码
int main() {
	int stack_var = 42;
	std::unique_ptr<int> ptr(&stack_var);  //ptr析构时会调用delete &stack_var
	return 0;  // delete 不能释放栈内存
}

因此,我们在面对不是new出来的内存时,我们要自己主动告诉智能指针该怎么办,由此引入了自定义删除器的概念。

举例展示:

a.管理malloc分配的内存

cpp 复制代码
//自定义删除器管理malloc分配的内存
int main()
{
	int* p = (int*)malloc(sizeof(int));
	*p = 100;

	//自定义删除器
	std::function<void(int*)> deleter = [](int* p) {free(p); };

	std::shared_ptr<int> sp(p, deleter);
	return 0;
}

b.管理文件描述符

cpp 复制代码
//自定义删除器管理文件描述符
int main() {
	FILE* file = std::fopen("test.txt", "w");
	if (!file) return 1;

	// 自定义删除器
	auto file_deleter = [](FILE* fp) {
		if (fp) std::fclose(fp);
		};

	std::shared_ptr<FILE> file_ptr(file, file_deleter);
	return 0; 
}

3、new[ ]和delete[ ]

如果用默认的delete去释放一个数组会导致未定义行为(因为new[ ]必须配对delete[ ],智能指针提供了特化版本来正确处理数组。

错误示例:

cpp 复制代码
#include <memory>

int main() {
    int* arr = new int[5]{1, 2, 3, 4, 5};
    std::unique_ptr<int> ptr(arr); //ptr析构时会调用delete而不是 delete[]
    return 0; 
}

正确示例:

cpp 复制代码
#include <memory>

int main() {
    int* arr = new int[5]{1, 2, 3, 4, 5};
    std::unique_ptr<int[]> ptr(arr);
    return 0;
}

4、shared_ptr的简单实现

在模拟实现之前先补充一些关于原子性的知识。

原子性指一个操作要么执行,要么完全不执行,中间不会因其他线程的干扰而被打断或部分完成。在多线程中,原子操作避免了数据竞争,即多个线程同时读写同一变量导致的不确定行为。

cpp 复制代码
int i=1;
--i;

我们来看这两句代码,如果i是一个多线程程序中的临界资源,那么i就不是线程安全的。

为什么?我们来看一下--i的汇编代码。

0076185D mov eax,dword ptr [i] ; 1. 读取变量 i 的值到寄存器 eax

00761860 sub eax,1 ; 2. 将 eax 的值减 1

00761863 mov dword ptr [i],eax ; 3. 将 eax 的新值写回变量 i

当代码正在执行--i,但是汇编代码还没有运行第三句汇编代码,这是如果有其他线程要访问或使用i这个变量的时候,i的值依然还会是1,这就会引发线程安全的问题。

如何解决,可以只用互斥锁,但在这里使用另一种方法std::atomic。

std::atomic

std::atomic 是 C++11 引入的模板类,用于在多线程环境中实现原子操作,避免数据竞争。它支持多种数据类型(如int 、 bool、指针等),并提供线程安全的读写、修改和内存序控制。

cpp 复制代码
std::atomic<int> i;
--i;

实现

cpp 复制代码
namespace hebre
{
	template<class T>
	class shared_ptr
	{
	public:
		explicit shared_ptr(T* ptr = nullptr)
			:_ptr(ptr),
			_pcount(new atomic<int>(1))
		{
			std::cout << "shared_ptr(T* ptr = nullptr)" << std::endl;
		}

		//带自定义删除器的构造函数
		template<class D>
		explicit shared_ptr(shared_ptr<T> ptr, D del)
			:_ptr(ptr),
			_pcount(new atomic<int>(1)),
			_del(del)
		{
			std::cout << "shared_ptr(shared_ptr<T> ptr, D del)" << std::endl;
		}

		~shared_ptr()
		{
			std::cout << "~shared_ptr()" << std::endl;
			//如果引用计数>0,则引用计数-1,否则销毁智能指针
			(*_pcount)--;
			if (*_pcount == 0)
			{
				_del(_ptr);
				delete _pcount;

				_ptr = nullptr;
				_pcount = nullptr;
			}
		}

		//拷贝构造
		shared_ptr(shared_ptr<T>& sp)
			:_ptr(sp._ptr),
			_pcount(sp._pcount),
			_del(_del)
		{
			std::cout << "shared_ptr(shared_ptr<T>& sp)" << std::endl;
			(*_pcount)++;
		}

		//赋值重载
		shared_ptr<T>& operator=(shared_ptr<T>& ptr)
		{
			std::cout << "operator=(shared_ptr<T>& ptr)" << std::endl;
			//两种情况:1.p1=p1   2.p1=p2
			if (_ptr != ptr._ptr)
			{
				//检查p1被赋值后,原来的值是否需要被销毁(判定标准是引用计数是否为1)
				(*_pcount)--;
				if (*_pcount == 0)
				{
					_del(_ptr);
					delete _pcount;

					_ptr = nullptr;
					_pcount = nullptr;
				}

				//开始赋值
				_pcount = ptr._pcount;
				_ptr = ptr._ptr;
				_del = ptr._del;
			}
			return *this;
		}

		//运算符重载
		T* operator->()
		{
			return _ptr;
		}

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

		T& operator[](int i)
		{
			return _ptr[i];
		}

		T* get()
		{
			return _ptr;
		}

		//返回引用计数
		int use_count()
		{
			return _pcount;
		}

		operator bool()
		{
			return _ptr == nullptr;
		}

	private:
		T* _ptr;													//被智能指针管理的指针
		atomic<int>* _pcount;										//引用计数
		std::function<void(T*)> _del = [](T* ptr) {delete ptr; };	//自定义删除器
	};
}

四、weak_ptr

shared_ptr循环引用

cpp 复制代码
struct ListNode
{
	int _data;
	std::shared_ptr<ListNode> _next;
	std::shared_ptr<ListNode> _prev;
	~ListNode()
	{
		cout << "~ListNode()" << endl;
	}
};
int main()
{
	std::shared_ptr<ListNode> n1(new ListNode);
	std::shared_ptr<ListNode> n2(new ListNode);
	n1->_next = n2;
	n2->_prev = n1;
	return 0;
}

在这种情况下智能指针的析构时会发生内存泄漏,理由如下:

1.右边的节点什么时候释放呢,左边节点中的_nex管着呢,_next析构后,右边的节点就释放了。

2._next什么时候析构呢,_next是左边节点的的成员,左边节点释放,_next就析构了。

3.左边节点什么时候释放呢,右边节点中的_prev管着呢,_prev析构后,左边的节点释放。

4._prev什么时候析构呢,_prev是右边节点的成员,右边节点释放,_prev就析构了。

这导致了内存无法被释放从而导致内存泄漏。

weak_ptr

使用weak_ptr可以解决上述shared_ptr循环引用的问题。

cpp 复制代码
struct ListNode
{
	int _data;
	std::weak_ptr<ListNode> _next;
	std::weak_ptr<ListNode> _prev;
	~ListNode()
	{
		cout << "~ListNode()" << endl;
	}
};

weak_ptr不支持RAII,也不支持访问资源,所以我们看文档发现weak_ptr构造时不支持绑定到资

源,只支持绑定到shared_ptr,绑定到shared_ptr时,不增加shared_ptr的引用计数,那么就可以

解决上述的循环引用问题。

相关推荐
楼田莉子2 小时前
Linux学习之库的原理与制作
linux·运维·服务器·c++·学习
浅念-2 小时前
C++第一课
开发语言·c++·经验分享·笔记·学习·算法
charlie1145141912 小时前
现代嵌入式C++教程:对象池(Object Pool)模式
开发语言·c++·学习·算法·嵌入式·现代c++·工程实践
HABuo2 小时前
【linux进程控制(三)】进程程序替换&自己实现一个bash解释器
linux·服务器·c语言·c++·ubuntu·centos·bash
一只小bit3 小时前
Qt 多媒体:快速解决音视频播放问题
前端·c++·qt·音视频·cpp·页面
凯子坚持 c3 小时前
C++大模型SDK开发实录(二):DeepSeek模型接入、HTTP通信实现与GTest单元测试
c++·http·单元测试
uoKent3 小时前
c++中的运算符重载
开发语言·c++
量子炒饭大师3 小时前
【C++入门】面向对象编程的基石——【类与对象】基础概念篇
java·c++·dubbo·类与对象·空指针规则
MSTcheng.3 小时前
【C++】链地址法实现哈希桶!
c++·redis·哈希算法