【与C++的邂逅】--- 继承和多态扩展
Welcome to 9ilk's Code World
(๑•́ ₃ •̀๑) 个人主页: 9ilk
(๑•́ ₃ •̀๑) 文章专栏: 与C++的邂逅
菱形虚拟继承的原理
回顾
1. 单继承:一个子类只有一个直接父类的继承关系。
2. 多继承:一个子类有两个或以上直接父类的继承关系。
3. 菱形继承其实是多继承的一种特殊情况。
菱形继承存在的问题:从下面的对象模型构造中我们可以看出,Assisant对象存在数据二义性和数据冗余的问题,具体体现可以看到它内部有两份_name,一份是从Student类继承过来的,另一份是从Teacher类继承过来的。
二义性无法明确知道访问的是哪一个,需要显示指定访问哪个父类成员的成员,但是数据冗余的问题无法解决:
//二义性,无法判断访问的是哪个部分的_nameAssistant a ;a._name = "peter";//显示指定哪个父类的成员a.Student::_name = "xxx";a.Teacher::_name = "yyy";
而虚拟继承可以解决菱形继承可以解决菱形继承的二义性和数据冗余的问题。比如下面的继承关系,在B
和C
的继承A
时采用虚拟继承,即可解决问题。但是需要注意的是,虚拟继承不要在其他地方去使用。
class A
{
public:int _a;
};// class B : public A
class B : virtual public A
{
public:int _b;
};// class C : public A
class C : virtual public A
{
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._a = 3;d._b = 4;d._c = 5;d._d = 6;return 0;
}
Q:为什么虚拟继承能解决二义性和冗余问题呢?原理是什么?
我们这里借助内存窗口观察对象成员的模型。要注意的是这里必须借助内存窗口才能看到真实的底层对象内存模型 , VS编译器的监视窗口是经过特殊处理的,以它的角度给出了一个方便看的样子,但并不是本来的样子。但是有时想看清真实的内存模型,往往需要借助内存窗口。下面我们对采用虚拟继承方式的菱形继承进行观察:
d对象的起始地址如下:
对B类部分的_a进行赋值:
对C类部分的_a进行赋值:
使用d对象对_a进行赋值:
我们可以看到即使显示指定访问哪个父类的成员 , 还是直接使用d对象修改_a,访问的都是同一个成员_a,而且放到了整个对象模型的最下面。
对从B对象部分继承来的_b进行赋值:
对从C对象部分继承来的_c进行赋值:
对D对象自己的成员_d赋值:
总的来说,D
类型对象d
的对象模型如下:
我们可以发现B
和C
部分在D
中都是多了一样东西 , 像是内存地址 , 我们可以再用内存窗口进行查看 , 注意判断大端机器还是小端机器,再用内存窗口查看:
0x14对应的是十进制的20,我们发现它恰好是d对象模型中B对象部分距离A部分的相对偏移量距离,0x0C对应的是十进制的12,它恰好是d对象模型中C对象部分距离A部分的相对偏移量距离。其实0x00177b48
和0x00177b54
分别指向的是D对象中B和C部分的虚基表,有了这个虚基表,这样公共的虚基类A部分在D对象中虽只有一份,但是能通过这个存储的偏移量来找到公共部分,也解决了数据冗余性和二义性问题。
大概原理如下图:
补充:虚基类表的第一项记录的是当前子对象相对于虚基类表指针的偏移,由于我们这里没有虚函数表指针(代码中类没有虚函数),所以子对象第一个就是虚基表指针,因此是0x00000000
。
Q:公共部分A位置就在最后的位置,直接访问不就醒了吗,为什么还要用偏移量去算?
我们看下面的场景:
int main()
{D d;d.B::_a = 1;d.C::_a = 2;d._a = 3;d._b = 4;d._c = 5;d._d = 6;B b;b._a = 7;b._b = 8;// B的指针指向B对象 B* p2 = &b;// B的指针指向D对象切⽚ B* p1 = &d;// p1和p2分别对指向的_a成员访问修改 p1->_a++;p2->_a++;return 0;
}
我们发现菱形虚拟继承中B和C的对象模型跟D保持的⼀致的方式去存储管理A:也是把_a
放到最下面以及存储了虚基表的指针:
对于上面的场景 , B类型对象的指针指向的可能是切片指向D对象中B的部分,也可能直接指向的就是一个B对象:
此时他们的对象模型是不同的,距离_a
的偏移量也不同,怎么找到_a
呢?此时都是需要通过虚基表的指针找到虚基表,拿到偏移量计算来找到公共的A部分!
注意:
- 菱形虚拟继承虽然解决了数据冗余和二义性问题,但是会降低访问数据的效率,因为之前数据的位置都是不变的,按照声明顺序挨着,编译时就能确定,现在只能运行时确定,运行时才知道你的父类指针指向的是父类对象还是子类对象的父类部分。
- 虚表(虚函数表,存的是虚函数地址,用来实现多台态)!= 虚基表(存的是偏移量,用来找公共的A部分,解决的是数据冗余和二义性问题)。当然,我们上面的代码不存在虚函数,因此没有虚函数表指针,如果有的话,虚表指针是在虚基表指针前面的。
单继承虚函数表继承
我们知道虚函数指针都要放进虚函数表,我们使用VS的监视窗口观察不完全,因此我们下面通过指针的方式,强制访问虚函数表,调用虚函数,来确认单继承下虚函数表中的真实内容:
class Base {
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }
private:int a;
};class Derive :public Base {
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }virtual void func4() { cout << "Derive::func4" << endl; }void func5() { cout << "Derive::func5" << endl; }
private:int b;
};typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{// 依次取虚表中的虚函数指针打印并调⽤。调⽤就可以看出存的是哪个函数 cout << " 虚表地址>" << vTable << endl;// 注意如果是在g++下⾯,这⾥就不能⽤nullptr去判断访问虚表结束了 for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d个虚函数地址 :0X%p,->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}int main()
{Base b;Derive d;VFPTR * vTable1 = (VFPTR*)(*(int*)&b);PrintVTable(vTable1);VFPTR* vTable2 = (VFPTR*)(*(int*)&d);PrintVTable(vTable2);return 0;
}
主要思路(32位下)32位下虚函数表的指针是4个自己,因此我们需要想办法取出b,d对象的头四个字节,即虚表指针,虚函数表本质是一个存虚函数指针的指针数组,VS这个数组最后放了一个nullptr,g++下面最后没有nullptr。因此我们可以:
1. 先取b/d地址,强转成int*的指针
2. 再解引用取值,就取到了头四个自己的值,即虚表指针。
3. 再强转成虚函数指针类型VFPTR*,虚表就是一个存虚函数指针类型的数组
4. 虚表指针传递给PrintVTbale进行打印虚表。
我们可以看到:对于单继承虚函数表,先继承父表,再覆写同签名虚函数,最后把子类自己新声明的虚函数按声明顺序追加到表尾,普通的成员函数不放进虚表。
多继承虚函数表继承
跟前面单继承类似,多继承时Derive对象的虚表在监视窗口也观察不到部分虚函数的指针。所以我们⼀样可以借助上⾯的思路强制打印虚函数表。
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(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{cout << " 虚表地址>" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d个虚函数地址 :0X%p,->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}int main()
{Derive d;VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);PrintVTable(vTableb1);VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));PrintVTable(vTableb2);Base1* p1 = &d;p1->func1();Base2* p2 = &d;p2->func1();d.func1();return 0;
}
需要注意的是多继承时,由于Derive同时继承了Base1和Base2,内存中先继承的对象在前面,因此Derive中包含的Base1和Base2各有一张虚函数表。
我们上面就需要分别打印出这两张虚表,Base1部分的虚表地址很简单,找到D对象的起始地址就能找到 ; 而对于Base2部分的虚表地址,我们需要计算出它距离整个起始地址的偏移量,即sizeof(Base1) , 然后注意需要强转成char*, 更改偏移的步长。这样就能分别获得两张虚表进行打印。要找Base2
的虚函数表指针 , 就是要在Derive
对象中找Base2
的部分 , 此时我们也可以运用切片,它会自动指向Base2
部分的起始地址:
Base2* ptr = &d;
VFPTR* vTableb2 = (VFPTR*)(*(int*)(ptr))
结果如下:
我们可以看到,对于多继承虚函数表:
1. 几个基类有虚函数就有几个虚表。
2. 派生类的新虚函数放在第一个基类部分的虚函数表里。
3. 重写同样会进行替换。
Q:还存在的一个问题是Derive对象中重写的Base1虚表的func1地址和重写Base2 虚表的func1地址不⼀样,这是为什么呢?
我们查看p1指针调用func1的汇编:
-
编译器先将对象指针读取出来到eax
-
然后取出对象的前4字节(vptr)到edx
-
this指针放进ecx(给真正的成员函数准备隐藏参数
this
,给被调用的成员函数传“我是谁”) -
取虚表第0项,即func1的地址放到eax
-
动态派发调用真正的func1(),调用的func1地址跟编译时调用(直接用d对象调用func1)其实是一样的。
再查看p2指针调用func2的汇编:
我们可以看到p2
比p1
多了一次jump
,但是其实最终调用的func1()
都是一样的。
这是因为要调用Derive::func1, 需要把this指针传过去 , 通过ecx传递 , Base1将p1传过去 , 正好就是整个对象的起始地址,这个ecx是ok的 ; 但是对于p2,它指向的位置是不对的 , 调用Derive::func1应该指向整个对象的开始,因此多跳了两层 , 修正this指针,所以是sub-8正好是Base1*p
指向的位置 , 即这个对象的起始地址。
结论:本质 Base2虚表中func1的地址并不是真实的func1的地址,而是封装过的func1地址,因为Base2指针 p2指向Derive时,Base2部分在中间位置,切片时指针会发生偏移,那么多态调用p2->func1() 时,p2传递给this前需要把p2给修正回去指向Derive对象,因为func1是Derive重写的,里面this应该是指向Derive对象的。
常见问题
1. inline函数可以是虚函数吗?
答: 可以,不过编译器就忽略inline属性,这个函数就不再是inline属性,因为虚函数要放到虚表中去,也就是说inline属性和虚函数属性是不同同时存在的。
2. 静态成员函数可以是虚函数吗?
答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
3. 构造函数可以是虚函数吗?
答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
4. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
答:析构函数的主要职责是释放对象资源。当通过基类指针删除派生类对象时,如果基类析构函数不是虚函数,则只会调用基类的析构函数,而不会调用派生类的析构函数。这可能导致派生类中分配的资源无法正确释放,进而引发内存泄漏。通过将基类析构函数声明为虚函数,可以确保在删除基类指针时,先调用派生类的析构函数,再调用基类的析构函数,从而正确释放所有资源。
5. 对象访问普通函数快还是虚函数更快?
答: 首先如果是普通对象调用,是一样快的。如果是指针或者是引用对去调用,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
6. 虚函数表是在什么阶段生成的,存在哪的?
答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。而虚表指针是在运行时产生确定的。