【C++学习手札】多态:掌握面向对象编程的动态绑定与继承机制(深入)

news2025/1/21 2:52:33

                                               🎬慕斯主页修仙—别有洞天

                                              ♈️今日夜电波:世界上的另一个我

                                                                1:02━━━━━━️💟──────── 3:58
                                                                    🔄   ◀️   ⏸   ▶️    ☰  

                                      💗关注👍点赞🙌收藏您的每一次鼓励都是对我莫大的支持😍


目录

多态的原理

首先理解虚函数表

正式理解多态的原理

一些拓展

多态对于引用、指针和对象

虚表的拓展

多继承中的虚函数表

先了解如何打印虚函数表

然后理解多继承中的虚函数表

方法一:加上base1的大小

方法二:切片

结论


多态的原理

首先理解虚函数表

class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};

        请问sizeof(Base)的大小为多少?

        答案为:x64下16字节,x86下8字节

        解析如下:

        Base类中包涵着int类型的成员变量占4字节,而由于有虚函数,因此会有一个虚函表的指针vfptr,因此根据内存对齐,得到上述答案。

        这时就会有疑惑了?虚函数表指针和虚函数表是什么呢?

        如下通过监视窗口可以看到vfptr指向了一个数组(也就是虚函数表),而数组中存储着虚函数指针:

        继续分析,我们在上述代码的基础上增加代码:

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;
	}
private:
	int _d = 2;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

        通过观察和测试,我们发现了以下几点问题:

  1. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。
  2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
  3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
  4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
  5. 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
  6. 这里还有一个童鞋们很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的,Linux g++下大家自己去验证?

正式理解多态的原理

        见以下代码:

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
	virtual void vv() { cout << "打折" << endl; }
	int a = 0;
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }

	int b = 1;
};
void Func(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person m;
	Func(m);
	Student s;
	Func(s);
	return 0;
}

        从监视中很明显的看到,子类继承了父类的虚函数表,但是很明显的看到虚函数表中我们的BuyTicket()的虚函数指针地址改变了,而vv()确没有改变,这就很明显了,因为BuyTicket()被重写了,而vv()没有,而重写也有另外一个名字:覆盖当我们重写了虚函数,那么就会覆盖对应虚函数在虚函数表中的指针

        内存方面观察:

        可以看到其中vfptr中存储的地址是发生了改变的,也就是说我们可以根据这个地址找到新的一张虚函数表,在前面我们学习过“切片”的概念,我们知道当以父类的类型去访问子类的类型会发生“切片”使得只访问父类的类型的空间,也就是说我们只访问上图中蓝色框内的内容,再结合上上张图监视中如果子类重写了虚函数则虚函数表中虚函数指针改变。当我们调用对应的虚函数时,就会调用子函数的虚函数而不是父类的虚函数!这就是多态实现的原理!因此,多态中指向父类调用父类,指向子类调用子类!

一些拓展

多态对于引用、指针和对象

        为什么多态只允许引用和指针呢?我们都知道引用的底层实现实际上还是指针,多态的实现就是指向子类对象中切割出来的那一部分!而对象只会拷贝子类对象中父类的那一部分,但是不会拷贝虚函数表指针。为什么呢?因为如果允许虚函数表指针的拷贝会造成二义性,如下:

int main()
{
	Person m;
	Student s;
    m=s;
	Func(m);
	Func(s);
	return 0;
}

        如果对象可以像引用和指针一样,那么当拷贝了虚函数表指针后,你会发现我们实现不了多态中指向父类调用父类,指向子类调用子类的场景。也会造成析构函数调用调错等等的错误。

虚表的拓展

        如果子类与父类中不重写虚函数,子类与父类的续虚函数表一样吗?不一样!他们存在不同的位置!虽然他们的内容是一样的!同类对象的虚函数表一样吗?一样!

        总结,不同的类不会共用虚函数表,只有相同的类才会共用虚函数表!

多继承中的虚函数表

先了解如何打印虚函数表

        我们都知道虚函数表是一个函数指针数组,并且数组最后一位是以nullptr结尾的。因此,我们可以根据该特性打印虚函数表:

typedef void(*VFPTR) ();
void PrintVTable(VFPTR* vTable)
{
	// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
	cout << " 虚表地址>" << vTable << endl;
	for (size_t i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}

        例子,打印单继承的虚函数表:

class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
private:
	int a;
};
class Derive :public Base {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
private:
	int b;
};

typedef void(*VFPTR) ();
void PrintVTable(VFPTR* vTable)
{
	// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
	cout << " 虚表地址>" << vTable << endl;
	for (size_t i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}

int main()
{
	Base b;
	Derive d;
	VFPTR * vTableb = (VFPTR*)(*((int*)&b));
	PrintVTable(vTableb);
	VFPTR* vTabled = (VFPTR*)(*((int*)&d));
	PrintVTable(vTabled);
	return 0;
}

        思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr。需要注意的是这是在x86的运行环境下的,如果是x64则需强转为long long:

        1.先取b的地址,强转成一个int*的指针

        2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针

        3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。

        4.虚表指针传递给PrintVTable进行打印虚表

        5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的 - 生成 - 清理解决方案,再编译就好了。

然后理解多继承中的虚函数表

        概念:

C++中的多继承是指一个派生类可以同时从多个基类派生,从而继承它们的属性和行为

        多继承是面向对象编程中一个重要的概念,它允许一个类继承多个其他类的成员。这样做有几个目的:

  • 代码重用:多继承可以提高代码的重用性,因为派生类可以访 问所有基类的公有成员和保护成员。
  • 功能组合:通过继承多个类,派生类可以将不同基类的功能组合在一起,形成更复杂的功能。

        然而,多继承也可能带来一些问题,如菱形继承问题,这可能导致二义性。为了解决这个问题,C++引入了虚基类的概念。

        如下为一段多继承的代码,可以看到drive继承了base1和base2:

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;
};

        那么他的虚函数表又是什么样的呢?如下:

        可以看到正如我们猜测的那样,它包含着两张虚函数表!然而,我们在derive中重写了func1()函数,以及额外添加了一个func3()函数,但是并没有在监视中显示,这是因为编译器并没有让你实际的看到,也就是说编译器在骗人,实际上就是在其中的一张表当中,可以理解为监视的一个bug。我们通过上述打印虚函数表可以看到具体的效果(注意此为x64环境下):

typedef void(*VFPTR) ();
void PrintVTable(VFPTR* vTable)
{
	// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
	cout << " 虚表地址>" << vTable << endl;
	for (size_t i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}

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;
};

int main()
{
	cout << "base1:" << endl;
	base1 b;
	PrintVTable((VFPTR*)(*(long long*)&b));

	cout << "base2:" << endl;
	base2 c;
	PrintVTable((VFPTR*)(*(long long*)&c));

	cout << "derive 表1:" << endl;
	derive d;
	PrintVTable((VFPTR*)(*(long long*)&d));

	//printvft((vfunc*)(*(int*)((char*)&d+sizeof(base1))));
	cout << "derive 表2:" << endl;
	base2* ptr = &d;
	PrintVTable((VFPTR*)(*(long long*)ptr));
}

        得到第一个虚基表的方法很简单,因为第一个虚基表的指针正好处在前8个字节处,只需要向上面一样进行强转即可,如果要找到第二个虚基表则有如下两种方法:

方法一:加上base1的大小
	printvft((vfunc*)(*(int*)((char*)&d+sizeof(base1))));

        也就是加上sizeof(base1)即可,但是!需要注意的是d的类型是Derive,在&d后变为Derive* 的一个指针,+1 跳转的是Derive类型的字节大小!而我们想要的是每次+1跳转1个字节,所以需要强制转换char* !

方法二:切片
	base2* ptr = &d;
	PrintVTable((VFPTR*)(*(long long*)ptr));

        把d利用切片的原理给到ptr,然后再按照上面强转的原理找到虚基表即可!

结论

        如下为上面代码的运行结果:

        可以看到上面的图示,我们可以得出相应的结论:多继承中重写的虚函数以及新增的虚函数都是在第一个虚基表当中进行修改以及增加的!如果重写的虚函数在其他基类中也有对应的虚函数,那么继承下来的虚基表也需要重写。

        更加详细的图解如下:

​        这里又引申出来一个问题,为什么其derive继承的两个虚基表中func1()的地址不同呢?这里就需要从汇编的角度进行理解了:

        在以上的代码的基础上调试下面这段代码(x86环境下),通过反汇编可得结果如下:

	derive d;
	base1* p1 = &d;
	p1->func1();

	base2* p2 = &d;
	p2->func1();

        ​从上图的图示可以看到p1只经过了一次jmp就找到了derive中的func1()的地址,而p2则是经过了多次的jmp才找到func1()地址。这是因为:p1的调用的地址恰好与derive* 类型的this指针的地址是重叠的,因此不需要去找这个地址,而p2要经过蓝框中的“8字节的偏移”才能找到this指针(可以看到有ecx标识(ecx是存储this指针的)),才能指向derive对象的开始,才可以调用derive的func1()(毕竟fun1也可能调用成员函数、成员变量等等)。

        总结:这里是为了修正this指针指向derive对象,这里调用的是derive重写的func1()。

 


                      感谢你耐心的看到这里ღ( ´・ᴗ・` )比心,如有哪里有错误请踢一脚作者o(╥﹏╥)o! 

                                       

                                                                        给个三连再走嘛~  

 

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

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

相关文章

手把手教你Linux系统下的Java环境配置,简单到不行!

推荐阅读 给软件行业带来了春天——揭秘Spring究竟是何方神圣&#xff08;一&#xff09; 给软件行业带来了春天——揭秘Spring究竟是何方神圣&#xff08;二&#xff09; 文章目录 推荐阅读下载JDK安装包方式一方式二 添加环境变量验证安装情况 下载JDK安装包 方式一 1.进入…

如何引导llm为自己写prompt生成剧本

如何使用写prompt让你自己生一个狗血修仙穿越短剧&#xff0c;且短剧有趣生动让人流连忘返 好的&#xff0c;我会尝试编写一个狗血修仙穿越短剧的prompt&#xff0c;以激发你的想象力&#xff0c;让你创作出一个既有趣又生动的短剧。以下是我的prompt&#xff1a; 标题&#x…

神经网络代码实现

目录 神经网络整体框架 核心计算步骤 参数初始化 矩阵拉伸与还原 前向传播 损失函数定义 反向传播 全部迭代更新完成 数字识别实战 神经网络整体框架 核心计算步骤 参数初始化 # 定义初始化函数 normalize_data是否需要标准化def __init__(self,data,labels,layers,…

遨博I20协作臂关节逆解组Matlab可视化

AUBO I20协作臂关节逆解组Matlab可视化 前言1、RTB使用注意点2、代码与效果2.1、完整代码2.2、运行效果 总结 前言 注意&#xff1a;请预先配置好Matlab和RTB机器人工具箱环境&#xff0c;本文使用matlab2022b和RTB10.04版本 工作需要&#xff0c;使用matlab实现对六轴机械臂…

【Kubernetes in Action笔记】1.快速开始

在Kubernetes上运行一个程序 基础运行环境 当前的运行环境为使用虚拟机构建的单master集群。 [rootk8s-master ~]# kubectl get nodes NAME STATUS ROLES AGE VERSION k8s-master Ready control-plane 109d v1.27.1 k8s-node1 Ready …

Jetpack Compose 第 2 课:布局

点击查看&#xff1a;Jetpack Compose 教程 点击查看&#xff1a;Composetutorial 代码 简介 Jetpack Compose 是用于构建原生 Android 界面的新工具包。它使用更少的代码、强大的工具和直观的 Kotlin API&#xff0c;可以帮助您简化并加快 Android 界面开发。 在本教程中&a…

Centos7挂载磁盘

1 查看未挂载的磁盘 命令&#xff1a; fdisk -l红框圈中的即是本次要挂载的磁盘&#xff0c;/dev/vdb 与 /dev/vda 相比&#xff0c;其没有下方的 /dev/vda1 等信息&#xff0c;代表 /dev/vdb 磁盘并没有进行过分区操作&#xff0c;是一个新加的硬盘。 2 对新建的磁盘进行分…

2024.2.18

使用fgets统计给定文件的行数 #include<stdio.h> #include<string.h> int main(int argc, const char *argv[]) {FILE *fpNULL;if((fpfopen("./test.txt","w"))NULL){perror("open err");return -1;}fputc(h,fp);fputc(\n,fp);fput…

【Webpack】处理字体图标和音视频资源

处理字体图标资源 1. 下载字体图标文件 打开阿里巴巴矢量图标库open in new window选择想要的图标添加到购物车&#xff0c;统一下载到本地 2. 添加字体图标资源 src/fonts/iconfont.ttf src/fonts/iconfont.woff src/fonts/iconfont.woff2 src/css/iconfont.css 注意字体…

【MySQL进阶之路】MySQL中的聚簇索引和非聚簇索引、以及回表查询

欢迎关注公众号&#xff08;通过文章导读关注&#xff1a;【11来了】&#xff09;&#xff0c;及时收到 AI 前沿项目工具及新技术的推送&#xff01; 在我后台回复 「资料」 可领取编程高频电子书&#xff01; 在我后台回复「面试」可领取硬核面试笔记&#xff01; 文章导读地址…

uniapp返回上一级页面,传参,上一级通过参数重新请求数据

小程序navigateback传值_微信小程序 wx.navigateBack() 返回页面如何传递参数 - 文章...-CSDN博客 当前页面 上一级页面

【Kuiperinfer】笔记01 项目预览与环境配置

学习目标 实现一个深度学习推理框架设计、编写一个计算图实现常见的算子&#xff0c;例如卷积、池化、全连接学会如何进行算子的优化加速使用自己的推理框架推理常见模型&#xff0c;检查结果是否能够和torch对齐 什么是推理框架&#xff1f; 推理框架用于对已经训练完成的模…

php数组运算符 比较 isset、is_null、empty的用法和区别

php数组运算符 1. 数组运算符2. 判断两个数组是否相等3. isset、is_null、empty的用法和区别 1. 数组运算符 注意&#xff1a;只会保留第一个数组中的键值对&#xff0c;而忽略后面数组中相同键名的元素&#xff0c;如果想要合并两个数组并覆盖相同键名的元素&#xff0c;可以…

微信小程序之开发会议OA项目

目录 前言 本篇目标 首页 会议 投票 个人中心 会议OA项目-首页 配置 tabbar mock工具 page swiper 会议信息 会议OA项目-会议 自定义tabs组件 会议管理 会议OA项目-投票 会议OA项目-个人中心 前言 文章含源码资源&#xff0c;投票及个人中心详细自行查看…

HTTP请求报文与响应报文格式

HTTP请求报文与响应报文格式 HTTP请求报文与响应报文格式 请求报文包含四部分&#xff1a; a、请求行&#xff1a;包含请求方法、URI、HTTP版本信息b、请求首部字段c、请求内容实体d、空行 响应报文包含四部分&#xff1a; a、状态行&#xff1a;包含HTTP版本、状态码、状态码…

程序员也需要休息:为什么有时候他们不喜欢关电脑

程序员为什么不喜欢关电脑&#xff1f; 背景&#xff1a;作为程序员&#xff0c;长时间与电脑为伴是家常便饭。然而&#xff0c;有时候他们也会觉得厌倦和疲惫&#xff0c;不喜欢过多地与电脑打交道。本文将探讨程序员为何需要适当的休息和放松&#xff0c;以及如何更好地管理…

代码随想录第33天|● 1005.K次取反后最大化的数组和 ● 134. 加油站 ● 135. 分发糖果

文章目录 1005.K次取反后最大化的数组和贪心思路&#xff1a;代码&#xff1a; 34. 加油站思路一&#xff1a;全局贪心代码&#xff1a; 思路二&#xff1a;代码&#xff1a; 135. 分发糖果思路&#xff1a;两边考虑代码&#xff1a; 1005.K次取反后最大化的数组和 贪心思路&am…

[C++]二叉搜索树

一、定义 二叉搜索树又称二叉排序树&#xff0c;它或者是一棵空树&#xff0c;或者是具有以下性质的二叉树: 若它的左子树不为空&#xff0c;则左子树上所有节点的值都小于根节点的值若它的右子树不为空&#xff0c;则右子树上所有节点的值都大于根节点的值它的左右子树也分别…

Java+Vue+MySQL,国产动漫网站全栈升级

✍✍计算机编程指导师 ⭐⭐个人介绍&#xff1a;自己非常喜欢研究技术问题&#xff01;专业做Java、Python、微信小程序、安卓、大数据、爬虫、Golang、大屏等实战项目。 ⛽⛽实战项目&#xff1a;有源码或者技术上的问题欢迎在评论区一起讨论交流&#xff01; ⚡⚡ Java实战 |…

综合练习

目录 查询每个员工的编号、姓名、职位、基本工资、部门名称、部门位置 确定要使用的数据表 确定已知的关联字段 查询每个员工的编号、姓名、职位、基本工资、工资等级 确定要使用的数据表 确定已知的关联字段 查询每个员工的编号、姓名、职位、基本工资、部门名称、工资…