C++ 智能指针

发布于:2024-04-24 ⋅ 阅读:(24) ⋅ 点赞:(0)

智能指针

异常会导致资源泄露等安全问题,可以用智能指针管理这些资源。

1. 为什么要使用智能指针?

在C++编程中,动态内存管理是一个常见且重要的问题。传统上,程序员需要手动管理动态分配的内存,通过new来分配内存,通过delete来释放内存。然而,这种手动管理内存的方式容易导致诸如内存泄漏、悬挂指针等问题,给程序的稳定性和可维护性带来挑战。

首先给大家看一份常见的内存泄漏代码

int div()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除0错误");
	return a / b;
}
void Func()
{
	// 1、如果p1这里new 抛异常会如何?
	// 2、如果p2这里new 抛异常会如何?
	// 3、如果div调用这里又会抛异常会如何?
	int* p1 = new int;
	int* p2 = new int;
	cout << div() << endl;
	delete p1;
	delete p2;
}
int main()
{
	try
	{
		Func();
	}
	catch (exception& e)
	{
		cout << e.what() << endl;
	}
	return 0;
}

这段代码存在多个潜在的内存泄漏问题,我们逐个分析一下:

  1. 如果p1这里new抛异常会如何?

    • 如果在new操作中抛出异常,那么在分配内存之后就会抛出异常,导致没有机会释放已经分配的内存。这会导致内存泄漏,因为delete p1;不会被执行,p1指向的内存将永远无法释放。
  2. 如果p2这里new抛异常会如何?

    • 同样地,如果在new操作中抛出异常,那么p2指向的内存也将永远无法释放,导致内存泄漏。
  3. 如果div()调用抛出异常会如何?

    • 如果div()调用抛出异常,则Func()函数内的后续代码将不会执行,包括delete p1;delete p2;,这会导致p1p2指向的内存无法释放,从而造成内存泄漏。

    内存泄漏的危害

    什么是内存泄漏

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

    内存泄漏的危害

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

    C/C++程序中一般我们关心两种方面的内存泄漏:

    • 堆内存泄漏 (Heap leak)

    堆内存指的是程序执行中依据需要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的free或者delete删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。

    • 系统资源泄漏

    指程序使用系统分配的资源,比如套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统性能减少,系统执行不稳定。

内存泄漏问题 描述
未释放动态分配的内存 使用new、malloc等关键字动态分配内存后,未使用delete、free等释放内存函数将内存释放。
循环引用 当两个或多个对象相互引用,并且它们的引用计数永远不会归零时,就会出现循环引用问题,导致对象无法被释放。
异常处理不当 在异常抛出的过程中,如果动态分配的内存没有被适当地释放,就会出现内存泄漏问题。
全局变量未释放 全局变量在程序结束时才会被系统自动释放,如果全局变量占用的内存过多,并且在程序运行过程中没有被释放,就会造成内存泄漏。
资源管理不当 如文件、套接字等系统资源没有被正确释放,会导致系统资源泄漏,进而引发内存泄漏问题。

2. RAII

RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:

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

智能指针概念

​ 在c++中,动态内存的管理式通过一对运算符来完成的:new,在动态内存中为对象分配空间并返回一个指向该对象的指针,我们可以选择对对象进行初始化;delete,接受一个动态对象的指针,销毁该对象,并释放与之关联的内存。动态内存的使用很容易出现问题,因为确保在正确的时间释放内存是极其困难的。有时使用完对象后,忘记释放内存,造成内存泄漏的问题。

智能指针需要解决三个问题:

  1. 支持模拟*->++--等指针运算;
  2. 自动初始化自动销毁;
  3. 解决指针拷贝问题。

下面是智能指针的基本框架,所有的智能指针类模板中都需要包含一个指针对象,构造函数和析构函数。

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

    ~SmartPtr()
    {
        delete _ptr;
    }

private:
    T* _ptr;
};

void Func()
{
    try {
		SmartPtr<int> sp = new int;
    }
    catch(const exception& e) {
        cout << e.what() << endl;
    }
}


int main()
{
    SmartPtr<int> sp1 = new int;
    SmartPtr<int> sp2(sp1); // 程序崩溃 ...
}

智能指针是用来管理资源的,如果要支持拷贝构造那必是浅拷贝,管理同一份资源。但析构两遍会导致程序崩溃。

多个对象管理同一份资源才是实现智能指针的难点。

跟随智能指针的发展历程讨论如何实现智能指针。

 

3. auto_ptr

std::auto_ptr是c++98版本库中提供的智能指针,该指针解决上诉的问题采取的措施是管理权转移的思想,也就是原对象拷贝给新对象的时候,原对象就会被设置为nullptr,此时就只有新对象指向一块资源空间。

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

    auto_ptr(auto_ptr& sp)
        : _ptr(sp._ptr) // 将资源的管理权移交到自己手上
	{
		sp._ptr = nullptr; // 将原指针置空
	}

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

    ~auto_ptr()
    {
        delete _ptr;
    }

private:
    T* _ptr;
};

void test_auto_ptr()
{
    auto_ptr<int> p1(new int);
    auto_ptr<int> p2(p1); // 管理权转移

    cout << *p2 << endl;
    cout << *p1 << endl; // 程序崩溃
}

将原指针置空,并将资源的管理权移交到自己手上,就不需要担心析构的问题。
在这里插入图片描述

这样虽然实现拷贝的功能,但原智能指针sp1已经变成了空指针。所以库中的auto_ptr是个失败的半成品。

 

4. unique_ptr

当时Boost库设计出了三个智能指针scoped_ptrshared_ptrweak_ptr

C++11更新的std::unique_ptr就是Boost库的scoped_ptr改名来的。std::unique_ptr简单粗暴,禁止拷贝构造

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

		//c++11 defult&& delete
		unique_ptr(const unique_ptr& ptr) = delete;
		unique_ptr<T>& operator=(const unique_ptr& ptr) = delete;


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

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

		~unique_ptr()
		{
			if (_ptr) {
				std::cout << "delete:" << _ptr << std::endl;
				delete _ptr;
			}
		}


	//private:
	c++98 只声明不实现
	//	unique_ptr(const unique_ptr& ptr);

	private:
		T* _ptr;
};

void test_unique_ptr()
{
    std::unique_ptr<int> up1(new int);
    std::unique_ptr<int> up2(up1); // 编译报错
}

 

5. shared_ptr

那如何让智能指针可以拷贝,也可以准确析构呢?

使用引用计数,为管理的资源配一个变量用来计数。只有最后一个对象析构时再去释放资源,其他对象析构只需减减引用计数。

注意是为被管理的资源配一个引用计数,而不是给对象,所以不能用静态成员变量。

template<class T>
class shared_ptr
{
public:
    shared_ptr(T* ptr)
        : _ptr(ptr)
        , _pcnt(new int(1))
    {}

    shared_ptr(const shared_ptr& p)
        : _ptr(p._ptr)
        , _pcnt(p._pcnt)
    {
        ++(*_pcnt);
    }

    shared_ptr& operator=(const shared_ptr& p)
    {
        if (_ptr != p._ptr)
        {
            if (--(*_pcnt) == 0)
            {
                delete _ptr;
                delete _pcnt;
                _ptr = nullptr;
                _pcnt = nullptr;
            }

            _ptr = p._ptr;
            _pcnt = p._pcnt;
            _pmtx = p._pmtx;
            ++(*_pcnt);
        }

        return *this;
    }

    ~shared_ptr()
    {
        if (--(*_pcnt) == 0)
        {
            delete _ptr;
            delete _pcnt;
            _ptr = nullptr;
            _pcnt = nullptr;
        }
    }

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

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

    T* get()
    {
        return _ptr;
    }

    size_t use_count()
    {
        return *_pcnt;
    }

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

在构造函数首次接受资源的托管时,开辟一个堆变量作引用计数,就实现资源和引用计数配套了。

线程安全

除此之外,多线程下无法保证引用计数的安全,智能指针需要考虑引用计数的线程安全。可以使用标准库中的原子操作封装引用计数,也可以在AddRefRelease处加锁。

故库中的shared_ptr的引用计数是线程安全的,但不是保证资源的线程安全。

template<class T>
class shared_ptr
{
private:
    void add_ref()
    {
        _pmtx->lock();
        ++(*_pcnt);
        _pmtx->unlock();
    }

    void release()
    {
        _pmtx->lock();

        bool flag = false;
        if (--(*_pcnt) == 0)
        {
            cout << "delete: " << _ptr << endl;
            delete _ptr;
            delete _pcnt;
            _ptr = nullptr;
            _pcnt = nullptr;

            flag = true;
        }

        _pmtx->unlock();

        if (flag)
        {
            delete _pmtx;
            _pmtx = nullptr;
        }
    }

public:
    shared_ptr(T* ptr)
        : _ptr(ptr)
        , _pcnt(new int(1))
        , _pmtx(new mutex)
    {}

    shared_ptr(const shared_ptr& p)
        : _ptr(p._ptr)
        , _pcnt(p._pcnt)
        , _pmtx(p._pmtx)
    {
        add_ref();
    }

    shared_ptr& operator=(const shared_ptr& p)
    {
        if (_ptr != p._ptr)
        {
            release();

            _ptr = p._ptr;
            _pcnt = p._pcnt;
            _pmtx = p._pmtx;
            add_ref();
        }

        return *this;
    }

    ~shared_ptr()
    {
        release();
    }

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

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

    T* get()
    {
        return _ptr;
    }

    size_t use_count()
    {
        return *_pcnt;
    }

private:
    T* _ptr;
    int* _pcnt;
    mutex* _pmtx;
};

循环引用

普通场景下不会产生循环引用的问题,但在某些的特殊场景,如双向链表节点的前后指针。

// 双向链表
struct ListNode
{
    shared_ptr<ListNode> _prev;  
    shared_ptr<ListNode> _next;
    int _val;
};

void test()
{
    shared_ptr<ListNode> n1 = new ListNode;
	shared_ptr<ListNode> n2 = new ListNode;
	n1->_next = n2;
	n2->_prev = n1;
}


void test_shared_cycle()
{
    /*ListNode* n1 = new ListNode;
	ListNode* n2 = new ListNode;

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

	delete n1;
	delete n2;*/

	shared_ptr<ListNode> n1(new ListNode);
	shared_ptr<ListNode> n2(new ListNode);

	cout << n1.getCount() << endl;
	cout << n2.getCount() << endl;

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

	cout << n1.getCount() << endl;
	cout << n2.getCount() << endl;
	}

循环引用问题导致程序崩溃

  • n2->prevn1一起管理第一块资源,释放第一块资源不仅需要n1析构还需要n2->prev析构。
  • n1->nextn2一起管理第二块资源,释放第二块资源不仅需要n2析构还需要n1->next析构。

出作用域后n1n2相继析构,两个资源分别由n2->prevn1->next管理。

n2->prevn1->next分别属于第二块资源和第一块资源。现在就出现了循环引用的问题:

  • 释放第一块资源需要先析构n2->prev,也就是需要先释放第二块资源

  • 释放第二块资源需要先析构n1->next,也就是需要先释放第一块资源;

 

6. weak_ptr

template <class u> 
weak_ptr (const shared_ptr<u>& x) noexcept;

weak_ptr不是常规意义的智能指针。它用于辅助shared_ptr解决重复引用问题。

weak_ptr可以访问资源,但不参与资源的释放,所以不改变引用计数,就避免循环引用的问题

weak_ptr比原生指针的优势在于weak_ptr支持和share_ptr进行相互赋值和拷贝。

template <class T>
class weak_ptr
{
public:
    weak_ptr(T* ptr = nullptr) 
        : _ptr(ptr)
    {}
    
    weak_ptr(const weak_ptr<T>& wp)
        : _ptr(wp._ptr)
    {}

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

    ~weak_ptr()
    {}

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

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

private:
    T* _ptr;
};

将ListNode放进weak_ptr中,weak_ptr对象指向shared_ptr对象时,不会增加shared_ptr中的引用计数,因此当node1销毁掉时,则node1指向的空间就会被销毁掉,node2类似,所以weak_ptr指针可以很好解决循环引用的问题。
struct ListNode
{
/ListNode _next;
ListNode* _prev;*/

//shared_ptr<ListNode> _next;
//shared_ptr<ListNode> _prev;
weak_ptr<ListNode> _next;
weak_ptr<ListNode> _prev;
int _val;

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

};
在这里插入图片描述

 

7. 定制删除器

智能指针的析构要适配delete和delete两种情况,我们使用定制删除器解决这个问题。

提供一个删除器的仿函数,作智能指针的模版参数,析构时调用该仿函数释放资源。

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

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

		void Release()
		{
			bool delflag = false;
			_pmtx->lock();
			if (--(*_pcount) == 0) {

				if (_ptr) {
					//cout << "delete:" << _ptr << endl;
					//delete _ptr;
					// 删除器进行删除
					_del(_ptr);
				}
				delete _pcount;
				delflag = true;
			}
			_pmtx->unlock();

			if (delflag)delete _pmtx;
		}

	   // ...

		~shared_ptr()
		{
			Release();
		}


	private:
		//...
		// 包装器
		function<void(T*)> _del = [](T* ptr) {
			cout << "lambda delete:" << ptr << endl;
			delete ptr;
		};
	};

 

8. 总结

智能指针对比

智能指针 实现方案
auto_ptr 管理权转移,悬空被拷贝对象
unique_ptr 直接禁止拷贝赋值
shared_ptr 添加引用计数以支持拷贝,但会出现循环引用
weak_ptr 不修改引用计数,用于解决循环引用

不建议使用auto_ptr,无拷贝需求就使用unique_ptr,有拷贝需求就使用shared_ptr

智能指针的使用陷阱

  1. 不要给多个智能指针管理同一个原生指针 或者使用reset()函数时要小心:

    int *x = new int(10);
    unique_ptr<int> up1(x);
    unique_ptr<int> up2(x); // 危险!up1 和 up2 指向同一块内存
    up1.reset(x);
    up2.reset(x);
    
  2. 记得使用 release() 的返回值
    在调用 release() 时,不会释放智能指针所指的内存,返回值是对这块内存的唯一索引。如果没有使用这个返回值释放内存或是保存起来,这块内存就会泄漏。

  3. 禁止删除智能指针 get 函数返回的指针
    如果我们主动释放掉 get 函数获得的指针,智能指针内部的指针就变成野指针了,析构时会造成重复释放,导致严重后果!

  4. 禁止用任何类型智能指针 get 函数返回的指针去初始化另外一个智能指针

    shared_ptr<int> sp1(new int(10));
    shared_ptr<int> sp2(sp1.get()); // 错误的用法