C++ 智能指针:shared_ptr 和 weak_ptr
引言
C++ 中经常需要 new 一个对象,开辟一个内存空间,返回一个指针来操作这个内存。使用完毕之后,需要通过 delete 来释放内存空间。如果内存没有释放,那这块内存将无法再利用,导致内存泄漏。为降低人为疏忽,C++ 11 的新特性中引入了三种智能指针,来自动化地管理内存资源:
unique_ptr: 管理的资源唯一的属于一个对象,但是支持将资源移动给其他 unique_ptr 对象。当拥有所有权的 unique_ptr 对象析构时,资源即被释放。
shared_ptr: 管理的资源被多个对象共享,内部采用引用计数跟踪所有者的个数。当最后一个所有者被析构时,资源即被释放。
weak_ptr: 与 shared_ptr 配合使用,虽然能访问资源但却不享有资源的所有权,不影响资源的引用计数。有可能资源已被释放,但 weak_ptr 仍然存在。因此每次访问资源时都需要判断资源是否有效。
本文主要在循环引用的场景下探讨 shard_ptr 和 weak_ptr 原理。
循环引用
shared_ptr 通过引用计数的方式管理内存,当进行拷贝或赋值操作时,每个 shared_ptr 都会记录有多少个其他的 shared_ptr 指向相同的对象,当引用计数为 0 时,内存将被自动释放。1
2auto p = make_shared<int>(10); // 创建一个名为 p 的 shared_ptr,指向一个取值为 10 的 int 型对象,这个数值 10 的引用计数为 1,只有 p
auto q(p); // 创建一个名为 q 的 shared_ptr,并用 p 初始化,此时 p 和 q 指向同一个对象,此时数值 10 的引用计数为 2
当对 shared_ptr 赋予新值,或被销毁时,引用计数会递减。1
2auto r = make_shared<int>(20); // 创建一个名为 r 的 shared_ptr,指向一个取值为 20 的 int 型对象,这个数值 20 的引用计数为 1,只有 r
r = q; // 对 r 赋值,让 r 指向数值 10。此时数值 10 的引用计数加 1 为 3,数值 20 的引用计数减 1 位 0,数值 20 的内存将被自动释放
通常情况下 shared_ptr 可以正常运转,但是在循环引用的场景下,shared_ptr 无法正确释放内存。循环引用,顾名思义,A 指向 B,B 指向 A,在表示双向关系时,是很可能出现这种情况的,例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using namespace std;
class Son;
class Father {
public:
shared_ptr<Son> son_;
Father() {
cout << __FUNCTION__ << endl;
}
~Father() {
cout << __FUNCTION__ << endl;
}
};
class Son {
public:
shared_ptr<Father> father_;
Son() {
cout << __FUNCTION__ << endl;
}
~Son() {
cout << __FUNCTION__ << endl;
}
};
int main()
{
auto son = make_shared<Son>();
auto father = make_shared<Father>();
son->father_ = father;
father->son_ = son;
cout << "son: " << son.use_count() << endl;
cout << "father: " << father.use_count() << endl;
return 0;
}
程序的执行结果如下:
Son
Father
son: 2
father: 2
可以看到,程序分别执行了 Son 和 Father 的构造函数,但是没有执行析构函数,出现了内存泄漏。
shared_ptr 原理
shared_ptr 实际上是对裸指针进行了一层封装,成员变量除了裸指针外,还有一个引用计数,它记录裸指针被引用的次数(有多少个 shared_ptr 指向这同一个裸指针),当引用计数为 0 时,自动释放裸指针指向的资源。影响引用次数的场景包括:构造、赋值、析构。基于三个最简单的场景,实现一个 demo 版 shared_ptr 如下(实现既不严谨也不安全,仅用于阐述原理):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
using namespace std;
template<typename T>
class SharedPtr {
public:
int* counter; // 引用计数,用指针表示,多个 SharedPtr 之间可以同步修改
T* resource; // 裸指针
SharedPtr(T* resc = nullptr) { // 构造函数
cout << __PRETTY_FUNCTION__ << endl;
counter = new int(1);
resource = resc;
}
SharedPtr(const SharedPtr& rhs) { // 拷贝构造函数
cout << __PRETTY_FUNCTION__ << endl;
resource = rhs.resource;
counter = rhs.counter;
++*counter;
}
SharedPtr& operator=(const SharedPtr& rhs) { // 拷贝赋值函数
cout << __PRETTY_FUNCTION__ << endl;
--*counter; // 原来指向的资源的引用计数减 1
if (*counter == 0) {
delete counter;
delete resource;
}
resource = rhs.resource;
counter = rhs.counter;
++*counter; // 新指向的资源的引用计数加 1
}
~SharedPtr() { // 析构函数
cout << __PRETTY_FUNCTION__ << endl;
--*counter;
if (*counter == 0) {
delete counter;
delete resource;
}
}
int use_count() {
return *counter;
}
};
在循环引用示例中,用到了 make_shared 函数:1
auto son = make_shared<Son>(); // 新建一个 Son 对象,返回指向这个 Son 对象的指针
此用法等价于:1
2auto son_ = new Son(); // 新建一个 Son 对象,返回指向这个对象的指针 son_
shared_ptr<Son> son(son_); // 创建一个管理 son_的 shared_ptr
代入 SharedPtr 的实现来分析示例中 main 函数的执行过程,可以得到:1
2
3
4auto son = make_shared<Son>(); // 调用构造函数,son.counter=1
auto father = make_shared<Father>(); // 调用构造函数,father.counter=1
son->father_ = father; // 调用赋值函数,son.counter=2
father->son_ = son; // 调用赋值函数,father.counter=2
当 main 函数执行完时,执行析构函数,此时由于 son.counter=1,father.couter=1,不满足 if 条件,不会实行 delete 命令完成资源释放,导致内存泄漏。
weak_ptr 原理
为解决循环引用的问题,仅使用 shared_ptr 是无法实现的。堡垒无法从内部攻破的时候,需要借助外力,于是有了 weak_ptr,字面意思是弱指针。为啥叫弱呢?shared_ptr A 被赋值给 shared_ptr B 时,A 的引用计数加 1;shared_ptr A 被赋值给 weak_ptr C 时,A 的引用计数不变。引用力度不够强,不足以改变引用计数,所以就弱了(个人理解,有误请指正)。
weak_ptr 在使用时,是与 shared_ptr 绑定的。基于 SharedPtr 实现来实现 demo 版的 WeakPtr,并解决循环引用的问题,全部代码如下:
1 |
|
代码执行结果如下:
WeakPtr
::WeakPtr(T*) [with T = Father] Son::Son()
SharedPtr
::SharedPtr(T*) [with T = Son] Father::Father()
SharedPtr
::SharedPtr(T*) [with T = Son] SharedPtr
::SharedPtr(T*) [with T = Father] WeakPtr
& WeakPtr ::operator=(SharedPtr &) [with T = Father] SharedPtr
& SharedPtr ::operator=(const SharedPtr &) [with T = Son] son: 2
father: 1
SharedPtr
::~SharedPtr() [with T = Father] Father::~Father()
SharedPtr
::~SharedPtr() [with T = Son] SharedPtr
::~SharedPtr() [with T = Son] Son::~Son()
WeakPtr
::~WeakPtr() [with T = Father]
可以看到 Son 对象和 Father 对象均被析构,内存泄漏的问题得到解决。析构过程解读如下:
SharedPtr
::~SharedPtr() [with T = Father] # 析构 father,由于 father.couter=1,减 1 后执行 delete father_ Father::~Father() # 析构 father_,执行~Father(),进一步析构成员变量
SharedPtr
::~SharedPtr() [with T = Son] # 析构 SharedPtr ,此时 son.couter 减 1,son.counter=1 SharedPtr
::~SharedPtr() [with T = Son] # 析构 son,由于 son.counter=1,减 1 后执行 delete son_ Son::~Son() # 析构 son_,执行~Son(),进一步析构成员变量
WeakPtr
::~WeakPtr() [with T = Father] # 析构 WeakPtr
总结
- 尽量使用智能指针管理资源申请与释放,减少人为 new 和 delete 误操作和考虑不周的问题。
- 使用 make_shared 来创建 shared_ptr,如果先 new 一个对象,再用这个对象的裸指针构造一个 shared_ptr 指针,可能出现问题。shared_ptr 会自动释放资源,如果再手动 delete,释放两次那就挂了。