提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
序言
第一次写博客,不足之处希望大家多多指出。本文并非谈论多态的功能与调用,而是多继承时多态的分析。
前言
阅读本文需要理解继承,多继承的底层实现原理,以及单继承多态的实现原理,即虚表。
本文代码测试环境为Visual Studio 2022 x86
一、多继承派生类的虚表
相信各位都是有足够强的接受能力的,那我就直接上一个代码,说一下结论就好了,这里不是本文的重点。
很简单的一个样例吧,就是有三个类,A类,B类,C类,其中C类继承了A类和B类,并且重写了B类中的Func2。每个类中都放了一些变量,便于在监视窗口观察嘛。
(关于为什么A类有两个成员变量,后续会说)
//头文件别忘了
class A
{
public:
virtual void Func1()
{
cout << "A::Func1" << endl;
}
private:
int _a = 1;
int _aa = 0;
};
class B
{
public:
virtual void Func2()
{
cout << "B::Func2" << endl;
}
private:
int _b = 2;
};
class C : public A, public B
{
public:
virtual void Func2()
{
cout << "C::Func2" << endl;
_c = 0;
}
virtual void Func3()
{
cout << "C::Func3" << endl;
}
private:
int _c = 3;
};
int main()
{
B* pb = new C;
pb->Func2();
return 0;
}
监视结果
虚表是一个函数指针数组,vftable`[num]{}(num为有效元素个数),在vs下对于虚表,编译器会在最后一个元素位置放入nullptr,(g++下不会),但有时候编译器会处理不干净(放入nullptr只是为了给程序员看的,并非作为结束符),忘了放nullptr等一些操作(在本次演示中导致了两个虚表连接在了一起),不过没关系,有几个虚函数无论是编译器还是程序员心里都有数
在VS的监视窗口中可以直接拖拽类的成员变量,将其拖出来单独观察,如果是数组的话,数组名,num,代表显示num个数组中的元素
可以看出多继承的时候,编译器会将派生类中新增的虚函数放在第一个基类的虚表中。
二、多态调用时的函数调用
我们已经了解了存放虚函数地址的虚表,接下来看一下多态调用是怎么完成的
还是简单说一下汇编吧(简单上手的理解,仅解释要讲到的汇编),32位下寄存器分为eax,ecx(在本文中用于存储参数this),eip(存放程序执行下句指令的地址),esp(存放函数栈帧顶部的地址),ebp(存放函数栈帧底部的地址)...,dword ptr取4个字节的空间,qword ptr取8个字节的空间,注:32位寄存器是e开头,64位寄存器是r开头
接下来讲代码运行起来,转到反汇编
1. mov eax,dword ptr [pb] 将pb(该句汇编中的pb相当于C++代码中的&pb)中存放的值解引用给eax寄存器,即eax放的是父类地址(dword ptr [] 只在该地址下取前4个字节的数据)
2. mov edx,dword ptr [eax] 将eax中存放的值解引用给edx
mov esi,esp (与多态调用无关,不解释了)
3. mov ecx,dword ptr [pb] 将pb中存放的值解引用给ecx寄存器,用于给类成员函数传递this指针(注意ecx的值)
4. mov eax,dword ptr [edx] 将edx的存放的值解引用给eax,即获得了该函数的地址
5. call eax 调用该函数
其实个人来说还是比较喜欢64位下这段多态调用的汇编,给大家放一下,对比一下
相对来说,64位下多态调用的汇编更佳流畅一点
注意:
多态的调用是一种运行时的绑定,在程序运行的时候,通过对该对象的虚函数表进行查找,调用。而每个虚函数在虚表中占据的位置时固定的,也就是说编译器在调用的时候,只会通过固定的偏移去查找调用的是哪一个虚函数。
假设有多个虚函数,我们需要调用第n个虚函数,编译器只会通过调用的函数名确定它将这个虚函数放在了虚表中的第几个位置,之后再多态调用中的第四步改为eax,dword ptr [edx + 偏移量],偏移量等于指针的大小(虚表中存储的是函数指针)*n。
换句话说,我们可以在运行时手动的更改虚表中的数据,比如将Func2的地址改为main函数的地址,之后再去调用Func2,就会发现编译器再经过了上述步骤后去调用了main函数。(虚表的位置是在常量区,代码不能更改,编译器会报错,可以再监视/内存窗口修改。关于虚表的存放位置,可以自己测试试试,写一个栈上的数据,堆上的数据,静态区上的数据,常量区上的数据,比较一下虚表和哪一个挨着)
只要是基类的指针或者引用去调用的虚函数,无论是否该虚函数被派生类重写,那么就会采用上述的调用方式。
非多态调用的函数地址是在编译期间确定的(也有可能是在链接期间确定的),静态绑定
int main()
{
C c;
c.Func3();
return 0;
}
二、多继承多态调用的this指针
多态调用的重点
多态调用,调用的是派生类的虚函数,但ecx中传递的却是父类的地址
看一下VS是怎么处理的
pop ecx 建立栈帧的时候使用了ecx寄存器,为了保存ecx的值,将其push进去,建立完成之后,pop接收回来。(不用在意,理解为ecx的值从没变过就行,ecx的值为基类的地址)
mov dword ptr [this], ecx 使用this指针接收ecx的值,this指针指向父类地址
问题来了,调用派生类对象的函数,给this指针基类的地址?
对的,可以,但是一旦使用了子类的成员,其this指针就会进行一个偏移
mov eax,dword ptr [this] 将this指向的地址赋给eax
mov dword ptr [eax + 8], 0 将eax中存的基类地址进行一个偏移,将其偏移至派生类该成员的地方去使用(注意手动计算核对偏移量时,不要忘记内存对齐)。
实际上,哪怕时传递的时派生类的地址,在使用其中成员的情况下,也需要进行一次指针偏移,二者操作几乎相同,只不过是具体偏移量的大小。
但是在监视窗口中观察的话,会发现this指针的值为派生类的地址,(千万不要相信,血泪),监视窗口不可信(大部分时候是可信的),内存窗口最可信(VS 32位下,一般会将this放在ebp-8的位置,可以将反汇编中的符号名去掉,也可以看到this的地址,直接在内存窗口输入就可以查看了),监视窗口中带有太多的封装了,从窗口中看到的和真实的可能就是两码事。
当然,这只是vs下的一个处理,其他平台下的处理可能有所不同。但无论是哪一个平台,其在多态调用中,使用了派生类的成员,一定会进行一个指针偏移,找到正确的地址,进行写入/读取。
多态中派生类重写了多个基类同一函数
对上面的代码做了一点修改,新增加了一个D类,并使D类继承A类,B类,C类
class A
{
public:
virtual void Func1()
{
cout << "A::Func1" << endl;
}
private:
int _a = 1;
};
class B
{
public:
virtual void Func2()
{
cout << "B::Func2" << endl;
}
private:
int _b = 2;
};
class C
{
public:
virtual void Func2()
{
cout << "C::Func2()" << endl;
}
private:
int _c = 3;
};
class D : public A, public B, public C
{
public:
virtual void Func2()
{
cout << "C::Func2" << endl;
}
private:
int _d = 4;
};
int main()
{
C* pc = new D;
pc->Func2();
return 0;
}
运行监视:
很明显,B类和C类中都有一个Func2,而D类中的Func2将其全部重写。
但经过观察可以看出B的虚表和C的虚表中存储的Func2地址不同。
(嗯,实际上在vs中调用函数前是需要先进行一步跳转的,多了一步封装,假设func的地址为0x05,在代码中调用func的时候,会先跳转至0x03执行jmp func(0x05),再去跳转的func函数地址,这一步提一下就行了,我就不放出来了)
通过C类的虚表调用Func2
会在中间跳转的过程中中转一步,sub ecx,8 就和C中的虚表[thunk]:D::Func2`adjustor{8}'(void)中的那个数字8对应上了,指针偏移8字节,执行完之后,ecx的值就从指向C的地址,变成了指向B的地址,ecx再将B类的地址传过去,就完成了this指针的传递和虚函数的调用。
注意看细节,sub ecx之后的下一句汇编是jmp D::Func2(05e1505h),对比一下B类虚表中存储的Func2的地址,是不是发现就一样了,两者殊途同归。
可以看出,多继承时,当派生类的一个虚函数重写多个基类的虚函数,只有第一个被重写的父类的虚表中会直接存储重写虚函数的跳转地址,后续父类的虚表中存的是中转修整的地址,在调用时,需要先将其指针修正至第一个父类的地址。
总结
多态调用虚函数的方式很好理解,检查派生类是否有虚函数,若有虚函数,是否重写了基类的虚函数。
若没重写基类的虚函数,则直接放入第一个继承的基类虚表之中,若重写了基类的虚函数,则将派生类的虚函数在基类的虚表中将其覆盖继承(当然,一个形象的说法,基类和派生类又不是共用一个虚表,在派生类的虚表生成时会自动检查,未重写就拷贝,重写就写自己的虚函数地址)。
之后调用的时候,再根据在虚表对应固定好的虚函数地址,对实例化对象的虚表进行读取调用。