C++ | 虚函数表内存布局

摘要:
存在虚函数的类会在类的数据成员中生成一个虚函数指针vfptr,而vfptr指向了一张表。其中虚函数表由三部分组成,分别是RTTI、偏移及虚函数的入口地址。/*Deriver类内存布局*/classDeriversize:+---0|+---0||{vfptr}4||ma|+---8|mb+---Deriver::$vftable@:|&Deriver_meta|00|&Deriver::Show发现:我们在源代码中并没有把Deriver::Show()声明为虚函数,但在Deriver的类内存布局中也存在{vfptr}指针。意思是,在派生类中同名同参的函数即使没有virtual关键字声明也默认是虚函数,也会产生一张虚表。因此,通过这一步虚表合并最终得到了Deriver了的12字节内存布局。

虚表指针

虚函数有个特点。存在虚函数的类会在类的数据成员中生成一个虚函数指针 vfptr,而vfptr 指向了一张表(简称,虚表)。正是由于虚函数的这个特性,C++的多态才有了发生的可能。

其中虚函数表由三部分组成,分别是 RTTI(运行时类型信息)、偏移及虚函数的入口地址。而虚表与类及类生成的对象有存在着以下两种关系:

  • 类与虚表的关系:一个类只有一个虚表
  • 对象与类的关系:所有对象共享一个虚表

如下图所示:对象通过一个 vfptr (虚表指针)共享虚表.在这里插入图片描述

虚表指针在类中的布局

1、虚表指针 vfptr 在上,类成员变量 ma 在下(图左)
2、类成员变量 ma 在上,虚表指针 vfptr 在下(图右)
在这里插入图片描述
在类中,vfptr 的优先级最高,所以虚函数在类中的布局应该是上图左边的结构,其中vftpr指针指向虚表,在虚表的起始位置存放这虚表所属类的类型信息RTTI(运行时类型信息 Run-Time Type Identification)。可以通过 typeid(pb).name() 查看。

虚函数表在类中的布局

现有基类 Base、派生类 Deriver 为测试代码:

#include<iostream>

class Base		//定义基类
{
public:
	Base(int a) :ma(a) {}
	virtual void Show()		// 声明为虚函数
	{
		std::cout << "Base: ma = " << ma << std::endl;
	}
protected:
	int ma;
};
class Deriver : public Base		//派生类
{
public:
	Deriver(int b) :mb(b), Base(b) {}
	void Show()				// 没有声明为虚函数
	{
		std::cout << "Deriver: mb = " << mb << std::endl;
	}
protected:
	int mb;
};

1. 查看Base类的内存布局

在VS 2019开发者命令提示中输入:
cl 虚函数.cpp /d1reportSingleClassLayoutBase
其中,虚函数.cpp 为源文件的文件名, 最后的Base为要查看的类

/* Base类 内存布局 */
class Base      size(8):
        +---
 0      | {vfptr}
 4      | ma
        +---

Base::$vftable@:
        | &Base_meta		//运行时类型信息 Run-Time Type Identification
        |  0				//虚函数指针相对于整体作用域的偏移
 0      | &Base::Show		//虚函数入口地址,虚函数入口地址有一个或多个
2. 查看Deriver内存布局

输入:cl 虚函数.cpp /d1reportSingleClassLayoutDeriver
我们查寻到 Deriver的内存布局中类对象占据12个字节的空间。

/* Deriver类 内存布局 */
class Deriver   size(12):
        +---
 0      | +--- (base class Base)
 0      | | {vfptr}
 4      | | ma
        | +---
 8      | mb
        +---

Deriver::$vftable@:
        | &Deriver_meta
        |  0
 0      | &Deriver::Show

发现:我们在源代码中并没有把 Deriver::Show() 声明为虚函数,但在Deriver的类内存布局中也存在 {vfptr} 指针。

这里不得不说虚函数的另一个特点了,“基类中同名同参的函数是虚函数,派生类中同名同参的函数也会变成虚函数”。意思是,在派生类中同名同参的函数即使没有 virtual 关键字声明也默认是虚函数,也会产生一张虚表。

那么派生类中的虚表结构又是什么样的呢?

根据上面提到的 vfptr 的优先级最大,并且 Deriver 是继承自 Base 类。因此,我门推测 Deriver 的内存布局应该是如下格式16字节布局才对,但显然不是这样。那么在派生类 Deriver 的内存布局中究竟进行了怎样的操作,才形成了12字节的内存布局呢?

注:以下结构为错误示范

/* 我们推测的Deriver类的内存布局 */
class Deriver   size(16):
        +---
 0      | {vfptr}		// Deriver::
 4      | +--- (base class Base)
 4      | | {vfptr}		// Base::
 8      | | ma
        | +---
 12     | mb
        +---
解释这个原因之前我们先得了解派生类的虚表是怎样生成的?

在编译基类时,基类生成了一张虚表,在编译派生类时,又生成一张虚表。
我们在基类中添加一个Print() 函数,派生类中没有该函数。在上述假设成立的前提下,对应内存布局如下:
在这里插入图片描述
如果是这样,那么试想,在调用Print的时候,就需要查询两张虚表,从而找到 Base::Show() 对应的入口地址。这样做的确可行,但是整个调用的效率会变得非常差。

那么怎么来解决这个效率问题呢?

虚表合并
其实,在派生类的虚表生成好之后还有一个步骤,就是虚表的合并,具体演示如下:
在这里插入图片描述

将派生类中同名的虚函数覆盖到基类的虚表中,虚表合成之后,其中一个虚表指针已经没用了,不如也一并合并了。虚表指针合并的方式为向内层合并。因此,通过这一步虚表合并最终得到了Deriver 了的12字节内存布局。


那么有人就问了:为什么虚表指针合并的方式是向内合并,就不能向外合并吗?

要知道在继承中基类的指针是可以指向派生类对象,更加具体的说法是,基类的指针指向派生类对象中基类的起始部分。如果虚表指针向外层合并,那么对应的结构如下图所示,其中 Base* pb = new Deriver(10);
注:以下结构为错误示范
在这里插入图片描述
而正如我们问到的那样,如果虚表指针向外层合并的话,我们会发现无法通过虚表指针找到我们的虚表,因为在 Base:: 作用域中已经不存在虚表指针了。并且,当我们想要释放 new 出的堆区资源时,也不再是用 delete pb 而是 delete (Base*)((char*)pb - 4),因为在申请空间时内存分配的程序往往在被分配出的内存块“头部”放上一些校验信息。释放时必须从此空间的头部开始释放,否则会报 “Expression: is_block_type_valid(header->block_use)”错误。而我们申请的内存空间头部是在 0x100 的位置,而不是 0x104 的位置。这样在我们实际操作中就会很麻烦。因此,选择向内层合并就不就有这种问题产生。

因此,虚表指针选择向内层合并。

免责声明:文章转载自《C++ | 虚函数表内存布局》仅用于学习参考。如对内容有疑问,请及时联系本站处理。

上篇Solr 15一次使用自定义 Http Header 引发的血案下篇

宿迁高防,2C2G15M,22元/月;香港BGP,2C5G5M,25元/月 雨云优惠码:MjYwNzM=

相关文章

C++使用new和不使用new创建对象区别

前言 在使用面向对象的时候,发现使用new和不使用new创建的对象区别还是蛮大的,做个总结; 总结 new创建的是一个指向类对象的指针,需要指针进行接收,一处初始化,多处使用,但是不用new创建的话不需要指针,其创建的是一个类对象; new创建一个实例对象,并且指针指向该对象,作用域变成了全局,使用完时需要用delete进行销毁;但是不用new创建的话,...

【文文殿下】后缀自动机(SAM)求最长公共子串的方法

首先,在A 串上建立一个SAM,然后用B串在上面跑。具体跑的方法是: 从根节点开始,建立一个指针 p ,指着B串的开头,同步移动指针,沿着SAM的边移动,如果可以移动(即存在边)那么万事皆好,直接len++就好,但是,如果无法继续转移(失配了),那么,我们考虑跳回其父节点,因为其父节点的Right集是当前状态的真超集,那么其父节点状态所代表的字符串的集合中...

windows编程--x64调用约定

windows32位程序包括stdcall,thiscall,fastcall,cdecl,clrcall,vectorcall,nakedcall等调用方式,x64位程序默认使用新的fastcall调用方式。 这种调用方式得益于x64平台寄存器数量的增加。  x64 fastcall调用约定 空间大于8字节的参数用参照传递,不能把一个参数分割到多个寄存器...

Delphi部分函数、命令、属性中文说明

Abort 函数 引起放弃的意外处理Abs 函数 绝对值函数AddExitProc 函数 将一过程添加到运行时库的结束过程表中Addr 函数 返回指定对象的地址AdjustLineBreaks 函数 将给定字符串的行分隔符调整为CR/LF序列Align 属性 使控件位于窗口某部分Alignment 属性 控件标签的文字位置AllocMem 函数 在堆栈上分...

智能指针之 auto_ptr

  C++的auto_ptr所做的事情,就是动态分配对象以及当对象不再需要时自动执行清理,该智能指针在C++11中已经被弃用,转而由unique_ptr替代,那这次使用和实现,就具体讲一下auto_ptr被弃用的原因,(编译平台:Linux centos 7.0 编译器:gcc 4.8.5 )   首先使用std::auto_ptr时,需要#include...

VC++通用控件编程

滑动条控制(Slider Control)也叫轨道条控制,其主要是用一个带有轨道和滑标的小窗口以及窗口上的刻度,来让用户选择一个离散数据或一个连续的数值区间。通过鼠标或键盘来进行数据的选择操作,这在WIN98/95中的很多应用程序中都可以看到,如控制面板中的鼠标等,滑动条既可以是水平方式的也可以是垂直方式的。滑动条控制的风格如下: TBS_HORZ 滑动条...