当前位置: 首页 > news >正文

C++类对象多态底层原理及扩展问题

文章目录

  • 前言
  • 1. 现象及剖析
    • 1.1 现象
    • 1.2 虚函数指针和虚函数表
    • 1.3 多态实现
      • 1.3.1 多态的两个条件剖析
        • 虚函数下的继承模型
      • 1.3.2 多态调用的消耗
        • 不同调用方式的汇编对比
  • 2. 虚函数表的存放位置
  • 3. 拓展问题
    • 3.1 派生类自己虚函数的存放位置
    • 3.2 多继承下的虚函数的细节问题

前言

这篇文章,小编会和大家一起探讨C++类对象多态的实现原理和其扩展问题。

  • 注:本文章环境vs2022 x86环境下。每个编译器可能实现有所差异,但是殊途同归!

1. 现象及剖析

1.1 现象

例1:

#include<iostream>
using namespace std;
class Base
{
public:virtual void func(){cout << "Base::func" << endl;}
};int main()
{cout << sizeof(Base) << endl;return 0;
}

上面代码的运行结果是多少呢?

在这里插入图片描述
出乎意料的是:作为一个没有成员属性的类,这个类占有4个字节的空间

  • 下面我们打算来看看监视窗口:
    在这里插入图片描述

  • 现象:

    声明了虚函数的类,其中多了一个字段,一个名为_vfptr的字段!

1.2 虚函数指针和虚函数表

  • _vfptr

    全称为:Virtual Function Pointer(虚函数指针)

    其含义也不言而喻:指向虚函数(…)的一个指针

来看例2:

#include<iostream>
using namespace std;
class Base
{
public:virtual void func1(){cout << "Base::func1" << endl;}virtual void func2(){cout << "Base::func2" << endl;}virtual void func3(){cout << "Base::func3" << endl;}
};
int main()
{Base b;cout << sizeof(b) << endl;return 0;
}

上面代码在例1的基础上扩展了func2func3

仍然打开监视窗口:

在这里插入图片描述
我们从监视窗口可以得到以下结论

  1. _vfptr是一个指针。这个指针指向了一个指针数组

  2. 这个数组中的元素都是函数指针,每一个指针都指向的是被声明的虚函数

  3. 这个数组的元素个数:当前被声明为虚函数的函数个数 + 1。在vs下最后一个元素是nullptr来标记结束。但是在g++编译器下并不是!!

  4. 来看内存中:
    在这里插入图片描述

下面正式给出定义:

  • _vfptr:被称为虚函数表指针。这个指针是一个函数指针数组指针,指向的是虚函数表的首地址

  • 指向的那个数组被称为:虚函数表。实际上里面存放就是:作为该类的虚函数方法的指针。如果是普通函数肯定不会进入该表。

通过以上的了解我们可以大致得到调用一个虚函数的过程

(0. 指针或者引用。如果是对象调用:那么就采用会在编译期决定调用,后面验证)

  1. 找到虚函数表指针_vfptr
  2. 通过虚函数指针找到虚函数表_vftable
  3. 通过对应的信息,找到调用函数的指针,再调用函数!

至此,我们已经了解了多态的底层原理的前置知识,接下来让我们一起探讨多态到底是如何进行的:

1.3 多态实现

了解上面虚函数的调用过程后,我们是否可以设想一下,当完成重写的虚函数是如何完成多态的呢?
是否只需要将对应位置的函数指针的值修改为子类的虚函数是否就可以了?

考虑下面继承场景:

例3:

#include<iostream>
using namespace std;
class Base
{
public:virtual void func1(){cout << "Base::func1" << endl;}virtual void func2(){cout << "Base::func2" << endl;}virtual void func3(){cout << "Base::func3" << endl;}
};class Derived : public Base
{
public:virtual void func1() //完成重写{cout << "Derived::func1" << endl;}virtual void func2(){cout << "Derived::func2" << endl;}
};int main()
{Base b;Derived d;return 0;
}

注意:上面代码的派生类只重写了函数func1,和func2.

  • 我们仍然通过监视窗口观察:
    在这里插入图片描述
    通过对比相同部分和不同部分,我们可以得到以下结论

    1. 基类和派生类的虚函数表不是同一张表
    2. 派生类中重写的虚函数都替换了原来基类中的虚函数
    3. 派生类中没有被重写的虚函数仍然是原来基类中的虚函数

1.3.1 多态的两个条件剖析

至此我们再来看,类对象实现多态的两个条件

  1. 基类指针或者引用,指向派生类

  2. 被调用的函数一定是虚函数,派生类必须对虚函数进行重写

下面进行解析:

  1. 条件一剖析

    为什么需要基类的指针或者引用呢?根据上面的现象,我们得知:不同的类是有自己的虚函数表的!而指针和引用可以做到直接指向实体

例4:

#include<iostream>
using namespace std;
class Base
{
public:virtual void func1(){cout << "Base::func1" << endl;}virtual void func2(){cout << "Base::func2" << endl;}virtual void func3(){cout << "Base::func3" << endl;}
};class Derived : public Base
{
public:virtual void func1() //完成重写{cout << "Derived::func1" << endl;}virtual void func2(){cout << "Derived::func2" << endl;}
};
int main()
{Derived d;Base* ptr = &d; //指针//Base& ref = d; //引用ptr->func1();ptr->func2();ptr->func3();return 0;
}

上面代码给出了完整的示例

虚函数下的继承模型

来看存储模型:

  • Base
    在这里插入图片描述

  • Derived
    在这里插入图片描述

  • 当发生基类指针或者引用指向派生类时
    在这里插入图片描述
    那么很显然地:当我们使用Base的指针(引用)的时候,由于赋值兼容的规则存在,我们的指针指向的那片实体中的_vfptr仍然是我们Derived的虚函数指针,所以采用虚函数调用的时候看见的虚函数就是Derived的虚函数!!!
    同理:不同派生类重写虚函数的方法不同虚函数表的内容就不同,那么同类型的指针看到的虚函数的方法就不同。这就产生了多态!!

    • 为什么不能是基类的对象呢?

      • 如果采用基类的对象,那么就会导致:得到的对象中的虚函数表仍然是基类对象的。因为在发生这样的赋值兼容的时候,我们的基类会调用自己的构造函数而虚函数表的初始化给对象是在构造时期完成
      • 我们不可能让编译器识别到我们是在用一个派生类赋值基类从而将派生类的虚函数表交给基类,这样会破坏本来的继承体系结构。
  1. 条件二剖析

    • 为什么要虚函数?

      • 需要进入虚函数表!
    • 为什么需要满足重写三同条件?

      • 编译器需要一个标准来将对应基类中的虚函数表对应的函数指针替换为派生类的函数指针,这个标准就是由三同来决定的!

1.3.2 多态调用的消耗

实际上多态的调用不会在任何时候发生!

多态的调用是会影响运行时间的。消耗来源于

  • 查找虚函数指针
  • 虚函数表

这些操作在汇编层面都是有开销的。所以编译期不会将任何的调用都会采用类似于多态的调用方式,这对性能是有负担的!

  • 事实:编译器在采用类对象调用的时候,即使调用虚函数也不会采用类似于多态的调用方式
不同调用方式的汇编对比

例5:

#include<iostream>
using namespace std;
class Base
{
public:virtual void func1(){cout << "Base::func1" << endl;}virtual void func2(){cout << "Base::func2" << endl;}void func3() //让func3成为一个普通函数{cout << "Base::func3" << endl;}
};class Derived : public Base
{
public:virtual void func1() //完成重写{cout << "Derived::func1" << endl;}virtual void func2(){cout << "Derived::func2" << endl;}
};int main()
{Derived d;Base b = d;Derived *ptr_d = &d; //d的指针Base* ptr = &d; //指针//对比一:对象调用虚函数d.func1();  //d对象调用b.func1();  //b对象调用//对比二:指针调用虚函数ptr_d->func1();ptr->func1();//对比三:指针调用普通函数ptr_d->func3();ptr->func3();return 0;
}
  • 来看如下的汇编代码:
    在这里插入图片描述

  • 事实不管是子类还是父类的指针/引用调用虚函数代价远远大于对象调用虚函数或者指针调用普通函数

  • 现象能直接确定调用的函数编译器绝对不会采用多态的方式进行调用

2. 虚函数表的存放位置

小编直接告诉大家:在VS下存放在常量区

下面我们将写一段代码进行验证!

我们采用的方式是直接打印虚函数指针指向的地址,然后打印该地址和各个区域的变量进行比较!

例6:

#include<iostream>
using namespace std;
class Base
{
public:virtual void func1(){cout << "Base::func1" << endl;}
};class Derived : public Base
{
public:virtual void func1() //完成重写{cout << "Derived::func1" << endl;}
};int global = 0;
int main()
{Base b;Derived d;int _vftable_b = *(int*)&b; //拿到b对象的低四个字节的值,作为整型拿到int _vftable_d = *(int*)&d;printf("Base _vftable: %p\n", (void*)_vftable_b);printf("Derived _vftable: %p\n\n", (void*)_vftable_d);//从上往下int stack = 0;printf("Stack address: %p\n", &stack); //栈区int *heap = new int(0);printf("Heap address: %p\n", heap); //堆区printf("Global address: %p\n", &global); //全局数据区static int _static = 0;printf("Static address: %p\n", &_static); //静态数据区const char* str = "hello";printf("char const address: %p\n", str); //字符常量区printf("code address: %p\n", main); //代码区delete heap;return 0;
}

上面也是相当于介绍了如何查看每个区域的地址。

在这里插入图片描述

上面图片的结果告诉我们这个虚函数表的地址比较接近字符常量区
我们便有理由相信:虚函数表被存放在字符常量区。
同时:上面的堆区地址空间貌似大于栈区地址空间,这个我们不必关系!

3. 拓展问题

3.1 派生类自己虚函数的存放位置

我们一定会好奇,如果派生类自己声明了一个基类没有的虚函数,那么这个虚函数被存放在哪里呢?

例7:

#include<iostream>
using namespace std;
class Base
{
public:virtual void func1(){cout << "Base::func1" << endl;}virtual void func2(){cout << "Base::func2" << endl;}
};class Derived : public Base
{
public:virtual void func1() //完成重写{cout << "Derived::func1" << endl;}virtual void func2(){cout << "Derived::func2" << endl;}virtual void func3() //func3是派生类自己声明的虚函数。{cout << "Derived::func3" << endl;}
};

上面的func3会被存储到哪里呢?

下面我们进行验证:

续例7代码

typedef void(*fptr)(); //类型重命名—>利用虚函数都是同种类型void print(fptr* table, int n)
{for (int i = 0; i < n; ++i){printf("func[%d]: %p say: ", i + 1, (void*)table[i]); //打印的地址为函数的地址table[i](); //调用该函数}
}int main()
{Base b;Derived d;//打印基类表:printf("Base _vftable: %p\n", (void*)*(int*)&b);print((fptr*)*(int*)&b, 2);//(fptr*)*(int*)&b —> (int*)&b强转为int*,解引用拿到第四个字节,再强转为(fptr*)//打印派生类表printf("Derived _vftable: %p\n", (void*)*(int*)&d);print((fptr*)*(int*)&d, 3);return 0;
}

小编在测试的时候这个虚函数表出现了一些问题,所以小编采用直接传入个数。

  • 运行结果:
    在这里插入图片描述

  • 结论

    1. 基类和派生用不同的虚函数表
    2. 派生类自己的虚函数会依序放在自己的虚函数表后面。

3.2 多继承下的虚函数的细节问题

接下来我们会探讨多继承下的虚函数问题,小编会根据现象抛出几个问题!

例8:

#include<iostream>
using namespace std;
class Base1
{
public:virtual void func1(){cout << "Base1::func1" << endl;}virtual void func2(){cout << "Base1::func2" << endl;}
};class Base2
{
public:virtual void func1(){cout << "Base2::func1" << endl;}virtual void func2(){cout << "Base2::func2" << endl;}
};class Derived : public Base1, public Base2
{
public:virtual void func1() //完成重写{cout << "Derived::func1" << endl;}virtual void func3() //func3是派生类自己声明的虚函数。{cout << "Derived::func3" << endl;}
};int main()
{Base1 b1;Base2 b2;Derived d;return 0;
}

上面代码:Derived继承Base1,Base2,同时重写了函数func1和声明定义自己的虚函数func3()

  • 现象
    在这里插入图片描述

不再验证:多继承下派生类的虚函数表有多个且每个都是独立于基类的

  • 问题

    1. Derived::func1为什么在Base1::_vftableBase2::_vftable中的地址不同?难道这是两个函数吗?
    2. Derived::func3函数没有在任何一个_vftable中出现,它应该在哪一个_vftable中呢?
  • 验证

    我们仍然可以通过上面例7的方式进行验证

例9:

//前置命名继承例8
typedef void(*fptr)();void print(fptr* table)
{for (int i = 0; table[i] != nullptr; ++i){printf("func[%d]: %p say: ", i + 1, (void*)table[i]);table[i](); //调用该函数}
}int main()
{Base1 b1;Base2 b2;Derived d;//打印基类表1:printf("Base _vftable: %p\n", (void*)*(int*)&b1);print((fptr*)*(int*)&b1);cout << endl;//打印基类表2:printf("Base _vftable: %p\n", (void*)*(int*)&b2);print((fptr*)*(int*)&b2);cout << endl;//打印派生类表1:printf("Derived _vftable1: %p\n", (void*)*(int*)&d);print((fptr*)*(int*)&d);//打印派生类表2:printf("Derived _vftable2: %p\n", (void*)*(int*)(Base2*)&d);print((fptr*)*(int*)(Base2*)&d);return 0;
}

编译器(VS)个性化行为,小编在测试这里的时候,每一个虚函数表的最后一个位置都被设置为了nullptr方便了测试

  • 结果
    在这里插入图片描述

  • 结论

    1. Derived::func1是一个函数并且只能是一个函数。为什么_vftable中的地址不同呢?(本来小编打算和大家看汇编的,但是编译期封装太厉害了,看不了一点,小编就口头叙述)

      是这样的:我们都是应该了解到Base2Derived中是有偏移量的。所以当使用Base2指向一个Derived对象的时候,实际上的调用该函数的时候采用的传入的this指针是不恰当的,此时this指针的地址是指向Base2的,所以编译器会利用汇编调整Base2this指针到正确的位置,这就导致了地址不同但是经过调整过后的地址是相同的。

    2. func3在第一个虚函数表后面。因为这样不用找偏移量减少开销。

完。

  • 希望这篇文章能够帮助到正在学习多态的你!!
http://www.dtcms.com/a/271949.html

相关文章:

  • Python-GEE遥感云大数据分析与可视化(如何建立基于云计算的森林监测预警系统)
  • Yolov模型参数对比
  • Docker的/var/lib/docker/目录占用100%的处理方法
  • 变压器初级(原边)和次级(副边)的感应电动势、电压方向如何标注?
  • 安卓应用启动崩溃的问题排查记录
  • 《Effective Python》第十三章 测试与调试——使用 Mock 测试具有复杂依赖的代码
  • 【笔记分享】集合的基数、群、环、域
  • Python毕业设计232—基于python+Django+vue的图书管理系统(源代码+数据库)
  • EXCEL_单元格中图片调整代码留存
  • 什么是Kibana
  • 【C++】第十四节—模版进阶(非类型模版参数+模板的特化+模版分离编译+模版总结)
  • 保姆级搭建harbor私有仓库与docker-ce教程与使用教程
  • 机器学习基础:从理论到实践的完整指南
  • 解锁医疗新视界:医患共决策时间轴AI可视化工具
  • Linux面试问题-软件测试
  • Web前端:table标签的用法与属性
  • 酒店IPTV系统:重塑数字化时代的宾客体验生态
  • 图计算怎么用?从数据到关系的魔力
  • 实时风险监控系统工具设计原理:2025异常检测算法与自动化响应机制
  • 深度学习中的激活函数
  • window显示驱动开发—XR_BIAS 和 BltDXGI
  • RISC-V:开源芯浪潮下的技术突围与职业新赛道 (二) RISC-V架构深度解剖(上)
  • 【网络】Linux 内核优化实战 - net.ipv4.tcp_moderate_rcvbuf
  • 文件系统子系统 · 核心问题问答精要
  • Redis持久化机制深度解析:数据安全的双保险
  • 机器学习12——支持向量机中
  • ElementUI:高效优雅的Vue.js组件库
  • Rust 简介
  • 工厂的神经进化—工业智能体重塑制造本质的技术革命与文明挑战
  • 【Linux】Rocky Linux 清华镜像源安装 GitLab