用现代 C++ 的方法管理内存
希望看过这篇文章的人都可以写出无内存泄漏的 C++ 代码。
吐槽(不用看的废话)
本校开设了基于 C++ 的面向对象课程。可是正如老师说的那样,这门课只教会了大家面向对象(这点存疑),完全没教 C++ 的应用。所以很多人写的其实只是 C with Class 。竞赛类 C++ 使用者尤其严重。在我看来竞赛对语言方面只教授了基础的 C with STL,完全没教 C++ 。于是看了各种名为 .cpp 打开一看都是 `#include <.h>` (注: C++ 的头文件都没有 .h 后缀)的代码后有了这篇文章的诞生。
0. 前言
0.1. 部分解释
本篇代码默认读者使用 C++11 及以后的标准。实际上如果用的不是 VC6 这种老古董,大概都可以运行吧。以下列出 Windows 上我用过的部分工具支持情况:
- MinGW-W64:最新版都支持。
- Visual Studio 系列:从
Visual Studio 2015
开始都支持,如果你用的还是Visual Studio 2010
,我推荐你换一个 IDE。 - LLVM/Clang:最新版都支持。
0.2. 术语表
列出后面的部分术语解释。此处不全,可能会随时间补充。如果有看不懂的地方欢迎评论区讨论,评论不需要注册,填一下信息就可以。
- 实例 大部分面向对象教材应该会叫这个为对象(object)。不过我因为这句描述:“实例化一个类。”,所以比较喜欢用实例这个词。毕竟有句话叫做:“对象就是类的一个实例。”。
1. 初入内存管理
1.1. 利用构造和析构函数
正如陈硕老师在 《Linux 多线程服务器编程》
中提到的,内存方面的问题在 C++
中很容易解决。C++ 在提出构造和析构函数的同时也展示了一种解决内存问题的渠道: 把内存申请放在构造函数中,内存释放操作放在析构函数中。所以解决内存泄漏的第一步先用这个方法看看。
以下是用一个类管理一个 int *
的代码:
class IntPtr {
public:
int *ptr;
IntPtr(int n = 0) {
this->ptr = new int(n); // 申请内存
}
~IntPtr() {
delete this->ptr; // 释放内存
}
};
int main() {
IntPtr i(3);
std::cout << *i.ptr << std::endl; // 解引用对象中的指针
return 0;
}
这样就能保证你在实例化这个类的时候自动申请内存,这个类被销毁的时候自动释放内存了。或者有人说每个类型都写一个类很麻烦。C++ 给我们提供了模板这一特殊又强大的工具,可以把以上的代码修改成这样:
template < typename T >
class TPtr {
public:
T *ptr;
TPtr(T n) {
this->ptr = new T(n);
}
~TPtr() {
delete this->ptr;
}
};
int main() {
TPtr< double > double_ptr(2.5);
std::cout << *double_ptr.ptr << std::endl;
return 0;
}
简单的模板类定义只需要在类的定义前面加上 template < typename T >
这一行,然后把类内所有的 int
替换成 T
就行。然后使用的时候在类名后面加上 < 自己想要的类型 >
就行。比如下面的 TPtr< double > double_ptr(2.5);
。不过或许有人说自己懒得写 <>
,那么还可以定义一下别名:
typedef TPtr< double > DoublePtr; // 定义 TPtr< double > 的别名为 DoublePtr
int main() {
DoublePtr double_ptr(2.5); // 直接使用别名
std::cout << *double_ptr.ptr << std::endl;
return 0;
}
另一种定义别名的 C++ 写法是:
using DoublePtr = TPtr< double >;
标准库也会用定义别名这招,比如
std::string
其实是std::basic_string<char>
的别名,而不是一个真正的 class 。
那么写出相应的申请一个数组的代码也很方便:
class IntArrayPtr {
public:
int *array;
IntArrayPtr(int size) {
this->array = new int[size]; // 申请 size*sizeof(int) 大小的空间,new 操作会自动计算需要的空间
}
~IntArrayPtr() {
delete[] this->array; // 记得用与 new [] 对应的 delete[]
}
};
int main() {
IntArrayPtr int_array_ptr(3);
int_array_ptr.array[0] = 4; // 只是自动分配了内存,还是需要手动初始化的。
std::cout << int_array_ptr.array[0] << std::endl;
return 0;
}
模板版本:
template < typename T >
class TArrayPtr {
public:
T *ptr;
TArrayPtr(int size, T n) {
this->ptr = new T[size];
for (int i = 0; i < size; i++) {
ptr[i] = n;
}
}
~TArrayPtr() {
delete[] this->ptr;
}
};
typedef TArrayPtr<double> DoubleArrayPtr;
1.2. 部分细节
1.2.1. 性能如何(给竞赛的解释)?
模板 template 是在编译期确定类型进行替换的,相当于写了一份指南给编译器,编译器根据这份指南生成代码,所以是运行时无开销。
1.2.2. 为什么用 new/delete
而不是 malloc/free
?
最开始也吐槽了明明是 C++ 代码,结果用了一堆 C 语言的头文件。如果想使用 malloc/free
的话,就需要导入 C 的头文件了(指 stdlib.h/cstdlib
)。不过比较喜欢 malloc
的话也可以用,没什么问题。我是不想写一堆 sizeof()
啦。
1.2.3. 为什么没有判断 ptr
不等于 NULL/nullptr
这一步?
因为 C++ 标准中的 new 规定了一定不会返回空指针,所以这一步判断是完全无用的。 new 分配内存失败是通过抛出异常处理的。所以想判断空指针是这么个写法:
class IntPtr {
public:
int *ptr;
IntPtr(int n = 0) {
this->ptr = new (std::nothrow) int(n); // 调用无异常版本的 new ,这样就会和 malloc 的行为保持一致了
if(ptr== nullptr){
// 内存分配失败。救命啊。
}
}
~IntPtr() {
delete this->ptr;
}
};
另外 new 的异常处理并不是什么地方都需要的。要我说你的程序如果 new 都能抛出异常,那程序基本上没救了。异常处理,处理个屁,赶紧写个日志跑路。
2. 深入内存管理
2.1. 上一部分的错误
上一部分看似美好,实际上经不起实用。我不知道大家喜欢用什么 IDE ,我用的 Clion + cppcheck 疯狂给我提示之前代码的 warning : IntPtr
类没有复制构造函数,也没有处理赋值问题。所以在某些情况(这个情况还非常普遍)下,我们写的代码其实不堪一击:
int function(IntPtr lhs, IntPtr rhs) {
int l = *lhs.ptr;
int r = *rhs.ptr;
return l + r;
}
int main() {
IntPtr i(3);
IntPtr j(4);
std::cout << function(i, j) << std::endl;
return 0;
}
这么一小段人畜无害的代码看起来一切正常,岁月静好。实际上,在你把 i
传递进函数的时候,就执行了一次默认拷贝函数(编译器自动生成的)。默认拷贝函数会把类内的成员直接复制一遍。在 function
函数执行完成的时候,局部变量 lhs
也被析构了——问题出现了: lhs
析构把指针 ptr 指向的地址释放了,那等下在 main
函数中还需要访问 ptr 指向的地址怎么办?外面的 i
再次析构的时候会发生什么?我可以明确地说在我的实验中会触发重复释放内存的段错误,但是错误号我忘记了。
2.2. C++11 之后的内存管理方法: <memory>
头文件
那怎么办?把所有用到函数的地方都改成传递引用吗?如果需要函数返回一个 IntPtr
实例怎么办?这时候我们需要掏出标准库。如果你仔细观察,会发现我们写的内存管理类代码都比较重复,而且都比较简单。标准库就给我们提供了好用的内存管理工具,让我们不需要自己写内存管理类了。使用这些工具首先需要导入头文件:
#include <memory>
之后我们就获得了三个好用的智能指针: unique_ptr
,shared_ptr
,weak_ptr
。
C++ 智能指针底层是采用引用计数的方式实现的。简单的理解,智能指针在申请堆内存空间的同时,会为其配备一个整形值(初始值为 1),每当有新对象使用此堆内存时,该整形值 +1;反之,每当使用此堆内存的对象被释放时,该整形值减 1。当堆空间对应的整形值为 0 时,即表明不再有对象使用它,该堆空间就会被释放掉。
这里重点讲 shared_ptr
。
基础的 shared_ptr
使用方法是:
int *temp = new int(2); // 分配内存
std::shared_ptr< int > ptr(temp); // 生成智能指针 shared_ptr
std::cout << *ptr << std::endl; // 和普通指针一样使用 shared_ptr
std::shared_ptr< int > ptr2;
ptr2 = ptr; // 注意这里必须要用等于号初始化
*ptr2 = 10; // 修改 ptr2 的值,会反映到 ptr 上
std::cout << *ptr << std::endl;
注意,同一普通指针不能同时为多个 shared_ptr 实例赋值,否则会导致程序发生异常。例如:
int* ptr = new int;
std::shared_ptr<int> p1(ptr);
std::shared_ptr<int> p2(ptr); // 错误
紧凑点还可以这样写:
std::shared_ptr< int > ptr(new int(2)); // 分配完内存直接生成智能指针
顺便还能避免上面那个多次赋值的问题。
因为 shared_ptr
默认使用了 delect
而不是 delect[]
,用 new[]
分配的内存得不到正确释放。所以在申请动态数组的时候稍微有些不同,可以借助 lambda 表达式也可以用传统方法:
// 借助标准库给的默认 default_delete
std::shared_ptr< int > array(new int[10], std::default_delete< int[] >());
// 自定义释放规则
void deleteInt(int*p) {
delete []p;
}
// 初始化智能指针,并在第二个参数位置传递函数指针,自定义释放规则
std::shared_ptr< int > array(new int[10], deleteInt);
// 借助 lambda 表达式
std::shared_ptr< int > array(new int[10], [](int *p) { delete[] p; });
lambda 表达式是 C++11 中引入的一项新技术,利用 lambda 表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象,并且使代码更可读。但是从本质上来讲,lambda 表达式只是一种语法糖,因为所有其能完成的工作都可以用其它稍微复杂的代码来实现。但是它简便的语法却给 C++带来了深远的影响。如果从广义上说,lamdba 表达式产生的是函数对象。在类中,可以重载函数调用运算符(),此时类的对象可以将具有类似函数的行为,我们称这些对象为函数对象(Function Object)或者仿函数(Functor)。
更多的介绍和用法有不少帖子比我讲得好,我推荐去看那些帖子。其实是我懒得复制。
扩展阅读:
shared_ptr
lambda 表达式
2.3. 部分细节
2.3.1. 性能如何(给竞赛的解释)?
以下为 GCC 编译器的报告。
在多线程环境中因为 shared_ptr
还有一个很重要的功能是多线程编程下的安全,所以 shared_ptr
本身是用了原子操作保证线程安全。这里的原子操作是用户应用程序直接调用硬件提供的原子操作实现的,比需要切换到内核态的各种锁操作开销更少。讲人话就是几乎没什么额外开销,就是编译器不能把这个整型优化成一个寄存器而已。具体可以参考各种多线程无锁数据结构的实现。我都不知道本校操作系统课程上不上程序的内核态和用户态切换这部分。
注:原子操作需要硬件平台支持,如果硬件平台不支持原子操作,那么会使用互斥锁来保证线程安全。主要不支持原子操作的硬件平台有 i386 ,ARMv6 及之前。
如果你写的一直是单线程代码,GCC 编译器不会在 shared_ptr
中使用原子操作,所以开销和多了个普通整型没什么区别。
如果开销非常非常敏感,请使用我没有讲到的 unique_ptr
。
2.3.2. 为什么我有时候在一个函数直接 return
了一个 IntPtr
实例也没有触发 bug?
这是因为你触发了 RVO(Return Value Optimization) 返回值 优化。RVO 即“Return Value Optimization”,是一种编译器优化技术,通过该技术编译器可以减少函数返回时生成临时值值(对象)的个数,从某种程度上可以提高程序的运行效率,对需要分配大量内存的类对象其值复制过程十分友好。来自 Google 搜索第一条。讲人话就是编译器把函数返回实例那一步需要调用的复制构造函数优化掉了。没有复制 IntPtr
实例的话就不会触发 bug。
但是指望编译器修复自己写的代码的bug不是什么好选择!请永远记住这点!不要依靠不确定行为来给不正确的代码修补漏洞!
3. 附录
3.1. 参考资料
本作品采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。