【说明】这部分的加餐课和其他部分的加餐课相比,重要性最次,其他加餐课还是有必要学一下的。这部分的加餐课只能说知识多多益善,但是这部分的知识:
- 在实际和工作中使用地非常少。
- 实践中不推荐使用菱形继承。
1. 菱形虚拟继承原理剖析
继承的课程中我们讲到C++的多继承,会引发⼀些场景出现菱形继承,有了菱形继承,就会出现数据冗余和二义性的问题。
C++引入了虚拟继承来解决数据冗余和二义性。
cpp
//基类:人
class Person
{
public:
string _name; // 姓名
};
//派生类:学生
// class Student : public Person //常规继承
class Student : virtual public Person //虚拟继承
{
protected:
int _num; // 学号
};
//派生类:老师
// class Teacher : public Person //常规继承
class Teacher : virtual public Person //虚拟继承
{
protected:
int _id; // 职⼯编号
};
//派生类:助教
class Assistant : public Student, public Teacher //菱形继承
{
protected:
string _majorCourse; // 主修课程
};
void Test()
{
// 这样会有二义性------无法明确知道访问的是哪一个
Assistant a;
a._name = "peter";
// 需要显式指定,访问哪个⽗类的成员,可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "张三"; //助教作为学生的身份
a.Teacher::_name = "张老师"; //助教作为老师的算法
}


使用了虚拟继承,底层的对象模型内部,菱形继承的类的对象的Person的成员就会单独放在一个地方,具体放在哪里不确定,VS下是放在整个对象的最底部。
也有可能放在整个对象的最头部。
不是放在这里这么简单,还要存储一张虚基表。
- 产生数据冗余(二义性)的基类称为虚基类。
- 需要一张虚基表来找它的成员。
为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员 的模型。
要注意的是这里必须借助内存窗口才能看到真实的底层对象内存模型,vs编译器的监视窗口是经过特殊处理的,以它的角度给出了一个方便看的样子,但并不是本来的样子。
有时想看清真实的内存模型,往往就需要借助内存窗口。
上面的类比较大,存储了string这样的成员,通过下面这样的简化的类,来看看虚拟继承的原理。
通过下面的简化菱形虚拟继承模型,我们可以看到,D对象中的B和C部分中分别包含一个指向指向虚基表,B指向的虚基表中存储了B对象部分距离公共的A的相对偏移量距离,C指向的虚基表中存储了C对象部分距离公共的A的相对偏移量距离。这样公共的虚基类A部分在D对象中就只有一份了,这样就解决了数据冗余和二义性的问题。
通过B的对象模型,我们发现菱形虚拟继承中B和C的对象模型跟D保持的一致的方式去存储管理A,这样当B的这指针访问A时,无论B指针切片指向D对象,还是B指针直接指向B对象,访问A成员都是通过虚基表指针的方式查找到A成员再访问。
cpp
//虚基类
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;
}
cpp
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成员访问修改
// 分析内存模型,我们发现B对象也使⽤了虚基表指向A成员的模型
// 所以打开汇编我们看到下⾯的访问_a的⽅式是⼀样的
p1->_a++;
p2->_a++;
return 0;
}

【课堂演示】
从监视窗口看,好像有3份A成员,其实并不是,实际上只有1份A成员。

这个时候需要借助内存窗口来看。


然后执行给成员变量_a赋值:


可以发现,不管是指定B的作用域,还是指定C的作用域,访问到的_a成员都是同一个地址。
说明这个_a既没有放到B里面,有没有放到C里面,而是放到了一个公共的位置------VS是最下面。
C++并没有规定放到哪里,只规定了虚拟继承只存一份,显然放到最上面或最下面相对合理。

通过d直接访问_a,也是同一个地址。

可以看到,除了4个成员变量,d对象内部还有两个"8字节的连续空间"。
X86下是两个"4字节的连续空间"。



这里是小端存储:数据的低位字节存储在内存的低地址,高位字节存储在内存的高地址。
(左低右高)
实际地址:00347b48、00347b54。
这两个地址相隔很近。

这两个地址的下8字节分别是:14H(20D)、0cH(12D)
这其实是偏移量:

在D类的B成员头部存储了一个偏移量------指导了往下偏移多少字节,能找到B类继承的A成员。
- 也就是说,本来B和C中都有一份A成员,但是这会导致数据冗余和二义性的问题。
- 所以把继承了B、C的类型D的A成员放到了一个公共位置,B和C要找到A成员就需要通过偏移量。
有个疑问是,既然已知A成员在最后面,B和C的大小都已知,那直接去访问A不就可以了,为什么还要使用偏移量来计算。
编译之后,对象内的成员按声明顺序,挨着挨着分布,那A的位置应该就在B和C后面就能找到。
在之前的代码情况确实直接找就可以,但是下面的情况不是这样------切片场景

由于B类和C类是虚拟继承,所以它们的对象模型和D是保持一致的,不是把A放到上面,也是把A倒过来放到最后。

分析内存模型,我们发现B对象也使用了虚基表指向A成员的模型。
所以打开汇编我们看到下面的访问_a的方式是一样的。

B和D保持了同样的对象模型------就是因为B类型的指针,可能指向B对象,也可能指向D对象。

【说明】p1和p2哪个指向D(B)不重要
无论B指针指向D对象还是B对象,访问_a成员的方式都一样。
B指针指向B对象,那_a就紧挨在_b后面。
B指针指向D对象切出来的B,那_a可能在更后面一点,中间可能间隔了其他成员。
那这样不看其他的代码,只看:
cpp
p1->_a++;
p2->_a++;
怎么能确定_a在哪里?通过汇编能看到进行了同样的操作。
- 不清楚p1指向B还是D,编译器无法识别,都是B类型的指针。
- 无论指向原始的B,还是切片的B,访问_a都通过虚基表的偏移量去访问。
【结论】
可以看到,菱形虚拟继承虽然可以解决数据冗余和二义性,但是降低了数据访问的效率。
以前的话,访问对象的成员数据位置都是固定的,都是按照声明顺序挨着的,编译的时候就能确定位置。
现在的菱形继承 + 切片引用,一个B类型的指针就确定不了它的_a成员的位置。
只能像多态一样,运行时才能确定_a的位置。
多态调用成员函数,编译时不确定父类的指针是指向父类对象还是子类对象,运行时去指针指向的对象的虚函数表里面找到对应的虚函数去调用,指向父类调父类,指向子类调子类。
这里也是B指针不知道指向B还是B的切片,不知道_a在哪里,只能在B的起始取到偏移量再去访问,这时候访问代价就变大了。
- 菱形继承的问题解决在于虚拟继承,虚拟继承的核心是虚基表。
- 虚基表≠虚表:
- 虚表是虚函数表,存储虚函数的地址,实现多态。
- 虚基表存储的是偏移量,用来找公共的_a,解决数据冗余和二义性。
唯一的联系是都使用了virture关键字。
日常代码中尽量不要搞出菱形继承,有菱形继承就要搞虚拟继承,底层结构更复杂,效率也有一点下降。
2. 单继承和多继承的虚函数表深入探索
2.1 单继承虚函数表深入探索
vs编译器的监视窗口是经过特殊处理的,以它的角度给出了一个方便看的样子,但并不是本来的样子。
多态部分我们讲了,虚函数指针都要放进虚函数表,这里我们通过监视窗口观察Derive对象,看不到func3和func4在虚表中,借助内存窗口可以看到⼀个地址,但是并不确认是不是func3和func4的地址。
所以下面我们写了一份特殊代码,通过指针的方式,强制访问了虚函数表,调用了虚函数,确认继承中虚函数表中的真实内容。
cpp
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;
// 32位程序的访问思路如下:
// 需要注意的是如果是在64位下,指针是8byte,对应程序位置就需要进⾏更改
// 思路:取出b、d对象的头4bytes,就是虚表的指针,前⾯我们说了虚函数表本质是
// 一个存虚函数指针的指针数组,vs下这个数组最后⾯放了⼀个nullptr,g++下⾯最后没有nullptr
// 1.先取b的地址,强转成⼀个int*的指针
// 2.再解引⽤取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
// 3.再强转成VFPTR*,因为虚表就是⼀个存VFPTR类型(虚函数指针类型)的数组。
// 4.虚表指针传递给PrintVTable进⾏打印虚表
// 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,
// 虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的 - 生成 - 清理解决方案,再编译就好了。
VFPTR* vTable1 = (VFPTR*)(*(int*)&b);
PrintVTable(vTable1);
VFPTR* vTable2 = (VFPTR*)(*(int*)&d);
PrintVTable(vTable2);
return 0;
}

【课堂演示】

可以看到,D的虚表里面有Derive::func1和Base::func2。
从监视窗口来看,D的虚表里面没有func3和func4。(func5不是虚函数)
想着说通过内存窗口来看,但这里和前面的菱形虚拟继承还不太一样。

可以看到D的虚函数表里面实际上有4个地址,但是不确定后两个地址是不是就是fun3和fun4的地址。
【分析】

基类Base给了派生类Derive两个成员------虚表、_a。

想要看后面的地址是不是func3和func4,可以取出Derive类的对象d的头4个字节(指向虚表)
这个虚表是一个函数指针数组,可以进行打印。
拿到函数指针数组里的函数指针,是可以进行调用的,然后根据调用情况就能知道是否是func4和func4了。
在d的监视窗口看到虚表里面的2个函数指针,在内存窗口看到虚表里面的4个指针。
到底后两个指针是不是func3和func4,可以调用来看看:
【32位程序的访问思路如下】
- 取出b、d对象的头4bytes,就是虚表的指针(地址),前面我们说了虚函数表本质是一个存虚函数指针的指针数组,vs下这个数组最后面放了一个nullptr,g++下面最后没有nullptr。
- 1.先取b的地址,强转成一个int*的指针。
- 2.再解引用取值,就取到了b对象的头4bytes的值,这个值就是指向虚表的指针。
- 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
- 4.虚表指针传递给PrintVTable进行打印虚表。
- 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚****表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的 - 生成 - 清理解决方案,再编译就好了。
需要注意的是如果是在64位下,指针是8byte,对应程序位置就需要进行更改。
强转成int*,这样解引用就只会访问4Byte的内存。

解引用之后,拿到里面存储的函数指针数组的地址。
int*类型指针解引用是int类型,传不过去,需要强转。
g++下面只能说知道有4个指针,结束条件给成i < 4。


这个知识本身没那么重要,也几乎用不到。
实践当中也不关系虚函数放在哪个虚表哪个位置,找虚函数都是为了重写。
只是知识多多益善。
【重要的是这种解决问题的思路方法】
- 想确认某块内存的内容,可以强转成对应大小的指针解引用拿到对应内存空间段的内容
- 拿到之后,由于是函数指针,接收方需要把解引用的结果类型强转才能接收。
- 拿到函数指针,可以进行函数调用,为了确定调用的函数,可以给现有函数打上标记,在调用的时候会触发标记打印。
2.2 多继承虚函数表深入探索
跟前面单继承类似,多继承时Derive对象的虚表在监视窗口也观察不到部分虚函数的指针。所以我们一样可以借助上面的思路强制打印虚函数表。
需要注意的是多继承时,Derive中同时继承了Base1和Base2,内存中先继承的对象在前面,并且Derive中包含的Base1和Base2各有一张虚函数表,通过观察我们发现Derive没有重写的虚函数func3,选择放在先继承的Base1的虚函数表中。
另外需要注意的是,有些细心的同学发现Derive对象中重写的Base1虚表的func1地址和重写Base2虚表的func1地址不一样,这是为什么呢?这个问题还比较复杂。需要我们分别对这两个函数进行多态调用,并翻阅对应的汇编代码进行分析,才能捋清楚问题所在。这里简单说一个结论就是本质Base2虚表中func1的地址并不是真实的func1的地址,而是封装过的func1地址,因为Base2指针p2指向Derive时,Base2部分在中间位置,切片时,指针会发生偏移,那么多态调用p2->func1()时,p2传递给this前需要把p2给修正回去指向Derive对象,因为func1是Derive重写的,里面this应该是指向Derive对象的。

cpp
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自己的虚函数func3还是没有。
通过内存观察虚表,也不确定存储的是不是func3,所以还是通过调用虚表函数指针的方式。
【先说结论】Derive自己的虚函数放到先继承的Base1的虚表后面。


需要重新生成解决方案。

打印Derive的Base2虚表,还可以是下面这种写法:

这里有意思的一个点在于:Derive对象中重写的Base1虚表的func1地址和重写Base2虚表的func1地址不一样,但是调的却是同一个函数。
原因对于实践当中使用多继承并不重要,重要的是思维方式。
这里需要通过汇编代码来看。



Base1的虚表里面重写的func1的地址,就是func1的真实地址。


其实Base1的虚表func1地址和Base2的虚表func1地址都能去到jmp(func1)指令。

只是
- Base1的虚表地址------jmp(func1)指令地址,是真的jmp(func1)指令地址;
- Base2的虚表地址------jmp(func1)指令地址,是中转指令地址,中转过后又是一个jmp(func1)指令,这也是一个中转指令,最后才是真的jmp(func1)指令。
这里Base2有两次额外的jmp,才到真的jmp指令,真实意图就在于------sub ecx, 8。
从汇编代码可以看到:
- mov ecx, [p1];
- mov ecx, [p2];
可知ecx存储的是this指针,而sub ecx, 8是用来修正this指针。

可以看到p1和p2的指向的地方偏差量刚好就是8。
p2调用func1成员函数,第一个参数是this指针------对象的指针。
而对象的直接指针,和Base2对象内存块,隔着8字节的距离。
p1调用func1成员函数,第一个参数是this指针------对象的指针,没偏差可以直接使用。
【父类指针指向子类对象,使用这个父类指针调用成员函数,this指针------】
- this 指针指向的是子类对象中「父类切片」的起始地址,而不是整个子类对象的起始地址。
2.3 菱形继承、菱形虚拟继承
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定的性能损耗。
所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。如果好奇心强的同学,可以去看下面的两篇链接文章。
cpp
class A
{
public:
virtual void func1() {}
public:
int _a;
};
class B : virtual public A
{
public:
virtual void func1() {}
virtual void func2() {}
public:
int _b;
};
class C : virtual public A
{
public:
virtual void func1() {}
virtual void func3() {}
public:
int _c;
};
class D : public B, public C
{
public:
D()
:_d(1)
{
}
inline virtual void func1() {}
virtual void func4() {}
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;
}
【课堂演示】
先来看到之前的虚基表:

- 虚基表第2行才存储偏移量 ------到菱形继承的公共成员变量(_a)的偏移量。
- 虚基表第1行的00 00 00 00是预留的------到自己虚表的偏移量
首先这里如果A、B、C、D有虚函数,在D类对象内部会多出3张虚表:
- A有虚函数,则A有自己的虚表,D中继承一张A的虚表。
- B、C各自有自己的虚函数,则B、C也有自己的虚表,D中同样继承。
D有自己的虚函数,会放到D继承的B虚表后面。
监视窗口不方便观察,通过内存窗口观察:


公共成员_a已经偏得很远了:

_b就在B类切片的虚表指针、虚基表指针后面:

_c就在C类切片的虚表指针、虚基表指针后面:

_d就在C类切片后面。

A有func1的虚函数,B、C重写了自己的func1虚函数,则D必须重写自己的func1虚函数。

D不重写func1,会报错。
在菱形虚拟继承中------D 对象中一定有一个 A 子对象(只有一份,在末尾),这个 A 子对象有自己的虚表指针(vptr),指向 A 的虚表。

上图的错误:不是从88到00的28偏移量,而是从ac到a4的28偏移量。
fc ff ff ff是-4,表示B的虚基表到B的虚表的偏移量。
3. 继承和多态考察的一些常见问题测试
- 什么是多态?答:参考课堂讲解。
- 什么是重载、重写(覆盖)、重定义(隐藏)?答:参考课堂讲解。
- 多态的实现原理?答:参考课堂讲解。
- inline函数可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是inline属性,因为虚函数要放到虚表中去,也就是说inline属性和虚函数属性是不同同时存在的。
- 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
- 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
- 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析构函数定义成虚函数。参考本节课件内容。
- 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象调用,是一样快的。如果是指针或者是引用对去调用,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
- 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的,++同一个类型的多个对象会指向同一张虚函数表。++
(每个对象内的虚函数表指针是运行时调构造的时候在初始化列表初始化的)- C++菱形继承的问题?虚继承的原理?答:参考课堂讲解。注意这里不要把虚函数表和虚基表搞混了。
- 什么是抽象类?抽象类的作用?答:参考课堂讲解;抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。
【1】什么是多态?------结合面向对象三大特性考察:什么是封装?继承?多态?
封装:类+访问限定符、迭代器。
继承:类层次的复用(复用成员变量、成员函数)。
多态:分为静态多态、动态多态。
- 静态多态(编译时):函数重载------自动识别类型。
- 动态多态(运行时):指向父类调用父类的函数;指向子类调用子类的函数。
【3】多态的实现原理?
核心是虚表,父类有自己的虚表,子类会继承父类的虚表,然后形成自己的虚表,形成规则:
- 没重写的虚函数项:照用父类虚表的对应项
- 重写了的虚函数项:覆盖父类虚表的对应项
- 子类自己的虚函数:加在父类虚表的后面
实际通过父类指针调用的时候,不一定调用父类的函数,而是都通过虚表去调用。
都是去指向的对象的虚表里面,拿着函数名去找对应的函数地址。
【4】内联函数可以是虚函数吗?

可以是,语法上inline和virtual可以同时存在,不会报错。
但是,一个函数如果是虚函数了,那它的内联属性就丢了。(在类体内定义的成员函数默认内联)
正常函数的调用是call函数的地址,跳转过去调用------建立栈帧。
内联函数是符合内联属性时,调用的地方直接原地展开,不需要地址,不建立新的栈帧。
虚函数要求一定有地址放进虚表,指向谁就谁的虚表找到函数地址,然后call调用。
语法上允许inline存在------因为虚函数的调用不一定是多态调用,可能d.finc1()直接调用,如果符合内联属性,就能直接原地展开。
【5】静态成员函数可以是虚函数吗?
不可以,静态成员函数不属于对象,没有this指针,一般通过指定类域调用,不是对象调用无法访问虚函数表。
【6】构造函数可以是虚函数吗?
不可以,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。


实际是构造函数的初始化列表之后:


虚表指针在初始化列表中比_d先初始化完成。
【7】析构函数可以是虚函数吗?
可以,而且建议把析构函数写成虚函数。
一个父类指针,可能指向父类对象,也可能指向子类对象,就需要调对应的析构。
【8】访问虚函数更快还是访问普通函数更快?
虚函数和普通函数都是放在代码段,通过call调用。
不同的是虚函数的指针放在虚表,走多态的逻辑,那相对慢一点。但是虚函数也可能只是普通调用,那就一样快。
【11】什么是抽象类?抽象类的作用?
包含纯虚函数的类就是抽象类,派生类要是不重写,那还是包含纯虚函数,还是抽象类。
抽象类不能实例化对象,派生类就需要强制重写虚函数。
override放在派生类的虚函数后,检查重写。
完