目录
- 一.多态的概念
- 二.多态的定义及实现
- 2.1虚函数
- 2.2虚函数的重写
- 虚函数重写的两个例外
- 2.3多态的构成条件
- 2.4C++11 override 和final
- 2.5重载、重写、隐藏的对比
- 三.抽象类
- 3.1概念
- 3.2接口继承和实现继承
- 四.多态的原理
- 4.1虚函数表
- 4.2多态的原理
- (1)代码分析
- (2)清理解决方案
- 4.3动态绑定和静态绑定
- 五.单继承和多继承的虚函数表
- 5.1单继承中的虚函数表
- (1)子类中的虚函数
- 打印虚表:
- 虚表的两个问题:
- (2)子类多对象的虚函数
- 5.2多继承中的虚函数表
- 5.3虚表与虚基表
- 5.4菱形继承中的多态
一.多态的概念
通俗讲:多态 == 多种形态
具体讲:不同的对象完成同一个行为时,会产生出不同的状态
放在类中:继承一个类的不同子类,执行一个函数,产生不同结果。
举个例子:
比方说我们放假回家买票 这个行为,普通人 买是全价,学生 买票是半价,军人 买票则是优先买票。
二.多态的定义及实现
2.1虚函数
被
virtual
修饰的类成员函数称为虚函数
class Person {
public:
virtual void Buy_Ticket()
{
cout << "Person:全价" << endl;
}
};
- 其中,
Buy_Ticket
就是一个虚函数
2.2虚函数的重写
子类中有一个和父类完全相同的虚函数(返回值类型、函数名字、参数列表(缺省值不考虑)三部分完全相同) ,称之为子类的虚函数重写了父类的虚函数。(所以重写,就是重写了父类对应虚函数的实现,继承了它的接口)
//子类添加virtual
class Person {
public:
virtual void Buy_Ticket()
{
cout << "Person:全价" << endl;
}
};
class Student :public Person {
public:
virtual void Buy_Ticket()
{
cout << "Student:半价" << endl;
}
};
//子类不添加virtual
class Person {
public:
virtual void Buy_Ticket()
{
cout << "Person:全价" << endl;
}
};
class Student :public Person {
public:
void Buy_Ticket()
{
cout << "Student:半价" << endl;
}
};
-
在重写虚函数时,子类使不使用
virtual
都构成虚函数的重写。这个规则可能是为了方便不同的人在继承父类后,编写程序时,可以更好的实现代码,而不必在乎父类中的成员是否为虚函数,毕竟只要是虚函数,就会完成重写,无需在子类中使用virtual。
-
但是子类重写父类却不使用
virtual
的写法不是很规范,不建议这样使用。
( 这一段简绍虚函数的重写,至于虚函数重写后的应用在下面)
虚函数重写的两个例外
-
协变
父类与子类虚函数返回值类型不同
子类重写父类的虚函数时,与父类虚函数返回值类型不同。即父类虚函数返回父类对象的指针或引用,子类返回子类对象的指针或引用,称之为协变 。
//返回本父类和子类的指针或引用 class Person { public: virtual Person* Buy_Ticket() { cout << "Person:全价" << endl; return new Person(); } }; class Student :public Person { public: virtual Student* Buy_Ticket() { cout << "Student:半价" << endl; return new Student(); } }; //返回其它父类和子类的指针或引用 class A{}; class B : public A{}; class Person { public: virtual A* Buy_Ticket() { cout << "Person:全价" << endl; return nullptr; } }; class Student :public Person { public: virtual B* Buy_Ticket() { cout << "Student:半价" << endl; return nullptr; } };
- 只要是返回父类和子类的引用或指针即可,不用管是不是属于所在的类。
- 不能将子类的指针或引用作为父类虚函数的返回值,而父类的指针或引用作为子类的虚函数的返回值,编译器会报错警告返回值类型不匹配。
- 子类不使用
virtual
关键字也是可以的
-
析构函数重写
父类与子类析构函数的名字不同
如果父类的析构函数为虚函数,此时子类析构函数只要定义,无论是否加virtual关键字,都与父类的析构函数构成重写 ,虽然基类与派生类析构函数名字不同,看似违背了重写的原则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成
destructor
。class A{ public: virtual ~A() { cout << "A()" << endl; } }; class B : public A{ public: virtual ~B() { cout << "B()" << endl; } };
2.3多态的构成条件
多态是继承同一个类的不同子类对象,调用同一函数,产生不同的行为。
比如:Student继承了Person,Person对象买票全价,Student对象买票半价。
继承中构成多态的两个条件:
必须通过父类的指针或者引用调用虚函数。
被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
下面我们分类来看一下,在多态和不在多态的情况下,下面代码的运行情况:
void test(Person& p){ p.Buy_Ticket(); }
int main()
{
Person p;
Student s;
test(p);
test(s);
return 0;
}
- 这里的test函数参数只能是父类的引用或指针,否则在传输父类对象时无法正常接收。
满足多态:
class Person {
public:
virtual void Buy_Ticket()
{
cout << "Person:全价" << endl;
}
};
class Student :public Person {
public:
virtual void Buy_Ticket()
{
cout << "Student:半价" << endl;
}
};
这里我们可以看到,满足多态时,根据指针指向或引用指向的对象的类型,调用这个类型的成员函数
不满足多态:
-
普通函数不构成虚函数
class Person { public: void Buy_Ticket() { cout << "Person:全价" << endl; } }; class Student :public Person { public: void Buy_Ticket() { cout << "Student:半价" << endl; } };
这里我们看到,父类中的成员函数不是虚函数,而且父子类的这种关系为隐藏,而不是重写,这是不满足多态的条件的,这种情况看指针或引用的类型,调用这个类型的成员函数
-
析构函数不构成虚函数
之前我们讲了析构函数的重写,使其也变为一个虚函数,或许很多人会有疑问,为什么要将析构函数也变为虚函数,这里我们编写一段代码,看一下,析构函数不是虚函数时,运行的结果满不满足我们的需求:
//析构函数不使用virtual class A{ public: ~A() { cout << "A()" << endl; } }; class B : public A{ public: ~B() { cout << "B()" << endl; } }; int main() { A* a = new A; A* b = new B; delete a; delete b; return 0; }
只调用了两次A类的析构函数并且编译器报错,而没有调用B类的析构函数,这显然是错误的。
- 在
delete b
释放b指针的空间时,在析构函数不构成多态的情况下,也是根据当前指针或引用的类型来调用对应的析构函数,也就是父类的析构函数。这会导致内存显露 等问题
//析构函数使用virtual class A{ public: virtual ~A() { cout << "A()" << endl; } }; class B : public A{ public: virtual ~B() { cout << "B()" << endl; } }; int main() { A* a = new A; A* b = new B; delete a; delete b; return 0; }
- 在
多态有什么作用?隐藏即可解决的事情,为什么还要用多态?
-
像我们要做一个买票的功能,不同的人票价不同,所以要设置一个父类Person,多个子类继承父类,在使用时创建对象,当我们要将对象作为参数传递给一个函数实现一个具体的功能时,我们不能每种类型的对象就创建一个参数吧(有过多的类时,代码冗余),只能使用父类的指针或引用接收,而如果没有多态在这个函数体内,我们使用到的只有父类的成员,而没有其它子类的
这正是多态的作用,使不同对象调用同一个函数,结果不同 ,
这也是多态的构成条件所迫使我们达出的结果。
-
C++的虚函数就是为了重写而生的,重写就是为了多态而生的,C++这里的语法概念可能会有些难理解,这是因为它的底层就很复杂。
2.4C++11 override 和final
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名的字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,而到了在程序运行时没有得到预期结果才来debug查找错误,就会得不偿失,所以,C++11提供了两个关键字override和final两个关键字,可以帮助用户检测是否重写。
-
final:修饰虚函数,表示该虚函数不能被重写
class A { public: virtual void test() final{} }; class B : public A{ public: virtual void test() { cout << "test" << endl; } };
error C3248: “A::test”: 声明为“final”的函数无法被“B::test”重写
-
override:检查子类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
class A { public: virtual void test(){} }; class B : public A{ public: virtual void test1() override { cout << "test" << endl; } };
error C3668: “B::test1”: 包含重写说明符“override”的方法没有重写任何基类方法
2.5重载、重写、隐藏的对比
三.抽象类
3.1概念
在虚函数的后面写上
= 0
,则这个虚函数就是纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象 。
特点:
子类继承抽象类后不能实例化出对象,只有在子类重写纯虚函数后,子类才能实例化出对象。
作用:
纯虚函数规范了子类必须重写,体现了接口的继承。
class A {
public:
virtual void test() = 0;
};
class B : public A {
public:
virtual void test()
{
cout << "void test" << endl;
}
};
class C : public A
{
public:
void test1()
{
cout << "void test1" << endl;
}
};
int main()
{
A a;//报错,A::test是纯虚函数
B b;
A c;//报错,A::test是纯虚函数
return 0;
}
- 一个类型在现实中没有对应的实体,我们就可以将一个类定义为抽象类。
- 纯虚函数和
overrid
的区别在于,纯虚函数:是在父类中应用(强制重写),override:是在子类中应用(检查重写)。
3.2接口继承和实现继承
实现继承: 普通函数的继承,子类继承了父类,可以使用函数,继承的是函数的实现。
接口继承: 虚函数继承,子类继承的是父类虚函数的接口 ,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义为虚函数
四.多态的原理
4.1虚函数表
我们先来看一道常见的笔试题,看下面的代码运行结果是多少?
class A
{
public:
virtual void test()
{
cout << "test()" << endl;
}
private:
int _a = 1;
char _c;
};
int main()
{
cout << "sizeof(A):" << sizeof(A) << endl;
return 0;
}
我们看到,上面的代码运行结果为12,这似乎和我们了解到的类的大小有区别,正常来说,一个类的大小只与它的成员变量有关,也就是_a占四个字节,_c占一个字节,总大小为最大成员的整数倍,也就是8,结果却是12。
因为该类中,除了两个成员变量外,还有一个_vfptr
放在对象所占空间的开头(注意有些普通可能会放到对象的最后面,这个和平台有关,我使用的VS2019),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。
一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数也简称虚表 。
那么派生类中这个表放了些什么呢?我们编写如下程序查看:
class A
{
public:
virtual void test()
{
cout << "test()" << endl;
}
private:
int _a = 1;
char c;
};
int main()
{
A a;
return 0;
}
通过调试在监视窗口查看,a对象的情况,如下图:
我们看到,_vfptr指针就是a对象的虚函数表指针,我们现在对上面代码继续如下修改,使之可以了解更多的东西:
class A
{
public:
virtual void test()
{
cout << "A::test()" << endl;
}
virtual void test1()
{
cout << "A::test1()" << endl;
}
void test2(){}
private:
int _a = 1;
char c;
};
class B : public A
{
public:
virtual void test()
{
cout << "B::test()" << endl;
}
private:
int _b = 0;
};
int main()
{
A a;
B b;
return 0;
}
-
子类对象b中也有一个虚表指针,b对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在这部分,另一部分是自己的成员。
-
父类a对象和子类b对象虚表是不一样的,这里我们发现test完成了重写,所以b的虚表中存储的是重写的B::test(void),所以虚函数的重写也叫作覆盖 ,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
-
另外,test1继承下来是虚函数,所以放进了虚表(因为没有重写,所以父子类对象使用的是同一个虚函数,可以观察到父子类对象的虚函数A::test1(void)的地址相同),test2也继承下来了,但不是虚函数,所以不放在虚表。
-
虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组在最后放类一个nullptr值,表示数组到此结束。
我们通过内存窗口,来查看子类对象虚表的情况,如下图:
我们观察内存窗口和监视窗口的两个图,可以看到,虚表的前两个地址所存放的是test和test1的虚函数地址,在最后一个位置存放的是空地址也就是nullptr。
-
派生类虚表生成如下:
- 先将父类中的虚表内容拷贝一份到子类虚表中;
- 如果子类重写了父类中的某个虚函数,用子类自己的虚函数覆盖虚表中父类的虚函数;
- 子类自己新增的虚函数按其在子类中的声明次序增加到子类的虚表的最后。
注意:
这里还有一个容易混淆的问题:
虚函数存在哪里?虚表存在哪里?
答案大多是虚函数存在虚表,虚表存在对象中。切记,这里的答案是错的,但很多人都是这样深以为然。
首先,我们要明白,对象中存的是不是虚表,而是虚表的指针,虚表里存的是虚函数指针,不是虚函数, 虚函数和普通的函数一样,都存在代码段中。如下图:
int main()
{
A a;
int b = 0;
int* c = new int;
static int d = 0;
const char* e = "hello";
printf("栈:%p\n", &b);
printf("堆:%p\n", c);
printf("数据段:%p\n", &d);
printf("代码段:%p\n", e);
printf("虚表:%p\n", *(int*)&a);
return 0;
}
结论:
- 虚表和虚函数都存在代码段中
4.2多态的原理
通过上面的学习,我们知道了多态中虚表的存在,现在我们来看一下,多态是如何利用虚表来实现的。
(1)代码分析
class Person {
public:
virtual void Buy_Ticket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void Buy_Ticket() { cout << "买票-半价" << endl; }
};
void Func(Person* p)
{
p->Buy_Ticket();
}
int main()
{
Person* pptr = new Person;
Student* sptr = new Student;
Func(pptr);
Func(sptr);
return 0;
}
- 观察上图红色箭头:p是指向pptr对象时,p->Buy_Ticket在pptr的虚表中找到虚函数Person::Buy_Ticket.
- 观察上图蓝色箭头:p指向sptr对象时,p->Buy_Ticket在sptr的虚表中找到虚函数Student::Buy_Ticket.
- 这样就实现出了不同对象去完成统一行为时,展现出不同的形态。
问题:
达成多态有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调用虚函数,为什么呢?用普通对象接收可以吗?
-
虚函数表覆盖:
首先,一个类中有虚函数才会有虚函数表,可以在其中存储虚函数地址,才会有调用指向的函数。
其次,虚函数表覆盖,是使不同的类指向同一功能时产生不同的结果,也就是想要执行出不同的结果,只有虚函数表覆盖,才有不同的东西来让我们执行,否则多态毫无意义。
-
对象指针或引用:
这就必须要结合继承的切片来看了,在父类对象和引用中,接收的时子类对象中父类对象那部分成员的地址,所以此时调用的还是子类对象的成员。
而当子类对象过多时,即可使用父类对象接收,接收后,指向的仍然是对应子类对象的成员,依然可以在对应的虚表中查找到要调用的虚函数。
-
使用对象接收:
如上图,在父类对象接收子类对象时,进行了拷贝,那问题就在于,此时是否拷贝了子类对象的虚表,要是拷贝了虚表,那依然可以调用对应的虚函数,执行出想要的结果,要是没有,那它就是一个普通的父类对象,执行的是父类的虚函数。
为了让结果更加明显,我们假设当父子类中的成员只有一个虚函数,其它什么都没有时,
父类 = 子类
过程中拷贝子类虚表这件事发生了,那该父类对象到底是子类对象还是父类对象?说它是父类对象,它的虚函数和其它的父类对象都不同,却和子类对象的相同,说它是子类对象,它的类型又是父类对象,“我即使自己的父亲,又是自己的儿子”,这是什么奇葩?
说到这里,答案也就呼之欲出了,使用对象拷贝是不被允许的。
(2)清理解决方案
在我们编译程序,在调试的监视和内存窗口查看对应的内存和数据变化时,可能得到的和预期的有所不同,这可能不是因为你的代码出了问题,而是因为之前允许程序后没来的及清理干净,造成的错误,一般这个时候,在下图位置点击清理解决方案 即可解决问题。
清理解决方案是一项非常重要的功能。它的主要目的是清除解决方案中所有项目的编译输出文件和中间文件,以确保下一次重新构建时,从头开始重新编译所有文件,而不是使用以前的缓存文件。
清理解决方案可以帮助开发人员解决以下问题:
- 释放磁盘空间:在解决方案中有许多项目时,每个项目都会生成大量的中间文件和编译输出文件。这些文件会占用大量的磁盘空间,清理解决方案可以释放这些空间。
- 解决构建错误:有时候在构建解决方案时会出现一些奇怪的编译错误,这些错误可能是由于缓存文件损坏或过期导致的。清理解决方案可以解决这些问题。
- 确保重新构建:有时候在修改代码后,由于某些文件没有被重新编译,导致程序运行出现问题。清理解决方案可以确保所有文件都被重新编译。
4.3动态绑定和静态绑定
- 静态绑定又称为前期绑定(早绑定),在程序编译期间缺点了程序的行为,也称静态多态 ,比如:函数重载。
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称动态多态。
两者其实都是编译时就确定好的,只是静态多态是在编译时就写死了call
地址,而动态的多态是在运行是在虚表中找到地址,但是虚表是编译时就确定好的。
五.单继承和多继承的虚函数表
下面我们在来看一下,在单继承和多继承关系中,子类对象的虚表模型,因为父类对象的虚表模型我们已经知道,没有更多要研究的。
5.1单继承中的虚函数表
(1)子类中的虚函数
class A {
public:
virtual void test1() { cout << "A::test1" << endl; }
virtual void test2() { cout << "A::test2" << endl; }
private:
int a;
};
class B :public A {
public:
virtual void test1() { cout << "B::test1" << endl; }
virtual void test3() { cout << "B::test3" << endl; }
virtual void test4() { cout << "B::test4" << endl; }
private:
int b;
};
观察下图,我们发现子类B中的虚函数test3、test4并没有在监视窗口展示:
这里是编译器的监视窗口故意隐藏了这两个函数,也可以将其看作是一个小bug,下面我们先在内存窗口查看,b对象的虚表中确实有四个函数地址。
我们看到,在b对象中确实存在4个虚函数地址,也就是test3、test4也存在虚表中。
打印虚表:
接下来我们使用代码来打印出虚表中的函数。(先展示代码,之后分析代码)
void PrintVTable(VFPTR* VTable)
{
for (int i = 0; VTable[i] != nullptr; i++)
{
printf("[%d]:%p->", i + 1, VTable[i]);
VFPTR f = VTable[i];
f();
}
cout << endl;
}
int main()
{
A a;
B b;
//VFPTR* VTable = (VFPTR*)(*(int*)&a);
VFPTR* VTableA = *(VFPTR**)&a;
//VFPTR* VTable = (VFPTR*)(*(int*)&b);
VFPTR* VTableB = *(VFPTR**)&b;
PrintVTable(VTableA);
PrintVTable(VTableB);
return 0;
}
我们看到,虚函数表的打印和我们在内存窗口看到的相同,b对象的虚函数表中由四个虚函数。
- 注意: 观察上图,我们可以发现,虚表中的虚函数是按照声明的顺序存储的,而通过多态调用不同对象的虚函数时,看的是虚函数的声明顺序,先声明的在虚函数表的前面,后声明的在后面,更具顺序来查找对应虚函数的位置。
接下来我们在来分析一下上面查看虚表的代码:
-
首先,虚表中存储的是虚函数的地址,所以我们要定义一个函数指针类型 ,来存储这些地址:
typedef void (* VFPTR)();
-
其次,有了存储虚表中地址的类型,接下来就是将虚表变为一个数组,存储起来在打印即可,而我们知道,虚表是以nullptr结尾的,那我们只要知道虚表的首地址,依次遍历,直到地址内的值为nullptr时停止 。就能得到这个虚表:
通过上面的内存图,和下面更直观的存储逻辑图,我们知道,对象的首地址存储的就是虚表的地址,也就是虚表的首地址,所以要取的就是对象头四个字节的值 ,有以下两种方法。
-
方法1:
B b; VFPTR* VTable = (VFPTR*)(*(int*)&b);//32字节下有效
首先,&b表示b对象的首地址,我们将其转换为(int*)类型(只有转化为int*类型后,此时的&b才表示指向首地址的四个字节地址,在解引用后才能得一个int型的值,也就是4个字节的值,对应的虚表的地址),在32位下,我们就得到了b对象的首地址。
其次,32位下,指针的大小为4,而int类型的大小也是4,通过解引用,得到一个int类型的值,该值大小为4个字节,也就是获得了首地址内4个字节的虚表地址。
最后,在将其转化为
VFPTR*
函数指针的指针类型,虚表地址内的值才是虚函数的地址,而虚函数的地址类型为VFPTR
,所以存放虚函数地址的指针的类型是VFPTR*
,在由一个对应类型的变量VTable接收,该变量即表示虚函数表数组。 -
方法2:
B b; VFPTR* VTable = (*(VFPTR**)&b);
这个方法想到了就简单,想不到就难,不像上一个按部就班。
首先,我们将存放虚表地址的虚表指针,也就是b对象的首地址转化为函数指针类型的二级指针,此时的首地址即为二级指针(严格来看是三级指针)
其次,我们想要得到该二级指针内的值,只要解引用,通过一次解引用得到虚表的地址,该地址此时的类型为
VFPTR*
,直接接收即可。 -
方法1只能在32位下使用,若在64位下使用需将int改为double或long long,方法2在32位和64位都可以使用。
-
-
注意: 在获取虚表的地址时,千万不要将代码写成下面这个样子:
B b; VFPTR* VTable = (VFPTR*)&b;
因为&b是对象首元素的地址,而该操作只是将对象首元素的地址也就是虚表指针的类型转化为
VFPTR*
,获得的还是虚表指针,而非虚表指针内的值虚表的地址。而且经过我们上面的分析,虚表指针的类型为
VFPTR**
,这么写从这方面看也是错的。
虚表的两个问题:
虚表是什么时候创建的?
通过上面的知识我们知道,虚表内存的是类对应的虚函数的地址,所以是在编译 阶段,因为只有在编译阶段才会有函数的地址生成。
虚表指针在什么时候初始化?
在执行构造函数的初始化列表时初始化。
如下图,我们执行代码,观察在对象调用构造函数时,虚表指针的变化
如上图,虚函数表指针在构造函数的初始化列表中完成初始化。
(2)子类多对象的虚函数
int main()
{
B b1;
B b2;
printf("b1:%p\n", *(VFPTR**)&b1);
printf("b2:%p\n", *(VFPTR**)&b2);
cout << endl;
PrintVTable(*(VFPTR**)&b1);
PrintVTable(*(VFPTR**)&b2);
return 0;
}
- 如上图所示,当子类对象创建出多个对象时,使用的时相同的虚表。
5.2多继承中的虚函数表
class B {
public:
virtual void test1()
{
cout << "B::test1()" << endl;
}
virtual void test2()
{
cout << "B::test2()" << endl;
}
};
class C {
public:
virtual void test1()
{
cout << "C::test1()" << endl;
}
virtual void test2()
{
cout << "C::test2()" << endl;
}
};
class D : public B, public C {
public:
virtual void test1()
{
cout << "D::test1()" << endl;
}
virtual void test3()
{
cout << "D::test3()" << endl;
}
};
int main()
{
D d;
return 0;
}
我们观察上图可以看到,d对象中,因为继承了两个类,所以有两个虚表 ,分别是B和C,大家如果足够细心,应该会发现,这两个虚表有以下两个问题:
-
d对象的test3函数在那个虚表中?
我们编写如下代码打印虚表查看,test3在那个虚表中:
typedef void(*VFPTR)(); void PrintVTable(VFPTR* VTable) { for (int i = 0; VTable[i] != nullptr; i++) { printf("[%d]:%p->", i + 1, VTable[i]); VFPTR f = VTable[i]; f(); } cout << endl; } int main() { D d; PrintVTable((VFPTR*)*(int*)&d); PrintVTable((VFPTR*)*((int*)&d + sizeof(B)/4)); return 0; }
因为是先继承的类先定义,所以第一个打印出的虚表为B的虚表,,第二个打印的是C的虚表所以我们可以得出结论,子类中未实现重载的虚函数,会存在先继承的类的虚表中,也就是第一张虚表。
注意:
在打印第二个虚表时需注意,第一个虚表是在对象所在空间的首部,而第二个虚表不一定是紧挨着第一个虚表的下一个位置,对象内的空间存储结构如下:
第一个虚表地址和第二个虚表地址是否相邻取决于第一个类是否还有其它的成员会被继承。
所以我们在获取第二个虚表的地址时,有以下两种方案:
-
方案1:
//1 VFPTR* VTable = (VFPTR*)*((int*)&d + sizeof(B)/4); //2 VFPTR* VTable = (VFPTR*)*(int*)((char*)&d + sizeof(B));
首先,使用sizeof获得B类的大小,也就是在d对象空间内,B类所占的空间大小。
其次,(32位下)当将对象的地址强转为int*时,每加1则表示移动一个int的大小(4字节),所以将获得的B类空间的总大小除以4,知道一共要移动几次4字节。
当将对象的地址强转为char*时,每加1则表示移动一个char类型的大小(1字节),直接移动B类空间的总大小即可,这里注意,移到第二个虚表的首地址后,需将地址的空间类型改为int*,否则解引用后无法获得4个字节的地址了。
-
方案2:
C* c = &d; PrintVTable((VFPTR*)*(int*)c);
利用切片,要获得的是C类对应的虚表,只需用C类的指针变量接收对象d的地址,此时指针c指向的首地址就会是对象d的C类对应的虚表,然后正常传值。
-
-
为什么D类重写了虚函数test1,而其父类的两个test1的地址却不同?
因为地址后的字符串是根据所调用的函数打印的,而两张虚表打印的相同都是D::test1(),并且上述代码完全符合构成多态的两条规则,它们的地址应该相同才对,为什么地址会不同呢?
为了方便测试,编写如下代码:
int main() { D d; B* b = &d; C* c = &d; b->test1(); c->test1(); return 0; }
如下汇编运行图:
如上图c指针要比b指针多执行了两步,才最终指向了相同的地址,调用了同一个函数。
观察上图,第一步是相同的,只是在c指针的中间两步产生了差异,为什么c指针不能直接省略中间的两步呢?我们看到c指针的中间两部中,第一步是一个
jmp
跳转执行sub
指令,之后在jmp
来到最终的位置,为什么不能在执行第一个jmp
指令的时候直接跳转到合适的位置?原因就在于
sub
指令了,它所在的行的后面ecx,4
表示将当前位置向后移动4字节。现在我们再来看一下,d对象的空间内容,如下图:
如上图,之所以b对象可以直接找到虚函数,是因为d对象的this指针指向的是d对象首元素的地址,而第一个继承的类的存储位置又是从首地址开始,所以在调用虚函数需传递this指针时,是直接传递。
而C类的成员和虚表是存在B类的下面,它的地址并不是this指针指向的首地址,而类中成员函数都有隐藏的this指针,并且该指针必须是指向调用类对象的指针,所以需要经过偏移后才能使用。
通过观察汇编代码我们知道,c指针向后移动了4字节,而B类存储的只有一个虚表地址,32位下是4个字节,经过偏移向后移动四位,来满足this指针的传递。
注意: 指针是否发生移动至于是否是第一个继承的类相关,移动的距离和具体首地址的距离,也就是对应对象中在所需移动的类前继承的各个类的大小有关。
(这里的空间地址在上下文中会不一样,是我在不同时间运行后的结构,不会影响阅读学习)
5.3虚表与虚基表
在C++的继承中我们了解到,为了解决菱形继承,产生了菱形虚拟继承,而菱形虚拟继承的产生导致了虚基表的产生,虚基表只有8个字节大小,前后四个字节各代表一个偏移量,当代码不存在多态时,第一个偏移量为0,第二个偏移量则表示公共成员距离当前位置的偏移量,我们编写如下程序,通过内存窗口查看当存在多态时,菱形虚拟继承中虚基表的变化。
class A {
public:
virtual void test(){}
};
class B : virtual public A{
public:
virtual void test1()
{
cout << "B::test1()" << endl;
}
virtual void test2()
{
cout << "B::test2()" << endl;
}
};
class C : virtual public A{
public:
virtual void test1()
{
cout << "C::test1()" << endl;
}
virtual void test2()
{
cout << "C::test2()" << endl;
}
};
class D : public B, public C {
public:
virtual void test1()
{
cout << "D::test1()" << endl;
}
virtual void test3()
{
cout << "D::test3()" << endl;
}
};
int main()
{
D d;
return 0;
}
如上图,对象d的空间中,B类所占的8个字节的空间中,前4个字节表示虚表的地址,后四个字节表示虚基表,C类也是相同的,而公共父类A的所占空间则在下方,只存放一个虚表的地址。
那我们就能得出如下结论:
C++虚基表指针指向的地址中,前四个字节是指向所属空间内对应虚表所在的虚表指针的偏移量(向后移动四个字节找到虚表指针)。后四个字节是虚基类的偏移量(B类向前移动12个字节找到A类空间),用于计算虚基类在派生类对象中的地址。
5.4菱形继承中的多态
如下面代码:
class A {
public:
virtual void test(){}
};
class B : virtual public A{
public:
virtual void test()
{
cout << "B::test()" << endl;
}
};
class C : virtual public A{
public:
virtual void test()
{
cout << "C::test()" << endl;
}
};
class D : public B, public C {
public:
};
B和C类同时重写了父类A类的虚函数,而在D类继承B和C类时,编译器会爆出错误,因为无法确定到底使用那个重写的虚函数,所以此时语法规定,遇到这种多个父类重写相同虚函数的情况,子类必须重写,并且最终使用的是子类重写的虚函数。
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。如果有感兴趣的,可以去看下面的两篇链接文章。
- C++ 虚函数表解析
- C++ 对象的内存布局