解锁C++多态的魔力:灵活与高效的编码艺术(下)

在这里插入图片描述

文章目录

  • 前言
    • 🎱四、多态的原理
      • 🔮4.1 虚函数表(`vtable`)
      • 🔮4.2 派生类对象中的虚函数表
        • 4.2.1 编写程序去访问虚函数表
        • 4.2.2 虚表存储位置的验证
    • 🎱五、 多态的静态绑定和动态绑定
      • 🔮5.1 静态绑定(Static Binding)
        • 5.1.1 典型场景:
      • 🔮5.2 动态绑定(Dynamic Binding)
        • 5.2.1 典型场景:
        • 5.2.2 C++ 中静态绑定与动态绑定的区别总结
        • 5.2.3 延伸到汇编底层的解释
        • 5.2.4 汇编中动态绑定的例子:
      • 总结
    • 🎱六、多态的常见面试题
  • 结语


前言

继上篇解锁C++多态的魔力:灵活与高效的编码艺术(上)
多态性是面向对象编程的重要特性之一,而C++通过虚函数、继承等机制实现了这一强大的功能。多态性使得代码更加灵活和可扩展,允许不同类型的对象以统一的方式进行操作。在本篇文章中,我们将深入探讨C++中多态的实现原理、使用场景及其优劣势,并通过具体代码示例展示如何利用多态来提升代码的可维护性和复用性。


🎱四、多态的原理

C++ 中的 多态性(运行时多态)的底层实现依赖于 虚函数表(vtable虚指针(vptr。要理解 C++ 中多态的底层原理,需要深入了解虚函数是如何通过这两者来实现的。下面是详细的解释。

🔮4.1 虚函数表(vtable

  • 笔试题,sizeof(Base)是多少?
class Base {
public:virtual void func1(){cout << "func1()" << endl;}
private:int b = 1;
};int main()
{cout << sizeof(Base) << endl;return 0;
}

在这里插入图片描述

通过上面的打印结果和调试,我们发现一个 Base 对象是 8 bytes,除了 b 成员,还多了一个 _vfptr 放在对象成员变量的前面。_vfptr 本质上是一个指针,这个指针我们叫做虚函数表指针,一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中(虚函数本质上是存在代码段的),虚函数表也简称虚表。

🔮4.2 派生类对象中的虚函数表

上面我们看了一个普通类对象中的虚表,下面我们再来看看派生类中的虚表又是怎样的。

// 针对上面的代码我们做出以下改造
// 1.我们增加一个派生类Derive去继承Base
// 2.Derive中重写Func1
// 3.Base再增加一个虚函数Func2和一个普通函数Func3
class Base {
public:virtual void fun1() { cout << "Base::fun1()" << endl; }virtual void fun2() { cout << "Base::fun2()" << endl; }void fun3() { cout << "Base::fun3()" << endl; }
private:int _b = 1;
};class Derive : public Base {
public:void fun1() {}
private:int _d = 2;
};int main()
{Base b;Derive d;return 0;
}

在这里插入图片描述

通过监视窗口我们发现了以下几个问题:

  • 派生类对象 d 中也有一个虚表,这个虚表是作为基类成员的一部分被继承下来的。总的来说,d 对象由两部分构成,一部分是父类继承下来的成员,d 对象中虚表指针就是就是这部分成员中的一个。另一部分则是自己的成员。

  • 基类 b 对象和派生类 d 对象的虚表是不一样的,上面的代码中 func1 完成了重写,所以 d 的虚表中存的是重写后的 Derive::func1,所以虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法层面的叫法,覆盖是原理层面的叫法。

  • 另外 func2 继承下来后是虚函数,所以放进了虚表,func3 也继承下来了,但是不是虚函数,所以不会放进虚表。

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

  • 总结一下派生类虚表的生成:

    1. 先将基类中的虚表内容拷贝一份到派生类虚表中。

    2. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数。

    3. 派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。(在 VS 监视窗口显示的虚表中是看不见的,下面将通过程序带大家来验证)

  • 这里还有一个比较容易混淆的问题:虚函数存在哪?虚表存在哪?很多小伙伴会觉得:虚函数存在虚表,虚表存在对象中,注意这种回答是错的。这里再次强调:虚表存的是虚函数的地址,不是虚函数,虚函数和普通的成员函数一样,都是存在代码段的,只是它的地址又存到了虚表中。另外,对象中存的不是虚表,存的是虚表的地址。那虚表是存在哪儿呢?通过验证,在 VS 下虚表是存在代码段的。Linux g++ 下大家可以自己去验证。同一个程序中,同一类型的对象共用一个虚表。

4.2.1 编写程序去访问虚函数表

上面提到派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。但是在 VS 的监视窗口中是看不到,以下面的代码为例:

class Person
{
public:virtual void func1() const { cout << "virtual void Person::fun1()" << endl; }virtual void func2() const { cout << "virtual void Person::fun2()" << endl; }virtual void func3() const { cout << "virtual void Person::fun3()" << endl; }//protected:int _a = 1;
};class Student : public Person
{
public:virtual void func1() const { cout << "virtual void Student::fun1()" << endl; }virtual void func3() const { cout << "virtual void Student::fun3()" << endl; }virtual void func4() const { cout << "virtual void Student::fun4()" << endl; }//protected:int _b = 2;
};int main(){Person Mike;Student Jack;return 0;
}

在这里插入图片描述

  • 监视窗口中展现的派生类对象的虚函数表中并没有派生类自己的虚函数 func4。但是我们从内存窗口可以看到第四个地址,我们可以大胆的猜测这个就是派生类自己的虚函数 func4 的地址,但是口说无凭,下面我们来写一段代码验证一下我们的猜想。
typedef void (*FUNC_PTR)();	//定义了一个名为 FUNC_PTR 的类型,它是一个指向返回类型为 void 的函数的指针类型。typedef 用于给复杂类型定义一个别名,在这里,FUNC_PTR 表示一个指向无参数且返回 void 的函数的指针。void PrintVFT(FUNC_PTR* table){for (int i = 0; table[i] != nullptr; i++) {// 使用 printf 输出当前虚函数表中第 i 个函数指针的地址。printf("[%d]:%p->", i, &table[i]);	// 将 table[i] 的值(即第 i 个函数指针)赋值给 f,f 是一个函数指针,可以像调用普通函数一样调用它。FUNC_PTR f = table[i];	f();}printf("\n");
}int main() {Person ps;Student st;// 取前四个字节int vft1 = *(int*)&ps;	// 获取 ps 的虚表指针。int vft2 = *(int*)&st;	// 获取 st 的虚表指针。// 将 vft1/vft2 强制转换为 VFPTR*(函数指针数组的类型),然后传递给 PrintVfptr 函数。// PrintVfptr 函数会输出对象的虚表中每个函数指针的地址并调用这些函数。PrintVFT((FUNC_PTR*)vft1);PrintVFT((FUNC_PTR*)vft2);return 0;
}

在这里插入图片描述

  • 通过上图可以看出我们程序打印出来的地址和监视窗口中显示的地址是一样的,并且成功的调用了派生类中的虚函数 func4,上图显示的结果完美的验证了我们的猜想。这里也说明了一个问题,VS 的监视窗口是存在 Bug 的,以后我们在调试代码过程中也不能完全相信监视窗口展现给我们的内容,比起监视窗口我们更应该相信内存窗口展现给我们的内容。这里也侧面反映了一个问题,只要我们能拿到函数的地址就能去调用该函数,正常情况下,我们只能通过派生类对象去调用虚函数 func4,这里我们直接拿到了这个函数的地址去调用,这里的问题在于函数的隐藏形参 this 指针接收不到实参,因为不是派生类对象去调用该函数。函数中如果去访问了成员变量,那么我们这种调用方式就会出问题。
4.2.2 虚表存储位置的验证
class Person{
public:virtual void func1() const { cout << "virtual void Person::fun1()" << endl; }//protected:int _a = 1;
};class Student : public Person{
public:virtual void func1() const {	cout << "virtual void Student::fun1()" << endl; }//protected:int _b = 2;
};int main(){Person Mike;Student Jack;// 栈区int a = 10;printf("栈区:%p\n", &a);// 堆区int* pa = new int(9);printf("堆区:%p\n", pa);// 常量区(代码段)const char* c = "hello world!";printf("常量区(代码段):%p\n", c);// 静态区(数据段)static int b = 8;printf("静态区(数据段):%p\n", &b);// 虚表printf("基类的虚表:%p\n", (void*)*(int*)&Mike);printf("派生类的虚表:%p\n", (void*)*(int*)&Jack);}
  • *(int*)&Mike:通过将 Mike 对象的地址强制转换为 int* 类型,并解引用该指针,获得 Mike 的虚表指针vptr

  • (void*) 是为了将这个指针转换为 void* 类型,以便 printf 正确输出它的地址。

在这里插入图片描述

  • 上面取虚表地址是通过强制类型转化来实现的,通过上面的监视窗口我们可以看出,虚表的地址永远是存储在对象的前四个字节,所以这里我们先取到对象的地址,然后将其强转为 int* 类型,为什么要强转为 int* 呢?因为,一个 int 型的大小就是四个字节,而指针的类型决定了该指针能够访问到内存空间的大小,一个 int* 的指针就能够访问到四个字节,再对 int* 解引用,这样就能访问到内存空间中前四个字节的数据,这样就能取到虚表的地址啦。通过打印结果我们可以看出,虚表的地址和常量区(代码段)的地址最为接近,因此我们可以大胆的猜测,虚表就是存储在常量区(代码段)的。

🎱五、 多态的静态绑定和动态绑定

在 C++ 中,静态绑定(Static Binding)和动态绑定(Dynamic Binding)涉及到对象方法的解析,即在调用一个对象的方法时,程序如何决定使用哪个具体的实现。这两种绑定机制是面向对象编程中多态性的核心概念,特别是在类继承和虚函数的场景下。

🔮5.1 静态绑定(Static Binding)

静态绑定也叫早期绑定(Early Binding),是在编译时决定函数调用的绑定方式。编译器在编译过程中根据对象的类型和函数的签名,直接将调用的目标地址确定下来。因此,静态绑定的函数调用在运行时没有额外的性能开销。

5.1.1 典型场景:

静态绑定通常出现在没有使用虚函数的场景下,即普通的成员函数调用时,编译器在编译期就能确定调用的是哪个函数。

#include <iostream>class Animal {
public:void speak() {std::cout << "Animal speaks" << std::endl;}
};class Dog : public Animal {
public:void speak() {std::cout << "Dog barks" << std::endl;}
};int main() {Animal a;Dog d;a.speak();  // 调用的是 Animal 的 speakd.speak();  // 调用的是 Dog 的 speak
}

在上述代码中,a.speak()d.speak() 的调用在编译期已经被静态解析,分别调用了 AnimalDogspeak() 方法。这就是静态绑定。

特点:

  • 编译时决定:调用的函数在编译期决定,不依赖运行时的信息。
  • 性能高:静态绑定不需要运行时开销,因此执行效率较高。
  • 缺少灵活性:不能根据实际对象的类型在运行时做出决策。

🔮5.2 动态绑定(Dynamic Binding)

动态绑定也叫晚期绑定(Late Binding),是在运行时决定函数调用的绑定方式。这种方式依赖于对象的实际类型(而不是变量声明的类型)。C++ 中的动态绑定依赖于虚函数(virtual 关键字)实现。

5.2.1 典型场景:

动态绑定通常在类的继承结构中使用虚函数时出现。编译器生成一个虚函数表(vtable),对象在运行时根据其实际类型从虚函数表中查找函数的具体实现。

class Animal {
public:virtual void speak() {cout << "Animal speaks" << endl;}
};class Dog : public Animal {
public:void speak() override {cout << "Dog barks" << endl;}
};int main() {Animal* a = new Dog();a->speak();  // 调用的是 Dog 的 speakdelete a;
}

在这个例子中,Animal* a = new Dog(); 语句中,虽然 a 的类型是 Animal*,但由于 speak() 是虚函数,调用时会根据对象的实际类型(Dog),从虚函数表中动态地选择 Dog 类的 speak() 方法。

特点

  • 运行时决定:调用的函数在运行时根据对象的实际类型决定。
  • 支持多态:可以实现基类指针或引用指向派生类对象,并在运行时调用派生类的函数。
  • 有一定性能开销:因为需要通过虚函数表查找函数的实际实现,动态绑定相对于静态绑定有额外的开销。
5.2.2 C++ 中静态绑定与动态绑定的区别总结
静态绑定动态绑定
绑定发生在编译时绑定发生在运行时
不需要虚函数表依赖虚函数表(vtable
调用的是编译时确定的类型的函数调用的是运行时对象实际类型的函数
使用普通成员函数使用虚函数virtual
执行效率高,没有运行时开销有一定的运行时开销
不支持多态支持多态
5.2.3 延伸到汇编底层的解释

在汇编层面,静态绑定和动态绑定的区别可以通过函数调用方式来理解:

  • 静态绑定:编译器在生成机器码时,直接将函数的地址放入调用指令中,程序执行时直接跳转到这个地址,没有额外的查找开销。

    • 汇编代码中,函数调用通常是通过直接的 call 指令跳转到固定的内存地址。
  • 动态绑定:编译器为每个包含虚函数的类生成一个虚函数表(vtable),该表中存储了虚函数的地址。在运行时,对象通过虚函数表指针查找实际要调用的函数地址,然后跳转执行。

    • 汇编代码中,虚函数调用通常会先通过一个中间的表指针,间接跳转到实际的函数实现。
5.2.4 汇编中动态绑定的例子:
  1. 静态绑定的汇编实现可能会包含直接调用目标函数地址:

    call Dog::speak
    
  2. 动态绑定的汇编实现需要通过虚表间接调用:

    mov rax, [rdi]          ; 从对象实例中加载虚表地址
    call [rax + offset]     ; 从虚表中取出实际函数的地址并调用
    

这种方式使得动态绑定的函数调用在运行时依赖对象的实际类型,而不是编译时的静态类型。

总结

  • 静态绑定发生在编译时,依赖于编译时确定的类型,执行效率高但缺少灵活性。
  • 动态绑定发生在运行时,通过虚函数表查找具体的实现,支持多态,但有一定的运行时开销。

🎱六、多态的常见面试题

inline 函数可以是虚函数嘛?
答:可以,不过编译器会忽略 inline 属性,这个函数就不再是 inline,因为虚函数要放进虚函数表中。

● 静态成员可以是虚函数嘛?
答:不能,因为静态成员函数没有 this 指针,使用“类型::成员函数”的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。

● 构造函数可以是虚函数嘛?
答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。

● 析构函数可以是虚函数嘛?什么场景下析构函数是虚函数?
答:可以,并且最好把基类的析构函数定义成虚函数。

● 对象访问普通函数快还是虚函数更快?
答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数更快。因为构成多态,运行时调用虚函数要到虚函数表中去查找。

● 虚函数表是在什么阶段生成的?存在哪?
答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)。


结语

通过对C++多态性的深入了解,我们可以更好地编写具有高扩展性和灵活性的代码。多态不仅让代码变得更具适应性,还能够减少代码重复,提高维护效率。在未来的开发中,合理运用多态将为我们的项目带来显著的提升。希望本文的讲解能够帮助读者在实践中更好地掌握这一重要概念。
在这里插入图片描述

今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,17的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是17前进的动力!

在这里插入图片描述

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

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

相关文章

spring底层原理

本文参考黑马程序员的spring底层讲解&#xff0c;想要更详细的可以去看视频。 另外文章会每日更新&#xff0c;大概持续1个月&#xff01;&#xff01;&#xff01;每天更新一讲 这部分比较抽象&#xff0c;要经常复习&#xff01;&#xff01;&#xff01; 一、BeanFactory与A…

【JPCS独立出版 | 福州大学主办 | 有确定的ISSN号】第三届可再生能源与电气科技国际学术会议(ICREET 2024)

第三届可再生能源与电气科技国际学术会议&#xff08;ICREET 2024&#xff09; 2024 3rd International Conference on Renewable Energy and Electrical Technology ICREET 2024已成功申请JPCS - Journal of Physics: Conference Series (ISSN:1742-6596) 独立出版&#xf…

引领智慧文旅新纪元,开启未来旅游新境界

融合创新科技&#xff0c;重塑旅游体验&#xff0c;智慧文旅项目定义旅游新未来 在全球化的浪潮中&#xff0c;旅游已成为连接世界的重要纽带。智慧文旅项目&#xff0c;不仅仅是一次技术的革新&#xff0c;更是对旅游行业未来发展的一次深刻思考。信鸥科技通过运用云计算、大数…

Vue3动态组件原来是这样

什么是Vue3动态组件 在Vue3中&#xff0c;动态组件简单来说就是根据不同的条件进行不同组件的渲染&#xff0c;可以联想一下在前端中常用到的动态样式 基本使用 在Vue3中&#xff0c;动态组件的使用也是非常简单的&#xff0c;只需要使用<component>标签&#xff0c;并…

WPFDeveloper正式版发布

WPFDeveloper WPFDeveloper一个基于WPF自定义高级控件的WPF开发人员UI库&#xff0c;它提供了众多的自定义控件。 该项目的创建者和主要维护者是现役微软MVP 闫驚鏵: https://github.com/yanjinhuagood 该项目还有众多的维护者&#xff0c;详情可以访问github上的README&…

Redis 高可用:从主从到集群的全面解析

目录 一、主从复制 (基础)1. 同步复制a. 全量数据同步b. 增量数据同步c. 可能带来的数据不一致 2. 环形缓冲区a. 动态调整槽位 3. runid4. 主从复制解决单点故障a. 单点故障b. 可用性问题 5. 注意事项a. Replica 主动向 Master 建立连接b. Replica 主动向 Master 拉取数据 二、…

STM32传感器模块编程实践(八) HX711压力传感器称重模块简介及驱动源码

文章目录 一.概要二.HX711主要技术指标三.HX711模块参考原理图四.模块接线说明五.模块工作原理介绍六.模块通讯协议介绍七.STM32单片机与HX711模块实现重量测量实验1.硬件准备2.软件工程3.软件主要代码4.实验效果 八.小结 一.概要 电子秤是将检测与转换技术、计算机技术、信息…

Python网络爬虫从入门到实战

目录 引言 一、网络爬虫的概念 二、 网络爬虫的基本工作流程 &#xff08;一&#xff09;过程&#xff1a; &#xff08;二&#xff09;安装requests模块和beautifulsoup4模块 &#xff08;三&#xff09;requests库的使用 1、requests库的基本介绍 2、导入requests库的…

一款零依赖、跨平台的流媒体协议处理工具,支持 RTSP、WebRTC、RTMP 等视频流协议的处理

大家好&#xff0c;今天给大家分享一款功能强大的流媒体协议处理工具go2rtc&#xff0c;支持多种协议和操作系统&#xff0c;具有零依赖、零配置、低延迟等特点。 项目介绍 go2rtc可以从各种来源获取流&#xff0c;包括 RTSP、WebRTC、HomeKit、FFmpeg、RTMP 等&#xff0c;并…

学习文档10/16

MySQL 字符集&#xff1a; MySQL 支持很多种字符集的方式&#xff0c;比如 GB2312、GBK、BIG5、多种 Unicode 字符集&#xff08;UTF-8 编码、UTF-16 编码、UCS-2 编码、UTF-32 编码等等&#xff09;。 查看支持的字符集 你可以通过 SHOW CHARSET 命令来查看&#xff0c;支持…

ARINC 429总线协议

一、概述 ARINC 是美国航空无线电公司英文字头的缩写&#xff0c; 该公司1977年7月21日出版了“ARINC 429规范”一书&#xff0c;429规范就是飞机电子系统之间数字式数据传输的标准格式&#xff0c;在飞机上使用429总线的电子设备均应遵守这个规范&#xff0c;这样才能保证电子…

Redis应用高频面试题

Redis 作为一个高性能的分布式缓存系统,广泛应用于后端开发中,因此在后端研发面试中,关于 Redis 的问题十分常见。 本文整理了30个常见的 Redis 面试题目,涵盖了 Redis 的源码、数据结构、原理、集群模式等方面的知识,并附上简要的回答,帮助大家更好地准备相关的面试。 …

2024年【N2观光车和观光列车司机】及N2观光车和观光列车司机模拟考试题

题库来源&#xff1a;安全生产模拟考试一点通公众号小程序 N2观光车和观光列车司机考前必练&#xff01;安全生产模拟考试一点通每个月更新N2观光车和观光列车司机模拟考试题题目及答案&#xff01;多做几遍&#xff0c;其实通过N2观光车和观光列车司机操作证考试很简单。 1、…

LabVIEW提高开发效率技巧----用户权限控制

在LabVIEW开发中&#xff0c;用户权限控制是一个重要的设计模块&#xff0c;尤其在多用户系统中&#xff0c;它可以确保数据安全并控制不同用户的操作权限。为了实现用户权限控制&#xff0c;可以通过角色与权限管理模块来进行设计和实施。以下将从多个角度详细说明如何在LabVI…

Sentinel 快速入门

前置推荐阅读:Sentinel 介绍-CSDN博客 前置推荐阅读&#xff1a;Nacos快速入门-CSDN博客 快速开始 欢迎来到 Sentinel 的世界&#xff01;这篇新手指南将指引您快速入门 Sentinel。 Sentinel 的使用可以分为两个部分: 核心库&#xff08;Java 客户端&#xff09;&#xff1a…

新版vs code + Vue高亮、语法自动补全插件

vs code 版本或及以上 安装以下三个插件插件 Vetur Vue语法支持。包括语法高亮、语法代码提示、语法lint检测 ESLint语法纠错 Prettier 2.左下角设置 3.进行配置 配置内容&#xff1a; {"editor.fontSize": 20,"window.zoomLevel": 1,"workben…

Windows】【DevOps】Windows Server 2022 采用WinSW 启动一个会创建新的控制台程序窗口的程序行为分析

WinSW使用参考 【Windows】【DevOps】Windows Server 2022 采用WinSW将一个控制台应用程序作为服务启动&#xff08;方便&#xff09;-CSDN博客 源码 调整ConsoleApp1程序源代码如下&#xff1a; using System; using System.Diagnostics; using System.IO; using System.R…

软件生存期和软件过程

软件生存周期 软件生存周期&#xff08;Software Life Cycle&#xff09;&#xff1a;一个软件项目从问题提出开始&#xff0c;直到软件产品最终退役&#xff08;废弃不用&#xff09;为止。 软件生存周期分为三个时期&#xff1a;计划、开发和维护 整个软件生存周期划分为多…

王爽汇编语言第三版实验3

实验任务 将下面的程序保存为t1.asm&#xff0c;将其生成可执行文件t1.exe 用Vscode编写源程序t1.asm 用脚本一键生成可执行文件t1.exe 成功运行 查看资源管理器&#xff0c;成功生成T1.obj与t1.exe文件‘ 用debug跟踪t1.exe的执行过程&#xff0c;写出每一步执行后&#xff…

大模型生图安全疫苗注入——进阶解决方案与系统优化(DataWhale组队学习)

引言 大家好&#xff0c;我是GISer Liu&#x1f601;&#xff0c;上篇博客中&#xff0c;我们基于DataWhale 2024年10月大模型生图安全疫苗注入赛道的任务&#xff0c;介绍了攻击与防御的基本策略&#xff0c;如通过上下文稀释法、隐喻替换等绕过检测机制&#xff0c;并提出了多…