普通的虚继承 下面我们来看虚继承。首先看看这C020类,它从C010虚继承: struct C010 { C010() : c_(0x01) {} void foo() { c_ = 0x02; } char c_; }; struct C020 : public virtual C010 { C020() : c_(0x02) {} char c_; }; 运行如下代码,查看对象的内存布局: PRINT_SIZE_DETAIL(C020) 结果为: The size of C020 is 6 The detail of C020 is c0 c2 45 00 02 01 很明显对象的起始处是一个指针,然后是子类的成员变量,接下来是父类的成员变量。和以前的讨论不同的是由于使用了虚继承,父类的成员变量被放到了最后面。 运行如下的代码: C020 c020; c020.C010::c_ = 0x04; 由于子类中的变量和父类中的变量重名,所以我们必须用这种方式来访问属于父类的成员变量,普通情况下不需要这种写法。我们看看后面这行代码对应的汇编代码: 0042387E mov eax,dword ptr [ebp+FFFFF82Ch] 00423884 mov ecx,dword ptr [eax+4] 00423887 mov byte ptr [ebp+ecx+FFFFF82Ch],4 前面说过对象的起始是一个指针,第1行指令取到这个指针的值,第2行把这个指针指向的地址后移4字节后的值(做为一个4字节的值)取出来。执行完这句我们看看ecx寄存器,可知取出来的值为5。最后一行是真正的赋值指令,它通过在对象的起始处(即[ebp+FFFFF32Ch])加上ecx中的值做偏移值(即5)来得到赋值的目的地址。接合前面的对象布局输出,我们可以发现从对象起始地址开始加5字节的偏移值,刚好得到父类的成员变量的地址。这样我们可以大致分析出直接虚继承的子类的对象布局。 |子类5 |父类1 | |偏移值指针4,5|子类成员变量1|父类成员变量1| (注:第一个数字为所在区域的长度(字节数),偏移值指针后的第二个数字为该指针指向的偏移值。后同。) 通过查看内存可以发现偏移值指针指向的内存前4字节为0,我不知道它的具体的用途是什么。接下来的4字节是一个32位的整数,也就是真正的偏移值。即从子类的起始位置到被虚继承的父类的起始位置的偏移值,在我们前面的例子中这个值为5(一个指针加一个char成员变量)。 通过这个分析我们可以看到在虚承继的情况下,通过子类的对象访问父类的普通成员变量的效率是相当低的。如果必须用到虚继承,也应该尽量不要在父类中放置普通成员变量(静态成员变量不受影响)。 另外为什么微软不把偏移值直接放到子类中,而是采用偏移值指针。我想是因为采用指针的方式更为灵活,即使以后需要扩展也不影响类对象的布局。 按下来我们再看看这几行代码: PRINT_OBJ_ADR(c020); C010 * pt = &c020; PRINT_PT(pt); pt->c_ = 0x03; 第2行声明了一个父类指针,并让它指向一个子类的对象。第3行打印出这个指针的值。运行结果为: c020's address is : 0012F708 pt's value is : 0012F70D 我们可以看到赋值后的指针的值并不等于赋给它的对象地址值。也就是说在这个赋值过程中编译器进行了额外的工作,即调整了指针的值。我们看看第2行对应的汇编代码,看看编译器究竟做了些什么? 01 004238EA lea eax,[ebp+FFFFF82Ch] 02 004238F0 test eax,eax 03 004238F2 jne 00423900 04 004238F4 mov dword ptr [ebp+FFFFF014h],0 05 004238FE jmp 00423916 06 00423900 mov ecx,dword ptr [ebp+FFFFF82Ch] 07 00423906 mov edx,dword ptr [ecx+4] 08 00423909 lea eax,[ebp+edx+FFFFF82Ch] 09 00423910 mov dword ptr [ebp+FFFFF014h],eax 10 00423916 mov ecx,dword ptr [ebp+FFFFF014h] 11 0042391C mov dword ptr [ebp+FFFFF820h],ecx 喔!比想象的要复杂的多。一行简单的指针赋值语句却产生了这么多的汇编代码。这行代码本身的语义是取对象的地址赋给一个指针,对于编译器来说它把这做为指针到指针的赋值来处理。由于牵涉到了向上的类型转换,同时又有虚继承存在。根据前面的布局分析,在虚继承的情况下,父类位于对象布局的后部。因此在这里要做一个指针位置的调整。由于调整要根据源指针来进行计算,所以先要对源指针的合法性进行检查,以避免运行时的指针异常错误。前3行的汇编指令就是在做这件事,检查源指针是否为NULL。如果为NULL则执行4、5、10、11行,最终给pt赋0。如果不为NULL跳至第6行执行到最后。重要的是第6、7、8行代码,它们通过偏移值指针找到偏移值,并以此来调整指针的位置,让目的指针最终指向对象中的父类部分的数据成员。 对比一下普通的指针赋值,我们可以对上面赋值的复杂性和低效有更深的认识。 C010 * pt1 = NULL; C010 * pt2 = pt1; 这两行相应的汇编代码为: 0042397D mov dword ptr [ebp+FFFFF814h],0 00423987 mov eax,dword ptr [ebp+FFFFF814h] 0042398D mov dword ptr [ebp+FFFFF808h],eax 第1行是普通的赋值,编译器并不做任何的检查,即使源指针为NULL。因为它不需要根据源指针(本处为NULL)做任何计算。第2个赋值也很直接,只是通过eax做了一个中转。这里我们就可以看到前面的虚继承下的子类指针到父类指针的赋值是我么的低效。在程序中应尽量的避免这种代码。 (未完待继) 菱形结构的虚继承
菱形结构的虚继承(3) 最后我们看看,如果在上篇例子的基础上,子类及左、右父类都各自定义了自己的虚函数,这时的情况又会怎样。 struct C140 : public virtual C041 { C140() : c_(0x02) {} virtual void foo() { c_ = 0x11; } char c_; }; struct C160 : public virtual C041 { C160() : c_(0x02) {} virtual void foo() { c_ = 0x12; } virtual void f160() { c_ = 0x12; } char c_; }; struct C161 : public virtual C041 { C161() : c_(0x03) {} virtual void foo() { c_ = 0x13; } virtual void f161() { c_ = 0x13; } char c_; }; struct C170 : public C160, public C161 { C170() : c_(0x04) {} virtual void foo() { c_ = 0x14; } virtual void f170() { c_ = 0x14; } char c_; }; 首先运行如下的代码,看看内存的布局。 PRINT_SIZE_DETAIL(C041) PRINT_SIZE_DETAIL(C160) PRINT_SIZE_DETAIL(C161) PRINT_SIZE_DETAIL(C170) 结果为: The size of C041 is 5 The detail of C041 is f0 b2 45 00 01 The size of C160 is 18 The detail of C160 is 84 b3 45 00 88 b3 45 00 02 00 00 00 00 80 b3 45 00 01 The size of C161 is 18 The detail of C161 is 98 b3 45 00 9c b3 45 00 03 00 00 00 00 94 b3 45 00 01 The size of C170 is 28 The detail of C170 is b0 b3 45 00 c8 b3 45 00 02 ac b3 45 00 bc b3 45 00 03 04 00 00 00 00 a8 b3 45 00 01 C170对象的布局为: |C160,9 |C161,9 |C170,1 |zero,4 |C041,5 | |vp,4 |op,4,19 |m,1 |vp,4 |op,4,10 |m,1 |m,1 | |vp,4 |m1 | (注:为了不折行,我用了缩写。op代表偏移值指针、m代表成员变量、vp代表虚表指针。第一个数字是该区域的大小,即字节数。只有偏移值指针有第二个数字,第二个数字就是偏移值指针指向的偏移值的大小。) 左右父类由于各自定义了自己的新的虚函数,因此都拥有了自己的虚表指针。奇怪的是子类虽然也定义了自己的新的虚函数,我们在上面的布局中却看到它并没有自己的虚表指针。那么它应该是和顶层类或是某一父类共用了虚表。我们可以在后面通过对调用的跟踪来找到答案。 另一个奇怪的地方是在左右父类中的偏移值指针指向的偏移值不再是到祖父类的偏移量,而是变成了到祖父类之前的4字节0值的偏移量。同时在前面第八篇中我们说过偏移值指针指向的地址的前4个字节为零,接下来的4个字节才是真正的偏移量。在这个例子中,前4个字节不再为0,而是0xFFFFFFFC,即整数-4。 照例我们先通过对象来调用一下。 C170 obj; PRINT_OBJ_ADR(obj); obj.foo(); 结果为: obj's address is : 0012F54C 最后一行调用对应的汇编指令为: 004245B8 lea ecx,[ebp+FFFFF687h] 004245BE call 0041D122 ecx中的值(即this指针的值)为0x0012F563,和前面一样是指向祖父类的起始部分。同样函数中的指令也是通过将this-5字节来定位到正确的成员变量的地址,这里不再列出函数的汇编指令。 再看看调用它自己新定义的虚函数。 obj.f170(); 对应的汇编指令为: 004245C3 lea ecx,[ebp+FFFFF670h] 004245C9 call 0041D127 让我非常惊奇的是这次this指针的值居然是0x0012F54C。和前面的对象地址输出是一样的,也就是指向了整个对象的起始位置。这就让人非常的奇怪了,在同一个对象上调用的两个虚函数,编译器为它们传递的this指针却是不同的。 让我们跟到函数中,看它怎样取得正确的成员变量的地址。 01 00426F80 push ebp 02 00426F81 mov ebp,esp 03 00426F83 sub esp,0CCh 04 00426F89 push ebx 05 00426F8A push esi 06 00426F8B push edi 07 00426F8C push ecx 08 00426F8D lea edi,[ebp+FFFFFF34h] 09 00426F93 mov ecx,33h 10 00426F98 mov eax,0CCCCCCCCh 11 00426F9D rep stos dword ptr [edi] 12 00426F9F pop ecx 13 00426FA0 mov dword ptr [ebp-8],ecx 14 00426FA3 mov eax,dword ptr [ebp-8] 15 00426FA6 mov byte ptr [eax+12h],14h 16 00426FAA pop edi 17 00426FAB pop esi 18 00426FAC pop ebx 19 00426FAD mov esp,ebp 20 00426FAF pop ebp 21 00426FB0 ret 看看第15行可以知道,是直接在this指针上加了18字节(即16进制的12h)来定位到子类的成员变量。 由于函数中的指令是以这种方式来定位子类成员变量,所以即使我们是通过指针来调用,不同的只是怎样定位函数地址,而this指针的值是肯定不会变的。我们来验证一下。 C170 * pt = &obj; pt->f170(); 第二行代码对应的汇编指令如下: 01 004245DA mov eax,dword ptr [ebp+FFFFF664h] 02 004245E0 mov edx,dword ptr [eax] 03 004245E2 mov esi,esp 04 004245E4 mov ecx,dword ptr [ebp+FFFFF664h] 05 004245EA call dword ptr [edx+4] 06 004245ED cmp esi,esp 07 004245EF call 0041DDF2 第一行把整个对象的起始地址放到eax中,第2行把eax当指针,并把所指地址放到edx中。对象的起始地址正好也是左父类中的虚表指针,第5行进行调用的时候果然是把edx指向的地址后移了4字节后取值,做为函数地址。这也就回答了前面的一个问题,子类没有虚表,它的虚表实际合并到了左父类的虚表中,左父类定义了一个自己的虚函数,占用了虚函数表的第一个条目,子类的虚函数则占用了第二个条目。因此在寻址时要加上4个字节。ecx中的this指针值和我们前面估计一样,是整个对象的起始地址。 最后我们看看怎样得到祖父类地址。 pt->C041::c_ = 0x33; 对应的汇编指令为: 01 004245F4 mov eax,dword ptr [ebp+FFFFF664h] 02 004245FA mov ecx,dword ptr [eax+4] 03 004245FD mov edx,dword ptr [ecx+4] 04 00424600 mov eax,dword ptr [ebp+FFFFF664h] 05 00424606 mov byte ptr [eax+edx+8],33h 首先把对象的起始地址赋给eax。第2行把eax+4字节后得到的指针指向的地址赋给ecx,这个值就是偏移值指针指向的地址。果然第3行把它+4字节后取值,再赋给edx。这时edx的值为13h,照理这应该是到祖父类区域的偏移值,但实际是只到我们在对象布局中列出的4字节0值,也就是真正的祖父类起始地址的前4个字节。我们在前面讨论C170的对象布局时已经提到这个问题。所以我们看到第5行定位到成员变量时再加了8字节,以跳过4字节的0值为4字节的祖父类的虚表指针,而不是只加4字节跳过虚表指针。在C150对象中我们可以看到偏移值是直接跳过4字节0值,定位到祖父类起始地址的。 我们始终没有清楚的解释过祖父类之前的4字节0值及偏移值指针指向地址的前4字节的语义。有可能是出于兼容的原因,也有可能是为编译器提供一些薄记信息。而且,引入虚继承后的对象继承的拓朴结构可以比我们讨论过的菱形结构要复杂得多。这两个值也可能是用来处理更复杂的继承结构。要想通过表象去揣测出使用它们的动机太困难了。 (未完待续) 后记
结合前面的讨论,我们可以看到,只要牵涉到了虚继承,在访问父类的成员变量时生成的代码相当的低效,需要通过很多间接的计算来定位成员变量的地址。在指针类型转换,动态转型,及虚函数调用时,也需要生成很多额外的代码来调整this指针。象前一篇中对C170对象的obj.foo()和obj.f170()两次调用,传递到两个函数中的this指针居然是不一样的。 前面我们碰到过的怪异行为还有很多,比如偏移值指针指向地址的前4字节,及C150、C170对象中的4字节0值的语义,为什么对C150和C170调用foo函数时,this指针指向的不是子类部分的起始位置而是祖父类的起始位置,等等。去彻底的探究这些问题的意义并不是很大。虚继承的实现属于编译器厂商的行为,厂商出于不同的考虑,实现的方法也会大相径庭。 对于传统的C程序员,他们可能会认为C++的效率低。其实效率低是低在多态部分,因为这要在运行时通过虚表来决议出函数的地址。但对于设计而言,多态是一个非常强大的武器。多态也是面向对象设计的核心技术之一。虽然在执行的效率上有所损失,但对于大规模的程序设计,对于问题域到模型的映射,使用以多态为核心的面向对象设计技术可以提高设计、实现及维护的效率,对于大部分的应用,总体来说得大于失。 但是对于虚继承,个人感觉只是为了解决菱形继承及更复杂继承问题不得已而引入的一项机制,而且没有完美的解决方案,不但大幅的损失效率,而且带来了巨大的复杂性,使得继承结构晦涩难懂。如非万不得已,且在自己清楚一切后果的情况下,建议不要使用。尤其是不要在被虚继承的基类中声明非静态的成员变量。 C++支持多种编程范型,面向过程式的、数据抽象及封装、面向对象、现在又多了一种基于模板技术的泛型编程。我们以一个优秀的开源C++编程环境ACE(之所以叫编程环境,因为它提供了从类库到框架的多层次的支持)为例,看看在设计时的衡量及各种编程范型的运用契机。ACE分几个层次,依次为:OS适配层、wrapper facade层、框架层、服务组件层。OS适配层、wrapper facade层主要运用了数据抽象及封装,没有用到多态及虚机制。因为这两层的关键是效率。而且在这两层中的类的成员方法尽量的内联。其实不使用多态及虚机制的话,C++的效率和C应该是差不多的,但对象封装导致了大量的琐碎方法(如对成员变量的访问封装,即set,get方法),而方法调用的成本也是相当高昂的(需要保存及恢复当前的执行环境上下文,参数的传递及返回值可能产生很多的临时变量及对象等)。所以这两层通过内联来减少函数调用的开销,提高执行效率。在框架层则使用了大量的设计模式,大量使用了多态机制及泛型技术。在这一层的主要关注点是结构的清晰,及实现设计上的语义。在这时多态机制在执行效率上的损失是可以忽略不计的。 最后,我用Lippman在他的经典书籍《Inside the C++ Object Model》中关于描述虚成员函数章节中的一段话来做为这系列文章的结束。“Although I have a folder full of examples worked out and more than one algorithm for determining the proper offsets and adjustments, the material is simply too esoteric to warrant discussion in this text. My recommendation is not to declare nonstatic data members within a virtual base class. Doing that goes a long way in taming the complexity.”大意为在虚继承时用以确定偏移地址及进行this指针调整的可行算法很多,而且大都非常的诡异(这个我们已经见识了:))。同时他建议不要在被虚继承的基类中声明非静态的成员变量,这样做纯属自取烦恼。 另,感谢张水松和张建业这两个土人,在写这些文章时和他们进行了一些非常有益的探讨。最后也是他们提醒我,不要再深入下去,以免走火入魔。 (全文完) |
|