【C++深度探索】全面解析多态性机制(二)

news/2024/9/11 3:53:52/ 标签: c++, java, jvm

在这里插入图片描述

🔥 个人主页:大耳朵土土垚
🔥 所属专栏:C++从入门至进阶
这里将会不定期更新有关C/C++的内容,欢迎大家点赞,收藏,评论🥳🥳🎉🎉🎉

在这里插入图片描述

前言

我们知道C++多态实现有两个条件——一是基类的指针或引用调用虚函数,另一个是基类中有虚函数并且在派生类中实现虚函数重写;这两个条件缺一不可,这与多态实现的底层原理有关,今天我们就来学习一下多态实现的原理🥳🥳

目录

  • 前言
  • 1.虚函数表
  • 2.派生类中的虚表
    • 情况一:只有基类有虚函数,派生类没有
    • 情况二:基类和派生类中都有虚函数,并且虚函数没有被重写
    • 情况三:基类中定义虚函数,并且派生类中对该虚函数进行了重写
    • 综合这三种情况
  • 3.多态原理
    • 动态绑定与静态绑定
  • 4.多继承中的虚函数表
  • 5.结语

1.虚函数表

虚函数表(Virtual Function Table,VTable)是C++中实现动态多态性的一种机制。每个包含虚函数的类都有一个对应的虚函数表,用于存储该类的虚函数地址。

虚函数表是一个包含函数指针的数组,每个函数指针指向相应虚函数的实现。

也就是说在类中定义了虚函数,那么该类就会包含一个虚函数表来存放虚函数的函数指针,注意这里是指类中会存储虚函数表的指针来达到效果,因为如果虚函数很多,直接存储虚函数表可能会占用很多空间。

例如:

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

当我们计算Base类的大小时,发现只有一个int类型的成员变量_b,所以应该是4个字节,但是我们可以看一下结果:

在这里插入图片描述

这里显示的是8字节,这是因为Base类中创建了虚函数,而每个包含虚函数的类都有一个对应的虚函数表,虚函数的地址要被放到虚函数表中,所以需要多余的空间来存储虚函数表的指针,这个指针我们叫做虚函数表指针。

如下图所示:

在这里插入图片描述

Base对象b中除了_b成员,还多一个__vfptr指针放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。

注意是虚函数表的指针,而不是直接存储虚函数表。

因为上述例子是在32位操作系统下执行的,所以指针的大小是4字节,Base类大小是8字节;如果是64位那么指针的大小是8字节,Base类的大小就应该参考结构体内存对齐规则,应该是16字节。

2.派生类中的虚表

如果基类中没有虚函数,派生类中有虚函数,那么它的虚函数表和上面的一致。
例如:

class Base
{
private:int _b = 1;
};class Derive :public Base
{
public:virtual void Func2(){cout << "Func2()" << endl;}
private:int _d = 2;
};int main()
{Derive d;return 0;
}

上述代码基类中没有虚函数也就不存在虚函数表,但派生类Derive类中存在虚函数,所以会存放一个虚函数表指针__vfptr,来指向虚函数表,而虚函数表中又会存放Derive类中虚函数的指针。

如下图所示:

在这里插入图片描述

但是如果基类中有虚函数表,那么派生类该如何继承呢?

情况一:只有基类有虚函数,派生类没有

例如:

//情况一:只有基类有虚函数
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};class Derive :public Base
{
private:int _d = 2;
};int main()
{Derive d;return 0;
}

d对象由两部分构成,一部分是父类继承下来的成员,另一部分是自己的成员,如下图所示:

在这里插入图片描述

我们看到d对象继承了基类的成员变量_b和虚函数表的指针,虚函数表里面存放的是基类中虚函数Func1()的地址。

情况二:基类和派生类中都有虚函数,并且虚函数没有被重写

例如:

//情况二:基类和派生类中都有虚函数,并且虚函数没有被重写
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};class Derive :public Base
{
public:virtual void Func2(){cout << "Func2()" << endl;}
private:int _d = 2;
};int main()
{Derive d;return 0;
}

首先基类中有虚函数,所以派生类中包含基类的那部分成员肯定会包含基函数表的指针,但是派生类的虚函数应该怎么存放呢?结果如下图所示:

在这里插入图片描述

我们发现派生类并没有生成自己的虚函数表,所以它的虚函数应该存放在从基类继承下来的虚函数表中,上图中看到虚函数数组内只存放一个虚函数Func1(),没有派生类自己的虚函数Fun2(),这里是编译器的监视窗口故意隐藏了Fun2()函数,也可以认为是他的一个小bug,那么我们可以思考从打印虚函数地址的思路入手,查看Fun1()Fun2()的地址是否是存放在一起。

虚函数的地址存放在虚函数表中,而对象中前四个字节存放的是虚函数表的指针,所以我们可以使用强制类型转换取出对象的前四个字节,但是int类型与BaseDerive类型不兼容,不能相互转换,但是指针之间可以相互转换,所以我们考虑先取BaseDerive类对象的地址然后强制转换成int*类型,然后再解引用就得到了虚函数表的地址🥳🥳
代码如下:

//情况二:基类和派生类中都有虚函数,并且虚函数没有被重写
//基类和派生类代码如上//先定义一个函数指针类型
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;
}void test()
{Base b;Derive d;//打印b对象中虚函数的地址VFPTR* vTableb = (VFPTR*)(*(int*)&b);PrintVTable(vTableb);//打印d对象中虚函数的地址VFPTR* vTabled = (VFPTR*)(*(int*)&d);PrintVTable(vTabled);
}

结果如下:

在这里插入图片描述

我们发现基类对象和派生类对象的虚函数表是不同的,并且派生类对象虚函数表中存放了两个虚函数的地址,其中一个与基类的虚函数地址相同也就是Func1()的地址,另一个则是派生类自己定义的虚函数Func2()的地址。

综上所述,如果派生类和基类都定义了自己的虚函数,并且基类的虚函数没有在派生类中重写的话,那么派生类中虚函数的地址会存放在派生类继承的基类那部分的虚函数表中的末尾,并且基类定义的对象和派生类定义的对象的虚函数表的地址是不同的。

同一类定义的不同对象使用的基函数表是同一个
如下图所示:

在这里插入图片描述

情况三:基类中定义虚函数,并且派生类中对该虚函数进行了重写

例如:

//情况三:基类中定义虚函数,并且派生类中对该虚函数进行了重写
class Base
{
public:virtual void Func1(){cout << "Base::Func1()" << endl;}
private:int _b = 1;
};class Derive :public Base
{
public:virtual void Func1()//重写虚函数Fun1(){cout << "Derive::Func1()" << endl;}
private:int _d = 2;
};int main()
{Base b;Derive d;return 0;
}

按照之前的结论,派生类中的虚函数的地址是存放在继承的基类的虚函数表中的,那么对于重写的虚函数是写在基类虚函数表的末尾,还是将基类被重写的虚函数地址覆盖呢?

结果如下图:

在这里插入图片描述

可以看到派生类的重写的虚函数的地址覆盖了继承的基类的虚函数的地址,我们还可以使用上文中打印虚函数地址的方式更加直观的看清楚:

在这里插入图片描述

上述例子中基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。

综合这三种情况

  1. 如果基类没有虚函数,子类有虚函数,那么子类会自己生成一个虚函数表来存放自己的虚函数;
  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;}
private:int _d = 2;
};int main()
{Base b;Derive d;
}

在这里插入图片描述

基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。

虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr

总结一下派生类的虚表生成:
a.先将基类中的虚表内容拷贝一份到派生类虚表中
b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

✨✨这里还有一个很容易混淆的问题:虚函数存在哪的?虚表存在哪的?

虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的。

代码如下:

int main()
{int i = 0;static int j = 1;int* p1 = new int;const char* p2 = "aaaaa";Base b;Derive d;printf("栈:%p\n", &i);printf("静态区:%p\n", &j);printf("堆:%p\n", p1);printf("常量区:%p\n", p2);printf("Base虚表地址:%p\n", *(int*)&b);printf("Derive虚表地址:%p\n", *(int*)&d);return 0;
}

通过比较不同区域的地址来判断虚表地址在哪,发现虚表离常量区最近,如下图所示:

在这里插入图片描述

3.多态原理

了解了虚函数表我们就可以深入学习多态的原理。

例如:

//多态原理
class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-半价" << endl; }
};void Func(Person& p)
{p.BuyTicket();
}int main()
{Person Black;Func(Black);Student tutu;Func(tutu);return 0;
}

对于买票这个行为,不同的对象会有不同的结果,普通人买票是全价,学生则可能是半价,如下图所示:

在这里插入图片描述

那么我们可以通过调试来看看它有不同结果的原因:

当使用Person类对象调用函数Func时:

在这里插入图片描述

当使用Student类对象调用函数Func时:

在这里插入图片描述

我们看到,p是指向Black对象时,p->BuyTicket在Black的虚表中找到虚函数是Person::BuyTicket。p是指向tutu对象时,p->BuyTicket在tutu的虚表中找到虚函数Student::BuyTicket。

我们发现不同的对象调用Func函数时,使用的虚函数表是不同的,Person类对象和Student类对象都使用各自的虚函数表,所以调用不同的虚函数,如下图所示:

在这里插入图片描述

这样就实现出了不同对象去完成同一行为时,展现出不同的形态。

反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是基类对象的指针或引用调用虚函数。具体原因,我们先要了解一下动态绑定。

动态绑定与静态绑定

  • 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
  • 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

那么当我们直接使用对象调用成员函数时走的是静态绑定,是指编译期间就确定的程序行为;当我们使用基类指针或引用调用虚函数时走的是动态绑定,需要通过虚函数表来确定不同对象调用不同的函数,根据具体拿到的类型确定程序的具体行为。

所以对于多态实现的两个条件,首先我们需要通过基类对象的指针或引用调用虚函数才能走动态绑定,其次派生类的虚函数还需要重写,这样不同类的对象使用的虚函数才是不一样的,才会显现不同的状态,实现多态。

如果只是完成了虚函数的覆盖而没有通过基类对象的指针或引用调用,或者只有第二个条件都无法完成多态的实现。

多态实现的两个条件缺一不可。

4.多继承中的虚函数表

在多继承中,派生类会继承多个基类,每个基类都有自己的虚表。因此,派生类会有多个虚表,每个虚表对应于一个基类。

这是为了保证派生类能够正确调用和重写每个基类的虚函数。当派生类实例化时,会为每个基类分配一个虚表指针,这些虚表指针会存储在派生类对象的内存中。

例如:

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; }//重写Fun1()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;
}

结果如下:

在这里插入图片描述

上图可以看出多继承派生类的未重写的虚函数func3()放在第一个继承基类部分的虚函数表中,图示如下:

在这里插入图片描述

5.结语

虚函数表的存在是为了实现动态绑定也就是实现多态,当派生类对基类的虚函数进行重写时,通过基类对象指针和引用调用虚函数时,就会通过虚函数表来确定不同对象调用不同的函数,根据具体拿到的类型确定程序的具体行为,所以多态实现的两个条件缺一不可。以上就是今天所有的内容啦~ 完结撒花🥳🎉🎉


http://www.ppmy.cn/news/1475424.html

相关文章

Elasticsearch 理解相关性评分(TF-IDF、BM25等)

在Elasticsearch中&#xff0c;相关性评分是搜索功能的核心&#xff0c;它决定了搜索结果的质量和排序。了解Elasticsearch是如何计算相关性评分的&#xff0c;特别是TF-IDF和BM25算法&#xff0c;对于优化搜索性能和结果至关重要。本文将深入探讨这两种算法及其在Elasticsearc…

vue3 ts 报错:无法找到模块“../views/index/Home.vue”的声明文件

解决办法&#xff1a; env.d.ts 新增代码片段&#xff1a; declare module "*.vue" {import type { DefineComponent } from "vue";// eslint-disable-next-line typescript-eslint/no-explicit-any, typescript-eslint/ban-typesconst component: Define…

C#面:阐述控制反转是什么?

控制反转&#xff08;Inversion of Control&#xff0c;缩写为IoC&#xff09;&#xff0c;是⾯向对象编程中的⼀种设计原则&#xff0c;可以⽤来减低计算机代码之间的耦合度。其中最常⻅的⽅式叫做依赖注⼊&#xff08;Dependency Injection&#xff0c;简称DI&#xff09;&am…

深入解析C#中的Stopwatch类:精准计时的艺术

目录 引言 了解Stopwatch类 创建与使用Stopwatch 使用多个Stopwatch实例 性能分析与优化 结论 后记 引言 在软件开发中&#xff0c;性能分析是不可或缺的一环&#xff0c;它帮助我们识别瓶颈、优化代码&#xff0c;确保应用程序的高效运行。C#中的Stopwatch类便是开发者…

数据湖仓一体(六)安装flink

上传安装包到/opt/software目录并解压 [bigdatanode106 software]$ tar -zxvf flink-1.17.2-bin-scala_2.12.tgz -C /opt/services/ 重命名文件 [bigdatanode106 services]$ mv flink-1.17.2-bin-scala_2.12 flink-1.17.2 配置环境变量 [bigdatanode106 ~]$ sudo vim /etc…

Elasticsearch:Node.js ECS 日志记录 - Morgan

这是之前系列文章&#xff1a; Elasticsearch&#xff1a;Node.js ECS 日志记录 - Pino Elasticsearch&#xff1a;Node.js ECS 日志记录 - Winston 中的第三篇文章。在今天的文章中&#xff0c;我将描述如何使用 Morgan 包针对 Node.js 应用进行日子记录。此 Morgan Node.j…

threejs

1.场景清空&#xff0c;释放内容 // 假设你已经有一个Three.js的场景对象scene// 函数&#xff1a;清空场景中的所有对象 function clearScene(scene) {while(scene.children.length > 0){const object scene.children[0];if(object.isMesh) {// 如果有几何体和材质&#…

2024年上半年信息系统项目管理师——综合知识真题题目及答案(第1批次)(2)

2024年上半年信息系统项目管理师 ——综合知识真题题目及答案&#xff08;第1批次&#xff09;&#xff08;2&#xff09; 第21题&#xff1a;在一个大型信息系统项目中&#xff0c;项目经理发现尽管已经建立了沟通机制&#xff0c;但团队间的沟通依然不畅&#xff0c;项目风险…

免费开源工具—— Clarity Al:一键图像放大/增强,Magnific平替!

今天给大家推荐一款图像增强工具——Clarity AI &#xff0c;免费且开源&#xff0c;快来看看吧&#xff01; 1、效果展示 MagnificAl是一款基于人工智能技术的图像处理工具,主要功能包括图像放大、像素级AI重绘、灵活的设置调整以及多种优化场景。它能够支持最高放大至16倍,甚…

电脑 DNS 缓存是什么?如何清除?

DNS&#xff08;Domain Name System&#xff0c;域名系统&#xff09;是互联网的重要组成部分&#xff0c;负责将人类易记的域名转换为机器可读的 IP 地址&#xff0c;从而实现网络通信。DNS 缓存是 DNS 系统中的一个关键机制&#xff0c;通过临时存储已解析的域名信息&#xf…

PG大会周五于杭州举办;Pika发布4.0;阿里云MySQL上线Zero-ETL集成能力

重要更新 1. PostgreSQL中国技术大会举行12日&#xff08;周五&#xff09;于杭州举办&#xff0c;是PostgreSQL社区年度的大会&#xff0c;举办地点&#xff1a;杭州君尚云郦酒店&#xff08;杭州市上城区临丁路1188号&#xff09;&#xff0c;感兴趣的可以考虑现场参加 ( [1]…

iCloud数据宝库:全方位掌握笔记应用数据存储

iCloud数据宝库&#xff1a;全方位掌握笔记应用数据存储 在数字化时代&#xff0c;我们的日常生活和工作中产生了大量的笔记数据&#xff0c;这些数据的安全性、可访问性和同步性至关重要。苹果的iCloud服务提供了一个强大而便捷的解决方案&#xff0c;允许用户在所有设备上存…

向量索引【草稿】

用「向量」化数据表示「概念」。 向量表达:概念上更为接近的点在空间中更为聚集,而概念上更为不同的点,则距离更远。 向量数学表达:以坐标原点为起点,这些坐标点重点。 在语言上应用–词向量。 一个训练恰当的词向量集合,将和指代的事物之间的向量集合十分接近。有利于自…

智能家居装修怎么布线?智能家居网络与开关插座布置

打造全屋智能家居。计划的智能家居方案以米家系列为主&#xff0c;智能家居联网方案以无线为主。装修前为了装备智能家居做了很多准备工作&#xff0c;本文深圳侨杰智能分享一个智能家居装修和布线方面的心得与实战知识。希望能对大家的装修有所帮助。 ​1.关于网络 如果房子比…

VUE export import

在Vue.js项目中&#xff0c;export 和 import 是ES6&#xff08;ECMAScript 2015&#xff09;模块系统的一部分&#xff0c;用于在JavaScript文件中导出和导入模块&#xff08;比如组件、函数、变量等&#xff09;。这对于组织大型项目中的代码、提高代码的可复用性和可维护性非…

什么是面向对象编程

什么是面向对象编程&#xff1f;&#xff08;OOP&#xff09; ● 面向对象编程是一种基于对象概念的编程范式&#xff1b;&#xff08;所谓的编程范式&#xff0c;就是代码风格&#xff0c;我们“如何”编写和组织代码&#xff09;&#xff1b; ● 我们使用对象来模拟&#xf…

新版k8s拉取镜像失败问题

新版版从k8s从1.23后放弃使用docker容器作为的默认运行时了&#xff0c;而是采用的containerd&#xff0c;使用时不在使用docker拉取镜像&#xff0c;这就带来了一系列的问题。在k8s1.24之后用户可以选择docker或者选择contained作为容器运行时&#xff0c;如果不指定使用的doc…

Postman 中的 API 安全性测试:最佳实践与技巧

在当今快速发展的数字化世界中&#xff0c;API&#xff08;应用程序编程接口&#xff09;已成为软件系统之间通信的桥梁。然而&#xff0c;随着API使用的增加&#xff0c;安全风险也随之上升。本文将详细介绍如何在 Postman 中进行 API 的安全性测试&#xff0c;帮助开发者和测…

循环结构(一)——for语句【互三互三】

文章目录 &#x1f341; 引言 &#x1f341; 一、语句格式 &#x1f341; 二、语句执行过程 &#x1f341; 三、语句格式举例 &#x1f341;四、例题 &#x1f449;【例1】 &#x1f680;示例代码: &#x1f449;【例2】 【方法1】 &#x1f680;示例代码: 【方法2】…

一类账户认证API在Java、Python、PHP中的使用教程

随着金融科技的快速发展&#xff0c;一类账户认证在金融服务中扮演着越来越重要的角色。对于个人和企业而言&#xff0c;拥有一个高级别的账户不仅能提高交易效率&#xff0c;还能享受到更多优惠和服务。然而&#xff0c;这也带来了账户安全的挑战和风险。 为了确保账户的真实…