【C++】智能指针全解

在 C++ 开发中,动态内存管理一直是容易出错的环节。资源申请后忘记释放、异常导致清理代码未执行、对象之间循环引用等问题,都可能引发内存泄漏甚至程序崩溃。为了更安全地管理资源,C++ 引入了 RAII 思想和智能指针机制。本文将从资源管理面临的问题出发,逐步介绍智能指针的设计思路、使用方式以及底层实现原理,并结合实际案例分析循环引用、线程安全等常见问题,帮助理解现代 C++ 中资源管理的核心思想。

一、智能指针的使用场景分析

智能指针这一章,核心不是先记接口,而是先明白它为什么会出现。

最常见的场景就是:资源已经申请成功,但中途发生异常,导致释放代码没有执行,最后形成内存泄漏。普通 newdelete 的写法,在没有异常时看起来没有问题,一旦中间某一步抛异常,后面的清理代码就可能走不到。

例如:

cpp 复制代码
double Divide(int a, int b)
{
    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];

    try
    {
        int len, time;
        cin >> len >> time;
        cout << Divide(len, time) << endl;
    }
    catch (...)
    {
        delete[] array1;
        delete[] array2;
        throw;
    }

    delete[] array1;
    delete[] array2;
}

这段代码的问题在于,array1array2 申请后,Divide、输入操作、new 本身都有可能抛异常。只要中间某一步出错,手动 delete 的逻辑就会被打断。

智能指针的作用,就是把这类资源的释放动作交给对象自己管理,让释放动作跟着对象生命周期自动执行。


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

2.1 RAII是什么

RAII 是 Resource Acquisition Is Initialization 的缩写,意思是:资源获取即初始化。

通俗一点说,就是把资源交给一个对象管:

  • 对象活着,资源就活着
  • 对象析构,资源就释放

RAII 可以管理的资源不只是内存,还包括:

  • 文件句柄
  • 网络连接
  • 互斥锁
  • 数据库连接
  • 其他需要成对释放的资源

它的核心价值是:把资源释放责任交给析构函数,让异常不再轻易导致泄漏。


2.2 智能指针的设计思路

智能指针本质上就是一种 RAII 类。它除了负责资源释放,还要尽量像普通指针一样好用,所以通常还会重载:

  • operator*
  • operator->
  • operator[]

下面是一个简化版智能指针:

cpp 复制代码
template<class T>
class SmartPtr
{
public:
    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;
};

这段代码的关键点

  1. 构造时接管资源
  2. 析构时自动释放资源
  3. 重载运算符,让它像普通指针一样访问资源

这就是智能指针最核心的设计思路。


2.3 用智能指针优化代码

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

void Func()
{
    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;
}

这里最重要的变化是:

  • 不再手写 delete[]
  • 异常发生时,智能指针对象析构,资源自动释放
  • 代码结构更清晰

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

C++ 标准库中的智能指针都在 <memory> 头文件里。

常见的有:

  • auto_ptr
  • unique_ptr
  • shared_ptr
  • weak_ptr

其中 auto_ptr 已经不建议使用,现代代码基本都用 unique_ptrshared_ptrweak_ptr 则是配合 shared_ptr 解决循环引用。


3.1 auto_ptr

auto_ptr 是 C++98 的产物,它的设计思路是:拷贝时转移资源管理权。

这个设计最大的问题是:被拷贝对象会悬空,极容易出错,所以 C++11 之后强烈不推荐使用。

cpp 复制代码
auto_ptr<Date> ap1(new Date);
auto_ptr<Date> ap2(ap1);
// ap1 已经悬空

这个行为太危险,现代项目基本不用。


3.2 unique_ptr

unique_ptr 是 C++11 引入的,名字就说明了它的特点:唯一拥有。

它的特点很明确:

  • 不支持拷贝
  • 支持移动
  • 适合独占资源的场景
cpp 复制代码
unique_ptr<Date> up1(new Date);
//unique_ptr<Date> up2(up1);

unique_ptr<Date> up3(move(up1));

注意:move 之后,原对象通常也会变成空状态,所以移动操作虽然高效,但使用时要注意对象后续是否还会被访问。


3.3 shared_ptr

shared_ptr 是共享指针,核心特点是:

  • 支持拷贝
  • 支持移动
  • 使用引用计数管理资源
  • 多个对象可以共同拥有同一份资源
cpp 复制代码
shared_ptr<Date> sp1(new Date);
shared_ptr<Date> sp2(sp1);
shared_ptr<Date> sp3(sp2);

cout << sp1.use_count() << endl;

use_count() 可以查看当前有多少个 shared_ptr 在共享这份资源。


3.4 weak_ptr

weak_ptr 叫弱指针,它和前面几种指针有本质区别:

  • 不参与资源管理
  • 不增加引用计数
  • 不直接释放资源
  • 主要用途是打破 shared_ptr 的循环引用

weak_ptr 不能直接像普通智能指针那样用 *-> 访问资源,因为它本身不负责资源生命周期。


3.5 删除器

智能指针默认会用 delete 释放资源,但有些资源不是 new 出来的,比如:

  • new[]
  • fopen
  • 自定义资源句柄

这时就需要自定义删除器,也就是一个可调用对象,告诉智能指针该怎么释放资源。

数组删除器

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

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

文件删除器

cpp 复制代码
class Fclose
{
public:
    void operator()(FILE* ptr)
    {
        cout << "fclose:" << ptr << endl;
        fclose(ptr);
    }
};

使用示例

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

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>);

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);
});

这里最重要的是:

  • unique_ptr 的删除器在模板参数里
  • shared_ptr 的删除器在构造函数参数里
  • new[] 管理数组时,默认删除方式不能写错

3.6 make_shared

shared_ptr 还支持 make_shared,它可以直接传构造参数来创建对象。

cpp 复制代码
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);

相比直接 newmake_shared 更推荐使用, 它相对于速度更快,更加安全,有原子性。


3.7 operator bool

shared_ptrunique_ptr 都支持布尔上下文判断:

cpp 复制代码
shared_ptr<Date> sp4;
if (sp1)
    cout << "sp1 is not nullptr" << endl;

if (!sp4)
    cout << "sp4 is nullptr" << endl;

它的意义是:

  • 有资源时返回 true
  • 空对象时返回 false

这让智能指针可以直接用于 if 判断。


3.8 explicit

shared_ptrunique_ptr 的构造函数通常都用了 explicit

目的很简单:

防止普通指针自动转换成智能指针

比如下面这种写法会报错:

cpp 复制代码
shared_ptr<Date> sp5 = new Date(2024, 9, 11);
unique_ptr<Date> sp6 = new Date(2024, 9, 11);

这是为了避免隐式转换带来的歧义和风险。


四、智能指针的原理


4.1 auto_ptr 的实现思路

auto_ptr 的核心思想是:拷贝时把资源管理权从原对象转移给新对象。

cpp 复制代码
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;
            _ptr = ap._ptr;
            ap._ptr = NULL;
        }
        return *this;
    }

    ~auto_ptr()
    {
        if (_ptr)
        {
            delete _ptr;
        }
    }

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

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

private:
    T* _ptr;
};

这个设计的问题

拷贝以后原对象会被掏空,容易出现悬空指针,所以这个设计后来被放弃了。


4.2 unique_ptr 的实现思路

unique_ptr 的思想更干净:

  • 不允许拷贝
  • 只允许移动
  • 独占资源
cpp 复制代码
template<class T>
class unique_ptr
{
public:
    explicit unique_ptr(T* ptr)
        :_ptr(ptr)
    {}

    ~unique_ptr()
    {
        if (_ptr)
        {
            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;
};

这个实现的核心就是两个字:独占。


4.3 shared_ptr 的实现思路

shared_ptr 的重点在于引用计数。

核心逻辑

  • 创建对象时,引用计数初始化为 1
  • 拷贝一个 shared_ptr,计数加 1
  • 析构一个 shared_ptr,计数减 1
  • 当计数减到 0 时,真正释放资源
cpp 复制代码
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;
    function<void(T*)> _del = [](T* ptr) { delete ptr; };
};

这段实现最关键的地方

  1. 资源指针 _ptr
  2. 引用计数 _pcount
  3. 删除器 _del
  4. 析构时调用 release()

这就是 shared_ptr 的本质。


4.4 weak_ptr 的实现思路

weak_ptr 的设计目标不是管理资源,而是观察资源。

cpp 复制代码
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;
};

它不负责释放资源,所以也不参与引用计数增长。


五、shared_ptr和weak_ptr

5.1 shared_ptr循环引用问题

shared_ptr 很好用,但有一个经典问题:循环引用。

典型场景是双向链表或图结构。两个对象互相持有对方的 shared_ptr 时,引用计数永远不可能归零,资源就不会释放。

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

    ~ListNode()
    {
        cout << "~ListNode()" << endl;
    }
};
cpp 复制代码
int main()
{
    std::shared_ptr<ListNode> n1(new ListNode);
    std::shared_ptr<ListNode> n2(new ListNode);

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

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

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

    return 0;
}

为什么会泄漏

因为:

  • n1 持有 n2
  • n2 持有 n1
  • 两边都认为自己还有人在用
  • 所以都不会释放

注意,这种泄漏不是忘记 delete,而是设计结构上形成了互相依赖。


5.2 weak_ptr

weak_ptr 的作用就是打破这种循环引用。

它的特点是:

  • 不增加引用计数
  • 不能直接管理资源
  • 可以观察资源是否还存在
  • 可以通过 lock() 临时转成 shared_ptr

常用接口

  • expired():判断资源是否过期
  • use_count():查看当前引用计数
  • lock():获取一个可用的 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;
    cout << wp.use_count() << endl;

    sp1 = make_shared<string>("222222");
    cout << wp.expired() << endl;
    cout << wp.use_count() << endl;

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

    wp = sp1;
    auto sp3 = wp.lock();

    cout << wp.expired() << endl;
    cout << wp.use_count() << endl;

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

    return 0;
}

关键理解

weak_ptr 不是用来直接用的,而是用来辅助 shared_ptr 管理复杂关系。


六、shared_ptr的线程安全问题

shared_ptr 的引用计数通常放在堆上,多个线程同时拷贝和析构同一个 shared_ptr 时,引用计数的增减就可能发生竞争。

这意味着:

  • 引用计数本身不是天然线程安全的
  • 需要加锁
  • 或者改成原子计数

例子

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;
}

这里要区分两件事

对象 线程安全责任
shared_ptr 的引用计数 需要线程安全控制
shared_ptr 管理的对象本身 不归 shared_ptr 管

也就是说,shared_ptr 只能保证自己那一层的管理逻辑,不能替对象本身兜底。


七、内存泄漏

7.1 什么是内存泄漏,内存泄漏的危害

内存泄漏指的是:程序申请了内存,但后面没有正确释放,或者因为异常中断导致释放动作没有执行。

它不是物理内存消失,而是程序失去了对那块内存的控制,造成浪费。

危害分两类

场景 影响
短生命周期程序 影响较小,进程结束后系统会回收资源
长生命周期程序 危害很大,会越跑越慢,最终卡死

例如操作系统、后台服务、长期运行的客户端,都会非常怕这种问题。

cpp 复制代码
int main()
{
    char* ptr = new char[1024 * 1024 * 1024];
    cout << (void*)ptr << endl;
    return 0;
}

这段代码申请了 1G 内存,但程序马上结束,所以影响没那么明显。真正危险的是长时间运行的服务程序。


7.2 如何避免内存泄漏

避免内存泄漏,建议从三个层面入手:

  1. 编码阶段做好资源配对,申请就释放
  2. 尽量使用智能指针管理资源
  3. 定期做泄漏检测,尤其是项目上线前

更稳妥的思路是:

  • 能用智能指针就用智能指针
  • 特殊资源使用 RAII 自己封装管理类
  • 不把异常处理和资源释放逻辑写得过于分散

九、总结

  • newdelete 在异常场景下不安全
  • RAII 的本质是对象生命周期管理资源
  • unique_ptr 适合独占资源
  • shared_ptr 适合共享资源
  • weak_ptr 用来打破循环引用
  • make_shared 更推荐
  • shared_ptr 的引用计数在多线程里要注意安全
  • 内存泄漏最适合用智能指针和 RAII 去预防

相关推荐
是阿建吖!2 小时前
【Linux】信号
android·linux·c语言·c++
城北徐宫2 小时前
Linux信号深度解剖:5种产生、3张表、4次切换
linux·c++·学习
liulilittle2 小时前
论 Linux 内核态全局稳态带宽的卡尔曼估计与工程实现
linux·服务器·网络·c++·计算机网络·tcp·通信
XBodhi.2 小时前
Visual Studio C++ 语法错误: 缺少“;”(在“return”的前面)
开发语言·c++·visual studio
froyoisle4 小时前
CSP-J 历年复赛 T1 及解析(2019~2025)
数据结构·c++·算法·csp-j·csp·算法竞赛·信息学
basketball6164 小时前
C++ 高级编程:2. 基本线程池实现
java·开发语言·c++
chao1898444 小时前
SGM(Semi-Global Matching)立体匹配算法 — C++ 实现
开发语言·c++·算法
10岁的博客5 小时前
IOI 2018 高速公路收费(Highway)题解:二分与树的巧妙结合
开发语言·c++
不知名的老吴5 小时前
C++运算符重载的常见注意点
开发语言·c++