【C++详解】C++ 智能指针:使用场景、实现原理与内存泄漏防治

发布于:2025-09-08 ⋅ 阅读:(17) ⋅ 点赞:(0)


一、智能指针的使⽤场景分析

我们知道C++是是公认的高效编程语言,其中一点原因就是C++手动内存管理(new/delete),避免了很多高级语言(如 Java、Python)的自动内存管理(垃圾回收)带来的额外开销,这也是一把双刃剑,这对C++程序员的要求就会更高,因为手动内存管理很容易出现内存泄漏的问题,我们之前的说法是尽可能小心,但是有些场景无法避免会出现内存泄漏(或者处理起来很麻烦),这时候就需要借助我们接下来要介绍的智能指针了。
下⾯程序中我们可以看到,new了以后,我们也delete了,但是因为抛异常导致后⾯的delete没有得到执⾏,所以就内存泄漏了,所以我们需要new以后捕获异常,捕获到异常后delete内存,再把异常抛出,但是因为new本⾝也可能抛异常,连续的两个new和下⾯的Divide都可能会抛异常(并且不能把两个new放在try内部,这样指针作用域就仅限在try内部了),第一个new抛异常无所谓,因为没new成功,但是第二个new抛异常就会导致第一个new的资源没有正确释放,让我们处理起来很⿇烦。智能指针放到这样的场景⾥⾯就让问题简单多了。

double Divide(int a, int b)
{
	// 当b == 0时抛出异常
	if (b == 0)
	{
		throw "Divide by zero condition!";
	}
	else
	{
		return (double)a / (double)b;
	}
}

void Func()
{
	// 这⾥可以看到如果发⽣除0错误抛出异常,另外下⾯的array和array2没有得到释放。
	// 所以这⾥捕获异常后并不处理异常,异常还是交给外⾯处理,这⾥捕获了再重新抛出去。
	// 但是如果array2new的时候抛异常呢,就还需要套⼀层捕获释放逻辑,这⾥更好解决⽅案
	// 是智能指针,否则代码会极其臃肿
	int* array1 = new int[10];
	int* array2 = new int[10]; // 抛异常呢
	try
	{
		int len, time;
		cin >> len >> time;
		cout << Divide(len, time) << endl;
	}
	catch (...)
	{
		cout << "delete []" << array1 << endl;
		cout << "delete []" << array2 << endl;
		delete[] array1;
		delete[] array2;
		throw; // 异常重新抛出,捕获到什么抛出什么
	}
	// ...
	cout << "delete []" << array1 << endl;
	delete[] array1;
	cout << "delete []" << array2 << endl;
	delete[] array2;
}

int main()
{
	try
	{
		Func();
	}
	catch (const char* errmsg)
	{
		cout << errmsg << endl;
	}
	catch (const exception& e)
	{
		cout << e.what() << endl;
	}
	catch (...)
	{
		cout << "未知异常" << endl;
	}
	return 0;
}

二、RAII和智能指针的设计思路

RAII是Resource Acquisition Is Initialization(资源获取立即初始化)的缩写,他是⼀种管理资源的类的设计思想,本质是⼀种利⽤对象⽣命周期来管理获取到的动态资源,避免资源泄漏,这⾥的资源可以是内存、⽂件指针、⽹络连接、互斥锁等等。RAII在获取资源时把资源委托给⼀个对象(构造一个对象),接着控制对资源的访问,资源在对象的⽣命周期内始终保持有效,最后不论是正常退出函数栈帧还是抛异常退出栈帧(退出栈帧时析构函数一定会被调用),都会在在对象析构的时候释放资源,这样保障了资源的正常释放,避免资源泄漏问题。
• 智能指针类除了满⾜RAII的设计思路,还要⽅便资源的访问,所以智能指针类还会想迭代器类⼀样,重载 operator*/operator->/operator[] 等运算符,⽅便访问资源。

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

double Divide(int a, int b)
{
	// 当b == 0时抛出异常
	if (b == 0)
	{
		throw "Divide by zero condition!";
	}
	else
	{
		return (double)a / (double)b;
	}
}

void Func()
{
	// 这⾥使⽤RAII的智能指针类管理new出来的数组以后,程序简单多了
	SmartPtr<int> sp1 = new int[10];
	SmartPtr<int> sp2 = new int[10];
	for (size_t i = 0; i < 10; i++)
	{
		sp1[i] = sp2[i] = i;
	}
	int len, time;
	cin >> len >> time;
	cout << Divide(len, time) << endl;
}

int main()
{
	try
	{
		Func();
	}
	catch (const char* errmsg)
	{
		cout << errmsg << endl;
	}
	catch (const exception& e)
	{
		cout << e.what() << endl;
	}
	catch (...)
	{
		cout << "未知异常" << endl;
	}
	return 0;
}

三、C++标准库智能指针的使⽤

  • C++标准库中的智能指针都在< memory >这个头⽂件下⾯,我们包含< memory >就可以是使⽤了,智能指针有好⼏种,除了weak_ptr他们都符合RAII和像指针⼀样访问的⾏为,原理上⽽⾔主要是解决智能指针拷⻉时的思路不同。
  • auto_ptr是C++98时设计出来的智能指针,因为指针不能像普通内置类型对象那样拷贝,这样会让两个指针指向同一空间,也不能深拷贝,这样同一指针指向两块不同的空间,和指针的特性相背离。所以这时auto_ptr的设计思路是破坏性拷贝:拷贝直接拿走资源,原指针会被强制置为nullptr(失去所有权),只有新指针指向资源(该行为和移动拷贝构造类似,但是移动拷贝构造事先知道原对象会被置为空),这是⼀个⾮常糟糕的设计,因为他会导致被拷⻉对象悬空,访问报错的问题,C++11设计出新的智能指针后,强烈建议不要使⽤auto_ptr。其他C++11出来之前很多公司也是明令禁⽌使⽤这个智能指针的。
  • unique_ptr是C++11有了移动语义后设计出来的智能指针,他的名字翻译出来是唯⼀指针,他的特点的不⽀持拷⻉,只⽀持移动(移动构造和移动赋值),移动的前提必须是右值。如果不需要拷⻉的场景就⾮常建议使⽤他。
  • shared_ptr也是C++11设计出来的智能指针,他的名字翻译出来是共享指针,他的特点是⽀持拷⻉,也⽀持移动,移动构造的前提也是只能移动。如果需要拷⻉的场景就需要使⽤他了。底层是⽤引⽤计数的⽅式实现的,所以它是有一定代价的,如果是需要移动的场景更推荐使用unique_ptr。
  • 引用计数就像一个 “使用计数器”,shared_ptr 通过它实现了多个指针安全共享同一份资源: 有新指针共享资源时,计数增加; 有指针不再使用资源时,计数减少; 计数为 0 时,自动释放资源。 这种机制既允许资源被多个指针共享,又能保证资源只被释放一次,从根本上避免了 “双重释放” 和 “内存泄漏” 问题。
  • weak_ptr也是C++11设计出来的智能指针,他的名字翻译出来是弱指针,他完全不同于上⾯的智能指针,他不⽀持RAII,也就意味着不能⽤它直接管理资源,weak_ptr的产⽣本质是要解决shared_ptr的⼀个循环引⽤导致内存泄漏的问题。具体细节下⾯我们再细讲。
//三种智能指针的使用
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);
	// 拷⻉时,管理权限转移,被拷⻉对象ap1悬空
	auto_ptr<Date> ap2(ap1);
	// 空指针访问,ap1对象已经悬空
	//ap1->_year++;

	unique_ptr<Date> up1(new Date);
	// 不⽀持拷⻉
	//unique_ptr<Date> up2(up1);
	// ⽀持移动,但是移动后up1也悬空,所以使⽤移动要谨慎
	unique_ptr<Date> up3(move(up1));

	shared_ptr<Date> sp1(new Date);
	// ⽀持拷⻉
	shared_ptr<Date> sp2(sp1);
	shared_ptr<Date> sp3(sp2);
	cout << sp1.use_count() << endl;
	sp1->_year++;
	cout << sp1->_year << endl;
	cout << sp2->_year << endl;
	cout << sp3->_year << endl;
	// ⽀持移动,但是移动后sp1也悬空,所以使⽤移动要谨慎
	shared_ptr<Date> sp4(move(sp1));
	return 0;
}

其他有关智能指针的使用说明:

1、make_shared
shared_ptr 除了⽀持⽤指向资源的指针构造,还⽀持 make_shared
这一可变参数模板⽤初始化资源对象的值直接构造,make_shared相比手动申请资源效率更高,因为它会将计数器和申请的资源放在一起,优化了内存碎片问题。
make_shared 不是 std::shared_ptr 的成员函数,而是一个独立的非成员函数模板,定义在 头文件中。

在这里插入图片描述

	shared_ptr<Date> sp1(new Date(2024, 9, 11));
	shared_ptr<Date> sp2 = make_shared<Date>(2024, 9, 11);

2、operator bool

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

	shared_ptr<Date> sp1(new Date(2024, 9, 11));
	shared_ptr<Date> sp2 = make_shared<Date>(2024, 9, 11);
	auto sp3 = make_shared<Date>(2024, 9, 11);
	shared_ptr<Date> sp4;

	// if (sp1.operator bool()) 1
	if (sp1) //2  1、2两种写法含义相同
		cout << "sp1 is not nullptr" << endl;
	if (!sp4)
		cout << "sp1 is nullptr" << endl;

3、构造函数用explicit修饰
标准库中shared_ptr 和 unique_ptr 的构造函数都使⽤了explicit修饰,可以阻止普通指针隐式类型转换成智能指针对象,是 C++ 标准为了强制开发者显式管理内存所有权而设计的安全机制。

	// 报错
	shared_ptr<Date> sp5 = new Date(2024, 9, 11);
	unique_ptr<Date> sp6 = new Date(2024, 9, 11);

关于定制删除器:
智能指针析构时默认是进⾏delete释放资源,这也就意味着如果不是new出来的资源,交给智能指针管理,析构时就会崩溃。
我们知道new必须和delete匹配,new[]必须和delete[]匹配,其他申请资源的方式也需要用和它匹配的方式释放资源,如果错配系统就会崩溃。所以为了解决这一问题,智能指针⽀持在构造时给⼀个删除器,所谓删除器本质就是⼀个可调⽤对象,这个可调⽤对象中实现你想要的释放资源的⽅式,当构造智能指针时,给了定制的删除器,在智能指针析构时就会调⽤删除器去释放资源。因为new[]经常使⽤,所以为了简洁⼀点,unique_ptr和shared_ptr都特化了⼀份[]的版本,使⽤如下方式就可以管理new []的资源。

unique_ptr<Date[]> up1(newDate[5]);
shared_ptr<Date[]> sp1(new Date[5]); 

关于C++标准库的定制删除器小编想特别吐槽一下,unique_ptr和shared_ptr⽀持删除器的⽅式有所不同,unique_ptr是在类模板参数⽀持的,shared_ptr是构造函数参数⽀持的:

在这里插入图片描述
在这里插入图片描述

删除器的本质就是可调用对象,所有可调用对象类型都可以只通过构造函数参数传递,但是类模板参数传递时只有仿函数可以只通过类模板参数,因为仿函数是自定义的类
/结构体,类型可显式命名并且支持默认构造,所以仿函数定义的对象可以直接使用,函数指针和lambda表达式不仅要通过类模板参数传递,还需要通过构造函数参数传递,其中函数指针是因为“类型”与 “值”分离,函数指针类型定义出的对象的空指针,需要在构造函数参数部分显示传递实例函数指针,lambda表达式是因为类型无法显式命名,所以只能通过decltype推导对象类型再传递,并且lambda表达式没有默认构造能力,因此必须通过构造函数传递具体的lambda 实例。
所以在需要使用定制删除器的场景更推荐使用shared_ptr。

//当删除器是lambda表达式时
auto delArrOBJ = [](Date* ptr) {delete[] ptr; };
//decltype推导对象类型
unique_ptr<Date, decltype(delArrOBJ)> up4(new Date[5], delArrOBJ)

四、智能指针的原理

下⾯我们模拟实现了auto_ptr和unique_ptr的核⼼功能,这两个智能指针的实现⽐较简单,⼤家了解⼀下原理即可。小编这里提一下unique_ptr禁用拷贝构造和拷贝赋值的实现,因为拷贝构造和拷贝赋值是默认成员函数,我们不写编译器会自动生成,所以需要显示写出拷贝构造和拷贝赋值并把它们=delete,这样才能彻底禁用它们。

namespace wusaqi
{
	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;
	};

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

⼤家要重点关注shared_ptr是如何设计的,小编来分步拆解:
1、首先实现出智能指针的基本功能
我们要明确智能指针通常是用来管理动态申请的资源的,所以析构需要把资源delete掉,并且析构前需要先判断资源是否为空,不为空再delete。

namespace wusaqi
{
	template <class T>
	class shared_ptr
	{
	public:
		//默认构造
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
		{
		}

		//析构
		~shared_ptr()
		{
			if (_ptr)
			{
				//_str不为空再析构
				delete _ptr;
				_ptr = nullptr;
			}
		}

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

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

	private:
		T* _ptr;
	};
}

2、引用计数功能实现
首先我们能想到的是创建一个静态成员变量计数器来记录有多少指针指向同一资源,但其实是行不通的,因为这样的话所有同类型对象都共享同一计数器,而我们期望的是对每份资源各自分配一个独立的计数器来记录指向这份资源的引用对象,例如下图的两个红色资源。

在这里插入图片描述

所以实现计数器的最佳思路是创建一个int*类型的指针变量,用该指针指向一个动态开辟的整型变量,用该整型变量来充当计数器。
这里小编有个问题,这个整型变量为什么要动态开辟呢?原因是该整型变量的生命周期要和该整型变量所在的shared_ptr对象管理的资源生命周期匹配,交由程序员控制。
接着优化其他函数接口,构造函数增加初始化_pcount。
拷贝构造要把两个成员变量初始化为和源对象的成员变量一样,并且拷贝构造一次就需要把计数器加一,所以在拷贝构造体内还需要把计数器加一。
析构函数这里不用再关注_ptr是否为空,因为delete空指针的安全的,这里我们主要关注计数器是否为0,为0才调用析构函数。并且每次调用析构函数都需要把计数器减一。

namespace wusaqi
{
	template <class T>
	class shared_ptr
	{
	public:
		//默认+带参构造
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			, _pcount(new int(1))
		{
		}

		//拷贝构造
		shared_ptr(const shared_ptr<T>& ptr)
			:_ptr(ptr._ptr)
			, _pcount(ptr._pcount)
		{
			//拷贝构造后又有一份指针指向_ptr指向的资源
			//把计数器加一
			++(*_pcount);
		}

		//析构
		~shared_ptr()
		{
			if (--(*_pcount) == 0)
			{
				//计数器为0则释放资源和计数器
				delete _ptr;
				delete _pcount;
				_ptr = nullptr;
				_pcount = nullptr;
			}
		}

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

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

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

int main()
{
	wusaqi::shared_ptr<Date> ptr1 = new Date;
	wusaqi::shared_ptr<Date> ptr2 = ptr1; //拷贝构造

	wusaqi::shared_ptr<Date> ptr3 = new Date;
	return 0;
}

3、赋值运算符重载的实现
首先要把两个成员变量给给赋值对象、计数器加一这是我们能想到的,但是仅这样就足够了吗?我们以上面图片为例,把sp3赋值给sp1:sp1 =
sp3,让sp1也指向左边的资源,那么sp1之前指向的左边资源怎么办呢?所以我们还需要处理sp1之前指向的资源,这里是示例我们不需要处理左边的资源,因为还有sp2在维护,如果sp1是最后一个指向左边资源的指针,我们就需要把它释放,所以赋值操作是有可能释放资源的。具体的做法就是赋值前把被赋值对象计数器减一,如果等于0就需要释放被赋值对象指向的资源,否则不需要做处理。
除此之外还需要处理自己给自己赋值的问题,智能指针这里自赋值有两种情况,两个相同对象s1 = s1是自赋值,两个不同对象s1 =
s2也是自赋值,因为s1、s2指向同一份资源,所以判断自赋值不能只判断两个对象的this指针:this !=
&sp,而应该判断两个对象是否指向同一份资源: _ptr != sp._ptr。
赋值运算符重载和析构函数都要用同一份代码,所以我们把它封装成release函数。

//释放资源
void release()
{
	if (--(*_pcount) == 0)
	{
		//计数器为0则释放资源和计数器
		delete _ptr;
		delete _pcount;
		_ptr = nullptr;
		_pcount = nullptr;
	}
}

//赋值运算符重载
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
	//避免自赋值
	if (_ptr != sp._ptr)
	{
		release();

		_ptr = sp._ptr;
		_pcount = sp._pcount;
		++(*_pcount);
	}

	return *this;
}

4、定制删除器的实现
至此,shared_ptr大部分功能我们已经实现完毕,还有最后一个问题现在shared_ptr释放资源逻辑被写死成delete,所以只支持释放new出来的资源,如果要释放通过new[]、malloc等等其他方式申请的资源程序就会崩。我们前面介绍了标准库为了解决这一问题专门为unique_ptr和shared_ptr都特化了⼀份[]的版本,但是这只适用于new[]申请的资源,如果是malloc出来的资源还需要用free,所以又引入自定义删除器,关于自定义删除器具体说明在前面介绍C++标准库智能指针的使⽤部分,这里我们聚焦为shared_ptr实现一个定制删除器。
首先因为删除器有可能有多种类型,所以我们还需要再创建一个带模板参数D的专门用于接受传递删除器的构造函数,但是如果我们直接用D类型对象_del接受传递的删除器就会导致_del的作用域只在构造函数内部,无法在释放资源的时候使用,所以我们还需要用一个能作用于整个类的成员变量接受删除器参数,用function类型成员变量就是这里的首选,它可以用来接受各种删除器类型,然后将释放资源:delete _ptr 改为 使用删除器释放资源: _del(_ptr)。 如果我们没有显示传递删除器就需要用到缺省值,这里我们在成员变量声明处给一个lambda表达式缺省值,默认delete释放资源。这里不能在构造函数:
shared_ptr(T* ptr, D del)
给删除器缺省值,这样就会依发传递单参数时的二义性,普通构造和模板构造都能匹配,所以这里的最佳实践是在成员变量声明处给删除器缺省值,普通构造参数位置给ptr缺省值,这样传递0 个或 1 个参数时匹配普通构造,使用默认删除器,传递 2 个参数时匹配模板构造,使用用户定制的删除器。

		//默认+带参构造(接受 0 个或 1 个参数)
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			, _pcount(new int(1))
		{
		}

		//支持传递删除器的构造函数(接受 2 个参数)
		template<class D>
		shared_ptr(T* ptr, D del)
			:_ptr(ptr)
			, _pcount(new int(1))
			, _del(del)
		{
		}

  		//释放资源
		void release()
		{
			if (--(*_pcount) == 0)
			{
				//计数器为0则释放资源和计数器
				_del(_ptr);
				delete _pcount;
				_ptr = nullptr;
				_pcount = nullptr;
			}
		}
		
	private:
		T* _ptr = nullptr;
		int* _pcount;
		function<void(T*)> _del = [](T* ptr) {delete ptr; };

shared_ptr源码

namespace wusaqi
{
	template <class T>
	class shared_ptr
	{
	public:
		//默认+带参构造(接受 0 个或 1 个参数)
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			, _pcount(new int(1))
		{
		}

		//支持传递删除器的构造函数(接受 2 个参数
		template<class D>
		shared_ptr(T* ptr, D del)
			:_ptr(ptr)
			, _pcount(new int(1))
			, _del(del)
		{
		}

		//拷贝构造
		shared_ptr(const shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			, _pcount(sp._pcount)
			, _del(sp._del)
		{
			//拷贝构造后又有一份指针指向_ptr指向的资源
			//把计数器加一
			++(*_pcount);
		}

		//释放资源
		void release()
		{
			if (--(*_pcount) == 0)
			{
				//计数器为0则释放资源和计数器
				_del(_ptr);
				delete _pcount;
				_ptr = nullptr;
				_pcount = nullptr;
			}
		}

		//赋值运算符重载
		shared_ptr<T>& operator=(const shared_ptr<T>& sp)
		{
			//避免自赋值
			if (_ptr != sp._ptr)
			{
				release();

				_ptr = sp._ptr;
				_pcount = sp._pcount;
				_del = sp._del;
				++(*_pcount);
			}

			return *this;
		}

		//析构
		~shared_ptr()
		{
			release();
		}

		T* get() const
		{
			return _ptr;
		}

		int use_count() const
		{
			return *_pcount;
		}

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

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

	private:
		T* _ptr;
		int* _pcount;
		function<void(T*)> _del = [](T* ptr) {delete ptr; };
	};
}

五、shared_ptr和weak_ptr

shared_ptr循环引⽤问题

shared_ptr⼤多数情况下管理资源⾮常合适,⽀持RAII,也⽀持拷⻉。但是在循环引⽤的场景下会导致资源没得到释放内存泄漏,所以我们要认识循环引⽤的场景和资源没释放的原因,并且学会使⽤weak_ptr解决这种问题。
如下图所述场景,n1和n2析构后,管理两个节点的引⽤计数减到1。

在这里插入图片描述

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);
	cout << n1.use_count() << endl;
	cout << n2.use_count() << endl;
	n1->_next = n2;  //结构体原生指针无法赋值,不支持隐式类型转换
	n2->_prev = n1;  //只能智能指针类型赋给智能指针类型
	cout << n1.use_count() << endl;
	cout << n2.use_count() << endl;

	return 0;
}

上面代码中我们可以看到ListNode结构体的的_next和_prev指针类型并不是它自己的结构体类型,而是shared_ptr指针类型,原因是这个示例中两块资源是由智能指针维护的,而智能指针是不支持隐式类型转换的,所以只有当_next和_prev的类型也为智能指针类型时才能实现互相指向的操作。

  1. 右边的节点什么时候释放呢,左边节点中的_next管着呢,_next析构后,右边的节点就释放了。
  2. _next什么时候析构呢,_next是左边节点的的成员,左边节点释放,_next就析构了。
  3. 左边节点什么时候释放呢,左边节点由右边节点中的_prev管着呢,_prev析构后,左边的节点就释放了。
  4. _prev什么时候析构呢,_prev是右边节点的成员,右边节点释放,_prev就析构了。

⾄此逻辑上成功形成回旋镖似的循环引⽤,谁都不会释放就形成了循环引⽤,导致内存泄漏。
循环引用的核心本质确实可以简化理解为:两块资源互相通过强引用(如shared_ptr)持有对方,导致各自的引用计数无法减到0,因此都不会被释放,最终造成内存泄漏。
把ListNode结构体中的_next和_prev改成weak_ptr,weak_ptr绑定到shared_ptr时不会增加它的引⽤计数,_next和_prev不参与资源释放管理逻辑,就成功打破了循环引⽤,解决了这⾥的问题。

weak_ptr

weak_ptr不⽀持RAII,也不⽀持访问资源,所以我们看⽂档发现weak_ptr构造时不⽀持绑定到资源,只⽀持绑定到shared_ptr,绑定到shared_ptr时,不增加shared_ptr的引⽤计数,那么就可以解决上述的循环引⽤问题。

weak_ptr的构造函数文档:

在这里插入图片描述

// weak_ptr不⽀持管理资源,不⽀持RAII
// weak_ptr是专⻔绑定shared_ptr,不增加他的引⽤计数,作为⼀些场景的辅助管理
std::weak_ptr<ListNode> wp(new ListNode); //不支持

利用weak_ptr解决循环引用问题:

struct ListNode
{
	int _data;
	//std::shared_ptr<ListNode> _next;
	//std::shared_ptr<ListNode> _prev;

	// 这⾥改成weak_ptr,当n1->_next = n2;绑定shared_ptr时
    // 不增加n2的引⽤计数,不参与资源释放的管理,就不会形成循环引⽤了
    std::weak_ptr<ListNode> _next;
    std::weak_ptr<ListNode> _prev;

	~ListNode()
	{
		cout << "~ListNode()" << endl;
	}
};

int main()
{
	std::shared_ptr<ListNode> n1(new ListNode);
	std::shared_ptr<ListNode> n2(new ListNode);
	cout << n1.use_count() << endl;
	cout << n2.use_count() << endl;
	n1->_next = n2;  
	n2->_prev = n1;
	cout << n1.use_count() << endl;
	cout << n2.use_count() << endl;

	return 0;
}

weak_ptr也没有重载operator*和operator->等,因为他不参与资源管理,那么如果他绑定的shared_ptr已经释放了资源,那么它通过其他方式绕开限制去访问资源就是很危险的。weak_ptr⽀持expired检查指向的资源是否过期,use_count也可获取shared_ptr的引⽤计数,weak_ptr想访问资源时,可以调⽤lock返回⼀个管理同一资源的shared_ptr,如果资源已经被释放,返回的shared_ptr是⼀个空对象,如果资源没有释放,则通过返回的shared_ptr访问资源是安全的。
以下是具体解释:

  1. 避免访问已销毁的资源(解决悬垂指针问题)
    weak_ptr 不持有资源的所有权,因此它指向的资源可能在任意时刻被最后一个 shared_ptr 释放(此时资源已销毁)。
    如果允许 weak_ptr 直接访问资源(比如重载 operator* 或 operator->),就可能出现 “悬垂指针” 问题:访问已经被销毁的内存,导致程序崩溃或未定义行为。
    而 lock() 方法的本质是 “原子性检查并获取资源的临时所有权”:
    若资源未被释放,lock() 返回一个有效的 shared_ptr(此时引用计数 +1,保证资源在该 shared_ptr 生命周期内不会被销毁);
    若资源已被释放,lock() 返回空的 shared_ptr,此时访问会被安全拦截(不会操作无效内存)。
  2. 符合 “所有权管理” 的设计逻辑 C++ 智能指针的核心是 “所有权” 管理:shared_ptr 通过引用计数明确资源的所有者,所有者存在则资源存活,所有者消失则资源销毁。 weak_ptr 作为 “观察者”,其设计目的是不干扰所有权(因此不增加引用计数)。如果允许它直接访问资源,就绕过了 shared_ptr 的所有权机制,破坏了“只有所有者才能安全操作资源” 的逻辑。 通过 lock() 转为 shared_ptr 后,相当于临时获取了资源的所有权(引用计数 +1),此时访问资源才符合 “所有权决定访问权限” 的设计原则,确保了资源管理的一致性。
//最简化版本的weak_ptr
namespace wusaqi
{
	template<class T>
	class weak_ptr
	{
	public:
		weak_ptr()
		{
		}

		weak_ptr(const shared_ptr<T>& sp)
			:_ptr(sp.get())
		{
		}

		weak_ptr<T>& operator=(const shared_ptr<T>& sp)
		{
			_ptr = sp.get();
			return *this;
		}

	private:
		T* _ptr = nullptr;
	};
}


int main()
{
	std::shared_ptr<string> sp1(new string("111111"));
	std::shared_ptr<string> sp2(sp1);
	std::weak_ptr<string> wp = sp1;
	cout << wp.expired() << endl;
	cout << wp.use_count() << endl;

	// sp1和sp2都指向了其他资源,则weak_ptr就过期了
	sp1 = make_shared<string>("222222");
	cout << wp.expired() << endl;
	cout << wp.use_count() << endl;
	sp2 = make_shared<string>("333333");
	cout << wp.expired() << endl;
	cout << wp.use_count() << endl;

	//刚才wp过期了,重新赋值一下
	wp = sp1;
	//std::shared_ptr<string> sp3 = wp.lock();
	//weak_ptr想访问资源需要通过lock返回管理同一份资源的shared_ptr
	//再通过这个shared_ptr间接访问资源
	auto sp3 = wp.lock();
	cout << wp.expired() << endl;
	cout << wp.use_count() << endl;
	*sp3 += "###";
	cout << *sp1 << endl;

	return 0;
}

六、内存泄漏

什么是内存泄漏,内存泄漏的危害

什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使⽤的内存,⼀般是忘记释放或者发⽣异常释放程序未能执⾏导致的。内存泄漏并不是指内存在物理上的消失,⽽是应⽤程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因⽽造成了内存的浪费。
内存泄漏的危害:普通程序运⾏⼀会就结束了出现内存泄漏问题也不⼤,进程正常结束,⻚表的映射关系解除,物理内存也可以释放。⻓期运⾏的程序出现内存泄漏,影响很⼤,如操作系统、后台服务、⻓时间运⾏的客⼾端等等,不断出现内存泄漏会导致可⽤内存不断变少,各种功能响应越来越慢,最终卡死。

如何避免内存泄漏

  • ⼯程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下⼀条智能指针来管理才有保证。
  • 尽量使⽤智能指针来管理资源,如果⾃⼰场景⽐较特殊,采⽤RAII思想⾃⼰造个轮⼦管理。
  • 定期使⽤内存泄漏⼯具检测,尤其是每次项⽬快上线前,不过有些⼯具不够靠谱,或者是收费。
  • 总结⼀下:内存泄漏⾮常常⻅,解决⽅案分为两种:1、事前预防型。如智能指针等。2、事后查错型。如泄漏检测⼯具。

以上就是小编分享的全部内容了,如果觉得不错还请留下免费的关注和收藏
如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~

在这里插入图片描述


网站公告

今日签到

点亮在社区的每一天
去签到