C++ 智能指针的使用及其原理详解

一、为什么需要智能指针

先看一段很常见的代码:函数里 new 了两块内存,中间调用了一个可能抛异常的函数,然后手动 delete。看起来没问题,但只要中间抛了异常,后面的 delete 就永远不会执行,内存直接泄漏。

cpp 复制代码
double Divide(int a, int b)
{
    // 当 b == 0 时抛出异常
    if (b == 0)
    {
        throw "Divide by zero condition!";
    }
    else
    {
        return (double)a / (double)b;
    }
}

void Func()
{
    int* array1 = new int[10];
    int* array2 = new int[10];   // 这里 new 也可能抛异常
    try
    {
        int len, time;
        cin >> len >> time;
        cout << Divide(len, time) << endl;
    }
    catch (...)
    {
        cout << "delete []" << array1 << endl;
        delete[] array1;
        cout << "delete []" << array2 << endl;
        delete[] array2;
        throw; // 异常重新抛出,捕获到什么抛出什么
    }

    cout << "delete []" << array1 << endl;
    delete[] array1;
    cout << "delete []" << array2 << endl;
    delete[] array2;
}

这段代码的问题在于:

  • Divide 可能抛异常,导致后面的 delete 不执行;
  • 第二个 new 本身也可能抛异常,此时 array1 已经泄漏;
  • 为了处理这些情况,需要层层嵌套 try-catch,代码可读性极差。

智能指针正是为了解决这类问题而出现的:把资源交给一个对象管理,让对象的生命周期自动完成释放。

二、RAII 与智能指针的设计思路

RAII 是 Resource Acquisition Is Initialization 的缩写,是一种管理资源的设计思想。它的本质是利用对象的生命周期来管理动态获取的资源,避免资源泄漏。这里的资源可以是内存、文件指针、网络连接、互斥锁等等。

RAII 的做法是:获取资源时把资源委托给一个对象,资源在对象的生命周期内始终保持有效,最后在对象析构时释放资源。这样无论函数正常返回还是异常退出,资源都能被正确释放。

智能指针除了满足 RAII,还要方便访问资源,所以它像迭代器一样重载了 operator*、operator->、operator[] 等运算符。

cpp 复制代码
template<class T>
class SmartPtr
{
public:
    // RAII
    SmartPtr(T* ptr)
        : _ptr(ptr)
    {}

    ~SmartPtr()
    {
        cout << "delete[] " << _ptr << endl;
        delete[] _ptr;
    }

    // 重载运算符,模拟指针的行为,方便访问资源
    T& operator*()
    {
        return *_ptr;
    }

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

    T& operator[](size_t i)
    {
        return _ptr[i];
    }

private:
    T* _ptr;
};

用这个类改写上面的 Func,代码立刻清爽了:

cpp 复制代码
void Func()
{
    // 使用 RAII 的智能指针类管理 new 出来的数组以后,程序简单多了
    SmartPtr<int> sp1 = new int[10];
    SmartPtr<int> sp2 = new int[10];

    for (size_t i = 0; i < 10; i++)
    {
        sp1[i] = sp2[i] = i;
    }

    int len, time;
    cin >> len >> time;
    cout << Divide(len, time) << endl;
}

无论 Divide 是否抛异常,sp1 和 sp2 析构时都会自动释放内存。

三、C++ 标准库中的智能指针

C++ 标准库中的智能指针都在 <memory> 头文件下。除了 weak_ptr,它们都符合 RAII 和像指针一样访问的行为,原理上的主要区别在于拷贝时的处理思路不同。

智能指针 标准 拷贝 特点与建议
auto_ptr C++98 支持(转移管理权) 拷贝后被拷贝对象悬空,设计糟糕,强烈建议不要使用
unique_ptr C++11 不支持,只支持移动 不需要拷贝的场景优先使用
shared_ptr C++11 支持拷贝和移动 需要共享资源的场景使用,底层是引用计数
weak_ptr C++11 不支持 RAII 不能直接管理资源,用于解决 shared_ptr 循环引用

3.1 auto_ptr:一个糟糕的设计

auto_ptr 在拷贝时把被拷贝对象的资源管理权转移给拷贝对象,导致被拷贝对象悬空,访问就会报错。C++11 之后很多公司明令禁止使用它。

3.2 unique_ptr:独占所有权

unique_ptr 不支持拷贝,只支持移动。移动之后原对象也会悬空,所以使用移动要谨慎。

3.3 shared_ptr:共享所有权

shared_ptr 支持拷贝和移动,底层通过引用计数实现。每多一个 shared_ptr 指向同一资源,计数加一;析构时计数减一,减到 0 才真正释放资源。

cpp 复制代码
struct Date
{
    int _year;
    int _month;
    int _day;

    Date(int year = 1, int month = 1, int day = 1)
        : _year(year)
        , _month(month)
        , _day(day)
    {}

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

int main()
{
    auto_ptr<Date> ap1(new Date);
    // 拷贝时,管理权限转移,被拷贝对象 ap1 悬空
    auto_ptr<Date> ap2(ap1);
    // 空指针访问,ap1 对象已经悬空
    // ap1->_year++;

    unique_ptr<Date> up1(new Date);
    // 不支持拷贝
    // unique_ptr<Date> up2(up1);
    // 支持移动,但是移动后 up1 也悬空,所以使用移动要谨慎
    unique_ptr<Date> up3(move(up1));

    shared_ptr<Date> sp1(new Date);
    // 支持拷贝
    shared_ptr<Date> sp2(sp1);
    shared_ptr<Date> sp3(sp2);

    cout << sp1.use_count() << endl;   // 3
    sp1->_year++;
    cout << sp1->_year << endl;
    cout << sp2->_year << endl;
    cout << sp3->_year << endl;

    // 支持移动,但是移动后 sp1 也悬空,所以使用移动要谨慎
    shared_ptr<Date> sp4(move(sp1));

    return 0;
}

3.4 删除器:管理非 new 资源

智能指针析构时默认执行 delete,如果资源不是 new 出来的,直接交给智能指针管理就会崩溃。为此智能指针支持在构造时传入删除器------本质是一个可调用对象,里面实现你想要的释放方式。

因为 new[] 经常使用,unique_ptr 和 shared_ptr 都特化了一份 [] 版本:

cpp 复制代码
unique_ptr<Date[]> up1(new Date[5]);
shared_ptr<Date[]> sp1(new Date[5]);

也可以用仿函数、函数指针或 lambda 作为删除器:

cpp 复制代码
template<class T>
void DeleteArrayFunc(T* ptr)
{
    delete[] ptr;
}

template<class T>
class DeleteArray
{
public:
    void operator()(T* ptr)
    {
        delete[] ptr;
    }
};

class Fclose
{
public:
    void operator()(FILE* ptr)
    {
        cout << "fclose:" << ptr << endl;
        fclose(ptr);
    }
};

int main()
{
    // 这样实现程序会崩溃
    // unique_ptr<Date> up1(new Date[10]);
    // shared_ptr<Date> sp1(new Date[10]);

    // 解决方案 1:使用特化版本,析构时用 delete[]
    unique_ptr<Date[]> up1(new Date[5]);
    shared_ptr<Date[]> sp1(new Date[5]);

    // 解决方案 2:仿函数对象做删除器
    // unique_ptr 在类模板参数中指定删除器,shared_ptr 在构造函数参数中传入
    unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);
    shared_ptr<Date> sp2(new Date[5], DeleteArray<Date>());

    // 函数指针做删除器
    unique_ptr<Date, void(*)(Date*)> up3(new Date[5], DeleteArrayFunc<Date>);
    shared_ptr<Date> sp3(new Date[5], DeleteArrayFunc<Date>);

    // lambda 表达式做删除器
    auto delArrOBJ = [](Date* ptr) { delete[] ptr; };
    unique_ptr<Date, decltype(delArrOBJ)> up4(new Date[5], delArrOBJ);
    shared_ptr<Date> sp4(new Date[5], delArrOBJ);

    // 实现其他资源管理的删除器
    shared_ptr<FILE> sp5(fopen("Test.cpp", "r"), Fclose());
    shared_ptr<FILE> sp6(fopen("Test.cpp", "r"), [](FILE* ptr) {
        cout << "fclose:" << ptr << endl;
        fclose(ptr);
    });

    return 0;
}

注意:unique_ptr 的删除器通过类模板参数指定,shared_ptr 的删除器通过构造函数参数传入,两者方式并不统一。

3.5 make_shared 与 operator bool

shared_ptr 除了支持用指向资源的指针构造,还支持 make_shared 用初始化资源对象的值直接构造。shared_ptr 和 unique_ptr 都支持 operator bool 类型转换,可以直接放进 if 判断是否为空。

cpp 复制代码
int main()
{
    shared_ptr<Date> sp1(new Date(2024, 9, 11));
    shared_ptr<Date> sp2 = make_shared<Date>(2024, 9, 11);
    auto sp3 = make_shared<Date>(2024, 9, 11);

    shared_ptr<Date> sp4;
    if (sp1)
        cout << "sp1 is not nullptr" << endl;
    if (!sp4)
        cout << "sp4 is nullptr" << endl;

    // 报错:构造函数被 explicit 修饰,禁止普通指针隐式转换成智能指针
    // shared_ptr<Date> sp5 = new Date(2024, 9, 11);
    // unique_ptr<Date> sp6 = new Date(2024, 9, 11);

    return 0;
}

四、智能指针的原理与模拟实现

下面模拟实现 auto_ptr、unique_ptr 和 shared_ptr 的核心功能。重点看 shared_ptr 的引用计数设计。

一份资源需要一个引用计数,所以引用计数不能用静态成员实现,必须在堆上动态开辟:构造智能指针对象时 new 一个引用计数出来;多个 shared_ptr 指向同一资源时计数加一;析构时计数减一,减到 0 才释放资源。

cpp 复制代码
namespace bit
{
    template<class T>
    class auto_ptr
    {
    public:
        auto_ptr(T* ptr)
            : _ptr(ptr)
        {}

        auto_ptr(auto_ptr<T>& sp)
            : _ptr(sp._ptr)
        {
            // 管理权转移
            sp._ptr = nullptr;
        }

        auto_ptr<T>& operator=(auto_ptr<T>& ap)
        {
            // 检测是否自己给自己赋值
            if (this != &ap)
            {
                // 释放当前对象中资源
                if (_ptr)
                    delete _ptr;
                // 转移 ap 中资源到当前对象中
                _ptr = ap._ptr;
                ap._ptr = NULL;
            }
            return *this;
        }

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

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

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

    private:
        T* _ptr;
    };

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

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

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

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

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

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

        unique_ptr<T>& operator=(unique_ptr<T>&& sp)
        {
            delete _ptr;
            _ptr = sp._ptr;
            sp._ptr = nullptr;
            return *this;
        }

    private:
        T* _ptr;
    };

    template<class T>
    class shared_ptr
    {
    public:
        explicit 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)
        {}

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

        void release()
        {
            if (--(*_pcount) == 0)
            {
                // 最后一个管理的对象,释放资源
                _del(_ptr);
                delete _pcount;
                _ptr = nullptr;
                _pcount = nullptr;
            }
        }

        shared_ptr<T>& operator=(const shared_ptr<T>& sp)
        {
            if (_ptr != sp._ptr)
            {
                release();
                _ptr = sp._ptr;
                _pcount = sp._pcount;
                ++(*_pcount);
                _del = sp._del;
            }
            return *this;
        }

        ~shared_ptr()
        {
            release();
        }

        T* get() const
        {
            return _ptr;
        }

        int use_count() const
        {
            return *_pcount;
        }

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

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

    private:
        T* _ptr;
        int* _pcount;
        // atomic<int>* _pcount;
        function<void(T*)> _del = [](T* ptr) { delete ptr; };
    };

    // 这里的 shared_ptr 和 weak_ptr 都以最简洁的方式实现,只能满足基本功能,
    // 这里的 weak_ptr lock 等功能无法实现。想要实现就要把 shared_ptr 和 weak_ptr
    // 一起改,把引用计数拿出来放到一个单独类型,shared_ptr 和 weak_ptr 都要存储
    // 指向这个类的对象才能实现。
    template<class T>
    class weak_ptr
    {
    public:
        weak_ptr()
        {}

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

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

    private:
        T* _ptr = nullptr;
    };
}

使用自己实现的版本验证一下:

cpp 复制代码
int main()
{
    bit::auto_ptr<Date> ap1(new Date);
    // 拷贝时,管理权限转移,被拷贝对象 ap1 悬空
    bit::auto_ptr<Date> ap2(ap1);
    // 空指针访问,ap1 对象已经悬空
    // ap1->_year++;

    bit::unique_ptr<Date> up1(new Date);
    // 不支持拷贝
    // bit::unique_ptr<Date> up2(up1);
    // 支持移动,但是移动后 up1 也悬空,所以使用移动要谨慎
    bit::unique_ptr<Date> up3(move(up1));

    bit::shared_ptr<Date> sp1(new Date);
    // 支持拷贝
    bit::shared_ptr<Date> sp2(sp1);
    bit::shared_ptr<Date> sp3(sp2);

    cout << sp1.use_count() << endl;
    sp1->_year++;
    cout << sp1->_year << endl;
    cout << sp2->_year << endl;
    cout << sp3->_year << endl;

    return 0;
}

五、shared_ptr 与 weak_ptr

5.1 shared_ptr 的循环引用问题

shared_ptr 大多数情况下管理资源非常合适,既支持 RAII 也支持拷贝。但在循环引用的场景下会导致资源无法释放,造成内存泄漏。

考虑一个双向链表节点:

cpp 复制代码
struct ListNode
{
    int _data;
    std::shared_ptr<ListNode> _next;
    std::shared_ptr<ListNode> _prev;

    // 这里改成 weak_ptr,当 n1->_next = n2; 绑定 shared_ptr 时
    // 不增加 n2 的引用计数,不参与资源释放的管理,就不会形成循环引用了
    /*std::weak_ptr<ListNode> _next;
    std::weak_ptr<ListNode> _prev;*/

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

int main()
{
    // 循环引用 -- 内存泄露
    std::shared_ptr<ListNode> n1(new ListNode);
    std::shared_ptr<ListNode> n2(new ListNode);
    cout << n1.use_count() << endl;   // 1
    cout << n2.use_count() << endl;   // 1

    n1->_next = n2;
    n2->_prev = n1;
    cout << n1.use_count() << endl;   // 2
    cout << n2.use_count() << endl;   // 2

    return 0;
}

为什么两个节点都释放不掉?

  1. 右边的节点什么时候释放?由左边节点的 _next 管着,_next 析构后右边节点才释放。
  2. _next 什么时候析构?_next 是左边节点的成员,左边节点释放,_next 才析构。
  3. 左边节点什么时候释放?由右边节点的 _prev 管着,_prev 析构后左边节点才释放。
  4. _prev 什么时候析构?_prev 是右边节点的成员,右边节点释放,_prev 才析构。

逻辑上形成了一个回旋镖式的循环引用,谁都不会释放,最终导致内存泄漏。

把 ListNode 中的 _next 和 _prev 改成 weak_ptr 即可解决:weak_ptr 绑定到 shared_ptr 时不会增加引用计数,不参与资源释放管理,循环引用被成功打破。

5.2 weak_ptr 的用法

weak_ptr 不支持 RAII,也不支持访问资源。它构造时不支持绑定到资源,只支持绑定到 shared_ptr,绑定时不增加引用计数。

weak_ptr 也没有重载 operator* 和 operator->,因为它不参与资源管理,如果它绑定的 shared_ptr 已经释放了资源,直接访问就很危险。它提供了以下接口:

  • expired():检查指向的资源是否过期;
  • use_count():获取 shared_ptr 的引用计数;
  • lock():返回一个管理资源的 shared_ptr。如果资源已释放,返回空对象;否则通过返回的 shared_ptr 访问资源是安全的。
cpp 复制代码
int main()
{
    std::shared_ptr<string> sp1(new string("111111"));
    std::shared_ptr<string> sp2(sp1);
    std::weak_ptr<string> wp = sp1;

    cout << wp.expired() << endl;      // 0
    cout << wp.use_count() << endl;    // 2

    // sp1 和 sp2 都指向了其他资源,则 weak_ptr 就过期了
    sp1 = make_shared<string>("222222");
    cout << wp.expired() << endl;      // 0
    cout << wp.use_count() << endl;    // 1

    sp2 = make_shared<string>("333333");
    cout << wp.expired() << endl;      // 1
    cout << wp.use_count() << endl;    // 0

    wp = sp1;
    auto sp3 = wp.lock();
    cout << wp.expired() << endl;      // 0
    cout << wp.use_count() << endl;    // 2

    *sp3 += "###";
    cout << *sp1 << endl;              // 222222###

    return 0;
}

六、shared_ptr 的线程安全问题

shared_ptr 的线程安全问题要分两层看:

  • 引用计数 :引用计数对象在堆上,多个线程同时拷贝、析构 shared_ptr 时会修改引用计数,存在线程安全问题,需要加锁或用原子操作保证。
  • 指向的对象 :对象本身的线程安全不归 shared_ptr 管,也管不了,应该由外层使用 shared_ptr 的人进行控制。

下面的程序可能崩溃,也可能出现 A 资源没释放的情况。把 bit::shared_ptr 的引用计数从 int* 改成 atomic<int>* 就能保证引用计数的线程安全,或者使用互斥锁加锁也可以。

cpp 复制代码
struct AA
{
    int _a1 = 0;
    int _a2 = 0;

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

int main()
{
    bit::shared_ptr<AA> p(new AA);
    const size_t n = 100000;
    mutex mtx;

    auto func = [&]()
    {
        for (size_t i = 0; i < n; ++i)
        {
            // 这里智能指针拷贝会 ++ 计数
            bit::shared_ptr<AA> copy(p);
            {
                unique_lock<mutex> lk(mtx);
                copy->_a1++;
                copy->_a2++;
            }
        }
    };

    thread t1(func);
    thread t2(func);
    t1.join();
    t2.join();

    cout << p->_a1 << endl;
    cout << p->_a2 << endl;
    cout << p.use_count() << endl;

    return 0;
}

七、C++11 与 boost 中智能指针的关系

Boost 库是为 C++ 标准库提供扩展的程序库总称,Boost 社区的初衷之一就是为 C++ 标准化工作提供可供参考的实现,C++11 及之后的新语法和库有很多都来自 Boost。智能指针的演进大致如下:

  • C++98 中产生了第一个智能指针 auto_ptr;
  • Boost 给出了更实用的 scoped_ptr / scoped_array、shared_ptr / shared_array 和 weak_ptr 等;
  • C++ TR1 引入了 shared_ptr 等,但 TR1 并不是标准版;
  • C++11 引入了 unique_ptr、shared_ptr 和 weak_ptr,其中 unique_ptr 对应 Boost 的 scoped_ptr,这些智能指针的实现原理参考了 Boost 中的实现。

八、内存泄漏

8.1 什么是内存泄漏,有什么危害

内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存,一般是忘记释放,或者发生异常导致释放程序未能执行。内存泄漏并不是指内存在物理上消失,而是应用程序分配某段内存后,因为设计错误失去了对该段内存的控制,从而造成内存浪费。

普通程序运行一会就结束了,出现内存泄漏问题也不大,进程正常结束,页表的映射关系解除,物理内存也可以释放。但长期运行的程序出现内存泄漏影响很大,比如操作系统、后台服务、长时间运行的客户端等,不断泄漏会导致可用内存不断变少,各种功能响应越来越慢,最终卡死。

cpp 复制代码
int main()
{
    // 申请一个 1G 未释放,这个程序多次运行也没啥危害
    // 因为程序马上就结束,进程结束各种资源也就回收了
    char* ptr = new char[1024 * 1024 * 1024];
    cout << (void*)ptr << endl;
    return 0;
}

8.2 如何检测内存泄漏

  • Linux 下:使用 Valgrind 等内存泄漏检测工具;
  • Windows 下:使用第三方工具 VLD(Visual Leak Detector)等。

8.3 如何避免内存泄漏

  • 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配释放。但碰上异常时,即使注意释放了还是可能出问题,需要智能指针来管理才有保证。
  • 尽量使用智能指针管理资源,如果场景比较特殊,可以采用 RAII 思想自己造个轮子管理。
  • 定期使用内存泄漏工具检测,尤其是每次项目快上线前,不过有些工具不够靠谱,或者收费。

总结一下:内存泄漏非常常见,解决方案分为两种------事前预防型 (如智能指针)和事后查错型(如泄漏检测工具)。

九、总结

  • 智能指针的核心是 RAII:用对象的生命周期管理资源,析构时自动释放。
  • auto_ptr 拷贝转移管理权,已被废弃;unique_ptr 独占所有权,不支持拷贝;shared_ptr 共享所有权,基于引用计数。
  • 引用计数必须放在堆上,不能用静态成员,否则无法做到一份资源一个计数。
  • weak_ptr 不增加引用计数,专门用来打破 shared_ptr 的循环引用。
  • shared_ptr 只保证引用计数的线程安全,指向对象的线程安全需要使用者自己控制。
  • 内存泄漏重在预防,智能指针是最有效的手段,配合检测工具做兜底。
相关推荐
akinggw2 小时前
libagentcore - 一个基于c++的AI多agent系统升级到2.0版本
开发语言·jvm·c++
繁星蓝雨2 小时前
C++设计原理———多重继承(虚基类、菱形继承、委托、重命名、基类和成员初始化)
c++·委托·多重继承·mi·显式初始化列·虚基类·对象布局
旖旎夜光3 小时前
【C++ 基础入门】命名空间、输入输出、缺省参数与函数重载
开发语言·c++·笔记·学习
扶尔魔ocy4 小时前
【QT入门到晋级】用开源的自定义控件开发
c++·qt·自定义控件
AiNightVision4 小时前
无人机夜视模块如何选择?——从参数指标到系统集成的完整选型指南
人工智能·算法·计算机视觉·车载系统·无人机·视频·智能硬件
牧羊人.3335 小时前
卷积神经网络之VGG
人工智能·深度学习·神经网络·算法·cnn
akinggw5 小时前
libsherpacore - 基于 sherpa-onnx 的 C++ 语音交互静态库
c++·交互·语音识别
xiaoye-duck5 小时前
《C++ 项目:手写大模型接入 SDK》ChatGPT接口设计(中):全量响应实现和测试
网络·c++·语言模型·chatgpt
小郎君6585 小时前
Vue 多标签页的加载态设计:为什么我没有用 Suspense 实现 PageLoading
vue.js·算法