C++智能指针:RAII、shared_ptr与内存泄漏

之前学习 C++ 异常的时候,遇到过一个问题:使用 new 申请了内存,正常情况下可以在后面使用 delete 释放,但是如果程序中途抛出了异常,后面的释放代码就有可能执行不到,最后造成内存泄漏。

如果只申请一个对象,可能还比较好处理。但要是一次申请了多个资源,中间每一步都可能发生异常,那就需要写很多额外的清理代码,稍不注意就会出问题。

智能指针就是为了解决这类资源管理问题而设计的。

一、为什么需要智能指针?

先看一个简单的例子:

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

    return (double)a / b;
}

再来看调用它的函数:

cpp 复制代码
void Func()
{
    int* p = new int[10];

    int a, b;
    cin >> a >> b;

    cout << Divide(a, b) << endl;

    delete[] p;
}

如果输入的 b 为 0,Divide() 就会抛出异常,程序不会继续执行后面的普通语句。因此,delete[] p 就可能执行不到。

这样一来,申请的内存就没有按照预期释放。

当然,可以在外面使用 try-catch,捕获异常、释放内存,再通过 throw; 重新抛出异常。但是,当程序中申请了多个资源,而且申请资源本身也可能失败时,手动管理就会越来越麻烦。异常发生的位置不同,需要清理的资源也可能不同。

这时候就需要换一种思路:不要依赖程序执行到某一行代码才释放资源,而是让资源跟着对象的生命周期自动释放。 这就是理解智能指针之前,需要先弄明白的事情。

二、RAII:智能指针的设计基础

RAII 是 Resource Acquisition Is Initialization 的缩写,通常理解为利用对象的生命周期管理资源。

它的核心思路是:

  1. 获取资源时,把资源交给一个对象管理。
  2. 使用资源时,通过这个对象访问资源。
  3. 对象生命周期结束时,自动释放资源。

这里的资源不只有动态内存,还可以是文件指针、网络连接、互斥锁等。

比如手动管理内存:

cpp 复制代码
int* p = new int(10);

// 使用 p

delete p;

如果中间发生异常,或者代码提前返回,就有可能漏掉 delete。

而如果把指针交给一个具有 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;
    }

private:
    T* _ptr;
};

使用时:

cpp 复制代码
void Func()
{
    SmartPtr<int> sp(new int(10));

    cout << *sp << endl;
}

sp 离开作用域时会调用析构函数,进而释放它管理的内存。

这里还重载了 operator* 和 operator->,所以使用起来比较像普通指针。智能指针除了自动管理资源,也需要尽量保留指针原本的访问方式。

不过,这只是一个帮助理解原理的简化版本。实际使用中还需要考虑拷贝、移动、数组释放、自定义删除器等问题,不能直接把这个例子当成完整的智能指针实现。

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

C++ 标准库提供的智能指针放在:

cpp 复制代码
#include <memory>

里面。

这里主要涉及四种:

  • auto_ptr
  • unique_ptr
  • shared_ptr
  • weak_ptr

它们的区别主要体现在资源所有权的管理方式上。

1. auto_ptr:了解即可,不建议使用

auto_ptr 是 C++98 中引入的智能指针。

它的问题在于拷贝时会转移资源的管理权。被拷贝对象原本管理的资源会转交给新对象,原来的对象就不再管理该资源。

例如:

cpp 复制代码
auto_ptr<Date> ap1(new Date);
auto_ptr<Date> ap2(ap1);

执行拷贝之后,ap2 接管了资源,而 ap1 不再拥有原来的资源。

这种行为容易让人误以为两个对象都能正常访问同一份资源,实际上并不是这样。因此,这种设计存在明显的问题,现在不建议继续使用 auto_ptr。

2. unique_ptr:独占资源

unique_ptr 的名字就说明了它的特点:一份资源只能由一个 unique_ptr 独占管理。

例如:

cpp 复制代码
unique_ptr<Date> up1(new Date);

它会在对象生命周期结束时释放所管理的资源。

unique_ptr 不允许普通拷贝:

cpp 复制代码
unique_ptr<Date> up1(new Date);

// 错误:unique_ptr不支持拷贝
// unique_ptr<Date> up2(up1);

如果确实需要转移所有权,可以使用移动语义:

cpp 复制代码
unique_ptr<Date> up1(new Date);

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

资源的管理权转交给 up2 后,up1 不再管理原来的对象。

所以,使用 unique_ptr 时要明确一点:移动是转移资源管理权,不是复制一份资源。 移动之后也不能再把原来的 up1 当作仍然拥有该对象来使用。

如果一份资源只需要一个所有者,通常优先考虑 unique_ptr。

3. shared_ptr:共享资源

有些场景下,一份资源需要被多个对象共同管理,这时候就可以使用 shared_ptr。

例如:

cpp 复制代码
shared_ptr<Date> sp1(new Date);

shared_ptr<Date> sp2(sp1);
shared_ptr<Date> sp3(sp1);

这三个智能指针共同管理同一个 Date 对象。

shared_ptr 的底层思路是引用计数:每增加一个共享所有者,引用计数就增加;每有一个共享所有者结束管理,引用计数就减少。当最后一个共享所有者释放时,引用计数归零,资源才会被释放。

可以通过:

cpp 复制代码
sp1.use_count()

查看当前共享所有者的引用计数。

例如:

cpp 复制代码
shared_ptr<Date> sp1(new Date);
shared_ptr<Date> sp2(sp1);
shared_ptr<Date> sp3(sp1);

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

在没有其他所有者的情况下,这里的结果是 3。

需要注意,shared_ptr 共享的是同一份资源,并不是分别创建三个独立的 Date 对象。

cpp 复制代码
sp1->_year++;

cout << sp2->_year << endl;
cout << sp3->_year << endl;

通过其中一个智能指针修改对象,其他智能指针访问到的也是同一个对象,因此能看到修改后的值。

4. weak_ptr:不参与资源所有权管理

weak_ptr 和前面三种不太一样。

它不会增加 shared_ptr 的引用计数,也不负责决定资源什么时候释放。它主要用于观察由 shared_ptr 管理的对象,特别适合解决 shared_ptr 循环引用的问题。

例如:

cpp 复制代码
shared_ptr<string> sp1 =
    make_shared<string>("hello");

weak_ptr<string> wp = sp1;

这里 wp 指向 sp1 管理的对象,但不会因为绑定了 wp 就增加共享所有者的数量。

weak_ptr没有重载operator*和operator->等,因为他不参与资源管理,那么如果他绑定的

shared_ptr已经释放了资源,那么他去访问资源就是很危险的

如果想判断资源是否已经失效,可以使用:

cpp 复制代码
wp.expired()

如果想访问资源,则可以调用:

cpp 复制代码
auto sp2 = wp.lock();

当资源仍然有效时,lock() 会得到一个管理该资源的 shared_ptr;如果资源已经释放,就会返回空的 shared_ptr。因此,访问之前需要检查结果是否为空。

四种智能指针可以简单记成:

智能指针 资源管理方式
auto_ptr 拷贝时转移管理权,不建议使用
unique_ptr 独占管理,不支持拷贝,支持移动
shared_ptr 共享管理,通过引用计数释放资源
weak_ptr 不增加引用计数,不负责资源释放

四、auto_ptr 模拟实现

cpp 复制代码
template<class T>
class auto_ptr
{
public:
	auto_ptr(T* ptr)
		:_ptr(ptr)
	{ }
	auto_ptr(auto_ptr<T>& ap)
		:_ptr(ap._ptr)
	{
		ap._ptr = nullptr;
	}
	auto_ptr<T>& operator=(auto_ptr<T>& ap)
	{
		if (this != &ap)
		{
			if (_ptr)
				delete _ptr;
			_ptr = ap._ptr;
			ap._ptr = nullptr;
		}
		return *this;
	}
	~auto_ptr()
	{
		if (_ptr)
		{
			cout << "delete:" << _ptr << endl;
			delete _ptr;
		}
	}
	T& operator*()
	{
		return *_ptr;
	}
	T* operator->()
	{
		return _ptr;
	}
private:
	T* _ptr;
};

五、unique_ptr 模拟实现

cpp 复制代码
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>& up) = delete;
	unique_ptr<T>& operator=(const unique_ptr<T>& up) = delete;
	unique_ptr(unique_ptr<T>&& up)
		:_ptr(up._ptr)
	{
		up._ptr = nullptr;
	}
	unique_ptr<T>& operator=(unique_ptr<T>&& up)
	{
		delete _ptr;
		_ptr = up._ptr;
		up._ptr = nullptr;
	}
private:
	T* _ptr;
};

六、shared_ptr的引用计数是怎么实现的?

shared_ptr 能够被多个对象共同持有,关键就在于引用计数。

如果只在智能指针对象内部保存一个普通的计数变量,就无法直接解决多个智能指针共同管理同一份资源的问题。因为这些智能指针需要共享同一个计数。

简化理解,可以把它设计成两个部分:

text 复制代码
shared_ptr
    |
    |---- 指向资源
    |
    |---- 指向引用计数

创建一个新的共享所有者时,引用计数增加。

析构一个共享所有者时,引用计数减少。

计数变成 0 时,释放资源以及计数本身。

这是 shared_ptr 管理资源的基本逻辑。实际标准库的实现比这个示意更加完善,还需要处理自定义删除器、对象生命周期以及并发访问等问题。

还有一点,shared_ptr 的构造函数带有 explicit,因此不应该直接把普通指针隐式转换成智能指针:

cpp 复制代码
// 错误
// shared_ptr<Date> sp = new Date;

// 正确
shared_ptr<Date> sp(new Date);

// 也可以使用
auto sp2 = make_shared<Date>();

make_shared 可以直接创建并管理对象,也是实际编程中经常使用的方式。

七、智能指针怎样释放数组和其他资源?

这里还有一个容易忽略的问题。

普通的智能指针默认使用 delete 释放资源。如果申请时使用的是 new[],就必须用对应的 delete[] 释放。

例如:

cpp 复制代码
Date* p = new Date[5];

如果使用普通的单对象删除方式,就不匹配。

标准库为数组提供了相应的智能指针写法:

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

对于数组资源,应该使用适合数组的版本,而不是直接拿普通的单对象智能指针来管理。

除了 new 申请的内存,实际程序还经常需要管理文件句柄等资源。

例如,使用 fopen() 打开的文件,需要通过 fclose() 关闭。智能指针默认并不知道该如何释放这样的资源,所以可以指定自定义删除器,让它在析构时执行相应的释放操作。

删除器可以使用函数、仿函数或者 lambda 表达式实现。

例如:

cpp 复制代码
auto delArr = [](Date* ptr)
{
    delete[] ptr;
};

然后将它交给智能指针:

cpp 复制代码
shared_ptr<Date> sp(
    new Date[5],
    delArr
);

这样最后释放资源时,就会调用自定义删除器,使用 delete[] 而不是默认的 delete。

这也说明,智能指针管理的核心并不是某一种固定的资源,而是通过合适的删除方式,在生命周期结束时完成清理。

八、shared_ptr的循环引用问题

shared_ptr 使用起来很方便,但它也有一个比较典型的问题:循环引用会导致资源无法释放。

例如,双向链表中的两个节点:

cpp 复制代码
struct ListNode
{
    int _data;

    shared_ptr<ListNode> _next;
    shared_ptr<ListNode> _prev;
};

假设有两个节点:

此时:

  • n1 管理第一个节点。
  • n2 管理第二个节点。
  • 第一个节点的 _next 又持有第二个节点。
  • 第二个节点的 _prev 又持有第一个节点。

这样就形成了循环引用。

问题在于,即使外部的 n1 和 n2 离开作用域,两个节点之间仍然相互持有。

引用计数无法降到 0,节点的析构函数也就无法正常触发,最后造成内存泄漏。

使用weak_ptr打破循环引用

解决这个问题的方法之一,就是将不需要参与资源所有权管理的关联改成 weak_ptr:

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

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

当然,使用 weak_ptr 访问资源时也要先确认资源是否还有效,通常通过 lock() 获取临时的 shared_ptr 再进行操作。

九、shared_ptr 模拟实现

cpp 复制代码
template<class T>
class shared_ptr
{
public:
	shared_ptr(T* ptr)
		:_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)
	{
		(*_pcount)++;
	}
	shared_ptr<T>& operator=(const shared_ptr<T>& sp)
	{
		if (_ptr != sp._ptr)
		{
			if (--(*_pcount) == 0)
			{
				delete _ptr;
				delete _pcount;
			}
			_ptr = sp._ptr;
			_pcount = sp._pcount;
			++(*_pcount);
		}
		return *this;
	}
	~shared_ptr()
	{
		if (--(*_pcount) == 0)
		{
			//delete _ptr;
			_del(_ptr);
			delete _pcount;
		}
	}
	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; };
};

十、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的线程安全问题

看到 shared_ptr 使用引用计数管理资源,很容易认为它已经把所有线程安全问题都解决了。

实际上并不是这样。

shared_ptr 有两个层面的问题:

第一个是引用计数的线程安全。多个智能指针对象在不同线程中复制、销毁时,引用计数的增减必须得到正确处理。实现时需要使用适当的原子操作等机制保证计数安全。

第二个是被管理对象本身的线程安全。

例如:

cpp 复制代码
shared_ptr<AA> p(new AA);

即使多个线程都持有 p 所管理的对象,如果它们同时修改对象里的普通成员变量,仍然可能发生数据竞争。

引用计数安全,不代表被管理对象的访问也安全。 共享对象内部的数据需要根据实际情况使用互斥锁、原子变量等方式进行同步。

所以使用 shared_ptr 时,不能只关注对象有没有释放,还要考虑多个线程是否会同时访问和修改资源。

十二、内存泄漏到底是什么?

前面一直在提内存泄漏,这里再单独理一下。

内存泄漏指的是:程序申请了内存,但由于疏忽或者设计错误,没有在不再需要这段内存时释放它,或者已经失去了对这段内存的有效管理。内存本身并没有凭空消失,而是被程序占着,无法按预期释放。

例如:

cpp 复制代码
void Func()
{
    int* p = new int[100];

    // 忘记 delete[]
}

这就是一个典型的内存泄漏场景。

不过,短时间运行的普通程序和长期运行的服务,面对内存泄漏时的影响不太一样。

普通程序退出时,进程终止会使其占用的资源得到系统回收;而对于操作系统服务、后台服务等长期运行的程序,如果泄漏不断累积,可用内存就可能越来越少,最终造成性能下降甚至程序卡死。

因此,内存泄漏不能简单理解成"程序退出之后没有释放内存",真正需要关注的是:资源不再需要时,程序是否仍然失去了释放它的机会。

十三、怎样减少内存泄漏?

我觉得可以从三个方面来考虑。

1. 养成资源管理的习惯

手动使用 new、delete 的时候,要明确谁负责释放资源,避免申请之后忘记释放。

2. 尽量使用智能指针和RAII

对于动态内存,优先考虑让智能指针管理资源;对于文件、锁等其他资源,也可以使用合适的 RAII 对象,尽量避免依赖人工清理。

3. 必要时使用检测工具

代码规范和智能指针主要是从设计上减少问题,但实际项目中仍然可能有其他泄漏情况。因此,可以使用内存泄漏检测工具辅助排查,尤其是在项目上线之前进行检查。

十四、总结

这部分内容看起来比较多,但主线其实很清楚。

首先,手动管理资源容易受到异常、提前返回等情况的影响,因此需要 RAII 这样的资源管理思想。

然后,C++ 标准库提供了不同类型的智能指针,用于适应不同的所有权关系:

text 复制代码
unique_ptr
    独占资源

shared_ptr
    共享资源,使用引用计数

weak_ptr
    观察资源,不参与共享所有权管理

auto_ptr
    历史上的设计,现在不建议使用

最后,使用智能指针也不是一劳永逸。shared_ptr 可能产生循环引用;多线程下还要区分引用计数的安全和被管理对象本身的安全;如果管理的不是普通动态内存,还要选择合适的删除器。

我现在觉得,学习智能指针最重要的不是记住几个类的名字和语法,而是先把资源所有权这个概念弄清楚:

谁负责管理资源,谁有权决定资源何时释放?多个对象是否真的需要共同拥有一份资源?某个关联只是观察关系,还是所有权关系?

把这些问题考虑清楚之后,再选择 unique_ptr、shared_ptr 或 weak_ptr,就会容易很多。

说到底,智能指针解决的是资源生命周期管理问题,而 RAII 才是理解这一套机制的基础。

相关推荐
奋斗的阿狸_19862 小时前
ESP32-S3 + ES7210 四通道麦克风录音上传方案
c语言·开发语言·fpga开发
weixin199701080162 小时前
《1688图片空间API踩坑:img.upload 与 album.* 的防盗链与CDN缓存问题》(附Python源码)
开发语言·python·缓存
乐橙开放平台2 小时前
智慧连锁客流检测和离岗检测怎么对接
大数据·人工智能·笔记·物联网·自动化·音视频·智能家居
零基础1232 小时前
FinalShell 使用教程:从安装到高效运维
运维·经验分享·笔记·finalshell
神仙别闹2 小时前
基于C++实现的贪吃蛇游戏平台
java·c++·游戏
ACP广源盛139246256732 小时前
Type‑C 扩展坞方案选型笔记|国产 DP1.4 转 HDMI2.0 桥接芯片 GSV2201S @ACP评估
c语言·开发语言·笔记·硬件架构·硬件工程·国产芯片
niucloud-admin2 小时前
JAVA V6 多商户商城 开发文档——JAVA服务端
java·开发语言
轻舟技术A3 小时前
Pi 的 Rust 实现来了:rpi,不止一个终端 Agent
开发语言·后端·rust
王老师青少年编程3 小时前
2026年6月GESP真题及题解(C++三级): 选择题和判断题(题解)
c++·真题·gesp·答案·csp·三级·信奥赛