读【深度探索C++对象模型】【中】
【构造和析构函数】
通常我们的看法是:当定义一个类的时候,如果没有为它写一个构造函数,系统将帮我们生成一个,并完成成员的初始化。但是,从编译器来看,上述看法中的两点认识都不够正确。编译器只会在编译需要的情况下(nontrivial的条件)自动生成默认构造函数构造函数。一般包括下面四种情况:1.类中包含的数据成员有默认构造函数;2.其基类包含默认构造函数;3.具有虚成员函数;4.虚继承至某个类。其他情况被称为是trivial,编译器将不会添加一个默认构造函数。把握好编译器需要才添加这个前提,很容易理解为什么要添加默认构造函数。默认函数完成编译器需要做的一些事情,包括初始化基类,虚指针等。搞清楚这个概念有助于了解构造函数的存在意义。构造函数是为了完成对象的初始化(即初始化分配来的空间),与空间的分配没有关系,而我们会经常自觉不自觉的混淆这个概念,在处理一些问题(比如new操作符)的时候,理解不够清晰。现在了解了编译器的原理,可以加深对构造函数的认识。
另外如果一个类中被定义了一个构造函数,编译器也会根据它的需求扩充该构造函数。考虑下面一段代码:
class A
{
virtual void aMethod() {}
};class B :
public A
{
public:
A(int i) : aValue(i) {}virtual void aMethod() {}
private:
int aValue;
};
它的构造函数会被扩充成大概下面的样子:
A* A::A(A* this, int i)
{
// 1.如果有虚基类会被先行调用// 2.基类的构造函数被调用(可视为递归过程)
B::B();// 3.初始化虚指针
// 4.展开初始化列表,如果有数据元素有默认构造函数将被调用
aValue(i);// 5.构造函数实体
}
请注意虚表和虚指针被初始化的位置。它处于基类构造函数被调用后,本身内容被执行之前。也就是如果在该类基类的构造函数中放置一个虚函数,它执行的内容是定义在基类本身的版本(也许你想调用子类的实现版本),这个概念与其他的虚函数的调用不一样(其他调用的都是属于子类的版本)。因为在基类构造的时候,虚指针依然指向基类的虚表,只有初始化虚指针后才会生成子类的虚表。这中虚表构建的方式和其他一些语言会有所不同(比如C#),所以当你在你的构造函数中放置虚函数时(通常不建议这样做),请保证你是真的要使用本身版本的虚函数。
默认析构函数与默认构造函数的问题类似。即,只有在编译期需要的时候生成,目地是完成清理的工作,与空间回收无关。而且,析构函数并不一定要于构造函数配对。比如类中只是有一个虚函数的时候,就不必生成默认析构函数。所有的析构和构造对称出现的想法,都只是我们出于美学的主观臆断罢了。而对析构函数的扩展,其顺序与构造函数相反,这一点倒是符合了对称性。
【拷贝构造函数】
C++的每个类型中都有一个拷贝构造函数(伴随着一个复制操作符),如果类中没有显性的声明,系统会安插一个(想一想,为什么是必须添加的),以完成由一个已有对象复制生成新的对象。拷贝构造函数可以分成两种。一种是按位拷贝(bitwise copy),另一种是按成员拷贝(member copy)。所谓按位拷贝就是指把一个对象在内存中的每一位,一一拷贝到另一个新的位置,它的特点是拷贝速度快。而按函数拷贝就是分别调用各成员的拷贝构造函数,依次拷贝所需的成员,其特点是控制力强,可以选择需要拷贝的成员,或者对成员做特殊的处理。系统自动生成的拷贝构造函数通常是按位拷贝,但在某些情况(这些情况和上面生成构造函数的情况类似)下会采用按成员拷贝。
为什么需要按函数拷贝,举个例子就理解这个问题。假设下面这样一个继承体系:设定B类是A类的子类,且A, B的对象大小不等,并B重载A的一个虚方法。当一个B对象拷贝到另一个B对象中,按位拷贝不会出现问题。但如果把一个B对象拷贝到一个A对象上,再简单的应用按位拷贝就会出现问题。可能vptr被切割掉了(如果vptr放在对象的最后),要不即使不被切割掉也不会被正确设置(A对象和B对象的虚指针指向了同一个虚表)。因此在这种场合下,按位拷贝就不够用了,系统会安插一个具有成员拷贝的拷贝构造函数,对虚指针的拷贝问题进行特别的处理。
理解这个问题的益处在于可以帮助写出更有效的代码。通常我们写类的拷贝构造函数都会采用成员拷贝的方式。但其实很多时候更好的方式是先做一个按位拷贝,再修改不符合按位拷贝的内容。这样做不仅编码简单清晰,运行的也更加高效。
理解该问题另一个益处是帮助我们理解自定义拷贝构造函数的时机。编译器安插的拷贝构造函数,无论是哪种方式,都是一个浅拷贝。因为这对于编译器来说,以足够保证它不会出错了。但如果我们类型中涉及到指针类型的成员时候,我们可能需要的是一个深拷贝。这对于编译器来说不重要,但对于保证我们所需的程序逻辑是很重要的。只有在这种编译器无法满足需求的情况下才使用自定义拷贝构造函数,除此之外都不用画蛇添足。因为不但增加了工作量,还很有可能降低了效率(当用成员拷贝顶替了位拷贝时)。
【动态分配】
众所周知,C++在运行期间在堆上管理内存通常采用new和delete操作符。但new操作有时候不仅仅是等价于调用new操作符以完成空间的分配。比如int *pi = new int(5)。它相当于先调用new操作符:int *pi = __new(sizeof(int)),用以完成空间分配;再调用*pi = 5,用以完成初值设定。而深入一些来说,new操作符所作的内存分配也不是简单的按需分配。当分配失败的时候,会调用new_handler来进行一些处理(可能包括回收空间,输出信息等等),然后再重新分配直至分配成功或new_handler不再处理位置。因此,我们有时候只需要重写new_handler的行为就可以达到我们的目的(比如自定义重分配方式),而不再需要重写new操作符,这样降低了工作的难度。只有真正需要改变内存分配策略的时候,才需要重载new操作符。
相比较delete操作符所做的事情比较纯粹,就是释放对象占用的堆中的空间。但我们如何知道对象占用了多大的空间,我们该释放多少空间呢?常规的做法是在new的时候安插一个cookie,里面存放对象大小等信息,在delete的时候,函数访问该cookie,重而保证正确的释放。很明显,cookie的插入增加了对象的大小,降低了操作的效率,还会出现安全性的问题。所以当程序中有大量的小对象存在,或者是需要频繁获取和释放空间的时候,往往会常用一些更具技巧性的分配方案,以满足需求(比如,池化分配)。
数组的分配与单个对象的分配问题类似,可以简单的视为连续分配n个对象空间。只是为了保证delete的正确执行,在数组对象分配的时候,还需要多安插一些信息。
而除了常见的new操作符外,C++还支持另一种被称为Placement operator new的操作符,它多接受一个void*指针类型,默认情况下,是用于在指定地址开始分配空间(更确切的说法应该在该子句前加上优先)。当然也有的时候,通过重载,void*也会指向其他对象(比如ostream对象),用已完成其他的特殊功能(比如书写日志)。不论哪种,都需要保证placement new和placement delete成对出现,这样才能完成正常的空间回收。
【变量管理】
当在一个函数中构造了一个对象(栈中),会在离开函数域的时候析构这个对象。如果在函数头构造了该对象,而该函数有多个出口(return语句),编译器就会在每个出口前调用该对象析构函数。因此,我们最好在第一次使用该对象前构造该对象,这不仅仅是一种良好的编程习惯,有利于阅读和理解,并且也是一种减少无谓的代码损失的好方法。
另外一个特殊变量是全局变量。程序中任何代码调用全局变量的时候,都要保证全局变量已经按要求完成初始化。因此,系统会需要在进入main函数后首先初始化全局变量,在离开main函数前,清理全局变量。一种实现方法,是在编译每个全局变量的时候,为该变量添加一个用于构造的函数(eg. __sti_xxx())和用于释放的函数(eg. __std_xxx())。完成编译后,还需要收集所有用于全局变量构造和释放的函数把它们放入两个特殊的函数中(eg. _main()和exit())。把它们分别放在main()函数的入口和出口处。用于在进入main后初始化和离开main前解构。收集的具体实现取决于编译器,如果编译器支持跨平台性,对文件的操作要特别小心,避免调用平台文件格式相关的操作代码。
posted on 2007-05-19 00:31 duguguiyu 阅读(1775) 评论(1) 编辑 收藏 举报