多态
在C++中继承和多态为它的俩大特性。
那么对于我们常说的动态多态,它是如何形成的?通常我们都会说应用赋值兼容规则,让一个Base类指针或引用指向一个派生类对象,那么当我们在基类中定义出一个虚函数,在派生类中我们对它进行重写后。当我们用指向派生类对象的基类指针调用这个函数时,就会发生多态,当指针指向对象不同时,它就会调用不同类中的该函数。
那么何为赋值兼容规则呢,为什么Base类指针就可以指向派生类对象呢?为什么派生类对象就可以给Base类对象赋值呢?
Tips: 下面讨论中相应类的代码 :
class Base1
{
public:
Base1()
:_b(0)
{}
virtual Base1* A()
{
cout << "i am Base1" << endl;
return this;
}
int _b;
};
class Base2
{
public:
Base2()
:_b2(1)
{}
virtual Base2* A()
{
cout << "i am Base2" << endl;
return this;
}
virtual Base2* B()
{
cout << "B" << endl;
return this;
}
int _b2;
};
class Dervied:public Base1,public Base2
{
public:
Dervied()
:Base1(), Base2(), _d(2)
{}
Dervied* A()
{
cout << "i am Dervied" << endl;
return this;
}
int _d;
};
赋值兼容规则
1 Base类指针指向派生类对象
常常我们都说在赋值兼容规则中,基类指针可以指向派生类对象,那么它到底是什么原理呢?
从上面的汇编代码中,我们可以看出关键的几行。
1 lea eax,[d] 它代表我们取派生类对象D的地址放入寄存器eax中
2 lea ecx,[d] 它代表我们取派生类对象D的地址放入寄存器ecx中
3 add ecx,8 它代表我们这里把该派生类对象地址加8,这个加8之后代表的就是派生类对象中Base2处的地址。
4 mov ptr[ebp-10ch],ecx 它指把派生类中基于Base2的地址放入一个临时变量中。
5 mov edx,ptr[ebo-10ch] 它指从临时变量中把它放入到寄存器edx中。
6 mov ptr[q],edx 它代表把edx中的基于Base2的地址传给变量q中。
所以综上来看,其实为何我们能把一个派生类对象地址赋值给基类指针呢?只是编译器把该派生类对象地址调整至相应的基类对象处,再把这个调整之后的地址赋值给基类指针。(所以这里可以看出实际也是基类地址给基类指针赋值)
2 派生类对象赋值给基类对象
图2(该图为operator = 函数中的汇编)
那么对于派生类对象给基类对象赋值,我们可以从上面汇编中就可以看出它到底指的是什么了。
首先在main中的几条重要的汇编:
1lea eax,[d] 它代表把派生类对象地址放入eax中。
2lea ecx,[d] 它代表把派生类对象地址放入ecx中。
3add ecx,8 它代表把派生类对象下调8个字节,至Base2处,。
4mov edx,ptr [ebp-10Ch] 它代表把在派生类对象中基于Base2处的地址存入寄存器edx中。
5lea ecx,[b] 它代表把基类对象Base2地址存入ecx中。
6call Base2::operator= 这里指调用了赋值运算符重载函数。(可见在底层中类的每个运算符的应用都是调用相应的运算符重载函数)
那么在赋值运算符重载函数中的几条重要汇编:
我们都很熟悉栈帧结构了,在函数调用时它首先会把参数从右至左以此压栈,再把返回的地址和之前上一个栈帧中的ebp中的值压栈,再把之前寄存器中保存的值压入栈中。接下来会清空寄存器中的值。
1pop ecx 它代表从栈帧中把栈顶内容存入ecx中,即基类对象的地址。(因为最后一次push 的内容是ecx中内容)
2mov ptr[this],ecx 把基类对象地址放入this指针中。
3mov ecx,ptr[__that] 把that中派生类中基于Base2处的地址存入到ecx中。
4mov edx,ptr[ecx+4] 把基类对象地址向下调整四个字节,即成员变量_b2的值1存入edx中。
5mov ptr[eax+4],edx 把派生类对象中的整形变量内容赋值给基类对象中的成员变量。
综上我们可以看出实际派生类对象给基类对象赋值,首先调用了赋值重载函数。然后再把相应的派生类对象的地址向下偏移至对应的基类对象的地址处,然后再完成相应的赋值。
总结
那么从上我们可以看出实际上对于无论是基类指针引用派生类对象还是派生类对象给基类对象赋值,底层都是通过调整派生类对象的地址至相应的基类地址处然后进行操作(用派生类对象中相应的Base类对象部分进行赋值或用派生类对象中基于Base类对象地址的赋值)。
多态的实现之虚函数
那么从上面我们了解到了赋值兼容的实质,对于完成这些指针调整后是如何形成多态的呢?
在C++中,我们当我们在类中写了一个虚函数,底层编译器就会为这个对象生成一个虚表,在虚表中记录了相应的虚函数的地址,那么编译器在为我们生成对象时,就把这个虚表指针加入到了我们生成的对象中,在VS2013下(即微软编译器的处理下)虚表指针是在对象的顶部。当对象我们调用这个虚函数时,编译器在编译期并不知道是那个对象调用的该虚函数,所以它就会先查找虚表,找到相应的虚函数在表中的索引,因为索引是不会改变的,等到执行期程序跑起来时,该索引上的函数地址就为相应的虚函数地址,这样我们就完成了动态多态。
那么虚表中的虚函数都有那几种呢:
1 基类自己的虚函数
2 派生类自己的虚函数
3 一个纯虚函数的地址(因为纯虚函数是没有定义的,当我们在外面意外调用这个纯虚函数,它就会结束程序)
单继承下的虚函数
单继承下,无非就是在派生类对象顶部加入了个虚表指针,需要改写时用相应改写后的函数地址替换之前的该函数地址,然后如果派生类中自己也有虚函数,就在虚表的末位处按类声明的顺序一一加入虚表中。
多继承下的虚函数
派生类对象
图A(在gcc平台下)
图B(在gcc平台下)
图C(在gcc平台下)
Tips:
图B为Base2的虚表,图C为Base1的虚表,图A为Dervied虚表由上可看出虚表都存了些什么东西
因为每个派生类对象都与其后的基类对象都是继承关系,故每个基类子对象都有一个相应的虚表指针,第一个基类对象与派生类对象共享一个虚表指针。
那么在多继承下,假设继承了多个基类。我们用第一个基类指针调用虚函数或派生类调用第一个基类中重写的虚函数时,根据前面的赋值兼容规则,它与派生类对象地址相同不需要调整。那么后续的基类对象调用虚函数或派生类调用虚函数时,从赋值兼容规则看出有些情况当调用这些函数时,this指针是需要调整的。
那么那些情况呢,又是如何调整的呢?
具体是俩种情景:
第一种情景是用派生类指针调用未重写的基类虚函数,首先该指针会向下偏移到该基类地址处,然后查看该基类的虚表,通过在该基类的虚表中查找相应的虚函数把偏移后的地址传入完成调用。
第二种情景派生类对基类(指第二或其后基类)的虚函数进行了重写(我们常用的动态多态就是这种重写情景),
当用一个基类指针调用这个重写函数时,编译器会先查找该基类对象的虚表,然后调用该重写虚函数,但是该重写的虚函数现在已然是派生类的虚函数,它的隐含参数this指针为Dervied*p类型,故我们不能直接调用该函数。这里就引用了thunk技术。
thunk技术就是指先对它的指针做出相应的调整(调整为派生类的地址处),然后再调用该虚函数。(一般的虚析构函数就是这样调用的)。
另类疑问
既然基类虚表被派生类改写,那么基类指针是否可以那些调用只在派生类中存在的虚函数,在基类中是不存在的这些虚函数呢?
虽然它的虚表被改写,但实际上类就是一个作用域,在该作用域下并没有该虚函数,即使虚表中有这些虚函数的地址,它们也是不能被调用的。