第二十七章:智能指针

第二十七章:智能指针

一、智能指针的诞生背景:为什么需要智能指针?

C++中堆内存的管理完全由开发者手动控制,通过new申请内存,delete释放内存。手动管理内存在常规场景下可以正常工作,但异常场景下会出现严重的内存泄漏问题,这也是智能指针要解决的核心痛点。

1.1 原生指针的内存泄漏痛点

我们通过一个典型场景来看原生指针的问题:

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

void Func()
{
    int* array1 = new int[10];
    int* array2 = new int[10]; // 此处如果new失败抛异常,array1无法释放

    try
    {
        int len, time;
        cin >> len >> time;
        cout << Divide(len, time) << endl; // 此处抛异常,array1和array2都无法释放
    }
    catch (...)
    {
        delete[] array1;
        delete[] array2;
        throw;
    }

    delete[] array1;
    delete[] array2;
}

上述代码存在致命问题:

  1. 如果array2new操作抛异常,array1已经申请的内存没有机会被释放,直接造成内存泄漏;
  2. 如果Divide函数抛异常,必须通过嵌套的try-catch捕获并释放资源,当new的对象越多,需要嵌套的try-catch就越多,代码会变得极其臃肿、难以维护,且极易遗漏释放逻辑。

【核心结论】手动管理堆内存时,异常场景下的资源释放极难控制,代码可维护性极差,极易出现内存泄漏。智能指针就是为了解决这个问题而设计的。

二、智能指针的核心基石:RAII机制

智能指针的底层设计完全依赖RAII机制,这是C++管理资源的核心设计思想,也是整个C++标准库的基石。

2.1 RAII的核心定义
  • 全称 :Resource Acquisition Is Initialization,翻译为资源获取即初始化
  • 本质:利用对象的生命周期来管理资源(内存、文件句柄、网络套接字、锁等)。
  • 核心工作流程
    1. 资源获取时,立即将资源委托给一个自定义类的对象管理(在对象的构造函数中完成资源的接管);
    2. 在对象的整个生命周期内,资源始终有效,可正常访问;
    3. 当对象生命周期结束、出作用域时,系统会自动调用对象的析构函数,在析构函数中完成资源的释放。

【核心结论】RAII机制的核心优势是:无论程序正常执行结束,还是中途抛异常,只要对象出了作用域,系统一定会调用其析构函数,保证资源一定会被释放,从根本上避免了内存泄漏。

【补充说明】RAII不只是用于智能指针,C++中所有需要手动释放的资源,都可以用RAII机制管理,比如多线程中的互斥锁std::lock_guard,就是典型的RAII应用,构造时加锁,析构时解锁。

三、最简智能指针的实现与核心能力

基于RAII机制,我们可以实现一个最基础的智能指针,它需要具备两个核心能力:RAII资源管理 + 模拟原生指针的行为

3.1 第一步:实现RAII的基础骨架

首先完成最核心的资源接管与自动释放,这是智能指针的灵魂:

cpp 复制代码
template<class T>
class SmartPtr
{
public:
    // 构造函数:获取资源,完成初始化(RAII核心)
    SmartPtr(T* ptr)
        :_ptr(ptr) // 接管用户传入的堆内存指针
    {}

    // 析构函数:对象生命周期结束时,自动释放资源
    ~SmartPtr()
    {
        cout << "delete[]:" << _ptr << endl;
        delete[] _ptr; // 释放资源
    }

private:
    T* _ptr; // 内部封装的原生指针,管理堆内存
};

这个基础版本已经解决了异常场景的内存泄漏问题,我们用它重写之前的Func函数:

cpp 复制代码
void Func()
{
    SmartPtr<int> sp1(new int[10]);
    SmartPtr<int> sp2(new int[10]); // 即使此处抛异常,sp1出作用域会自动调用析构释放资源

    for (size_t i = 0; i < 10; i++)
    {
        sp1[i] = sp2[i] = i; // 此处暂时会报错,后续通过运算符重载解决
    }

    int len, time;
    cin >> len >> time;
    cout << Divide(len, time) << endl; // 即使此处抛异常,sp1和sp2都会自动析构释放资源
}

此时无论代码哪个位置抛异常,只要对象出作用域,就会自动调用析构函数释放资源,完全不需要手动写try-catchdelete,彻底解决了内存泄漏问题。

3.2 第二步:重载运算符,模拟原生指针行为

基础版本的智能指针无法像原生指针一样通过*解引用、->访问成员、[]访问数组元素,我们需要通过运算符重载来模拟原生指针的行为,让智能指针的使用和原生指针完全一致。

cpp 复制代码
template<class T>
class SmartPtr
{
public:
    // RAII构造与析构
    SmartPtr(T* ptr = nullptr)
        :_ptr(ptr)
    {}

    ~SmartPtr()
    {
        if (_ptr)
        {
            delete[] _ptr;
        }
    }

    // 重载解引用运算符*,返回指针指向的内容的引用
    T& operator*()
    {
        return *_ptr;
    }

    // 重载箭头运算符->,返回底层原生指针
    T* operator->()
    {
        return _ptr;
    }

    // 重载下标运算符[],支持数组元素的访问
    T& operator[](size_t i)
    {
        return _ptr[i];
    }

private:
    T* _ptr;
};

【补充说明】

  • operator*:用于访问单个对象的内容,比如*sp = 10;,和原生指针*p = 10;行为完全一致;
  • operator->:用于访问自定义类对象的成员,比如sp->_year = 2025;,等价于sp.operator->()->_year = 2025;,编译器会做语法简化;
  • operator[]:用于管理数组类型的资源,支持下标访问数组元素。

四、智能指针的核心难题:拷贝问题

上述最简智能指针存在一个致命缺陷:默认的拷贝行为会导致程序崩溃,这也是所有智能指针都必须解决的核心问题。

4.1 问题根源:浅拷贝导致的重复释放

C++中,如果我们没有自定义拷贝构造函数,编译器会生成默认的拷贝构造函数,执行浅拷贝 ------只拷贝对象的成员变量的值。对于智能指针来说,浅拷贝会导致两个智能指针对象的_ptr成员指向同一块堆内存。

cpp 复制代码
int main()
{
    SmartPtr<int> sp1(new int[10]);
    SmartPtr<int> sp2(sp1); // 调用默认拷贝构造,浅拷贝,sp1和sp2指向同一块内存
    return 0;
}

这段代码运行会直接崩溃,原因是:

  1. sp2出作用域,先调用析构函数,释放了指向的堆内存;
  2. sp1随后出作用域,调用析构函数,对已经释放的内存再次执行delete,也就是double free,这是C++中典型的未定义行为,会直接导致程序崩溃。
4.2 为什么不能用深拷贝解决?

很多同学第一反应是:既然浅拷贝有问题,那我们写深拷贝不就好了?这里必须明确:深拷贝不符合智能指针的语义

【核心结论】智能指针的核心定位是代管用户申请的资源 ,用户将一个智能指针拷贝给另一个智能指针,期望的是两个指针指向同一块资源、共同管理,而不是各自拷贝一份独立的资源。深拷贝会改变用户的原始意图,完全不符合智能指针的设计目标。

因此,我们必须在浅拷贝、指向同一块资源的前提下,解决重复释放的问题,这也是C++标准库中不同智能指针的核心设计差异。

五、C++标准库智能指针的演进与使用

C++标准库中的智能指针都定义在<memory>头文件中,所有智能指针都严格遵循RAII机制,只是针对拷贝问题给出了不同的解决方案,适配不同的使用场景。

5.1 C++98 auto_ptr(自动指针)

auto_ptr是C++98标准推出的第一个智能指针,针对拷贝问题给出了管理权转移的解决方案,目前已被彻底弃用。

5.1.1 核心设计

拷贝/赋值操作时,将原对象对资源的管理权完全转移给新对象,同时将原对象的内部指针置空,保证同一时间只有一个auto_ptr对象管理同一块资源,从根本上避免重复释放。

cpp 复制代码
int main()
{
    auto_ptr<Date> ap1(new Date(2025, 6, 6));
    auto_ptr<Date> ap2(ap1); // 拷贝时管理权转移,ap1的内部指针被置空

    ap1->_year++; // 悬空指针访问,程序直接崩溃
    return 0;
}
5.1.2 致命缺陷与弃用原因
  1. 隐式管理权转移导致悬空指针:拷贝操作会偷偷把原对象置空,开发者如果不知情,后续访问原对象就会触发空指针访问,程序崩溃,这个问题极其隐蔽;
  2. 不支持const对象的拷贝:const对象无法被修改,无法完成管理权转移,导致const auto_ptr无法拷贝,使用场景严重受限;
  3. 不支持数组管理 :默认使用delete释放资源,无法适配new[]申请的数组,极易出现释放不匹配。

【易错警告】C++11标准已正式弃用auto_ptr,任何场景下都不建议使用,绝大多数公司的C++编码规范都明确禁止使用auto_ptr。

5.2 C++11 unique_ptr(唯一指针)

unique_ptr是C++11为了替换auto_ptr推出的智能指针,也是C++11后默认首选的智能指针 ,针对拷贝问题给出了防拷贝、只支持移动的解决方案。

5.2.1 核心设计
  • 编译层面禁止拷贝 :将拷贝构造函数和拷贝赋值运算符用delete关键字删除,完全禁止拷贝操作,从根源上避免了管理权转移的坑;
  • 支持移动语义 :只允许通过右值进行资源的管理权转移,比如std::move转换后的左值、临时对象,管理权转移是开发者主动执行的,明确知晓原对象会被置空,不会出现隐蔽的悬空问题;
  • 保证唯一性:同一时间,只有一个unique_ptr对象管理同一块资源,当unique_ptr对象出作用域析构时,自动释放资源。
5.2.2 核心特性与用法
cpp 复制代码
int main()
{
    // 1. 基础构造:接管堆内存资源
    unique_ptr<Date> up1(new Date(2025, 6, 6));

    // 2. 禁止拷贝,编译直接报错
    // unique_ptr<Date> up2(up1); // 错误:拷贝构造已被删除
    // unique_ptr<Date> up3 = up1; // 错误:拷贝赋值已被删除

    // 3. 支持移动语义,主动转移管理权
    unique_ptr<Date> up2(move(up1)); // 正确:up1的管理权转移给up2,up1被置空

    // 4. 模拟原生指针行为
    up2->_year = 2026; // operator->
    cout << (*up2)._month << endl; // operator*

    // 5. operator bool:判断智能指针是否为空,是否管理有效资源
    if (up2)
    {
        cout << "up2 不为空,管理着有效资源" << endl;
    }
    if (!up1)
    {
        cout << "up1 为空,已转移管理权" << endl;
    }

    // 6. get():获取底层的原生指针,不改变管理权
    Date* p = up2.get();
    cout << p->_day << endl;

    // 7. release():放弃资源的管理权,返回原生指针,将智能指针对象置空,不会释放资源
    Date* pRelease = up2.release();
    delete pRelease; // 必须手动释放,否则会内存泄漏

    // 8. reset():重置智能指针,释放当前管理的资源,接管新的资源
    unique_ptr<Date> up3(new Date());
    up3.reset(new Date(2025, 6, 7)); // 先释放旧资源,再接管新的资源
    up3.reset(); // 只释放当前资源,置空智能指针

    return 0;
}
5.2.3 关键细节说明
  1. explicit构造函数 :unique_ptr的单参数构造函数被explicit修饰,禁止隐式类型转换。 【补充说明】禁止隐式转换的原因:如果允许unique_ptr<Date> up = new Date();这种隐式转换,编译器会先构造一个临时的unique_ptr对象,再用临时对象拷贝构造up,而unique_ptr禁止拷贝,即使编译器优化了拷贝,也存在语法风险,因此标准库用explicit强制要求显式构造。
  2. 数组支持 :unique_ptr专门提供了数组的特化版本,支持new[]申请的数组,默认使用delete[]释放资源,使用方式为unique_ptr<Date[]> up(new Date[10]);,可以直接通过[]访问数组元素。
5.2.4 适用场景

不需要资源共享的场景,优先使用unique_ptr,它的设计极其简洁,没有引用计数的性能开销,效率和原生指针几乎一致,是C++11后最常用的智能指针。

5.3 C++11 shared_ptr(共享指针)

shared_ptr是C++11推出的支持资源共享的智能指针,针对拷贝问题给出了引用计数的解决方案,支持多个智能指针对象共同管理同一块资源。

5.3.1 核心设计
  • 引用计数机制 :每一块被管理的资源,都会配套一个在堆上开辟的引用计数,记录当前有多少个shared_ptr对象在管理这块资源;
  • 拷贝/赋值时:新的shared_ptr对象指向同一块资源和同一个引用计数,同时将引用计数+1;
  • 析构时:将对应的引用计数-1,如果引用计数减到0,说明没有任何对象管理这块资源,自动释放资源和引用计数;如果引用计数不为0,说明还有其他对象在管理资源,不执行释放。
5.3.2 核心特性与用法
cpp 复制代码
int main()
{
    // 1. 基础构造:接管资源,引用计数初始化为1
    shared_ptr<Date> sp1(new Date(2025, 6, 6));
    cout << sp1.use_count() << endl; // 输出1,当前引用计数

    // 2. 支持拷贝,引用计数+1
    shared_ptr<Date> sp2(sp1);
    shared_ptr<Date> sp3 = sp1;
    cout << sp1.use_count() << endl; // 输出3,sp1/sp2/sp3共同管理资源

    // 3. 赋值操作:先释放原管理的资源(引用计数-1,减到0则释放),再指向新资源,引用计数+1
    shared_ptr<Date> sp4(new Date());
    sp4 = sp1;
    cout << sp1.use_count() << endl; // 输出4
    cout << sp4.use_count() << endl; // 输出4

    // 4. 模拟原生指针行为
    sp1->_year = 2026;
    cout << (*sp1)._month << endl;

    // 5. operator bool、get()、reset()用法和unique_ptr完全一致
    if (sp1)
    {
        Date* p = sp1.get();
        cout << p->_day << endl;
    }
    sp4.reset(new Date(2025, 6, 7)); // 原资源引用计数减到3,sp4接管新资源

    return 0;
}
5.3.3 make_shared工厂函数

C++标准库提供了std::make_shared函数,用于更安全、更高效地创建shared_ptr,是创建shared_ptr的推荐方式。

cpp 复制代码
int main()
{
    // 推荐用法:make_shared,传入构造对象的参数,内部完成内存申请和对象构造
    auto sp3 = make_shared<Date>(2025, 6, 7);
    // 等价于 shared_ptr<Date> sp3(new Date(2025, 6, 7));

    return 0;
}

【核心优势】make_shared会一次性在堆上开辟一块内存,同时存放管理的对象和引用计数,而普通构造会分两次开辟内存(一次给对象,一次给引用计数)。make_shared减少了内存开辟的次数,降低了内存碎片,提升了性能。

5.3.4 适用场景

需要多个对象共同管理同一份资源的场景,比如多个模块都需要访问同一块堆内存,此时使用shared_ptr,无需关心资源何时释放,引用计数会自动管理。

【性能提醒】shared_ptr的拷贝、析构需要操作引用计数,存在轻微的原子操作开销,不需要资源共享时,优先使用unique_ptr。

5.4 C++11 weak_ptr(弱指针)

weak_ptr是C++11推出的辅助智能指针,它不是常规的智能指针,不参与资源的管理,专门用来解决shared_ptr的循环引用问题

5.4.1 核心特性
  1. 不增加引用计数:weak_ptr可以指向shared_ptr管理的资源,但不会增加资源的引用计数,不影响资源的释放;
  2. 不直接管理资源:不能用原生指针直接构造weak_ptr,只能由shared_ptr或另一个weak_ptr构造;
  3. 不重载operator*和operator->:无法直接通过weak_ptr访问资源,避免资源释放后出现野指针访问。
5.4.2 核心接口与用法
cpp 复制代码
int main()
{
    shared_ptr<string> sp1(new string("111111"));
    shared_ptr<string> sp2(sp1);
    cout << sp1.use_count() << endl; // 输出2

    // 1. 用shared_ptr构造weak_ptr,不增加引用计数
    weak_ptr<string> wp = sp1;
    cout << wp.use_count() << endl; // 输出2,引用计数没有增加

    // 2. expired():判断指向的资源是否已经被释放(过期),返回true表示已释放
    cout << wp.expired() << endl; // 输出false,资源未释放

    // 3. 资源释放后,weak_ptr自动感知
    sp1 = make_shared<string>("222222");
    sp2 = make_shared<string>("333333");
    cout << wp.expired() << endl; // 输出true,原资源已释放
    cout << wp.use_count() << endl; // 输出0

    // 4. lock():获取一个管理该资源的shared_ptr,保证访问安全
    wp = sp1;
    shared_ptr<string> sp3 = wp.lock(); // 资源未过期,返回有效shared_ptr,引用计数+1
    if (sp3)
    {
        *sp3 += "###"; // 安全访问资源
        cout << *sp3 << endl;
    }

    return 0;
}

【安全说明】lock()是访问weak_ptr指向资源的唯一安全方式:如果资源已释放,lock()会返回空的shared_ptr;如果资源有效,lock()会返回一个shared_ptr,此时引用计数+1,保证访问过程中资源不会被其他线程释放。

六、shared_ptr的底层实现分步推演

shared_ptr是面试中最高频的手写考点,也是实现最复杂的智能指针,我们按照老师讲课的顺序,从基础骨架开始,一步一步迭代出完整、正确的实现,重点拆解老师强调的易错点。

6.1 第一步:基础骨架(RAII + 引用计数核心结构)

首先实现最核心的RAII机制、引用计数管理、拷贝构造和基础的运算符重载,重点解决引用计数的存储问题。

【易错方案避坑】老师重点强调了两种错误的引用计数设计:

  1. 普通成员变量int _count:每个shared_ptr对象都有自己的_count成员,拷贝后两个对象的计数不共享,无法同步加减,最终还是会重复释放;
  2. 静态成员变量static int _count:同类型的所有shared_ptr对象共享同一个静态计数,不同资源的计数会混在一起,比如两个独立的shared_ptr,会共用同一个计数,导致释放逻辑完全错误。

【正确方案】引用计数必须在堆上开辟,每一个资源对应一个独立的引用计数,所有管理该资源的shared_ptr都指向同一个堆上的计数,保证计数的全局共享。

基础骨架实现:

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

template<class T>
class shared_ptr
{
public:
    // 构造函数:接管资源,初始化引用计数为1
    shared_ptr(T* ptr = nullptr)
        :_ptr(ptr)
        , _pcount(new atomic<int>(1)) // 引用计数在堆上开辟,用atomic保证线程安全
    {}

    // 拷贝构造:共享资源和引用计数,计数+1
    shared_ptr(const shared_ptr<T>& sp)
        :_ptr(sp._ptr)
        , _pcount(sp._pcount)
    {
        ++(*_pcount); // 引用计数+1
    }

    // 析构函数:计数-1,减到0则释放资源和计数
    ~shared_ptr()
    {
        if (--(*_pcount) == 0)
        {
            delete _ptr; // 释放管理的资源
            delete _pcount; // 释放引用计数
        }
    }

    // 模拟原生指针行为
    T& operator*()
    {
        return *_ptr;
    }

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

    T* get()
    {
        return _ptr;
    }

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

    // 重载operator bool,支持判空
    operator bool()
    {
        return _ptr != nullptr;
    }

private:
    T* _ptr; // 管理的资源的原生指针
    atomic<int>* _pcount; // 堆上的引用计数,atomic保证线程安全
};
6.2 第二步:实现赋值重载(最高频易错点)

老师重点强调:shared_ptr的赋值重载是10个同学8个写错的难点,我们先拆解错误写法,再给出正确实现。

6.2.1 错误写法1:直接赋值,不处理原资源
cpp 复制代码
// 错误写法1
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
    // 直接拷贝指针和计数,计数+1
    _ptr = sp._ptr;
    _pcount = sp._pcount;
    ++(*_pcount);
    return *this;
}

错误原因:赋值前,当前对象可能已经管理着一份资源,直接覆盖指针和计数,会导致原资源的引用计数没有-1,永远无法释放,造成内存泄漏。

6.2.2 错误写法2:先释放原资源,再赋值
cpp 复制代码
// 错误写法2
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
    // 先释放原资源
    if (--(*_pcount) == 0)
    {
        delete _ptr;
        delete _pcount;
    }
    // 再赋值
    _ptr = sp._ptr;
    _pcount = sp._pcount;
    ++(*_pcount);
    return *this;
}

错误原因 :如果出现sp1 = sp1自己给自己赋值的情况,会先把自己的资源释放掉,后续拷贝的是已经释放的野指针,程序直接崩溃。

6.2.3 正确实现:先判断,再释放,后赋值

正确的逻辑分为3步:

  1. 判断是否指向同一块资源:如果是自己给自己赋值,或者两个对象已经管理同一块资源,直接返回,不做任何操作;
  2. 释放当前对象的原资源:将原资源的引用计数-1,如果减到0,释放资源和引用计数;
  3. 共享新资源:拷贝新资源的指针和引用计数,将引用计数+1,返回*this。

同时,老师建议将释放逻辑抽离为独立的release()函数,方便析构函数和赋值重载复用。

最终正确的赋值重载实现:

cpp 复制代码
template<class T>
class shared_ptr
{
public:
    // 抽离的释放函数:统一处理资源释放逻辑
    void release()
    {
        if (--(*_pcount) == 0)
        {
            delete _ptr;
            delete _pcount;
        }
    }

    // 正确的赋值重载
    shared_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
        // 核心判断:如果不是同一块资源,才执行赋值逻辑
        if (_ptr != sp._ptr)
        {
            release(); // 先释放自己原来管理的资源
            // 共享新的资源和计数
            _ptr = sp._ptr;
            _pcount = sp._pcount;
            ++(*_pcount);
        }
        return *this;
    }

    // 其他代码和第一步保持一致
    // ...
};
6.3 第三步:支持定制删除器

默认的shared_ptr用delete释放资源,无法适配数组、文件句柄等特殊资源,我们需要增加定制删除器的支持。

实现思路:

  1. std::function<void(T*)>包装删除器,它可以统一接收仿函数、lambda、函数指针等所有可调用对象;
  2. 给删除器设置默认值:默认用delete释放资源,兼容常规场景;
  3. 增加模板构造函数,支持用户传入任意类型的删除器。

最终完整的shared_ptr实现:

cpp 复制代码
#pragma once
#include<functional>
#include<atomic>
namespace bit
{
	template<class T>
	class shared_ptr
	{
	public:
		// 支持定制删除器的构造函数
		template<class D>
		shared_ptr(T* ptr, D del)
			:_ptr(ptr)
			, _pcount(new atomic<int>(1))
			, _del(del)
		{}

		// 基础构造函数,使用默认删除器
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			,_pcount(new atomic<int>(1))
		{}

		// 拷贝构造
		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;
		}

		// 资源释放函数
		void release()
		{
			if (--(*_pcount) == 0)
			{
				_del(_ptr); // 用定制删除器释放资源
				delete _pcount;
			}
		}

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

		// 模拟指针行为
		T& operator*()
		{
			return *_ptr;
		}

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

		T* get()
		{
			return _ptr;
		}

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

		// 判空支持
		operator bool()
		{
			return _ptr != nullptr;
		}

	private:
		T* _ptr; // 管理的资源指针
		atomic<int>* _pcount; // 线程安全的引用计数
		// 定制删除器,默认用lambda执行delete
		function<void(T*)> _del = [](T* ptr) {delete ptr; };
	};
}

七、智能指针的定制删除器

默认的智能指针使用delete释放资源,当我们管理的资源不是单个new出来的对象时,默认的释放逻辑就会出现问题,此时就需要使用定制删除器,自定义资源的释放方式。

7.1 为什么需要定制删除器?

默认释放逻辑不匹配的典型场景:

  1. 管理new[]申请的数组,需要用delete[]释放,否则会导致释放不匹配,行为未定义;
  2. 管理malloc/calloc申请的内存,需要用free释放;
  3. 管理文件句柄FILE*,需要用fclose关闭;
  4. 管理网络套接字、互斥锁、Windows句柄等系统资源,需要用对应的系统函数释放。
7.2 定制删除器的三种实现方式

C++智能指针的定制删除器支持三种可调用对象:仿函数lambda表达式函数指针,其中lambda表达式是最简洁、最常用的方式。

7.3 shared_ptr的定制删除器用法

shared_ptr的定制删除器设计非常友好,只需要在构造函数中传入删除器即可,编译器会自动推导删除器的类型,无需手动指定。

cpp 复制代码
// 1. 仿函数实现定制删除器
class Fclose
{
public:
    void operator()(FILE* ptr)
    {
        cout << "fclose:" << ptr << endl;
        fclose(ptr); // 关闭文件句柄
    }
};

int main()
{
    // 场景1:管理new[]数组,用delete[]释放
    shared_ptr<Date[]> sp1(new Date[10]); // C++17后支持,默认用delete[]释放

    // 场景2:管理文件句柄,用仿函数作为删除器
    shared_ptr<FILE> sp2(fopen("Test.cpp", "r"), Fclose());

    // 场景3:管理文件句柄,用lambda作为删除器(最常用)
    shared_ptr<FILE> sp3(fopen("Test.cpp", "r"), [](FILE* fp) {
        cout << "fclose:" << fp << endl;
        fclose(fp);
    });

    // 场景4:管理malloc申请的内存
    shared_ptr<int> sp4((int*)malloc(sizeof(int)*10), [](int* p) {
        cout << "free:" << p << endl;
        free(p);
    });

    return 0;
}
7.4 unique_ptr的定制删除器用法

unique_ptr的定制删除器设计和shared_ptr有明显区别:删除器的类型必须作为unique_ptr的第二个模板参数显式指定,使用起来相对繁琐。

cpp 复制代码
int main()
{
    // 1. 仿函数作为删除器,需要在模板参数中指定类型
    unique_ptr<FILE, Fclose> up2(fopen("Test.cpp", "r"));

    // 2. lambda作为删除器,需要用decltype推导lambda的类型
    auto FcloseFunc = [](FILE* fp) {
        cout << "fclose:" << fp << endl;
        fclose(fp);
    };
    unique_ptr<FILE, decltype(FcloseFunc)> up3(fopen("Test.cpp", "r"), FcloseFunc);

    return 0;
}

【老师提醒】定制删除器优先使用shared_ptr,它的设计更友好,lambda表达式可以直接传入,无需手动指定类型;unique_ptr的定制删除器需要在模板参数中指定类型,使用起来更繁琐。

八、shared_ptr的致命陷阱:循环引用问题

shared_ptr在绝大多数场景下都非常安全,但在循环引用的场景下,会出现内存无法释放的问题,这也是面试的高频考点。

8.1 什么是循环引用?

两个或多个对象,内部通过shared_ptr互相指向对方,形成一个闭环,导致每个对象的引用计数始终大于0,永远无法减到0,资源永远不会被释放,造成内存泄漏。

最典型的场景就是双向链表的节点:

cpp 复制代码
struct ListNode
{
    int _data;
    shared_ptr<ListNode> _next; // 用shared_ptr指向下一个节点
    shared_ptr<ListNode> _prev; // 用shared_ptr指向上一个节点

    ~ListNode()
    {
        cout << "~ListNode()" << endl; // 析构函数打印,观察是否被调用
    }
};

int main()
{
    shared_ptr<ListNode> n1(new ListNode);
    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;
}

运行这段代码,你会发现析构函数的打印永远不会出现,两个ListNode节点的资源完全没有被释放,造成了内存泄漏。

8.2 循环引用的根源分析

老师用一步步的逻辑拆解,讲透了循环引用的死循环:

  1. 程序执行到main函数末尾,n1n2出作用域,分别调用析构函数,各自的引用计数从2减到1;
  2. 右边的n2节点要释放,必须等左边节点的_next成员析构;而_next是左边节点的成员,只有左边节点释放了,_next才会析构;
  3. 左边的n1节点要释放,必须等右边节点的_prev成员析构;而_prev是右边节点的成员,只有右边节点释放了,_prev才会析构;
  4. 最终形成逻辑死循环:两个节点互相等待对方释放,自己永远无法释放,引用计数永远停留在1,资源永远不会被回收。
8.3 循环引用的解决方案:weak_ptr

解决循环引用的核心方法,就是把形成循环的shared_ptr替换成weak_ptr

weak_ptr不会增加资源的引用计数,因此不会影响资源的释放,完美打破循环引用的死循环:

cpp 复制代码
struct ListNode
{
    int _data;
    // 把形成循环的成员改成weak_ptr,不增加引用计数
    weak_ptr<ListNode> _next;
    weak_ptr<ListNode> _prev;

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

int main()
{
    shared_ptr<ListNode> n1(new ListNode);
    shared_ptr<ListNode> n2(new ListNode);
    cout << n1.use_count() << endl; // 输出1
    cout << n2.use_count() << endl; // 输出1

    // 互相指向,weak_ptr不增加引用计数
    n1->_next = n2;
    n2->_prev = n1;

    cout << n1.use_count() << endl; // 输出1,引用计数没有增加
    cout << n2.use_count() << endl; // 输出1,引用计数没有增加

    return 0;
}

运行这段代码,析构函数会正常打印,两个节点的资源被正常释放,循环引用问题被彻底解决。

【核心结论】当出现对象间互相指向、可能形成循环引用的场景时,把其中一方或双方的shared_ptr替换为weak_ptr,即可解决内存泄漏问题。

九、补充知识点

9.1 智能指针的线程安全问题

老师明确给出了关于智能指针线程安全的两个核心结论,必须严格区分:

  1. shared_ptr的引用计数是线程安全的 :引用计数使用原子操作atomic<int>实现,多线程下同时拷贝、析构shared_ptr,对引用计数的加减是原子的,不会出现数据竞争,结果完全正确;
  2. shared_ptr指向的资源不是线程安全的:多线程下同时对shared_ptr管理的资源进行读写操作,会出现数据竞争,需要开发者自己加互斥锁保证线程安全。
9.2 C++智能指针与Boost库的关系
  • Boost库是C++标准库的前置探索社区,由C++标准委员会成员发起,专门探索C++的新特性、新库,验证成熟后会被纳入C++标准;
  • C++11的智能指针完全来自Boost库:unique_ptr对应Boost的scoped_ptrshared_ptrweak_ptr完全来自Boost库的对应实现;
  • C++11后的很多核心特性,包括右值引用、移动语义等,最早都在Boost库中进行了验证和落地。
9.3 内存泄漏的全面讲解
9.3.1 什么是内存泄漏?

内存泄漏指的是:程序中已经不再使用的内存,因为开发者的疏忽或错误,没有被释放,导致程序无法再次使用这块内存,系统的可用内存越来越少。

【补充说明】内存泄漏不是内存物理上消失了,而是程序失去了对这块内存的控制,无法释放也无法使用,占着内存资源不释放。

9.3.2 内存泄漏的危害
  • 短生命周期的普通程序:危害极小,程序运行结束后,进程退出,操作系统会回收该进程的所有内存资源,泄漏的内存会被系统自动回收;
  • 长期运行的程序:危害极大,比如服务器后台程序、嵌入式设备程序、7*24小时运行的客户端,内存泄漏会不断累积,导致程序可用内存越来越少,最终程序卡顿、崩溃,引发严重的生产事故。
9.3.3 内存泄漏的解决方案
  1. 事前预防(核心)
    • 养成良好的编码习惯,申请的内存必须匹配对应的释放逻辑;
    • 优先使用智能指针管理堆内存,从根源上避免绝大多数内存泄漏;
    • 识别循环引用场景,正确使用weak_ptr打破循环。
  2. 事后排查
    • 使用内存泄漏检测工具,Windows平台常用VLD,Linux平台常用valgrind
    • 工具原理:通过钩子函数勾住malloc/freenew/delete,记录所有内存的申请和释放,程序结束后统计未释放的内存,定位泄漏的位置。
相关推荐
王老师青少年编程2 小时前
csp信奥赛C++高频考点专项训练之贪心算法 --【贪心与二分判定】:数列分段 Section II
c++·算法·贪心·csp·信奥赛·二分判定·数列分段 section ii
zh_xuan2 小时前
libcurl调用https接口
c++·libcurl
就叫飞六吧2 小时前
QT写一个桌面程序exe并动态打包基本流程(c++)
开发语言·c++
蜡笔小马2 小时前
1.c++设计模式-工厂模式
c++
汉克老师3 小时前
GESP2025年3月认证C++五级( 第三部分编程题(2、原根判断))
c++·算法·模运算·gesp5级·gesp五级·原根·分解质因数
winner88813 小时前
从零吃透C++命名空间、std、#include、string、vector
java·开发语言·c++
Aurorar0rua3 小时前
CS50 x 2024 Notes C - 07
c语言·学习方法
AI进化营-智能译站3 小时前
ROS2 C++开发系列07-高效构建机器人决策逻辑,运算符与控制流实战
开发语言·c++·ai·机器人
爱编码的小八嘎3 小时前
C语言完美演绎9-15
c语言