C/C++(七)RAII思想与智能指针

使用 C++ 的时候,会经常遇到内存泄漏问题,即程序分配了内存但在不再需要时未能正确释放。本章将介绍一种常用的思想------RAII(Resource Acquisition Is Initialization),以及C++中常用的智能指针,从使用到底层,全面介绍它们是如何帮助防止内存泄漏的。

一、前言:为什么要使用智能指针?

因为某些代码会存在内存泄漏问题,用于解决内存泄漏。

(内存泄漏问题的具体介绍详见本专栏第三章------《C/C++(三)C/C++内存管理》)

二、RAII思想

RAII思想就是一种利用对象的生命周期来控制程序资源*(如内存**、文件句柄、网络连接、互斥量等等 )***的思想。

核心思想:

需要被控制的资源,在构造对象的时候即被获取获取资源,获取到资源后就立刻初始化;**然后这些资源的生命周期就会与对象的生命周期紧密绑定,**利用这个对象的生命周期来控制资源。

由于 **不论是正常结束亦或是因异常而终止,对象都会在生命周期结束的时候自动调用析构函数,连带着释放这些资源。**因此可靠性极高,有效避免了内存泄漏问题。

示例代码:

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

// 使用RAII思想设计的SmartPtr类
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 div()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除0错误");
	return a / b;
}
void Func()
{
	SmartPtr<int> sp1(new int);
	SmartPtr<int> sp2(new int);
	cout << div() << endl;
}

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

在这段代码中,RAII思想得到了充分体现:

  1. 资源获取即初始化SharedPtr 的构造函数接受一个指向 T 类型对象的指针 _ptr,并在对象创建时立即持有该资源。这意味着资源的获取和对象的创建是同步的。

  2. 生命周期绑定_ptr 指向的资源生命周期与 SharedPtr 对象的生命周期绑定在一起。只要 SharedPtr 对象存在,资源就会被持有。

  3. 自动资源释放SharedPtr 的析构函数中包含了一个检查,如果 _ptr 不为空,则调用 delete 释放资源。这确保了即使在异常情况下,资源也能被正确释放,避免了内存泄漏。

  4. Func() 函数中,创建了两个 SharedPtr<int> 对象 sp1sp2

    cpp 复制代码
    void Func()
    {
        SharedPtr<int> sp1(new int);
        SharedPtr<int> sp2(new int);
        cout << div() << endl;
    }
  5. 资源获取即初始化sp1sp2 在创建时分别获取了两个动态分配的 int 对象的指针。

  6. 生命周期绑定 :这两个 int 对象的生命周期与 sp1sp2 的生命周期绑定在一起。只要 sp1sp2 存在,这两个 int 对象就会被持有。

  7. 自动资源释放 :当 Func() 函数结束时,它们的析构函数会被自动调用sp1sp2 对象会自动销毁,,从而释放持有的 int 对象的内存,不用手动释放。

三、智能指针的原理(重点)

智能指针,本质上就是 RAII思想 + * 解引用操作符重载 + -> 成员访问操作符重载,让委托管理资源的这个对象具有RAII特性 + 拥有指针的行为。

把上面的代码完善一下,就是一个简易智能指针了:

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

// 使用RAII思想设计的SharedPtr类
template<class T>
class SharedPtr 
{
public:
	SharedPtr(T* ptr = nullptr)
		: _ptr(ptr)
	{}
	~SharedPtr()
	{
		if (_ptr)
			delete _ptr;
	}

	// 智能指针的第二要点
	T& operator*()
	{
		return *_ptr;
	}
	T* operator->()
	{
		return _ptr;
	}

private:
	T* _ptr;
};

int div()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除0错误");
	return a / b;
}
void Func()
{
	SharedPtr<int> sp1(new int);
	SharedPtr<int> sp2(new int);
	cout << div() << endl;
}

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

四、基于RAII思想的C++标准库中的三大智能指针(重点)

基于RAII思想,C++标准库中为我们提供了三种智能指针:

1、std::auto_ptr (C++98标准库中所提供,缺陷很大,已被弃用

2、std::unique_ptr (C++11标准库提供的防拷贝智能指针)

3、std::shared_ptr (C++11标准库提供的支持拷贝的智能指针)

(PS:其实还有一个 std::weak_ptr,但它只是为了解决循环引用问题而出现的,并不是传统意义上的,基于RAII的智能指针)

1、auto_ptr

C++使用手册中对 auto_ptr 的详细介绍

auto_ptr是C++98标准库中提供的一种智能指针,其核心思想是管理权转移。

但是其缺陷很大,在拷贝构造和赋值时,有着潜在的指针悬空与内存崩溃风险,因此被视作失败设计而被C++11标准所抛弃。

我们给出一个示范代码***(为了从底层理解透彻其巨大的缺陷,这里使用了自己编写的一段简易auto_ptr,与库中的auto_ptr底层逻辑是一致的)***

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

template <typename T>
class my_auto_ptr
{
public:
    // 构造函数
    my_auto_ptr(T* ptr) : _ptr(ptr) {}

    // 拷贝构造函数
    my_auto_ptr(my_auto_ptr<T>& sp) : _ptr(sp._ptr)
    {
        // 管理权转移:将 sp 管理的资源转移给新的 my_auto_ptr 对象,并将 sp 的指针置为空,表示 sp 不再管理该资源
        sp._ptr = nullptr;
    }

    // 析构函数
    ~my_auto_ptr()
    {
        if (_ptr)
        {
            cout << "调用析构函数:" << _ptr << endl;
            delete _ptr;
        }
    }

    // 赋值操作符重载
    my_auto_ptr<T>& operator=(my_auto_ptr<T>& sp)
    {
        // 检测是否是自己给自己赋值
        if (this != &sp)
        {
            // 释放当前管理的资源
            delete _ptr;
            // 管理权转移:将 sp 管理的资源转移给当前对象,并将 sp 的指针置为空,表示 sp 不再管理该资源
            _ptr = sp._ptr;
            sp._ptr = nullptr;
        }
        return *this;
    }

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

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

private:
    T* _ptr;
};

int main()
{
    my_auto_ptr<int> p1(new int);
    *p1 = 10;

    // 此时,p1 把资源的管理权转交给了 p2,p1 的指针被置为空
    my_auto_ptr<int> p2 = p1;

    // 由于 p1 已经把资源管理权交给 p2,p1 的指针为 nullptr,访问 p1 会导致未定义行为
    cout << *p1 << endl;
}

指针悬空,内存崩溃,运行未定义

2、unique_ptr

C++使用手册中关于 unique_ptr 的详细介绍

unique_ptr 是C++11标准库中的一种智能指针,其特性是严格禁止了拷贝构造与拷贝赋值,简单粗暴地确保了资源独占,保证同一时间内只有⼀个智能指针可以控制该对象,从而避免了资源的多重管理和潜在的指针悬空及内存崩溃问题,有效防止了内存泄漏。

给出示例代码,从底层了解其逻辑:

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

template <class T>
class my_unique_ptr
{
public:
    // 构造函数
    my_unique_ptr(T* ptr) : _ptr(ptr) {}

    // 析构函数
    ~my_unique_ptr()
    {
        if (_ptr)
        {
            delete _ptr;
        }
    }

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

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

    // 拷贝构造函数,拷贝赋值函数都被添加 delete 关键字,不允许被调用
    my_unique_ptr(const my_unique_ptr<T>& sp) = delete;
    my_unique_ptr<T>& operator=(const my_unique_ptr<T>& sp) = delete;

private:
    T* _ptr;
};

int main()
{
    my_unique_ptr<int> p1(new int(10));
    my_unique_ptr<int> p2 = p1;
    return 0;
}

不再被允许拷贝,确保安全

3、shared_ptr(重点)

C++使用手册中关于 shared_ptr 的详细介绍

shared_ptr是C++11提供的另外一种智能指针,其核心特性是允许多个对象共同管理资源,且允许拷贝。目的是为了解决 unique_ptr 在对象管理权上的局限性。

3.1 shared_ptr是如何实现资源多对象共享的?

通过引用计数(本身也属于一种资源,被对象所共享)的方式。

1、在shared_ptr内部,每一个资源都维护了一份引用计数,用来记录这个资源被多少个对象所共享。当其中某一个对象调用析构函数销毁时,说明自己不再使用该资源,引用计数次数减一

2、在对象调用析构函数的时候,在引用计数次数减一后,还需要检查引用计数的值。如果不是0,说明这份资源还有对象在使用和管理,不能销毁资源;如果等于0,说明这份资源没有对象使用了,直接连带着销毁。

继续手撕底层逻辑源代码:

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

template <class T>
class my_shared_ptr
{
public:
	// 构造函数(构造新的对象,管理新的资源的时候,新建一个引用计数)
	my_shared_ptr(T* ptr = nullptr) :_ptr(ptr), _pRefCount(new int(1)), _pmtx(new mutex)
	{}

	// 返回引用计数的值
	int use_count()
	{
		return *_pRefCount;
	}

	// 两个指针用运算符的重载
	T& operator*() const
	{
		return *_ptr;
	}
	T* operator->() const
	{
		return _ptr;
	}

	// 增加引用计数
	void AddRef()
	{
		// 引用计数的加减不是原子的,需要加锁,保证线程安全
		_pmtx->lock();
		(*_pRefCount)++;
		_pmtx->unlock();
	}

	// 释放资源函数(析构同样需要加锁,因为也涉及到引用计数的加减,需要保证多线程析构安全)
	void Release()
	{
		_pmtx->lock();
		bool flag = false;	// 用来标记是否需要把锁资源也释放了

		if (--(*_pRefCount) == 0 && _ptr != nullptr)
		{
			// 引用计数归零,资源没有对象还要使用了,二者一起释放
			delete _ptr;
			delete _pRefCount;
			flag = true;	// 准备释放互斥锁,但不能立即释放,因为后面还需要解锁
		}
		_pmtx->unlock();

		if (flag)
		{
			delete _pmtx;
		}
	}
	
	// 拷贝构造函数(管理相同资源,引用计数增加)
	my_shared_ptr(my_shared_ptr<T>& sp):_ptr(sp._ptr), _pRefCount(sp._pRefCount), _pmtx(sp._pmtx)
	{
		AddRef();	// 增加引用计数
	}

	// 拷贝赋值函数
	my_shared_ptr<T>& operator=(const my_shared_ptr<T>& sp)
	{
		// 不论是直接还是间接给自己赋值,管理的资源一定是一样的,就不需要拷贝赋值了
		if (_ptr != sp._ptr)
		{
			Release();	// 先判断是否需要释放原来的资源

			_ptr = sp._ptr;
			_pRefCount = sp._pRefCount;
			_pmtx = sp._pmtx;
			AddRef();
		}
		return *this;
	}

	// 析构函数
	~my_shared_ptr()
	{
		Release();
	}
private:
	T* _ptr;			// 需要管理的资源
	int* _pRefCount;	// 引用计数(本身也属于一种资源,被对象所共享)
	mutex* _pmtx;		// 互斥锁,同样是共享的,可以确保在同一时刻只有一个线程能够执行引用计数的修改操作
};

整体的实现逻辑都在里面可以体现***(注意:真正的shared_ptr还支持右值引用和移动语义,这里没有实现)***

3.2 使用 shared_ptr 可能会遇到的线程安全问题

  1. 智能指针对象中引用计数也属于一个共享资源,是多个智能指针对象共享的,两个线程中智能指针的引用计数同时 ++ 或 --,这个操作不是原子的。引用计数原来是1,++了两次,可能还是2;这样引用计数就错乱了,会导致资源未释放或者程序崩溃的问题。

所以智能指针中引用计数++、--是需要加锁的,也就是说引用计数的操作是需要保证线程安全的。

  1. 智能指针管理的对象存放在堆上,两个线程中同时去访问,可能也会导致线程安全问题。

3.3 shared_ptr 的缺陷:循环引用问题(重点)

所谓循环引用问题,其实就是:

当两个或多个对象通过 shared_ptr 彼此持有对方的引用而形成一个闭环时,就可能发生循环引用问题。这会导致这些对象的引用计数永远不会降到零,即使它们已经不再被外部代码使用;从而无法被自动销毁,造成内存泄漏。

1、给出一个会造成循环引用的问题代码:
cpp 复制代码
#include <iostream>
using namespace std;

// 本代码以链表为例
struct ListNode
{
	int _data; 
	shared_ptr<ListNode> _prev;
	shared_ptr<ListNode> _next;
	~ListNode() { cout << "~ListNode()" << endl; }
};

int main()
{
	shared_ptr<ListNode> node1(new ListNode);
	shared_ptr<ListNode> node2(new ListNode);
	cout << node1.use_count() << endl;
	cout << node2.use_count() << endl;
	node1->_next = node2;
	node2->_prev = node1;
	cout << node1.use_count() << endl;
	cout << node2.use_count() << endl;
	return 0;
}

从这个双向链表的代码执行逻辑来分析一下,是怎么造成的循环引用:

1、 node1 和 node2 初始化构造对象,各自的引用次数+1,没有问题。

2、 node1 的下一个节点 next 指向 node2,node2引用次数 +1; node2 的上一个节点 prev 指向 node1,node1引用次数 +1;

3、node1 和 node2 自己释放后,二者的资源引用次数各自 -1,还剩 1。

4、重点来了:

此时如果 node1 的这个资源想释放,势必要让自己的引用次数归零,指向这个资源的,是 node2 中的 _prev,所以就要释放 node2;

而 node2 的这个资源想释放,也要让自己的引用次数归零,指向这个资源的,是 node1 中的 _next,所以就要释放 node1。

node1想完全释放,需要先释放 node2;想释放 node2 ,就要先释放 node1。二者构成了逻辑闭环,导致引用计数永远无法归零。
循环引用示意图

那么该如何解决呢?

2、std::shared_ptr 循环引用问题的解决方案:std::weak_ptr

解决此问题,就要用到本章第四小节开头所讲到的一个专门为解决循环引用问题而出现的,非传统智能指针:std::weak_ptr

weak_ptr 并不是基于RAII思想而实现的传统智能指针,其不参与真正的资源管理,不会影响 shared_ptr 内部的引用计数***(但是指向的还是 shared_ptr 管理的对象,仅用来观察资源是否存在)***,因此也就不会增加 / 减少共享资源的引用计数。

把上面代码中的 _prev 和 _next 节点改成 weak_ptr类型,由于不参与引用计数的增减,也就不存在循环引用问题了:

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

int main()
{
 shared_ptr<ListNode> node1(new ListNode);
 shared_ptr<ListNode> node2(new ListNode);
 cout << node1.use_count() << endl;
 cout << node2.use_count() << endl;
 node1->_next = node2;
 node2->_prev = node1;
 cout << node1.use_count() << endl;
 cout << node2.use_count() << endl;
 return 0;
}
3、一个简化版的 weak_ptr 实现
cpp 复制代码
#include <iostream>
using namespace std;

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

	// 拷贝构造
	weak_ptr(const shared_ptr<T>& sp) :_ptr(sp.get())
	{}
	
	// 拷贝赋值
	weak_ptr<T>& operator=(const shared_ptr<T>& sp)
	{
		// 可以看见,获取的仍然是 shared_ptr 指向的资源
		_ptr = sp.get();
		return *this;
	}
	T& operator*()
	{
		return *_ptr;
	}
	T* operator->()
	{
		return _ptr;
	}
private:
	T* _ptr;
};
相关推荐
FeboReigns1 小时前
C++简明教程(4)(Hello World)
c语言·c++
FeboReigns1 小时前
C++简明教程(10)(初识类)
c语言·开发语言·c++
zh路西法1 小时前
【C++决策和状态管理】从状态模式,有限状态机,行为树到决策树(二):从FSM开始的2D游戏角色操控底层源码编写
c++·游戏·unity·设计模式·状态模式
.Vcoistnt1 小时前
Codeforces Round 994 (Div. 2)(A-D)
数据结构·c++·算法·贪心算法·动态规划
小k_不小2 小时前
C++面试八股文:指针与引用的区别
c++·面试
沐泽Mu2 小时前
嵌入式学习-QT-Day07
c++·qt·学习·命令模式
ALISHENGYA2 小时前
全国青少年信息学奥林匹克竞赛(信奥赛)备考实战之分支结构(实战训练三)
数据结构·c++·算法·图论
GOATLong2 小时前
c++智能指针
开发语言·c++
F-2H3 小时前
C语言:指针3(函数指针与指针函数)
linux·c语言·开发语言·c++