一文读懂C++虚函数的内存模型[通俗易懂]

一文读懂C++虚函数的内存模型[通俗易懂]一文彻底读懂C++虚函数表的实现机制(使用GDB内存布局)1、虚函数简介2、虚函数表简介3、有继承关系的虚函数表剖析3.1、单继承无虚函数覆盖的情况3.2、单继承有虚函数覆盖的情况3.3、多重继承的情况3.4、多层继承的情况4、总结1、虚函数简介C++中有两种方式实现多态,即重载和覆盖。重载:是指允许存在多个同名函数,而这些函数的参数表不同(参数个数不同、参数类型不同或者两者都不同)。覆盖:是指子类重新定义父类的虚函数的做法,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际

大家好,又见面了,我是你们的朋友全栈君。如果您正在找激活码,请点击查看最新教程,关注关注公众号 “全栈程序员社区” 获取激活教程,可能之前旧版本教程已经失效.最新Idea2022.1教程亲测有效,一键激活。

Jetbrains全系列IDE使用 1年只要46元 售后保障 童叟无欺

1、前言

C++虚函数的内存模型是一个经典的问题,其具体实现依赖于编译器,可能会出现较大差异,但原理和最终的目的是大体相同的。本文将对g++中虚函数的内存模型进行详细解析。

2、虚函数简介

C++中有两种方式实现多态,即重载和覆盖。

  • 重载:是指允许存在多个同名函数,而这些函数的参数表不同(参数个数不同、参数类型不同或者两者都不同)。
  • 覆盖:是指子类重新定义父类虚函数的做法,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针拥有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法,比如:模板元编程是在编译期完成的泛型技术,RTTI、虚函数则是在运行时完成的泛型技术。

关于虚函数的具体使用方法,建议大家先去阅读相关的C++的书籍,本文只剖析虚函数的内存模型,让大家对虚函数有一个更加清晰的认识,并不对虚函数的具体使用方法作过多介绍。

3、虚函数表简介

学过C++的人都应该知道虚函数(Virtual Function)是通过虚函数表(Virtual Table,简称为V-Table)来实现的。虚函数表主要存储的是指向一个类的虚函数地址的指针,通过使用虚函数表,继承、覆盖的问题都都得到了解决。假如一个类有虚函数,当我们构建这个类的实例时,将会额外分配一个指向该类虚函数表的指针,当我们用父类的指针来操作一个子类的时候,这个指向虚函数表的指针就派上用场了,它指明了此时应该使用哪个虚函数表,而虚函数表本身就像一个地图一样,为编译器指明了实际所应该调用的函数。指向虚函数表的指针是存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下),这就意味着理论上我们可以通过对象实例的地址得到这张虚函数表(实际上确实可以做到),然后对虚函数表进行遍历,并调用其中的函数。

前面说了一大堆理论,中看不中用,下面还是通过一个实际的例子验证一下前面讲的内容,首先定义一个Base类,该类有三个虚函数,代码如下:

#include <iostream>
#include <string>

typedef void (*Fun)(void);

class Base
{ 
   
public:
    virtual void f()
    { 
   
        std::cout << "Base::f()" << std::endl;
    }

    virtual void g()
    { 
   
        std::cout << "Base::g()" << std::endl;
    }

    virtual void h()
    { 
   
        std::cout << "Base::h()" << std::endl;
    }
};

接下来按照前面的说法,我们通过Base类的实例对象base来获取虚函数表,代码如下:

int main(int argc, char* argv[])
{ 

Base base;
Fun fun = nullptr;
std::cout << "指向虚函数表指针的地址:" << (long*)(&base) << std::endl;
std::cout << "虚函数表的地址:" << (long*)*(long*)(&base) << std::endl;
std::cout << "offset_to_top:" << *((long*)*(long*)(&base) - 2) << std::endl;
std::cout << "typeinfo for Base:" << (long*)*((long*)*(long*)(&base) - 1) << std::endl;
fun = (Fun)*((long*)*(long*)(&base));
std::cout << "虚函数表中第一个函数的地址:" << (long*)fun << std::endl;
fun();
fun = (Fun)*((long*)*(long*)(&base) + 1);
std::cout << "虚函数表中第二个函数的地址:" << (long*)fun << std::endl;
fun();
fun = (Fun)*((long*)*(long*)(&base) + 2);
std::cout << "虚函数表中第三个函数的地址:" << (long*)fun << std::endl;
fun();
}

运行结果图3-1所示(GCC 4.8.5):

在这里插入图片描述


图3-1 程序运行结果

接下来我们就画一张内存布局图来更直观地看一下类Base的内存布局,如图3-2所示:

在这里插入图片描述


图3-2 类Base内存布局

可以看到因为类Base没有类成员,所以只要一个虚函数表指针。上述内容也可以在GDB中调试验证,如图3-3所示:

在这里插入图片描述


图3-3 GDB查看基类虚函数表内存布局

在上面的例子中我们通过把&base强制转换成long *类型,来取得指向虚函数表的指针的地址,然后对这个地址取值就得到对应的虚函数表了。从测试程序中可以看到,我们取到的指向虚函数表的指针指向的并非是虚函数表的首地址,虚函数表应该还包括虚函数表指针指向的地址的前两个地址空间(64位系统,也就是16个字节)。
接下来具体分析一下这个内存模型:

  1. _vptr.Base - 2:这里存储的是offset_to_top,这个表示的是当前的虚表指针距离类开头的距离,可以看到对于_vptr.Base来说这个值就是0,因为_vptr.Base就存在于类Base的起始位置。后续的例子中会有该值不是0的情况出现的。

offset_to_top深度解析:在多继承中,由于不同基类的起点可能处于不同的位置,因此当需要将它们转化为实际类型时,this指针的偏移量也不相同。由于实际类型在编译时是未知的,这要求偏移量必须能够在运行时获取。实体offset_to_top表示的就是实际类型起始地址到当前这个形式类型起始地址的偏移量。在向上动态转换到实际类型时(即基类转派生类),让this指针加上这个偏移量即可得到实际类型的地址。需要注意的是,由于一个类型即可以被单继承,也可以被多继承,因此即使只有单继承,实体offset_to_top也会存在于每一个多态类型之中。
(这里要注意一点就是offset_to_top只存在于多态类型中,非多态类型的类根本就没有什么所谓的虚函数表之类的东西,它也就不支持RTTI,最简单的大家可以使用dynamic_cast去试试,会报错说该类型不具备多态性质的。那么问题来了,怎样才能以最简短的方式让它具备多态的性质呢?很简单,定义一个析构函数,用virtual修饰即可)

  1. _vptr.Base - 1:这里存储的是typeinfo for Base,里面的内容其实也是一个指针,指向的是类Base的运行时信息,这些玩意都是为了支持RTTI的。RTTI的相关内容以后会讲,这里就先不多分析了。

  2. _vptr.Base:从这里开始的内存地址存储的就是指向各个虚函数的指针了,顺序与类中定义的顺序一致,这个就是我们平时从书中看到的虚函数表主要存储的数据内容了,比较直观这里就不多做分析了。

4、有继承关系的虚函数表剖析

前面分析虚函数表的场景是没有继承关系的,然而在实际开发中,没有继承关系的虚函数纯属浪费表情,所以接下来我们就来看看有继承关系下虚函数表会呈现出什么不一样的特点,分析的时候会分别就单继承无虚函数覆盖、单继承有虚函数覆盖、多重继承、多层继承这几个场景进行说明。

4.1、单继承无虚函数覆盖的情况

先定义一个Base类,再定义一个Derived类,Derived类继承于Base类,代码如下:

#include <iostream>
#include <string>
class Base
{ 

public:
virtual void f()
{ 

std::cout << "Base::f()" << std::endl;
}
virtual void g()
{ 

std::cout << "Base::g()" << std::endl;
}
virtual void h()
{ 

std::cout << "Base::h()" << std::endl;
}
};
class Derived : public Base
{ 

public:
virtual void f1()
{ 

std::cout << "Derived::f1()" << std::endl;
}
virtual void g1()
{ 

std::cout << "Derived::g1()" << std::endl;
}
virtual void h1()
{ 

std::cout << "Derived::h1()" << std::endl;
}
};

继承关系如图4-1所示:

在这里插入图片描述


图4-1 类继承关系UML图

测试的代码如下,因为等下要使用GDB来验证,所以就随便写点,定义个Derived类实例就行了

int main(int argc, char* argv[])
{ 

Derived derived;
derived.f();
}

派生类Derived的内存布局如图4-2所示:

在这里插入图片描述


图4-2 单继承无虚函数覆盖情况下派生类内存布局

接下来就用GDB调试一下,验证图4-2是否正确,如图4-3所示:

在这里插入图片描述


图4-3 GDB查看单继承无虚函数覆盖情况下派生类内存布局

从调试结果可以看出图4-2是正确的,Derived的虚函数确实是接在Base的虚函数后面的,而这里的虚函数表指针依然叫做_vptr.Base(因为继承于类Base),其余的内容与第三节的一致,就不重复讲了。

4.2、单继承有虚函数覆盖的情况

派生类覆盖基类的虚函数是很有必要的事情,不这么做的话虚函数的存在将毫无意义。下面我们就来看一下如果派生类中有虚函数覆盖了基类的虚函数的话,对应的虚函数表会是一个什么样子。还是老规矩先定义两个有继承关系的类,注意一下我这里只覆盖了基类的虚函数g()

#include <iostream>
#include <string>
class Base
{ 

public:
virtual void f()
{ 

std::cout << "Base::f()" << std::endl;
}
virtual void g()
{ 

std::cout << "Base::g()" << std::endl;
}
virtual void h()
{ 

std::cout << "Base::h()" << std::endl;
}
};
class Derived : public Base
{ 

public:
virtual void f1()
{ 

std::cout << "Derived::f1()" << std::endl;
}
virtual void g()
{ 

std::cout << "Derived::g()" << std::endl;
}
virtual void h1()
{ 

std::cout << "Derived::h1()" << std::endl;
}
};

继承关系如图4-4所示:

在这里插入图片描述


图4-4 类继承关系UML图

测试的代码如下,因为等下要使用GDB来验证,所以就随便写点,定义个Derived类实例就行了

int main(int argc, char* argv[])
{ 

Derived derived;
derived.g();
}

派生类Derived的内存布局如图4-5所示:

在这里插入图片描述


图4-5 单继承有虚函数覆盖情况下派生类内存布局

接下来就用GDB调试一下,验证图4-5是否正确,如图4-6所示:

在这里插入图片描述


图4-6 GDB查看单继承有虚函数覆盖情况下派生类内存布局

从调试结果可以看出图4-5是正确的,并且可以得到以下几点信息:

  1. 覆盖的g()被放到了虚表中原来父类虚函数的位置
  2. 没有被覆盖的虚函数位置排序依旧不变

有了前面的理论基础,我们可以知道对于下面的代码,原先虚函数表中存储指向Base::g()指针的位置已经被指向Derived::g()的指针所取代,于是在实际调用发生时,调用的是Derived::g(),从而实现了多态

int main(int argc, char* argv[])
{ 

Base* base = new Derived();
base->f();
base->g();
base->h();
}

输出结果如图4-7所示:

在这里插入图片描述


图4-7 程序运行结果

注意:在前面的例子中,我们分配内存的实例对象的类型是Derived,但是却用Base的指针去引用它,这个过程中数据并没有发生任何的转换,实例的真实类型依旧是Derived,但是由于我们使用时用的是Base类型,所以函数调用要依据Base类来,不能胡乱调用,比如说我们此时是无法调用Derivedf1()h1()的。

4.3、多重继承的情况

多重继承就不分开讲有覆盖和无覆盖的情况了,其实结合前面讲的就差不多知道是什么个情况了,下面的例子中会设计成派生类既有自己的虚函数,又有用于覆盖基类的虚函数,这样就能兼顾有覆盖和无覆盖的情况了。类的设计如下:

#include <iostream>
#include <string>
class Base1
{ 

public:
virtual void f()
{ 

std::cout << "Base1::f()" << std::endl;
}
virtual void g()
{ 

std::cout << "Base1::g()" << std::endl;
}
virtual void h()
{ 

std::cout << "Base1::h()" << std::endl;
}
};
class Base2
{ 

public:
virtual void f()
{ 

std::cout << "Base2::f()" << std::endl;
}
virtual void g()
{ 

std::cout << "Base2::g()" << std::endl;
}
virtual void h()
{ 

std::cout << "Base2::h()" << std::endl;
}
};
class Base3
{ 

public:
virtual void f()
{ 

std::cout << "Base3::f()" << std::endl;
}
virtual void g()
{ 

std::cout << "Base3::g()" << std::endl;
}
virtual void h()
{ 

std::cout << "Base3::h()" << std::endl;
}
};
class Derived : public Base1, public Base2, public Base3
{ 

public:
virtual void f()
{ 

std::cout << "Derived::f()" << std::endl;
}
virtual void g1()
{ 

std::cout << "Derived::g1()" << std::endl;
}
virtual void h1()
{ 

std::cout << "Derived::h1()" << std::endl;
}
};

继承关系如图4-8所示:

在这里插入图片描述


图4-8 类继承关系UML图

测试的代码如下:

int main(int argc, char* argv[])
{ 

Derived* d = new Derived();
Base1* b1 = d;
Base2* b2 = d;
Base3* b3 = d;
std::cout << (long*)(*(long*)b1) << std::endl;
std::cout << (long*)(*(long*)b2) << std::endl;
std::cout << (long*)(*(long*)b3) << std::endl;
}

输出结果如图4-9所示:

在这里插入图片描述


图4-9 程序运行结果

输出信息非常有趣,明明b1b2b3指向的都是d,但是它们各自取出来的虚函数表的地址却完全不同,按理来说不是应该相同吗?别急,下面我们通过图4-10来看一看多继承下派生类内存布局是什么样的

在这里插入图片描述


图4-10 多重继承情况下派生类内存布局

从图4-10中可以看出以下几点信息:

  1. 无论派生类继承与几个基类,虚函数表永远只有一个
  2. 虽然只有一个虚函数表,但是每个基类都有一个属于自己的虚函数表指针,这个指针指向自己虚函数在虚函数表中的位置
  3. 派生类自己特有的虚函数被放到了第一个基类的表中(第一个基类是按照继承顺序来确定的)
  4. 可以看到对于_vptr.Base2来说,offset_to_top不是0而是-8,表示_vptr.Base2的位置与类开头相差为8个字节(64为系统),至于为什么是负数,这是因为堆内存是向下增长的,越往下地址数值越大。_vptr.Base3同理。
  5. 对于_vptr.Base2_vptr.Base3来说,他们的第一个虚函数本来应该指向的是Derived::f(),但是事实并非如此,其指向的是代码段non-virtual thunk to Derived::f(),因为对于Base2类型,其this指针已经相对于Derived进行了偏移,所以需要由代码段non-virtual thunk to Derived::f()先将this转换为派生类Derived类型,之后才跳转到真正的Derived::f()函数。

到这里我们就可以解释前面那个问题了,对于上面例子中的b1,这个没啥问题,因为它的类型Base1就是第一个被继承的,所以我们当然可以认为这个不会出任何问题,但是对于b2呢,它被继承的位置可不是第一个啊,运行时要怎么确定它的虚函数表呢?它有没有可能一不小心找到Base1的虚函数去?恰好这个例子中几个基类的虚函数名字和参数又都是完全相同的。这里其实就涉及到编译器的处理了,当我们执行赋值操作Base2* b2 = d;时,编译器会自动把b2的虚函数表指针指向正确的位置,这个过程是编译期间完成的,所以虚函数所实现的多态应该是“静动结合”的,有部分工作需要在编译时期完成的。

下面我们依然借助GDB来看一下实际的内存布局,详见图4-11,从调试信息中可以看出确实有三个虚函数表指针,对应三个基类

在这里插入图片描述


图4-11 GDB查看多重继承情况下派生类虚函数表内存布局

_vptr.Base1相关信息如图4-12所示,可以看到和图4-10描述的内容是一致的,Derived自己特有的虚函数确实被加入到了第一张表中了

在这里插入图片描述


图4-12 _vptr.Base1相关信息

_vptr.Base2相关信息如图4-13所示

在这里插入图片描述


图4-13 _vptr.Base2相关信息

_vptr.Base3相关信息如图4-14所示

在这里插入图片描述


图4-14 _vptr.Base3相关信息

补充说明:如果继承的某个类没有虚函数的话,比如说将上面的Base2修改为以下格式:

class Base2
{ 

public:
void f()
{ 

std::cout << "Base2::f()" << std::endl;
}
void g()
{ 

std::cout << "Base2::g()" << std::endl;
}
void h()
{ 

std::cout << "Base2::h()" << std::endl;
}
};

main函数不变,再运行以下程序,输出结果如图4-15所示,说明此时Base2就不再拥有自己的虚函数表的指针了,因为它本来就没有虚函数表。

在这里插入图片描述


图4-15 程序运行结果

4.4、多层继承的情况

多层继承的在有前面的基础上来理解就非常简单了,测试程序如下:

#include <iostream>
#include <string>
class Base
{ 

public:
virtual void f()
{ 

std::cout << "Base::f()" << std::endl;
}
virtual void g()
{ 

std::cout << "Base::g()" << std::endl;
}
virtual void h()
{ 

std::cout << "Base::h()" << std::endl;
}
};
class Derived : public Base
{ 

public:
virtual void f()
{ 

std::cout << "Derived::f()" << std::endl;
}
virtual void g1()
{ 

std::cout << "Derived::g1()" << std::endl;
}
};
class DDerived : public Derived
{ 

public:
virtual void f()
{ 

std::cout << "DDerived::f()" << std::endl;
}
virtual void h()
{ 

std::cout << "DDerived::h()" << std::endl;
}
virtual void g2()
{ 

std::cout << "DDerived::g2()" << std::endl;
}
};
int main(int argc, char* argv[])
{ 

DDerived dd;
dd.f();
}

继承关系如图4-16所示:

在这里插入图片描述


图4-16 类继承关系UML图

派生类DDerived内存布局如图4-17所示:

在这里插入图片描述


图4-17 多层继承情况下派生类内存布局

多层继承的情况这里就不使用GDB去看内存布局了,比较简单,大家可以自行去测试一下。

5、总结

本文先对虚函数的概念进行了简单介绍,引出了虚函数表这个实现虚函数的关键要素,然后对g++下不同继承案例的虚函数内存模型进行画图分析,最后使用GDB查看实际的内存情况来验证内存模型图的正确性。下一篇文章《一文读懂C++虚继承的内存模型》已完成,是关于虚继承的,有兴趣的小伙伴可以跳转过去看看,相信对学C++的同学一定会有所帮助的。

最后,如果大家觉得本文写得好的话麻烦点赞收藏关注一下谢谢,也可以关注该专栏,以后会有更多优质文章输出的。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/158734.html原文链接:https://javaforall.cn

【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛

【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...

(0)
blank

相关推荐

  • pycharm写脚本_pycharm运行python脚本

    pycharm写脚本_pycharm运行python脚本打开pycharm,file->setting在右侧模板框图中填写模板##!/usr/bin/python3#-*-coding:utf-8-*-#@Time:${DATE}${TIME}#@Author:${USER}#@Email:wayne_lau@aliyun.com#@File:${NAME}.py#@Project:${PROJECT_NAME}其他可用的预定义文件模板变量为:${PROJECT_NAME}-.

  • java源文件扩展名_java源文件名的后缀是什么?

    java源文件扩展名_java源文件名的后缀是什么?java源文件名的后缀是:“.java”。java编译后的文件扩展名是:“.class”;“class”文件全名称为“Javaclass”文件,主要在平台无关性和网络移动性方面使Java更适合网络。java源文件扩展名是.java。java编译后的文件扩展名是.class。我们编好的Java源程序保存为.java后缀,然后再用javac(Java语言的编译器)进行编译,生成后缀名为.class的…

  • a4纸两版的小册子怎么打印_word怎么打印小册子用a4

    a4纸两版的小册子怎么打印_word怎么打印小册子用a4用word打印小册子具体操作步骤:1、首先打开将要打印的文档,然后在菜单栏下面的快捷菜单栏,找到打印机的快捷图标,单击鼠标左键选择打开打印机。2、会弹出打印机设置对话框,可以进行打印前的一些细节的设置,由于是要选择打印一本小册子所以选择打印“所有页面”。3、在下面一栏是调整页面大小,也就是打印模式,选择“小册子”进行打印。4、然后下面是关于小册子的一些设置,按照自己的需要进行设置。5、设置完成以后…

  • extract函数行结果

    extract函数行结果

  • 网站404页面怎么做

    网站404页面怎么做网站404页面怎么做?谈及网站404页面,诸多站长在网站运营的过程中,难以避免404页面的出现。404页面意味着网站存在死链接,当搜索引擎蜘蛛爬取此类网址的时候,应当反馈404状态吗,告知此URL网址已经失效,搜索引擎蜘蛛便会回馈到数据中心,从中清除数据。而在页面的前端,应该对访客予以信息提示,告知内容的已经不存在,优化相关搜索内容,尽可能避免降低访客体验度,由此可见,网站404页面的制作应当以两种出发点进行,即对于搜索引擎蜘蛛状态吗的正确回馈和对前端界面及内容设计出发。404网页正确设置网

  • Android 框架揭秘 –读书笔记[通俗易懂]

    Android 框架揭秘 –读书笔记[通俗易懂]Android框架揭秘InsiedtheAndroidFramework

发表回复

您的电子邮箱地址不会被公开。

关注全栈程序员社区公众号