终于来填坑了😂

1
2022-10-19 -> 2023-04-01

1.情景

对于C/C++而言,内存泄漏是一个老生常谈的问题。每次进行new操作之后,我们都需要对其进行对应的delete,已避免内存泄漏。

可代码一长,逻辑复杂起来了,想处理就没有那么容易了。

1.1 代码太长,看不到头

1
2
3
4
5
6
7
8
int main()
{
int* arr1 = new int[10];
int* arr2 = new int[10];
//中间有超级多行代码
delete arr1[];
delete arr2[];
}

我们日常的学习,可能写的代码并不是很多。但如果是一个大型的项目,new和delete之中可能隔了成千上万行代码,那时候还想去找这个变量的位置,可就不那么好找了。极其容易出现忘记delete的情况

1.2 异常安全

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
int func(int a,int b)
{
if(b==0)
{
throw invalid_argument("除0错误");
}
return a/b;
}

void test()
{
int* arr1 = new int[10];
int* arr2 = new int[10];
func(1,0);//抛异常
delete[] arr1;
delete[] arr2;
}

int main()
{
try{
test();
}
catch(...){
cout <<"出现异常"<<endl;
}
return 0;
}

如上面的代码,在test函数中,调用了另外一个会抛出异常的函数。如果这个函数抛出了异常,后续的delete操作不会被执行,出现内存泄漏。

如果操作的堆区变量较少,可以采用如下的方式来解决这个问题

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
int func(int a,int b)
{
if(b==0)
{
throw invalid_argument("除0错误");
}
return a/b;
}

void test()
{
int* arr1 = new int[10];
int* arr2 = new int[10];
try{
func(1,0);//抛异常
}
catch(...){//接管异常
// 销毁
delete[] arr1;
delete[] arr2;
throw;// 原样抛出
}
delete[] arr1;
delete[] arr2;
}

int main()
{
try{
test();
}
catch(...){
cout <<"出现异常"<<endl;
}
return 0;
}

但是,这样并不能完全解决问题,因为new函数本身也有可能抛出异常!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void test()
{
int* arr1 = new int[10];
int* arr2 = new int[10];//这里抛异常了
// 此时无法被接管!
// arr1没有被析构,出现内存泄漏

try{
func(1,0);//抛异常
}
catch(...){//接管异常
// 销毁
delete[] arr1;
delete[] arr2;
throw;// 原样抛出
}
delete[] arr1;
delete[] arr2;
}

把new也丢到try里面?依旧不行(详见注释)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void test()
{
try{
int* arr1 = new int[10];
int* arr2 = new int[10];//这里抛异常了
int* arr3 = new int[10];

func(1,0);//抛异常
}
catch(...){
// 你不知道哪一个出了异常!
// 假设arr2出异常,arr2和arr3都没有申请空间成功
// 怎么进行销毁?
delete[] arr1;
delete[] arr2;
delete[] arr3;
throw;// 原样抛出
}
delete[] arr1;
delete[] arr2;
delete[] arr3;
}

而且,如果需要操作的new变量很多,那么在catch里面就需要加上多个delete,代码就显得过于重复了。

为了解决这个问题,C++引入了智能指针

2.智能指针

2.1 RAII

1
RAII - Resource Acquisition is Initialization

需要注意的是,RAII是一种思想,并不能用它来指代智能指针

1
RAII != 智能指针

它是一种利用对象的生命周期来控制程序资源(内存、文件句柄、网络链接、互斥量等)的技术

只要是两步操作的,需要申请+释放的资源,都可以使用RAII的思想来进行处理。比如自己封装一个自动处理pthread_mutex锁的init和destory的类,std::unique_lock就是使用了RAII思想的锁管理类,会自动加锁并在出作用域的时候解锁。

说人话就是,在对象构造的时候获取资源,析构的时候释放资源。

  • 在对象生命周期到了之后,会自动释放资源,免去了我们手动释放资源or忘记释放的繁琐;
  • 资源在对象的生命周期内始终有效;

在大型项目中,一般都会用智能指针来管理堆区空间。

3.demo

3.1 基础示例

在认识不同类型的智能指针之前,先来看个最简单的demo

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
#pragma once

namespace mu {
using std::cout;
using std::endl;
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr)
:_ptr(ptr)
{
cout << "init " << (void*)_ptr << endl;
}

~SmartPtr()
{
cout << "des " << (void*)_ptr << endl;
delete[] _ptr;
}
private:
T* _ptr;
};

}

上面实现的,就是一个最简单的智能指针。它可以帮助我们管理堆区上的数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define _CRT_SECURE_NO_WARNINGS 1
//#include <stdio.h>
#include <iostream>
#include "demo.hpp"
using namespace std;

void test1()
{
mu::SmartPtr<int> p1(new int[10]);
mu::SmartPtr<char> p4(new char[10]);
mu::SmartPtr<double> p3(new double[10]);
cout << "test" << endl;
}

int main()
{
test1();
return 0;
}

运行后,输出的结果如下

1
2
3
4
5
6
7
init 006697A8
init 006724D0
init 00668860
test
des 00668860
des 006724D0
des 006697A8

其实现了在构造中托管,在析构中销毁资源的操作。


即便抛出异常,依旧能正常析构

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
#include <iostream>
#include "demo.hpp"
using namespace std;

int func(int a, int b)
{
if (b == 0)
{
throw invalid_argument("除0错误");
}
return a / b;
}

void test1()
{
mu::SmartPtr<int> p1(new int[10]);
mu::SmartPtr<char> p2(new char[10]);
mu::SmartPtr<double> p3(new double[10]);
cout << "test1" << endl;
try {
func(3, 0);
}
catch(...){
throw;
}

cout << "test2" << endl;
}
int main()
{
try {
test1();
}
catch (...)
{
cout << "mian 出现异常" << endl;
}

return 0;
}

注意,如果抛出异常又不进行catch,程序会被abort终止,无法观测到现象。

以下是运行的结果,可以看到异常出现后,走到了test1函数的生命周期末尾,释放了3个指针,才被main中的catch捕获

1
2
3
4
5
6
7
8
init 012B8F00
init 012C3010
init 012B8860
test1
des 012B8860
des 012C3010
des 012B8F00
mian 出现异常

3.2 运算符重载

当然,这个智能指针还是却少很多东西的

  • 写死了delete[],我只想让她管理单个变量怎么办?
  • 如何获取指针内的资源?

第一个问题我们暂且不提(后续讲解库中智能指针的时候会说明)第二个问题的答案便是:重载*->操作符

1
2
3
4
5
6
7
8
9
10
11
12
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T& operator[](const T& n)
{
return _ptr[n];
}

重载了之后,我们就可以操作类内的指针成员了

1
2
3
4
5
6
7
8
9
10
11
12
void test1()
{
mu::SmartPtr<int> p1(new int[10]);
mu::SmartPtr<char> p2(new char[10]);
mu::SmartPtr<double> p3(new double[10]);
cout << "test" << endl;
p2[0] = 'a';
p2[1] = 'b';
p2[2] = '\0';
cout << p2[0] <<" "<< p2[1] << endl;
cout << *p2 + 1 << endl;
}

运行结果如下

1
2
3
4
5
6
7
8
9
init 01661C30
init 01671748
init 016689B8
test
a b
98
des 016689B8
des 01671748
des 01661C30

3.3 拷贝

对于智能指针而言,有一个很重要的问题是针对拷贝的。在一些场景中,我们需要对指针进行拷贝,这时候就会出现异常

1
2
3
4
5
void test3()
{
mu::SmartPtr<int> p1(new int[10]);
mu::SmartPtr<int> p2(p1);
}

运行结果如下

1
2
3
init 00BBFB20
des 00BBFB20
des 00BBFB20

编译器报错了

image-20230401161340352

原因就是,默认的拷贝构造使用的是浅拷贝,再析构的时候,两个智能指针对同一个地址析构,相当于析构了两次,肯定是不行的!

接下来,就让我们看看cpp库中是怎么解决这个问题的吧!

4.auto_ptr

为了避免拷贝的时候,导致多次析构,C++98库函数中提供了auto_ptr。在拷贝的时候,auto_ptr采用的是管理权转移的思路

1
2
auto_ptr<int>  sp1(new int);//无法继续使用
auto_ptr<int> sp2 = sp1;//sp2接管了sp1

当我们这样操作了之后,sp1对象将不能再被使用,其内置指针会被置为nullptr,使用相当于解引用空指针!

1
2
3
4
auto_ptr<int> sp1(new int(10)); 
auto_ptr<int> sp2 = sp1; // sp2接管了sp1,sp1无法继续使用
cout << *sp2 << endl; // 打印10
cout << *sp1 << endl; // 解引用空指针,段错误

因为这个操作实在太坑人了,如果在某些函数传参的时候,进行值拷贝了,就会导致原有的指针失效,从而引发程序错误。

所以,在C++11之后,应避免使用auto_ptr。在使用新版本g++进行编译的时候,也会提示auto_ptr已经被抛弃了。

1
warning: ‘template<class> class std::auto_ptr’ is deprecated: use 'std::unique_ptr' instead [-Wdeprecated-declarations]

5.unique_ptr

5.1 基本说明

在boost库中,有一个scpoed_ptr,其就是unique_ptr的前身。

unique的做法更绝,既然拷贝会出现问题,那我直接不允许你拷贝不就行了?

直接将拷贝构造和赋值重载给delete了,即禁止对方使用拷贝。

1
2
unique_ptr(const unique_ptr<T>& n) = delete;
unique_ptr<T>& operator=(const unique_ptr<T>& n) = delete;

如果是C++11之前,可以采用只声明不实现的方式来禁用拷贝构造。为了避免使用者自己写一个拷贝构造,我们需要将其配置为私有。

1
2
3
private://只声明不实现
unique_ptr(const unique_ptr<T>& n);
unique_ptr<T>& operator=(const unique_ptr<T>& n);

如果我们写出这样的代码,触发了拷贝构造的场景,此时因为无法访问unique_ptr的拷贝构造,是无法编译成功的。

1
2
unique_ptr<int> sp1(new int(10));
unique_ptr<int> sp2(sp1); // 无法编译

虽然unique_ptr从源头解决了拷贝的问题,但是它有一个小问题:功能不全。如果我真的需要拷贝呢?你这个岂不是用不了呀。

5.2 可以被move吗?

面试的时候被考到了这个问题,已知unique_ptr无法拷贝,那请问他可以被std::move给其他变量吗?

使用下面的代码进行测试

1
2
3
4
5
6
7
8
9
10
11
unique_ptr<int> sp1(new int(20));
cout << "sp1:" << *sp1 << endl; // 打印20

unique_ptr<int> sp2 = std::move(sp1);
cout << "sp2:" << *sp2 << endl; // 打印20

unique_ptr<int> sp3(std::move(sp2));
cout << "sp3:" << *sp3 << endl; // 打印20

cout << "sp1(after move):" << *sp1 << endl; // 段错误
cout << "sp2(after move):" << *sp2 << endl; // 段错误

输出如下,访问sp1的时候会段错误(访问sp2的时候也会)

1
2
3
4
sp1:20
sp2:20
sp3:20
[1] 6693 segmentation fault ./test1

再来试试赋值重载可以不,会发现是可行的。

1
2
3
4
5
unique_ptr<int> sp3(new int(30));
cout << "sp3:" << *sp3 << endl; // 打印30

sp3 = std::move(sp2);
cout << "sp3:" << *sp3 << endl; // 打印20

可得结论:unique_ptr可以被move,move后原有对象失效,访问原有对象会段错误。

将上面的unique_ptr换成shared_ptr也是一样的结果,符合右值拷贝的特性。

其实这个问题是取决于unique_ptr是否实现了右值拷贝。使用vscode跳转到linux下unique_ptr的源码,可以看到它是实现了右值拷贝的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// Move constructor.
unique_ptr(unique_ptr&&) = default;

/** @brief Converting constructor from another type
*
* Requires that the pointer owned by @p __u is convertible to the
* type of pointer owned by this object, @p __u does not own an array,
* and @p __u has a compatible deleter type.
*/
template<typename _Up, typename _Ep, typename = _Require<
__safe_conversion_up<_Up, _Ep>,
typename conditional<is_reference<_Dp>::value,
is_same<_Ep, _Dp>,
is_convertible<_Ep, _Dp>>::type>>
unique_ptr(unique_ptr<_Up, _Ep>&& __u) noexcept
: _M_t(__u.release(), std::forward<_Ep>(__u.get_deleter()))
{ }

同时也能找到右值的赋值重载。

1
2
3
4
5
6
7
8
// Assignment.

/** @brief Move assignment operator.
*
* Invokes the deleter if this object owns a pointer.
*/
unique_ptr&
operator=(unique_ptr&&) = default;

6.shared_ptr

share即分享,这个智能指针是支持拷贝的。那它应该如何解决同一片空间被释放多次的问题呢?

6.1 引用计数

为了保证资源只会被释放一次,其采用了引用计数的方式来实现。

  • 初始化的时候,引用计数为1;
  • 每次拷贝,引用计数都+1(包括拷贝构造和赋值重载);
  • 析构的时候,引用计数不为1,将计数器-1;
  • 只有引用计数为1(当前是最后一个对象了),才在析构的时候释放资源;

这样就解决了被析构多次的问题!

6.2 如何实现?

  • 直接使用一个成员变量?

不行,一个对象的修改不影响第二个对象的成员,依旧会出现析构多次的问题。

  • 使用static成员?

static成员属于整个类,这样弄相当于给这个类加了个已有对象数量的计数器,南辕北辙。

1
2
T* _ptr;
int* _pcount;//计数器

我们只需要在对象中添加一个int类型的指针,在第一次初始化对象的时候,给这个指针创建堆区的int空间,并初始化为1;

这样每次拷贝、赋值的时候,都给这个pcount指向的int给+1

1
(*_pcount)++

每次析构的时候都-1,只有为0的时候,才进行资源释放。同时释放指针托管的内存以及pcount占用的内存。

1
2
3
4
5
6
(*_pcount)--;//先将自己的-1
if((*_pcount==0))//如果为0,则代表自己是最后一个
{//释放自己的资源
delete _ptr;
delete _pcount;
}

6.3 赋值

赋值有两种情况

1
2
3
4
5
6
shared_ptr<int> sp1(new int(1));
shared_ptr<int> sp2;
sp2 = sp1;//情况1
shared_ptr<int> sp3(new int(3));
sp1 = sp3;//情况2
shared_ptr<int> sp4 = sp1;//实际上调用的是拷贝构造

第一种 是对象没有初始化,调用了默认无参构造函数,其内部托管的指针是nullptrsp2=sp1,相当于是初始化sp2对象。

第二种 是对象已经初始化了,但是我想让他管理另外一份资源。

  • 针对情况1,操作和拷贝构造相同,我们只需要赋值给sp2后,将引用计数加+1即可
  • 针对情况2,我们就需要判断sp1的情况了。如果sp1的引用计数为1,则需要先销毁sp1托管的空间,再进行赋值。并将sp3的引用计数赋值给sp1,并将sp3的引用计数+1

落到代码上,应该如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
shared_ptr<T>& operator=(const shared_ptr<T>& p)
{
if(p._ptr == _ptr)//指向的空间相同
{
return *this;//啥都不做
}
(*_pcount)--;//先将自己的-1
if((*_pcount==0))//如果为0,则代表自己是最后一个
{//释放自己的资源
delete _ptr;
delete _pcount;
}
//赋值对方的资源
_ptr = p._ptr;
_pcount = p._pcount;
(*_pcount)++;//引用计数+1

return *this;
}

6.4 简单实现

库中的实现更为复杂,其还重载了<<操作符,实现了更多成员函数

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
:_ptr(ptr),
_pcount(new int(1))
{
cout << "[init] " << (void*)_ptr << endl;
}
//拷贝构造
shared_ptr(const shared_ptr<T>& p)
{
cout << "[copy] " << (void*)_ptr << endl;
_ptr = p._ptr;
_pcount = p._pcount;
(*_pcount)++;//引用计数+1
}
//赋值重载
shared_ptr<T>& operator=(const shared_ptr<T>& p)
{
cout << "[operator=] " << (void*)_ptr << endl;
if (p._ptr == _ptr)//指向的空间相同
{
return *this;//啥都不做
}
//释放自己的资源(判断)
_Release();
//赋值对方的资源
_ptr = p._ptr;
_pcount = p._pcount;
(*_pcount)++;//引用计数+1

return *this;
}

T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T& operator[](const T& n)
{
return _ptr[n];
}
T* get()
{
return _ptr;
}

~shared_ptr()
{
_Release();
}
private:
void _Release()
{
(*_pcount)--;
cout << "[des] pcount:" << (*_pcount) << " ptr:" << (void*)_ptr << endl;
if ((*_pcount) == 0)
{
cout << "[des] delete " << (void*)_ptr << endl; // 如果释放了资源,会打印这个
if(_ptr)//_ptr不为空的时候才释放它
delete _ptr;
//无论什么时候都需要删除计数器,否则会有内存泄漏
delete _pcount;
}
}

T* _ptr;
int* _pcount;//引用计数
};

6.5 测试

用如下代码进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void test4()
{
mu::shared_ptr<int> p1(new int(10));
mu::shared_ptr<int> p2(p1);
mu::shared_ptr<int> p3 = p1;//实际上调用的是拷贝构造
cout << "p1 " << (*p1) << endl;
cout << "p2 " << (*p2) << endl;
cout << "p3 " << (*p3) << endl;
mu::shared_ptr<int> p4;
p4 = p1;
cout << "p4 " << (*p4) << endl;

mu::shared_ptr<int> p5(new int(20));
p1 = p5;
cout << "p1 " << (*p1) << endl;
cout << "p5 " << (*p5) << endl;
}

运行的结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[init] 00BE0B10
[copy] 00000000
[copy] 00000000
p1 10
p2 10
p3 10
[init] 00000000
[operator=] 00000000
[des] pcount:0 ptr:00000000
[des] delete 00000000
p4 10
[init] 00BD8B30
[operator=] 00BE0B10
[des] pcount:3 ptr:00BE0B10
p1 20
p5 20
[des] pcount:1 ptr:00BD8B30
[des] pcount:2 ptr:00BE0B10
[des] pcount:1 ptr:00BE0B10
[des] pcount:0 ptr:00BE0B10
[des] delete 00BE0B10
[des] pcount:0 ptr:00BD8B30
[des] delete 00BD8B30

可以看到,每次析构,实际上都会先对引用计数进行-1的操作,只有引用计数为0的情况下,才会真的析构掉对应的值。

赋值的时候,也没有出现内存泄漏的问题!

1
2
3
4
5
6
7
8
9
10
void test5()
{
mu::shared_ptr<int> p1(new int(10));
mu::shared_ptr<int> p2(new int(20));
cout << "p1 " << (*p1) << endl;
cout << "p2 " << (*p2) << endl;
p1 = p2;
cout << "p1 " << (*p1) << endl;
cout << "p2 " << (*p2) << endl;
}

p2在赋值给p1之前,先析构了p1维护的变元,才进行了赋值操作

1
2
3
4
5
6
7
8
9
10
11
12
[init] 014DF1B8
[init] 014D89A8
p1 10
p2 20
[operator=] 014DF1B8
[des] pcount:0 ptr:014DF1B8
[des] delete 014DF1B8
p1 20
p2 20
[des] pcount:1 ptr:014D89A8
[des] pcount:0 ptr:014D89A8
[des] delete 014D89A8

6.6 线程安全问题

在实际应用场景中,可能会出现多线程对该指针进行拷贝的问题。为了避免引用计数pcount在多线程拷贝的时候出现二义性问题,需要对引用计数的操作进行加锁

详见 https://blog.musnow.top/posts/1249427441/12.shared_ptr;释放资源的时候需要使用锁来加锁解锁,保证计数器的操作是原子性的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void Release()
{
bool flag = false;//判断锁是否需要被释放

_pMutex->lock();
if (--(*_pRefCount) == 0 && _ptr)
{
//进入了这个函数,代表引用计数为0
delete _ptr;
delete _pRefCount;
//标识需要释放锁
flag = true;
}
_pMutex->unlock();//解锁
//释放锁
if (flag){
delete _pMutex;
}
}

每次拷贝的时候,引用计数的操作也需要加锁

1
2
3
4
5
6
7
8
void AddRef()//新增引用
{
_pMutex->lock();

++(*_pRefCount);

_pMutex->unlock();
}

6.7 循环引用问题

shared_ptr的引用计数可能会出现循环引用的问题,它需要用weak_ptr来解决。后文会提到。

6.8 make_shared和直接构造shared_ptr的区别

来自GPT

make_shared 和直接使用 shared_ptr 的构造函数有几个关键区别:

  1. 性能:make_shared 在创建对象时将分配内存来同时存储对象和控制块(control block),而直接使用 shared_ptr 的构造函数则会分别分配内存来存储对象和控制块。因此,make_shared 可以减少内存分配的次数,提高性能,尤其在频繁创建和销毁 shared_ptr 对象时更为明显。
  2. 内存管理:make_shared 会将对象和控制块一起存储在一块连续的内存中,这样可以减少内存碎片化。而直接使用 shared_ptr 的构造函数则会分别分配两块内存,可能导致内存碎片化的问题。
  3. 异常安全性:使用 make_shared 可以提供更强的异常安全性,因为对象和控制块是在同一次内存分配中创建的。如果在创建对象或控制块时抛出异常,make_shared 会自动销毁已经分配的内存,避免内存泄漏。而直接使用 shared_ptr 的构造函数可能会导致部分内存泄漏,因为对象或控制块可能已经被成功分配了,但另一块内存分配失败时可能无法正确释放。
  4. 使用方便性:make_shared 的语法更为简洁明了,只需提供对象类型和构造函数参数即可,不需要显式指定 shared_ptr 的模板类型。而直接使用 shared_ptr 的构造函数则需要显式指定模板类型,并且需要分别为对象和控制块进行内存分配和初始化。

综上所述,尽量优先使用 make_shared 来创建 shared_ptr 对象,以提高性能和内存管理效率,并增强异常安全性。

7.weak_ptr

7.1 简介

这个指针是专门用来辅助解决shared_ptr循环引用问题的,可以认为它是shared_ptr的小弟。

1
2
3
4
5
6
7
8
9
10
11
12
13
//default (1)	
constexpr weak_ptr() noexcept;
//copy (2)
weak_ptr (const weak_ptr& x) noexcept;
template <class U> weak_ptr (const weak_ptr<U>& x) noexcept;
//from shared_ptr (3)
template <class U> weak_ptr (const shared_ptr<U>& x) noexcept;

//copy (1)
weak_ptr& operator= (const weak_ptr& x) noexcept;
template <class U> weak_ptr& operator= (const weak_ptr<U>& x) noexcept;
//from shared_ptr (2)
template <class U> weak_ptr& operator= (const shared_ptr<U>& x) noexcept;

相比于其他智能指针的构造函数,weak_ptr只能进行拷贝,或从一个shared_ptr来构造。

它最大的特点就是:只托管资源,不处理引用计数,析构时也不进行资源释放。可以认为,它只是对指针进行了简单的封装。

这个特性也决定了,weak_ptr不能使用原生指针来构造!

7.2 什么是循环引用

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
struct ListNode
{
ListNode* _prev;
ListNode* _next;
T _val;

ListNode(const T& val=T())
:_prev(nullptr),
_next(nullptr),
_val(val)
{
cout << "[ListNode()] " << (void*)this << endl;
}

~ListNode()
{
cout << "[~ListNode()] " << (void*)this << endl;
}
};

void test6()
{
mu::shared_ptr<ListNode<int>> p1(new ListNode<int>(10));
mu::shared_ptr<ListNode<int>> p2(new ListNode<int>(20));
}

上面这个代码是一个最简单的双链表,在没有给链表内节点赋值之前,它是没有问题的。使用智能指针能成功调用对象的析构,并销毁空间

1
2
3
4
5
6
7
8
9
10
[ListNode()] 00DE10E0
[init] 00DE10E0
[ListNode()] 00DE14D0
[init] 00DE14D0
[des] pcount:0 ptr:00DE14D0
[des] delete 00DE14D0
[~ListNode()] 00DE14D0
[des] pcount:0 ptr:00DE10E0
[des] delete 00DE10E0
[~ListNode()] 00DE10E0

image-20230402101147142

但如果想要将这两个节点链接起来,那就出bug了

首先,自然是我们没办法将一个智能指针赋值给普通的指针,因为类型不同。我们也不能直接对它们进行强转

image-20230402101413177

解决这个问题,那就是将listnode中的指针成员也改成智能指针

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
// 链表节点
template<class T>
struct ListNode
{
mu::shared_ptr<ListNode<T>> _prev;
mu::shared_ptr<ListNode<T>> _next;
T _val;

ListNode(const T& val=T())
:_prev(nullptr),
_next(nullptr),
_val(val)
{
cout << "[ListNode()] " << (void*)this << endl;
}

~ListNode()
{
cout << "[~ListNode()] " << (void*)this << endl;
}
};

void test6()
{
mu::shared_ptr<ListNode<int>> p1(new ListNode<int>(10));
mu::shared_ptr<ListNode<int>> p2(new ListNode<int>(20));

p1->_next = p2;
p2->_prev = p1;
}

再次运行,诶,出问题了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[init] 00000000
[init] 00000000
[ListNode()] 00FFF1B8
[init] 00FFF1B8
[init] 00000000
[init] 00000000
[ListNode()] 00FF8D78
[init] 00FF8D78
[operator=] 00000000
[des] pcount:0 ptr:00000000
[operator=] 00000000
[des] pcount:0 ptr:00000000
[des] pcount:1 ptr:00FF8D78
[des] pcount:1 ptr:00FFF1B8

可以看到,虽然shared_ptr的析构函数被调用了,但直到最后,都没有打印出[des] delete,也没有进入ListNode的析构函数,即出现了内存泄漏!

1
2
3
4
5
6
7
8
9
10
11
12
13
void _Release()
{
(*_pcount)--;
cout << "[des] pcount:" << (*_pcount) << " ptr:" << (void*)_ptr << endl;
if ((*_pcount) == 0)
{
cout << "[des] delete " << (void*)_ptr << endl; // 如果释放了资源,会打印这个
if(_ptr)//_ptr不为空的时候才释放它
delete _ptr;
//无论什么时候都需要删除计数器,否则会有内存泄漏
delete _pcount;
}
}

画个图,看看到底是为甚

  • p1管理资源A,p2管理资源B;二者引用计数都为1
  • p1->next=p2,p1->next也开始管理资源B,引用计数为2
  • p2->prev=p1,p2->prev也开始管理资源A,引用计数为2
  • 出作用域,先析构p2,B引用计数-1,此时资源B是只由p1->next管理的
  • 后析构p1,A引用计数-1,此时资源A是只由p2->prev管理
  • 但是p1->next必须要完全析构资源A才会被释放;同理,p2->prev也需要完全析构资源B才会释放

这时候就陷入了一个死循环,因为资源A和资源B实际上已经没有外人在使用了,它们也无法被彻底释放掉,内存泄漏了!

image-20230402102752756

7.3 解决循环引用问题

讲到这里,如何解决这个问题,就很明了了。因为weak_ptr是不进行引用计数的操作的,其只对资源进行托管。我们只需要将listnode之中的指针从shared_ptr改为weak_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
template<class T>
struct ListNode
{
/*mu::shared_ptr<ListNode<T>> _prev;
mu::shared_ptr<ListNode<T>> _next;*/

std::weak_ptr<ListNode<T>> _next;
std::weak_ptr<ListNode<T>> _prev;
T _val;

ListNode(const T& val=T())
:_val(val)
{
cout << "[ListNode()] " << (void*)this << endl;
}

~ListNode()
{
cout << "[~ListNode()] " << (void*)this << endl;
}
};

void test6()
{
std::shared_ptr<ListNode<int>> p1(new ListNode<int>(10));
std::shared_ptr<ListNode<int>> p2(new ListNode<int>(20));

p1->_next = p2;
p2->_prev = p1;
}

可以看到,这时候就能成功释放节点了!

1
2
3
4
[ListNode()] 0117F348
[ListNode()] 01171950
[~ListNode()] 01171950
[~ListNode()] 0117F348

7.4 简单实现

库里面的实现比我们这个复杂很多,实现只是为了理解设计思路

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
template<class T>
class weak_ptr
{
public:
weak_ptr()
:_ptr(nullptr)
{}

//构造
weak_ptr(const shared_ptr<T>& p)
:_ptr(p.get())
{
_ptr;
}
//拷贝构造
weak_ptr(const weak_ptr<T>& p)
:_ptr(p._ptr)
{}
//赋值重载
weak_ptr<T>& operator=(shared_ptr<T>& p)
{
_ptr = p.get();
return *this;
}
weak_ptr<T>& operator=(const weak_ptr<T>& p)
{
_ptr = p._ptr;
return *this;
}


T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T& operator[](const T& n)
{
return _ptr[n];
}

private:
T* _ptr;
};

自己也实现一个简单的weak_ptr,还是用相同的代码进行测试,可以看到,成功进行了析构

1
2
3
4
5
6
7
8
9
10
[ ListNode()] 00C903C8
[init] 00C903C8
[ ListNode()] 00C90128
[init] 00C90128
[des] pcount:0 ptr:00C90128
[des] delete 00C90128
[~ListNode()] 00C90128
[des] pcount:0 ptr:00C903C8
[des] delete 00C903C8
[~ListNode()] 00C903C8

7.5 成员函数

库中实现的weak_ptr的成员函数做一定解释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void swap (weak_ptr& x) noexcept; // 交换两个weak_ptr托管的指针
void reset() noexcept;// 重置 (相当于恢复成默认构造的对象)

// 返回weak_ptr管理的空间的引用计数(本身不计入)
long int use_count() const noexcept;

// 判断weak_ptr是否已经过期(即用于构造这个weak_ptr的shared_ptr是否已经释放资源)
// 因为weak_ptr不参与资源的管理,只进行托管。所以如果shared_ptr已经将空间释放,那么weak_ptr指向的空间就已经过期了,不能继续使用
bool expired() const noexcept;

// 如果weak_ptr没有过期/不为空,则返回其托管的shared_ptr对象
// 等效于返回 expired() ? shared_ptr<T>() : shared_ptr<T>(*this)
// 该函数具有原子性
// https://legacy.cplusplus.com/reference/memory/weak_ptr/lock/
shared_ptr<element_type> lock() const noexcept;

// 下面这两个函数就有点看不懂了
template <class U> bool owner_before (const weak_ptr<U>& x) const;
template <class U> bool owner_before (const shared_ptr<U>& x) const;
//https://blog.csdn.net/weixin_45590473/article/details/113040456

7.6 weak_ptr和引用计数问题

上文对shared_ptrweak_ptr的实现只是最基础的一个处理,还有很多问题没有解决

  • weak_ptr里面真的没有引用计数吗?

std中的weak_ptr有一个函数是expired(),如果weak_ptr内部不存在引用计数,它就没有办法判断托管的对象是否已经过期。所以答案很明确了,weak_ptr里面肯定是有一个引用计数的!

这就会引出另外一个问题,我们上面实现的shared_ptr的代码就包含了这个问题。

  • 如果shared_ptr析构的时候已经将引用计数给delete了,weak_ptr还怎么判断?

其实解决它并不难:继续利用引用计数的思路,给shared_ptr的引用计数再上一个引用计数。这里将share_ptr的引用计数记为count(资源被引用的计数),对count的引用计数记为weak(引用计数的计数);

  • 当shared_ptr出现拷贝的时候,同时操作count和weak(都会加加);
  • 当从shared_ptr拷贝给weak_ptr,或者weak_ptr之间拷贝,给weak加加;
  • 当shared_ptr销毁的时候,同时操作count和weak(都会减减);
  • 当count为0的时候,销毁托管的资源;
  • 当weak为0的时候,销毁count(由weak_ptr和shared_ptr同时来管理);

这时候就需要两把锁了,一个用来锁weak一个用来锁count,weak_ptr在拷贝的时候只操作weak锁,在调用expired()函数的时候会调用count锁来判断count是否为0。

如果是自主实现,可以直接使用cpp里面提供的atmoic原子变量来操作,避免定义多个锁的麻烦。这里可以看看精简后的mvcc源码:https://zhuanlan.zhihu.com/p/680068428

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 引用计数结构体
template <typename Ty>
struct RefCount {
std::atomic_int32_t _uses = 1;
std::atomic_int32_t _weaks = 1;
Ty *_ptr;
};

// shared_ptr和weak_ptr的父类
template <class Ty>
struct Ptr_base {
friend class shared_ptr<Ty>;
friend class weak_ptr<Ty>;
Ty *_ptr = nullptr;
RefCount<Ty> *_ref = nullptr; // 整个都是指针
}

注意到这里,_ptr 存了两份,一个在count里面一个就在ptrBase里面,这是为了方便直接get(),少一次通过RefCount的内存访问。

8.定制删除器

对于我们自己写的shared_ptr,有一个问题就是,析构的时候,默认是写死的delete

如果用户传入的是一个数组new int[10],此时delete就不对应(需要用delete[]),会出现问题(但不一定会报错)

还有些情况,我们想给智能指针传入一个文件指针,此时就不能用delete来进行资源释放了。为了避免这些情况,智能指针引入了定制删除器

8.1 unique_ptr和shared_ptr的不同用法

1
2
3
4
//non-specialized	
template <class T, class D = default_delete<T>> class unique_ptr;
//array specialization
template <class T, class D> class unique_ptr<T[],D>;

比如库中unique_ptr的模板参数中,就有一个模板参数D用于接收用户传入的删除器。而shared_ptr则是采用在构造函数中传入删除器对象的方式来实现定制删除

1
2
3
//with deleter (4)	
template <class U, class D> shared_ptr (U* p, D del);
template <class D> shared_ptr (nullptr_t p, D del);

库中默认的删除器如下

1
2
3
4
//non-specialized	
template <class T> class default_delete;
//array specialization
template <class T> class default_delete<T[]>;

内部采用了重载操作符()的办法,来实现仿函数。默认情况下,使用的是delete,如果指定了是数组类型,则会使用delete[]

image-20230402122158558

如果我们要处理的是文件指针或者malloc的值,就只需要自己传入一个仿函数(定制删除器)即可

8.2 使用

这里我定制了一个free的删除器,和文件指针fclose的删除器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<class T>
struct Free
{
void operator()(T* ptr)
{
cout << "[free] " << ptr << endl;

free(ptr);
}
};

struct Fclose
{
void operator()(FILE* ptr)
{
cout << "[fclose] " << ptr << endl;

fclose(ptr);
}
};

用如下代码进行测试

1
2
3
4
5
6
7
unique_ptr<int, default_delete<int[]>> up1(new int[10]);

unique_ptr<ListNode<int>, default_delete<ListNode<int>[]>> up2(new ListNode<int>[2]);

unique_ptr<FILE, Fclose> up3((FILE*)fopen("test.cpp", "r"));

unique_ptr<ListNode<int>, Free<ListNode<int>>> up4((ListNode<int>*)malloc(sizeof(ListNode<int>)));

可以看到,成功进行了ListNode数组的销毁,以及malloc、文件指针的free、关闭操作

1
2
3
4
5
6
[ ListNode()] 011D5B7C
[ ListNode()] 011D5B88
[free] 011E1070
[fclose] 011D89A8
[~ListNode()] 011D5B88
[~ListNode()] 011D5B7C

需要注意的是,malloc创建的ListNode<int>空间并不会调用ListNode的构造函数,free也不会调用析构函数


因为shared_ptr需要采用类对象的方式在构造函数中进行传参。这方面的底层实现有些复杂。随之而来的好处就是我们可以直接传入一个lambda表达式来作为删除器,避免了代码冗长之后,找不到想要的删除器的定义的问题。

1
2
3
4
5
// shared_ptr需要采用传入对象的方式
default_delete<ListNode<int>[]> d1;
shared_ptr<ListNode<int>> sp1(new ListNode<int>[3], d1);
cout << "###########################" << endl;
shared_ptr<ListNode<int>> sp2(new ListNode<int>[3], [](ListNode<int>* ptr){delete[] ptr; });

运行结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
[ ListNode()] 0127F1BC
[ ListNode()] 0127F1C8
[ ListNode()] 0127F1D4
###########################
[ ListNode()] 01278D4C
[ ListNode()] 01278D58
[ ListNode()] 01278D64
[~ListNode()] 01278D64
[~ListNode()] 01278D58
[~ListNode()] 01278D4C
[~ListNode()] 0127F1D4
[~ListNode()] 0127F1C8
[~ListNode()] 0127F1BC

9.总结

智能指针拷贝特点定制删除器
auto_ptr(C++98)管理权转移。复制后,原有对象失效,使用原有对象会段错误。-
unique_ptr(scpoed_ptr)禁止拷贝,但是可以被move。使用模板参数来传入定制删除器
shared_ptr支持拷贝,采用引用计数,但是会有循环引用问题。在构造函数中传入删除器对象
weak_ptr支持拷贝,只用于托管指针,不参与指针空间的释放,不计入引用计数。用于解决循环引用问题。(注意不计入引用计数不代表没有引用计数)-

The end

智能指针的基本用法到这里就over了,了解智能指针的同时,需要熟知RAII思想。在不少类的设计中,都会用到这个思想。