跳到主要内容C++ 智能指针完全指南:从原理到实战 | 极客日志C++
C++ 智能指针完全指南:从原理到实战
介绍 C++ 智能指针的原理与实战。智能指针基于 RAII 机制自动管理动态内存,避免手动 delete 导致的泄漏。标准库提供 auto_ptr、unique_ptr、shared_ptr、weak_ptr 四种类型。auto_ptr 因拷贝悬空已被弃用;unique_ptr 独占资源;shared_ptr 通过引用计数共享资源;weak_ptr 解决循环引用。最佳实践包括优先使用 unique_ptr、共享时用 make_shared、循环引用用 weak_ptr 及自定义删除器。
修罗1 浏览 
一、智能指针的诞生:解决手动管理内存的'千古难题'
在 C++ 中,内存泄漏的核心原因往往是'资源申请与释放不匹配'——尤其是当程序流程被异常、分支跳转打断时,手动编写的 delete 可能永远不会执行。
内存泄漏: 内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存,一般是忘记释放或者发生异常释放程序未能执行导致的。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
危害: 普通程序运行一会就结束了,出现内存泄漏问题也不大,进程正常结束,页表的映射关系解除,物理内存也可以释放。但长期运行的程序出现内存泄漏影响就很大了,如操作系统、后台服务、长时间运行的客户端等等,不断出现内存泄漏会导致可用内存不断变少,各种功能响应越来越慢,最终卡死。
1.1 一个典型的内存泄露场景
若函数中存在异常抛出,裸指针会因 delete 未执行导致泄漏。例如我们在 Func 函数中 new 了两个数组,但如果 Divide 抛异常,后续的 delete 会被跳过,导致内存泄漏:
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];
int len, time; cin >> len >> time; cout << Divide(len, time) << endl;
delete[] array1;
delete[] array2;
}
{
{
();
} (...) {
cout << << endl;
}
;
}
微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown 转 HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
- HTML 转 Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online
int main()
try
Func
catch
"abnormal"
return
0
可以看到当 Divide 抛出异常时,我们 new 的两个数组就无法正常释放,导致内存泄漏:
即使我们加了 try-catch,若 new array2 时本身抛异常,array1 也无法释放,代码会变得臃肿且脆弱:
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 (...) {
cout << "delete []" << endl;
delete[] array1;
delete[] array2;
throw;
}
delete[] array1;
delete[] array2;
}
即便如此,因为 new 本身也可能抛异常,连续的两个 new 和下面的 Divide 都可能会抛异常,让我们处理起来很麻烦。这种场景下,我们需要一种'能自动释放资源'的机制——这就是智能指针的设计初衷。
1.2 智能指针的核心:RAII 思想
在 C++ 中,智能指针是一种封装了裸指针(raw pointer)的模板类,其核心作用是自动管理动态内存,避免因手动调用 delete 疏忽导致的内存泄漏、重复释放或悬空指针等问题。它基于RAII(资源获取即初始化) 机制:在智能指针构造时获取资源(如动态内存),在析构时自动释放资源,无需手动干预。
智能指针的本质是RAII(Resource Acquisition Is Initialization,资源获取即初始化) 的实践:
- 资源(如动态内存、文件句柄)在智能指针对象构造时获取,并委托给该对象管理;
- 智能指针对象析构时自动释放资源,无论程序是正常结束还是异常退出(对象生命周期由作用域管理,析构总会执行);
- 为了方便使用,智能指针会重载
*、->、[] 等运算符,模拟原生指针的行为。
基于此,我们可以先来自己简单粗略的实现一下智能指针:
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;
};
void Func() {
SmartPtr<int> sp1(new int[10]);
SmartPtr<int> sp2(new int[10]);
int len, time; cin >> len >> time; cout << Divide(len, time) << endl;
}
通过运行结果我们可以看到即便 Divide 函数抛出异常也不会影响我们 new 出来两个数组的释放:
虽然我们上面设计的智能指针是十分粗略的,但是可以看到即便如此也可以帮助我们解决内存泄漏的问题。那么接下来让我们看一看标准库中是如何设计智能指针的。
二、C++ 标准库智能指针:4 种指针的特性与适用场景
C++ 标准库(<memory>头文件)提供了 4 种智能指针,它们分别是 auto_ptr、unique_ptr、shared_ptr、weak_ptr。其中除了 auto_ptr 外的三个都是在 C++11 中提出的。除 weak_ptr 外均遵循 RAII,原理上而言主要是解决智能指针拷贝时的思路不同。下面来让我们看一看它们之间的区别,以及在不同场景下该如何选择。
2.1 auto_ptr:被淘汰的'过渡品'(C++98)
auto_ptr 是 C++98 时设计出来的智能指针,设计思路是'拷贝时转移资源管理权'——但这是一个致命缺陷:拷贝后原对象会'悬空'(资源指针被置空),后续访问原对象会触发空指针错误。如下面代码所示:
int main() {
auto_ptr<Date> ap1(new Date);
auto_ptr<Date> ap2(ap1);
return 0;
}
auto_ptr 拷贝的原理是将自己的指针赋值给新的 auto_ptr,并且使自己的指针置为空,这样我们再去访问这个对象时,就会因为访问空指针而导致报错。可以看到,当我们将 ap1 拷贝给 ap2 后,我们就访问 ap1 时就会报错,因为 ap1 已经悬空,我们不能去访问空指针。
正因如此,C++11 推出后,auto_ptr 被明确标记为'不推荐使用',多数公司的编码规范也会直接禁止它。
2.2 unique_ptr:不可共享的'独占指针'(C++11)
unique_ptr(唯一指针)的设计思路是禁止拷贝、仅支持移动——确保同一时间只有一个 unique_ptr 管理资源,从根源上避免'多个指针竞争释放'的问题。它是 C++11 中最常用的智能指针之一,适用于'资源无需共享'的场景。
- 不可复制,只能移动:由于是独占所有权,
unique_ptr 不支持复制构造或赋值(会编译报错),但可以通过 std::move 转移所有权(转移后原 unique_ptr 会失效,变为空指针)。
- 高效轻量:无额外引用计数开销,性能接近裸指针。
int main() {
std::unique_ptr<int> ptr1 = std::make_unique<int>(10);
std::cout << *ptr1 << std::endl;
std::unique_ptr<int> ptr2 = std::move(ptr1);
if (ptr1 == nullptr) {
std::cout << "ptr1 已失效" << std::endl;
}
return 0;
}
适用场景:管理独占资源(如局部动态对象、类的成员变量),作为函数返回值(无需手动释放,避免返回裸指针的风险)。
2.3 shared_ptr:可共享的'计数指针'(C++11)
shared_ptr(共享指针)允许多个 shared_ptr 共同拥有同一个动态对象。也就是说支持资源共享,其核心是通过'引用计数'跟踪管理资源的指针数量:
- 当新的
shared_ptr 拷贝或赋值时,引用计数 +1;
- 当
shared_ptr 析构时,引用计数 -1;
- 当引用计数减至
0 时,代表当前是最后一个管理资源的指针,自动释放资源。
- 引用计数透明管理:用户无需手动维护计数,
use_count() 方法可查看当前计数;
- 支持拷贝与移动:拷贝时计数
+1,移动时计数不变(原对象悬空);
int main() {
std::shared_ptr<int> ptr1 = std::make_shared<int>(20);
std::cout << "引用计数:" << ptr1.use_count() << std::endl;
std::shared_ptr<int> ptr2 = ptr1;
std::cout << "引用计数:" << ptr1.use_count() << std::endl;
{
std::shared_ptr<int> ptr3 = ptr1;
std::cout << "引用计数:" << ptr1.use_count() << std::endl;
}
std::cout << "引用计数:" << ptr1.use_count() << std::endl;
return 0;
}
shared_ptr 的构造有两种方式,除了支持用指向资源的指针构造,还支持 make_shared 用初始化资源对象的值直接构造:
shared_ptr<Date> sp(new Date(2024, 10, 1));:两次内存分配(一次给 Date 对象,一次给引用计数);
auto sp = make_shared<Date>(2024, 10, 1);:一次内存分配(同时存储 Date 对象和引用计数),效率更高,且避免内存泄漏风险(若 new 成功但计数分配失败,new 的对象无法释放)。
template<class T, class... Args>
shared_ptr<T> make_shared(Args&&... args);
对于 shared_ptr 和 unique_ptr,我们还需要注意下面几点:
shared_ptr 和 unique_ptr 都支持了 operator bool 的类型转换:如果智能指针对象是一个空对象没有管理资源,则返回 false,否则返回 true,意味着我们可以直接把智能指针对象给 if 判断是否为空。
shared_ptr 和 unique_ptr 的构造函数都使用 explicit 修饰,防止普通指针隐式类型转换成智能指针对象。
shared_ptr<Date> sp5 = new Date(2024, 9, 11);
unique_ptr<Date> sp6 = new Date(2024, 9, 11);
使用 shared_ptr 还要注意线程安全问题,shared_ptr 的引用计数对象在堆上,如果多个 shared_ptr 对象在多个线程中,进行 shared_ptr 的拷贝和析构时会访问修改引用计数,就会存在线程安全问题,所以 shared_ptr 引用计数是需要加锁或者原子操作保证线程安全的。
2.4 weak_ptr:解决循环引用的'辅助指针'(C++11)
weak_ptr(弱指针)完全不同于上面的智能指针,是一个特殊的智能指针。它不支持 RAII,也就意味着不能用它直接管理资源,weak_ptr 的产生本质是要解决 shared_ptr 的一个循环引用导致内存泄漏的问题。
什么是循环引用呢?shared_ptr 的引用计数机制可能导致循环引用问题:两个对象互相持有对方的 shared_ptr,此时它们的引用计数永远不会变为 0,导致内存泄漏。
例如:我们有两个链表结点,把它们分别交给智能指针 shared_ptr 管理,然后将它们连接起来:
struct ListNode {
int _data;
shared_ptr<ListNode> _next;
shared_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;
cout << n2.use_count() << endl;
n1->_next = n2;
n2->_prev = n1;
return 0;
}
循环引用的逻辑链:n1->_next 依赖 n2 释放,n2->_prev 依赖 n1 释放,最终谁都无法释放。
那么该如何解决循环引用呢?这时候 weak_ptr 就派上用场了。weak_ptr 是一种弱引用智能指针,它不拥有对象的所有权,也不会增加引用计数,因此我们修改链表节点为 weak_ptr 后,循环引用被打破:
struct ListNode {
int _data;
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);
n1->_next = n2;
n2->_prev = n1;
return 0;
}
weak_ptr 不支持 RAII,也不支持访问资源,所以 weak_ptr 构造时不支持绑定到资源,只支持绑定到 shared_ptr,绑定到 shared_ptr 时,不增加 shared_ptr 的引用计数,那么就可以解决上述的循环引用问题。
weak_ptr 的核心特性是:绑定到 shared_ptr 时不增加引用计数,仅作为'观察者'跟踪资源是否有效。weak_ptr 也没有重载 operator* 和 operator-> 等,因为他不参与资源管理。那么如果它绑定的 shared_ptr 已经释放了资源,那么它去访问资源就是很危险的,为此它提供了两个关键方法:
expired():判断绑定的 shared_ptr 资源是否已释放(计数为 0);
lock():若资源有效,返回一个 shared_ptr(计数 +1,安全访问资源);若无效,返回空 shared_ptr。
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 的辅助工具,解决循环引用(如链表、树、图等数据结构的节点引用)。
2.5 删除器
智能指针析构时默认是进行 delete 释放资源,这也就意味着如果不是 new 出来的资源,交给智能指针管理,析构时就会崩溃。因此当管理非 new 资源(如 new[]、文件指针)时,需自定义删除器。 智能指针支持在构造时给一个删除器,所谓删除器本质上就是一个可调⽤对象,在这个可调⽤对象中实现你想要的释放资源的方式,当构造智能指针时,给了定制的删除器,在智能指针析构时就会调用删除器去释放资源。因为 new[] 经常使用,所以为了简洁,unique_ptr 和 shared_ptr 都特化了一份 [] 的版本:
int main() {
unique_ptr<Date[]> up1(new Date[5]);
shared_ptr<Date[]> sp1(new Date[5]);
return 0;
}
除此之外,我们还可以自定义删除器,这里我们有三种方式:
auto delArrOBJ = [](Date* ptr){ delete[] ptr; };
unique_ptr<Date, decltype(delArrOBJ)> up4(new Date[5], delArrOBJ);
shared_ptr<Date> sp4(new Date[5], delArrOBJ);
template<class T>
class DeleteArray {
public:
void operator()(T* ptr) { delete[] ptr; }
};
unique_ptr<Date, void(*)(Date*)> up3(new Date[5], DeleteArrayFunc<Date>);
shared_ptr<Date> sp3(new Date[5], DeleteArrayFunc<Date>);
template<class T>
class DeleteArray {
public:
void operator()(T* ptr) { delete[] ptr; }
};
unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);
shared_ptr<Date> sp2(new Date[5], DeleteArray<Date>());
我们可以看到,使用不同的可调用对象,unique_ptr 和 shared_ptr 需要传入的参数也是不同的,这是因为 unique_ptr 和 shared_ptr 支持删除器的方式有所不同:
unique_ptr 是在类模板参数支持的;
shared_ptr 是构造函数参数支持的。
这里没有使用相同的方式还是挺不方便,也是标准库中的一点小弊端。
使用仿函数 unique_ptr 可以不在构造函数传递,因为仿函数类型构造的对象直接就可以调用,但是函数指针和 lambda 表达式的类型是不可以的,所以在传参时不仅要在模板传类型,还要在构造传入相应的对象。
3. shared_ptr 的模拟实现
想要加深对智能指针的印象,我们可以自己来模拟实现一下智能指针,在标准库中的四种智能指针中 shared_ptr 涉猎最广,所以我们来模拟实现一下 shared_ptr,当然我们这里只是简单的模拟,标准库中的 shared_ptr 的实现是极为复杂的。
3.1 原理
实现 shared_ptr 我们需要搞定两个比较重要的东西,其中之一是引用计数的设计,主要这里一份资源就需要一个引用计数,所以引用计数采用静态成员的方式是无法实现的,要使用堆上动态开辟的方式,构造智能指针对象时来一份资源,就要 new 一个引用计数出来。多个 shared_ptr 指向资源时就 ++ 引用计数,shared_ptr 对象析构时就 -- 引用计数,引用计数减到 0 时代表当前析构的 shared_ptr 是最后一个管理资源的对象,则析构资源。
其次就是删除器,我们要在构造时传入删除器,但是在析构时才会使用删除器,也就是说我们需要将删除器保存为成员函数,这样才能在析构时去调用,那么我们该如何保存删除器呢?我们知道函数指针、仿函数、lambda 表达式这些都可以做删除器,这时候我们就需要用到 function 包装器了,通过包装器来存储删除器,这样就可以存储不同的删除器了:function<void(T*)> _del = [](T* ptr) {delete ptr; };
3.2 代码
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; };
};
需要注意的是我们这里实现的 shared_ptr 是以最简洁的方式实现的,只能满足基本的功能。感兴趣的可以去查看源代码。
4. 总结:智能指针的最佳实践
掌握智能指针后,我们可以从'手动管理内存'的焦虑中解放出来。以下是核心实践原则:
- 优先用 unique_ptr:若资源无需共享,
unique_ptr 是最高效的选择(无引用计数开销);
- 共享用 shared_ptr:需多模块共享资源时用
shared_ptr,优先用 make_shared 优化;
- 循环引用用 weak_ptr:链表、树等结构中,节点间引用用
weak_ptr 避免泄漏;
- 自定义删除器:管理
new[]、文件句柄等非 new 资源时,务必指定删除器;
- 避免裸指针混用:尽量不要用智能指针管理'已被裸指针管理的资源',避免重复释放。
最后记住:智能指针不是'银弹',但它是现代 C++ 中避免内存泄漏的最有效工具。用好智能指针,让你的代码更安全、更优雅!