继承和多态扩展学习
菱形虚拟继承原理剖析
在C++中,多继承是一种强大的特性,但它也可能引发一些复杂的问题,尤其是菱形继承。菱形继承是指一个类继承了两个子类,而这两个子类又共同继承自同一个基类,从而形成一个类似“菱形”的继承结构。
#include <iostream>
#include <string>
using namespace std;class Person {
public:string _name; // 姓名
};class Student : virtual public Person {
protected:int _num; // 学号
};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 = "xxx"; // 指定通过 Student 路径访问a.Teacher::_name = "yyy"; // 指定通过 Teacher 路径访问
}
在这个例子中,Assistant
类通过Student
和Teacher
间接继承了Person
类,形成了一个菱形继承结构。如果没有使用虚拟继承,Assistant
类中会包含两份Person
类的成员变量_name
,一份来自Student
,另一份来自Teacher
。
数据冗余问题
在没有使用虚拟继承的情况下,Person
类会被Student
和Teacher
各自继承一份,导致Assistant
类中包含两份_name
成员变量。例如:
class Student : public Person {
protected:int _num; // 学号
};class Teacher : public Person {
protected:int _id; // 职工编号
};
在这种情况下,Assistant
类的对象a
的内存布局可能如下:
+-------------------+
| Student 的 Person | <- 包含 _name (1)
+-------------------+
| Student 的 _num |
+-------------------+
| Teacher 的 Person | <- 包含 _name (2)
+-------------------+
| Teacher 的 _id |
+-------------------+
| Assistant 的 _majorCourse |
+-------------------+
这里有两个_name
成员变量,分别来自Student
和Teacher
,这就是数据冗余。
二义性问题
由于Assistant
类中存在两份_name
成员变量,当直接访问a._name
时,编译器无法确定应该访问哪一份_name
,从而导致二义性。例如:
a._name = "peter"; // 编译错误:二义性
为了解决二义性问题,必须显式指定访问路径:
a.Student::_name = "xxx"; // 指定通过 Student 路径访问
a.Teacher::_name = "yyy"; // 指定通过 Teacher 路径访问
虚拟继承的解决方案
通过使用虚拟继承,Person
类在Student
和Teacher
中被共享,而不是各自独立继承。虚拟继承通过引入 虚基表(Virtual Base Table,VBT) 来解决数据冗余和二义性问题。
虚拟继承的对象模型
假设我们使用虚拟继承,Assistant
类的对象a
的内存布局大致如下:
+-------------------+
| Assistant 的 vptr | -> 指向 Assistant 的虚基表
+-------------------+
| Student 的 vptr | -> 指向 Student 的虚基表
+-------------------+
| Teacher 的 vptr | -> 指向 Teacher 的虚基表
+-------------------+
| Assistant 的 _majorCourse |
+-------------------+
| Student 的 _num |
+-------------------+
| Teacher 的 _id |
+-------------------+
| Person 的 _name | <- 虚基类 Person 的成员变量
+-------------------+
#include <iostream>
using namespace std;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;
}
为了研究虚拟继承原理,我们给出了一个简化的菱形继承体系,再借助内存窗口观察对象成员的模型。需要注意的是,这里必须借助内存窗口才能看到真实的底层对象内存模型,VS 编译器的监视窗口是经过特殊处理的,它从自身的角度给出了一个方便查看的样子,但并不是本来的样子。然而,有时若想看清真实的内存模型,往往需要借助内存窗口。
监视窗口
内存窗口
由于我的当前编译器显示不出来,我们下面借鉴网上的图片,前面步骤是一样的:
对于对应地址的直接存的是全0,这个我们在后面的多态部分再反过来浅浅看看,下一个位置这里的"14"和"0c",在16进制转化为十进制分别为"20 = 4*5"和"12 = 4*3";
其实这就是距离A的相对偏移量!
通过上面的简化菱形虚拟继承模型,我们可以看到,D
对象中的B
和C
部分中分别包含一个指向虚基表的指针。B
指向的虚基表中存储了B
对象部分距离公共的A
的相对偏移量,C
指向的虚基表中存储了C
对象部分距离公共的A
的相对偏移量。这样,公共的虚基类A
部分在D
对象中就只有一份了,从而解决了数据冗余和二义性的问题。
可是A不就是在当前的位置吗?我们直接访问A不就好了吗?为什么还要通过偏移量来进行对A的访问,就相当于编译后知道了B和C的位置?因为一个对象里面,挨着挨着分布,编译器编译的时候是知道按照声明的顺序分布的!
那么我们来看看关于切片的场景:
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;
}
这里就存在一个问题,B的指针,就有可能指向B的对象b,也有可能指向D的对象d,现在要对_a进行++操作,那么这时候_a在哪里呢?
当你有一个指向B
的指针p2/1,它可能指向一个B
类型的对象,也可能指向一个D
类型的对象的切片。
如果是指向对象D的切片的话,就是需要偏移4*5个偏移值,如果是直接指向对象B的话,就是偏移4*1个偏移值,如果没有偏移的概念,那么_a就不能明确的指向,就会出现冲突,不知道是指的是D切片下的_a,还是B下的_a!
通过B
的对象模型,我们发现菱形虚拟继承中B
和C
的对象模型与D
保持一致的方式去存储管理A
。这样,当B
的指针访问A
时,无论B
指针切片指向D
对象,还是B
指针直接指向B
对象,访问A
成员都是通过虚基表指针的方式查找到A
成员再访问。
菱形虚拟继承虽然解决了数据冗余和二义性的问题,但是带来了数据访问效率降低的负面效果。因为之前编译好了就可以直接确定数据的位置,但是现在就和运行时多态一样,需要运行时调用的虚函数到底是父类的还是子类的,编译的时候是不确定的,只能在运行起来了后,去p指向的对象的虚表中找到对应的虚函数进行调用,指向父类对象就调用父类对象的虚函数,指向子类对象就调用子类对象的虚函数。这里也是同样的道理!
上面就是菱形虚拟继承的原理,这个地方多了一个虚基表,注意:虚基表和虚表是不一样的!
- 虚表是虚函数表,存放的是虚函数的地址,用于实现多态的;
- 虚基表是存放偏移量的,用来找公共的数据的,用来解决数据冗余和二义性的。
虽然都用来virtual关键字,但是两者是不一样的!
单继承和多继承的虚函数表的深入探索
单继承虚函数表的深入探索
VS编译器的监视窗口经过特殊处理,它以一种方便查看的方式展示内容,但并不是对象的实际内存布局。我们之前已经讲过多态部分,虚函数指针都会被放入虚函数表中。通过监视窗口观察Derive
对象时,看不到func3
和func4
在虚表中。借助内存窗口可以看到一个地址,但无法确认这是否是func3
和func4
的地址。因此,我们编写了一份特殊代码,通过指针的方式强制访问虚函数表并调用虚函数,从而确认继承中虚函数表的真实内容。
#include <iostream>
using namespace std;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) {
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,对应程序位置就需要进行更改// 我们可以更加巧妙的使用: (*(void**))// 32位下 --- (void**) == (int*)// 64位下 --- (void**) == (long long*)// 思路:// 1.先取b的地址,强转成一个int*的指针// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针// 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。// 4.虚表指针传递给PrintVTable进行打印虚表// 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再编译就好了。VFPTR* vTable1 = (VFPTR*)(*(void**)&b);PrintVTable(vTable1);VFPTR* vTable2 = (VFPTR*)(*(void**)&d);PrintVTable(vTable2);return 0;
}
多继承虚函数表的深入探索
在单继承中,Derive
对象的虚表在监视窗口中观察不到部分虚函数的指针。类似地,在多继承中,Derive
对象的虚表在监视窗口中也观察不到部分虚函数的指针。因此,我们可以通过类似的思路强制打印虚函数表。
多继承的特点
-
当
Derive
类同时继承了Base1
和Base2
时,内存布局中先继承的对象在前面。 -
Derive
类中包含的Base1
和Base2
各自有一张虚函数表。 -
通过观察发现,
Derive
类中未重写的虚函数func3
被放置在先继承的Base1
的虚函数表中。
关于虚表中函数地址不同的原因
-
我们发现,
Derive
对象中重写的Base1
虚表的func1
地址和重写的Base2
虚表的func1
地址不一样。 -
这个问题比较复杂,需要分别对这两个函数进行多态调用,并查看对应的汇编代码才能分析清楚。
-
简单来说,
Base2
虚表中func1
的地址并不是真实的func1
地址,而是一个封装过的地址。因为当Base2
指针p2
指向Derive
对象时,Base2
部分在中间位置,切片时指针会发生偏移。因此,多态调用p2->func1()
时,需要将p2
修正为指向Derive
对象的指针,因为func1
是Derive
重写的,其内部的this
指针应该指向Derive
对象。
#include <iostream>
using namespace std;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;// 打印Base1部分的虚表VFPTR* vTableb1 = (VFPTR*)(*(void**)&d);PrintVTable(vTableb1);// 打印Base2部分的虚表// VFPTR* vTableb2 = (VFPTR*)(*(void**)((char*)&d + sizeof(Base1))); //法一Base2* ptr = &d; //法二: 利用切片使 ptr 指向Base2VFPTR* vTableb2 = (VFPTR*)(*(void**)(ptr));PrintVTable(vTableb2);// 多态调用Base1* p1 = &d;p1->func1();Base2* p2 = &d;p2->func1();d.func1();return 0;
}
关于菱形继承和菱形虚拟继承
在实际开发中,我们不建议设计出菱形继承及菱形虚拟继承。一方面,这种继承结构过于复杂,容易出现问题;另一方面,这种模型在访问基类成员时会存在一定性能损耗。因此,我们通常不会深入研究菱形继承或菱形虚拟继承的虚表结构,因为这些情况在实际开发中很少用到。
相关文章链接:
1.C++ 虚函数表解析
2.C++ 对象的内存布局
对于上面的虚基表的全0的现象,当有虚函数表的时候,就不会是全0了,而是对于自己虚函数表的偏移量,D对象继承了B和C,B和C又继承了A,A当中有一张虚表,A不是属于B,也不是属于C,而是B和C共享的,所以B和C可以重写A的虚函数,但是没有重写的虚函数就不能够往A里面放,会有各自的独立的虚函数表;如果D没有重写的虚函数,就会方法到第一个继承,即B当中。
多态考察的一些常见问题汇总
什么是多态?
答:多态就是同一个接口,可以被不同的底层实现。比如,同一个函数名,调用时会根据对象的实际类型表现出不同的行为。多态让代码更灵活,能用统一的方式处理多种情况。
多态分为两种:编译时多态:通过函数重载实现,编译器根据参数类型和数量选择函数。运行时多态:通过虚函数和继承实现,运行时根据对象的实际类型调用函数。
- 虚函数:在基类中定义,派生类可以重写。
- 虚表(vtable):存储类中虚函数的地址。
- 代码复用:用基类接口编写通用代码,派生类提供具体实现。
- 扩展性:新增派生类时,无需修改现有代码。
- 解耦:基类和派生类关系更松散。
什么是重载、重写(覆盖)、重定义(隐藏)?
答:重载是指在同一个作用域内,函数或运算符的名字相同,但参数类型或数量不同。编译器根据参数类型和数量来区分这些函数。
-
特点:函数名相同,参数列表不同。
重写是指派生类中有一个与基类同名的虚函数,参数列表完全相同。派生类的函数覆盖了基类的虚函数,运行时会调用派生类的函数。
- 特点:函数名和参数列表完全相同,必须通过
override
关键字(可选)明确表示。
重定义是指派生类中有一个与基类同名的函数,但参数列表不同。派生类的函数隐藏了基类的函数,而不是覆盖它。
-
特点:函数名相同,但参数列表不同,基类的函数被隐藏,而不是被覆盖。
多态的实现原理?
答:虚表!但是虚表只是其中一层,父类有了虚函数之后,父类就会有一个虚表,如果有多个子类继承,就会各自都有自己的虚表,重写虚函数之后,父类虚函数放的是父类的,派生类虚函数就是派生类的;在运行的时候,并不是编译的时候确定调用谁,而是一个父类指针调用这个函数,到底是调用的谁的?并不一定是调用的父类的,而是看指向哪一个对象,因为是去指针指向或引用的对象的虚函数表中去找,所以就达到了指向谁调用谁!只不过如果父类指向子类对象,调用重写的虚函数,会先进入到父类的声明,在调用子类的实现!(在C++中,当使用基类指针(或引用)指向派生类对象时,调用虚函数的过程确实会先通过基类的声明,然后动态绑定到派生类的具体实现。这个过程体现了运行时多态的机制。)
inline函数可以是虚函数吗?
答:可以,不过编译器就忽略inline属性,这个函数就不再是inline属性,因为虚函数要放到虚表中去,也就是说inline属性和虚函数属性是不能同时存在的。
静态成员可以是虚函数吗?
答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
构造函数可以是虚函数吗?
答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
答:可以,并且最好把基类的析构函数定义成虚函数。我们在之前的多态文章中有讲过!
对象访问普通函数快还是虚函数更快?
答:首先如果是普通对象调用,是一样快的。如果是指针或者引用去调用,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。(对照上面的多态的实现原理)
虚函数表是在什么阶段生成的,存在哪里?
答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
C++菱形继承的问题?虚继承的原理?
答:参考课堂讲解。注意这里不要把虚函数表和虚基表搞混了。
什么是抽象类?抽象类的作用?
答:包含纯虚函数,抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。