C++智能指针¶
约 3430 个字 485 行代码 4 张图片 预计阅读时间 17 分钟
RAII¶
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
- 不需要显式地释放资源。
- 采用这种方式,对象所需的资源在其生命期内始终保持有效
在C++中,auto_ptr
、unique_ptr
以及shared_ptr
都遵循RAII原则,但是weak_ptr
因为不直接管理空间,所以不遵循RAII原则
基本使用¶
在C++ 11标准中,一共有三种常用的智能指针,分别是unique_ptr
、shared_ptr
和weak_ptr
,下面是其三个的基本特性:
unique_ptr
:- 基本介绍:C++ 11中的
unique_ptr
的前身是C++ 扩展库boost中scope_ptr
/scope_array
,因为unique_ptr
是一个模版类型,所以需要传递指针指向对象的类型作为模版参数,构造时可以使用普通指针进行构造,也可以直接在构造处通过new
开辟空间 - 开辟连续空间的问题:默认情况下只能指向一个内存空间,如果开辟连续的空间,因为
unique_ptr
底层默认使用的是delete
,所以需要额外给一个自定义的删除器 - 拷贝和赋值问题:
unique_ptr
不支持赋值构造和拷贝构造,所以不可以使用unique_ptr
对象对一个新的unique_ptr
进行构造;当unique_ptr
指针销毁时,会自动调用析构函数销毁指针指向的内容;unique_ptr
不支持所有权的隐式转移;必须使用std::move
显式转移所有权 - 拷贝和赋值的例外:可以对一个即将销毁的
unique_ptr
对象进行拷贝和赋值,最常见的例子就是将unique_ptr
对象作为返回值(直接返回unique_ptr
对象或者返回unique_ptr
局部对象)
- 基本介绍:C++ 11中的
shared_ptr
:C++ 11中的shared_ptr
的前身是C++扩展库boost中shared_ptr
,基本使用方法与unique_ptr
基本一致,但是shared_ptr
可以支持赋值构造和拷贝构造,所以如果涉及到指针需要拷贝的情况,就可以考虑使用shared_ptr
weak_ptr
:C++ 11中新增的智能指针,用于解决shared_ptr
循环引用的问题
以下面的结构测试各种智能指针:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
基本使用如下:
C++ | |
---|---|
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 |
|
C++ | |
---|---|
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 |
|
除了使用shared_ptr
的构造函数以外,shared_ptr
还支持使用make_shared
创建shared_ptr
,make_shared
是一个可变模版参数函数模版,该函数会在堆上根据指定类型的开辟空间,并返回一个shared_ptr
,因为支持可变模版参数,所以可以传递构造对象的值,但是需要注意make_shared
模版参数需要与接收的shared_ptr
模版参数一致,make_shared
在头文件<memory>
中,使用时可以考虑引入
C++ | |
---|---|
1 2 3 4 5 6 7 8 |
|
对于unique_ptr
存在对应的make_unique
函数创建unique_ptr
对象,使用方式与shared_ptr
一样,此处不再演示
需要注意的是,shared_ptr
不支持通过原始指针类型隐式转换为一个同类型的shared_ptr
,因为该构造函数是被explicit
关键字所修饰的,但是可以使用原始指针创建一个shared_ptr
:
C++ | |
---|---|
1 2 3 4 5 6 7 8 |
|
另外需要注意的是,默认情况下shared_ptr
在析构时会调用delete
释放指向的空间,所以如果是在栈上创建的对象,一定要提供自定义删除器(这个删除器可以是对一些资源的释放)
unique_ptr
和shared_ptr
原理与模拟实现¶
unique_ptr
原理与模拟实现¶
原理:unique_ptr
本质是使用一个普通指针进行构造,给出了基本的指针操作,并且默认情况下会释放指针指向的单一空间,特点是将拷贝构造函数和赋值运算符重载函数修饰为=delete
基本结构模拟实现如下:
C++ | |
---|---|
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 |
|
shared_ptr
原理与模拟实现¶
原理:因为shared_ptr
支持拷贝,但是如果默认拷贝和赋值,则会出现同一块空间被释放两次导致的错误,所以在shared_ptr
中还存在一个引用计数,用于记录有多少个shared_ptr
指向同一块空间
基本结构模拟实现如下:
基本结构设计¶
对于构造shared_ptr
指针来说,与unique_ptr
一致,但是对于引用计数来说,需要注意不可以使用普通的成员变量直接进行计数,因为这种方法会导致不论是创建对象、拷贝还是赋值,每一个对象中的引用变量始终为1或者0,也不可以使用静态成员变量进行计数,考虑到静态变量是所有对象够用一份,当两个指针指向同一块空间时没有任何问题,此时计数器为2,但是如果有一个新的指针指向另一块空间,此时计数器更新为3,但是前面的两个指针和第三个指针不是指向同一块空间,不应该更改前面两个指针的计数器。两种情况分析如下图所示:
考虑到以上两种情况后,设计的计数器需要满足两个条件:1. 指向同一块空间的shared_ptr
共用一个引用计数器 2. 指向不同空间的shared_ptr
使用不同的引用计数器。可以使用在堆上开辟内存的计数器,成员变量只需要一个指针,指向堆上已经开辟好的计数器即可,如果有指向同一块空间的另一个shared_ptr
时,只需要使其指针指向相同的计数器空间即可,初始情况下引用计数器的数值为1
析构函数和构造函数设计¶
设计构造函数时,只需要考虑两点:1. 使用普通指针构造shared_ptr
2. 将计数器指针指向在堆上开辟的空间
设计析构函数时,需要考虑到何时释放空间的问题,如果有两个指针指向同一块空间,则需要避免出现两次释放同一块空间的问题,所以可以考虑析构第一个指针时将该指针置为空并且计数器减小1
拷贝构造函数设计¶
设计拷贝构造函数只需要考虑将原来指针中的内容拷贝到新指针,再将计数器加1即可
赋值运算符重载函数设计¶
设计赋值重载需要考虑到下面的问题:
- 自己给自己赋值,包括两种情况:1. 两个相同的指针 2. 指向同一个位置的两个指针
- 赋值之前需要先释放被赋值的指针原来的空间,否则会出现内存泄漏问题(原因借下面的图分析)
- 赋值需要更改用于赋值的指针的引用计数器
具体步骤如下图所示:
C++ | |
---|---|
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 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
|
添加可自定义删除器功能¶
与unique_ptr
不同的是,shared_ptr
的自定义删除器是在构造对象参数列表中传递,并且可以使用任意函数对象,所以可以考虑使用function
包装器对象作为成员(release
函数中调用,因为没有模版参数,所以不可以直接使用构造函数中的模版参数对象),默认情况下使用delete
Note
需要注意,因为需要默认情况下使用delete
,所以需要使在没有传递自定义删除器时调用delete
函数,此时lambda需要写在成员的后面,而不是构造函数关于自定义删除器的缺省参数,便于两个构造函数都可以初始化_del
成员
C++ | |
---|---|
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 |
|
数组类型特化版本¶
如果需要满足这样的调用:SharedPtr<Date[]> sp2(new Date[5], [](Date* ptr) { delete[] ptr; });
,上面的代码就存在一定的缺陷,因为数组指针并不是普通的指针,可以考虑对数组指针版本进行特化处理,可以参考下面的代码:
C++ | |
---|---|
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 76 77 78 79 80 |
|
T[]
是为了告诉编译器这是数组类型,而不是普通的T*
,如果将Date[]
直接赋值给T*
,此时编译器会认为是Date*
,导致因为Date[]
衰减成了Date*
丢失数组大小信息(这一过程称为数组衰减) shared_ptr
循环引用问题与weak_ptr
的使用¶
以下面的代码为例:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
上面的代码会出现内存泄漏的问题,首先分析内存泄漏出现的原因:
首先p2
指针析构,因为p1
指针的_next
成员与p2
指针共同管理一片空间,所以此时该空间的引用计数器由2变为1,p2
指针置为空,接着析构p1
指针,因为p2
指针的_prev
成员与p1
指针共同管理一片空间,所以此时该空间的引用计数器由2变为1,p1
指针置为空,接着程序结束。此时虽然p1
和p2
两个指针都已经被释放了,但是实际上因为还有_next
和_prev
指针管理空间导致这两片空间依旧没有被释放,从而造成内存泄漏
循环引用问题:
- 当
_next
指针需要析构时,需要p1
指针指向的空间析构 - 当
_prev
指针需要析构时,需要p2
指针指向的空间析构
......
如下图所示:
为了解决shared_ptr
的循环引用问题,可以将ListNode
的_next
和_prev
指针改为weak_ptr
,因为weak_ptr
支持使用shared_ptr
构造并且weak_ptr
不会增加shared_ptr
的引用计数,所以此时析构p1
和p2
时就可以直接进行析构而不会因为相互依赖导致的循环引用问题,参考代码如下:
C++ | |
---|---|
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 |
|
需要注意,weak_ptr
不可以单独用于管理对象,例如下面的代码:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 |
|
因为weak_ptr
本身不会影响由shared_ptr
维护的引用计数,所以weak_ptr
的基本结构类似下面的代码:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
并且当最后一个shared_ptr
对象离开作用域或被设置为无效时,它指向的对象才会被删除,即使此时仍然存在 weak_ptr
指向该对象,除非使用lock()
函数,例如下面的代码中
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
智能指针的前身:auto_ptr
¶
C++ 98时没有前面类型的智能指针,但是可以使用auto_ptr
达到基本一致的效果,但是auto_ptr
本身的原理是一种管理权转移的思想,所以存在已经将管理权转移,但是依旧使用被转移资源的指针进行内容访问导致的空指针解引用问题,所以为了更好地管理,auto_ptr
基本上被unique_ptr
和shared_ptr
代替
C++ | |
---|---|
1 2 3 4 5 |
|
内存泄漏问题¶
内存泄漏:指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费
内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死
例如下面的代码:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
检查内存溢出情况(仅供参考):
内存泄漏的分类¶
C/C++程序中一般我们关心两种方面的内存泄漏:
- 堆内存泄漏(Heap leak) :堆内存指的是程序执行中依据须要分配通过
malloc
/calloc
/realloc
/new
等从堆中分配的一块内存,用完后必须通过调用相应的free
或者delete
删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。 - 系统资源泄漏:指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。
避免内存泄漏¶
-
工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。
Note
这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
-
采用RAII思想或者智能指针来管理资源。
-
有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
-
出问题了使用内存泄漏工具检测。
Note
不过很多工具都不够靠谱,或者收费昂贵。
内存泄漏非常常见,解决方案分为两种:
- 事前预防型,如智能指针等
- 事后查错型,如泄漏检测工具