> 文章列表 > 【C++】多态---下(多态的原理)

【C++】多态---下(多态的原理)

【C++】多态---下(多态的原理)

前言:

     在多态---上中我们了解了什么是多态,以及多态的使用条件等。本章将进行更深入的学习,我们详细理解多态的原理。

目录

(一)虚函数

(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)多态虚函数的调用(进一步详解)

父类指针调用多态时:

我们来看一下汇编代码:

 

所以,就引入了以下的结论:

  •  多态调用: 运行时决议 – 运行时确定调用函数的地址(去对象的虚表中找函数的地址)
  •  普通调用: 编译时决议 – 编译时确定调用函数的地址(普通函数地址放在符号表,方便链接)

 

多态能够实现的依赖基础是:虚表完成了覆盖:

  • 父类对象的虚表里面存的是父类的虚函数地址
  • 子类对象的虚表里面存的是子类的虚函数地址

注意:

  • 这些地址不是直接存在对象里的,是间接存的,对象里存的是一个指针,这个指针指向的表是虚表,虚表中存的是虚函数的地址。
  • 不要和继承中菱形虚拟继承中的虚基表弄混了,虚基表中存的是偏移量。

 父类引用调用多态时:

  • 因为引用和指针一样都能发生切片,指针和引用底层是一样的。

(三)动态绑定与静态绑定

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载。
  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对象 “头部” 的指针!!

修正图:

(五)菱形继承、菱形虚拟继承

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的
模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看
了,一般我们也不需要研究清楚,因为实际中很少用。如果好奇心比较强的读者,可以去看下面
的两篇链接文章。
https://coolshell.cn/articles/12165.html
https://coolshell.cn/articles/12176.html