现在的位置: 首页 > 综合 > 正文

智能指针的作用与原理

2014年09月07日 ⁄ 综合 ⁄ 共 4754字 ⁄ 字号 评论关闭

对于C/C++程序员来说,指针是天堂,同时指针也是地狱。指针有多少好处,又有多少让人头疼的问题我们这里就不多说了。但为了局部解决指针的问题,我们提出了智能指针这个概念。

 

实际上,我一直不明白,智能指针用于干什么!直到我遇到有关栈和堆问题的时候,才依稀有了点感悟,我现在的感悟几乎肯定是不全面的,但是很重要。

几乎有关指针的问题的出现集中在指针指向堆上空间的时候,为什么呢?

如果指针指向的是栈上的空间,我们知道这里的空间是有系统自动管理的,申明释放都是由系统根据栈的策略来进行的。我们能够干预的部分很少。

而对于指向堆空间的指针,由于申请(new),和释放(free)必须要程序员显示的进行调用,并且该空间的生命周期在new语句和free之间。

注意,这就是当年设计C/C++的伟大之处:事实上所有名字的空间(变量)都只会存在于栈(或者更低的内存空间中),而栈根据它特有的先进后出的策略,实现了C/C++语言中复杂的变量生命周期与作用域问题。但是这还不够,对于一个C/C++程序而言,栈就像是一个工作间,有关程序的推进总调度都在这里,它的空间不是很大(有操作系统规定:1M2M),在逐个读取指令执行指令的过程中,需要读取其它空间的空间数据,如“代码区”,“常量区”,“全局/静态区”等等,还有一个非常重要的区域,也是程序得以弹性作业的区域——“堆”,堆区的空间没有大小限制,几乎以操作系统能够承载的最大虚拟存储空间为上限。这里想是一个任意的、临时仓库区。要申请什么空间,在这里声请就是,然后返回一个“句柄”(指针),给程序的“总调令区”——栈区,然后程序可以通过那个指针(栈区)来控制那个堆区的空间。于是似乎整个内存分布过程变得很完美。

但是,麻烦就发生在堆区的空间有用户自定申请,和组织释放,并且它还没有自己的名称(通常的变量名),而只是被指针(地址)指着,而指针有一个可以改变内容的变量(不想变量名和引用名那样的声明之后就不会改变指向的哪个地方)。

这样的境况导致的后果就是,程序员必须很仔细的申请并给出对应的释放语句,但是由于程序的复杂都增大,判断、循环、递归这样的语句会让程序的走向处于不定境地。很有可能出现内存泄露的问题。

1)内存泄露及其检测的详细内容这里就不介绍了。不过内存泄露的关键含义就是,操作系统将空间分配给你了,但是那个空间被你申请并使用之后就在也没有用,并且没有响应的释放语句,一句话,该空间不再补任何指针或引用所引用,成了一个幽灵空间。操作系统以为你在控制它,但其实你并没有控制它。

那么为了结局内存泄露问题,一个解决方法是,仔细的、在多个可能的“路口”放上free的语句,但是这又可能导致另外一个问题,就是重复释放问题。

2)重复释放问题发生在程序通过freedelete语句释放已经不属于该程序的空间。而是非常危险的,比起内存泄露,重复释放是一个非常严重的问题。有可能你第二次free的空间已经被别的程序所使用,所以C/C++中视这种错误为致命错误。(也就是说,我容许你局部的浪费,但绝对不容许你释放(使用)别人的东西)

事实上,还有一个问题是指向堆对象指针需要注意的,就是在释放之后,该指针仍然指向那个已经不属于该程序的空间,并且,由于指针的“霸气外漏”,它几乎能够仍然对该空间进行读甚至是写操作,但实验证明,系统不认为该空间不属于该程序。这是什么状况,就是你的指针偷偷的使用了别人的地盘,而你自己不知道,系统也不知道。这种行为几乎是没有什么用处的(如果你要用,就大大方法的向系统申请呗),而且有可能带来恐怖的坏处(改写别人家的空间内容)。所以这种行为的出现只能定位为——程序员没有严加看守,导致程序异常执行。所以前面我们讲过解决这一个问题的方法就是在执行delete删除之后,请一定记得,要将那个指针指向0也就是null,或者是一个你控制的空间,或者直接delete掉(如果它本身也是堆上对象)。

 

从上面可以看出,使用堆上对象的时候,需要我们保证“对象一定会被释放,但只能释放一次,并且释放后指向该对象的指针应该马上归0

要达到上面的要求,除了硬性要求程序员在设计的时候要小心,(而这几乎是无法避免的),还该有其它的一些方法(我们几乎可以把这些方法称为指针设计模式了)。硬性要求程序员时刻注意那些并非他“主营业务”的东西会转移程序员的核心注意力。

于是,我们思考出一个办法,这个办法就是使用智能指针。还是前面说的那句话,智能指针可能还有其他的用处,但这里我只设计这里的问题。

智能指针

实际上,智能指针是借鉴了java内存回收机制(引用计数器机制)。(或许是java在设计的时候借鉴了这个解决C++内存回收问题的机制,无所谓啦)。

智能指针的实现方法可以再很多地方找到,事实上到现在,我也不确定如何实现,但是要自己思考而不是被动的学习。自己思考能够得出为什么要这么实现,被动的学习基本只知道“哦!是这样的哦!”

(1)      A是我们定义的一个类,我们需要在堆上申请类A的对象,现在需要我们实现上面的安全标准——“对象一定会被释放,但只能释放一次,并且释放后指向该对象的指针应该马上归0

(2)      前面的分析提出使用引用计数器原理(注意这里的引用是广义的引用,包括指针和引用),那么我们能不能直接将那个计数器Count安装在类A对象的内部呢?这是不合适的,首先你这样需要改变该类的结构(而这有时候是无法做到的),其次……。

(3)      于是我们选在外部建立一个引用计数器类对象,它直接附属于一个类A对象,也就是说,它内容应该包括指向类A对象的指针,以及引用计数器。

于是就出现了下面的布局。那么对于这样一个整体(说是整体一点的夸张,他们必须同生共死(声明周期一样)),并且可以断定,UseCount对象一定也只能建在堆上,否则它就无法与A类对象同生共死了,哈哈。

A

Content

int count;

A * ap;

UseCount

(4)      那么对于上面这个整体,我们该如何使用呢?也就是说要使用类A对象的程序,现在由于你给它加上的UseCount并且规定它们是一个整体,那么程序要如何使用这个整体呢?只从类A入手,不行,因为A不知道UseCount的存在;从两方面入手,也不好,因为这就需要你时刻保持要同时操作两个对象(不过我们可以试试他能不能行)。于是之剩下从UseCount进入,因为该可以连接到A,所以从这里入口是理论可行的。于是操作A,就编程了通过UseCount操作A了。

(5)      根据上面的分析,我们发现,程序会变得复杂了,因为程序员面临的不在是它想要的A,而是代理AUseCount,这增加了程序员编程的复杂都,不过,这个时候面向对象编程的优势体现出来了。把这个复杂度封装在另外一个类中,让使用这个类程序员感觉不到UseCount的存在,同时有不必去手动的释放A这个堆空间,因为那个封装类做了所有这些繁琐的事情。

情景如下:

一个封装类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)      为了降低实现的复杂性,我们规定,Encap实例化的时候,一定要提供A堆对象的指针(注意一定是堆对象),或者是A对象的值。也就是说,不能有默认构造函数。

我们要达到的目标:

1, getValue()setValue()得到对值和操作。这个比较容易。

2, 构造函数有两个,一个是正对提供A类指针的,还有一个是提供A类对象值的。我们暂时,值考虑提供A类指针的。也就是一个普通构造函数。它的prototype大致为:Encap(A
* a)
,它的函数内容包括,new(保证为堆)一个UserCount,赋值给UC,然后,将将a付给UC->a Count=1.   不过这里地方我们似乎还有考虑构造函数中的参数a,是否被程序的其他部分使用,要是这样的话,就更复杂了,因为这样,该堆对象,就不只从属于Encap这一个体系了,并且UC也记录不了其他的引用,从这个角度上讲,我们考虑使用A类对象值作为操作还要好一点,这样保证了该堆对象(新申请)只会被这一圈子人使用。不过我们暂时就假设该指针(a)不会在被别人所使用了。

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)      通过上面的分析,和实现,我们的智能指针就这样完成了,虽然还有许多遗憾的地方。1,例如A对象指针必须是堆上的,如果是栈上面的,就不适合这里,需要编更复杂的程序来弥补这个问题。

2,如果使用A堆对象指针作为构造函数的参数,那么这个指针只能在这被使用一次,如果这个指针,还用于作为其他构造函数的参数,我们无法处理,因为这里的UseCount不是全局的。只是属于从这个构造函数开始的那个Encap对象群的。而实际上,UseCountA应该是一对一的。如果A对象被其他引用了,则导致UseCountA不是一对一的关系。将来看能不能做出这种全局的UseCount.

抱歉!评论已关闭.