OO编程思想之一---对象生命周期与内存模型

时间:2022-08-30 14:01:06

 

内存模型基础

==================================================== 

内存模型是随着越来越丰富和复杂的对象生命周期要求的发展而发展起来的。

最初的内存模型完全是线性的,静态的,一个程序运行时所有需要的对象都是在运行前完全准备好了的,运行完了时释放掉。典型的代表就是Fortran语言。这种语言的运行性能非常高(当然了,没有任何别的消耗嘛),但是表达能力受到限制(毕竟,要求静态的确定一切对象和内存的绑定关系)。最明显的一个限制就是没办法支持递归。这种内存模型支持的对象的生命周期跟应用程序的生命周期完全一致。同生共死,天下大同

Alogal的出现引入了一个强大的概念: lexical scope,内存模型也相应的出现了细分概念:栈。栈就是那种先进后出的容器,它完美的切合了lexical scope。同时,栈上的对象的生命周期也很清晰,进栈是出生,出栈时死亡。栈这个概念是如此的清晰、容易理解,而且如此的强大,导致现代各种语言的内存模型中,栈都占有非常显著的地位。栈自然地解决了递归问题栈上对象的生命周期的管理完全可以自动化而高效的完成。但是,栈仍然不足以满足某些对象生命周期的要求。

我先说说这是那种样子的生命周期。简单的说,栈对于共享的对象的生命周期无能为力。也就是说,某个对象,在多个平级的或者嵌套的scope之间共享//例:多线程中的共享,而栈却没办法解决。非得用栈来满足这种需求的话,会导致大量的memcpy,导致性能的低下。而且还是模拟的解决这个问题。其实,对于参数和返回值,基于栈架构的内存模型和对象就是采用复制和反向复制的方式来完成的。

面对这个挑战(共享对象生命周期问题),第一个反应就是避免共享,其实就是我前面说的完全副本的方式。这种方式在一定程度上有效,不过,对于大对象是非常不划算的。

C 和Pascal语言来了,这类语言提供了另一个概念叫做堆(其实,堆这个概念并不是它们最先引入的,但是是由它们发扬光大的)。从此内存模型分裂为两个界限明显的类型:栈和堆。堆就是那种随机进随机出的对象的栖息之地。也就是说,这种对象的生命周期不再可以向栈对象那样自动高效的管理了。有了这样的基础设施,共享对象就变得简单了。创建一个堆对象,然后A使用之,只要不销毁,B就可以使用之,这样,该对象就可以由A和B共享了。当然,牵扯到共享,必然会涉及到同步和互斥的问题。也就是A和B究竟怎样访问该对象的问题。一般来说,采取的策略是是把并发访问串行化的技术。由此导出很多互斥的技术。我们就不在这上面纠缠了。我们关注的是对象本身的生命周期,也就是说。这个可以共享的对象的生、死问题。由谁负责生(创建)似乎大家都没有什么抱怨的,由第一个要用该对象的负责,这也不会导致麻烦。原因是:如果你不负责生,那么该对象就不存在,你也就没办法使用了。:)由谁负责死(销毁)呢。这个看起来也很简单:最后一个用完了就销毁。而事实上也确实就这么简单。但是这儿有一点麻烦。跟创建不一样,创建不会被忘记,而销毁会。忘记销毁对象对自己没有伤害,所以,就选择忘记吧。:)

这样说吧。现代的语言按照由谁负责堆对象的销毁问题大致可以分成两大类,一类叫做有垃圾回收的,一类没有。有垃圾回收的那一类是由运行环境(运行时)负责销毁共享对象,没有垃圾回收的那一类由程序自己负责销毁共享对象。对程序员来说,当然有垃圾回收的语言跟友好了。但问题是垃圾回收需要首先搞定哪一些共享对象不再需要了。这是一个比较困难的问题。而对于没有垃圾回收的那种语言来说,程序自己逻辑上应该很清楚哪一些对象不再需要了,可以销毁了。

一般情况下,我们把那些由运行时负责销毁共享对象的那些堆叫做托管堆,负责销毁共享对象的运行时的一部分叫做垃圾回收器(GC),也就是说,托管堆就使那种委托给GC管理的堆。
相应的,没有委托给GC管理的堆就是普通的堆了。普通堆有应用程序自己负责销毁共享对象。

上面的描述还没有提到的一个问题是:对象究竟在什么时候销毁?由于一个对象不立即销毁一般不会导致什么重大的问题,所以销毁的时机相对来说可以很灵活。当然,肯定是在最后一个使用了以后。但是以后是多久呢?是立即销毁?还是延迟一段时间?如果是延迟,那么究竟延迟多长时间?还是更进一步的说延迟不定长的时间?

由于一般情况下,我们认为批量销毁比一个一个销毁要快一些,所以一个指导性的方案就是等到垃圾(生命周期应该已经结束了的共享对象)积累到一定的量以后(只要不影响程序的正常运行)批量销毁。这一般会导致销毁的不定长延迟。

上面说了:“由于一个对象不立即销毁一般不会导致什么重大的问题,所以销毁的时机相对来说可以很灵活。”。注意这个句子里面的“一般”这个字眼,这也就是说:在特殊情况下,不立即销毁会导致重大问题。那种情况是特殊情况呢?就是那种销毁对象的动作还带有别的副作用,而这个副作用会影响以后程序的运作行为的情况。这种情况在使用C++的RAII的时候是基本情况。

垃圾回收的基本方法
===========================================

我们把托管堆中的对象以及对象的互相引用关系看作是一个“图”(数学上定点和边的集合),应用程序的栈上有一些引用引用到这个图的某些顶点。从GC的角度来看,应用程序的作用就是不断地改变图的连通性,故此把应用程序叫做Mutator。GC就是通过查看图的连通性把那些孤立的顶点(就是那种生命周期结束的共享对象)回收的。

那么,我们怎么知道图的连通性的呢?

1、可以通过从根集跟踪。所谓根集,就是应用程序栈上的引用集合。我们知道,这种跟踪肯定可以搞定图的连通性,但是,这要求我们能够区分什么是引用,而什么不是。
2、每次应用程序修改图的连通性的时候,记录下来。这样GC就可以直接销毁那些孤立的顶点了。
第一种方式是最常用的方式,或者甚至可以这样说,第二种方式根本没人用。

不过,第二种方式的某种变形方式用的人却很多。那就是RefCount。第二种方式把集中存放的连通图信息分散的放置到各个共享对象身上,让他们记住自己被多少个别的对象引用就行了。这样当它发觉自己被0个对象引用的时候,就自裁。

这个方式看起来非常好,也非常干净,但是有两个缺点。一是性能。每一次引用一个对象,都需要增加这个引用计数,放弃引用的时候得减少之。影响了性能。另一个是对于环形图的无能为力。
            4。类类型的变量的生存期是与其所定义的作用域相关的,也就是说出了“}”,它就立  
                刻烟消云散了。而对象的生存期却与传统意义上的作用域没有任何必然的联系,在作  
                用域嵌套层次中的任何一个层次中定义(准确地说应该是构造)的对象可以在程序生  
                存期的任何时刻被GC回收。  
            5。对象被回收必须满足两个条件:一是没有任何指向该对象的强引用(强弱引用这里  
                  不详讲);二是GC回收操作被触发。这两个条件必须同时满足,对象才会被回收。    
                  通常情况下,对象被回收是该对象被利用完以后很久之后的事情了。因此,.net中  
                  没有析构函数,只有Finalize函数。dispose()函数的作用只是释放一些资源(比如  
                  说打开的文件)而以,并不会析构对象。并且,dispose()的使用必须由程序员自己  
                  控制。  
            6。无论如何,对象不能显式回收。即使你显式调用GC地回收功能,GC常常并不那么听    
                  话的去执行回收操作。GC回收操作的触发是根据一定策略进行的。  
            7。应该说,GC回收是安全的,稳定的。但是其开销也很大,效率不够高,尤其是占用  
                  内存很多,尽管微软采用了许多优化措施仍是如此。其开销也仅仅体现在GC进行垃    
                  圾回收的那一霎那。 
  c#中的对象生命周期 ============================================ 无论是指类型的变量或是类类型的变量,其存储单元都是在栈中分配的,唯一不同的是类类型的变量实际上存储的是该类对象的指针,相当于vc6中的CType*,只是在.net平台的语言中将指针的概念屏蔽掉了。我们都知道栈的一大特点就是LIFO(后进先出),这恰好与作用域的特点相对应(在作用域的嵌套层次中,越深层次的作用域,其变量的优先级越高)。因此,对于楼主的问题,再出了“}”后,无论是值类型还是类类型的变量(对象指针)都会被立即释放(值得注意的是:该指针所指向的托管堆中的对象并未被释放,正等待GC的回收,这也许才是楼主想问的)。.NET中的栈空间是不归GC管理的,GC仅管理托管堆。  
          楼主的问题实际上提到了.NET与以前的VS语言的一个根本的不同,主要是由于GC存在。我想就我的理解简要说明一下:  
          1。GC只收集托管堆中的对象。  
          2。所有值类型的变量都在栈中分配,超出作用域后立即释放栈空间,这一点与VC6完全  
                一样。  
          3。区别类类型的变量和类的对象,这是两个不同的概念。类类型的变量实际上是该类对  
                象的指针变量。如C#中的定义CType   myType;与VC6中的定义CType*   myType;是完全一  
                样的,只是.net语言将*号隐藏了。与VC6相同,必须用new   关键字来构造一个对象,  
                如(C#):CType   myType=new   CType();其实这一条语句有两次内存分配,一次是为类类  
                型变量myType在栈中分配空间(指针类型所占的空间,对32位系统分配32位,64位  
                系统则分配64位,在同一个系统中,所有指针类型所占的内存空间都是一样的,而  
                不管该类型的指针所指向的是何种类型的对象),另一次是在托管堆(GC所管理的  
                堆)中构造一个CType类型的对象并将该对象的起始地址赋给变量myType。正因为如  
                此才造成了在同一个作用域中声明的类类型的变量和该类型的对象的生存期不一样。   

 
C++中的对象生命周期 =============================== C++中各类对象详解如果一个人自称为程序高手,却对内存一无所知,那么我可以告诉你,他一定在吹牛。用C或C++写程序,需要更多地关注内存,这不仅仅是因为内存的分配是否合理直接影响着程序的效率和性能,更为主要的是,当我们操作内存的时候一不小心就会出现问题,而且很多时候,这些问题都是不易发觉的,比如内存泄漏,比如悬挂指针。笔者今天在这里并不是要讨论如何避免这些问题,而是想从另外一个角度来认识C++内存对象

我们知道,C++将内存划分为三个逻辑区域:堆、栈和静态存储区。既然如此,我称位于它们之中的对象分别为堆对象,栈对象以及静态对象。那么这些不同的内存对象有什么区别了?堆对象和栈对象各有什么优劣了?如何禁止创建堆对象或栈对象了?这些便是今天的主题。

一.基本概念

先来看看栈。栈,一般用于存放局部变量或对象,如我们在函数定义中用类似下面语句声明的对象:

Type stack_object ;

stack_object便是一个栈对象,它的生命期是从定义点开始,当所在函数返回时,生命结束。

另外,几乎所有的临时对象都是栈对象。比如,下面的函数定义:

Type fun(Type object) ;

这个函数至少产生两个临时对象,首先,参数是按值传递的,所以会调用拷贝构造函数生成一个临时对象object_copy1 ,在函数内部使用的不是使用的不是object,而是object_copy1,自然,object_copy1是一个栈对象,它在函数返回时被释放;还有这个函数是值返回的,在函数返回时,如果我们不考虑返回值优化(NRV),那么也会产生一个临时对象object_copy2,这个临时对象会在函数返回后一段时间内被释放。比如某个函数中有如下代码

Type tt ,result ; //生成两个栈对象
tt = fun(tt) ; //函数返回时,生成的是一个临时对象object_copy2

上面的第二个语句的执行情况是这样的,首先函数fun返回时生成一个临时对象object_copy2 ,然后再调用赋值运算符执行

tt = object_copy2 ; //调用赋值运算符

看到了吗?编译器在我们毫无知觉的情况下,为我们生成了这么多临时对象,而生成这些临时对象的时间和空间的开销可能是很大的,所以,你也许明白了,为什么对于“大”对象最好用const引用传递代替按值进行函数参数传递了。

接下来,看看堆。堆,又叫*存储区,它是在程序执行的过程中动态分配的,所以它最大的特性就是动态性。在C++中,所有堆对象的创建和销毁都要由程序员负责,所以,如果处理不好,就会发生内存问题。如果分配了堆对象,却忘记了释放,就会产生内存泄漏;而如果已释放了对象,却没有将相应的指针置为NULL,该指针就是所谓的“悬挂指针”,再度使用此指针时,就会出现非法访问,严重时就导致程序崩溃。

那么,C++中是怎样分配堆对象的?唯一的方法就是用new(当然,用类malloc指令也可获得C式堆内存),只要使用new,就会在堆中分配一块内存,并且返回指向该堆对象的指针。

再来看看静态存储区。所有的静态对象、全局对象都属于静态存储区分配。关于全局对象,是在main()函数执行前就分配好了的。其实,在main()函数中的显示代码执行之前,会调用一个由编译器生成的_main()函数,而_main()函数会进行所有全局对象的的构造及初始化工作。而在main()函数结束之前,会调用由编译器生成的exit函数,来释放所有的全局对象。比如下面的代码:

void main(void)
{
 … …// 显式代码
}

实际上,被转化成这样:

void main(void)
{
 _main(); //隐式代码,由编译器产生,用以构造所有全局对象
 … … // 显式代码
 … …
 exit() ; // 隐式代码,由编译器产生,用以释放所有全局对象
}

所以,知道了这个之后,便可以由此引出一些技巧,如,假设我们要在main()函数执行之前做某些准备工作,那么我们可以将这些准备工作写到一个自定义的全局对象的构造函数中,这样,在main()函数的显式代码执行之前,这个全局对象的构造函数会被调用,执行预期的动作,这样就达到了我们的目的。刚才讲的是静态存储区中的全局对象,那么,局部静态对象了?局部静态对象通常也是在函数中定义的,就像栈对象一样,只不过,其前面多了个static关键字。局部静态对象的生命期是从其所在函数第一次被调用,更确切地说,是当第一次执行到该静态对象的声明代码时,产生该静态局部对象,直到整个程序结束时,才销毁该对象。

还有一种静态对象,那就是它作为class的静态成员。考虑这种情况时,就牵涉了一些较复杂的问题。

第一个问题是class的静态成员对象的生命期,class的静态成员对象随着第一个class object的产生而产生,在整个程序结束时消亡。也就是有这样的情况存在,在程序中我们定义了一个class,该类中有一个静态对象作为成员,但是在程序执行过程中,如果我们没有创建任何一个该class object,那么也就不会产生该class所包含的那个静态对象。还有,如果创建了多个class object,那么所有这些object都共享那个静态对象成员。

第二个问题是,当出现下列情况时:

class Base
{
 public:
  static Type s_object ;
}
class Derived1 : public Base / / 公共继承
{
 … …// other data
}
class Derived2 : public Base / / 公共继承
{
 … …// other data
}

Base example ;
Derivde1 example1 ;
Derivde2 example2 ;
example.s_object = …… ;
example1.s_object = …… ;
example2.s_object = …… ;

请注意上面标为黑体的三条语句,它们所访问的s_object是同一个对象吗?答案是肯定的,它们的确是指向同一个对象,这听起来不像是真的,是吗?但这是事实,你可以自己写段简单的代码验证一下。我要做的是来解释为什么会这样?我们知道,当一个类比如Derived1,从另一个类比如Base继承时,那么,可以看作一个Derived1对象中含有一个Base型的对象,这就是一个subobject。一个Derived1对象的大致内存布局如下:
  
让我们想想,当我们将一个Derived1型的对象传给一个接受非引用Base型参数的函数时会发生切割,那么是怎么切割的呢?相信现在你已经知道了,那就是仅仅取出了Derived1型的对象中的subobject,而忽略了所有Derived1自定义的其它数据成员,然后将这个subobject传递给函数(实际上,函数中使用的是这个subobject的拷贝)。

所有继承Base类的派生类的对象都含有一个Base型的subobject(这是能用Base型指针指向一个Derived1对象的关键所在,自然也是多态的关键了),而所有的subobject和所有Base型的对象都共用同一个s_object对象,自然,从Base类派生的整个继承体系中的类的实例都会共用同一个s_object对象了。上面提到的example、example1、example2的对象布局如下图所示:

二.三种内存对象的比较

栈对象的优势是在适当的时候自动生成,又在适当的时候自动销毁,不需要程序员操心;而且栈对象的创建速度一般较堆对象快,因为分配堆对象时,会调用 operator new操作,operator new会采用某种内存空间搜索算法,而该搜索过程可能是很费时间的,产生栈对象则没有这么麻烦,它仅仅需要移动栈顶指针就可以了。但是要注意的是,通常栈空间容量比较小,一般是1MB~2MB,所以体积比较大的对象不适合在栈中分配。特别要注意递归函数中最好不要使用栈对象,因为随着递归调用深度的增加,所需的栈空间也会线性增加,当所需栈空间不够时,便会导致栈溢出,这样就会产生运行时错误。

堆对象,其产生时刻和销毁时刻都要程序员精确定义,也就是说,程序员对堆对象的生命具有完全的控制权。我们常常需要这样的对象,比如,我们需要创建一个对象,能够被多个函数所访问,但是又不想使其成为全局的,那么这个时候创建一个堆对象无疑是良好的选择,然后在各个函数之间传递这个堆对象的指针,便可以实现对该对象的共享。另外,相比于栈空间,堆的容量要大得多。实际上,当物理内存不够时,如果这时还需要生成新的堆对象,通常不会产生运行时错误,而是系统会使用虚拟内存来扩展实际的物理内存。
接下来看看static对象。

首先是全局对象。全局对象为类间通信和函数间通信提供了一种最简单的方式,虽然这种方式并不优雅。一般而言,在完全的面向对象语言中,是不存在全局对象的,比如C#,因为全局对象意味着不安全和高耦合,在程序中过多地使用全局对象将大大降低程序的健壮性、稳定性、可维护性和可复用性。C++也完全可以剔除全局对象,但是最终没有,我想原因之一是为了兼容C。

其次是类的静态成员,上面已经提到,基类及其派生类的所有对象都共享这个静态成员对象,所以当需要在这些class之间或这些class objects之间进行数据共享或通信时,这样的静态成员无疑是很好的选择。

接着是静态局部对象,主要可用于保存该对象所在函数被屡次调用期间的中间状态,其中一个最显著的例子就是递归函数,我们都知道递归函数是自己调用自己的函数,如果在递归函数中定义一个nonstatic局部对象,那么当递归次数相当大时,所产生的开销也是巨大的。这是因为nonstatic局部对象是栈对象,每递归调用一次,就会产生一个这样的对象,每返回一次,就会释放这个对象,而且,这样的对象只局限于当前调用层,对于更深入的嵌套层和更浅露的外层,都是不可见的。每个层都有自己的局部对象和参数。

在递归函数设计中,可以使用static对象替代nonstatic局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放nonstatic对象的开销,而且static对象还可以保存递归调用的中间状态,并且可为各个调用层所访问。

三.使用栈对象的意外收获

前面已经介绍到,栈对象是在适当的时候创建,然后在适当的时候自动释放的,也就是栈对象有自动管理功能。那么栈对象会在什么会自动释放了?第一,在其生命期结束的时候;第二,在其所在的函数发生异常的时候。你也许说,这些都很正常啊,没什么大不了的。是的,没什么大不了的。但是只要我们再深入一点点,也许就有意外的收获了。

栈对象,自动释放时,会调用它自己的析构函数。如果我们在栈对象中封装资源,而且在栈对象的析构函数中执行释放资源的动作,那么就会使资源泄漏的概率大大降低,因为栈对象可以自动的释放资源,即使在所在函数发生异常的时候。实际的过程是这样的:函数抛出异常时,会发生所谓的 stack_unwinding(堆栈回滚),即堆栈会展开,由于是栈对象,自然存在于栈中,所以在堆栈回滚的过程中,栈对象的析构函数会被执行,从而释放其所封装的资源。除非,除非在析构函数执行的过程中再次抛出异常――而这种可能性是很小的,所以用栈对象封装资源是比较安全的。基于此认识,我们就可以创建一个自己的句柄或代理来封装资源了。智能指针(auto_ptr)中就使用了这种技术。在有这种需要的时候,我们就希望我们的资源封装类只能在栈中创建,也就是要限制在堆中创建该资源封装类的实例。

四.禁止产生堆对象

上面已经提到,你决定禁止产生某种类型的堆对象,这时你可以自己创建一个资源封装类,该类对象只能在栈中产生,这样就能在异常的情况下自动释放封装的资源。

那么怎样禁止产生堆对象了?我们已经知道,产生堆对象的唯一方法是使用new操作,如果我们禁止使用new不就行了么。再进一步,new操作执行时会调用 operator new,而operator new是可以重载的。方法有了,就是使new operator 为private,为了对称,最好将operator delete也重载为private。现在,你也许又有疑问了,难道创建栈对象不需要调用new吗?是的,不需要,因为创建栈对象不需要搜索内存,而是直接调整堆栈指针,将对象压栈,而operator new的主要任务是搜索合适的堆内存,为堆对象分配空间,这在上面已经提到过了。好,让我们看看下面的示例代码:

#include <stdlib.h> //需要用到C式内存分配函数
class Resource ; //代表需要被封装的资源类
class NoHashObject
{
 private:
  Resource* ptr ;//指向被封装的资源
  ... ... //其它数据成员
  void* operator new(size_t size) //非严格实现,仅作示意之用
  {
   return malloc(size) ;
  }
  void operator delete(void* pp) //非严格实现,仅作示意之用
  {
   free(pp) ;
  }
 public:
  NoHashObject()
  {
   //此处可以获得需要封装的资源,并让ptr指针指向该资源
   ptr = new Resource() ;
  }
  ~NoHashObject()
  {
   delete ptr ; //释放封装的资源
  }
};

NoHashObject现在就是一个禁止堆对象的类了,如果你写下如下代码:

NoHashObject* fp = new NoHashObject() ; //编译期错误!
delete fp ;

上面代码会产生编译期错误。好了,现在你已经知道了如何设计一个禁止堆对象的类了,你也许和我一样有这样的疑问,难道在类NoHashObject的定义不能改变的情况下,就一定不能产生该类型的堆对象了吗?不,还是有办法的,我称之为“暴力破解法”。C++是如此地强大,强大到你可以用它做你想做的任何事情。这里主要用到的是技巧是指针类型的强制转换。

void main(void)
{
 char* temp = new char[sizeof(NoHashObject)] ;

 //强制类型转换,现在ptr是一个指向NoHashObject对象的指针
 NoHashObject* obj_ptr = (NoHashObject*)temp ;

 temp = NULL ; //防止通过temp指针修改NoHashObject对象

 //再一次强制类型转换,让rp指针指向堆中NoHashObject对象的ptr成员
 Resource* rp = (Resource*)obj_ptr ;

 //初始化obj_ptr指向的NoHashObject对象的ptr成员
 rp = new Resource() ;
 //现在可以通过使用obj_ptr指针使用堆中的NoHashObject对象成员了
 ... ...

 delete rp ;//释放资源
 temp = (char*)obj_ptr ;
 obj_ptr = NULL ;//防止悬挂指针产生
 delete [] temp ;//释放NoHashObject对象所占的堆空间。
}

上面的实现是麻烦的,而且这种实现方式几乎不会在实践中使用,但是我还是写出来路,因为理解它,对于我们理解C++内存对象是有好处的。对于上面的这么多强制类型转换,其最根本的是什么了?我们可以这样理解:

某块内存中的数据是不变的,而类型就是我们戴上的眼镜,当我们戴上一种眼镜后,我们就会用对应的类型来解释内存中的数据,这样不同的解释就得到了不同的信息。

所谓强制类型转换实际上就是换上另一副眼镜后再来看同样的那块内存数据。

另外要提醒的是,不同的编译器对对象的成员数据的布局安排可能是不一样的,比如,大多数编译器将NoHashObject的ptr指针成员安排在对象空间的头4个字节,这样才会保证下面这条语句的转换动作像我们预期的那样执行:

Resource* rp = (Resource*)obj_ptr ;

但是,并不一定所有的编译器都是如此。

既然我们可以禁止产生某种类型的堆对象,那么可以设计一个类,使之不能产生栈对象吗?当然可以。

五.禁止产生栈对象

前面已经提到了,创建栈对象时会移动栈顶指针以“挪出”适当大小的空间,然后在这个空间上直接调用对应的构造函数以形成一个栈对象,而当函数返回时,会调用其析构函数释放这个对象,然后再调整栈顶指针收回那块栈内存。在这个过程中是不需要operator new/delete操作的,所以将operator new/delete设置为private不能达到目的。当然从上面的叙述中,你也许已经想到了:将构造函数或析构函数设为私有的,这样系统就不能调用构造/析构函数了,当然就不能在栈中生成对象了。

这样的确可以,而且我也打算采用这种方案。但是在此之前,有一点需要考虑清楚,那就是,如果我们将构造函数设置为私有,那么我们也就不能用new来直接产生堆对象了,因为new在为对象分配空间后也会调用它的构造函数啊。所以,我打算只将析构函数设置为private。再进一步,将析构函数设为private除了会限制栈对象生成外,还有其它影响吗?是的,这还会限制继承。

如果一个类不打算作为基类,通常采用的方案就是将其析构函数声明为private。

为了限制栈对象,却不限制继承,我们可以将析构函数声明为protected,这样就两全其美了。如下代码所示:

class NoStackObject
{
 protected:
  ~NoStackObject() { }
 public:
  void destroy()
  {
   delete this ;//调用保护析构函数
  }
};

接着,可以像这样使用NoStackObject类:

NoStackObject* hash_ptr = new NoStackObject() ;
... ... //对hash_ptr指向的对象进行操作
hash_ptr->destroy() ;

呵呵,是不是觉得有点怪怪的,我们用new创建一个对象,却不是用delete去删除它,而是要用destroy方法。很显然,用户是不习惯这种怪异的使用方式的。所以,我决定将构造函数也设为private或protected。这又回到了上面曾试图避免的问题,即不用new,那么该用什么方式来生成一个对象了?我们可以用间接的办法完成,即让这个类提供一个static成员函数专门用于产生该类型的堆对象。(设计模式中的singleton模式就可以用这种方式实现。)让我们来看看:

class NoStackObject
{
 protected:
  NoStackObject() { }
  ~NoStackObject() { }
 public:
  static NoStackObject* creatInstance()
  {
   return new NoStackObject() ;//调用保护的构造函数
  }
  void destroy()
  {
   delete this ;//调用保护的析构函数
  }
};

现在可以这样使用NoStackObject类了:

NoStackObject* hash_ptr = NoStackObject::creatInstance() ;
... ... //对hash_ptr指向的对象进行操作
hash_ptr->destroy() ;
hash_ptr = NULL ; //防止使用悬挂指针

现在感觉是不是好多了,生成对象和释放对象的操作一致了。