• [C++](26)智能指针


    引入

    首先看下面这个程序:

    int div()
    {
    	int a, b;
    	cin >> a >> b;
    	if (b == 0) throw invalid_argument("除以0错误");
    	return a / b;
    }
    
    void func()
    {
    	int* p1 = new int[10];
    	int* p2 = new int[10];
    
    	div();
    
    	delete[] p1;
    	delete[] p2;
    }
    
    
    int main()
    {
    	try
    	{
    		func();
    	}
    	catch (const exception& e)
    	{
    		cout << e.what() << endl;
    	}
    	return 0;
    }
    

    这个程序最尴尬的地方就在于 func 函数,这里有 3 个地方可能会抛异常,如果 p1 的 new 抛异常,那么直接跳到 main 函数去处理,没问题;如果 p2 的 new 抛异常,跳出去后就会导致 p1 没有释放,内存泄漏;如果 div() 抛异常,那么就会导致 p1 和 p2 都没有释放。

    如何解决呢?

    如果在 func 函数内进行捕获再抛出呢?

    void func()
    {
    	int* p1 = new int[10];
    	int* p2 = new int[10];
    
    	try
    	{
    		div();
    	}
    	catch (...)
    	{
    		delete[] p1;
    		delete[] p2;
    		throw;
    	}
    
    	delete[] p1;
    	delete[] p2;
    }
    

    如果 div() 抛出异常就在 func 函数内部捕获然后释放已经开辟的 p1 和 p2 后抛出。但是这里还有一个问题,如果 p2 抛异常呢?p2 抛异常就会直接跳出到 main 函数,导致 p1 未释放。如果把 p2 放到 try 块里面,又会导致最后一行正常释放的 delete[] p2; 报未定义的错误。

    所以这里用 try/catch 解决比较困难也比较麻烦。更好的解决方式就是使用我们下面要学的智能指针。

    智能指针的原理

    RAII

    RAII (Resource Acquisition Is Initialization) “资源获取即初始化”,是C++管理资源,避免泄漏的一种方法。它利用对象的生命周期来控制资源(如内存、文件句柄、网络链接、互斥量等)。

    在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效最后在对象析构的时候释放资源,这实际上是把管理一份资源的责任委托到一个对象上。这样做我们就不需要显式释放资源


    下面我们简单地包装一个智能指针类,然后把 new 出来的指针交给这个类的对象管理:

    template<class T>
    class SmartPtr
    {
    public:
    	SmartPtr(T* ptr)
    		: _ptr(ptr)
    	{}
    
    	~SmartPtr()
    	{
    		delete _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[10]);
    	SmartPtr<int> sp2(new int[10]);
    	SmartPtr<int> sp3(new int[10]);
    	SmartPtr<int> sp4(new int[10]);
    
    	div();
    }
    
    
    int main()
    {
    	try
    	{
    		func();
    	}
    	catch (const exception& e)
    	{
    		cout << e.what() << endl;
    	}
    	return 0;
    }
    

    这样只要 sp1、sp2、sp3、sp4 这些对象调用析构函数就能释放空间,对象的生命周期跟着函数跑,我们不用担心它不会被销毁。

    我们上面实现的智能指针还不完整,还需要实现指针的行为,不过这已经体现了 RAII 的核心思想。

    // 重载*和->,使之可以像指针一样使用
    T& operator*()
    {
        return *_ptr;
    }
    
    T* operator->()
    {
        return _ptr;
    }
    

    C++智能指针及其问题

    智能指针的基本思想有了,但是它的实现却面临着两大难题

    1. 拷贝问题
    2. 循环引用问题

    我们下面来看C++是如何解决这两个问题的

    回想普通指针之间的拷贝,就是让两个指针指向同一块空间,不会开辟新的空间,但是两个智能指针如果指向同一块空间,那么它的两个对象一共析构两次,导致同一块空间被释放两次,程序崩溃。

    auto_ptr

    我们先看 C++98 是如何解决这个问题的

    C++98 的智能指针:auto_ptr

    它的核心思想是管理权转移,即直接将指针的管理权移交给要拷贝的对象

    auto_ptr<int> sp1(new int(10));
    auto_ptr<int> sp2 = sp1;
    
    cout << *sp1; // 运行此处时程序崩溃
    cout << *sp2;
    

    这显然不合理啊,原来的智能指针不能用了,不符合拷贝的含义。

    我们可以轻松实现它的拷贝构造:

    auto_ptr(auto_ptr<T>& sp)
        : _ptr(sp.ptr)
    {
        sp._ptr = nullptr;
    }
    

    目前 auto_ptr 已被废弃,很多公司命令禁止使用 auto_ptr


    unique_ptr

    C++11 智能指针发展得较为成熟,引入了 3 类智能指针

    该智能指针非常简单粗暴,就是明确表示不允许拷贝。

    unique_ptr<int> sp1(new int(10));
    unique_ptr<int> sp2 = sp1;	//错误	C2280	尝试引用已删除的函数
    

    C++11 直接使用 delete 禁止了其默认拷贝构造和赋值重载的生成

    unique_ptr(const unique_ptr<T>& sp) = delete;
    unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;
    

    防拷贝还有一种方式:将拷贝构造和赋值重载显式声明出来,但是不去实现,并把它设为 private,防止其他人在类外实现。


    shared_ptr

    该智能指针真正解决了拷贝问题,可以当成普通的指针来使用。

    它的核心原理是,采用引用计数共有多少个对象管理同一块资源,最后一个析构的对象负责释放资源。

    要让多个对象共用一个成员变量进行计数,静态成员变量行不行?

    显然不行,因为静态成员变量是属于所有一个类的所有对象的,不只是指向同一块空间的所有对象。

    解决方式:

    • 给一个成员变量 int* _pCount; 它指向的是一个 int,是用来计数的空间,我们让它的计数空间和 _ptr 的资源同时分配(在构造函数中给 _pCount new 一个 int 并初始化为 1),保证一块资源配一个计数空间(计数器)
    • 在拷贝时,只需要浅拷贝这两个成员变量,然后将计数器 *_pCount +1。保证两个对象指向的是同一块资源和同一个计数器。
    • 一个对象析构时,将计数器*_pCount -1,如果减到0,则释放资源和计数器。
    template<class T>
    class shared_ptr
    {
    public:
    	shared_ptr(T* ptr)
    		: _ptr(ptr)
    		, _pCount(new int(1))
    	{}
    
    	~shared_ptr()
    	{
    		if (--(*_pCount) == 0 && _ptr)
    		{
    			delete _ptr;
                delete _pCount;
    			_ptr = nullptr;
                _pCount = nullptr;
    		}
    	}
    
    	shared_ptr(const shared_ptr<T>& sp)
    		: _ptr(sp.ptr)
    		, _pCount(sp._pCount)
    	{
    		++(*_pCount);
    	}
    
    	T& operator*()
    	{
    		return *_ptr;
    	}
    
    	T* operator->()
    	{
    		return _ptr;
    	}
    
    	T* get()
    	{
    		return _ptr;
    	}
    
    private:
    	T* _ptr;
    	int* _pCount;
    };
    

    赋值重载的实现稍微麻烦一点

    1. 首先处理自己给自己赋值的情况,即两个相同的智能指针对象之间赋值是没有意义的,另外,两个指向同一块空间的不同智能指针对象之间赋值也是没有意义的,所以我们不去比较 this&sp,而是直接比较_ptrsp._ptr
    2. 被赋值的智能指针会被改变指向,那么其原来管理的资源的计数器应该-1,如果计数器为0,就把原来的资源和计数器释放。
    3. 将要赋值的成员变量赋过去,并且让计数器+1。
    shared_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
        if (_ptr == sp._ptr) return *this;
    
        if (--(*_pCount) == 0)
        {
            delete _ptr;
            delete _pCount;
            _ptr = nullptr;
            _pCount = nullptr;
        }
        _ptr = sp._ptr;
        _pCount = sp._pCount;
        ++(*_pCount);
        return *this;
    }
    

    weak_ptr

    解决了拷贝问题,智能指针还面临一个问题——循环引用

    用智能指针让两个双链表的结点互连,代码如下:

    struct ListNode
    {
    	std::shared_ptr<ListNode> _next = nullptr;
    	std::shared_ptr<ListNode> _prev = nullptr;
    	int _val = 0;
    
    	~ListNode()
    	{
    		cout << "~ListNode()" << endl;
    	}
    };
    
    void test1()
    {
    	std::shared_ptr<ListNode> p1(new ListNode);
    	std::shared_ptr<ListNode> p2(new ListNode);
    
    	p1->_next = p2;
    	p2->_prev = p1;
    }
    

    程序运行结果发现 ListNode 的析构函数没有被调用,这是怎么回事?

    在两个结点互连之后,两个结点资源对应的计数器都为2(一个资源有2个只能指针维护,如 p1 指向的结点还有 p2->_prev 指向),函数结束后智能指针 p1 p2 被销毁,两个结点的计数器都变为1,没有变为0,无法释放。此时如果要让一个结点释放->那么必须先把它的计数器变为0->要让另一个结点的成员指针释放->要让另一个结点释放,这个逻辑死循环了,所以两个都不会释放。

    怎么解决呢?

    一个方案就是,让结点内部指针 _prev_next 指向的时候不使对应的资源的计数器+1,那么它们俩用原生指针行不行?不行,因为原生指针无法接受智能指针类型 p1->_next = p2; p2->_prev = p1; 这两句会报错。


    为了解决这个问题,C++引入了智能指针 weak_ptr

    weak_ptr - C++ Reference (cplusplus.com)

    该指针不参与资源的创建与释放,它的特点是 share_ptr 拷贝给它的时候不会让计数器+1,它的释放也不会让计数器-1

    其构造函数原型:

    //default (1)	
    constexpr weak_ptr() noexcept;
    //copy (2)	
    weak_ptr (const weak_ptr& x) noexcept;template <class U> weak_ptr (const weak_ptr<U>& x) noexcept;
    //from shared_ptr (3)	
    template <class U> weak_ptr (const shared_ptr<U>& x) noexcept;
    

    作为 share_ptr 的辅助,它只支持默认构造,拷贝构造,传入 share_ptr,不支持传入原生指针。

    要解决上面的问题,我们只要把 ListNode 定义的 _next_prev 改成 weak_ptr 就可以了:

    struct ListNode
    {
    	std::weak_ptr<ListNode> _next;
    	std::weak_ptr<ListNode> _prev;
    	int _val = 0;
    
    	~ListNode()
    	{
    		cout << "~ListNode()" << endl;
    	}
    };
    

    下面是 weak_ptr 的简易实现(库里的实现还要复杂很多,此处不细讲):

    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)
    	{
    		_ptr = sp.get();
    		return *this;
    	}
    
        T& operator*()
        {
            return *_ptr;
        }
    
        T* operator->()
        {
            return _ptr;
        }
        
    private:
    	T* _ptr;
    };
    

    总结:

    使用智能指针能避免大部分的内存泄漏问题,但是还有少部分场景不注意会出现内存泄漏,比如循环引用问题,如果程序员没识别出这个场景并改用 weak_ptr 还是会内存泄漏。相比之下,unique_ptr 更安全一些,也更节省空间,在不需要拷贝的场景,推荐使用 unique_ptr

    删除器

    又一个问题,智能指针的析构函数释放资源用的是 delete,是写死的,如果 new 的时候用了 [] 就会出现不匹配的问题。不仅如此,如果指向的资源是 malloc 出来的呢,又或者智能指针是一个文件指针呢?

    std::unique_ptr<Date> up1(new Date[10]);
    std::unique_ptr<FILE> up2((FILE*)fopen("test.cpp", "r"));
    std::unique_ptr<Date> up3((Date*)malloc(sizeof(Date)));
    

    我们来看 C++ 是如何解决的

    unique_ptr类模板原型:

    //non-specialized	
    template <class T, class D = default_delete<T>> class unique_ptr;
    //array specialization	
    template <class T, class D> class unique_ptr<T[],D>;
    

    可以看到,这里提供了一个模板参数 class D = default_delete ,这就是删除器,它支持传入仿函数类型,可以由我们自己定制。

    要 delete 多个对象,我们就可以传入这样一个仿函数:

    template<class T>
    struct DeleteArray
    {
    	void operator()(T* ptr)
    	{
    		delete[] ptr;
    	}
    };
    
    std::unique_ptr<Date, DeleteArray<Date>> up1(new Date[10]);
    

    释放 malloc 出来的内存:

    template<class T>
    struct Free
    {
    	void operator()(T* ptr)
    	{
    		free(ptr);
    	}
    };
    
    std::unique_ptr<Date, Free<Date>> up3((Date*)malloc(sizeof(Date)));
    

    关文件:

    struct Fclose
    {
    	void operator()(FILE* ptr)
    	{
    		fclose(ptr);
    	}
    };
    
    std::unique_ptr<FILE, Fclose> up2((FILE*)fopen("test.cpp", "w"));
    

    unique_ptr 的改造,使其支持传入定制删除器:

    template<class T>
    struct default_delete
    {
    	void operator()(T* ptr)
    	{
    		delete ptr;
    	}
    };
    
    template<class T, class D = default_delete<T>>
    class unique_ptr
    {
    public:
    	~unique_ptr()
    	{
    		//delete _ptr;
            D()(_ptr);
    	}
        
        //...
    

    shared_ptr 支持定制删除器的方式有点不一样,它是在构造函数部分传函数对象支持的

    其构造函数原型一部分:

    //with deleter (4)	
    template <class U, class D> shared_ptr (U* p, D del);
    template <class D> shared_ptr (nullptr_t p, D del);
    

    使用方式:

    std::shared_ptr<Date> sp1(new Date[10], DeleteArray<Date>()); // 传函数对象
    std::shared_ptr<Date> sp2(new Date[10], [](Date* ptr) {delete[] ptr; }); // 传lambda表达式
    

    传对象显然更轻松一些,但是 unique_ptr 不支持,因为析构函数必须是无参的,拿不到函数对象,shared_ptr 在库里面实现比较复杂,支持传函数对象。

  • 相关阅读:
    一、nginx配置
    AntvG6-graph图谱工具
    discuz教程 毫无基础常识的站长搭建HTTPS。图文并茂
    前端之CSS 创建css--行内引入、内联样式、外联样式
    代码随想录 -- 哈希表--两数之和
    解决 idea maven依赖引入失效,无法正常导入依赖问题
    seurat dotplot lengend text ptsize
    前端项目:小程序电商管理平台难点整理
    QT基础 - 文件目录操作
    短视频矩阵系统源代码开发|技术源代码部署/OEM贴牌搭建
  • 原文地址:https://blog.csdn.net/CegghnnoR/article/details/127115371