• 【C++练级之路】【Lv.25】智能指针




    快乐的流畅:个人主页


    个人专栏:《算法神殿》《数据结构世界》《进击的C++》

    远方有一堆篝火,在为久候之人燃烧!

    一、智能指针的引入

    什么是智能指针?为什么要引入它呢?在此之前,先来看一段代码:

    void test()
    {
    	int* p1 = new int;
    	int* p2 = new int;
    	delete p1;
    	delete p2;
    }
    

    C++内存分配中,使用 new 操作符,如果分配失败,可能会抛出 std::bad_alloc 异常。那么请想一想:

    • p1抛异常会怎么样?
    • p2抛异常会怎么样?

    如果p1抛出异常,则不会有问题出现。而如果p2申请时抛出异常,那么就会导致p1申请的空间无法释放,导致内存泄漏

    二、智能指针的概念

    鉴于异常导致执行流乱跳,可能造成内存泄漏,我们期望一种“智能”的指针,可以在抛异常时,自动释放已申请的空间

    1.1 RAII

    智能指针的核心思想,就是 RAII。

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

    template<class T>
    class SmartPtr
    {
    public:
    	SmartPtr(T* ptr = nullptr)
    		: _ptr(ptr)
    	{}
    
    	~SmartPtr()
    	{
    		delete _ptr;
    	}
    private:
    	T* _ptr;
    };
    
    void TestSmartPtr()
    {
    	SmartPtr<int> sp1 = new int;
    	SmartPtr<int> sp2 = new int;
    }
    

    我们将申请的空间托管给 SmartPtr 对象,申请空间时构造对象,对象析构时释放空间。这样,就可以在 new 抛异常时,伴随着对象的析构而自动释放空间。

    1.2 指针特性

    除了最核心的 RAII 思想,我们还要实现指针的特性,让类能拥有指针的行为,即为重载指针运算符

    template<class T>
    class SmartPtr
    {
    public:
    	SmartPtr(T* ptr = nullptr)
    		: _ptr(ptr)
    	{}
    
    	~SmartPtr()
    	{
    		delete _ptr;
    	}
    
    	T& operator*()
    	{
    		return *_ptr;
    	}
    
    	T* operator->()
    	{
    		return _ptr;
    	}
    private:
    	T* _ptr;
    };
    
    void TestSmartPtr()
    {
    	SmartPtr<int> sp1 = new int;
    	*sp1 = 2;
    
    	SmartPtr <pair<int, string>> sp2 = new pair<int, string>(1, "Black Myth:");
    	sp2->first += 2;
    	sp2->second += "WuKong";
    	cout << sp2->first << ":" << sp2->second << endl;
    }
    

    我们重载了*操作符->操作符,使得 SmartPtr 类拥有了和指针一样的行为。这点早在迭代器的实现时,便已经详细讲解过。

    1.3 拷贝问题

    智能指针的难点,便是拷贝问题

    void TestSmartPtr()
    {
    	SmartPtr<int> sp1 = new int;
    	SmartPtr<int> sp2 = sp1;
    }
    

    在上述代码的拷贝构造中,以往常规意义的浅拷贝和深拷贝都不对:

    • 浅拷贝,导致内存重复释放
    • 深拷贝,导致指针意义不对,没有指向同一份资源

    而我们接下来介绍的智能指针,将围绕这点来展开讨论。

    1.4 auto_ptr

    C++98 时,提供了第一个智能指针auto_ptr

    auto_ptr 原理:独占资源的所有权,并且在拷贝时转移资源的所有权。

    template<class T>
    class auto_ptr
    {
    public:
    	auto_ptr(T* ptr = nullptr)
    		: _ptr(ptr)
    	{}
    
    	~auto_ptr()
    	{
    		delete _ptr;
    	}
    
    	auto_ptr(auto_ptr<T>& ap)
    		: _ptr(ap._ptr)
    	{
    		ap._ptr = nullptr;
    	}
    
    	auto_ptr<T>& operator=(auto_ptr<T>& ap)
    	{
    		if (this != &ap)
    		{
    			delete _ptr;
    			_ptr = ap._ptr;
    			ap._ptr = nullptr;
    		}
    	}
    
    	T& operator*()
    	{
    		return *_ptr;
    	}
    
    	T* operator->()
    	{
    		return _ptr;
    	}
    private:
    	T* _ptr;
    };
    
    void test_auto_ptr()
    {
    	auto_ptr<int> ap1 = new int;
    	auto_ptr<int> ap2 = ap1;//所有权转移
    
    	*ap1 = 1;//悬空指针
    }
    

    auto_ptr 在拷贝时,将资源的所有权转移,从而使自身置空。这种行为虽然可以防止多个 auto_ptr 同时释放同一块内存,但是由于悬空指针的情况出现,在后续的代码中极易出现访问空指针的错误。

    由于其设计上的一些缺陷和危险性,它在 C++11 中被弃用,并最终在 C++17 中被完全移除

    三、智能指针的模拟实现

    C++11 时,新增了unique_ptrshared_ptrweak_ptr等智能指针,以代替auto_ptr。

    2.1 unique_ptr

    unique_ptr 原理:独占资源的所有权,并禁止任何拷贝

    template<class T>
    class unique_ptr
    {
    public:
    	unique_ptr(T* ptr = nullptr)
    		: _ptr(ptr)
    	{}
    
    	~unique_ptr()
    	{
    		delete _ptr;
    	}
    
    	unique_ptr(const unique_ptr<T>& up) = delete;
    	unique_ptr<T>& operator=(const unique_ptr<T>& up) = delete;
    
    	T& operator*()
    	{
    		return *_ptr;
    	}
    
    	T* operator->()
    	{
    		return _ptr;
    	}
    private:
    	T* _ptr;
    };
    
    void test_unique_ptr()
    {
    	unique_ptr<int> up1 = new int;
    	//unique_ptr up2 = up1;//独占所有权
    
    	*up1 = 1;
    }
    

    unique_ptr 通过显式 delete 拷贝构造和赋值重载,使得其对象不能进行任何拷贝,只能进行移动。

    它是 auto_ptr 的直接替代品,推荐用于管理独占所有权的动态内存。

    2.2 shared_ptr

    shared_ptr 原理:共享资源的所有权,通过引用计数来实现,多个指针可以共享同一个资源,当最后一个指针销毁时,资源才会被释放。

    如何实现引用计数呢?常规的 int 类型的成员变量或者静态成员变量都不行:

    • int count:属于每个对象,没有公有属性来“共享”。
    • static int count:属于整个类,有公有属性,但并不是我们期望的。

    所以,我们转换角度,引用计数不应该与对象或类挂钩,而是与资源相挂钩

    template<class T>
    class shared_ptr
    {
    public:
    	shared_ptr(T* ptr = nullptr)
    		: _ptr(ptr)
    	{}
    
    	~shared_ptr()
    	{
    		release();
    	}
    
    	shared_ptr(const shared_ptr<T>& sp)
    		: _ptr(sp._ptr)
    		, _pcount(sp._pcount)
    	{
    		++(*_pcount);
    	}
    
    	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;
    	}
    
    	T* get() const
    	{
    		return _ptr;
    	}
    
    	int use_count() const
    	{
    		return *_pcount;
    	}
    private:
    	void release()
    	{
    		if (--(*_pcount) == 0)
    		{
    			delete _ptr;
    			delete _pcount;
    		}
    	}
    	
    	T* _ptr;
    	int* _pcount = new int(1);//指向当前资源的指针数
    };
    
    void test_shared_ptr()
    {
    	shared_ptr<int> sp1 = new int;
    	shared_ptr<int> sp2 = sp1;//共享所有权
    
    	shared_ptr<int> sp3 = new int;
    	sp3 = sp2;
    }
    

    我们为了实现引用计数,新增一个成员变量 _pcount,指向当前资源的计数(表示有多少指针共享这个资源)。

    • 构造时计数初始化为1,此后每次拷贝都增加计数,实现资源共享。
    • 析构时减少计数,当计数减为0时,才释放资源。

    值得一提的是,赋值重载的判断,不再是以对象来判断(this != &sp),而是以资源来判断(_ptr != sp._ptr),防止同一资源的指针相互赋值。跨资源赋值时,先将当前资源 release(计数减1,如果为0则释放资源),再指向目标资源,增加计数。


    但是,shared_ptr 的引用计数,会出现一种问题——循环引用

    template<class T>
    struct ListNode
    {
    	T _val;
    	shared_ptr<ListNode<T>> _prev;
    	shared_ptr<ListNode<T>> _next;
    
    	ListNode(const T& val = T())
    		: _val(val)
    	{}
    };
    
    void test_shared_ptr()
    {
    	shared_ptr<ListNode<int>> n1 = new ListNode<int>;
    	shared_ptr<ListNode<int>> n2 = new ListNode<int>;
    
    	//循环引用
    	n1->_next = n2;
    	n2->_prev = n1;
    }
    

    在上述代码中:

    • 经过 n1 和 n2 的构造和节点的相互链接,两个节点的计数均为2.
    • 析构时,n2 先析构,使得 n2 指向的节点计数减到1,接着 n1 再析构,使得 n1 指向的节点计数减到1。
    • 而两块资源的计数都没有减到0,导致无法析构节点。

    这就是循环引用,这会导致引用计数不会减到0,从而内存泄漏

    2.3 weak_ptr

    为了解决 shared_ptr 的循环引用问题,weak_ptr 应运而生。

    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;
    };
    
    template<class T>
    struct ListNode
    {
    	T _val;
    	weak_ptr<ListNode<T>> _prev;
    	weak_ptr<ListNode<T>> _next;
    
    	ListNode(const T& val = T())
    		: _val(val)
    	{}
    };
    
    void test_shared_ptr()
    {
    	shared_ptr<ListNode<int>> n1 = new ListNode<int>;
    	shared_ptr<ListNode<int>> n2 = new ListNode<int>;
    
    	n1->_next = n2;
    	n2->_prev = n1;
    }
    

    正因其作用就是配合 shared_ptr 打破循环引用,所以 weak_ptr 没有原生指针的构造函数,只有默认构造和 shared_ptr 的构造函数。

    将节点内的指针换为 weak_ptr,则让两个节点的计数均为1,当 n1 和 n2 析构时,计数便能正常减到0,从而释放节点空间。

    2.4 定制删除器

    试想一下,如果不是用 new 来申请资源,应该如何进行正确地释放资源呢?这就涉及到定制删除器的设计。

    默认情况下,我们应该用 delete 进行释放,而对于特殊的资源申请方式,我们要传对应的删除器(如函数对象、lambda表达式等)进行特定的删除。

    unique_ptr

    template<class T>
    class unique_ptr
    {
    public:
    	unique_ptr(T* ptr = nullptr, function<void(T*)> del = [](T* ptr) {delete ptr; })
    		: _ptr(ptr)
    		, _del(del)
    	{}
    
    	~unique_ptr()
    	{
    		_del(_ptr);
    	}
    
    	//...
    private:
    	T* _ptr;
    	function<void(T*)> _del;
    };
    
    template<class T>
    struct DelArray
    {
    	void operator()(T* ptr)
    	{
    		delete[] ptr;
    	}
    };
    
    void test_unique_ptr()
    {
    	unique_ptr<ListNode<int>> up1(new ListNode<int>[10], DelArray<ListNode<int>>());
    	unique_ptr<ListNode<int>> up2(new ListNode<int>[10], [](ListNode<int>* ptr) {delete[] ptr; });
    	unique_ptr<FILE> up3(fopen("Test.cpp", "r"), [](FILE* ptr) {fclose(ptr); });
    
    	unique_ptr<ListNode<int>> up4(new ListNode<int>);
    }
    

    shared_ptr

    template<class T>
    class shared_ptr
    {
    public:
    	shared_ptr(T* ptr = nullptr, function<void(T*)> del = [](T* ptr) {delete ptr; })
    		: _ptr(ptr)
    		, _del(del)
    	{}
    
    	~shared_ptr()
    	{
    		release();
    	}
    	
    	//...
    private:
    	void release()
    	{
    		if (--(*_pcount) == 0)
    		{
    			_del(_ptr);
    			delete _pcount;
    		}
    	}
    	
    	T* _ptr;
    	int* _pcount = new int(1);
    	function<void(T*)> _del;
    };
    
    template<class T>
    struct DelArray
    {
    	void operator()(T* ptr)
    	{
    		delete[] ptr;
    	}
    };
    
    void test_shared_ptr()
    {
    	shared_ptr<ListNode<int>> sp1(new ListNode<int>[10], DelArray<ListNode<int>>());
    	shared_ptr<ListNode<int>> sp2(new ListNode<int>[10], [](ListNode<int>* ptr) {delete[] ptr; });
    	shared_ptr<FILE> sp3(fopen("Test.cpp", "r"), [](FILE* ptr) {fclose(ptr); });
    
    	shared_ptr<ListNode<int>> sp4(new ListNode<int>);
    }
    

    我们为了实现定制删除器,新增了一个成员变量 _del,由于参数类型无法显式定义,所以使用function函数包装器,默认缺省为 delete 的lambda表达式。

    同时,默认构造函数简化为一个双参数构造函数,可以自由选择是否显式传入自定义删除器。

    总结

    C++智能指针的优势:

    1. 自动内存管理:智能指针在超出作用域时自动释放所管理的内存资源,避免内存泄漏、悬挂指针和重复释放
    2. 异常安全:智能指针的使用符合RAII原则,在对象创建时获取资源,并在对象销毁时释放资源,即使发生异常也能确保资源被正确释放。
    3. 定制删除器:智能指针允许自定义删除器,可以管理各种资源,而不仅仅是内存,比如文件句柄、网络连接等。
    4. 线程安全:一些智能指针(如shared_ptr)的引用计数是线程安全的,可以安全地在多线程环境中使用。
    5. 调试和维护:智能指针提供的接口和操作符重载使得调试信息更加清晰明了,易于追踪对象的生命周期,提高了代码的可维护性和可读性。

    真诚点赞,手有余香

  • 相关阅读:
    CSS 3之美化表格样式
    Hive3 on Spark3配置
    海格里斯四向穿梭车|为何越来越多的仓库选择使用四向穿梭车立体库?
    LVS+keepalived
    MFC转Winform&&C++转C#
    为 ASP.NET Core (6.0)服务应用添加ApiKey验证支持
    书赞桉诺中国研创中心正式动工 以创新力推动可持续发展
    探索APP性能优化之稳定性优化(解决方案)
    【day11.02】网络编程脑图
    zabbix监控添加监控项及其监控Mysql、nginx
  • 原文地址:https://blog.csdn.net/2301_79188764/article/details/139759189