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

本文主要是介绍【C++学习手札】多态:掌握面向对象编程的动态绑定与继承机制(深入),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

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

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

                                                                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! 

                                       

                                                                        给个三连再走嘛~  

 

这篇关于【C++学习手札】多态:掌握面向对象编程的动态绑定与继承机制(深入)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!


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

相关文章

Jvm sandbox mock机制的实践过程

《Jvmsandboxmock机制的实践过程》:本文主要介绍Jvmsandboxmock机制的实践过程,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录一、背景二、定义一个损坏的钟1、 Springboot工程中创建一个Clock类2、 添加一个Controller

SpringBoot实现Kafka动态反序列化的完整代码

《SpringBoot实现Kafka动态反序列化的完整代码》在分布式系统中,Kafka作为高吞吐量的消息队列,常常需要处理来自不同主题(Topic)的异构数据,不同的业务场景可能要求对同一消费者组内的... 目录引言一、问题背景1.1 动态反序列化的需求1.2 常见问题二、动态反序列化的核心方案2.1 ht

C++ HTTP框架推荐(特点及优势)

《C++HTTP框架推荐(特点及优势)》:本文主要介绍C++HTTP框架推荐的相关资料,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧... 目录1. Crow2. Drogon3. Pistache4. cpp-httplib5. Beast (Boos

golang实现动态路由的项目实践

《golang实现动态路由的项目实践》本文主要介绍了golang实现动态路由项目实践,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习... 目录一、动态路由1.结构体(数据库的定义)2.预加载preload3.添加关联的方法一、动态路由1

JDK9到JDK21中值得掌握的29个实用特性分享

《JDK9到JDK21中值得掌握的29个实用特性分享》Java的演进节奏从JDK9开始显著加快,每半年一个新版本的发布节奏为Java带来了大量的新特性,本文整理了29个JDK9到JDK21中值得掌握的... 目录JDK 9 模块化与API增强1. 集合工厂方法:一行代码创建不可变集合2. 私有接口方法:接口

Dubbo之SPI机制的实现原理和优势分析

《Dubbo之SPI机制的实现原理和优势分析》:本文主要介绍Dubbo之SPI机制的实现原理和优势,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录Dubbo中SPI机制的实现原理和优势JDK 中的 SPI 机制解析Dubbo 中的 SPI 机制解析总结Dubbo中

C#继承之里氏替换原则分析

《C#继承之里氏替换原则分析》:本文主要介绍C#继承之里氏替换原则,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录C#里氏替换原则一.概念二.语法表现三.类型检查与转换总结C#里氏替换原则一.概念里氏替换原则是面向对象设计的基本原则之一:核心思想:所有引py

Java 的 Condition 接口与等待通知机制详解

《Java的Condition接口与等待通知机制详解》在Java并发编程里,实现线程间的协作与同步是极为关键的任务,本文将深入探究Condition接口及其背后的等待通知机制,感兴趣的朋友一起看... 目录一、引言二、Condition 接口概述2.1 基本概念2.2 与 Object 类等待通知方法的区别

重新对Java的类加载器的学习方式

《重新对Java的类加载器的学习方式》:本文主要介绍重新对Java的类加载器的学习方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录1、介绍1.1、简介1.2、符号引用和直接引用1、符号引用2、直接引用3、符号转直接的过程2、加载流程3、类加载的分类3.1、显示

Python Selenium动态渲染页面和抓取的使用指南

《PythonSelenium动态渲染页面和抓取的使用指南》在Web数据采集领域,动态渲染页面已成为现代网站的主流形式,本文将从技术原理,环境配置,核心功能系统讲解Selenium在Python动态... 目录一、Selenium技术架构解析二、环境搭建与基础配置1. 组件安装2. 驱动配置3. 基础操作模