前言:
在多态---上中我们了解了什么是多态,以及多态的使用条件等。本章将进行更深入的学习,我们详细理解多态的原理。
目录
(一)虚函数表
(1)虚函数表的引入
(2)虚表
1、基类的虚表
2、派生类的虚表
3、小结
(二)多态的原理
(1)到底什么是多态?
(2)多态虚函数的调用(进一步详解)
(三)动态绑定与静态绑定
(四)探索虚表
(1)虚函数重写的过程
(2)虚表的打印
(3)虚表存放在哪个区域
(五) 多继承 - 虚表打印
(五)菱形继承、菱形虚拟继承
(一)虚函数表
(1)虚函数表的引入
首先,我们先来做一道笔试题:
sizeof(Base)是多少?
#include<iostream>
using namespace std;
class Base
{
public:
void Func1()
{
cout << "Func1()" << endl;
}
virtual void Func2()
{
cout << "Func3()" << endl;
}
virtual void Func3()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
char _ch;
};
//有了虚函数之后对象中就有了一个表 -- 虚表(虚函数表)
int main()
{
Base b;
cout << sizeof(Base) << endl;
return 0;
}
根据我们之前计算结构体大小的经验和内存对齐等知识,得出的答案应该是8.
但是运行结果却不太对劲:
这是为什么呢???
这里我们就引入了虚函数表的指针:
- 有了虚函数之后对象中就有了一个表 – 虚表(虚函数表)
- 虚函数都会放到虚表当中去,虚表中有虚函数的指针
我们调试一下,如图:
如介绍的一样,b对象中多了一个指针,这个指针就是虚函数表的指针。
只有虚函数才会进虚表,用来实现多态。
解释:
- v - 是virtual的单词首字母
- f - 是function的单词首字母
- ptr - 是pointer的单词缩写
(2)虚表
1、基类的虚表
我们给出一段代码:
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func3()
{
cout << "Base::Func3()" << endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
void Func3()
{
cout << "Derive::Func3()" << endl;
}
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
子类的虚表:
- 一个对象的vfptr也是在构造函数的时候才初始化的
- 虚表中只存虚函数的地址
- 在对象里面间接存的
- 普通函数和虚函数都是存在一个地方的,编译好了之后都是放在代码段。
- 虚表本质上是函数指针数组,存的是虚函数的指针
2、派生类的虚表
还是以上面那个代码为例,调试得到派生类对象d的组成部分如下:
综上:
- 很明显和上述结果一样,子类对象中还是存在一个虚表。
- 父类对象的虚表里面存的是父类的虚函数地址
- 子类对象的虚表里面存的是子类的虚函数地址
3、小结
我们再把上面调试的部分整合到一起再来观察:
我们发现在我们重写了func1之后,两个虚表中的func1的地址不一样,但是func2的地址却是一样的。
这是因为我们再子类中对Func1进行了重写(覆盖),重写完实际上是一个区别于父类的Func1的新函数,所以地址不一样;而Func2我们并没有进行重写,他继承了下来,所以地址不变。
我们还发现对于构成多态的每一个类都有自己的虚表(他们的地址并不一样)
也就是说对于基类虚表的第一个位置存的是基类的虚函数地址,对于子类虚表的第一个位置存的是子类的虚函数地址。
这样我们业科技理解虚函数重写又名覆盖的原因了:
- 虚函数重写 – 语法层的概念 – 派生类对继承基类虚函数实现进行了重写
- 虚函数覆盖 – 原理层的概念 – 子类的虚表,拷贝父类虚表进行了修改,覆盖重写那个虚函数(可以这样理解)
(二)多态的原理
(1)到底什么是多态?
- 多态是父类指针指向父类对象就调用父类的虚函数,指向子类的对象就去调用子类的虚函数。
- 所以到底要去调用哪个函数不是按照指针的类型定的,而是去到指向的对象中去查表。
- 指向谁就在谁的虚表中找虚函数对应的地址 – 这是多态
ps:
同一个类型都是指向一张表的,同一个类型的不同对象它们的虚表都是一样的。(重点)
(2)多态虚函数的调用(进一步详解)
父类指针调用多态时:
我们来看一下汇编代码:
所以,就引入了以下的结论:
- 多态调用: 运行时决议 – 运行时确定调用函数的地址(去对象的虚表中找函数的地址)
- 普通调用: 编译时决议 – 编译时确定调用函数的地址(普通函数地址放在符号表,方便链接)
多态能够实现的依赖基础是:虚表完成了覆盖:
- 父类对象的虚表里面存的是父类的虚函数地址
- 子类对象的虚表里面存的是子类的虚函数地址
注意:
- 这些地址不是直接存在对象里的,是间接存的,对象里存的是一个指针,这个指针指向的表是虚表,虚表中存的是虚函数的地址。
- 不要和继承中菱形虚拟继承中的虚基表弄混了,虚基表中存的是偏移量。
父类引用调用多态时:
- 因为引用和指针一样都能发生切片,指针和引用底层是一样的。
(三)动态绑定与静态绑定
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载。
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
- C++中:动态是运行时,静态是编译时
- 静态库: 链接阶段去链接的
- 动态库: 程序运行起来才会去加载动态库
动静态多态:
- 编译时 – 静态的多态: 函数重载·
- 运行时 – 动态的多态: 本节内容讲的这个
(四)探索虚表
(1)虚函数重写的过程
我们来看下面的代码:
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func3()
{
cout << "Base::Func3()" << endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
void Func3()
{
cout << "Derive::Func3()" << endl;
}
virtual void Func4()
{
cout << "Derive::Func4()" << endl;
}
private:
int _d = 2;
};
int main()
{
Derive d;
return 0;
}
调试得到:
- 子类的虚表是继承了父类的虚表
- 子类的虚表可以认为是将父类的虚表拷贝过来,然后将自己重写的虚函数Func1进行覆盖
- Func2是从父类虚表留下来的,Func1是子类在虚表中重写的
但是子类中明明还有一个虚函数Func4,我们为什么看不到?
其实
- 和继承那一章节一样,vs的监视窗口在复杂的情况下被处理过,看到的就不准了
- 此时就需要我们看内存窗口了
- VS窗口看到的虚函数表不一定是真实的,可能被处理过
- 虚函数的地址不在对象里面,而是在虚表指针指向的虚表里面
所以下面我们去内存中看:
我们进入虚函数地址查看得:
我们对应的看到了Func1和Func2的函数地址存放在虚函数表里;
那么黄色框中是否是Func4的地址呢?
我们为了验证,下面我们讲解如何打印虚表。
(2)虚表的打印
下面我们来验证一下:(目的是确认Func4的指针在不在虚表)
- 取内存值,打印并调用,确认是否是func4
补充:
- Vs平台下,虚表最后末尾统一放了一个空指针
- g++平台下不会,g++就得写死
- 知道几个虚函数,就要尝试打印几个虚函数地址
- 如何打印虚表:
- 我们前面也提到了,vfptr是一个函数指针数组首元素(函数指针)的地址(指针),而虚表则是一个函数指针数组
- 因为函数指针的指针(二级指针)不好定义,我们先typedef一下方便后续使用:
- 正常的typedef:typedef void(*)() V_FUNC; – 不支持,定义不出来
- 函数指针有要求,定义变量或者进行typedef都得放在中之间
- 正确定义:
- typedef void( * V_FUNC)( );
打印虚表见如下代码:
//正确定义:
typedef void(*V_FUNC)();
//void PrintVFTable(V_FUNC a[]) -- 数组在传参的时候都会退化成了指针
//void PrintVFTable(void(**a)())-- 不用typedef的写法
void PrintVFTable(V_FUNC* a)
{
printf("vfptr:%p\n", a);
//**切记这里要记得清理解决方案** -- 不然会有非法访问
//g++的话在这里就要写死,因为它的虚表中不存在空指针
for (size_t i = 0; a[i] != nullptr; i++)
{
//printf("[%d]:%p\n", i, a[i]);
printf("[%d]:%p->", i, a[i]);
//用函数的地址直接去调用函数 -- 通过函数打印出结果便于观察
V_FUNC f = a[i];
f();
}
cout << endl;
}
- 我们只需要取到虚表首元素的地址就可以打印虚表了
- 虚表指针一般是存在对象头上的,也就是前四个字节
我们该如何取对象头上【四个字节】呢?
- 取子类对象头四个字节是不可以通过强转的:
- 不相近的类型强转也转不了 – 没有一定关联性的类型不能直接转
- int* p = (int)d; – 这样是不行的
解决办法(重点理解):
- 可以将指针强转,先取对象的地址,再强转成int*
- 指针之间是可以互相转换的 – 任何类型之间的指针都可以互相强转
- 解引用就拿到了子类对象的前四个字节的地址
- 再将该地址的类型强转成 函数地址的指针(二级指针) 类型 – 这样才能传的过去
int main()
{
Base b;
Derive d;
//函数指针数组的地址指针
PrintVFTable((V_FUNC*)(*((int*)&d)));//取到对象头4byte的虚表指针
return 0;
}
我们通过运行窗口:
- 见图,结果和我们预想的一样,监视窗口将Func4给隐藏掉了
- 同时我们还可以直接通过函数指针调用虚函数。
(3)虚表存放在哪个区域
虚表存在哪个区域?
- 虚表应该是一个类型共用一个虚表,所有这个类型对象都存这个虚表指针
- Base b1;Base b2;Base b3;Base b4;这几个虚表应该是一样的
- 所以虚表应该存在一个长期存储的区域
深入理解:(重点)
按理来说在编译的时候就建好了虚表,对象在构造的时候才初始化虚表,其实不是初始化虚表,而是把这个类型的虚表找到,虚表的地址放在对象的头四个字节上。而是在对象初始化列表的时候挨个给vfptr。
首先我们排除虚表是存在栈上的:
- 因为栈是用来建立栈帧的
- 栈帧运行结束就销毁了
- 那么虚表也是时而创建,时而销毁吗
- 显然不可能
其次我们再排除虚表是存在堆上的:
- 因为堆区是空间是动态申请的
- 那么是在什么时候申请,什么时候释放呢
- 第一个对象申请吗,最后一个对象释放吗?
- 很显然会很麻烦,可能性也不大
剩下的我们只能猜两个区域: 静态区/数据段 常量区/代码段。
这里猜测放在 常量区/代码段 更合理,因为 常量区/代码段 放的是全局数据和静态数据,因为函数指针数组放在静态区不正常,放在 常量区/代码段 相对来说就很合理。
我们大概的用栈/堆/静态区的一些数据的地址来和虚表地址进行比较:
int c = 2;
int main()
{
Base b1;
Base b2;
Base b3;
Base b4;
//打印虚表
PrintVFTable((V_FUNC*)(*((int*)&b1)));
PrintVFTable((V_FUNC*)(*((int*)&b2)));
PrintVFTable((V_FUNC*)(*((int*)&b3)));
PrintVFTable((V_FUNC*)(*((int*)&b4)));
//方向验证 -- 对比验证
int a = 0;
static int b = 1;
const char* str = "hello world";
int* p = new int[10];
printf("栈:%p\n", &a);
printf("静态区/数据段:%p\n", &b);
printf("静态区/数据段:%p\n", &c);
printf("常量区/代码段:%p\n", str);
printf("堆:%p\n", p);
cout << endl;
printf("虚表:%p\n", (*((int*)&b4)));
cout << endl;
//成员函数取地址都得这么玩
//函数编译完了是一段指令,第一句指令的地址就可以认为是函数的地址
printf("函数地址:%p\n", &Derive::Func3);
printf("函数地址:%p\n", &Derive::Func2);
printf("函数地址:%p\n", &Derive::Func1);
return 0;
}
我们大概的比较一下,发现圈中的几个地址非常相近。
所以:
结合上图几个地址来看,充分的说明了虚表是存在 常量区/代码段 中的:
- 由结果可得,虚函数和普通函数的地址都差不多
- 说明虚函数和普通函数的地址都是放在一起的
- 只不过虚函数要把地址放到虚表里去
(五) 多继承 - 虚表打印
多继承代码:
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%x,->", 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);
return 0;
}
我们调试监视窗口:
在之前多继承和菱形继承的基础上,我们再来理解这里:
- Derive类中继承了两个类Base1和Base2
- 那么Derive对象中应该就有两张虚表
问题来了,Derive中的fucn3放在哪一张虚表中呢?
- 显然通过监视窗口观察还是遇到了和上述同样的问题,虚表中内容看不全
我们来打印一下子类中的两个虚表:
- 我们该如何打印虚表呢?
- 在之前的多继承中,我们是能知道子类对象的内存结构的
- Derive对象中Base1对象在前,Base2对象在后,然后是d1成员变量
- 所以我们和上述办法一样,取到Derive对象头四个字节,就可以打印Base1的虚表
Base2的虚表我们该如何打印呢?
根据Derive对象中内存布局,我们可以知道,Base1中的vfptr后面是Base1的成员变量b1,紧接着就是Base2对象中的vfptr,然后紧接着的是Base2的成员变量b2。
所以我们只需要跳过Base1对象中vfptr指针之后的成员,就可以找到Base2对象中的vfptr了
- 去掉红框框出来的,我们取到的是红色箭头指向的Base1中的vfptr,是我们之前取到头四个字节的办法
- 而下面我们是先取到d的地址,加一整个Base1对象大小个字节就能指向Base2中的vfptr了
- 因为&d是Base1的指针,Base1的指针加减是跳过一整个对象大小的字节
- 我们需要先将&d强转成char* 类型的指针,这样指针加减就是跳过一个字节了
打印出结果如下:
所以没有重写的虚函数放在第一张虚表,第二张虚表不放。
补充:
- 首先这三个指针的值是不一样的
- 其次这三个指针的意义也是不一样的
- 这里发生了切片
图示分析:
- ptr1和ptr2之间差了8个字节,正好是一个Base1的大小
- ptr1和ptr3指向同一个位置并且大小一样,但是意义不一样
- ptr1向后“看”的是Base1,ptr3向后看的是Derive
我们还发现一个问题:
- Base1的虚表和Base2的虚表中第一个位置都被子类对象重写了才对
- 那也就是说,两个虚表的第一个位置都应该是同一个函数的指针才对
- 但是根据打印虚表的结果来看,并不相同
我么再直接把func1地址打印出来比较:
竟然都不一样!
原因:
- 这是Windows的自己的机制,多了几层封装
- 因为它们都不是真函数的地址
我们通过底层的实现来分析:
通过汇编来实现这个过程:
通过汇编逐层调用的结果来看:
- 虽然地址不一样但是都调用到了同一个函数
- 说明它们虽然表面不一样,但是都最终调转到了同一个地址去调用同一个函数
- 最终都调用到了 “ 006528A0 ” 这个地址!!
对于:
为什么在调用Base2::func1()的时候会比调用Base1::func1()的时候多跳了几层?(重点)
- Derive对象Base2虚表中func1时, 是Base2指针ptr2去调用
- 但是这时ptr2发生切片指针偏移(指向Derive对象中Base2那一部分),就需要修正
- 中途需要修正存储this指针的ecx的值(ecx寄存器存的是this指针)
- 因为现在调用的是Derive对象的func1那么传给func1的this指针应该是指向Derive对象 “头部” 的指针!!
修正图: