我们已经表明,非虚类的对象实例不包含虚指针,编译器在编译阶段也没有为非虚类没有构建虚表.而本篇我们会从简单的单继承链分析虚类中虚表构造过程和内存布局。这一切假定你有如下基础

  • 对gdb调试器使用有一个比较全面的了解。
  • 对栈内存管理和堆内存管理有所了解。
  • 类/结构体的内存对齐操作有所了解
  • 对类的继承特性有所了解。

本文是从编译器的角度结合GDB调试器来理解虚表的创建过程,而不是像绝大部分份文章逼格高一般抛一大堆和虚成员函数相关的理论,而是从更务实从内存分析的角度讨论为什么在使用虚函数过程中需要虚指针和虚表。

明确虚函数的目的

我们要明确在类继承中使用虚函数的目的。在开发需求中,我们旨在让调用层代码保留相同的公共接口,因为调用层代码不需要关心被调用层的功能实现细节。那么虚函数就是**让不同的派生类将继承自父类的同一个虚成员函数(接口)的根据派生类的功能需求进行不同行为的实现,以此达到不同的派生类提供调用层的决策代码同一个函数接口的不同实现版本,从而保持对调用层代码逻辑无需变动,而且隐藏了同一个函数接口的不同版本的实现细节。

示例导入

#include 

在这里我们可以尝试打印*tm1,*tm2,*pp1和 *pp2,如下图所示

图1

从上图的输出中,我们要引入一个虚指针(_vptr)的概念

  • 虚类的对象初始化时会自动创建一个隐藏的数据成员_vptr指针指向虚表,此前声明该虚类的对象编译器也创建了该虚类的虚表
  • 后续同一个虚类所有对象实例共享同一个虚表,截图中的tm1和tm2的隐藏指针指向同一个地址0x400cf0,pp1和pp2的虚表是同理如是.
  • 虚表表当前的地址是一个已经+16字节偏移后的内存地址

另外我们还打印出所有Teamer对象和Employee对象,他们获得内存分配都为16个字节。因此我们不妨在查看我们刚才实例化的所有对象。

查看对象的内存数据

现在我们不妨看看刚才实例化的各个对象的内存布局,使用x命令,因为每个对象的堆内存块尺寸都为16个字节,因此我们使用x/16xb将他们的内存数据转存到屏幕中,如下图所示。

  • _vptr在虚类的对象中就占用8个字节,该_vptr存储了指向该虚类的虚表的内存地址值。
  • iService是一个bool类型仅占用1个字节,另外高位的3个字节空间由于内存对齐的原因都以0填充。
  • idNo是一个4字节的int类型,对于Teamer的对象0x03e8的值就是十进制的1000,对于Employee的对象这里的4个字节由于按8字节内存对齐,仅作为填充位之用。

备注:这里我们回顾了内存对齐的相关知识。

探究虚表的内存布局

我们从前文打印的第一个Teamer对象 tm1的信息中,可以知道其_vptr指针指向0x400cf0,你是否发现“<虚表 for Teamer+16>”的字样。这个其实表明0x400cf0是已经+16字节偏移后的地址值

我们已经在前文提到在首个的新的虚类对象且初始化时,编译器会该类动态创建一个虚表,但为什么每个不同虚类的虚表都要额外偏移16个字节呢? 在本示例中,我们不妨减去这个偏移量,也即得到0x400ce0这个地址,然后使用x命令,该命令将300字节的内存数据转储到屏幕。

(gdb) x/300xb 0x400ce0

上面的命令以十六进制格式打印300字节,从0x400d00开始。 为什么要这个地址? 因为在上面我们看到类Teamer的虚表指针指向0x400d10,该地址已经偏移0x10个字节,即减去0x10就能得到原本虚表的地址。

下图中_ZTV是虚表的前缀,_ZTS是type-string(名称)的前缀,_ZTI是type-info的前缀。

我们从下图可以得到很多虚表的内存细节。

  • 每个Teamer虚表存在一个虚表表头占用16个字节,前8个字节0填充,后8个字节包含一个指向与该类对应的typeinfo表的地址(没必要理会,只需知道他们占用16个字节即可)。
  • 每个typeinfo表的前面也包含一个typeinfo name的信息(没必要理会,l罗列出来只是让你知道有这么一个描述字段)
  • 绿色的部分就是不同虚类的虚表,虚表就是包含了该类定义的所有virtual成员函数的函数地址。

我们可以从上图中绿色部分的内存数据中即每行冒号之后的8字节空间提取有用的数据,例如

  • 0x400cf0到0x400d08的内存区域中的内存数据,对应的是Teamer类类虚表中virtual成员函数地址的条目。
  • 0x400d30到0x400d40的内存区域中的内存数据,对应的是Employee类虚表中virtual成员函数地址的条目

我们这两个内存区域的数据分别整理成如下表,注意写本文时使用的是CentOS 7的x64小端机器,因此读取图中的内存数据时,是从右向左读取,因此整理下表每个内存位置对应的值,并且分别是有info symbol命令 再次查看每个内存位置的值对应的具体含义。

结合整理如下表可知:虚表中的地址值分别代表虚拟类中对应虚函数的地址

虚表内存布局

更简单获取虚类的虚表条目的另外一条命令就是info vtbl,这里就不展示了,我们看到上图的虚表中的虚解构函数都成对地出现,我们先暂不讨论为什么会这样,因为我日后会令起一文再阐述该问题。

  • 第一个解构函数,称为完整对象解构函数(complete object destructor),执行销毁操作时无需在对象上调用delete()。
  • 第二个解构函数称为删除析构函数( deleting destructor),在销毁对象后调用delete()。
  • 两者都摧毁了任何虚拟基类.一个独立的非虚函数称为基类对象解构函数(base object destructor)执行对象的销毁操作,但不执行其虚拟基类子对象的销毁操作,并且不调用delete()。
  • 非虚函数是静态绑定的(编译时绑定),因此在虚表中不存在任何非虚函数。

虚表构建细节

我们仍然使用上文的调用示例代码

int 

从上面的示例代码中我们已经知道

  • 首先,每个使用虚函数的类或从基类派生的虚函数的类都被赋予自己的虚表。该表只是C++编译器在“编译时”设置的静态数组。虚表包含当前类中所有虚成员函数的函数指针的相关条目,那么填入虚表的虚成员函数指针有四种来源。
  1. 派生类本身原创定义的虚函数,例如上图的Teamer::info()函数。
  2. 从父类继承的虚成员函数,且该函数未被派生类重写
  3. 从父类继承的虚成员函数,但该函数已被派生类重写。值的注意的是,虚表的虚成员函数指针始终指向该类中的最新的派生版本的虚成员函数。理解这句话非常重要!举个例子Teamer类从Employee类继承了add_salary()函数,但Teamer类重写(注意:不是重载)了该add_salary()函数,对于Teamer虚表来说,填入表中的add_salary()函数的地址是0x400b3e,而不是父类的add_salary()的地址0x400ab4。
  4. 若当前类定义了虚解构函数,那么该类的虚解构函数的解构函数的地址会“成双成对”地填入虚表中。按照惯例,由于定义类时优先定义解构函数,再实现其他成员函数,因此该虚解构函数对的地址通常会出现在表中头两行,上图是很好的例证。

然后,当类对象实例化时会将*_vptr设置为指向该类的虚表。例如,当创建类型为Teamer的对象时*_vptr设置为指向Teamer的虚表。构造类型为Employee对象时,*_vptr设置为指向的Employee的虚表。我们这里先不讨论virtual解构函数,目前只针对其他虚函数进行讨论。

  • 对于基类Employee类型的对象,它只能访问Employee的成员,Employee类型的对象无法访问Teamer类的的成员函数,因为地址为0x400ab4的地址仅指向Employee::salary()
  • 同理,Teamer类型的对象也只能访问Teamer::add_salary()和Teamer::info()。

多态:

理解完虚表的内存布局和构建细节之后,这个时候才合适抛出一些理论性的东西,多态是面相对象语言一个重要的特性,多态即让同一个用户自定义类型的对象在不同的决策时机呈现不同的行为实现
C++中的多态就分为

  • 编译时多态:就包括类成员函数重写operator函数重载
  • 运行时多态:C++编译器在运行时,根据决策逻辑判断传入所对象的类型,然后查找并根据该类虚表中的虚成员函数的地址,以进行动态调度目标类中的成员函数。

小结

我们在本篇的最后引入了C++多态的概念,我们会在后续的文章会详细阐述运行时多态的实现技术,而虚函数是C++实现运行时多态的基础。而实现运行时动态调度函数的驱动载体是虚指针虚表,因此本篇着重介绍包含虚成员函数的类创建虚表的细节和内存布局。

基类成员的public访问权限在派生类中变为_第17篇:C++继承中虚表的内存布局相关推荐

  1. 基类成员的public访问权限在派生类中变为_C++ 派生类的构造函数(学习笔记:第7章 06)...

    派生类的构造函数[1] 默认情况 基类的构造函数不被继承; 派生类需要定义自己的构造函数. C++11规定 可用using语句继承基类构造函数. 但是只能初始化从基类继承的成员. 派生类新增成员可以通 ...

  2. 基类成员的public访问权限在派生类中变为_C++ 派生类的构造函数举例:继承+组合(学习笔记:第7章 07)...

    派生类构造函数举例[1] 例7-4 派生类构造函数举例 #include 对程序的说明:构造函数的执行顺序 1.调用基类构造函数. 顺序按照它们被继承时声明的顺序(从左向右):Base2, Base1 ...

  3. java 类中有几种访问权限_类中成员的访问权限_Java语言程

    类中成员的访问权限_Java语言程 4.7.2 类中成员的访问权限 Java将类中成员(成员变量和成员方法)的访问权限(可见性)划分为4种情况,按照访问权限的范围大小从小到大列出如下. ·私有(pri ...

  4. C++知识点11——this指针,const成员函数,访问权限控制

    1.this指针 每个类都有this指针,this指针指向this指针指向的是类的对象本身 class A { public:A() {}~A() {}void func() {cout<< ...

  5. java变量访问权限_JAVA成员变量的访问权限

    成员变量的访问权限 我看到很多地方谈到关于Java里变量的访问权限问题. 很多地方认为对于默认修饰符的理解是这样的: 不写时默认为friendly 但就我所知Java里没有 friendly这一关键字 ...

  6. gitlab添加成员开通项目访问权限

    gitlab添加成员开通项目访问权限 项目下寻找Settings按钮,选择Members 选择Members后可以看到下面图片页面,输入用户,赋予用户权限 权限分为4种: Guest Reporter ...

  7. 用电脑回收站的数据保护机制:理解python类成员保护和访问限制,及编程思想

    类成员保护和访问限制有什么用 python类的成员可以通过"成员保护和访问限制的机制"非常大程度地禁止类实例对象对其进行直接访问和直接的修改,只能通过类实例方法来获取.访问或修改. ...

  8. C++的继承和派生(一)父类和派生类(子类)的介绍以及派生类的访问控制

    在介绍继承和派生之前,先看下面一段代码 class Student1 {public:int m_socre;int m_age;void speak() {cout << "S ...

  9. C++ 在派生类中使用using声明改变基类成员的可访问性

    通过在类的内部使用using声明语句 , 我们可以将该类的直接或间接基类中的任何可访问成员标记出来 (只限于非私有成员) .using声明语句中名字的访问权限由该using声明语句之前的访问说明符来决 ...

最新文章

  1. python 命令行参数-Python 中最好用的命令行参数解析工具
  2. BIO-NIO-AIO
  3. 数据结构(一)线性表链式存储实现
  4. sql 计数_SQL不同,SQL计数不同,SQL选择不同
  5. 从键盘输入n个整数,求它们的最小公倍数
  6. 信号处理第一式——离散信号序列的基本运算及MATLAB实现
  7. matlab对控制系统进行时域和频域联合分析
  8. Android自定ViewGroup实现流式布局
  9. Deepin15.3 安装firefox flash插件
  10. Downward API
  11. 央企招聘:中储粮集团2023公开招聘公告(校招+社招,共700人)
  12. 从零开发短视频电商 隐藏业务ID以及缩短业务链接
  13. 禾匠二开系列之兑换码禁用以后启用功能
  14. python 登录新浪微博_Python 模拟登录新浪微博
  15. VS code编辑器出现open a floder or workspace... (File -> Open Folder)错误
  16. 入院前、入产房前、分娩前物品准备
  17. 解决Chrome和Chrome内核edge浏览器在启用硬件加速后颜色异常的问题
  18. Swift初步探究-正确导入第三方库
  19. 优化AWS使用成本系列之预留实例(RI)为您提供大幅折扣
  20. 微信小程序 - 分享商品海报

热门文章

  1. C#LeetCode刷题之#367-有效的完全平方数(Valid Perfect Square)
  2. C#开发笔记之06-为什么要尽可能的使用尾递归,编译器会为它做优化吗?
  3. 面向对象 solid_用简单的英语解释面向对象程序设计的SOLID原理
  4. 华为面试分配_什么时候不做面试分配
  5. 云服务器 存放 文件夹,云服务器 存放 文件夹
  6. java zip压缩_压缩工具
  7. 让你python代码更快的3个小技巧
  8. threading注意点(python 版)
  9. .NET设计模式(1): 简单工厂模式
  10. .NET方法演化史 从Delegate到Lambda再到LINQ