【C++】智能指针

发布于:2025-03-09 ⋅ 阅读:(121) ⋅ 点赞:(0)

内存泄漏

内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。下面我们看一段代码

while (true)
{
    int* p = new int(10);

    // 这里忘记释放内存,导致内存泄漏
}

在这个程序中,会不断的动态申请内存而不释放,最后会导致内存资源被消耗殆尽,甚至最后导致奔溃。

内存泄漏有很多危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。

这里程序并没有问题,只是我们写代码忘记了释放。如果我们每次申请资源后都自己释放当然是可以的,但是这样也太麻烦了吧。有没有什么简单的方式?有的兄弟,有的。下面我们介绍RAII


RAII

RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源的简单技术,是C++ 中一种非常重要的内存管理机制。

RAII的核心思想

  1. 将资源的分配和释放绑定在对象的生命周期上。
  2. 在对象构造时获取资源,在对象析构时释放资源。

这样做有两个好处

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

举一个例子,我们来写一个最简单的RAII

template <class T>
class smartPtr
{
public:
    smartPtr(T* ptr)
        :_ptr(ptr)
    {}

    ~smartPtr()
    {
        delete _ptr;
    }

private:
    T* _ptr;
};

int main()
{
    smartPtr<int> ptr1 = new int(5);
    smartPtr<double> ptr2 = new double(3.14);
    smartPtr<vector<int>> ptr3 = new vector<int>(10);

    return 0;
}

在上述代码中,smartPtr 类负责管理资源的获取和释放。ptr1、ptr2、ptr3这些对象进入作用域时,构造函数会被调用,将smatrPtr的_ptr成员初始化为对应动态内存的指针。当三个对象离开作用域时,析构函数会被自动调用,自动delete _ptr释放资源。

我们可以发现,通过RAII我们可以不用自己释放资源,但可以确保资源一定会被正确释放,避免了手动释放资源时忘记的问题。


智能指针

智能指针是RAII的一种实现,C++一共提供了四种智能指针:auto_ptr、unique_ptr、shared_ptr、weak_ptr。头文件是<memory>

我们再次看看上面的这个代码

template <class T>
class smartPtr
{
public:
    smartPtr(T* ptr)
        :_ptr(ptr)
    {}

    ~smartPtr()
    {
        delete _ptr;
    }

private:
    T* _ptr;
};

int main()
{
    smartPtr<int> ptr1 = new int(1);
    smartPtr<int> ptr2 = ptr1;

    return 0;
}

我们发现程序会崩溃。这是因为我们的ptr先构造,指向了一个int的动态内存空间,然后ptr1拷贝构造出了ptr2,此时ptr1和ptr2都指向这个int的动态内存空间。当ptr1和ptr2出了作用域,那么两个对象都会调用析构函数,导致同一块内存被delete两次,这会直接导致进程崩溃。下面我们要介绍的智能指针回去解决这些问题。

auto_ptr

auto_ptr是最早的智能指针,诞生于C++98。

auto_ptr<int> ptr1(new int(5));//正确
auto_ptr<int> ptr2 = new int(10);//错误

auto_ptr只允许第一种直接构造,因为第二种本质是类型转换。从int*转为auto_ptr<int>,但是由于auto_ptr的构造函数被explicit修饰,这个类型转换功能就会被禁止,而auto_ptr就被禁止了。

事实上,auto_ptr、unique_ptr、shared_ptr、weak_ptr,都不允许通过原生指针的类型转化来构造,也就是四种智能指针都只能通过小括号来初始化。

我们上面说了,如果多个类指向同一个空间会导致同一块资源被释放多次的问题,那么auto_ptr是如何解决的呢?答案是:当auto_ptr发生拷贝,原先的auto_ptr会变成空指针

我们可以验证一下

我们发现ptr1的指针变成了00000000,原先指向动态内存的指针交给了ptr2。


unique_ptr

unique_ptr解决问题的方式更加粗暴,它直接不允许拷贝构造。

但是相比于auto_ptr,unique_ptr允许下标访问opertaor[]。

unique_ptr<int[]> ptr1(new int[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10});
cout << ptr1[5] << endl;

你需要注意的是下面这种写法是错误的。当指向的动态内存是一次性开辟的数组的形式,模板参数要写为type[]的形式,来告诉unique_ptr该指针维护的动态内存,是以数组的形式开辟的。

unique_ptr<int> ptr1(new int[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10});
cout << ptr1[5] << endl;//错误


shared_ptr

shared_ptr通过引用计数来处理多个指针指向同一块内存的问题。也是使用最多的智能指针。

你可以想象一个场景去理解这个事情。比如晚上上课结束后,最后一个人走了才会锁门。

1. shared_ptr在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享。

2. 在对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减 一。

3. 如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源;

4. 如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了。

ptr3离开作用域后,count--,count=2。说明此时还有两个指针指向这个动态内存,那么ptr3析构函数不会释放掉动态内存!

需要注意的是,不是所有shared_ptr都共用一个count,对于每一块动态内存,都有独立的count,互不影响。

shared_ptr在循环引用时会存在一个问题。

我们来看一段代码

class ListNode
{
public:
    ListNode(int val)
        : _prev(nullptr)
        , _next(nullptr)
        , _val(val)
    {}

    shared_ptr<ListNode> _prev;
    shared_ptr<ListNode> _next;
    int _val;
};

int main()
{
    shared_ptr<ListNode> l1(new ListNode(5));
    shared_ptr<ListNode> l2(new ListNode(10));

    l1->_next = l2;
    l2->_prev = l1;
    
    return 0;
}

这串代码已经出现了内存泄漏的问题。我们来看一下。

由于LinkList内部的指针也是shared_ptr,所以count=2。当L1离开作用域之后,count--,但是由于L1->_next指向L2区域,所以count=1,L1内存不会释放。同理,L2离开作用域后也不会释放空间。左边的空间要释放就必须右边的先释放,而右边的想要释放就必须左边的释放,这就造成了一个死循环。它们两个都不会释放,导致内存泄漏。

我们发现如果在类的内部使用这种相互指向的shared_ptr,就很容易发生循环引用的问题。所以对于_next、_prev这种只用于访问资源,不需要释放资源的完全不需要用shared_ptr,用普通的指针就可以。


weak_ptr

weak_ptr不涉及RAII,不参与资源管理,所以就不会产生上面死循环的这种问题。

我们可以把刚才的代码优化成下面这样

class ListNode
{
public:
    //weak_ptr不支持原生指针初始化,所以在列表初始中删除了_prev和_next的初始化
    ListNode(int val)
        : _val(val)
    {}

    weak_ptr<ListNode> _prev;
    weak_ptr<ListNode> _next;
    int _val;
};

int main()
{
    shared_ptr<ListNode> l1(new ListNode(5));
    shared_ptr<ListNode> l2(new ListNode(10));

    l1->_next = l2;
    l2->_prev = l1;
    
    return 0;
}

但是要注意,weak_ptr不支持原生指针初始化,所以在列表初始中删除了_prev和_next的初始化。


定制删除器

智能指针该如何辨别我们的资源是用new int开辟的还是new int[]开辟的呢?这个问题我们就交给定制删除器来解决。

图中的del就是定制删除器,它是一个可调用对象,可以是函数指针、仿函数、lambda表达式等待。当shared_ptr对象的生命周期结束时就会调用传入的删除器完成资源的释放,调用该删除器时会将shared_ptr管理的资源作为参数进行传入。

我们可以用模拟实现一下

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

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

		// function<void(T*)> _del;

		void release()
		{
			if (--(*_pcount) == 0)
			{
				_del(_ptr);

				delete _pcount;
			}
		}

		~shared_ptr()
		{
			release();
		}

		shared_ptr(const shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			, _pcount(sp._pcount)
		{
			++(*_pcount);
		}

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

				_ptr = sp._ptr;
				_pcount = sp._pcount;

				++(*_pcount);
			}

			return *this;
		}

		// 像指针一样
		T& operator*()
		{
			return *_ptr;
		}

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

		int use_count() const
		{
			return *_pcount;
		}

		T* get() const
		{
			return _ptr;
		}

	private:
		T* _ptr;
		int* _pcount;

		function<void(T*)> _del = [](T* ptr) {delete ptr; };
	};


网站公告

今日签到

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