虚函数表在对象内存中的布局

  • 步骤一,先表述虚函数表的3个特性来做引子:

1, 单继承时,虚函数表指针通常存储在类对象“内存布局”的最前面。

2,虚函数表实质上是一个“函数指针”的数组,该数组最后一个元素必然为0。(很多博客上都说虚函数表的最后一个元素是0,但我自己用vs2010做的实验有时候不是0)。

3,一个有虚函数(无论是继承得到的虚函数还是自身有的)的类,该类的所有对象,都共用一份虚函数表。

      每个对象有一套(这里用套而不用个,是因为多重继承时,可能有多个指针组成的一套)“虚函数表指针”,指向该虚函数表。

  • 步骤二,下面来证明上面几个特性,并推导出类对象的内存布局。
//VC++ 32位编译器下
#include "stdafx.h"
#include <stdio.h>
#include <iostream>
using namespace std;

typedef void(*FUNC)();
class A{
public:
    virtual void func(){
        cout << "  A::func" << endl;
    }
    virtual void funcA(){
        cout << "  A::funcA" << endl;
    }
public:
    int a;
};

class B:public A{
public:
    virtual void func(){
        cout << "  B::func" << endl;
    }
    virtual void funcB(){
        cout << "  B::funcB" << endl;
    }
public:
    int b;
};

int main()
{
    B b1;
    B b2;

    printf("\n b1对象的首地址里面存放的虚函数表的指针是:0x%x\n", (*(int*)&b1));
    ((FUNC)(  *(int*)*((int*)&b1)  ))();
    ((FUNC)*((int*)*((int*)&b1) + 1))();


    printf(" b2对象的首地址里面存放的虚函数表的指针是:0x%x\n", (*(int*)&b2));

    system("pause");
    return 0;
}

输出结果:
image.png

由输出结果可以看出,程序正确调用了两个虚函数(先不管调用的是什么虚函数),所以找到的虚函数表的指针是正确的。步骤一中的1得到证实。又因为 b1 和 b2 虚函数表的指针值是相同的,所以步骤一中的3也得到了证实

  • 步骤三,再来看一个单继承的例子
//VC++  32位编译器下:
#include "stdafx.h"
#include <iostream>
using namespace std;

//单继承下虚函数表:是如何组织的
class A{
public:
    virtual void func(){
        cout << "A::funccommon" << endl;
    }
    virtual void funcA(){
        cout << "A::funcA" << endl;
    }
};
 
class B:public A{
public:
    virtual void func(){
        cout << "B::funccommon" << endl;
    }
    virtual void funcB(){
        cout << "B::funcB" << endl;
    }
};
 
 
class C:public A{
public:
    virtual void func(){
        cout << "C::funccommon" << endl;
    }
    virtual void funcC(){
        cout << "C::funcC" << endl;
    }
};
 
typedef void (*FUNC)();
int main()
{
    A a;
    B b;
    C c;

    cout << "A::虚表:" << endl;
    ((FUNC)(*(int*)(*(int*)(&a))))();
    ((FUNC)(*((int*)(*(int*)(&a)) + 1)))();
    cout << "-------------------------------------" << endl;

    cout << "B::虚表:" << endl;
    ((FUNC)(*(int *)(*(int*)(&b))))();
    ((FUNC)(*((int*)(*(int*)(&b)) + 1)))();
    ((FUNC)(*((int*)(*(int*)(&b)) + 2)))();
    cout << "-------------------------------------" << endl;
 
    cout << "C::虚表:" << endl;
    ((FUNC)(*(int *)(*(int*)(&c))))();
    ((FUNC)(*((int*)(*(int*)(&c)) + 1)))();
    ((FUNC)(*((int*)(*(int*)(&c)) + 2)))();
    system("pause");
    return 0;
}

输出结果:
image.png

在分析输出结果之前,先看一下这句代码是什么意思?

              (    (FUNC)   (  *(int*)  (*(int*)(&a))  )    )();

    (*(int*)(&a))的意思是,从对象 a 的起始地址所指向的那个字节的位置算起,取4个字节的一个整形值。我们知道,在VC++ 32位编译器下,指针和 int 型一样,也是占4个字节。

    所以实际上,取出来的这个整形值,也可以看作是一个地址(也称指针,实际上该指针就是对象a指向虚函数表的指针)。

    ( *(int*)  (*(int*)(&a)) )的意思是,将上面取出的指针,强制转换为 int* 的指针,然后取出该指针所指的整形值(同样,也可以看作是一个地址),该整形值事实上是一个函数指针。

    由以上分析可以对代码进行解释:


QQ截图20181029164850.png

    我们再来看刚才的输出结果,我们对虚函数表,按照由数组首地址,计算偏移获取到数组元素的做法,获取了A, B, C三个类里的虚函数的指针,并且都调用成功了。
    由此步骤一中的2也得到了证实

  • 步骤四,如果派生类的虚函数和基类的虚函数相同,即派生类的虚函数“覆盖”了基类的虚函数,则在派生类的虚函数表中,只有派生类的那个虚函数。如果是派生类新增的虚函数,则将该虚函数追加到派生类虚函数表的末尾。如下图,是类B的虚函数表的产生过程:
untitled.png
  • 步骤五,虚函数表的生成的时间

这里说明下一个问题,我用 VS2010 的 Debug 模式来调试 步骤三 里面的代码,然后在监视窗口里查看变量b, c的虚函数表,发现只能查看到 从基类继承下来的派生类覆盖的 虚函数,不能看到派生类自己追加的虚函数。

而且对于对 虚函数表 没有深刻理解的人来说,VS2010 的显示方式让人容易误解,认为存储的是基类的虚函数表的指针。如下图:

image.png

VS运行起来,可以打印上图 b 和 c 的_vfptr[2],但是不明白为什么在调试器里不能查看到。

      现在在 linux 下用 gdb 来查看:


image.png

注:上图中,因为我的g++编译器是64位的,所以把 int 改成了 long long 类型。因为在g++ 64位编译器下,指针是占 8个字节的,long long 类型也是占8个字节的。当然直接用 long 也行。

程序构建(Build)的四个过程(预编译、编译、汇编和链接)
虚函数表应该是在编译期确定的,原因如下:

1)预编译器主要处理那些源代码文件中的以“#”开始的预编译指令,如“#include”、“#define”。很明显这个过程可以排除。

2)汇编器是将编译器生成的汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。汇编过程相对于编译期来说比较简单,没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就行了。所以,汇编期也是可以排除的。

3)链接器(现只考虑静态链接)是将汇编器生成的目标文件(和库)链接成一个可执行文件,本质上做的是重定位(Relocation)的工作,详细可参考《程序员的自我修养》2.3、2.4节。很明显链接期也是可以排除的。

4)编译器要做的事情就比较多了,包括词法分析、语法分析、语义分析及优化代码等,是整个程序构建的核心。所以,排除了预编译期、汇编期、链接期及考虑到编译期所做的事情,虚函数表应该是在编译期建立的。


为什么不是在运行时确定的呢?
C++是编译型语言,当然是在编译阶段把能够做的工作都做完,执行起来效率更高。像多态那种因为用户行为会影响执行路径的,才不得不在执行阶段确定。

  • 步骤五,虚函数表存放在进程(在磁盘上称 ”可执行文件”,在内存中就称 “进程”)的哪个区?

   用readelf命令查看,这个以后再回来学习并补充。开始因为不知道还有readelf这种命令,也不知道有elf文件这种格式,我愚蠢地去学习了一下汇编,想用反汇编的方法看进程在内存中分布,搞得花了一天的时间来折腾,还没有好的结果。再写的时候参考一下 https://blog.csdn.net/chenlycly/article/details/53377942 这篇博文

   借用别人博客的一句话先告诉结果:vtable在Linux/Unix中存放在可执行文件的只读数据段中(rodata),而微软的编译器将虚函数表存放在常量段

  • 步骤六,多重继承时 ,派生类对象的内存布局

多重继承的概念:网上很多人的博客对多继承多重继承两个概念有不同的解释,搞得很混乱。尤其是多重继承,有些博客错得离谱,说类C继承自类B,类B继承自类A,这样叫多重继承。对多继承歧义的倒比较少。这里我统一一下概念,根据C++ Primer中文版(第4版) 的说法:

      多重继承是从多于一个直接基类派生类的能力,多重继承的派生类继承其所有父类的属性。

      多继承 与 多重继承实际上是一个概念。

//多继承条件下的虚函数表

#include "stdafx.h"
#include <iostream>  
using namespace std;  
  
#include<iostream>
using namespace std;

class A
{
public:
    virtual void fun1()
    {
        printf("A::virtual void fun(int n)\n");
    }
    int _a;
};

class B
{
public:
    virtual void fun2()
    {
        printf("B::virtual void fun(int n)\n");
    }
    int _b;
};

class C:public A,public B
{
public:
    int _c;
};

int main()
{
    A a;
    B b;
    C c;
    a._a = 1;
    b._b = 2;
    c._a = 3;
    c._b = 4;
    c._c = 5;
    printf("%p\n", &a);
    printf("%p\n", &b);
    printf("%p\n", &c);
    return 0;
}

内存窗口分析:


index.png
33.png
  • 步骤七,虚继承的作用

      虚继承是解决C++多重继承问题的一种手段,从不同途径继承来的同一基类,会在子类中存在多份拷贝。

这将存在两个问题:

其一,浪费存储空间;

第二,存在二义性问题,通常可以将派生类对象的地址赋值给基类对象,实现的具体方式是,将基类指针指向继承类(继承类有基类的拷贝)中的基类对象的地址,但是多重继承可能存在一个基类的多份拷贝,这就出现了二义性。

      虚继承是为了解决上述两个问题而产生的。

      这个比较复杂,用一篇博客里的说法就是,其复杂度远远大于它的使用价值,不想花太多时间研究,仅仅知道其用法就行了。

      如果很想知道,可以参考一下https://blog.csdn.net/zhourong0511/article/details/79950847这篇博客的最后一张图,那里讲的菱形继承里有虚继承的内容。

      参考了以下两篇博客:
1,https://blog.csdn.net/zongyinhu/article/details/51276806?tdsourcetag=s_pcqq_aiomsg。发现作者讲得很透彻,为了我能完全弄懂并记住虚函数表的有关问题,现在用自己的话整理出来,并发布。

2,https://blog.csdn.net/zhourong0511/article/details/79950847使用的内存表示方法非常好,让我完全看懂了,不过博客中有少量错误和歧义的内容。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,165评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,503评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,295评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,589评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,439评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,342评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,749评论 3 387
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,397评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,700评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,740评论 2 313
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,523评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,364评论 3 314
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,755评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,024评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,297评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,721评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,918评论 2 336