目录
2.4 异常安全性(Exception Safety)的初步认识
3.5 weak_ptr(C++11):弱引用(这里的弱是辅助的意思)
5.4 weak_ptr 的底层实现原理:控制块(Control Block)
引言:
在现代 C++ 的语境下,手动管理内存早已被视为"反模式"。C++11 引入的 unique_ptr、shared_ptr、weak_ptr 不仅是一套工具,更是一种资源所有权(Ownership)语义的表达。它们背后的 RAII 思想,是 C++ 区别于 C、Java、Go 等语言的标志性设计哲学。
然而,很多开发者对智能指针的理解停留在"自动释放内存"的表层,对为什么需要删除器、引用计数为何必须堆分配、控制块如何支撑 weak_ptr、循环引用的本质是什么等底层机制一知半解。一旦遇到自定义删除器、跨线程共享、双向链表循环引用等场景,往往束手无策。
本文将从异常安全这一原始痛点出发,系统推演 RAII 的设计逻辑,逐层拆解 auto_ptr → unique_ptr → shared_ptr → weak_ptr 的演进脉络,并手写模拟实现揭示其底层原理。全文涵盖引用计数设计、控制块内存模型、循环引用破局、线程安全边界、删除器类型擦除等核心议题,力求让你对 C++ 智能指针建立起从使用到原理的完整认知闭环。
那么话不多说,接下来进入正文—————————>

1.智能指针的使用场景分析
1.1 一个典型的异常安全问题
考虑下面这段代码:
cpp
double Divide(int a, int b)
{
if (b == 0)
throw "Divide by zero condition!";
return static_cast<double>(a) / b;
}
void Func()
{
int* array1 = new int[10];
int* array2 = new int[10];
int len, time;
std::cin >> len >> time;
std::cout << Divide(len, time) << std::endl;
delete[] array1;
delete[] array2;
}
当Divide抛出异常时,函数栈会展开(stack unwinding),直接跳转到main中的catch块。此时Func中delete[]之后的代码永远不会执行——array1和array2指向的内存就此泄漏。
1.2 手动处理:异常安全的"地狱模式"
为了保证内存不泄漏,我们不得不用try-catch包裹可能抛异常的代码,并在catch中手动释放资源:
cpp
void Func()
{
int* array1 = new int[10];
int* array2 = new int[10];
try {
int len, time;
std::cin >> len >> time;
std::cout << Divide(len, time) << std::endl;
}
catch (...) {
delete[] array1;
delete[] array2;
throw; // 重新抛出,异常交给外层处理
}
delete[] array1;
delete[] array2;
}
这段代码表面上解决了问题:正常流程和异常流程都执行了delete[]。但它隐藏着一个更深层的隐患:
new本身也可能抛异常(std::bad_alloc)。
如果array1分配成功,但array2分配失败,异常会直接跳出Func,此时array1指向的内存无人释放——内存仍然泄漏了。
为了覆盖这种情况,代码必须进一步嵌套:
cpp
void Func()
{
int* array1 = new int[10];
try {
int* array2 = new int[10];
try {
int len, time;
std::cin >> len >> time;
std::cout << Divide(len, time) << std::endl;
}
catch (...) {
delete[] array1;
delete[] array2;
throw;
}
delete[] array1;
delete[] array2;
}
catch (const std::bad_alloc&) {
delete[] array1;
throw;
}
}
这还仅仅是两个new的情况。 如果资源数量增加,这种嵌套会呈指数级膨胀,代码变得极其冗余、难以维护,而且每增加一个资源,就需要重新审视所有异常路径——这是不可持续的。
1.3 核心矛盾总结
这个问题的本质是:资源释放逻辑与业务逻辑高度耦合,而异常机制打破了正常的执行流,使得"在哪里释放"变得异常困难。
我们需要一种机制,让资源的释放自动绑定到某个对象的生命周期上,无论函数是正常返回还是异常退出,资源都能被可靠释放。这就是RAII(Resource Acquisition Is Initialization)思想,而智能指针正是RAII在内存管理上的典型应用。
2.RAII和智能指针的设计思路
2.1 什么是RAII?
RAII 是 Resource Acquisition Is Initialization 的缩写,是C++资源管理的核心范式。其核心思想是:构造函数保存资源,析构函数释放资源
将资源的生命周期与对象的生命周期绑定。资源在对象构造时获取,在对象析构时释放。
无论函数是正常执行完毕,还是因异常导致栈展开(stack unwinding),局部对象都会被自动析构。这意味着:只要资源被委托给了对象,它的释放就是确定的。
RAII不仅适用于内存,也适用于文件句柄、网络连接、互斥锁等一切需要显式释放的资源。
2.2 智能指针的雏形:一个RAII类
基于RAII思想,我们可以设计一个极简的智能指针类模板:
cpp
template <class T>
class SmartPtr {
public:
// explicit防止隐式转换,避免意外构造
explicit SmartPtr(T* ptr = nullptr)
: _ptr(ptr)
{}
// 析构时自动释放资源
~SmartPtr() {
std::cout << "delete: " << _ptr << std::endl;
delete _ptr; // 注意:这里假设管理的是单个对象
}
// 重载运算符,使其行为像原生指针
T& operator*() const {
return *_ptr;
}
T* operator->() const {
return _ptr;
}
T& operator[](size_t i) const {
return _ptr[i];
}
// 提供原生指针访问接口(后续标准库中对应 get())
T* get() const {
return _ptr;
}
private:
T* _ptr;
};
关键设计点:
-
构造函数接管指针:外部
new出的内存交给SmartPtr对象管理 -
析构函数释放资源:对象销毁时自动
delete,无需手动调用 -
运算符重载:支持
*、->、[],访问资源的方式与原生指针一致
2.3 用智能指针重构异常场景
现在,让我们用SmartPtr重写第1节的代码:
cpp
void Func()
{
SmartPtr<int> sp1(new int[10]); // 注意:这里为了演示,实际应使用 delete[]
SmartPtr<int> sp2(new int[10]);
int len, time;
std::cin >> len >> time;
std::cout << Divide(len, time) << std::endl;
// 不再需要任何 delete!
}
发生了什么变化?
| 场景 | 行为 |
|---|---|
| 正常执行 | Func返回,局部对象sp1、sp2析构,内存自动释放 |
Divide抛异常 | 栈展开,sp1、sp2被析构,内存自动释放 |
new抛异常(sp2) | sp1已成功构造,会正常析构;sp2构造失败,不存在析构问题 |
三行代码,覆盖了所有异常路径。 这就是RAII的威力。
2.4 异常安全性(Exception Safety)的初步认识
通过上面的对比,我们可以引入C++中一个重要的概念——异常安全性。它衡量的是当异常发生时,程序维持正确状态的能力。通常分为几个级别:
-
基本保证(Basic Guarantee):异常发生后,程序不会泄漏资源,对象处于有效但不确定的状态。
-
强保证(Strong Guarantee):异常发生后,程序状态回滚到操作之前。
-
不抛异常保证(No-throw Guarantee):操作绝不抛异常。
RAII是达成基本保证的基石。智能指针通过RAII,让我们在不写一行catch的情况下,就能实现内存管理的基本异常安全。
2.5 当前SmartPtr的缺陷(为后续章节埋坑)
虽然我们实现了一个能用的SmartPtr,但它还有两个致命问题没有解决:
-
拷贝语义不明确:如果执行
SmartPtr<int> sp2 = sp1;,默认生成的拷贝构造函数会进行浅拷贝,导致两个对象管理同一块内存,最终重复析构(double free)。 -
不支持数组释放:上面代码中
new int[10]配delete是未定义行为,应该用delete[]。
第一个问题正是C++标准库中
auto_ptr、unique_ptr、shared_ptr采用不同策略的根源——智能指针的核心难点不在于"管理资源",而在于"定义所有权"。我们将在下一节详细展开。
3.C++标准库智能指针的使用
C++标准库中的智能指针定义在头文件 <memory> 中(<memory> - C++ Reference)。它们都遵循RAII原则,并支持像原生指针一样的访问语法(operator*、operator->等)。智能指针之间的核心差异在于对"拷贝"这一行为的不同定义——即如何确定资源的所有权(Ownership)。
3.1 所有权问题:智能指针的核心矛盾
回顾第2节末尾的SmartPtr,如果执行:
cpp
SmartPtr<int> sp1(new int(10));
SmartPtr<int> sp2 = sp1; // 拷贝构造
默认的浅拷贝会让sp1和sp2的_ptr指向同一块内存。当两者析构时,会执行两次delete,导致重复释放(double free),程序直接崩溃。
原生指针允许多个指针指向同一对象,但原生指针不负责释放,所以不存在这个问题。智能指针自动释放的特性,使得"多个对象共同管理同一资源"成为一个必须显式设计的语义。
C++标准库通过四种智能指针,给出了四种不同的所有权策略:
| 智能指针 | 所有权策略 | 是否支持拷贝 | 是否支持移动 | 推荐使用 |
|---|---|---|---|---|
auto_ptr | 拷贝时转移所有权(被拷贝对象悬空) | 支持(危险) | 支持 | ❌ 已废弃 |
unique_ptr | 独占所有权 | 不支持 | 支持 | ✅ 首选(无需共享时) |
shared_ptr | 共享所有权(引用计数) | 支持 | 支持 | ✅ 需要共享时 |
weak_ptr | 弱引用,不管理资源 | 不支持 | 支持 | ✅ 辅助shared_ptr |
下面我们逐一讲解。为了便于观察析构行为,先定义一个测试用的Date类:
cpp
struct Date {
int _year;
int _month;
int _day;
Date(int year = 1, int month = 1, int day = 1)
: _year(year), _month(month), _day(day)
{}
~Date() {
std::cout << "~Date()" << std::endl;
}
};
3.2 auto_ptr(C++98):一个失败的设计
auto_ptr是C++98引入的智能指针,其策略是:拷贝时转移资源管理权。
cpp
std::auto_ptr<Date> ap1(new Date);
std::auto_ptr<Date> ap2(ap1); // 拷贝构造:ap1的资源转移给ap2
// ap1已经悬空,内部指针被置为nullptr
// ap1->_year++; // 危险!空指针访问
为什么这是糟糕的设计?
ap1是一个左值,程序员完全有理由在拷贝后继续使用它。但auto_ptr的拷贝语义是破坏性的——被拷贝对象被"偷走"了资源,变成了空指针。这与程序员对"拷贝"的直觉严重冲突。
注意:
auto_ptr的转移语义与C++11的移动语义有本质区别。移动语义针对的是右值/将亡值(临时对象),转移其资源是合理的;但auto_ptr对左值也执行破坏性拷贝,这是其致命缺陷。
结论:C++11起auto_ptr已被废弃,C++17起被彻底移除。任何新项目都不应使用。
3.3 unique_ptr(C++11):独占所有权
unique_ptr实现了严格的独占所有权语义:同一时刻,只有一个unique_ptr可以管理某块资源。他的特点是不支持拷贝,只支持移动,如果不需要拷贝的场景就非常建议使用它
cpp
std::unique_ptr<Date> up1(new Date);
// 不支持拷贝
// std::unique_ptr<Date> up2(up1); // 编译错误!
// 支持移动:将所有权从一个unique_ptr转移给另一个
std::unique_ptr<Date> up3(std::move(up1));
// 移动后up1悬空,需谨慎使用
底层实现非常简单: 将拷贝构造函数和拷贝赋值运算符显式delete,同时提供移动构造函数和移动赋值运算符。
cpp
// 伪代码示意
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;
unique_ptr(unique_ptr&& other) noexcept;
unique_ptr& operator=(unique_ptr&& other) noexcept;
使用建议: 如果资源不需要被多个对象共享,unique_ptr是首选。它零开销(与原生指针大小相同),且语义清晰。
3.4 shared_ptr(C++11):共享所有权
shared_ptr通过引用计数(Reference Counting)实现共享所有权:多个shared_ptr可以共同管理同一块资源,当最后一个shared_ptr析构时,资源才被释放。
他的特点是是支持拷贝,也支持移动,如果需要拷贝的场景就需要使用它了
引用计数我在string部分也初步讲过了C++_string_c++ string-CSDN博客
cpp
std::shared_ptr<Date> sp1(new Date);
std::shared_ptr<Date> sp2(sp1); // 拷贝:引用计数+1
std::shared_ptr<Date> sp3(sp2); // 拷贝:引用计数+1
std::cout << sp1.use_count() << std::endl; // 输出 3
sp1->_year = 2024;
std::cout << sp2->_year << std::endl; // 输出 2024
// 支持移动,但移动后sp1悬空
std::shared_ptr<Date> sp4(std::move(sp1));
引用计数的工作机制:
-
构造
shared_ptr时,在堆上分配一个引用计数(初始值为1) -
拷贝构造/拷贝赋值时,引用计数+1
-
析构/赋值给另一块资源时,引用计数-1
-
引用计数减到0时,释放资源和引用计数本身(无weak_ptr下,有weak_ptr指向同一块空间时,只释放资源)
(引用计数的具体实现细节将在第4节展开。)
3.5 weak_ptr(C++11):弱引用(这里的弱是辅助的意思)
weak_ptr是一种特殊的智能指针:它不参与资源管理,不增加引用计数。它的设计目的是解决shared_ptr的循环引用问题(详见第5节)。
cpp
std::shared_ptr<Date> sp(new Date);
std::weak_ptr<Date> wp(sp); // 绑定到shared_ptr,不增加引用计数
// weak_ptr不支持直接访问资源
// wp->_year++; // 错误!没有operator->
// 需要通过lock()获取shared_ptr
if (auto locked = wp.lock()) {
locked->_year = 2024; // 安全访问
}
weak_ptr的核心接口:
-
expired():检查绑定的资源是否已被释放 -
lock():返回一个管理该资源的shared_ptr;若资源已释放(指的weak_ptr),返回空shared_ptr -
use_count():返回当前资源的引用计数
3.6 删除器(Deleter):定制资源释放方式
标准库智能指针默认使用delete释放资源。如果资源不是通过new分配的(如new[]、fopen、malloc等),就必须提供自定义删除器。
删除器本质就是⼀个可调用对象,这个可调用对象中实现你想要的释放资源的方式
3.6.1 为什么需要删除器?
cpp
// 错误:new[] 必须用 delete[] 释放
std::shared_ptr<Date> sp1(new Date[10]); // 未定义行为,程序可能崩溃
new[]在分配的内存前会存储数组元素个数等元信息,delete[]会读取这些信息来逐个调用析构函数。如果用delete匹配new[],会从错误的内存位置开始释放,导致崩溃。
3.6.2 两种解决方案
方案一:特化的数组版本(推荐用于new[])
unique_ptr和shared_ptr都提供了数组特化版本:
cpp
std::unique_ptr<Date[]> up1(new Date[5]); // 析构时自动调用 delete[]
std::shared_ptr<Date[]> sp1(new Date[5]); // 同上
方案二:自定义删除器(通用方案)
如下图,这种就是支持删除器的构造版本

如果我们给了删除器,底层走的时候就会用del去释放我们的ptr,即del(_ptr);
删除器可以是函数指针、仿函数、lambda表达式,或任何可调用对象。其签名必须是 void(T*)。
cpp
// 1. 仿函数作为删除器
template <class T>
struct DeleteArray {
void operator()(T* ptr) {
delete[] ptr;
}
};
// 2. 函数指针作为删除器
template <class T>
void deleteArrayFunc(T* ptr) {
delete[] ptr;
}
// 3. lambda表达式作为删除器(最推荐,可读性最好)
auto delArr = [](Date* ptr) { delete[] ptr; };
shared_ptr如果想要定制删除器,三种方式都可以,建议lambda方式因为可读性好,然后unique_ptr如果要定制删除器,虽然三种方式都可以,但只建议用仿函数(原因在使用部分讲)
使用方式:
cpp
// unique_ptr:删除器通过模板参数指定
std::unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);
// shared_ptr:删除器通过构造函数参数指定
std::shared_ptr<Date> sp2(new Date[5], DeleteArray<Date>());
// lambda方式
std::unique_ptr<Date, decltype(delArr)> up3(new Date[5], delArr);//这边后面还要加delArr是因为lambda不支持默认构造,导致unique_ptr传lamdba过去后,需要构造删除器构造不出来,所以我们只能再把delArr传过去
std::shared_ptr<Date> sp3(new Date[5], delArr);
// 函数指针做删除器
std::unique_ptr<Date, void(*)(Date*)> up3(new Date[5], DeleteArrayFunc<Date>);//这传俩个的原因也跟lambda类似
std::shared_ptr<Date> sp3(new Date[5], DeleteArrayFunc<Date>);
// 管理非内存资源:文件句柄
struct Fclose {
void operator()(FILE* ptr) {
std::cout << "fclose: " << ptr << std::endl;
fclose(ptr);
}
};
std::shared_ptr<FILE> sp4(fopen("test.cpp", "r"), Fclose());
std::shared_ptr<FILE> sp5(fopen("test.cpp", "r"), [](FILE* ptr) {
std::cout << "fclose: " << ptr << std::endl;
fclose(ptr);
});
注意:
unique_ptr和shared_ptr对删除器的支持方式不同:
unique_ptr:删除器类型是模板参数,存储在对象内部(通常通过空基类优化实现零开销)
shared_ptr:删除器类型是构造函数参数的类型擦除,通过类型擦除存储(有一定开销,但更灵活)
3.6.3 unique_ptr与shared_ptr的效率差异
unique_ptr未被shared_ptr取代,一个重要原因是:在不需要共享的场景下,unique_ptr效率更高。
-
unique_ptr大小与原生指针相同(通常8字节),无额外开销 -
shared_ptr需要维护引用计数(堆分配),存在同步开销和内存碎片风险
因此,优先使用unique_ptr,仅在需要共享所有权时才使用shared_ptr。
3.7 make_shared与make_unique:更优的构造方式
3.7.1 make_shared
shared_ptr支持通过make_shared直接构造资源,而非先new再传入指针:
cpp
// 方式一:先new,再构造shared_ptr(两次堆分配)
std::shared_ptr<Date> sp1(new Date(2024, 9, 11));
// 方式二:make_shared(一次堆分配,更优)
std::shared_ptr<Date> sp2 = std::make_shared<Date>(2024, 9, 11);
auto sp3 = std::make_shared<Date>(2024, 9, 11);
make_shared的优势:
-
性能更高:
make_shared将资源对象和引用计数分配到同一块连续内存中,只需一次堆分配;而new + shared_ptr需要两次(一次给对象,一次给引用计数)。 -
内存碎片更少:连续存储减少了内存碎片。
-
异常安全:在复杂表达式中,
make_shared能避免new成功但shared_ptr构造前抛异常导致的泄漏。
cpp
// 异常安全问题示例
void process(std::shared_ptr<Date> sp, int val);
// 危险:如果foo()抛异常,new Date可能泄漏
process(std::shared_ptr<Date>(new Date), foo());
// 安全:make_shared保证原子性
process(std::make_shared<Date>(), foo());
3.7.2 make_unique(C++14)
C++14引入了make_unique,与make_shared类似:
cpp
// C++14起支持
auto up1 = std::make_unique<Date>(2024, 9, 11);
// 等价于
std::unique_ptr<Date> up2(new Date(2024, 9, 11));
注意: unique_ptr没有引用计数,所以make_unique的优势主要是代码简洁和异常安全,而非内存布局优化。
一般不需要考量这些,除非对内存碎片和效率很敏感的时候
3.8 其他常用接口
3.8.1 operator bool
shared_ptr和unique_ptr都支持隐式转换为bool:若管理了资源返回true,否则返回false。
cpp
std::shared_ptr<Date> sp1 = std::make_shared<Date>();
std::shared_ptr<Date> sp2;
if (sp1) {
std::cout << "sp1 is not nullptr" << std::endl;
}
if (!sp2) {
std::cout << "sp2 is nullptr" << std::endl;
}
3.8.2 explicit构造函数
所有标准库智能指针的构造函数都是explicit的,禁止从原生指针隐式转换:
cpp
// 错误:explicit禁止隐式转换
std::shared_ptr<Date> sp = new Date(2024, 9, 11);
std::unique_ptr<Date> up = new Date(2024, 9, 11);
3.8.3 reset与swap
cpp
std::shared_ptr<Date> sp = std::make_shared<Date>(2024, 1, 1);
// reset:释放当前资源,接管新资源(或置空)
sp.reset(); // 释放资源,sp变为空
sp.reset(new Date(2024, 2, 2)); // 释放旧资源,管理新资源
// swap:交换两个智能指针管理的资源
std::shared_ptr<Date> sp2 = std::make_shared<Date>(2024, 3, 3);
sp.swap(sp2);
4.智能指针的原理
本节我们手动模拟实现标准库智能指针的核心机制。理解底层实现,有助于更深刻地把握各种智能指针的设计权衡。
4.1 auto_ptr的模拟实现
auto_ptr的核心思路是拷贝时转移所有权:将被拷贝对象的指针置空,拷贝对象接管资源。
cpp
template <class T>
class Auto_ptr {
public:
Auto_ptr()
: _ptr(nullptr)
{}
explicit Auto_ptr(T* ptr)
: _ptr(ptr)
{}
// 拷贝构造:转移所有权
Auto_ptr(Auto_ptr& ap) // 注意:非const引用!
: _ptr(ap._ptr)
{
ap._ptr = nullptr; // 被拷贝对象悬空
}
// 拷贝赋值:转移所有权
Auto_ptr& operator=(Auto_ptr& ap)
{
if (this != &ap) {
delete _ptr; // 释放原有资源
_ptr = ap._ptr;
ap._ptr = nullptr; // 被拷贝对象悬空
}
return *this;
}
~Auto_ptr()
{
delete _ptr;
}
T& operator*() {
return *_ptr;
}
T* operator->() {
return _ptr;
}
private:
T* _ptr;
};
关键细节:
-
拷贝构造函数参数是非const引用(
Auto_ptr&),因为需要修改被拷贝对象(置空其指针) -
赋值运算符需先
delete自身原有资源,再接管新资源
再次强调:
auto_ptr的设计已被否定,仅作历史了解,实际项目中禁止使用。
4.2 unique_ptr的模拟实现
unique_ptr的核心思路是禁止拷贝,允许移动。
cpp
template <class T>
class Unique_ptr {
public:
Unique_ptr()
: _ptr(nullptr)
{}
explicit Unique_ptr(T* ptr)
: _ptr(ptr)
{}
// 禁止拷贝
Unique_ptr(const Unique_ptr&) = delete;
Unique_ptr& operator=(const Unique_ptr&) = delete;
// 允许移动
Unique_ptr(Unique_ptr&& up) noexcept
: _ptr(up._ptr)
{
up._ptr = nullptr;
}
Unique_ptr& operator=(Unique_ptr&& up) noexcept
{
if (this != &up) {
delete _ptr; // 释放原有资源
_ptr = up._ptr;
up._ptr = nullptr;
}
return *this;
}
~Unique_ptr()
{
delete _ptr;
}
T& operator*() const {
return *_ptr;
}
T* operator->() const {
return _ptr;
}
T* get() const {
return _ptr;
}
private:
T* _ptr;
};
关键细节:
-
noexcept修饰移动操作:移动不应抛异常,这是标准库的要求 -
移动赋值需先释放自身资源,避免内存泄漏
4.3 shared_ptr的模拟实现:引用计数的设计
shared_ptr是最复杂的智能指针,其核心是引用计数机制。
4.3.1 引用计数该放在哪里?
一份被共享的资源,需要对应一个引用计数。我们分析几种错误方案:
方案一:普通成员变量 int _count
每个shared_ptr对象独立存储计数。拷贝时把对方的计数复制过来再加一?不行——如果原始对象的计数被修改(如赋值操作),其他对象无法感知。
方案二:static int _count
静态成员属于类,而非对象。如果两个shared_ptr分别管理资源A和资源B,它们的引用计数会混在一起——这是类级别的计数,而非资源级别的计数。

正确方案:堆上动态分配的引用计数
每个资源对应一个在堆上分配的计数器。所有管理该资源的shared_ptr都持有指向这个计数器的指针。

4.3.2 基础版实现
cpp
template <class T>
class Shared_ptr {
public:
Shared_ptr()
: _ptr(nullptr)
, _pcount(new int(1))
{}
explicit Shared_ptr(T* ptr)
: _ptr(ptr)
, _pcount(new int(1))
{}
// 拷贝构造:共享资源,引用计数+1
Shared_ptr(const Shared_ptr& sp)
: _ptr(sp._ptr)
, _pcount(sp._pcount)
{
++(*_pcount);
}
// 拷贝赋值:释放原资源,共享新资源
Shared_ptr& operator=(const Shared_ptr& 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;
delete _pcount;
}
}
T& operator*() const {
return *_ptr;
}
T* operator->() const {
return _ptr;
}
T* get() const {
return _ptr;
}
int use_count() const {
return *_pcount;
}
private:
T* _ptr;
int* _pcount; // 指向堆上的引用计数
};
4.3.3 支持定制删除器的优化版
基础版只能使用delete释放资源。为了支持数组、文件句柄等,需要引入删除器。
shared_ptr的删除器通过类型擦除实现:在类内部用一个std::function<void(T*)>成员保存任意类型的可调用对象。
cpp
#include <functional>
template <class T>
class Shared_ptr {
public:
Shared_ptr()
: _ptr(nullptr)
, _pcount(new int(1))
{}
explicit 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& sp)
: _ptr(sp._ptr)
, _pcount(sp._pcount)
, _del(sp._del)
{
++(*_pcount);
}
Shared_ptr& operator=(const Shared_ptr& sp)
{
if (_ptr != sp._ptr) {
release();
_ptr = sp._ptr;
_pcount = sp._pcount;
_del = sp._del;
++(*_pcount);
}
return *this;
}
~Shared_ptr()
{
release();
}
T& operator*() const {
return *_ptr;
}
T* operator->() const {
return _ptr;
}
T* get() const {
return _ptr;
}
int use_count() const {
return *_pcount;
}
private:
void release()
{
if (--(*_pcount) == 0) {
_del(_ptr); // 使用删除器释放资源
delete _pcount;
}
}
T* _ptr;
int* _pcount;
std::function<void(T*)> _del = [](T* ptr) { delete ptr; };
};
关键设计:
-
使用
std::function<void(T*)>实现类型擦除,允许存储任意签名的删除器 -
默认删除器是
delete的lambda -
将释放逻辑抽取为
release()私有函数,避免代码重复
注意:标准库的
shared_ptr实现远比这复杂(考虑线程安全、多态删除、分配器定制等),此处仅为教学简化版。
4.4 weak_ptr的简化实现
weak_ptr不参与资源管理,只作为shared_ptr的观察者。简化版实现只需保存资源指针:
cpp
template <class T>
class Weak_ptr {
public:
Weak_ptr()
: _ptr(nullptr)
{}
Weak_ptr(const Shared_ptr<T>& sp)
: _ptr(sp.get())
{}
Weak_ptr& operator=(const Shared_ptr<T>& sp)
{
_ptr = sp.get();
return *this;
}
// 注意:此简化版未实现lock()和expired()
// 实际实现需要访问shared_ptr的引用计数,判断资源是否存活
private:
T* _ptr = nullptr;
};
完整的
weak_ptr实现需要将引用计数封装为一个独立类型(包含强引用计数和弱引用计数),shared_ptr和weak_ptr都指向该类型。当强引用计数归零时释放资源,但引用计数对象本身要等到弱引用计数也归零时才释放。这超出了入门教学的范围,有兴趣可以查阅标准库源码。
5.shared_ptr和weak_ptr:循环引用问题
shared_ptr在绝大多数场景下工作良好,但在特定数据结构(如双向链表、树形结构的父子节点互相引用)中,会出现循环引用(Circular Reference)导致的内存泄漏。
5.1 循环引用的产生
考虑一个双向链表节点的设计:
cpp
struct ListNode {
int _data;
ListNode* _next;
ListNode* _prev;
~ListNode() {
std::cout << "~ListNode()" << std::endl;
}
};
如果两个节点由shared_ptr管理,且通过原生指针连接:
cpp
std::shared_ptr<ListNode> n1(new ListNode);
std::shared_ptr<ListNode> n2(new ListNode);
此时n1和n2的引用计数均为1。程序结束时,两者正常析构,没有问题。
但如果我们希望_next和_prev也参与智能管理,将它们改为shared_ptr:
cpp
struct ListNode {
int _data;
std::shared_ptr<ListNode> _next;
std::shared_ptr<ListNode> _prev;
~ListNode() {
std::cout << "~ListNode()" << std::endl;
}
};
然后建立双向连接:
cpp
std::shared_ptr<ListNode> n1(new ListNode);
std::shared_ptr<ListNode> n2(new ListNode);
n1->_next = n2; // n2的引用计数变为2
n2->_prev = n1; // n1的引用计数变为2
引用计数变化:
| 步骤 | n1引用计数 | n2引用计数 | 说明 |
|---|---|---|---|
| 初始 | 1 | 1 | shared_ptr构造 |
n1->_next = n2 | 1 | 2 | _next共享了n2的资源 |
n2->_prev = n1 | 2 | 2 | _prev共享了n1的资源 |
析构时的死锁:
当main函数结束,局部变量n1和n2被销毁:
-
n1的shared_ptr析构:引用计数从2减到1 -
n2的shared_ptr析构:引用计数从2减到1
此时两个节点的引用计数都还剩1,资源不会被释放。
为什么?因为:
-
左边节点要释放,需要右边节点的
_prev析构 -
右边节点的
_prev要析构,需要右边节点本身释放 -
右边节点要释放,需要左边节点的
_next析构 -
左边节点的
_next要析构,需要左边节点本身释放
逻辑上形成了闭环,谁都无法释放——这就是循环引用导致的内存泄漏。(一般weak_ptr不出场,遇到这种情况了才会出场)

5.2 weak_ptr:打破循环的钥匙
weak_ptr的设计哲学是:观察但不拥有。它绑定到shared_ptr时,不增加引用计数,因此不会参与资源释放的决策。
将ListNode中的_next和_prev改为weak_ptr:
cpp
struct ListNode {
int _data;
std::weak_ptr<ListNode> _next; // 弱引用
std::weak_ptr<ListNode> _prev; // 弱引用
~ListNode() {
std::cout << "~ListNode()" << std::endl;
}
};
重新执行:
cpp
std::shared_ptr<ListNode> n1(new ListNode);
std::shared_ptr<ListNode> n2(new ListNode);
n1->_next = n2; // weak_ptr绑定,n2引用计数不变(仍为1)
n2->_prev = n1; // weak_ptr绑定,n1引用计数不变(仍为1)
当n1和n2销毁时,引用计数直接从1减到0,资源被正确释放,循环引用被打破。
5.3 weak_ptr的核心接口
weak_ptr 不支持RAII,也支持直接访问资源(没有operator*和operator->),因为它管理的资源可能随时被释放。访问资源前,必须通过lock()进行安全检查。
cpp
std::shared_ptr<std::string> sp = std::make_shared<std::string>("hello");
std::weak_ptr<std::string> wp = sp;
// 检查资源是否已释放
if (!wp.expired()) {
// lock()返回一个shared_ptr
std::shared_ptr<std::string> locked = wp.lock();
*locked += " world"; // 安全访问
std::cout << *sp << std::endl; // 输出 "hello world"
}
// 当所有shared_ptr都释放后
sp = std::make_shared<std::string>("new string"); // 原资源释放
std::cout << wp.expired() << std::endl; // 输出 1(true,已过期)
// lock()返回空shared_ptr
auto empty_sp = wp.lock();
if (!empty_sp) {
std::cout << "resource has been freed" << std::endl;
}
接口说明:
表格
| 接口 | 作用 |
|---|---|
expired() | 检查绑定的资源是否已被释放。若已释放返回true(本质就是通过引用计数判断) |
lock() | 返回一个管理该资源的shared_ptr。若资源已释放,返回空的shared_ptr |
use_count() | 返回当前资源的强引用计数 |
为什么需要lock()而不是直接访问?
考虑以下场景:线程A持有weak_ptr,检查expired()返回false(资源未释放),但在A准备访问资源前,线程B释放了最后一个shared_ptr。如果weak_ptr直接提供访问接口,A就会访问到已释放的内存。
lock()的原子性保证了:一旦返回非空shared_ptr,该资源在整个shared_ptr的生命周期内必定有效。
5.4 weak_ptr 的底层实现原理:控制块(Control Block)
要真正理解 weak_ptr,必须了解 shared_ptr 的底层内存布局。标准库的实现中,shared_ptr 和 weak_ptr 并不直接只管理"资源对象",而是共同指向一个中间结构——控制块(Control Block)。
控制块里有什么?
控制块是一个独立分配的对象(通常由 make_shared 与资源一起分配,或由 new 单独分配),内部至少包含:
| 成员 | 含义 |
|---|---|
| 强引用计数(strong_ref) | 当前有多少个 shared_ptr 在管理资源 |
| 弱引用计数(weak_ref) | 当前有多少个 weak_ptr 在观察资源 |
| 资源指针 | 指向实际管理的对象 |
| 删除器 | 保存的自定义释放逻辑 |
内存布局示意:

为什么需要弱引用计数?
这是理解 weak_ptr 的核心。考虑以下场景:
-
sp1(shared_ptr)和wp1(weak_ptr)共同指向同一个控制块。 -
当
sp1析构时,强引用计数减到 0。此时:-
资源对象应该被释放(调用删除器)
-
但控制块不能立即释放,因为
wp1还需要通过控制块查询expired()的状态
-
-
当
wp1也析构时,弱引用计数减到 0。此时:-
强引用计数已经是 0,弱引用计数也变成 0
-
控制块本身才能被释放
-

总结释放规则:
表格
| 操作 | 强引用计数 | 弱引用计数 | 结果 |
|---|---|---|---|
shared_ptr 构造 | +1 | 不变 | — |
shared_ptr 析构 | -1 | 不变 | 若强引用计数归零,释放资源对象 |
weak_ptr 构造 | 不变 | +1 | — |
weak_ptr 析构 | 不变 | -1 | 若弱引用计数归零且强引用计数已为 0,释放控制块 |
这与我们简化实现的区别
在第4节的简化版 Shared_ptr 中,我们只实现了一个 int* _pcount(强引用计数),并且资源释放和计数器释放是同时发生的:
cpp
if (--(*_pcount) == 0) {
delete _ptr; // 释放资源
delete _pcount; // 释放计数器
}
这种简化实现不支持 weak_ptr。因为一旦 shared_ptr 把 _pcount 给 delete 了,任何还指向这块内存的 weak_ptr 就会访问已释放的内存(use-after-free)。
因此,标准库必须把资源对象和控制块的生命周期分离:
-
强引用计数归零 → 释放资源对象
-
弱引用计数归零(且强引用已为0)→ 释放控制块
简单概括:weak_ptr 不直接参与资源对象的管理,但参与控制块的管理。 它通过控制块得知资源是否存活(expired()),并通过原子操作安全地"升级"为 shared_ptr(lock())。
5.5 使用建议
-
weak_ptr不直接管理资源,不能用于RAII场景 -
weak_ptr专门用于辅助shared_ptr,解决循环引用或需要"观察但不阻止释放"的场景 -
访问
weak_ptr指向的资源前,务必使用lock(),并检查返回值是否为空
6.shared_ptr的线程安全问题(了解)
6.1 引用计数的线程安全
shared_ptr的引用计数存储在堆上,由所有共享该资源的shared_ptr对象共同访问。当多个线程同时拷贝或析构shared_ptr时,会并发地修改引用计数,这就带来了线程安全问题。
问题演示:
cpp
struct AA {
int _a1 = 0;
int _a2 = 0;
~AA() {
std::cout << "~AA()" << std::endl;
}
};
int main()
{
std::shared_ptr<AA> p(new AA);
const size_t n = 100000;
auto func = [&]() {
for (size_t i = 0; i < n; ++i) {
std::shared_ptr<AA> copy(p); // 拷贝:++引用计数
copy->_a1++;
copy->_a2++;
}
};
std::thread t1(func);
std::thread t2(func);
t1.join();
t2.join();
std::cout << "a1 = " << p->_a1 << std::endl;
std::cout << "a2 = " << p->_a2 << std::endl;
std::cout << "use_count = " << p.use_count() << std::endl;
return 0;
}
如果不加保护,可能出现以下问题:
-
引用计数操作非原子化,导致计数丢失(如两个线程同时读-改-写,结果只增加了1而非2)
-
最终引用计数不为0,资源无法释放,造成内存泄漏
-
极端情况下可能重复释放,导致程序崩溃
6.2 解决方案
标准库实现: 实际的std::shared_ptr在标准库中已经通过原子操作(atomic)或互斥锁保证了引用计数的线程安全。用户无需额外处理。
自定义实现的改进: 如果在第4节的简化版Shared_ptr中,将int* _pcount改为std::atomic<int>* _pcount,即可保证引用计数的原子性:
cpp
// 简化示意
std::atomic<int>* _pcount;
// 构造
_pcount = new std::atomic<int>(1);
// 拷贝构造
++(*_pcount); // atomic的operator++是原子的
// 析构
if (--(*_pcount) == 0) { ... }
6.3 重要区分:引用计数安全 ≠ 对象安全
shared_ptr保证的是引用计数操作的线程安全,但不保证所管理对象的线程安全。
cpp
std::shared_ptr<int> sp = std::make_shared<int>(0);
// 线程1和线程2同时执行:
// *sp += 1; // 危险!对同一对象的写操作需要外部同步
如果多个线程并发读写shared_ptr管理的同一对象,仍需使用互斥锁(std::mutex)、原子变量或其他同步机制来保护。
总结:
-
shared_ptr的控制块(引用计数等)是线程安全的 -
shared_ptr管理的资源对象的线程安全,由使用者负责
7.C++11和boost中智能指针的关系
Boost是为C++标准库提供扩展的程序库集合,其社区与C++标准委员会有深厚联系。C++11及之后的许多特性都源自Boost。
智能指针的演进历程:
| 时间 | 事件 |
|---|---|
| C++98 | 引入第一个智能指针 auto_ptr(设计有缺陷) |
| Boost | 提供更实用的 scoped_ptr / scoped_array / shared_ptr / shared_array / weak_ptr |
| C++11 | 正式引入 unique_ptr(对应Boost的scoped_ptr)、shared_ptr、weak_ptr;废弃auto_ptr |
| C++14 | 引入 make_unique |
关键对应关系:
-
std::unique_ptr≈boost::scoped_ptr(独占所有权) -
std::shared_ptr≈boost::shared_ptr(共享所有权) -
std::weak_ptr≈boost::weak_ptr(弱引用)
C++11标准库智能指针的实现原理直接参考了Boost的设计。理解这一点有助于阅读历史代码或跨平台库。
8.内存泄漏
8.1 什么是内存泄漏?有什么危害?
内存泄漏(Memory Leak) 指程序在运行过程中动态分配了内存,但在使用完毕后未能释放,导致系统可用内存逐渐减少的现象。
内存泄漏不是指内存在物理上消失,而是指程序失去了对某段内存的控制能力,无法再次使用,造成了内存浪费。
危害:
-
短期程序:运行片刻即退出的程序,内存泄漏影响较小。进程结束后,操作系统会回收其全部内存。
-
长期运行程序:如服务器后台服务、操作系统内核、长时间运行的客户端等,内存泄漏会导致可用内存持续减少,系统响应变慢,最终可能因内存耗尽而卡死或崩溃。
8.2 内存泄漏的检测工具
| 平台 | 工具 | 说明 |
|---|---|---|
| Linux | Valgrind、AddressSanitizer (ASan) | 功能强大,推荐ASan(编译器内置,开销较小) |
| Windows | Visual Leak Detector (VLD) | 第三方工具,集成到Visual Studio |
使用建议: 在项目上线前进行内存泄漏检测,作为质量保障的一环。但工具检测并非万能,部分场景可能漏报或误报。
Linux下几款C++程序中的内存泄露检查工具_c++内存泄露工具分析-CSDN博客
windows下的内存泄露检测工具VLD使用_windows内存泄漏检测工具-CSDN博客
8.3 如何避免内存泄漏
-
事前预防:使用智能指针
-
优先使用
unique_ptr,需要共享时使用shared_ptr -
遵循RAII原则,将资源管理交给对象
-
-
良好的编码规范
-
new与delete/new[]与delete[]严格配对 -
减少裸指针的使用,避免资源所有权模糊
-
-
事后检测
-
定期使用内存泄漏工具检测
-
代码审查时重点关注资源管理逻辑
-
总结: 内存泄漏的解决方案分为两类——事前预防型(智能指针、RAII)和事后查错型(检测工具)。前者是根治之道。
结语:
智能指针的本质从来不是"自动释放内存"这么简单,而是将资源的所有权语义化、显式化、安全化。从 auto_ptr 的失败实验中,我们学到了"破坏性拷贝"违背直觉;从 unique_ptr 的独占语义中,我们理解了零开销抽象的魅力;从 shared_ptr 的引用计数中,我们看到了共享所有权的代价与权衡;从 weak_ptr 的控制块中,我们掌握了打破循环引用的钥匙。
记住一条实践准则:优先使用 unique_ptr,必要时使用 shared_ptr,遇到循环引用时拿出 weak_ptr,永远不要让裸指针承担资源所有权。 配合 make_unique 和 make_shared,你可以在不牺牲性能的前提下,写出异常安全、内存安全的现代 C++ 代码。
RAII 是 C++ 的基石,而智能指针是 RAII 在内存管理领域最优雅的实践。掌握它们,你才真正迈入了现代 C++ 的大门。
文章到这里结束,感谢阅读。若觉得写的还可以,可以分享给朋友一起来看,一起进步更有动力;当然,能关注一下就更好啦,我们下篇见。


453

被折叠的 条评论
为什么被折叠?



