之前学习 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 的缩写,通常理解为利用对象的生命周期管理资源。
它的核心思路是:
- 获取资源时,把资源交给一个对象管理。
- 使用资源时,通过这个对象访问资源。
- 对象生命周期结束时,自动释放资源。
这里的资源不只有动态内存,还可以是文件指针、网络连接、互斥锁等。
比如手动管理内存:
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_ptrunique_ptrshared_ptrweak_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 才是理解这一套机制的基础。