原文出处:http://blog.aaronballman.com/2011/10/virtual-methods-and-multiple-inheritance/#comment-85589,原文的作者是Aaron Ballman,voting member of the C++ standards committee.
本文介绍了在多重继承(MI)以及存在虚函数的场景下,派生类对象的内存布局及虚函数的访问机制,代码很简洁,见下:
class A {
private:
int i;
public:
A() : i( 42 ) {}
virtual void Foo() {
}
};
class B {
private:
double d;
public:
B() : d( 1.0 ) {}
virtual void Bar() {
}
};
class C : public A, public B {
private:
char *s;
public:
C() : s( "Hello World" ), A(), B() {}
void Foo() {
}
};
在这段代码中有3个类,类A拥有一个int型成员变量和一个虚函数Foo(),类B拥有一个double型成员变量和一个虚函数Bar(),类C继承于A和B,拥有一个指向字符(或字符串)的指针,且重新定义了从A中继承得到的Foo()。A、B、C的对象的内存布局如下:
可以看到C对象将包含A类子对象和B类子对象,其中A类子对象和B类子对象被分别插入了一个指向虚函数表的指针。假设现在我们有一个C类对象 C c; ,那么c中的内容可以用下图表示:
c的首地址是0x00045032,在该地址上有一个void*型的指针,该指针指向虚函数表,即它的内容就是虚函数表的地址,0x0004503A处也是一个指针,该指针也指向一个虚函数表(次虚函数表),即该指针的内容是次函数表的地址(可以看到主虚函数表和次虚函数表并不在一起,有的编译器会把它们放在一起),下面我们可以看看这两张虚函数表的内容:
主虚函数表中记录了C::Foo的地址,次虚函数表中记录了B::Bar的地址,同时这里可以看到,常量字符串也放在不远的位置,s指向了这个位置。趁热打铁,我们可以看看实际调用背后的汇编代码!
c->Foo();
// #1
c->Bar();
// #2
1)把this指针的值存在ECX寄存器中;
2)如果有返回值的话,把返回值存在EAX寄存器;
3)函数参数从右向左依次压入栈中;
这样,#1大概可以转换成如下的汇编指令:
mov eax, 1 ; Load the function pointer
mov ecx, c ; Load the this pointer
call eax ; Call the function
因为Foo()就在主虚函数表中,所以汇编语言相对简单,#2的汇编指令就要复杂一些了,里面涉及了给this指针调整offset,称为 thunk:
mov edx, c ; Get the instance pointer
add edx, 8 ; Advance by 8 bytes
mov eax, [edx] ; Load the function pointer
mov ecx, edx ; Load the this pointer
call eax ; Call the function
前两条指令就是调整this指针,第4条指令将调整后的this指针放入ecx中。以下是几点结论:
1)类中如果有虚函数,则会有相应的虚函数表,虚函数表中的信息在编译时就会确定;
2)一个类可能有多个虚函数表(虚函数表指针);
3)编译器有时候需要插入thunk来保证this指针指向合适的位置;
4)使用虚函数会付出一些空间和复杂度的代价,使用多重继承下的虚函数付出的代价更多,如果效率很重要,可以考虑不依赖虚函数。