C++多态的底层原理

news2024/12/28 20:26:35

目录

1.虚函数表

(1)虚函数表指针

(2)虚函数表

2.虚函数表的继承--重写(覆盖)的原理

3.观察虚表的方法

(1)内存观察

(2)打印虚表

        虚表的地址

        函数

        传参

(3)虚表的位置

4.多态的底层过程

5.几个原理性问题

(1)虚表中函数是公用的吗?(2)为什么必须传入指针或引用而不能使用对象?

(3)为什么私有虚函数也能实现多态?

(4)VS中的虚表中存的是指令地址?

6.多继承中的虚表

7.总结

1.虚函数表

(1)虚函数表指针

首先我们在基类Base中定义一个虚函数,然后 观察Base类型对象b的大小:

class Base
{
public:
    virtual void Func1()
    {
        cout << "Func1" << endl;
    }
    virtual void Func2()
    {
        cout << "Func2" << endl;
    }
    void f()
    {
        cout << "f()" << endl;
    }
protected:
    int b = 1;
    char ch = 1;
};

int main()
{
    Base b;
    cout << sizeof(b) << endl;
    return 0;
}

我们发现,如果按照对齐原则来计算b的大小时,得到的结果是8,而我们打印的结果是:

这说明带有虚函数的类所定义的对象中,除了成员变量之外还有其他的东西被加入进去了(成员函数默认不在对象内,在代码段)。

我们可以通过调试来观察b中的内容:

我们发现对象中多了一个——vfptr,即为虚函数表指针。简称为虚表指针。

(2)虚函数表

仍然看上图,我们发现虚函数表指针下方的两个地址,这两个地址分别对应的就是Base中两个虚函数的地址,构成了一个虚函数表。所以虚函数表本质是一个指针数组,数组中的每一个元素都是一个虚函数的地址。

VS2019封装更为严密,在底层的汇编代码中,虚函数表中的地址并不一定是虚函数的地址,可能存放的是跳转到虚函数的地址的指令的地址。这个在后面会加以演示。

因此当我们调用普通函数和虚表函数时,它们的本质是不同的:

 Base* bb = nullptr;
 bb->f();
 bb->Func1();

其中bb调用f()的过程没有发生解引用的操作,非虚函数在公共代码段中,直接对其进行调用即可。而bb调用Func1()的过程中,需要通过虚表指针类来找到Func1(),而拿到虚表指针,找到虚函数的地址,这个时候需要对这个地址进行解引用操作,而bb是空,因此程序会崩溃。

//类指针访问成员变量的时候,会解引用:因为成员变量在类中
//类指针访问成员函数的时候,不解引用:因为成员函数不在类中,具体位置在编译的时候确定(具体见C++类和对象

我们知道对象中只存储成员变量,成员函数存储在公共代码段中,其实虚函数也是一样存储在公共代码段,只不过寻找虚函数需要通过虚表来确定位置。普通函数在编译时直接就可以确定位置。

2.虚函数表的继承--重写(覆盖)的原理

还拿上一节中买票的例子举例,其中父类中有两个虚函数,子类重写了其中的一个,子类中还有字节的函数。

class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "全价" << endl;
	}
	virtual void Func1()
	{
		cout << "Func1" << endl;
	}
protected:
	int _a;
};
class Student :public Person
{
public:
	virtual void BuyTicket()
	{
		cout << "半价" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2" << endl;
	}
protected:
	int _b;
};

int main()
{
	Person a;
	Student b;
	return 0;
}

我们可以通过调试来观察以下他们的虚表和虚表指针。

显然父类对象_vfptr[0]中存放的是BuyTicket的地址,_vfptr[1]中存放的是Func1()的地址。子类对象中_vfptr[0]中存放的是继承并重写的BuyTicket的地址,_vfptr[1]存放的是继承下来但没有进行重写的Func1()的地址。通过对比我们发现:对于没有进行重写的Func1()的地址,通过对比我们发现:对于没有进行重写的Func1()来说,子类中虚表中的地址和父类中的是一样的,可以说是直接拷贝下来的。而对于进行了重写的BuyTicket来说,子类中虚表的地址与父类中明显不一样,其实是在拷贝了父类的地址后又进行了覆盖的。因此重写在底层的角度来说又叫做覆盖。

同时我们又发现了一个问题,那就是子类对象的虚表中为什么没有些它自己的虚函数地址Func2()呢?其实是写了的,只不过通过VS的监视窗口并不能看到,我们可以通过内存来进行观察:

3.观察虚表的方法

(1)内存观察

我们可以通过观察内存来观察虚函数表的情况,这里观察的是父类对象,会发现在虚函数指针的地址存放的是父类中两个虚函数的地址。

我们也可以观察一下子类对象:

与父类对象中存储的相同,唯一有区别的地方就是紫色的部分,存放的其实是子类虚函数Func2()的地址。这说明Func2()也在虚表中只不过在监视窗口没有看不到而已。

(2)打印虚表

        虚表的地址

通过观察内存,对于单继承来说,我们只需要打印对象的首元素的地址即可找到虚表,并进行打印。

我们发现对象的前四个字节存储的就是虚表的地址。可以通过这一点来打印虚表。

我们关闭一下调试来重新写一下代码(关闭调试后在进行运行地址会发生变化但是规律是不变的)

typedef void(*vfptr)();
void Printvfptr(vfptr* table)
{
	for (int i = 0; table[i] != nullptr; i++)
	{
		printf("%d:%p\n", i, table[i]);
	}
	cout << endl;
}
int main()
{
	Person a;
	Student b;
	Printvfptr((vfptr*)*(void**)&a);
	Printvfptr((vfptr*)*(void**)&b);
	return 0;
}

下面来解释以下如何打印的虚表,分为两部分,一部分是函数,一部分是传参:

        函数

首先我们明确,虚函数指针是一个函数指针,因此为了简便我们可以将函数指针重命名为vfptr。通过接收虚表指针,并以此打印指针数组中的内容(虚函数的地址)。

        传参

拿父类对象a举例,我们要找到a的前四个字节的内容,即虚表指针,然后再传入函数中。

首先使用(void**)对a的地址进行强制类型转换,这其中发生了切割。使用(void**)的原因在于,由于不知道是使用32位还是64位系统,但我们可以通过指针的大小来判断。首先将&a转换成一个指针,再将其转换成一个指针类型,再进行解引用就得到了a的前4或者8个字节。但同时我们需要传递的是一个vfptr类型的函数指针,所以还需要进行(*vfptr)类型的强制转换。

有了前面的解释,我们就可以理解打印虚表的原理了,我们把这一段代码运行一下:

发现分别打印出了a和b的虚函数表。

如果打印的虚函数数量不对,这是VS编译器的bug,我们可以重新生成解决方案,再重新运行代码。

(3)虚表的位置

我们还可以观察一下虚表的位置,在哪个区域:

使用其他区域的变量进行对比:

Person per;
Student std;
int* p = (int*)malloc(4);
printf("堆:%p\n", p);
int a = 0;
printf("栈:%p\n", &a);
static int b = 1;
printf("数据段:%p\n", &b);
const char* c = "aaa";
printf("常量区:%p\n", &c);
printf("虚表:%p\n", *(void**)&std);
return 0;

打印的结果是:

我们发现虚表的位置在数据段和常量区之间。大致属于数据段。

4.多态的底层过程

class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "全价" << endl;
	}
	virtual void Func1()
	{
		cout << "Func1" << endl;
	}
protected:
	int _a;
};
class Student :public Person
{
public:
	virtual void BuyTicket()
	{
		cout << "半价" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2" << endl;
	}
protected:
	int _b;
};
void F(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person per;
	Student std;
	F(per);
	F(std);
	return 0;
}

我们还使用这一段代码来举例,首先复习一下多态:使用父类的指针或引用去接收子类或者父类的对象,使用该指针或者引用调用虚函数,调用的是父类或子类中不同的虚函数。

下面来分析原理:

父类对象原理:

首先用父类引用p来接收父类对象per,此时p中的虚表和per中的虚表一摸一样,只需要访问_vfptr中的BuyTicket地址。此时的p不是新创建了一个父类对象,而是子类对象std切片后构成的,其中男就将重写之后的BuyTicket()的地址也随之切入了p。可以把怕堪称原std的包含_vfptr的一部分。

总结:基类的指针或者引用,指向谁就去谁的虚函数表中找到对应位置的虚函数进行调用。

5.几个原理性问题

了解了多态原理之后,就可以分析出在上一节中出现的一些现象规律。

(1)虚表中函数是公用的吗?

虚表中的函数和类中的普通函数一样是放在代码段的(虚表在数据段),只是虚函数还需要将地址存一份到虚表,方便实现多态。这也说明同一类型的不同对象的虚表指针是相同的,我们还可以通过调试观察:

	Person per;
	Person pper;


(2)为什么必须传入指针或引用而不能使用对象?

当我们使用父类对象去接收时,父类对象本身就具有一个虚表了,当子类对象传给父类对象的时候,其他内容会发生拷贝,但是虚表不会,C++这样处理的原因在于,如果虚表也会发生拷贝的化,那么该父类对象的虚表就存了子类对象的虚表,这是不合理的。

我们同样可以通过调试来进行观察:

void F(Person p)
{
	p.BuyTicket();
}
int main()
{
	Person per;
	Student std;
	F(std);
}

这是std中的虚表内容。

这是p中虚表内容,而且在调试过程中,程序是进入父类中进行调用函数的。

(3)为什么私有虚函数也能实现多态?

这是因为编译器调用了父类的public接口,由于是父类的引用或者指针,因此编译器 发现是public之后就不再进行检查了,只要在虚表中可以找到就能调用函数。

(4)VS中的虚表中存的是指令地址?

在VS2019中,为了封装严密,其实虚表中存入的是跳转指令,我们可以通过反汇编进行观察:

我们将虚表中的地址输入反汇编,看到的是这样的一条语句:

这是一条跳转指令,会跳转到BuyTicket()的实际地址处。

6.多继承中的虚表

谈到多继承就要谈到零星虚拟继承,这是一个庞大而复杂的问题,这里只介绍多继承中虚表的内容:

class Base1
{
public:
	virtual void Func1()
	{
		cout << "Func1" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2" << endl;
	}
protected:
	int _a;
};
class Base2
{
public:
	virtual void Func3()
	{
		cout << "Func3" << endl;
	}
	virtual void Func4()
	{
		cout << "Func4" << endl;
	}
};
class Derive :public Base1, Base2
{
public:
	virtual void Func5()
	{
		cout << "Func5" << endl;
	}
};
int main()
{
	Derive a;
}

我们可以使用调试来观察a中的虚表内容:

通过调试我们可以看到a中有两个虚表指针分别存放的是Base1中的虚函数的地址和Base2中虚函数的地址,那么a中特有的类Func5()存放哪个虚表呢?这需要通过内存进行观察:

我们发现它被存放在了第一个虚表指针指向的虚表中。

我们知道打印第一个虚表指针指向虚表的方法,那么第二个虚表指针的该怎么处理呢:

Printvfptr((vfptr*)*(void**)((char*)&a+sizeof(Base1));

注意需要先将&a转换成char*类型,这样对其加一,才代表加一个字节。

7.总结

实际中我们不建议设定出菱形继承或者菱形虚拟继承,在实际中很少使用。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1951317.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【无标题】Git(仓库,分支,分支冲突)

Git 一种分布式版本控制系统&#xff0c;用于跟踪和管理代码的变更 一&#xff0e;Git的主要功能&#xff1a; 二&#xff0e;准备git机器 修改静态ip&#xff0c;主机名 三&#xff0e;git仓库的建立&#xff1a; 1.安装git [rootgit ~]# yum -y install git 2.创建一个…

postman请求响应加解密

部分接口&#xff0c;需要请求加密后&#xff0c;在发动到后端。同时后端返回的响应内容&#xff0c;也是经过了加密。此时&#xff0c;我们先和开发获取到对应的【密钥】&#xff0c;然后在postman的预执行、后执行加入js脚本对明文请求进行加密&#xff0c;然后在发送请求&am…

Android adb shell ps进程查找以及kill

Android adb shell ps进程查找以及kill 列出当前Android手机上运行的所有进程信息如PID等&#xff1a; adb shell ps 但是这样会列出一大堆进程信息&#xff0c;不便于定向查阅&#xff0c;可以使用关键词查找&#xff1a; adb shell "ps | grep 关键词" 关键词查…

AI视频生成(即梦)

1.打开即梦网页版 https://jimeng.jianying.com/ai-tool/home 2.图片生成-导入参考图&#xff08;这里原本的红色或者灰度图都是可以的&#xff09;-精细度5&#xff08;最高图质量越高&#xff09; 注&#xff1a;根据需要&#xff0c;选择不同的生图模型&#xff0c;具有…

【后端开发实习】Python基于Quart框架实现SSE数据传输

Python基于Quart框架实现SSE数据传输 前言SSE简介理论分析代码实现 前言 在类似Chatgpt的应用中要实现数据的流式传输&#xff0c;模仿实现打字机效果&#xff0c;SSE是不二之选。传统的Flask框架不能满足异步处理的要求&#xff0c;没有异步处理就很难实现实时交互的需求&…

聊一次线程池使用不当导致的生产故障-图文解析

聊一次线程池使用不当导致的生产故障–图文解析 原文作者&#xff1a;货拉拉技术团队 原文链接&#xff1a;https://juejin.cn/post/7382121812434747418 1 抢救 交代了背景&#xff1a;交付的软件运行中出现了故障&#xff0c;报警机制被触发&#xff0c;通过飞书与报警电…

《500 Lines or Less》(5)异步爬虫

https://aosabook.org/en/500L/a-web-crawler-with-asyncio-coroutines.html ——A. Jesse Jiryu Davis and Guido van Rossum 介绍 网络程序消耗的不是计算资源&#xff0c;而是打开许多缓慢的连接&#xff0c;解决此问题的现代方法是异步IO。 本章介绍一个简单的网络爬虫&a…

静止轨道卫星大气校正(Atmospheric Correction)和BRDF校正

文章内容仅用于自己知识学习和分享&#xff0c;如有侵权&#xff0c;还请联系并删除 &#xff1a;&#xff09; 目的&#xff1a; TOA reflectance 转为 surface refletance。 主要包含两步&#xff1a; 1&#xff09;大气校正&#xff1b; 2&#xff09;BRDF校正 进度&#x…

C语言日常练习Day12(文件)

目录 一、从键盘输入一些字符&#xff0c;逐个把他们送到磁盘上去&#xff0c;直到用户输入#为止 二、输入连续几个正整数n和m&#xff0c;求其最大公约数和最小公倍数 三、将‘China’翻译成密码&#xff0c;密码规律是&#xff1a;用原来的字母后面第4个字符代替原来的字母…

C++初阶:string(字符串)

✨✨所属专栏&#xff1a;C✨✨ ✨✨作者主页&#xff1a;嶔某✨✨ 为什么要学习string类 C语言中&#xff0c;字符串是以\0结尾的一些字符的集合&#xff0c;为了操作方便&#xff0c;C标准库中提供了一些str系列 的库函数&#xff0c;但是这些库函数与字符串是分离开的&#…

springboot中使用knife4j访问接口文档的一系列问题

springboot中使用knife4j访问接口文档的一系列问题 1.个人介绍 &#x1f389;&#x1f389;&#x1f389;欢迎来到我的博客,我是一名自学了2年半前端的大一学生,熟悉的技术是JavaScript与Vue.目前正在往全栈方向前进, 如果我的博客给您带来了帮助欢迎您关注我,我将会持续不断的…

鸿蒙(API 12 Beta2版)【创建NDK工程】

创建NDK工程 下面通过DevEco Studio的NDK工程模板&#xff0c;来演示如何创建一个NDK工程。 说明 不同DevEco Studio版本的向导界面、模板默认参数等会有所不同&#xff0c;请根据实际工程需要&#xff0c;创建工程或修改工程参数。 通过如下两种方式&#xff0c;打开工程创…

【软考】设计模式之生成器模式

目录 1. 说明2. 应用场景3. 结构图4. 构成5. 适用性6. 优点7. 缺点8. java示例 1. 说明 1.生成器模式&#xff08;Builder Pattern&#xff09;&#xff0c;也称为建造者模式&#xff0c;是设计模式中的一种创建型模式。2.将一个复杂对象的构建与它的表示分离&#xff0c;使得…

C++初学(2)

2.1、其他简单C语句例子 下面这个程序要求运行时输入值 #include <iostream> int main() {using namespace std;int yuanshi;cout << "How many yuanshi do you have?" << endl;cin >> yuanshi;cout << "Here are two more.&q…

数据结构——堆(C语言版)

树 树的概念&#xff1a; 树&#xff08;Tree&#xff09;是一种抽象数据结构&#xff0c;它由节点&#xff08;node&#xff09;的集合组成&#xff0c;这些节点通过边相连&#xff0c;把 节点集合按照逻辑顺序抽象成图像&#xff0c;看起来就像一个倒挂着的树&#xff0c;也…

15 Python常用内置函数——类型转换与类型判断

① 内置函数 bin()、oct()、hex() 用来将整数转换为二进制、八进制和十六进制形式&#xff0c;这3个函数都要求参数必须为整数。 print((bin(168), oct(168), hex(168))) # 把数字转换为二进制串、八进制串、十六进制串内置函数 int() 用来将其他形式的数字转换为整数&#x…

【计算机网络】HTTP协议实验

一&#xff1a;实验目的 1&#xff1a;理解HTTP协议的基本工作原理。 2&#xff1a;使用Wireshark或其他抓包工具捕获并分析HTTP数据包&#xff0c;理解HTTP通信的具体过程。 3&#xff1a;通过分析抓包数据&#xff0c;识别常见的HTTP状态码及其含义。 二&#xff1a;实验仪…

华为OD机试 - 数的分解 (Java/c++/python 2024年C卷D卷)

华为OD机试&#xff08;C卷D卷&#xff09;2024真题目录(Java & c & python) 题目描述 给定一个正整数 n&#xff0c;如果能够分解为 m&#xff08;m > 1&#xff09;个连续正整数之和&#xff0c;请输出所有分解中&#xff0c;m最小的分解。 如果给定整数无法分…

Linux中的三类读写函数

文件IO和标准IO的区别 遵循标准&#xff1a; 文件IO遵循POSIX标准&#xff0c;主要在类UNIX环境下使用。标准IO遵循ANSI标准&#xff0c;具有更好的可移植性&#xff0c;可以在不同的操作系统上重新编译后运行。可移植性&#xff1a; 文件IO的可移植性相对较差&#xff0c;因为…

从丢失到找回:2024年U盘数据恢复软件全攻略

优盘作为我们日常短时间存储分享数据来说非常方便&#xff0c;毕竟小巧便携。但是也正因为他小巧数据很容易丢失&#xff0c;如果有备份还好&#xff0c;没有备份就麻烦了。但是只要掌握U盘数据恢复方法就可以缩小我们的损失。 1.福foxit昕数据恢复工具 一键直达>>http…