【c++11】智能指针 -- 摆脱内存困扰,现代编程的智能选择


🌟🌟作者主页:ephemerals__
🌟🌟所属专栏:C++

前言

在软件开发的世界里,资源的有效管理至关重要,尤其是在处理动态分配的内存时。稍不留神,内存泄漏就会像潜伏的幽灵,悄无声息地消耗系统资源,最终导致程序崩溃或性能下降。

现代编程语言和技术为我们提供了更智能的工具来应对这一挑战。其中一种优雅的解决方案就是"智能指针"。它们通过自动化资源回收过程,将开发者从繁琐的手动内存管理中解放出来。

本文将深入探讨智能指针的概念及其在实践中的应用。你将了解到它们是如何工作的,以及如何在你的项目中利用它们来编写更健壮、更可靠的代码,从而告别那些令人头疼的内存管理问题。让我们一起探索智能指针的奥秘,提升我们的编程效率和代码质量。

一、RAII设计思想

先看一段代码:

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

void func()
{
    int* p1 = new int[10]{0};
    float* p2 = new float[10]{0};

    int x = -1;
    cin >> x;
    if(x == -1) throw x;
    
    delete p1;
    delete p2;
}

int main()
{
    try
    {
        func();
    }
    catch(const exception& e)
    {
        std::cerr << e.what() << endl;
    }
    return 0;
}

试想,在上述代码中,如果p1动态申请失败,抛出异常会怎么样?

程序会自动处理异常,不会造成内存泄漏问题。

如果p2动态申请失败会怎么样?

此时p1肯定动态申请了内存,而程序会直接跳到异常处理的位置,并没有释放内存,就会导致内存泄漏。

如果输入x错误抛出异常会怎么样?

此时p1、p2都动态申请了内存,程序跳到异常处理处,两者都没有释放内存,导致内存泄漏。

针对这些问题,如果在异常处理逻辑中释放内存,虽然能解决问题,但会增加代码冗余,并且如果有更多内存需要申请,代码就会更加杂乱。因此,就有了RAII设计思想

什么是RAII

RAII是"Resource Acquisition Is Initialization"的缩写,其核心思想是将资源的生命周期与对象的生命周期绑定在一起 。在我们获取资源时,将资源委托给一个对象,让该对象访问并控制资源。随着对象的生命周期结束,通过析构函数自动释放资源,就有效避免了内存泄漏问题。

注:这里的"资源"可以是动态申请的内存、文件指针、互斥锁等等。

因此,刚才的问题当中,如果抛出异常,程序就会直接退出func函数,跳到主函数的异常处理部分。如果将资源委托给RAII对象,那么它就会在跳转之前调用析构,从而释放资源。

二、智能指针

在C++当中,"智能指针"就是RAII设计思想的具体体现。

接下来,我们写一个简单的智能指针,感受一下RAII思想的妙处:

cpp 复制代码
template<class T>
class SmartPtr
{
public:
    //构造函数接收资源地址
    SmartPtr(T* ptr)
        :_ptr(ptr)
    {}

    //析构函数,释放资源
    ~SmartPtr()
    {
        delete _ptr;
    }

    //一些重载函数,支持类似于指针的操作
    T& operator*()
    {
        return *_ptr;
    }

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

    T& operator[](int i)
    {
        return _ptr[i];
    }
private:
    T* _ptr;
};

使用示例:

cpp 复制代码
SmartPtr<int> p1(new int[10]{0});
SmartPtr<float> p2(new float[10]{0});

这样,随着p1、p2离开作用域,生命周期结束,就会自动调用析构函数,释放申请的内存资源。

标准库的智能指针

刚才我们实现的智能指针有一个巨大的问题:如果要进行拷贝,默认生成的拷贝构造会让两个智能指针指向同一份资源 ,这样如果一个智能指针的生命周期结束后,会释放该资源,而等到另一个指针释放资源时,就会导致同一块资源多次释放的问题。

那么,如果我们手动实现深拷贝呢?也不可行,因为我们不知道指向的内存空间有多大,是连续的还是非连续的。因此还得支持浅拷贝,通过某些机制解决问题。

为此,C++标准库也设计了几种智能指针,针对拷贝问题的应对方式各有不同,接下来让博主一一讲解。

注:使用标准库的智能指针时,需要引头文件<memory>;标准库智能指针初始化时不能用赋值符号,因为不支持隐式类型转换。

auto_ptr

auto_ptr是C++98设计的智能指针,也是第一代智能指针。当auto_ptr间发生拷贝时,它的应对措施是:将原指针指向的资源移动 给新指针。这就会导致原指针失效 ,后续使用时,稍不注意就会出现错误。因此,auto_ptr是一个妥妥的败笔,强烈建议不使用

unique_ptr

unique_ptr是C++11提出的智能指针,它的特点是要求一份资源仅被一个unique_ptr维护 ,而不能是多个unique_ptr指向同一份资源。因此,它不支持拷贝,仅支持移动 。这样就有效地避免了多次释放等问题。在不需要地址拷贝的场景下,非常建议使用unique_ptr

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

int main()
{
    unique_ptr<int> p1(new int);

    unique_ptr<int> p2(p1); // 报错,不可拷贝

    unique_ptr<int> p3(move(p1)); // 仅支持移动
    return 0;
}

为了更安全、更高效地创建unique_ptr所管理的对象,C++14引入了一个函数make_unique,用于创建一个unique_ptr对象。使用示例:

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

class MyClass
{
public:
    MyClass(int x, int y) :_x(x), _y(y) {}
private:
    int _x;
    int _y;
};

int main()
{
    unique_ptr<MyClass> p1 = make_unique<MyClass>(3, 5); // 用make_unique构造对象并赋值
    return 0;
}

shared_ptr

shared_ptr也是C++11提出的智能指针,它可以支持多个shared_ptr指向同一份资源,但析构时不会造成多次释放问题 (底层使用引用计数实现,引用计数表示指向这份资源的shared_ptr的个数,当引用计数为0时才会释放资源)。

当然,shared_ptr也具有对应的make_shared函数。

使用示例:

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

class MyClass
{
public:
    MyClass()
    {
        cout << "调用构造函数" << endl;
    }

    ~MyClass()
    {
        cout << "调用析构函数" << endl;
    }
};

int main()
{
	shared_ptr<MyClass> p1 = make_shared<MyClass>();
	{
		shared_ptr<MyClass> p2 = p1;
		cout << "拷贝给p2" << endl;
		{
			shared_ptr<MyClass> p3 = p1;
			cout << "拷贝给p3" << endl;
			cout << "p3析构" << endl;
		}
		cout << "p2析构" << endl;
	}
	cout << "p1析构" << endl;
    return 0;
}

运行结果:

可以看到,当所有指向该资源的shared_ptr全部都析构时,才会将资源进行释放。

接下来,我们手动实现一个简单的shared_ptr,体会其引用计数机制的妙处。

手动实现shared_ptr

之前提到shared_ptr是用引用计数的方式实现的,不同的shared_ptr,针对同一份资源,使用同一个引用计数。

要让不同的shared_ptr使用同一份引用计数,你首先想到的可能是使用static成员。但这种做法是错误的,为什么呢?因为引用计数是针对资源而言的,表示的是指向该资源的shared_ptr的个数。因此,正确做法是:在构造shared_ptr的同时new一个整形变量,表示引用计数。发生拷贝时,将这个引用计数作为公共资源,新的shared_ptr也指向它;析构时,将引用计数-1,此时如果减为0,说明没有shared_ptr指向公共资源了,就将资源和引用计数一起释放。 这样引用计数就可以和资源绑在一起,多个shared_ptr对象也可以操作同一份引用计数了。

代码如下:

cpp 复制代码
template<class T>
class SharedPtr
{
public:
    //构造函数
    explicit SharedPtr(T* ptr = nullptr)
        :_ptr(ptr)
        , _pcount(new int(1)) // 动态申请引用计数
    {}

    //拷贝构造
    SharedPtr(const SharedPtr<T>& sp)
        :_ptr(sp._ptr)
        , _pcount(sp._pcount) // 多个指针指向同一份引用计数
    {
        (*_pcount)++; // 引用个数+1
    }

    //析构函数
    ~SharedPtr()
    {
        release();
    }

    //负责引用计数的减少和释放
    void release()
    {
        (*_pcount)--;
        if(*_pcount == 0) // 如果减到0,说明没有SharedPtr指向该资源了,释放引用计数和资源
        {
            if(_ptr) delete _ptr; // 防止空指针释放
            delete _pcount;
        }
    }

    //赋值重载
    SharedPtr<T>& operator=(const SharedPtr<T>& sp)
    {
        //如果不是自己给自己赋值,就调用拷贝构造的逻辑
        if(*this != sp)
        {
            release(); // 当前指针不在指向原来的资源,所以要release一次

            //重新赋值
            _ptr = sp._ptr;
            _pcount = sp._pcount;
            (*_pcount)++;
        }
        return *this;
    }

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

    T* operator->()
    {
        return _ptr;
    }
private:
    T* _ptr; // 指向资源
    int* _pcount; // 指向引用计数
};

测试:

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

class MyClass
{
public:
    MyClass()
    {
        cout << "调用构造函数" << endl;
    }

    ~MyClass()
    {
        cout << "调用析构函数" << endl;
    }
};

int main()
{
    {
        SharedPtr<MyClass> p1(new MyClass());
        {
            SharedPtr<MyClass> p2 = p1;
            cout << "拷贝给p2" << endl;
            {
                SharedPtr<MyClass> p3 = p1;
                cout << "拷贝给p3" << endl;
                cout << "p3析构" << endl;
            }
            cout << "p2析构" << endl;
        }
        cout << "p1析构" << endl;
    }
    return 0;
}

运行结果:

weak_ptr

weak_ptr是C++11提出的,但它不支持RAII,专门用于解决shared_ptr的循环引用问题。

什么是循环引用

先来看一段代码:

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

//双向链表节点
struct ListNode
{
    int data;
    shared_ptr<ListNode> prev;
    shared_ptr<ListNode> next;
};

int main()
{
    shared_ptr<ListNode> p1(new ListNode);
    shared_ptr<ListNode> p2(new ListNode);

    p1->next = p2;
    p2->prev = p1;
    return 0;
}

这里我们用shared_ptr创建了一个双向链表的节点,并分别用两个shared_ptr维护两个节点,并且将两个节点连接起来。这段代码会出现内存泄漏问题。为什么呢?让我们分析一下:

首先,p1和p2分别维护一个链表节点,此时它们的引用计数各为1。

接下来,连接两个节点,此时节点1的next指向节点2,节点2的prev指向节点1,再加上p1和p2,它们的引用计数都为2。

现在,p1的生命周期结束,节点1的引用计数变为1;p2的生命周期结束,节点2的引用计数变为1。

此时两个指针都已经销毁了,但是两个节点并没有释放。想要释放节点1,就要先释放节点2,节点2的prev才会释放;想要释放节点2,就要先释放节点1,节点1的next才会释放...这样无限循环,谁也不放过谁,就出现了循环引用,导致内存泄漏。

weak_ptr解决循环引用问题

weak_ptr不支持RAII,也不支持访问资源(没有operator*和operator->),但是通过weak_ptr就可以解决循环引用的问题。它的特点是:无法直接指向资源,只能接收shared_ptr 。此时weak_ptrshared_ptr虽然指向同一份资源,但 weak_ptr并不会参与引用计数。 在刚才的代码中,如果我们将链表节点的成员指针改成weak_ptr,由于weak_ptr不参与引用计数,所以p1和p2释放后,引用计数变为0,直接释放节点,就不会出现循环引用了。

有两个问题:

  • weak_ptr既然不支持访问资源,那么作为链表的指针域还有什么意义呢? 其实weak_ptr可以间接性的访问资源。weak_ptr成员函数lock 可以返回一个shared_ptr,它指向的是weak_ptr指向的资源,使用这个shared_ptr就可以进行资源访问。
  • shared_ptr不参与引用计数,也不支持RAII,那么如果其维护的资源已经释放了,如何判断呢? weak_ptr的成员函数成员函数expired 可以帮助我们判断其指向的资源是否已经释放(如果已经被释放,返回true)。当然,如果已经被释放,lock也会返回一个空对象。

定制删除器

刚才我们学的unique_ptrshared_ptr,它们在释放资源时默认使用delete进行释放,这也就意味着如果我们申请了多个资源或者将文件/网络套接字作为资源,就会在析构时出现错误。

因此,在定义智能指针时,如果我们需要申请多个资源或打开文件,那么就需要传入对应的删除/关闭规则,定制删除器。

unique_ptr定制删除器示例:

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

template<class T>
class delete_array
{
    void operator()(T* p)
    {
        delete[] p;
    }
};

class close_file
{
    void operator()(FILE* pf)
    {
        fclose(pf);
    }
};

template<class T>
void delete_arr(T* ptr)
{
    delete[] ptr;
}

int main()
{
    //unique_ptr,在模板传入可调用对象的类型,定制删除器
    unique_ptr<int, delete_array<int>> p(new int[10]);
    unique_ptr<FILE, close_file> pf(fopen("xxx.txt", "r"));

    //如果传入函数指针,需要在模板中传入函数类型,并在构造函数的第二个参数中传入函数地址
    unique_ptr<int, void(int*)> p2(new int[10], delete_arr<int>);

    //如果传入lambda表达式,需要在模板中传入lambda的类型,并且在构造函数的第二个参数中传入lambda
    auto del = [](int* ptr){ delete[] ptr; };
    unique_ptr<int, decltype(del)> p3(new int[10], del);

    //多个资源的申请也可以使用针对数组的特化版本
    unique_ptr<int[]> p4(new int[10]);
    return 0;
}

shared_ptr定制删除器示例:

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

template<class T>
void delete_arr(T* ptr)
{
    delete[] ptr;
}

int main()
{
    //shared_ptr,在构造函数的第二个参数中传入可调用对象,定制删除器
    shared_ptr<int> p(new int[10], [](int* ptr){ delete[] ptr; });
    shared_ptr<FILE> pf(fopen("xxx.txt", "r"), [](FILE* ptr){ fclose(ptr);});

    //也可以直接传入删除函数的地址
    shared_ptr<int> p2(new int[10], delete_arr<int>);

    //多个资源的申请也可以使用针对数组的特化版本
    shared_ptr<int[]> p3(new int[10]);
    return 0;
}

吐槽一下,这两种智能指针定制删除器的方法居然完全不一样,一个是在模板中,一个是传给构造函数,用的时候很容易混淆。并且各种可调用对象的传入方式也不尽相同,实际开发中还是使用仿函数比较方便。

补充知识

  • unique_ptrshared_ptr成员函数get可以帮我们获取到底层的原生指针,需要时可以使用它。
  • shared_ptrweak_ptr成员函数use_count可以帮我们获取到当前资源的引用计数数量。

接下来,基于我们刚才学习的删除器定制,以及一些成员函数,再略微完善一下我们手动实现的shared_ptr

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

template<class T>
class SharedPtr
{
public:
    //构造函数
    explicit SharedPtr(T* ptr = nullptr)
        :_ptr(ptr)
        , _pcount(new int(1)) // 动态申请引用计数
    {}

    //显式传入删除器的重载构造
    template<class D>
    explicit SharedPtr(T* ptr = nullptr, D del)
        :_ptr(ptr)
        , _pcount(new int(1)) // 动态申请引用计数
        , _del(del)
    {}

    //拷贝构造
    SharedPtr(const SharedPtr<T>& sp)
        :_ptr(sp._ptr)
        , _pcount(sp._pcount) // 多个指针指向同一份引用计数
        , _del(sp._del) // 删除器拷贝
    {
        (*_pcount)++; // 引用个数+1
    }

    //析构函数
    ~SharedPtr()
    {
        release();
    }

    //负责引用计数的减少和释放
    void release()
    {
        (*_pcount)--;
        if(*_pcount == 0) // 如果减到0,说明没有SharedPtr指向该资源了,释放引用计数和资源
        {
            if(_ptr) _del(_ptr); // 用删除器删除
            delete _pcount;
        }
    }

    //赋值重载
    SharedPtr<T>& operator=(const SharedPtr<T>& sp)
    {
        //如果不是自己给自己赋值,就调用拷贝构造的逻辑
        if(*this != sp)
        {
            release(); // 当前指针不在指向原来的资源,所以要release一次

            //重新赋值
            _ptr = sp._ptr;
            _pcount = sp._pcount;
            (*_pcount)++;
        }
        return *this;
    }

    //获取底层原生指针
    T* get()
    {
        return _ptr;
    }

    //获取引用计数
    int use_count()
    {
        return *_pcount;
    }

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

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

    //[]重载
    T& operator[](int i)
    {
        assert(i >= 0);
        return _ptr[i];
    }

    //支持将对象转换为bool类型,直接判断是否是空指针,例如if(p)
    explicit operator bool() const
    {
        return _ptr != nullptr;
    }
private:
    T* _ptr; // 指向资源
    int* _pcount; // 指向引用计数
    function<void(T*)> _del = [](T* ptr){ delete ptr; }; // 定制删除器,默认是delete
};

总结

通过本篇文章,我们从原理上理解了 C++ 资源管理的精髓。从 RAII 这一核心设计思想出发,我们看到了 auto_ptr 的历史局限,进而掌握了 unique_ptr 的独占所有权,以及 shared_ptr 如何通过引用计数实现共享所有权。特别是对循环引用的解析和 weak_ptr 的引入,为我们处理复杂对象关系提供了优雅的方案。

定制删除器则进一步展现了智能指针的强大和灵活性,使我们能以统一且安全的方式管理各种自定义资源。最终,无论是避免内存泄漏,还是提高代码的健壮性和可维护性,智能指针都无疑是现代 C++ 编程中不可或缺的利器。希望这些知识能够帮助大家在未来的 C++ 之旅中,写出更安全、更高效的代码。如果你觉得博主讲的还不错,就请留下一个小小的赞在走哦,感谢大家的支持❤❤❤

相关推荐
用户6869161349011 小时前
哈希表实现指南:从原理到C++实践
数据结构·c++
大老板a12 小时前
c++五分钟搞定异步处理
c++
全干engineer15 小时前
Web3-Web3.js核心操作:Metamask、合约调用、事件订阅全指南
开发语言·javascript·web3·区块链·智能合约
羑悻的小杀马特16 小时前
从信息孤岛到智能星云:学习助手编织高校学习生活的全维度互联网络
c++·学习·生活·api
刘一说16 小时前
资深Java工程师的面试题目(六)数据存储
java·开发语言·数据库·面试·性能优化
江沉晚呤时16 小时前
EventSourcing.NetCore:基于事件溯源模式的 .NET Core 库
java·开发语言·数据库
C++ 老炮儿的技术栈16 小时前
VSCode -配置为中文界面
大数据·c语言·c++·ide·vscode·算法·编辑器
祁同伟.16 小时前
【C++】类和对象(上)
c++
火鸟216 小时前
Rust 通用代码生成器:莲花,红莲尝鲜版三十六,哑数据模式图片初始化功能介绍
开发语言·后端·rust·通用代码生成器·莲花·红莲·图片初始化功能
90wunch16 小时前
更进一步深入的研究ObRegisterCallBack
c++·windows·安全