1 前言
了解你所使用的编程语言究竟是如何实现的,对于C++程序员可能特别有意义。首 先,它可以去除我们对于所使用语言的神秘感,使我们不至于对于编译器干的活感到完全不可思议;尤其重要的是,它使我们在Debug和使用语言高级特性的时 候,有更多的把握。当需要提高代码效率的时候,这些知识也能够很好地帮助我们。
本文着重回答这样一些问题:
1* 类如何布局?
2* 成员变量如何访问?
3* 成员函数如何访问?
4* 所谓的“调整块”(adjuster thunk)是怎么回事?
5* 使用如下机制时,开销如何:
* 单继承、多重继承、虚继承
* 虚函数调用
* 强制转换到基类,或者强制转换到虚基类
* 异常处理
首先,我们顺次考察C兼容的结构(struct)的布局,单继承,多重继承,以及虚继承;
接着,我们讲成员变量和成员函数的访问,当然,这里面包含虚函数的情况;
再接下来,我们考察构造函数,析构函数,以及特殊的赋值操作符成员函数是如何工作的,数组是如何动态构造和销毁的;
最后,简单地介绍对异常处理的支持。
对每个语言特性,我们将简要介绍该特性背后的动机,该特性自身的语意(当然,本 文决不是“C++入门”,大家对此要有充分认识),以及该特性在微软的 VC++中是如何实现的。这里要注意区分抽象的C++语言语意与其特定实现。微软之外的其他C++厂商可能提供一个完全不同的实现,我们偶尔也会将 VC++的实现与其他实现进行比较。
2 类布局
本节讨论不同的继承方式造成的不同内存布局。
2.1 C结构(struct)
由于C++基于C,所以C++也“基本上”兼容C。特别地,C++规范在“结构”上使用了和C相同的,简 单的内存布局原则:成员变量按其被声明的顺序排列,按具体实现所规定的对齐原则在内存地址上对齐。 所有的C/C++厂商都保证他们的C/C++编译器对于有效的C结构采用完全相同的布局。这里,A是一个简单的C结构,其成员布局和对齐方式都一目了然
struct A {
char c;
int i;
};
译者注:从上图可见,A在内存中占有8个字节,按照声明成员的顺序,前4个字节包含一个字符(实际占用1个字节,3个字节空着,补对齐),后4个字节包含一个整数。A的指针就指向字符开始字节处。
2.2 有 C++ 特征的 C 结构
当然了,C++不是复杂的C,C++本质上是面向对象的语言:包 含 继承、封装,以及多态 。原始的C结构经过改造,成了面向对象世界的基石——类。除了成员变量外,C++类还可以封装成员函数和其他东西。然而,有趣的是, 除非 为了实现虚函数和虚继承引入的隐藏成员变量外,C++类实例的大小完全取决于一个类及其基类的成员变量!成员函数基本上不影响类实例的大小。
这里提供的B是一个C结构,然而,该结构有一些C++特征:控制成员可见性的“public/protected/private”关键字、成员函数、静态成员,以及嵌套的类型声明。虽然看着琳琅满目,实际上, 只有成员变量才占用类实例的空间 。要注意的是,C++标准委员会不限制由“public/protected/private”关键字分开的各段在实现时的先后顺序,因此,不同的编译器实现的内存布局可能并不相同。 ( 在VC++中,成员变量总是按照声明时的顺序排列)。
struct B {
public :
int bm1;
protected :
int bm2;
private :
int bm3;
static int bsm;
void bf();
static void bsf();
typedef void * bpv;
struct N { };
};
译者注:B中,为何static int bsm不占用内存空间?因为它是 静态成员,该数据存放在程序的数据段 中,不在类实例中。
2.3 单继承
C++ 提供继承的目的是在不同的类型之间提取共性。比如,科学家对物种进行分类,从而有种、属、纲等说法。有了这种层次结构,我们才可能将某些具备特定性质的东 西归入到最合适的分类层次上,如“怀孩子的是哺乳动物”。由于这些属性可以被子类继承,所以,我们只要知道“鲸鱼、人”是哺乳动物,就可以方便地指出“鲸 鱼、人都可以怀孩子”。那些特例,如鸭嘴兽(生蛋的哺乳动物),则要求我们对缺省的属性或行为进行覆盖。
C++中的继承语法很简单,在子类后加上“:base”就可以了。下面的D继承自基类C。
struct C {
int c1;
void cf();
};
struct D : C {
int d1;
void df();
};
既然派生类要保留基类的所有属性和行为,自然地,每个派生类的实例都包含了一份完整的基类实例数据。在D中,并不是说基类C的数据一定要放在D的数据之前,只不过这样放的话,能够保证D中的C对象地址,恰好是D对象地址的第一个字节。这种安排之下, 有了派生类D的指针,要获得基类C的指针,就不必要计算偏移量 了。几乎所有知名的C++厂商都采用这种内存安排(基类成员在前)。 在单继承类层次下,每一个新的派生类都简单地把自己的成员变量添加到基类的成员变量之后 。 看看上图,C对象指针和D对象指针指向同一地址。
2.4 多重继承
大多数情况下,其实单继承就足够了。但是,C++为了我们的方便,还提供了多重继承。
比如,我们有一个组织模型,其中有经理类(分任务),工人类(干活)。那么,对 于一线经理类,即既要从上级经理那里领取任务干活,又要向下级工人分任务的角色来说,如何在类层次中表达呢?单继承在此就有点力不胜任。我们可以安排经理 类先继承工人类,一线经理类再继承经理类,但这种层次结构错误地让经理类继承了工人类的属性和行为。反之亦然。当然,一线经理类也可以仅仅从一个类(经理 类或工人类)继承,或者一个都不继承,重新声明一个或两个接口,但这样的实现弊处太多:多态不可能了;未能重用现有的接口;最严重的是,当接口变化时,必 须多处维护。最合理的情况似乎是一线经理从两个地方继承属性和行为——经理类、工人类。
C++就允许用多重继承来解决这样的问题:
struct Manager ... { ... };
struct Worker ... { ... };
struct MiddleManager : Manager, Worker { ... };
这样的继承将造成怎样的类布局呢?下面我们还是用“字母类”来举例:
struct E {
int e1;
void ef();
};
struct F : C, E {
int f1;
void ff();
};
结构F从C和E多重继承得来。与 单继承相同的是,F实例拷贝了每个基类的所有数据。 与单继承不同的是,在多重继承下,内嵌的两个基类的对象指针不可能全都与派生类对象指针相同:
F f;
// (void*)&f == (void*)(C*)&f;
// (void*)&f < (void*)(E*)&f;
译者注:上面那行说明C对象指针与F对象指针相同,下面那行说明E对象指针与F对象指针不同。
观察类布局,可以看到F中内嵌的E对象,其指针与F指针并不相同。正如后文讨论强制转化和成员函数时指出的,这个偏移量会造成少量的调用开销。
具体的编译器实现可以自由地选择内嵌基类和派生类的布局。 VC++按照基类的 声明顺序 先排列基类实例数据,最后才排列派生类数据。 当然,派生类数据本身也是按照声明顺序布局的( 本规则并非一成不变 ,我们会看到,当一些基类有虚函数而另一些基类没有时,内存布局并非如此)。
2.5 虚继承
回到我们讨论的一线经理类例子。让我们考虑这种情况:如果经理类和工人类都继承自“雇员类”,将会发生什么?
struct Employee { ... };
struct Manager : Employee { ... };
struct Worker : Employee { ... };
struct MiddleManager : Manager, Worker { ... };
如果经理类和工人类都继承自雇员类,很自然地,它们每个类都会从雇员类获得一份数据拷贝。 如 果不作特殊处理,一线经理类的实例将含有两个 雇员类实例,它们分别来自两个雇员基类 。 如果雇员类成员变量不多,问题不严重;如果成员变量众多,则那份多余的拷贝将造成实例生成时的严重开销。更糟的是,这两份不同的雇员实例可能分别被修改,造成数据的不一致。因此,我们需要让经理类和工人类进行特殊的声明,说明它们愿意共享一份雇员基类实例数据。
很不幸,在C++中,这种“共享继承”被称为 “虚继承” ,把问题搞得似乎很抽象。虚继承的语法很简单,在指定基类时加上virtual关键字即可。
struct Employee { ... };
struct Manager : virtual Employee { ... };
struct Worker : virtual Employee { ... };
struct MiddleManager : Manager, Worker { ... };
使用虚继承,比起单继承和多重继承有更大的实现开销、调用开销。回忆一下, 在单继承和多重继承的情况下,内嵌的基类实例地址比起派生类实例地址来,要么地址相同(单继承,以及多重继承的最靠左基类) ,要么地址相差一个固定偏移量(多重继承的非最靠左基类) 。 然而,当虚继承时,一般说来,派生类地址和其虚基类地址之间的偏移量是不固定的,因为如果这个派生类又被进一步继承的话,最终派生类会把共享的虚基类实例数据放到一个与上一层派生类不同的偏移量处。 请看下例:
struct G : virtual C {
int g1;
void gf();
};
译者注:GdGvbptrG(In G, the displacement of G’s virtual base pointer to G)意 思是:在G中,G对象的指针与G的虚基类表指针之间的偏移量,在此可见为0,因为G对象内存布局第一项就是虚基类表指针; GdGvbptrC(In G, the displacement of G’s virtual base pointer to C)意思是:在G中,C对象的指针与G的虚基类表指针之间的偏移量,在此可见为8。
struct H : virtual C {
int h1;
void hf();
};
struct I : G, H {
int i1;
void _if();
};
暂时不追究vbptr成员变量从何而来。 从上面这些图可以直观地看到,在G对象中,内嵌的C基类对象的数据紧跟在G的数据之后,在H对象中,内嵌的C基类对象的数据也紧跟在H的数据之后。但是, 在I对象中,内存布局就并非如此了。VC++实现的内存布局中,G对象实例中G对象和C对象之间的偏移,不同于I对象实例中G对象和C对象之间的偏移。当 使用指针访问虚基类成员变量时,由于指针可以是指向派生类实例的基类指针,所以,编译器不能根据声明的指针类型计算偏移,而必须找到另一种间接的方法,从 派生类指针计算虚基类的位置。
在VC++ 中,对每个继承自虚基类的类实例,将增加一个隐藏的 “虚基类表指针”(vbptr) 成员变量,从而达到间接计算虚基类位置的目的。该变量指向一个全类共享的偏移量表,表中项目记录了对于该类 而言,“虚基类表指针”与虚基类之间的偏移量。
其它的实现方式中,有一种是在派生类中使用指针成员变量。这些指针成员变量指向派生类的虚基类,每个虚基类一个指针。这种方式的优点是:获取虚基类地址时, 所用代码比较少。然而,编译器优化代码时通常都可以采取措施避免重复计算虚基类地址。况且,这种实现方式还有一个大弊端:从多个虚基类派生时,类实例将占 用更多的内存空间;获取虚基类的虚基类的地址时,需要多次使用指针,从而效率较低等等。
在VC++中,G拥有一个隐藏的“虚基类表指针”成员,指向一个虚基类表,该表的第二项是 G dGvbptrC。(在G中,虚基类对象C的地址与G的“虚基类表指针”之间的偏移量 ( 当对于所有的派生类来说偏移量不变时,省略“d”前的前缀))。比如,在32位平台上,GdGvptrC是8个字节。同样,在I实例中的G对象实例也有 “虚基类表指针”,不过该指针指向一个适用于“ G处于I之中” 的虚基类表,表中一项为IdGvbptrC,值为20。
观察前面的G、H和I, 我们可以得到如下关于VC++虚继承下内存布局的结论:
1 首先排列非虚继承的基类实例;
2 有虚基类时,为每个基类增加一个隐藏的vbptr,除非已经从非虚继承的类那里继承了一个vbptr;
3 排列派生类的新数据成员;
4 在实例最后,排列每个虚基类的一个实例。
该布局安排使得虚基类的位置随着派生类的不同而“浮动不定”,但是,非虚基类因此也就凑在一起,彼此的偏移量固定不变。
3 成员变量
介绍了类布局之后,我们接着考虑对不同的继承方式,访问成员变量的开销究竟如何。
没有继承:没有任何继承关系时,访问成员变量和C语言的情况完全一样:从指向对象的指针,考虑一定的偏移量即可。
C* pc;
pc->c1; // *(pc + dCc1);
译者注:pc是指向C的指针。a. 访问C的成员变量c1,只需要在pc上加上固定的偏移量dCc1(在C中,C指针地址与其c1成员变量之间的偏移量值),再获取该指针的内容即可。
单继承:由于派生类实例与其基类实例之间的偏移量是常数0,所以,可以直接利用基类指针和基类成员之间的偏移量关系,如此计算得以简化。
D* pd;
pd->c1; // *(pd + dDC + dCc1); // *(pd + dDc1);
pd->d1; // *(pd + dDd1);
译者注:D从C单继承,pd为指向D的指针。a. 当访问基类成员c1时,计算步骤本来应该为“pd+dDC+dCc1”,即为先计算D对象和C对象之间的偏移,再在此基础上加上C对象指针与成员变量c1 之间的偏移量。然而,由于dDC恒定为0,所以直接计算C对象地址与c1之间的偏移就可以了。
b. 当访问派生类成员d1时,直接计算偏移量。
多重继承:虽然派生类与某个基类之间的偏移量可能不为0,然而,该偏移量总是一个常数。只要是个常数,访问成员变量,计算成员变量偏移时的计算就可以被简化。可见即使对于多重继承来说,访问成员变量开销仍然不大。
F* pf;
pf->c1; // *(pf + dFC + dCc1); // *(pf + dFc1);
pf->e1; // *(pf + dFE + dEe1); // *(pf + dFe1);
pf->f1; // *(pf + dFf1);
译者注:F继承自C和E,pf是指向F对象的指针。a. 访问C类成员c1时,F对象与内嵌C对象的相对偏移为0,可以直接计算F和c1的偏移;
b. 访问E类成员e1时,F对象与内嵌E对象的相对偏移是一个常数,F和e1之间的偏移计算也可以被简化;
c. 访问F自己的成员f1时,直接计算偏移量。
虚继承: 当类有虚基类时,访问非虚基类的成员仍然是计算固定偏移量的问题。然而,访问虚基类的成员变量,开销就增大了, 因为必须经过如下步骤才能获得成员变量的地址:
1. 获取“虚基类表指针”;
2. 获取虚基类表中某一表项的内容;
3. 把内容中指出的偏移量加到“虚基类表指针”的地址上。
然而,事情并非永远如此。正如下面访问I对象的c1成员那样, 如果不是通过指针访问,而是直接通过对象实例,则派生类的布局可以在编译期间静态获得,偏移量也可以在编译时计算,因此也就不必要根据虚基类表的表项来间接计算了。
I* pi;
pi->c1; // *(pi + dIGvbptr + (*(pi+dIGvbptr))[1] + dCc1);
pi->g1; // *(pi + dIG + dGg1); // *(pi + dIg1);
pi->h1; // *(pi + dIH + dHh1); // *(pi + dIh1);
pi->i1; // *(pi + dIi1);
I i;
i.c1; // *(&i + IdIC + dCc1); // *(&i + IdIc1);
译者注:I继承自G和H,G和H的虚基类是C,pi是指向I对象的指针。a. 访问虚基类C的成员c1时,dIGvbptr是“在I中,I对象指针与G的“虚基类表指针”之间的偏移”,*(pi + dIGvbptr)是虚基类表的开始地址,*(pi + dIGvbptr)[1]是虚基类表的第二项的内容(在I对象中,G对象的“虚基类表指针”与虚基类之间的偏移),dCc1是C对象指针与成员变量c1之 间的偏移;
b. 访问非虚基类G的成员g1时,直接计算偏移量;
c. 访问非虚基类H的成员h1时,直接计算偏移量;
d. 访问自身成员i1时,直接使用偏移量;
e. 当声明了一个对象实例,用点“.”操作符访问虚基类成员c1时,由于编译时就完全知道对象的布局情况,所以可以直接计算偏移量。
当访问类继承层次中,多层虚基类的成员变量时,情况又如何呢?比如,访问虚基类 的虚基类的成员变量时?一些实现方式为:保存一个指向直接虚基类的指针,然后就可以从直接虚基类找到它的虚基类,逐级上推。VC++优化了这个过程。 VC++在虚基类表中增加了一些额外的项,这些项保存了从派生类到其各层虚基类的偏移量。
4 强制转化
如果没有虚基类的问题,将一个指针强制转化为另一个类型的指针代价并不高昂。如果在要求转化的两个指针之间有“基类-派生类”关系,编译器只需要简单地在两者之间加上或者减去一个偏移量即可(并且该量还往往为0)。
F* pf;
(C*)pf; // (C*)(pf ? pf + dFC : 0); // (C*)pf;
(E*)pf; // (E*)(pf ? pf + dFE : 0);
C和E是F的基类,将F的指针pf转化为C*或E*,只需要将pf加上一个相应的偏移量。转化为C类型指针C*时,不需要计算,因为F和C之间的偏移量为 0。转化为E类型指针E*时,必须在指针上加一个非0的偏移常量dFE。 C ++规范要求NULL指针在强制转化后依然为NULL , 因此在做强制转化需要的运算之前,VC++会检查指针是否为NULL。当然,这个检查只有当指针被显示或者隐式转化为相关类型指针时才进行;当在派生类对 象中调用基类的方法,从而派生类指针在后台被转化为一个基类的Const “this” 指针时,这个检查就不需要进行了,因为在此时,该指针一定不为NULL。
正如你猜想的, 当继承关系中存在虚基类时,强制转化的开销会比较大。具体说来,和访问虚基类成员变量的开销相当。
I* pi;
(G*)pi; // (G*)pi;
(H*)pi; // (H*)(pi ? pi + dIH : 0);
(C*)pi; // (C*)(pi ? (pi+dIGvbptr + (*(pi+dIGvbptr))[1]) : 0);
译者注:pi是指向I对象的指针,G,H是I的基类,C是G,H的虚基类。a. 强制转化pi为G*时,由于G*和I*的地址相同,不需要计算;
b. 强制转化pi为H*时,只需要考虑一个常量偏移;
c. 强制转化pi为C*时,所作的计算和访问虚基类成员变量的开销相同,首先得到G的虚基类表指针,再从虚基类表的第二项中取出G到虚基类C的偏移量,最后根据pi、虚基类表偏移和虚基类C与虚基类表指针之间的偏移计算出C*。
一般说来,当从派生类中访问虚基类成员时,应该先强制转化派生类指针为虚基类指针,然后一直使用虚基类指针来访问虚基类成员变量。这样做,可以避免每次都要计算虚基类地址的开销。见下例。
/* before: */ … pi->c1 … pi->c1 …
/* faster: */ C* pc = pi; … pc->c1 … pc->c1 …
译者注:前者一直使用派生类指针pi,故每次访问c1都有计算虚基类地址的较大开销;后者先将pi转化为虚基类指针pc,故后续调用可以省去计算虚基类地址的开销。
5 成员函数
一个C++成员函数只是类范围内的又一个成员。 X类每一个非静态的成员函数都会接受一个特殊的隐藏参数——this指针,类型为X* const。 该指针在后台初始化为指向成员函数工作于其上的对象。同样,在成员函数体内,成员变量的访问是通过在后台计算与this指针的偏移来进行。
struct P {
int p1;
void pf(); // new
virtual void pvf(); // new
};
P有一个非虚成员函数pf(),以及一个虚成员函数pvf()。很明显,虚成员 函数造成对象实例占用更多内存空间,因为虚成员函数需要虚函数表指针。这一点以后还会谈到。这里要特别指出的是,声明非虚成员函数不会造成任何对象实例的 内存开销。现在,考虑P::pf()的定义。