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

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

作者: 太平小小草 | 来源:发表于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