对于C/C++程序员来说,指针是天堂,同时指针也是地狱。指针有多少好处,又有多少让人头疼的问题我们这里就不多说了。但为了局部解决指针的问题,我们提出了智能指针这个概念。
实际上,我一直不明白,智能指针用于干什么!直到我遇到有关栈和堆问题的时候,才依稀有了点感悟,我现在的感悟几乎肯定是不全面的,但是很重要。
几乎有关指针的问题的出现集中在指针指向堆上空间的时候,为什么呢?
如果指针指向的是栈上的空间,我们知道这里的空间是有系统自动管理的,申明释放都是由系统根据栈的策略来进行的。我们能够干预的部分很少。
而对于指向堆空间的指针,由于申请(new),和释放(free)必须要程序员显示的进行调用,并且该空间的生命周期在new语句和free之间。
注意,这就是当年设计C/C++的伟大之处:事实上所有名字的空间(变量)都只会存在于栈(或者更低的内存空间中),而栈根据它特有的先进后出的策略,实现了C/C++语言中复杂的变量生命周期与作用域问题。但是这还不够,对于一个C/C++程序而言,栈就像是一个工作间,有关程序的推进总调度都在这里,它的空间不是很大(有操作系统规定:1M或2M),在逐个读取指令执行指令的过程中,需要读取其它空间的空间数据,如“代码区”,“常量区”,“全局/静态区”等等,还有一个非常重要的区域,也是程序得以弹性作业的区域——“堆”,堆区的空间没有大小限制,几乎以操作系统能够承载的最大虚拟存储空间为上限。这里想是一个任意的、临时仓库区。要申请什么空间,在这里申请就是,然后返回一个“句柄”(指针),给程序的“总调令区”——栈区,然后程序可以通过那个指针(栈区)来控制那个堆区的空间。于是似乎整个内存分布过程变得很完美。
但是,麻烦就发生在堆区的空间有用户自定申请,和组织释放,并且它还没有自己的名称(通常的变量名),而只是被指针(地址)指着,而指针有一个可以改变内容的变量(不想变量名和引用名那样的声明之后就不会改变指向的哪个地方)。
这样的境况导致的后果就是,程序员必须很仔细的申请并给出对应的释放语句,但是由于程序的复杂都增大,判断、循环、递归这样的语句会让程序的走向处于不定境地。很有可能出现内存泄露的问题。
(1)内存泄露及其检测的详细内容这里就不介绍了。不过内存泄露的关键含义就是,操作系统将空间分配给你了,但是那个空间被你申请并使用之后就在也没有用,并且没有响应的释放语句,一句话,该空间不再补任何指针或引用所引用,成了一个幽灵空间。操作系统以为你在控制它,但其实你并没有控制它。
那么为了解决内存泄露问题,一个解决方法是,仔细的、在多个可能的“路口”放上free的语句,但是这又可能导致另外一个问题,就是重复释放问题。
(2)重复释放问题发生在程序通过free或delete语句释放已经不属于该程序的空间。而是非常危险的,比起内存泄露,重复释放是一个非常严重的问题。有可能你第二次free的空间已经被别的程序所使用,所以C/C++中视这种错误为致命错误。(也就是说,我容许你局部的浪费,但绝对不容许你释放(使用)别人的东西)
事实上,还有一个问题是指向堆对象指针需要注意的,就是在释放之后,该指针仍然指向那个已经不属于该程序的空间,并且,由于指针的“霸气外漏”,它几乎能够仍然对该空间进行读甚至是写操作,但实验证明,系统不认为该空间不属于该程序。这是什么状况,就是你的指针偷偷的使用了别人的地盘,而你自己不知道,系统也不知道。这种行为几乎是没有什么用处的(如果你要用,就大大方法的向系统申请呗),而且有可能带来恐怖的坏处(改写别人家的空间内容)。所以这种行为的出现只能定位为——程序员没有严加看守,导致程序异常执行。所以前面我们讲过解决这一个问题的方法就是在执行delete删除之后,请一定记得,要将那个指针指向0也就是null,或者是一个你控制的空间,或者直接delete掉(如果它本身也是堆上对象)。
从上面可以看出,使用堆上对象的时候,需要我们保证“对象一定会被释放,但只能释放一次,并且释放后指向该对象的指针应该马上归0”。
要达到上面的要求,除了硬性要求程序员在设计的时候要小心,(而这几乎是无法避免的),还该有其它的一些方法(我们几乎可以把这些方法称为指针设计模式了)。硬性要求程序员时刻注意那些并非他“主营业务”的东西会转移程序员的核心注意力。
于是,我们思考出一个办法,这个办法就是使用智能指针。还是前面说的那句话,智能指针可能还有其他的用处,但这里我只涉及上面介绍的问题。
智能指针
实际上,智能指针是借鉴了java内存回收机制(引用计数器机制)。(或许是java在设计的时候借鉴了这个解决C++内存回收问题的机制,无所谓啦)。
智能指针的实现方法可以再很多地方找到,事实上到现在,我也不确定如何实现,但是要自己思考而不是被动的学习。自己思考能够得出为什么要这么实现,被动的学习基本只知道“哦!是这样的哦!”
(1)
(2)
(3)
于是就出现了下面的布局。那么对于这样一个整体(说是整体一点的夸张,他们必须同生共死(声明周期一样)),并且可以断定,UseCount对象一定也只能建在堆上,否则它就无法与A类对象同生共死了,哈哈。
|
|
|
|
(4)
(5)
情景如下:
一个封装类Encap需要类A对象,通过指针引用(这就是我们在书本上遇到的问题——含有指针的类)。
注意这里必须保证ap指向的是堆上空间,否则问题变得更复杂了,因为如果ap指向的是栈上空间,delete就绝对不能删除它(不过这个我们可以通过识别该对象是否为堆上还是栈上来 分开处理,很难,有人说不可能,不过我们可以试试)。
由于前面提出了使用UseCount来保证他的安全,于是转换成来对UseCount的引用指针,但是Encap内部要做大幅度的修改,才能让外部感觉不到这种修改,外界认为它仍然应用者类A堆对象。
Encap |
A *ap; Others; |
Encap |
UseCount *UC; Others; |
(6)
1, getValue()函数,需要返回的是A对象,而不是UC对象
2, setValue()函数同理也就是说,必须做一个转接
3, 构造函数需要做修改,它prototype不修改的情况下,修改内容是其能够反映到类A对象中。
4, 那句名言,当需要处理带有指针的类的时候,“3—规则”就派上用场了,也就是复制构造函数、赋值操作符重载、析构函数这三个系统默认的函数都必须做相应的修改。
(7)
我们要达到的目标:
1, getValue(),setValue()得到对值和操作。这个比较容易。
2, 构造函数有两个,一个是正对提供A类指针的,还有一个是提供A类对象值的。我们暂时,值考虑提供A类指针的。也就是一个普通构造函数。它的prototype大致为:Encap(A * a),它的函数内容包括,new(保证为堆)一个UserCount,赋值给UC,然后,将将a付给UC->a, Count=1.
3, 复制构造函数。复制构造函数与普通的构造函数没有什么不同,关键的不同在于他的参数一定是const Encap &类型的。在普通的情况下,这个构造函数我们可以完全不搭理,但是当出现指针成员的情况下,就不得不管了,因为默认情况下它只复制指针成员。这个不符合我们的要求,我们要求是,复制了指针成员(UC)之后,需要对UC->count进行加1处理,来表示引用增加了1.
4, 赋值操作符重载,我们知道,默认情况下,赋值操作符的结果与复制构造函数一样,(虽然实现过程相差大了,哈哈),但是在这个情况下,被赋值的那一方——左值,(注意,它已经保有了一次对某个A对象的计数,这个A对象可能与右值中的引用的对象A为一个,也可能不是同一个。)需要减掉一个引用技术。也就是说,除了与复制构造函数那样加上一个当前引用的引用计数,还要减掉一个它过去引用的。并且,如果它过去引用的只剩下一个,则需要手动delete掉他们。 这里的顺序要注意。
5, 析构函数,我们要保证程序员不需要主动delete掉那个A堆对象,就需要在这个类中(Encap)删除它,并且保证不二次delete,就需要联系UseCount。因为一个Encap释放,意味着A堆对象的引用计数减少一次,当减少到0的时候,就需要释放它了。
6, 也就是说,我们要搞清楚,什么时候初始化呢?(构造函数,并规定一个A堆指针只能一次被构造函数作为参数,否则就会出错),计数器什么时候会被增加?(复制构造、赋值操作符),什么时候会被减呢?(析构函数、赋值操作符),什么时候会被释放呢?(析构函数,当计数器为0)
(8)
2,如果使用A堆对象指针作为构造函数的参数,那么这个指针只能在这被使用一次,如果这个指针,还用于作为其他构造函数的参数,我们无法处理,因为这里的UseCount不是全局的。只是属于从这个构造函数开始的那个Encap对象群的。而实际上,UseCount和A应该是一对一的。如果A对象被其他引用了,则导致UseCount和A不是一对一的关系。将来看能不能做出这种全局的UseCount.