C++多态的底层原理_c++多态底层原理-程序员宅基地

技术标签: c++  C++编程  

零.前言

要了解C++多态的底层原理需要我们对C指针有着深入的了解,这个在打印虚表的时候就可以见功底,理解了多态的本质我们才能记忆的更牢,使用起来更加得心应手。

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);
	return 0;
}

我们发现,如果按照对齐数原则来计算b的大小时,得到的结果是8,而我们打印的结果是:
在这里插入图片描述
这说明带有虚函数的类所定义的对象中,除了成员变量之外还有其他的东西被加入进去了(成员函数默认不在对象内,在代码段)。
我们可以通过调试来观察b中的内容:
在这里插入图片描述

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

(2)虚函数表

仍然看上图,我们发现虚函数表指针下方有两个地址,这两个地址分别对应的就是Base中两个虚函数的地址,构成了一个虚函数表。所以虚函数表本质是一个指针数组,数组中每一个元素是一个虚函数的地址
VS2019封装更为严密,在底层的汇编代码中,虚函数表中的地址并不一定是虚函数的地址,可能存放的是跳转到虚函数的地址的指令的地址。这个在后面会加以演示。
因此当我们调用普通函数和虚函数时,它们的本质是不同的:

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

其中bb调用f()的过程没有发生解引用操作,非虚函数在公共代码段中,直接对其进行调用即可。而bb调用Func1()的过程中,需要通过虚表指针来找到Func1(),而拿到虚表指针需要对bb进行解引用操作,而bb是空,因此程序会崩溃。
我们知道对象中只存储成员变量,成员函数存储在公共代码段中,其实虚函数也是一样存储在公共代码段,只不过寻找虚函数需要通过虚表来确定位置。普通函数直接就可以确定位置。

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()来说,子类中虚表中的地址和父类中的是一样的,可以说是直接拷贝下来的。而对于进行了重写的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);

打印的结果是:
在这里插入图片描述
我们发现虚表的位置在数据段和常量区之间。大致属于数据段。

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,发生切片处理,会将子类中的虚表内容拷贝到父类引用p中,然后再调用其中的__vfptr中的BuyTicket地址。此时的p不是新创建了一个父类对象,而是子类对象std切片后构成的,其中就将重写之后的BuyTicket()的地址也随之切入了p。可以把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.总结

实际中我们不建议设定出菱形继承或者菱形虚拟继承,在实际中很少用,这里推荐大佬的两篇文章C++虚函数表解析C++对象的内存布局,对我们的提升有很大的帮助,看一些原理书似乎用时长成效难以体现,但是真正静下心来深入理解的人最后都会有所成就,当你经过七重的孤独,才能成为真正的强者!

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/qq_51492202/article/details/124393239

智能推荐

pip install pycocotools 安装报错_pip install mmpycocotools报错-程序员宅基地

文章浏览阅读1.9k次,点赞2次,收藏6次。在Anaconda中执行pip install pycocotools后报错如下提示Microsoft Visual C++ 14.0 or greater is required.可以按照提示去官网下载,下载完后我又进入另一个坑,在安装过程中总是提示安装包丢失或已损坏,所以采用离线下载的方式,来离线搜索安装包实现离线安装离线包我这里给大家一个百度网盘的连接,大家自行下载即可链接:https://pan.baidu.com/s/1n5eVgV3iaR3Zyhzf30qZCA提取码:3jt._pip install mmpycocotools报错

手把手教你利用爬虫爬网页(Python代码)_python 爬虫 登录网页-程序员宅基地

文章浏览阅读1.3k次,点赞6次,收藏4次。本文主要分为两个部分:一部分是网络爬虫的概述,帮助大家详细了解网络爬虫;另一部分是HTTP请求的Python实现,帮助大家了解Python中实现HTTP请求的各种方式,以便具备编写HTTP网络程序的能力。01网络爬虫概述接下来从网络爬虫的概念、用处与价值和结构等三个方面,让大家对网络爬虫有一个基本的了解。1. 网络爬虫及其应用随着网络的迅速发展,万维网成为大量信息的载体,如何有效地提取并利用这些信息成为一个巨大的挑战,网络爬虫应运而生。网络爬虫(又被称为网页蜘蛛、网络机..._python 爬虫 登录网页

基于STM32以及HAL库的MAX30102模块使用+OLED显示(资源下载免费,在博主我的资源下载处)_max30102+oled-程序员宅基地

文章浏览阅读3.4k次,点赞19次,收藏65次。必须要有I2C驱动,为其模块的寄存器写入相应的配置,才能够驱动红灯亮起(里面包括红光以及红外光)。那我们买回模块之后,如何确定模块的好坏,其实可以直接在购买物品的平台上。_max30102+oled

带缓存的Flutter网络请求——RxDio_flutter dio接口缓存-程序员宅基地

文章浏览阅读2.9k次。RxDio是在练习Dio、RxDart、Sql的时候,仿照Android网络请求OkGo做的,只实现了简单的功能,后续有需要再完善。在APP开发中,经常会遇到这样一种情况:在有网络正常的时候,展示网络数据,在网络断开或者网络很差的时候,展示上次正常访问的数据理想的解决方法是,设置几种缓存模式:1、只请求网络2、只访问缓存3、先访问缓存,再请求网络4、没有缓存再请求网络目前仅支持GE..._flutter dio接口缓存

基于广播星历的北斗定位解算原理(基于C语言和MATLAB实现)_卫星位置解算-程序员宅基地

文章浏览阅读2.3k次,点赞32次,收藏53次。本文先用C语言解算卫星位置,再用MATLAB绘出卫星三维坐标图。本篇博客所使用的资料和文件都是网络上公开发表且可以找到的资料文件。_卫星位置解算

Vue面试题-程序员宅基地

文章浏览阅读158次。ViewModel提供双向数据绑定把View和Model连接起来,他们之间的同步是自动的,不需要人为的干涉,所以我们只需要关注业务逻辑,不需要操作DOM,同时也不需要关注数据的状态问题,因为他是MVVM统一管理。当我们在组件中访问 Vuex 中的状态时,Vue.js 的响应式系统会建立依赖关系,并将组件与状态属性之间的关联记录下来。代码分割和异步加载:将页面按需拆分成多个模块,通过使用路由懒加载或动态导入组件的方式,使得页面初始化时只加载必要的代码,延迟加载其他非必要的模块,从而加快首屏渲染速度。

随便推点

Kafka(二)实战篇(集群搭建、客户端命令、日志查看、Kafka原生API、Spring Boot Kafka)_kafka查看所有客户端-程序员宅基地

文章浏览阅读2.0k次。1. Kafka 集群搭建在生产环境中为了防止单点问题,Kafka 都是以集群方式出现的。下面要搭建一个 Kafka集群,包含三个 Kafka 主机,即三个 Broker。1.1 Kafka 的下载http://kafka.apache.org/downloads1.2 安装并配置第一台主机(1) 上传并解压将下载好的 Kafka 压缩包上传至 CentOS 虚拟机,并解压。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hqgOu9TY-160023879115_kafka查看所有客户端

window下使用python import cx_Oracle时报错_windows import cx_oracle-程序员宅基地

文章浏览阅读9.1k次。python Oracle cx_Oracle_windows import cx_oracle

java-php-python-ssm学生校内兼职管理平台计算机毕业设计-程序员宅基地

文章浏览阅读100次。springboot基于Springboot的滑雪场学具租赁管理系统。springboot基于springboot的健身俱乐部综合管理系统。JSP基于Web的在线文献查阅系统的设计与实现sqlserver。springboot基于Bootstrap的家具商城系统设计。ssm基于SSM框架的菲特尼斯健身管理系统的设计与实现。jsp基于个性化推荐的扬州农业文化旅游管理平台。ssm基于javaweb的扶贫产品物资管理平台。ssm基于SpringMvC的流浪狗领养系统。ssm基于JavaEE的校园临时用工网站。

kindle安卓更新固件(已经装过安卓系统)_kindle enter updating mode-程序员宅基地

文章浏览阅读4.7w次,点赞2次,收藏14次。(http://182.254.232.41/download/update/update.170822/doc/3.%E5%9B%BA%E4%BB%B6%E6%9B%B4%E6%96%B0.html)1根据机型下载安卓固件,在电脑上解压缩固件,《kindle.xxxxxx.zip》入门版499《kpw2.xxxxxx.zip》Paperwhite二代《kpw3.xxxx_kindle enter updating mode

idea与vue+ElementUI搭建前后端分离的CRUD_elementplus vue idea-程序员宅基地

文章浏览阅读3.1k次,点赞2次,收藏15次。idea与vue+ElementUI搭建前后端分离的CRUD_elementplus vue idea

html+css+js图片加载失败设置默认图片_js图片加载失败事件-程序员宅基地

文章浏览阅读468次。【代码】html+css+js图片加载失败设置默认图片。_js图片加载失败事件

推荐文章

热门文章

相关标签