C++11: 智能指针(unique_ptr,shared_ptr和weak_ptr的使用及简单实现)

目录

1. 为何需要智能指针?

1.1 抛异常场景

1.2 什么是内存泄漏

2. 智能指针的原理

2.1 RAII技术

2.2 补充实现

3. auto_ptr

4. unique_ptr

4.1 使用及原理

4.2 定制删除器

5. shared_ptr

5.1 shared_ptr简介及使用

5.2 shared_ptr简单实现

5.2.1 基本框架

5.2.2 拷贝构造 赋值重载

5.2.3 删除器

5.2.4 其他接口函数及测试

6. weak_ptr

6.1 循环引用

6.2 weak_ptr使用

6.3 原理及简单实现

总结


1. 为何需要智能指针?

1.1 抛异常场景

在下面的场景中,Fun函数在堆上开辟了两个对象。如果Add函数中的两个参数相加为0,就会抛异常。抛出的异常被catch关键字捕获之后,会执行catch内的代码,不会再执行Func函数后面的内容。而catch内部代码可能会忘记释放之前的对象,就会造成内存泄漏。

//假设有特殊要求相加结果不能为0,为0抛异常
int Add(int x, int y)
{
	if (x + y == 0)
		throw "相加为0";
	
	return x + y;
}

void Func()
{
	int* p1 = new int;
	int* p2 = new int;

	try
	{
		int x, y;
		cin >> x >> y;
		Add(x, y);
	}
	catch(...)
	{
		delete p1;
        throw;
	}

	delete p1;
	delete p2;
}

int main()
{
	try
	{
		Func();
	}
	catch (exception& e)
	{
		cout << e.what() << endl;
	}

	return 0;
}

1.2 什么是内存泄漏

内存泄漏是指在程序运行过程中,由于疏忽或错误而未能释放不再使用的内存,导致这部分内存得不到回收。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,从而造成了内存的浪费。

内存泄漏的影响可能包括:

  • 内存使用量增加:随着内存泄漏的累积,程序占用的内存会越来越多。
  • 程序性能下降:频繁的内存分配和释放可能导致内存碎片化,进而影响程序性能。
  • 系统资源耗尽:长时间运行的程序可能会耗尽系统内存,特别是对于操作系统游戏服务器数据库系统客户端应用程序等关键任务程序,会导致响应越来越满,最终卡死。

2. 智能指针的原理

2.1 RAII技术

RAII(Resource Acquisition Is Initialization)是一种编程技术,主要用于C++等语言中,它将资源的获取与对象的生命周期绑定在一起,以确保资源在使用完毕后能够被正确释放,从而防止资源泄漏。

RAII的基本思想是:将资源的获取封装在一个对象的构造函数中。将资源的释放封装在同一个对象的析构函数中。利用对象的自动生命周期管理(即对象创建时自动调用构造函数,对象销毁时自动调用析构函数)来管理资源。这种做法有两大好处:

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

如下面代码所示,将资源创建跟SmartPtr类对象的生命周期绑定在一起,不管什么时候抛异常,执行跳到哪一步,都会随着对象的销毁并释放资源。

struct A
{
	~A()
	{
		cout << "~A()" << endl;
	}
};

template<class T>
class SmartPtr 
{
public:
	SmartPtr(T* ptr = nullptr)
		: _ptr(ptr)
	{}

	~SmartPtr()
	{
		if (_ptr)
			delete _ptr;
	}
private:
	T* _ptr;
};

//假设有特殊要求相加结果不能为0,为0抛异常
int Add(int x, int y)
{
	if (x + y == 0)
		throw "相加为0";
	
	return x + y;
}

void Func()
{
    SmartPtr<A> sp1(new A);
    SmartPtr<A> sp2(new A);

	try
	{
		int x, y;
		cin >> x >> y;
		Add(x, y);
	}
	catch(...)
	{
        throw;
	}
}

int main()
{
    try
	{
		Func();
	}
	catch (exception& e)
	{
		cout << e.what() << endl;
	}

    return 0;
}

运行结果如下:

2.2 补充实现

上面实现的SmartPtr类还不算智能指针。因为他只能进行资源的获取和释放操作,却不能类似于指针,进行解引用操作和使用->访问所指向的空间内容。我们需要重载一下*和->符号,达到类似指针的操作。

如下面的代码所示,先创建一个日期类,成员变量有表示年月日的整型。使用SmartPtr类创建两个对象,对象类型分别是日期类和整型。可以使用该类进行类似于指针的操作,对日期类的成员变量进行修改,并调用Print函数打印年月日。对于内置类型也可以进行操作。

struct Date
{
	int _year;
	int _month;
	int _day;

	Date(int year = 1900, int month = 1, int day = 1)
		:_year(year)
		, _month(month)
		, _day(day)
	{}

	void Print()
	{
		cout << "日期:" << _year << "/" << _month << "/" << _day << endl;
	}

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

template<class T>
class SmartPtr
{
public:
	SmartPtr(T* ptr = nullptr)
		: _ptr(ptr)
	{}

	~SmartPtr()
	{
		if (_ptr)
			delete _ptr;
	}

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

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

private:
	T* _ptr;
};

int main()
{
	SmartPtr<Date> sp1(new Date);
	SmartPtr<int> sp2(new int);

	sp1->_year = 2024;
	sp1->_month = 9;
	sp1->_day++;
	sp1->Print();

	*sp2 = 3;
	cout << "sp2指向的内容->" << *sp2 << endl;

	return 0;
}

运行结果如下:

3. auto_ptr

auto_ptr 是 C++98 标准库中的一个智能指针,用于管理动态分配的对象,以避免内存泄漏。在 C++11 标准发布后,auto_ptr 被废弃,并在 C++17 标准中被彻底移除。它的功能由 unique_ptr 取代,因为 unique_ptr 提供了更安全、更灵活的内存管理功能。

auto_pt的定义位于头文件<memory>中。它是一个模版类,可以管理任何类型的动态分配的对象。使用方式和上面实现的SmartPtr类类似。

#include <memory>

void test_auto1()
{
	auto_ptr<Date> ap1(new Date);
	ap1->_year = 2024;
	ap1->Print();
}

运行结果如下:

看着上面的代码,发现auto_ptr好像也没什么问题,那为什么会被弃用呢?

void test_auto2()
{
	auto_ptr<Date> ap1(new Date);
	auto_ptr<Date> ap2(ap1);

	ap1->_day++;
}

运行这段代码,你会发现程序直接崩溃了,这是为什么?

 调试中,我们可以在监视窗口中可以变量。初始化ap1时,如下图所示,没有问题。

但是ap2调用拷贝ap2进行初始化时,会发现ap1变量内部的指针被置为空指针。也就是说,被其他auto_ptr对象拷贝之后,内部会被悬空。所以再使用ap1访问元素,会导致使用空指针访问。

这种将管理权转移的方法,会导致被拷贝对象悬空,进而造成访问空指针的现象。所以auto_ptr不建议使用(甚至可以说是禁止使用)。auto_ptr实现的原理就是进行拷贝构造函数时,将被拷贝对象的指针置为空指针

4. unique_ptr

4.1 使用及原理

如果你使用的指针不需要拷贝构造,就可以使用C++11推出的unique_ptr。unique_ptr也是一个模板类,用法跟auto_ptr类似。虽然不可以进行拷贝构造,但是可以使用移动构造,因为移动构造会窃取原来对象的资源。

void test_unique()
{
	unique_ptr<Date> up1(new Date);
    up1->_day = 10;

	//禁止拷贝构造
	unique_ptr<Date> up2(up1);
	//可以使用移动构造,此时up1不能使用了。
	unique_ptr<Date> up3(move(up1));
    up3->_day = 5;
}

unique_ptr的原理也比较简单,使用delete关键字禁用了拷贝构造函数和赋值重载函数

4.2 定制删除器

unique_ptr其实有两个模版参数,第一个模版参数是资源的类型,第二个模版参数是删除器,里面重载()符号的函数,内部是释放资源的代码。如果不传第二个模版参数,默认是使用default_delete类删除器。default_delete删除器释放资源的方式就是正常的delete操作。

unique_ptr默认删除器是使用delete释放资源,对于创建一块资源的对象,可以正常处理。如果对象使用多个资源初始化,如下面的第二行代码所示,运行的程序会报错。因为使用new[]创建的对象,要使用delete[]连续释放。

void test_unique2()
{
	unique_ptr<Date> up1(new Date);
	//会报错
	unique_ptr<Date> up2(new Date[5]);
}

我们可以写一个类,重载()符号。初始化unique_ptr对象时,第二个模版参数写上该类,就可以使用delete[ ]释放连续的资源。运行程序就不会报错。

template<class T>
class DeleteArray
{
public:
	void operator()(T* ptr)
	{
		delete[] ptr;
	}
};

void test_unique2()
{
	unique_ptr<Date> up1(new Date);
	
	unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);
}

如果需要使用delete[]释放资源,其实可以不用实现一个删除器类。unique_ptr给出了一个array specialization特化版本的删除器,是需要在第一个模版参数后面加个“[ ]”即可。

void test_unique2()
{
	unique_ptr<Date> up1(new Date);
	
	unique_ptr<Date[]> up2(new Date[7]);
}

由于可以自定义释放资源方式的类,我们可以使用unique_ptr来管理文件指针。只需要创建一个类,重载()符号函数中使用fclose即可。

class DeleteFclose
{
public:
	void operator()(FILE* ptr)
	{
		cout << "fclose" << ptr << endl;
		fclose(ptr);
	}
};

void test_unique2()
{
	unique_ptr<Date> up1(new Date);
	unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);

	unique_ptr<FILE, DeleteFclose> up3(fopen("test.cpp", "r"));
}

运行结果如下:

5. shared_ptr

5.1 shared_ptr简介及使用

如果你想使用拷贝构造和赋值重载函数,那么就可以使用C++11推出的shared_ptr。shared_ptr使用了引用计数的方式支持多个对象共享一块资源

  • shared_ptr内部,对于共享同一块资源的对象,都维护着同一份计数。计数用来记录该资源被几个对象共享。
  • 在对象被销毁时,不在使用该资源,该对象的引用计数减一,但是不释放该资源。
  • 只有最后一个管理该资源的对象被删除,那么该资源就要被释放。

shared_ptr的用法跟unique_ptr类似,多了一些接口函数。get函数是获得该对象管理资源的指针,use_count函数可以查看共享资源的对象有几个,下面有两个对象共享该资源。

void test_shared1()
{
	shared_ptr<Date> sp1(new Date);
	sp1->_day = 5;

	shared_ptr<Date> sp2(sp1);
	sp2->_year = 2024;

	Date* ptr = sp1.get();
	ptr->_month = 9;

	sp1->Print();
	cout <<"引用计数:" << sp1.use_count() << endl;
}

运行结果如下:

如上图,可知shared_ptr只有一个模版参数,不能像unique_ptr在模版中传入删除器类,以此决定如何释放资源。不过可以在构造函数传个删除器类。对于使用new[]创建多个资源的对象,可以在传模版参数后面加一个“[ ]”,会使用delete[]释放资源。

如下面的代码,第一行代码使用的是模版特化,可以释放多个连续的资源。第二行代码,初始化sp2对象时,传入DeleteFclose的匿名对象,就可以使用shared_ptr管理文件指针。

class DeleteFclose
{
public:
	void operator()(FILE* ptr)
	{
		cout << "fclose" << ptr << endl;
		fclose(ptr);
	}
};

void test_shared2()
{
	shared_ptr<Date[]> sp1(new Date[5]);

	shared_ptr<FILE> sp2(fopen("test.cpp", "w"), DeleteFclose());
}

5.2 shared_ptr简单实现

5.2.1 基本框架

实现一个shared_ptr类,需要使用模版参数。成员变量一开始有模版类型对应的指针。那么计数该用什么变量存储?

  • 如果使用普通整型变量存储计数,会发现共享同一块资源的对象,每个计数变量都是独立的。当删除其中一个对象,其他对象的计数变量不会受影响,就无法确定有多少个对象管理该资源。
  • 如果使用静态整型变量,那么该计数变量属于该类的所有对象,管理两块不同的资源对象初始化时,都要对该变量加一。这样也无法判断某个资源有多少个对象管理。
  • 所以需要使用整型指针,对于同一块资源,可以清楚记录有多少个对象在进行共享。而不会干扰其他资源记录对象。

构造函数_pcount初始化为1,说明有一个对象正在管理该资源。析构函数只有解引用_pcount指针为0的情况下,即最后一个管理该资源的对象被销毁,才能释放_ptr指向的资源和_pcount指针,并且都置为空指针。并把这部分封装成release函数,因为赋值重载函数也需要使用到该函数。

namespace Rustle
{
	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr)
			: _ptr(ptr)
			,_pcount(new int(1))
		{}

		void release()
		{
			if (--(*_pcount) == 0)
			{
				delete _ptr;
				delete _pcount;
				_ptr = nullptr;
				_pcount = nullptr;
			}
		}

		~shared_ptr()
		{
			release();
		}

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

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

	private:
		T* _ptr;
		int* _pcount;

	};

5.2.2 拷贝构造 赋值重载

  • 拷贝构造函数只需要将被拷贝对象的资源和计数拷贝过来即可。再把计数加一。
  • 赋值重载函数需要先调用release函数,释放先前的资源和计数。将拷贝传参对象的资源和计数,再把计数加一。
		//s2(s1)
		shared_ptr(const shared_ptr<T>& sp)
			: _ptr(sp._ptr)
			, _pcount(sp._pcount)
		{
			++(*_pcount);
		}

        //s2 = s1
		shared_ptr<T> operator=(const shared_ptr<T>& sp)
		{
			if (_ptr != sp._ptr)
			{
				release();

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

			return *this;
		}

5.2.3 删除器

shared_ptr的使用中,在构造函数中可以传入自定义的删除器,删除器中可以定义各种释放资源的方法。因此,我们也需要实现一个删除器,需要使用包装器。

包装器可以封装仿函数类,做到类似使用函数的形式。定义一个包装器类,模版传函数返回值和函数参数类型。我们给一个缺省值,Default_delete类是使用delete释放资源。

	template<class T>
	struct Default_delete
	{
		void operator()(T* ptr)
		{
			delete ptr;
		}
	};

    class shared_ptr
    {
    public:
        //使用包装器
		void release()
		{
			if (--(*_pcount) == 0)
			{
				_del(_ptr);//类似使用函数的形式
				delete _pcount;
				_ptr = nullptr;
				_pcount = nullptr;
			}
		}

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

        //...
    private:
        //...
        //包装器
        function<void(T* ptr)> _del = Default_delete<T>();
    };

5.2.4 其他接口函数及测试

  • get函数是获得管理资源的指针变量。
  • use_count函数可以获取管理该资源的对象个数。
		T* get() const
		{
			return _ptr;
		}

		int use_count() const
		{
			return *_pcount;
		}

#include "shared_ptr.h"

void test_MySharedPtr()
{
	Rustle::shared_ptr<Date> sp1(new Date);
	sp1->_day = 5;
	sp1->_month = 9;
	sp1->_year = 2024;
	sp1->Print();

	Rustle::shared_ptr<Date> sp2(new Date[5], DeleteArray<Date>());
	Rustle::shared_ptr<FILE> sp3(fopen("test.cpp", "w"), DeleteFclose());
}
​

运行结果如下:

6. weak_ptr

weak_ptr是跟shared_ptr配套使用的,为了应对循环引用这个场景。

6.1 循环引用

下面的代码就是循环引用的示例。创建一个双向链表,链表指针的前后指针使用shared_ptr进行管理。这样才可以进行相互赋值。

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结点计数:" << n1.use_count() << endl;
	cout << "n2结点计数:" << n2.use_count() << endl;

	n1->_next = n2;
	n2->_prev = n1;

	cout << endl;
	cout << "n1结点计数:" << n1.use_count() << endl;
	cout << "n2结点计数:" << n2.use_count() << endl;

	return 0;
}

再创建两个shared_ptr对象管理ListNode类资源,当其中任意一个对象指向另外一个对象,并且不是互相指向时,是没有问题的。运行结果如下,资源被正常释放。

但是两个对象内部指针互相指向,如上面的代码所示。运行结果如下,两块资源都没有释放

观察下图,跟着下面的推导过程一步一步走,你会发现下面的推导过程形成了一个循环。也就是说,无法释放这两个结点所指向的资源。这就是循环引用。

6.2 weak_ptr使用

  • 上面是weak_ptr构造函数的原型。我们可以看到weak_ptr不支持给一块资源初始化。支持正常拷贝构造。最值得注意的是支持使用shared_ptr对象进行构造。

  • weak_ptr的赋值重载函数不仅提供了对同类型的赋值操作,还有对shared_ptr对象的赋值操作。

下面的代码是使用示例。

void test_weak()
{
	//不支持管理资源,不支持RAII计数
	std::weak_ptr<Date> wp1(new int);//error

	//默认构造,拷贝构造
	std::weak_ptr<Date> wp2;
	std::weak_ptr<Date> wp3(wp2);

	//赋值重载
	std::weak_ptr<Date> wp4;
	wp4 = wp2;

	//对shared_ptr的构造
	std::shared_ptr<ListNode> sp1(new ListNode);
	std::weak_ptr<ListNode> wp5(sp1);

	//对shared_ptr赋值重载
	std::weak_ptr<ListNode> wp6;
	wp6 = sp1;
}

6.3 原理及简单实现

struct ListNode
{
	int _data;
	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结点计数:" << n1.use_count() << endl;
	cout << "n2结点计数:" << n2.use_count() << endl;

	n1->_next = n2;
	n2->_prev = n1;

	cout << endl;
	cout << "n1结点计数:" << n1.use_count() << endl;
	cout << "n2结点计数:" << n2.use_count() << endl;

	return 0;
}

前面有提到weak_ptr是为了解决循环引用这个场景而产生的。我们将ListNode类中的_next和_prev类型换成weak_ptr。并分别打印两次n1结点和n2结点的计数。

运行程序发现这两块资源得到释放,并且在n1结点和n2结点互相指向时。因为weak_ptr会获得管理这块资源的权利,但是计数不会发生改变。这样在释放n1和n2时,两个资源计数都会减到0。

实现简单的weak_ptr,最主要的是支持shared_ptr对象对weak_ptr对象的构造和赋值。

  • 默认构造函数只要将内部指针变量置为空指针即可。
  • shared_ptr对象对weak_ptr对象的构造,可使用shared_ptr的get函数获取内部管理资源的指针。赋值重载函数也是类似的做法。
	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;
	};

将上述循环引用例子中的智能指针类替换成自己实现的智能指针类,进行测试。运行结果如下,没有问题。

当然,weak_ptr还有一些其他接口函数,也可以尝试去实现。


总结

正确使用智能指针是避免内存泄漏的关键策略。在C++中,智能指针提供了一种自动管理内存资源的方式,从而大幅降低了因疏忽而导致的内存泄漏风险。auto_ptr和unique_ptr作为早期的智能指针实现,其原理较为简单,主要是通过独占所有权模型来防止内存泄漏。然而,在需要多个对象共同管理同一块资源的情况下,shared_ptr和weak_ptr的配套使用则显得尤为重要。

创作不易,希望这篇文章能给你带来启发和帮助,如果喜欢这篇文章,请留下你的三连,你的支持的我最大的动力!!!

ee192b61bd234c87be9d198fb540140e.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值