C++智能指针通俗讲解

c++智能指定到底是什么?看完彻底搞懂auto_ptr/unique_ptr/shared_ptr/weak_ptr

前言:为什么需要智能智能?

先看一段很普通的代码

复制代码
double Divide(int a, int b)
{
    try
    {
        if (b == 0)
        {
            string s("Divide by zero condition!");
            throw s;
        }
        else
        {
            return ((double)a / (double)b);
        }
    }
    catch (const int& s)
    {
        cout << s << endl;
    }

    cout << __FUNCTION__ << ":" << __LINE__ << "继续执行" << endl;
    return 0;
}

这里 catch (const int&) 并不能捕获 string 类型的异常,所以 Divide 中抛出的异常会继续向上层传播。

如果按照传统写法在 Func 中申请两块内存:

复制代码
void Func()
{
    int* array1 = new int[10];
    int* array2 = new int[10]; // 如果这行抛异常,array1 就泄漏了
    try
    {
        int len, time;
        cin >> len >> time;
        cout << Divide(len, time) << endl;
    }
    catch (...)
    {
        cout << "delete []" << array1 << endl;
        cout << "delete []" << array2 << endl;
        delete[] array1;
        delete[] array2;
        throw; // 异常继续向外抛
    }
    cout << "delete []" << array1 << endl;
    delete[] array1;
    cout << "delete []" << array2 << endl;
    delete[] array2;
}

这段代码至少有三个问题:

  1. Divide 抛异常后,后面的 delete[] 不会执行,内存泄漏;

  2. 第一个 new 成功、第二个 new 抛异常时,array1 还没进入 try,同样泄漏;

  3. 每多申请一份资源,就要多写一套 catch 释放逻辑,代码又长又容易漏。

结果就是:明明 new 了也 delete 了,程序却仍然可能泄漏。 智能指针要解决的正是这类"资源释放必须依赖控制流"的问题。

一、RAII:智能指针的设计思想

RAII 是 Resource Acquisition Is Initialization 的缩写,即"资源获取即初始化"。它的核心不是"智能",而是把资源交给对象的生命周期管理:

  • 对象构造时获取资源;

  • 对象存活期间资源始终有效;

  • 对象析构时自动释放资源。

这里的资源不限于堆内存,还包括文件指针、网络连接、互斥锁等。只要资源的生命周期能和一个栈对象绑定,就能享受"离开作用域自动释放"的待遇,即使中途抛异常,栈展开时析构函数也一定会被调用。

对于上面代码段可能出现内存泄漏的问题,这里就可以借助RAII 的思想来实现一个智能指针管理数组的对象,其他RAII的核心思想就是:构造时获取资源,析构函数自动释放资源,依靠对象生命周期管理资源。代码实现如下:

复制代码
template<class T>
class smart_ptr
{
public:
    smart_ptr(T* ptr)
        : _ptr(ptr)
    { }

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

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

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

    T* operator&()
    {
        return _ptr;
    }

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

private:
    T* _ptr;
};

它做了两件事:

  1. 构造时接管裸指针,析构时释放,这是 RAII;

  2. 重载 *、->、[]、&,让对象用起来仍然"像指针"。

于是 Func 可以简化为:

复制代码
void Func()
{
    // 智能指针管控资源,此时程序不会因为异常导致资源泄漏
    smart_ptr array1 = new int[10];
    smart_ptr array2 = new int[10];

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

无论 Divide 是否抛异常、第二个 new 是否成功,只要 array1、array2 构造完成,离开 Func 时析构函数就会自动释放资源。

不过这个简易的智能指针还有明显缺陷:

  • 固定使用 delete[],无法管理单个对象、FILE* 等资源;

  • 没有处理拷贝:默认拷贝只是浅拷贝 _ptr,两个对象析构时会对同一块内存 delete[] 两次;

  • 没有移动语义,也没有引用计数。

"拷贝时资源归谁"正是各种智能指针的分水岭 ,由此引出了 auto_ptr、unique_ptr、shared_ptr 和 weak_ptr。

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

标准库智能指针都在 <memory> 中。除了 weak_ptr,它们都符合 RAII,并且像指针一样支持 *、-> 等操作。它们的主要区别在于拷贝时的所有权处理方式。

智能指针 引入标准 所有权 拷贝 移动 引用计数
auto_ptr C++98 独占 拷贝即转移 --- 无
unique_ptr C++11 独占 禁止 支持 无
shared_ptr C++11 共享 支持 支持 有
weak_ptr C++11 不拥有 可从 shared_ptr 构造 支持 不增加计数

1. auto_ptr:拷贝即转移的失败设计

复制代码
auto_ptr<Date> ap1(new Date);
auto_ptr<Date> ap2(ap1); // ap1 的资源被转移给 ap2,ap1 变成悬空指针

auto_ptr 在拷贝时把资源管理权直接转走,被拷贝对象随即悬空。一旦再访问 ap1,就是未定义行为。这个设计过于危险,C++11 已将其弃用,实际项目中应完全避免。

2. unique_ptr:独占所有权,只移不拷

unique_ptr 的名字就是"唯一指针":同一时刻只能有一个 unique_ptr 拥有资源。

复制代码
unique_ptr<Date> up1(new Date);
// unique_ptr<Date> up2(up1);          // 错误:拷贝构造被删除
unique_ptr<Date> up2(move(up1));       // 正确:转移所有权

所以在一些不用拷贝的场景,就很适合用unique_ptr 智能指针。unique_ptr 开销极小,内部就一个裸指针,几乎和原生指针性能一模一样。

3.shared_ptr:共享智能指针

共享的意思就是和其他人一起共享资源,所以shared_ptr 即支持拷贝构造,实现多个对象管理一个资源块,也支持移动构造。

底层是如何实现不同对象管理同一个资源,要释放的又是怎么做到不重复释放的。

解决思路:增加一块计数器(控制块),记录有多少个 shared_ptr 在管理这块资源。

  • 新建 / 拷贝一份 shared_ptr:计数器 + 1
  • 析构 / 放弃资源:计数器 - 1
  • 计数器减到 0 的时候,才真正 delete 释放内存

内存布局:

  • shared_ptr 对象内部两样东西:
    1. 资源裸指针(指向你 new 的对象)
    2. 指向控制块的指针,控制块堆上分配,里面存引用计数、自定义删除器。

多个 shared_ptr,对象本身是不同的,但是指向同一份控制块!

复制代码
    shared_ptr<int> s1(new int(100));
    cout << s1.use_count() << endl; // 1

    shared_ptr<int> s2 = s1; // 拷贝,计数+1
    cout << s1.use_count() << endl; //2
    cout << s2.use_count() << endl; //2

    s1.reset(); // s1放弃资源,计数-1
    cout << s2.use_count() << endl; //1

    s2.reset(); // 计数变成0,内存释放

运行结果:

补充:make_shared() 函数

复制代码
template <class T, class... Args>
  shared_ptr<T> make_shared (Args&&... args);

make_shared 是 C++ 提供的工厂函数,一次性分配一块连续内存存放目标对象和shared_ptr 的控制块

推荐:auto s = make_shared<int>(100);构造shared_ptr对象。

// 等价 shared_ptr<Date> sp(new Date(2026,10,21));

auto sp = make_shared<Date>(2026, 10, 21);

4.weak_ptr:只观察,不拥有

weak_ptr 不参与 RAII,不能直接管理资源,它必须从 shared_ptr 构造或赋值。它的特点是不增加引用计数,因此不会影响资源的释放。

复制代码
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 = make_shared<string>("222222");
cout << wp.expired() << endl;    // 0,sp2 还持有旧资源
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();            // 提升为 shared_ptr,计数加一
sp1.reset();

cout << wp.expired() << endl;    // 0,sp3 仍持有
cout << wp.use_count() << endl;  // 1

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

lock() 的语义是:如果资源还活着,就返回一个有效的 shared_ptr;否则返回空。访问 weak_ptr 管理的对象前,必须先 lock(),不能直接解引用,否则资源可能已经被释放。

weak_ptr的产生主要是为了解决shared_ptr循环引用导致内存泄露的场景,在后面我们还会介绍。

三、定制删除器

shared_ptr 和 unique_ptr 默认都用 delete 释放资源。但资源不一定是 new 出来的:

  • new[] 需要 delete[];

  • FILE* 需要 fclose;

  • malloc 需要 free;

  • 网络连接、锁等需要专门的释放函数。

这时就要传入定制删除器。删除器本质是一个可调用对象,可以是仿函数、函数指针或 lambda。

我们先看库里是如何解决这个问题的?

  • unique_ptr 删除器:模板参数,类型一部分
  • shared_ptr 删除器:构造函数参数,不参与模板类型

使用方法:

复制代码
 // ========== 错误写法(放开会崩溃!)==========
    // unique_ptr<Date> up1(new Date[10]);
    // shared_ptr<Date> sp1(new Date[10]);
    // 原因:默认deleter是delete,不是delete[],数组只调用第一个元素析构,内存释放错乱

    // ========== 解决方案1:使用数组特化 unique_ptr<T[]> / shared_ptr<T[]> (C++17) ==========
    unique_ptr<Date[]> up1(new Date[5]);
    shared_ptr<Date[]> sp1(new Date[5]);
    cout << "===== 数组特化版本 unique_ptr<T[]>、shared_ptr<T[]> =====" << endl;

    // ========== 解决方案2:自定义删除器 仿函数 ==========
    // 仿函数支持:DeleteArray无参构造,可以省略第二个参数
    unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);
    // shared_ptr 删除器是构造参数,必须传入删除器对象
    shared_ptr<Date> sp2(new Date[5], DeleteArray<Date>());
    cout << "===== 仿函数删除器 =====" << endl;

    // ========== 解决方案2:函数指针作为删除器 ==========
    unique_ptr<Date, void(*)(Date*)> up3(new Date[5], DeleteArrayFunc<Date>);
    shared_ptr<Date> sp3(new Date[5], DeleteArrayFunc<Date>);
    cout << "===== 函数指针删除器 =====" << endl;

    // ========== 解决方案2:lambda作为删除器 ==========
    auto delArrOBJ = [](Date* ptr) {
        cout << "【lambda删除器】delete[] 数组" << endl;
        delete[] ptr;
    };
    // lambda类型用decltype拿到,unique_ptr模板要带上删除器类型,构造必须传入lambda对象
    unique_ptr<Date, decltype(delArrOBJ)> up4(new Date[5], delArrOBJ);
    shared_ptr<Date> sp4(new Date[5], delArrOBJ);
    cout << "===== lambda删除器 =====" << endl;

    // ========== 其他资源:FILE 文件句柄管理 ==========
    // 方式1:lambda删除器(推荐,直观,可以加日志)
    shared_ptr<FILE> sp6(fopen("Test.cpp", "r"), [](FILE* ptr) {
        if (ptr)
        {
            cout << "【lambda】调用fclose, ptr=" << ptr << endl;
            fclose(ptr);
        }
    });

    // 方式2:直接传入标准fclose函数指针
    shared_ptr<FILE> sp5(fopen("Test.cpp", "r"), fclose);

四、智能指针的原理

下面我们模拟实现了unique_ptr的核心功能,这个智能指针的实现比较简单,我们了解一下原理即可。auto_ptr的思路是拷贝时转移资源管理权给被拷贝对象,这种思路是不被认可的,也不建议使用。unique_ptr的思路是不支持拷贝。

重点要看看shared_ptr是如何设计的,尤其是引用计数的设计,主要这里一份资源就需要一个

引用计数,所以引用计数才用静态成员的方式是无法实现的,要使用堆上动态开辟的方式,构造智

能指针对象时来一份资源,就要new一个引|用计数出来。多个shared_ptr指向资源时就++引用计

数,shared_ptr对象析构时就-引用计数,引用计数减到0时代表当前析构的shared_ptr是最后一

个管理资源的对象,则析构资源。

复制代码
namespace gxy
{
    template<class T>
    class unique_ptr
    {
    public:
        explicit unique_ptr(T* ptr)
            : _ptr(ptr)
        { }

        ~unique_ptr()
        {
            if (_ptr)
            {
                delete _ptr;
                _ptr = nullptr;
            }
        }

        // 禁止拷贝
        unique_ptr(unique_ptr<T>& ptr) = delete;
        unique_ptr<T>& operator=(unique_ptr<T>& ptr) = delete;

        // 允许移动:直接"窃取"对方的指针
        unique_ptr(unique_ptr<T>&& ptr)
            : _ptr(ptr._ptr)
        {
            ptr._ptr = nullptr;
        }

        T& operator[](const size_t i) { return _ptr[i]; }
        T& operator*() { return *_ptr; }
        T* operator->() { return _ptr; }

    private:
        T* _ptr;
    };
}

关键点:

  • 拷贝构造和拷贝赋值用 = delete 禁用,从编译期杜绝"两个对象管同一块资源";

  • 移动构造把 _ptr 取走,并把源对象置空,保证析构时只有一个对象真正释放;

  • 不需要引用计数,因此开销和裸指针几乎一样。

只要场景不需要共享所有权,unique_ptr 应该是默认选择。

复制代码
template<class T>
class shared_ptr
{
public:
    explicit shared_ptr(T* ptr = nullptr)
        : _ptr(ptr)
        , count(new int(1))
    { }

    template<class D>
    shared_ptr(T* ptr, D del)
        : _ptr(ptr)
        , count(new int(1))
        , _del(del)
    { }

    // 拷贝构造:共享指针和计数,计数加一
    shared_ptr(const shared_ptr<T>& sp)
        : _ptr(sp._ptr)
        , count(sp.count)
        , _del(sp._del)
    {
        (*count)++;
    }

    // 移动构造:窃取资源
    shared_ptr(shared_ptr<T>&& sp)
        : _ptr(sp._ptr)
        , count(sp.count)
        , _del(sp._del)
    {
        sp._ptr = nullptr;
    }

    void release()
    {
        // 最后一个持有资源的对象负责释放
        if (--(*count) == 0)
        {
            _del(_ptr);
            delete count;
        }
    }

    shared_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
        if (this != &sp)
        {
            release();          // 先放弃自己原来持有的资源
            _ptr = sp._ptr;
            count = sp.count;
            _del = sp._del;
            (*count)++;         // 再共享新资源
        }
        return *this;
    }

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

    ~shared_ptr()
    {
        release();
    }

    T* get() const { return _ptr; }

    int use_count() const { return *count; }

    T& operator[](const size_t i) { return _ptr[i]; }
    T& operator*() { return *_ptr; }
    T* operator->() { return _ptr; }

private:
    T* _ptr = nullptr;
    int* count = nullptr;  // 计数必须放在堆上,才能被所有拷贝共享
    function<void(T*)> _del = [](T* ptr) { delete ptr; };
};

几个容易忽略的细节:

  1. 计数为什么用 int* 而不是 int?

    如果用 int count,每个 shared_ptr 拷贝都有自己的计数,根本没法共享。必须让所有副本指向同一块堆上的计数 ,所以用 new int(1),析构时再 delete count。

  2. 拷贝构造只加计数,不拷贝资源,指向同一块资源。

  3. 赋值为什么先 release()?

    假设 sp1 原来指向 A,现在要指向 B:

    • 先放弃 A,A 的计数减一,若归零就释放 A;

    • 再指向 B,并让 B 的计数加一。

    顺序反了就可能漏释放,或者自己给自己赋值时把计数搞乱,所以还要判断 this != &sp。

  4. 移动构造要"掏空"源对象。

    源对象把指针交出去后应置空,避免析构时重复释放。

  5. 定制删除器的设置(模仿标准 shared_ptr 设计)

    我们想写一个模板构造函数,支持传入任意形式的删除器(仿函数、lambda、函数指针都行)。但有一个棘手问题:不同删除器是完全不同的类型,而且这个删除器只是构造函数的形参,构造结束后,析构函数拿不到它、没法调用。

    标准库的思路就是用std::function做类型擦除 :在 shared_ptr 内部保存一个std::function包装对象。构造智能指针的时候,把外面传进来的删除器打包存进这个 function。析构的时候不需要关心原来删除器是什么类型,直接调用function,就可以执行对应的资源释放逻辑。

五、循环引用:shared_ptr 的经典陷阱

shared_ptr 最典型的问题就是循环引用。看下面的一段双向链表的节点的代码

复制代码
struct ListNode
{
    int _data;
    // gxy::shared_ptr<ListNode> _next;
    // gxy::shared_ptr<ListNode> _prev;
    gxy::weak_ptr<ListNode> _next;
    gxy::weak_ptr<ListNode> _prev;

    ListNode(int val = 0)
        : _data(val)
    { }

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

int main()
{
    gxy::shared_ptr<ListNode> n1(new ListNode(1));
    gxy::shared_ptr<ListNode> n2(new ListNode(2));

    cout << n1.use_count() << endl; // 1
    cout << n2.use_count() << endl; // 1

    n1->_next = n2;
    n2->_prev = n1;

    cout << n1.use_count() << endl; // 1
    cout << n2.use_count() << endl; // 1

    return 0;
}

如果把成员改成 shared_ptr:

复制代码
struct ListNode
{
    int _data;
    gxy::shared_ptr<ListNode> _next;
    gxy::shared_ptr<ListNode> _prev;
    // ...
};

执行 n1->_next = n2; 后,n2 的计数从 1 变成 2;执行 n2->_prev = n1; 后,n1 的计数也从 1 变成 2。main 结束时:

  • n1 析构,计数 2 → 1;

  • n2 析构,计数 2 → 1。

两个计数都无法归零,于是两个节点永远不会被释放,~ListNode() 也不会执行,这就是循环引用导致的内存泄漏。

改成 weak_ptr 后,_next、_prev 只是"观察"对方,不增加计数,计数始终保持 1。n1、n2 析构时计数归零,节点正常释放,~ListNode() 会被调用两次。

由此可以总结出 weak_ptr 的典型用途:

  • 打破 shared_ptr 的循环引用;

  • 观察者/回调中引用对象,但不延长其生命周期;

  • 缓存中保留弱引用,需要时再 lock() 提升;

  • 父子结构中,父持有子的 shared_ptr,子只持有父的 weak_ptr。

六、总结

智能指针的核心可以概括为一句话:用对象的生命周期管理资源,用所有权模型管理拷贝。

  • RAII 解决了"异常路径下资源漏释放"的问题;

  • unique_ptr 用"禁止拷贝、只允许移动"表达独占所有权,零额外开销,应作为默认选择;

  • shared_ptr 用堆上的引用计数表达共享所有权,灵活但有计数和线程同步开销;

  • weak_ptr 不拥有资源,用来观察 shared_ptr,专门解决循环引用和悬空访问;

  • 定制删除器 让智能指针可以管理 new[]、FILE* 等非 delete 资源;

  • make_shared 在多数场景下更高效,但要注意与 weak_ptr 搭配时的生命周期问题。

相关推荐
黑化旺仔1 小时前
【OC】Runtime:类元数据、加载初始化与消息派发
开发语言·macos·ios·objective-c
Felven1 小时前
C. Removing Smallest Multiples
c语言·开发语言
云和数据.ChenGuang1 小时前
光哥说Python 循环导入(Circular Import)深度解析:成因、排查与解决方案
开发语言·前端·人工智能·python·langchain
星夜离尘562 小时前
MATLAB 工程实验:滞回加持续时间报警,把 UNKNOWN 留在状态机里
开发语言·matlab
fengci.2 小时前
BUUCTF(困难部分)
android·开发语言·前端·学习·php
Highcharts.js2 小时前
BI可视化图表“维护难”问题的解决方案
开发语言·前端·javascript·编辑器·ecmascript·bi·highcharts
yixun_gdas3 小时前
政企网站错敏巡查:错敏都藏在哪些位置?
开发语言·前端·javascript·内容运营
小羊没烦恼!3 小时前
.NET 11 Runtime Async 详解
java·运维·开发语言·javascript·python
Дерек的学习记录3 小时前
Unreal Engine 5:开关门实现和流程控制
开发语言·学习·ue5