美文网首页
虚函数表在对象内存中的布局

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

作者: 太平小小草 | 来源:发表于2018-10-29 12:48 被阅读0次
  • 步骤一,先表述虚函数表的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使用的内存表示方法非常好,让我完全看懂了,不过博客中有少量错误和歧义的内容。

相关文章

网友评论

      本文标题:虚函数表在对象内存中的布局

      本文链接:https://www.haomeiwen.com/subject/lnlbtqtx.html