以如下代码为例:
class Base1
{
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1;
};
class Base2
{
public:
virtual void func1() { cout << "Base2::func1" << endl; }
virtual void func2() { cout << "Base2::func2" << endl; }
private:
int b2;
};
class Derive : public Base1, public Base2
{
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1;
};
typedef void (*func)();
void print(func a[])
{
for (size_t i = 0; a[i] != nullptr; ++i)
{
printf("[%d]:%p\n", i, a[i]);
a[i]();
}
}
int main()
{
Derive d;
func* v1 = (func*)(*(int*)&d);
PrintVTable(v1);
func* v2 = (func*)*(int*)((char*)&d + sizeof(Base1));
//&d得到的是Derive*类型的,+1会跳过sizeof(Derive)个字节,所以&d要强转为char*类型,
//然后取对应位置的前四个字节,就拿到第二个虚表的地址了
PrintVTable(v2);
return 0;
}
代码打印了两张虚表,两张虚表中func1都是Derive类中func1的重写,但是他们的地址却不一样。
那么为什么都调用了Derive::func1显示的地址却不一样?
先说答案:因为这两个地址都不是真正的函数地址,真正地址的取用方式:&Derive::func1(cout是ostream类型的对象,但cout没有对函数指针类型进行适配,用cout打印函数指针会出问题)。虽然地址不一样,但是从调用结果来看,调用的都是Derive::func1函数。这和vs的实现有关,在Linux下看,地址可能就一样了。
图示可以发现Derive::func1的实际地址和虚表存的两个地址都不一样。
这是因为vs对成员函数实现了封装,虚表中储存的地址是封装位置的地址,虽然存储的地址不一样,但是最终还是调用到了Derive::func1,说明还是跳转到func1的位置了。
下面需要理解一些汇编语言的能力,这是程序员的基本素养,汇编语言不需要全看懂,大致看懂,了解在做什么就足够了。遇到不理解的,上网查查就足够了。
a[0]是第一个虚函数的地址,即虚表中存储的Derive::func1的地址
转到反汇编(需要在调试状态下才能转)
其中call是函数跳转,_RTC_CheckEsp是检查越界行为的函数,汇编代码中只有对edx的跳转不清楚情况,说明对edx的跳转可以到达真实Derive::func1的地址,那么看看edx中存储了什么(汇编代码也是可以调试的,逐语句,逐过程都可以,方括号代表其中存储的是地址,可以进行解引用):
结果发现edx中存储的就是虚表中的函数地址(在监视界面看edx的值时需要转为16进制显示)。
再对edx进行跳转,来到004A123F处。这里的jmp指令和call指令类似,可以继续跳转。
到这里就可以看出汇编指令在进行压栈(push),说明到这里就建立函数栈帧了,即已经到达真实的Derive::func1函数地址处。但是又和打印的Derive::func1地址不同。
刚才看的是base1中虚表地址的跳转,现在来看看base2的:
和之前的方式一样:
一路跳转,最终依然能看到跳转到建立栈帧的场景。而且和第一个虚表中最终跳转的位置一样。虚表中存储的地址是edx存储的地址。所以成员函数的真实地址和虚表中存储的地址不一致的原因是:虚表中存储的是实际地址的"跳板",通过不同路径最终跳转到真实地址的所在之处。
信息集中处理一下:
为了方便理解,下面的V1table为指向Base1虚表的数据,V2table为指向Base2虚表的数据。
仔细观察第二个虚表比第一个虚表多出来的地方。发现多了一个sub ecx,8的步骤,sub就是减,也就是说要ecx减8,这里的8是Base1的大小。虽然没有传,但编译器认为调用第一个虚表是Base1指针(即V1table的地址)在调用,调用第二个虚表是用Base2指针(即V2table的地址)调用,所以在通过Base2指针调用虚函数时,会有一个减去Base1大小的步骤,因为fun1是被Derive重写的函数,需要对齐到Derive位置来找对应的虚函数。实际上,我们是通过从虚表中拿指针的方式调用成员函数,所以这里的减8,并没有什么用。但是从中也能看到Base2类型指针调用Derive::func1的机制。
演示代码中,两个虚表指针相差了20。这是因为虚表是一个数组,这里差的是两张虚表之间首元素地址的位置。相差为8的是V1table的地址和V2table的地址,即图中Base1->vTable的地址和Base2->vTable的地址相差8。
下面用
Base1* ptr1 = &d;
Base2* ptr2 = &d;
ptr1->func1();
ptr2->func1();
验证一下猜想。
首先,可以看到确实调用了同一个函数。然后转到反汇编:
可以看到汇编代码和猜想的一致,也说明了虚表中存储的函数指针不同的原因。V2table的地址相对于Derive位置有一个偏移量(在用Derive地址对Base2指针进行赋值时,会将Derive中Base2位置的地址切片赋值给Base2指针),通过Base2指针调用成员函数,需要修正偏移量(V1table地址和Derive的位置相同,不需要修正),到Derive的位置去找相应的函数。这就导致了Base1和Base2的指针需要通过不同路径找到Derive::func1,所以虚表中存储的地址也就不同。
其实理解这里的知识点并没有什么意义,主要是了解可以通过这样的方法深入了解原理,知道可以通过什么方法来寻找答案。
在继承章节中虚拟继承的虚基表也与这个有关。
class A
{
public:
virtual void func()
{}
public:
int _a;
};
class B : public A
{
public:
virtual void func()
{}
public:
int _b;
};
class C : public A
{
public:
virtual void func()
{}
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
如图所示代码,此时A中有虚函数,B,C继承A,并对A中的虚函数进行重写。编译正常,B,C中各有一个虚表,可以储蓄对A中虚函数的重写,但是,如果B,C是虚拟继承就会报错,在虚拟继承下,B,C中存储A的未知统合为一块区域,(B,C中存储指向虚基表的指针,指针指向的虚基表中存储有对A区域的偏移量)这样就会导致编译器不知道在A的虚表中储存B中的重写,还是C中的重写。如果非要虚拟继承,就必须在继承了B,C的D中重写A中的虚函数。(虚基表和虚表没有关系)如图:
B,C都虚继承A,且对A中的虚函数重写会报错
D中存储的数据模型
B,C虚基表中存储有对A的偏移量
当B中增加一个A中没有的虚函数,B就会存在虚表,虚基表就会发生变化:
func1是B中的虚函数,所以不能放在A的虚表中,B就需要创建虚表。计算机中存储的是补码,所以fffffffc代表-4(ffffffff是-1),代表当前位置(虚基表指针的位置)对虚表的偏移量。所以虚表位置就在虚基表位置-4距离上。