关于智能指针的学习漫游
一类事物的存在必有其意义,作为C++ 11标准中新出现的智能指针也不例外。指针是C/C++的灵魂,它的存在使得 C/C++ coder能够灵活的对内存进行操作,但也随之带来风险,即内存如何管理。
本文会提及智能指针的使用,但重点并不在这里,而是尽量利用我有限的知识储备来讨论有关基本的内存管理和智能指针的有关知识,我会尽量使用简单易懂的代码例子来帮助大家理解。若有错误,请直接指出,不胜感激。
声明:同时本文性质不是教程,正如标题,属于学习C++过程中思维运作过程的具象化,而思维是具有发散性的,因此文章的内容组织方面会相对混乱。
指针与内存
**为什么需要管理内存呢?**内存是计算机的工作台,进程在内存中才能跑起来。假如你正在一张工作桌上工作,整洁干净的桌面会直接间接的影响你的工作效率,反之,若你的桌上满是杂乱的物品,你只能蜷缩在一个角落工作,一般而言,工作效率将会下降,内存之于计算机也是如此。当然这里只是提及了与内存相关的一个因素。
前面已经提及,C/C++的灵魂是指针。如果你尝试过Java语言,你必然会发现,里面的指针是透明的,取而代之的是引用的概念,该语言的内存管理机制大体采用了名为垃圾回收(Garbage Collection)的机制,当然如果你用过Unreal Engine进行C++开发,相信对于GC这一概念你并不陌生。垃圾回收的基本方法为标记清除和引用计数,在这里不过多讨论,只是提及,感兴趣的话可以进一步研究。
总之对于C/C++程序员,内存管理成为了我们的工作。
指针基础回顾
对于指针,对于一般初学者,至少在我的学习历程中,指针这概念相当模糊,究其原因,C语言是接近底层的高级语言,其涉及到诸如内存等底层计算机工作原理。在我初学指针时,只是停留在如何使用指针的层面,只是照葫芦画瓢罢了。
先简单回顾一下创建指针的方法,很简单,声明一下即可,如下:int* ptr;
几个有意思的点:
指针类型。我们知道,不同数据类型,如int、double、float的大小可能不同,但如果是对应类型的指针大小呢?我们知道指针本质是内存地址,直觉上我们认为地址位数是一致的,你可以尝试对它们进行sizeof,你会发现结果都是一样的,*使得int不再是int类型,而是指针类型。
野指针。如果你只是简单的声明一下指针,不初始化它,尝试去打印它,你会得到莫名其妙的值(有的编译器会发出警告),这是因为指针不知道它指向哪个具体的地方,于是它的指向也就不可知了,这是很危险的事情。该现象就是所谓的野指针(wild pointer),为避免此类风险,当你暂时不想设置具体的指向时,请将它置为空指针(NULL/nullptr)
关于空指针。空指针不是字面意思上的空,而是指向一个零地址的指针。为定义一个空指针,在C中,我们通常使用NULL; 在C++中,我们通常使用nullptr。那么二者有什么区别。
先考虑NULL,在C和C++中,NULL都以宏的方式存在,但存在差异
C:
#define NULL (void*)0
C++:
#define NULL 0
在C、C++中NULL内容上都是0,但类型上C是指针类型,而C++是整型。因此在C++中,当你尝试以NULL作为实参进行传递,当函数形参为指针类型时,你会发现你可能无法通过编译,需要进行类型转换。
再考虑nullptr,nullptr是C++11提供的字面量,其类型为std::nullptr_t,用于表示空指针
为了明确指针的指向,我们可以将其他变量的地址赋予它,或是直接为它动态分配一个地址空间。
malloc/free
在C语言中,我们利用malloc函数来分配内存,此时在堆(heap)上就会开辟一个空间,指针便有了指向,同时我们利用free函数来释放不需要的空间
你有没有考虑过free之后的指针,尝试打印free前后的指针并打印它指向的内容并作对比,你会发现得到的结果很奇怪:free前后,指针指向依然是同一块地址,但内容却截然不同,这是悬空指针(dangling pointer),具体原因本文不过多讲解(太菜了),许多编译器不会对悬空指针问题进行提示,所以为了更轻松愉快的debug,请在free后置NULL
new/delete
在C++中,我们通常使用new运算符动态分配空间,用delete运算符释放空间。
值得注意的是,当我们利用new创建一个数组时,需要注意delete的用法。
int* array = new int[5];
delete[] array;
//delete array; mistake!
上述代码提及了两种删除数组的语句,其中后者是错误的删除方法,编译器并不知道它删除的是数组,会带来不可预知的结果。所以删除数组时,用delete[]
这里再扩展一下,由于曾被人以“new和malloc的区别”这一问题问倒,难受。所以这里再讨论一下new和malloc的区别,同时大家可积极补充。
注意,由于new/delete和malloc/free确实存在各种差异,因此尽量避免混合使用
new是一个运算符,它会去调用operator new,malloc是一个函数
void* malloc (size_t size)
。另外提一句,作为运算符的new可以重载new类型安全,malloc()类型不安全(强制类型转换会带来隐患)
new不能改变分配的内存大小,而malloc()可以。因此如果实在需要修改大小的话,可以选择最直接的方法,直接分配另一个空间,再拷贝一下。
关于分配错误时的返回值,malloc()动态分配空间时,若发生错误,如空间不够,会返回空指针,而new不同,当它在分配空间时发生错误,并不返回空指针,而是抛出异常:bad_alloc
C++是面向对象语言,new一个对象会调用类的构造方法,malloc()则不会。delete和free亦是如此
new从自由存储区(free store)动态分配空间,而malloc()从堆动态分配空间。此类陈述在许多教科书和博客或教程中提到,但关于free store和heap的区别,就我搜集的资料,始终是模棱两可的说法。由于个人水平有限,深究区别必然涉及到我的知识盲区,因此本文不再进行讨论。
但一个现有的说法是,有很多通过利用heap来实现free store的做法。对象在free store的生命周期,可以在没有立即初始化的情况下分配内存,在没有立即释放内存的情况下被销毁,可视为区间内部。
当我第一次用GDB去追溯new操作符的执行过程,一个很熟悉的关键字出现了——malloc,这不免让我猜测,new本质调用了malloc()? 通过查询,一些编译器确实通过malloc/free实现global new/delete。
指针管理不当所带来的危害
一般的内存问题包括:内存泄漏、越界、悬空指针、野指针等
内存泄漏
下面介绍几类常见的内存泄漏现象。
那么指针为什么容易使用不当?使用不当又如何容易造成危害?
指针的使用分为三个阶段:初始化,使用,回收。指针的使用不当,通常发生在回收时,申请了内存却没有释放内存,此类问题称为内存泄漏(memory leak)。内存是有限的,不断消耗内存资源,其结果可想而知,crash。
内存泄漏一般难以发现,因此本质上它并不是错误而是缺陷(隐患),为检测内存泄漏问题,我们可以利用Valgrind等内存泄漏检测工具来进行检测。
忘记delete
这是一个最容易想到的情况,下面看一段代码
//Foo是一个类名
void memLeakCase1()
{
Foo* inst = new Foo();
//使用指针
}
当调用memLeakCase1()
时,由于最后没有delete掉指针,犹如手滑让一根针落入海底,你再也找不着这个指针了,该内存资源也就浪费了。如果你的直觉告诉你,在该函数结束时会自动回收资源,那么请在类中写个可以打印信息的析构函数或调试一下,看看对象的析构函数有没有被调用。遗憾的说,没有。
也许你会永远牢记在new后立即写下delete,但凭此并不能避免内存泄漏
在delete前发生异常
void memLeakCase2()
{
Foo* inst = new Foo();
throw "simulate exception event";
delete inst;
}
也许你没有忘记delete,但若在中间发生了异常等问题,那么会直接跳过delete操作
继承带来的内存泄漏
class Base{
public:
~Base(){std::cout << "Base destruct" << std::endl;}
};
class Child:public Base{
public:
~Child(){std::cout << "Child destruct" << std::endl;}
};
void memLeakCase3()
{
Base* foo = new Child();
delete foo;
}
请先预测一下,以上代码运行会输出什么样的结果。熟悉继承时的析构顺序的程序员,有人以为是先调用子类析构再调用基类析构,那么请自行执行一下,结果是只调用了Base的析构函数。这会导致一个问题,即子类析构并没有调用,析构的作用是对创建的对象进行收尾工作。
熟悉RAII设计原则的人会在析构时期进行资源回收操作,若子类声明了大量的指针,但却因为没有执行析构而导致没有delete掉这些指针,那么内存泄漏!
还望见谅,本文不探究其内部原因,仅提出该问题的解决办法——将基类的析构换成虚析构函数
重复释放
所谓重复释放(double free),即对同一内存空间进行多次释放。对此,我围绕两个讨论点,即重复释放有什么危害,以及为什么会有重复释放的现象。
重复释放属于未定义行为,由于首次delete已经释放资源,因此再次delete其结果不得而知,可能视内存是否会被复用的情况而有不同的结果。所谓undefined behavior并非我们想要看到的。
大多数人会习惯于在delete后立即赋nullptr,毕竟delete nullptr
没有任何危害。在这里,有一点值得注意,double free本身是代码逻辑的问题,nullptr处理是应急措施,在相关博客漫游的途中,看到了独特的观点,即nullptr处理会掩盖错误,不nullptr处理可以提前暴露问题。
如果在项目中想要避免此类问题,最简单直接的方法依然是使用智能指针(进入modern c++)。
智能指针
内存管理一直是一件难事,而智能指针正是内存管理的利器。
首先在C++11标准中,有多种具有不同特性的指针,其中最常见的为shared_ptr
和unique_ptr
首先两者虽称为智能指针,但本质上都不是指针,而是类模版
shared_ptr
此种指针最大的特性是所有权的共享,多个shared_ptr
可以共同拥有同一个对象,简单来说就是当你使用了一个shared_ptr
指向一个内存,你还可以用另一个shared_ptr
指向那个内存
为维持共享性,智能指针通过引用计数的方式进行控制,引用计数器的值表示指向某一内存的shared_ptr
的数量,引用计数值会不断变化。当然对引用计数器值修改自然会联想到线程安全问题,不过智能指针已经提供了相关机制来满足线程安全。
智能指针之所以被称为智能,是因为它能够自动进行内存管理,当引用计数器的值为0时,其指向的内存会自动释放。(对最后一个shared_ptr进行销毁或更改其指向会使得count = 0)
shared_ptr
拥有两个指针:指向存储的数据的指针和指向control block的指针
control block,控制块是动态分配对象,包含shared_ptr
的部分信息,包括deleter
和allocator
,以及shared_ptrs
和weak_ptrs
的引用次数的值(注:此处加的复数s表示多个智能指针的引用)
对于shared_ptr
的原始指针(raw pointer)和引用计数器(count),通过get()
方法可以得到raw pointer,通过use_count()
方法可以查看count。
下面介绍几个常见的操作
//初始化可通过new,但推荐用专属make_shared来创建
std::shared_ptr<int> s_ptr = std::make_shared<int>(value);//创建指向内容为value的智能指针
//智能指针重载->和*运算符,操作与常规指针类似
int x = *s_ptr;
//Foo是类
std::shared_ptr<Foo> foo_ptr = std::make_shared<Foo>();
foo_ptr->someMethod();//调用Foo::someMethod()方法
同时,智能指针可以指定删除器(deleter)来完成特定的收尾工作,如socket的关闭或是gui的关闭
//这里利用了lambda表达式和std::function来定义deleter
using deleter_type = std::function<void(int *)>;
deleter_type deleter = [](int *p){ std::cout << "just meaningless"; };
std::shared_ptr<int> p(new int(2), deleter);//由于make_shared并未提供指定deleter的方法,这里通过构造方法指定
unique_ptr
unique_ptr的特性从字面意思上可以看出,其对对象所有权是唯一的,如果有多个unique_ptr指向同一内存,将会编译失败。另外,有一个小技巧,当以const修饰符赋予unique_ptr
只读属性,类比指针常量,其地址不可改变,对于某一对象的所有关系自然也无法改变,其生命周期自然也就限制在了其作用域内
智能指针操作大同小异,简单介绍unique_ptr的创建操作
std::unique_ptr<int> s_ptr = std::make_unique<int>(value);
unique_ptr与shared_ptr的小差异
除主要特性外,两种智能指针在细节方面存在差异
- 继承与删除器:在内存泄漏中曾提及继承与析构的问题,这里类似,假设
T
继承基类B
,当unique_ptr<T>
隐式类型转换为unique_ptr<B>
,同时基类B的析构函数为no-virtual
时,默认删除器将调用B的析构函数,从而导致内存泄漏。但对于shared_ptr
而言,将会安全的删除干净 - 性能方面:结论是
unique_ptr
性能胜于shared_ptr
。首先最直观的层面是大小,unique_ptr的大小和裸指针大小一致,而shared_ptr的体积更大,究其原因是shared_ptr有两个指针,一是指向管理的数据,二是control block
References
[1] https://en.cppreference.com/
[2]Effective C++: 55 Specific Ways to Improve Your Programs and Designs 3rd\
[3]http://www.gotw.ca/gotw/009.htm