【C++11】常见智能指针总结

一. 内存泄漏

1. 什么是内存泄漏?

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

下面是两种常见的内存泄漏场景:

void MemoryLeaks()
{
	// 1、内存申请了,但是忘记释放
	int* p1 = (int*)malloc(sizeof(int));
	int* p2 = new int;
	
	// 2、抛异常导致内存泄漏
	try
	{
		int* p3 = new int[10];
		throw "exception";
		delete[] p3; //上面抛出了异常,导致 delete[] 没有执行
	}
	catch(const char* a)
	{
		cout << "catch exception" << endl;
	}
}

内存泄漏分类

在 C/C++ 程序中,我们一般只关心两方面内存泄漏的情况:

  • 堆内存泄漏(Heap Leak)
    堆内存指的是程序执行中依据需求,通过 malloc、calloc、realloc、new 等从堆中分配得到一块内存空间,用完后必须调用相应的 free 或者 delete 把它们释放。假设因为程序的设计错误导致这部分内存没有被释放,那么这块空间就会被白白浪费出去了,消耗系统资源,产生 Heap Leak。
  • 系统资源泄漏(System Resources Leak)
    指程序使用系统分配的资源,比如套接字、文件描述符、管道等,后续没有使用对应的函数把它们释放掉,导致系统资源浪费,严重可导致系统效能减少,系统运行不稳定。

2. 如何检测内存泄漏?

3. 如何避免内存泄漏?

  1. 工程前期设定和遵守良好的程序设计规范,养成良好的编码风格和习惯,申请的内存空间记得要进行相应的释放。
  2. 采用 RAII 思想或者智能指针来管理资源。
  3. 有些公司内部规范使用自己实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
  4. 出问题了使用内存泄漏工具检测。不过很多工具都不够靠谱,或者收费昂贵。

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

  • 事前预防型,如智能指针等
  • 事后查错型,如泄漏检测工具

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

下面我们实现一个最简单的智能指针,用它来管理我们所申请的系统资源。

1. RAII 机制

RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来管理程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。

实现方法:在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。本质上是把管理一份资源的责任交付给了一个对象。这种做法有两大好处:

  • 不需要我们显式地释放资源
  • 采用这种方式,被管理资源的生命周期和对象同步

RAII 机制的简单实现:

// 自定义一个智能指针模板
template<class T>
class SmartPtr
{
public:
	// 构造函数执行时把资源保存起来
	SmartPtr(T* ptr)
		:_ptr(ptr)
	{}

	// 析构函数执行时释放释放资源
	~SmartPtr()
	{
		delete _ptr;
		cout << "~SmartPtr() do delete" << endl;
	}
private:
	T* _ptr;
};

// 测试
int main()
{
	// 把申请到的堆空间交给 SmartPtr 类的对象来管理,后续不需要我们再手动释放
	SmartPtr<int> sp1(new int);
	SmartPtr<char> sp2(new char[30]);
	return 0;
}

//------输出结果------
~SmartPtr() do delete
~SmartPtr() do delete

2. 智能指针的原理

上述的 SmartPtr 还不能将其称为智能指针,因为这个类的对象还不具有以下两种指针的行为:

  • 指针可以通过 * 解引用获得它所指向的实体
  • 指针可以通过 -> 访问其内部的成员

因此,我们还需要在 SmartPtr 模板类中重载 * 、-> 这两个操作符:

template<class T>
class SmartPtr
{
public:
	// 构造函数执行时保存 ptr 的值
	SmartPtr(T* ptr)
		:_ptr(ptr)
	{}

	// 析构函数执行时释放 ptr 指向的内存空间
	~SmartPtr()
	{
		delete _ptr;
	}

	// 解引用操作符重载
	T& operator*()
	{
		return *_ptr;
	}

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

private:
	T* _ptr;
};

void TestSmartPtr()
{
	// 通过 SmartPtr 类去封装一个 new 出来的 pair<int, int> 键值对
	SmartPtr<pair<int, int>> sp(new pair<int, int>(1, 2));

	// 像原生指针一样,直接访问成员
	// (本来应该是 sp->->first 才对的,但编译器为了可读性,做了优化处理,只需要一个 -> 就够了)
	sp->first = 10; 
	sp->second = 20;

	// 像原生指针一样,解引用获得实体
	(*sp).first = 10;
	(*sp).second = 20;
}

总结:智能指针的原理

  1. 定义一个管理指针对象的类模板
  2. 具有 RAII 特性
  3. 重载 operator* 和 opertaor->,使它的实例化对象能够像指针一样去操作
  4. 最后还需要解决智能指针对象拷贝的问题(会在下文中说明,智能指针的分类和发展主要集中在这个问题的解决上)

3. 标准库中最早的智能指针(auto_ptr)

3.1 智能指针拷贝的问题

上面我们模拟实现的 SmartPtr 已经能够完成对资源的基本控制了:

  • 当 SmartPtr 对象的生命周期结束时,在其析构函数中自动释放资源。
  • 让 SmartPtr 对象能够像指针一样去解引用和使用 -> 访问指针所指向对象中的成员。

那这个智能指针对象能否拷贝和赋值呢?
在这里插入图片描述

可以看到,我们上面设计的智能指针还是有缺陷的:使用指针指针进行拷贝构造时,发生了浅(值)拷贝。最后析构时,同一块空间被 delete 了两次,导致程序崩溃。

3.2 auto_ptr 介绍

标准中最早的智能指针在 C++98 中被引入,叫做 auto_ptr :auto_ptr 官方文档

在 auto_ptr 中,针对对象拷贝时的处理方式为:进行资源控制权的转移。其核心在于拷贝时把资源交给另外一个对象去管理,之前管理这块资源的原对象会悬空,即始终保持一个资源只会被一个对象所管理。

在这里插入图片描述

3.3 auto_ptr 模拟实现

为了学习和了解 auto_ptr 智能指针,下面我们简单的模拟实现一个自己的 AutoPtr:

// 模拟实现 std::auto_ptr
template<class T>
class AutoPtr
{
public:
	// 构造函数 和 析构函数
	AutoPtr(T* ptr) :_ptr = ptr {}
	~AutoPtr() { delete _ptr; }

	// 拷贝构造
	AutoPtr<T>(AutoPtr<T>& ap)
		:_ptr(ap._ptr)
	{
		ap._ptr = nullptr;
	}

	// 赋值运算符重载
	AutoPtr<T>& operator=(AutoPtr<T>& ap)
	{
		// 防止自己给自己赋值
		if (this != &ap)
		{
			// 先释放当前对象中的资源
			delete _ptr;
			// 获得 ap 资源的控制权
			_ptr = ap._ptr;
			// 把 ap 中的指针置为空
			ap._ptr = nullptr;
		}
		return *this;
	}

	// * 和 -> 重载
	T& operator*() { return *_ptr; }
	T* operator->() { return _ptr; }

private:
	T* _ptr;
};

用我们自己实现的 AutoPtr 来对比标准库中的 auto_ptr,看看它们拷贝构造时的效果如何:

在这里插入图片描述

再来看看它们的赋值时的效果如何:

在这里插入图片描述

总结:auto_ptr 被设计出来之后就遭到了很多吐槽,因为在工程中,指针变量的拷贝是在所难免的,比如传参或者其它赋值场景时都要拷贝地址。而 auto_ptr 的拷贝原理是转移资源的控制权。一块资源只能被一个智能指针对象所管理,而且拷贝完之后还会导致原对象悬空的问题,这时如果没有注意,对原对象进行解引用或者 -> 访问成员,都会导致程序崩溃。所以对于 auto_ptr 一般是不推荐使用或者直接就禁止使用。

注意:auto_ptr 拷贝构造和赋值重载函数的形参不能加 const 修饰

不论是拷贝构造还是赋值:

  • 对于等号左边的对象而言,它都是要接受和管理被转移过来的新的资源。
  • 对于等号右边的对象而言,它的资源需要被转移给等号左边的对象,然后自己悬空。

等号右边的值,经过拷贝和赋值后,无论如何都会被置空,所以它一定会被修改的,我们在设计时就不能加 const 了。

在这里插入图片描述

4. 简单粗暴的防拷贝(unique_ptr)

C++98 标准之后,C++11 出来之前,boost 库中新设计出了一批智能指针,其中的几个智能指针后来被吸收到的 C++11 当中。首先就是比 auto_ptr 供更靠谱的 unique_ptr。

unique_ptr 原型为 boost 库中的 scoped_ptr,其针对拷贝场景的处理方式为:简单粗暴的禁止拷贝(内部直接把拷贝和赋值给禁了)。即这个智能指针对象所管理的资源不能被拷贝和转移给其他对象,自己牢牢把握资源的控制权。

下面简单模拟实现一下 unique_ptr:

// 模拟实现 std::unique_ptr
template<class T>
class UniquePtr
{
public:
	// 构造函数 和 析构函数
	UniquePtr<T>(T* ptr) :_ptr = ptr{}
	~UniquePtr<T>() { delete _ptr; }

	// 禁用拷贝构造、赋值运算符重载
	UniquePtr<T>(UniquePtr<T>&) = delete;
	UniquePtr<T>& operator=(UniquePtr<T>&) = delete;

	// * 和 -> 重载
	T& operator*() { return *_ptr; }
	T* operator->() { return _ptr; }

private:
	T* _ptr;
};

测试看看我们自己的 UniquePtr 和标准库中的 unique_ptr 各自进行拷贝时的效果如何:
在这里插入图片描述

总结:如果明确了我们申请的资源只需要短暂的使用或者它仅仅只需要被一个对象所管理时,可以考虑用 unique_ptr。

5. 让资源和引用计数绑定(shared_ptr)

5.1 shared_ptr 介绍

shared_ptr 也是 C++11从 boost 库中引入的一个智能指针,其原型在 boost 库中也叫 shared_ptr。

shared_ptr 针对拷贝的处理方式为:通过引用计数来统计一块资源被多少个智能指针对象所管理。

具体管理资源的方式如下:

  1. shared_ptr 在其内部,给每个资源都维护了一份计数,用来记录该份资源被几个对象所控制。
  2. 在对象被销毁时(也就是析构函数调用时),就说明该对象不再使用这个资源了,这时让引用计数减一。
    • 如果检测到引用计数变为0,就说明自己是最后一个使用该资源的对象,此时需要释放这块资源。
    • 如果不是0,就说明除了自己之外,还有其他对象在使用该资源,注意此时不能释放该资源,否则其他智能指针对象就变成野指针了。

5.1 关于引用计数

接下来我们模拟实现一个 shared_ptr。它的的核心在于引用计数,应该如何来设置这个引用计数呢?

把它设为普通成员变量行不行?
在这里插入图片描述

上面方式,不同的实例化对象没有共享引用计数,那我们把引用计数设为静态成员行不行?
在这里插入图片描述

还是不对,复盘一下,我们真正想要的是什么?是希望一个资源能够和一个特定的引用计数绑定在一起,然后这个引用计数可以被一起管理这块资源的所有对象所看到(注意不是类的所有对象)。

所以我们可以考虑在初次智能指针对象去接收一块资源时,顺便也到堆上动态申请一个 int 类型的引用计数对象。后续如果还有其它智能指针对象去指向这块空间的话(通过拷贝或复赋值),我们就让这块空间所绑定的引用计数加一:

在这里插入图片描述

5.2 share_ptr 模拟实现

  • 引入一个 int* 类型的成员变量 _count,作为引用计数
  • 把自增引用计数的操作单独封装成为一个方法AddRef()
  • 把自减引用计数的操作单独封装成为一个方法ReleaseRef()
// 模拟实现 std::sgare_ptr
template<typename T>
class SharePtr
{
public:
	// 构造函数
	SharePtr(T* ptr)
		:_ptr(ptr)
		,_count(new int(1))
	{}

	// 析构函数
	~SharePtr()
	{
		ReleaseRef();
	}

	// 拷贝构造
	SharePtr(SharePtr& sp)
		:_ptr(sp._ptr)
		,_count(sp._count)
	{
		AddRef(); //引用计数加一
	}

	// 赋值运算符重载
	SharePtr& operator=(SharePtr& sp)
	{
		if (_ptr != sp._ptr)
		{
			ReleaseRef();
			_ptr = sp._ptr;
			_count = sp._count;
			AddRef();
		}
		return *this;
	}

	// 重载 * 和 ->
	T* operator->() { return _ptr; }
	T& operator*() { return *_ptr; }

private:
	// 引用计数加一
	void AddRef() 
	{
		++*_count; 
	}

	// 引用计数减一
	void ReleaseRef()
	{
		if (--*_count == 0)
		{
			delete _ptr;
			delete _count;
		}
	}

	T* _ptr;    //指向一块空间
	int* _count;//对应空间的引用计数
};

注意:关于赋值运算符重载

在这里插入图片描述

5.3 shared_ptr 中的线程安全问题

在使用 shared_ptr 时涉及到的线程安全问题体现在以下两个方面:

  1. 智能指针对象中引用计数是参与管理这块资源的多个智能指针对象所共享的。如果两个线程中的不同智能指针对象同时同一个引用计数进行 ++ 或 --,这个操作不是原子的,在多线程、高并发的情况下 count 的值可能会出现混乱,进而导致资源未释放或者程序崩溃的问题。也就是说目前我们写的 SharedPtr 对引用计数的操作是线程不安全的,后续我们需要加锁。
  2. 智能指针负责保证内部引用计数的线程安全,但是不保证它所管理的这块资源的线程安全。因为如何使用资源是外部逻辑的事,这并不归智能指针所管理。

为了解决多线程高并发情况下,引用计数可能导致的线程不安全的问题,我们针对每个特定的空间和引用计数去专门配套一把互斥锁,后续所有对引用计数的加减操作都需要在加锁的情况下完成。

PS:互斥锁、资源、引用计数,它们三个的生命周期是同步的,在构造函数中一起初始化,到最后释放时也是同步的一起释放。

下面是引入互斥锁后的 shared_ptr 的模拟实现:

// 模拟实现 std::sgare_ptr
template<typename T>
class SharePtr
{
public:
	// 构造函数
	SharePtr(T* ptr)
		:_ptr(ptr)
		,_count(new int(1))
		,_mtx(new std::mutex)
	{}

	// 析构函数
	~SharePtr()
	{
		ReleaseRef();
	}

	// 拷贝构造
	SharePtr(SharePtr& sp)
		:_ptr(sp._ptr)
		,_count(sp._count)
		,_mtx(sp._mtx)
	{
		AddRef(); //引用计数加一
	}

	// 赋值运算符重载
	SharePtr& operator=(SharePtr& sp)
	{
		if (_ptr != sp._ptr)
		{
			ReleaseRef();
			_ptr = sp._ptr;
			_count = sp._count;
			_mtx = sp._mtx;
			AddRef();
		}
		return *this;
	}

	// 重载 * 和 ->
	T* operator->() { return _ptr; }
	T& operator*() { return *_ptr; }

private:
	// 引用计数加一
	void AddRef() 
	{
		_mtx->lock();
		++*_count; 
		_mtx->unlock();
	}

	// 引用计数减一
	void ReleaseRef()
	{
		bool deleteFlag = false;

		_mtx->lock();
		if (--*_count == 0)
		{
			deleteFlag = true;
			delete _ptr;
			delete _count;
		}
		_mtx->unlock();
		
		if (deleteFlag)
		{
			delete _mtx;
			_mtx = nullptr;
		}
	}

	T* _ptr;	//指向一块空间
	int* _count;//对应空间的引用计数
	std::mutex* _mtx; //每块空间对应一把锁(锁也在堆上创建)
};

注意:析构函数中释放互斥锁时的注意事项

在一个 SharedPtr 对象析构时,加锁后执行 count–,如果发现它的值变为0了,此时就要它释放所管理资源和引用计数,这些动作都是在加锁情况下完成的,释放完成之后我们最后还需要把互斥锁也给释放了,所以我们还需要一个标志位去检测释放需要释放互斥锁:
在这里插入图片描述

5.4 shared_ptr 中的循环引用问题

循环引用就是互相引用。下面是一个双向链表的节点结构的定义,我们把指向前后节点的 _prev 和 _next 指针变量用我们自己实现的 SharedPtr 封装:

struct ListNode
{
	SharePtr<ListNode> _prev = nullptr;
	SharePtr<ListNode> _next = nullptr;
};

下面这种情况就会发生循环引用问题:

void TestListNode()
{
	ListNode* p1 = new ListNode;
	ListNode* p2 = new ListNode;
	SharePtr<ListNode> node1(p1);
	SharePtr<ListNode> node2(p2);
	// 循环引用
	node1->_next = node2;
	node2->_prev = node1;
}

循环引用会导致智能指针所管理的空间得不到正确的释放,分析循环引用形成的原因:

在这里插入图片描述

分析发现:导致循环引用的根本原因在于节点内部的 _prev 和 _next 这两个成员变量它们类型也是 SharePtr。我们在对 _prev 和 _next 赋值时发生了智能指针的拷贝,导致引用计数在逻辑上不必要地自增了一次,相当于把这个智能指针的生命给延长了。

在这里插入图片描述

关于 _prev 和 _next 我们需要的仅仅是让它们起到一个指向的作用,不需要画蛇添足的再去给智能指针的count++。但是又不能把它们设置为原生指针,因为在类外使用节点指针时,我们是把这个节点指针让share_ptr 去管理的,即 shared_ptr< ListNode >,此时 _next 和 _prev 的类型也只能是 shared_ptr< ListNode >。

针对类似场景下的循环引用的问题,我们可以使用 weak_ptr,它 同样也是 C++11 从 boost 库中引入的。weak_prt 也是一种智能指针,同样具有 RAII 的功能,但是它没有引用计数:

在这里插入图片描述

使用 weak_prt< ListNode > 来代替 shared_ptr< ListNode > 可以解决 _prev 和 _next 在拷贝 shared_ptr< ListNode > 类型的指针对象时不必要地增加引用计数的问题:

struct ListNode
{
	std::weak_prt<ListNode> _prev = nullptr;
	std::weak_prt<ListNode> _next = nullptr;
};

void TestListNode()
{
	ListNode* p1 = new ListNode;
	ListNode* p2 = new ListNode;
	SharePtr<ListNode> node1(p1);
	SharePtr<ListNode> node2(p2);
	// _next和_prev的类型为 std::weak_prt
	// 赋值也不会再有循环引用的问题了
	node1->_next = node2;
	node2->_prev = node1;
}

5.5 shared_ptr 中的定制删除器

智能指针管理的资源不仅仅是一个 new 出来的空间,还可以是一个文件、数组等,这些不同类型的资源在释放时有不同的释放方式。我们在构造智能指针对象时,可以把资源和如何清理这块资源的方法一起传到构造函数中。清理资源的方法就是定制删除器,它可以是如下可调用对象:

  • 函数指针
  • 仿函数
  • lambda表达式
  • 包装器

在 shared_ptr 的构造函数列表中可以看到(4)是带有定制删除器的构造函数:
在这里插入图片描述
使用示例

// 定制删除器 - 控制资源的释放方式
template<class T>
struct DelArr
{
	void operator()(const T* ptr)
	{
		cout << "delete[]: " << ptr << endl;
		delete[] ptr;
	}
};

void TestSharedPtrDeletor()
{
	// 带定制删除器的构造函数:template <class U, class D> shared_ptr(U * p, D del);
	// 1、仿函数写的定制删除器 - 删除一段连续空间
	std::shared_ptr<int> spArr(new int[10], DelArr<int>()); 
	// 2、lambda表达式写的定制删除器 - 删除FILE*文件
	std::shared_ptr<FILE> spfl(fopen("Test.cpp", "r"), [](FILE* ptr) {
		cout << "fclose: " << ptr << endl;
		fclose(ptr);
	});		
}

//------输出结果------
fclose: 000002154CE08760
delete[]: 000002154CE16B60

补充:在自己模拟实现的 SharePtr 中引入删除器

思路:在模板参数中再引入一个删除器类型的参数,并在类中增加一个删除器类型的成员变量。在构造时完成赋值,析构时调用它来释放资源:

template<class T, class D>
class SharePtr
{
public:
	// 这里要求构造函数必须传资源+删除器
	SharePtr(T* ptr, D del)
		:_ptr(ptr)
		,_del(del)
		, _pcount(new int(1))
		, _pmutex(new std::mutex)
	{}

	~SharePtr()
	{
		ReleaseRef();
	}

private:
	void ReleaseRef()
	{
		bool flag = false;
		_pmutex->lock();
		if (--(*_pcount) == 0)
		{
			if (_ptr)
			{
				// 使用定制删除器来清理资源
				_del(_ptr);
			}
			delete _pcount;
			flag = true;
		}
		_pmutex->unlock();

		if (flag == true)
		{
			delete _pmutex;
		}
	}

	T* _ptr;
	D _del;
	int* _pcount;
	mutex* _pmutex;
};

6. 弱指针(weak_ptr)

weak_ptr 有如下几个特点:

  • 不支持通过原生指针直接构造对象
  • 支持自身类型和 shared_ptr 类型的构造、拷贝构造、赋值
  • 不增加 shared_ptr 所管理资源的引用计数
  • 不参与所管理资源的释放
  • 重载了 *、-> 这两个操作符

下面是 weak_ptr 的简单模拟实现:

template<class T>
class WeakPtr
{
public:
	// 默认构造函数
	WeakPtr()
		:_ptr(nullptr)
	{}

	// 通过 shared_ptr 对象构造
	WeakPtr(const shared_ptr<T>& sp)
		:_ptr(sp.get()) //get方法用来获取原生指针
	{}

	// 通过 shared_ptr 对象赋值
	WeakPtr<T>& operator=(const shared_ptr<T>& sp)
	{
		_ptr = sp.get();
		return *this;
	}

	// 通过 WeakPtr 对象赋值
	WeakPtr<T>& operator=(const WeakPtr<T>& wp)
	{
		if(this != &wp)
			_ptr = wp._ptr;
		return *this;
	}

private:
	T* _ptr;
};
  • 17
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值