0、摘要
本文先讲了智能指针存在之前C++面临的窘境,并顺理成章地引出利用RAII技术封装普通指针从而诞生了智能指针,然后以示例代码的形式讲解了三种智能指针的基本用法。为了更好地理解引用计数形式实现的智能指针,本文提供了实现一个简单版本的智能指针的方法,并讨论了引用计数形式的缺点。最后,本文讨论了使用智能指针应当注意的事项,包括shared_ptr 的循环引用问题等三个事项。
[if !supportLists]1、[endif]智能指针的前世今生
在智能指针出现以前,我们通常使用new 和 delete 来管理动态分配的内存,但这种方式存在几个常见的问题:
忘记delete 内存:会导致内存泄漏问题,且除非是内存耗尽否则很难检测到这种错误。
使用已经释放掉的对象:如果能够记得在释放掉内存后将指针置空并在下次使用前判空,尚可避免这种错误。
同一块内存释放两次:如果有两个指针指向相同的动态分配对象,则很容易发生这种错误。
发生异常时的内存泄漏:若在new 和 delete 之间发生异常,则会导致内存泄漏。
制造出这些错误很容易,但查找和修正这些错误就困难的多。于是,我们就要考虑如何从根本上克服这种弊端,不制造出这些错误。动态分配的内存是C++ 中最常使用的资源,所谓资源就是,一旦用了它,将来必须还给系统,否则就会发生糟糕的事情。所以,我们就要考虑如何更好地进行资源管理,来保证资源的有借必有还。
不难想到,资源管理技术的关键在于:要保证资源的释放顺序与获取顺序严格相反。这自然使我们联想到局部对象的创建和销毁过程。在C++中,定义在栈空间上的局部对象称为自动存储对象。管理局部对象的任务非常简单,因为它们的创建和销毁工作是由系统自动完成的。我们只需在某个作用域中定义局部对象(这时系统自动调用构造函数以创建对象),然后就可以放心大胆地使用之,而不必担心有关善后工作;当控制流程超出这个作用域的范围时,系统会自动调用析构函数,从而销毁该对象。
如果系统中的资源也具有如同局部对象一样的特性,自动获取,自动释放,那该多么美妙啊!既然类是C++中的主要抽象工具,那么就将资源抽象为类,用局部对象来表示资源,把管理资源的任务转化为管理局部对象的任务。把资源放进对象内,用资源来管理对象,便是 C++ 编程中最重要的编程技法之一,即 RAII ,它是 "Resource Acquisition Is Initialization" 的首字母缩写。智能指针便是利用 RAII 的技术对普通的指针进行封装,这使得智能指针实质是一个对象,行为表现的却像一个指针。
综上所述,RAII的本质内容是用对象代表资源,把管理资源的任务转化为管理对象的任务,将资源的获取和释放与对象的构造和析构对应起来,从而确保在对象的生存期内资源始终有效,对象销毁时资源必被释放。换句话说,拥有对象就等于拥有资源,对象存在则资源必定存在。由此可见,RAII是进行资源管理的有力武器。C++程序员依靠RAII写出的代码不仅简洁优雅,而且做到了异常安全。难怪微软的MSDN杂志在最近的一篇文章中承认:“若论资源管理,谁也比不过标准C++”。
说到这里,顺便diss 一下 Java 的 GC 机制,表面来看,Java 似乎更优秀,因为从一开始你就不用考虑什么特殊的机制,大胆地往前 new ,自有 GC 替你收拾残局。 Java 的 GC 实际上是 JVM 中的一个独立线程,采用不同的算法策略来收集堆中那些不再有引用指向的垃圾对象所占用的内存。但是,通常情况下,GC 线程的优先级比较低,只有在当前程序空闲的时候才会被调度,收集垃圾。当然,如果 JVM 感到内存紧张了,JVM 会主动调用 GC 来收集垃圾,获取更多的内存。请注意,Java 的 GC 工作的时机是:1. 当前程序不忙,有空闲时间。2. 空闲内存不足。现在我们考虑一种常见的情况,程序在紧张运行之中,没有空闲时间给 GC 来运行,同时机器内存很大,JVM 也没有感到内存不足,结果是什么?对了 ,GC 形同虚设,得不到调用。于是,内存被不断吞噬,而那些早已经用不着的垃圾对象仍在在宝贵的内存里睡大觉。
反过来看看C++ 利用智能指针达成的效果,一旦某对象不再被引用,系统刻不容缓,立刻回收内存。这通常发生在关键任务完成后的清理时期,不会影响关键任务的实时性,同时,内存里所有的对象都是有用的,绝对没有垃圾空占内存。
既然智能指针有如此多的好处,那我们还等什么,赶紧来学学它的用法吧!
[if !supportLists]2、[endif]智能指针的基本语法
C++11 中提供了三种智能指针,分别是 shared_ptr , unique_ptr 和 weak_ptr 。shared_ptr 允许多个指针指向同一个对象,unique_ptr 则“独占”所指向的对象,weak_ptr 则是和share_ptr 相辅相成的伴随类,具体用法后文细说。
这三种类型都定义在头文件memory中。类似vector,智能指针也是模板,需要在尖括号内给出类型信息。shared_ptr 和 unique_ptr 的使用方式和普通指针类似,都可以使用*和->等运算符。
关于基本语法,读完下面这段代码,你一定会了然于胸的。
以上代码的输出结果为:
为了更进一步透彻地理解智能指针的基本原理,我们有必要实现一个简单版本的智能指针(shared_ptr)来辅助理解。
[if !supportLists]3、[endif]自己实现一个简单的智能指针
智能指针(shared_ptr)能够自动释放所指向的对象,其实现原理却并不复杂。简单一说:
智能指针将一个计数器与类指向的对象相关联,引用计数跟踪共有多少个类对象共享同一指针。
每次创建类的新对象时,初始化指针并将引用计数置为1。
当对象作为另一对象的副本而创建时,拷贝构造函数拷贝指针并增加与之相应的引用计数。
对一个对象进行赋值时,赋值操作符减少左操作数所指对象的引用计数(如果引用计数为减至0,则删除对象),并增加右操作数所指对象的引用计数;这是因为左侧的指针指向了右侧指针所指向的对象,因此右指针所指向的对象的引用计数加1。
调用析构函数时,构造函数减少引用计数(如果引用计数减至0,则删除基础对象)。
下面是一个基于引用计数的智能指针的实现,需要实现构造,析构,拷贝构造,=操作符重载,重载*和->操作符。
这个智能指针的简单实现模仿的是share_ptr 的行为,不难发现,引用计数的存在会带来一些性能影响:
shared_ptr 的尺寸是裸指针的两倍:因为内部既包含一个指向该资源的裸指针,也包含一个指向该资源的引用计数的裸指针。
引用计数的内存必须动态分配
引用计数的递增和递减必须是原子操作:原子操作一般比非原子操作慢。我们的实现版本里为了简单起见没有实现原子操作。
[if !supportLists]4、[endif]使用智能指针的一些注意事项
4.1 shared_ptr 的循环引用问题
shared_ptr 意味着你的引用和原对象是一个强联系。你的引用不解开,原对象就不能销毁。滥用强联系,这在一个运行时间长、规模比较大,或者是资源较为紧缺的系统中,极易造成隐性的内存泄漏,这会成为一个灾难性的问题。
更糟的是,滥用强联系可能造成循环引用的灾难。即:B持有指向A内成员的一个shared_ptr,A也持有指向B内成员的一个 shared_ptr,此时A和B的生命周期互相由对方决定,事实上都无法从内存中销毁。 更进一步,循环引用不只是两方的情况,只要引用链成环都会出现问题。
举个循环引用的简单例子。
如此一来,A和B都互相指着对方吼,“放开我的引用!“,“你先发我的我就放你的!”,于是悲剧发生了,内存泄漏了。当然循环引用本身就说明设计上可能存在一些问题,如果特殊原因不得不使用循环引用,那可以让引用链上的一方持用普通指针(或弱智能指针weak_ptr)即可。
这就是weak_ptr 的用处。weak_ptr 提供一个(1)能够确定对方生存与否(2)互相之间生命周期无干扰(3)可以临时借用一个强引用(在你需要引用对方的短时间内保证对方存活)的智能指针。而 weak_ptr 要求程序员在运行时确定生存并加锁,这也是逻辑上必须的本征复杂度——如果别人活的比你短,你当然要:(1)先确定别人的死活(2)如果还活着,就给他续个命续到你用完了为止。
4.2 切记:让所有的智能指针都有名字
智能指针为解决资源泄漏、编写异常安全代码提供了一种解决方案,那么他是万能的良药吗?使用智能指针,就不会再有资源泄漏了吗?请看下面的代码:
上面的函数调用,看起来是安全的,但在现实世界中,其实不然:由于C++并未定义一个表达式的求值顺序,因此上述函数调用除了func在最后得到调用之外是可以确定,其他的执行序列则很可能被拆分成如下步骤:
1、分配内存给T1
2、分配内存给T2
3、构造T1对象
4、构造T2对象
5、构造T1的智能指针对象
6、构造T2的智能指针对象
7、调用func
此时,如果程序在第3步失败,那T1和T2对象所分配内存必然泄漏。而解决这个问题的方案也很简单,就是不要在函数实参中创建shared_ptr,抛弃临时对象,让所有的智能指针都有名字,就可以避免此类问题的发生。比如以下代码:
4.3 优先选用make_unique(shared)而非直接使用new
简单说来,相比于直接使用new表达式,make系列函数有三个优点:
消除了重复代码
改进了异常安全性
生成的目标代码尺寸更小速度更快
(在学习C/C++或者想要学习C/C++可以加我们的学习交流QQ群:712263501群内有相关学习资料)