C++的构造函数和析构函数

构造函数提供了一种机制,通过它有机会完成必要的初始化工作,从而使对象成为有意义
的存在物,而不仅仅只是一块原始的空间。

但是,我们逐渐了解到,构造函数具有的地位,不仅对于用户(程序员),对于编译器履
行职责也极为重要。通过这个机制,它让C++的一些基本的特性,如继承、多态得到了正确
的贯彻和表现。

首先不难理解的一点是在构造函数中,要确保基类对象的正确构造,如果是从基类继承的
话。因为继承类对象至少可以被“低”看为一个基类对象,具有后者的所有行为和表现,
所以基类的构造函数首先被调用。如果所谓的基类也是从其他类继承过来的,这就形成了
一个调用链。最后的情况是,最基础的类的构造函数首先被执行,然后才是上一层的构造
函数,如此到最外层的继承类。这个过程必须是严格有序的。如果没有这个次序保证,继
承类就有机会在基类还没构建好的情况下就访问基类的数据或函数,这将导致不可预料的
、灾难性的后果。

只有首先确保基类的正确构造,接下来才能进行继承类本身的构造。因为类中可能含有成
员对象,必须保证这些对象也被构造,按照一定的次序(一般就是变量声明的次序)。可
能其中一个或一些成员对象的构造需要参数,这需要在类的构造函数中提供所有需要的参
数(构成所谓显式的“初始化列表”)。

看起来这就是构造函数的全部隐含(或半隐含)工作?不是的。我们忽视了一个极为重要
的东西,我称之为类关联信息(表)。类关联信息是编译时生成的、为运行时所需的类的
附加数据(可以认为这些数据放在全局数据区中)。我们熟悉的虚函数表(v-table),我
把它归在类关联信息的范畴之中(最初,我以为虚函数表就是全部,但这个理解有点狭义
)。此外,还包含运行时类型信息(RTTI)。此外,不排除我们还不清楚的其它辅助数据
(总之,类关联信息是一种广义的、统一的称呼)。如果编译器为类生成了类关联信息,
那么毫无疑问,必须在构造函数中将它与当前对象(类的实例)关联起来(也许简单到只
需要设置一个指针即可)。

例如,如果类中含有虚函数,或者,它覆盖了基类中的虚函数(两种情况下都意味着类有
自己的虚函数表)。那么,设置正确的关联后,将存在一个指针(v-ptr),它正确地指向
了该虚函数表(v-table)。此后,多态才能表现出所期望的正确行为。

再次指出(次序的重要性),设置关联,或者狭义地说,设置v-ptr必须发生在对基类构造
函数的调用之后。因为,继承类如果有自己的虚函数表,那么v-ptr会被改写,以指向该表
,即使此前v-ptr已经被基类所设置。这是合法的,也正是所期望的。但是语义上我们绝不
允许基类可以改写继承类所设置的v-ptr。如果v-ptr设置发生在基类构造调用之前,那么
这种非法的一幕就会发生。

所有上述的事情完成之后(对于用户来说它们几乎是隐含的),才真正开始执行用户的初
始化代码。

在构造函数中调用虚函数,会发生什么?发生的情况也许是始料未及的。当前类的对象正
在构建,v-ptr指向的是当前类的虚函数表。此时,还没到继承类执行它自己的代码(如设
置v-ptr,执行初始化代码)的时候,那一切发生在当前类的构造函数执行完毕之后。所以
,将要执行的是本类的虚函数版本,而不是可能被覆盖的继承类的版本。这里有一个反面
的证据。假如v-ptr设置发生在基类构造函数调用之前,让我们有机会调用继承类的虚函数
版本,这意味着什么?继承类还没有完成初始化(因而对象还没有构建好),我们企图在
一个没有构建好的对象上执行它的成员函数,可以想见后果是灾难性的。

类的构造函数何时被调用?在对象被创建的时候。对象可能位于栈上,全局数据区,或堆
上。对象可能会在声明的地方创建,这样的对象位于栈上或全局数据区。对象也可以使用
new操作符动态地创建,这样的对象将位于堆上。

析构函数
析构函数提供了一种和构造函数相反的机制,允许在销毁一个对象之前(亦即回收对象所
占用的空间),让对象释放自己所使用的资源。再次,这里所关心的是语言底层所发生的
事情。

与构造函数的执行次序刚好相反,析构函数从最外层的类开始执行,最基础的类的析构函
数最后执行,看起来就象一层层的剥壳。这个次序要得到严格保证的理由也是明显的(违
反次序又将产生相关性问题)。结果看起来是这样的:用户析构代码->成员对象析构->基
类析构函数。

析构函数在对象行将被销毁时调用。当对象超出其作用域,例如一个函数内部的局部变量
,在函数返回时将被自动销毁。对于在堆上创建的对象,我们只能通过对象指针p,执行d
elete p来销毁它。现在就有一个问题。p指向了某个类型(例如A)的对象,但是却可能是
通过上溯造型(upcast)得来的,因此它指向的实际上是一个B的对象,那么将发生什么?


显然,正确的做法是把对象作为B的实例来销毁。也就是说,调用B的析构函数。我们也许
很快想到,首先,在运行时刻可以知道这个对象“实际上”是什么类型(那个最晚的派生
类,本例中假定就是B),例如通过RTTI。然后,根据实际的类型,“找到”并调用该类的
析构函数。然而,这个想法不会自然实现,需要在编译时把类的类型信息和一个指向析构
函数的指针关联起来。不过,上述考虑似乎复杂了点。我们可以把这个析构函数指针放入
虚函数表中(某个特别的位置)。理由是,与虚函数非常相似,存储这个析构函数指针的
slot可以为继承类所覆盖(从而指向继承类的析构函数),而且继承类应该总是覆盖它(
然而下面将看到,实际的情况与“总是覆盖”有出入)。

无论怎样,析构函数指针是存储在前面称之的类关联信息(表)中,编译器不难找到。因
而当通过对象指针(即使已经上溯造型)执行删除操作时,总是能够调用正确的析构函数
。但是,在多个编译器的试验发现,只有把基类的析构函数声明为虚的,继承类才会覆盖
它。看来的确采用虚函数的技术来对待析构函数了。如果基类的析构函数没有声明为虚的
,则执行前面的delete p时,只有A的析构函数得到执行,而B的没有执行!

这是令人惊讶的。竟然允许“不完全”析构的情况发生,把选择权以及责任交给了程序员
!我不能确定这么做的主要理由是什么。也许还是因为C++如此看重效率,它迫使程序员在
必要的时候显式声明他的需求,因为类似虚函数的间接调用要占用额外的空间和时间。


前面考察了虚函数在构造函数中的行为,继续来看一下在析构函数中的情形。因为析构过
程是自外向里的,当前类在调用虚函数的时候,其继承类此前已经完成了析构,不再是可
用的。因而,是不可以调用继承类的虚函数版本的。结果,调用的仍然是本地版本(和构
造函数中的结论一样,虚函数机制被忽略)。

Refer to <Thingking in C++> ,Bruce Eckel!

posted on 2007-11-16 22:11  J.D Huang  阅读(980)  评论(0编辑  收藏  举报