• 虚函数表与多态的认知


    虚函数表与多态

    虚函数表与多态,是C++开发人员终究要面对的问题。
    虽然很久没写C++了,此处还是将其整理一下进行记录。
    编译器信息:

    • gcc: gcc (Debian 7.3.0-19) 7.3.0;
    • clang: 7.0.1-8 (tags/RELEASE_701/final).

    1 类空间

    class Empty {
    public:
        Empty() = default;
        ~Empty() = default;
        void hello() { std::cout << "hello world" << std::endl; }
    };
    // sizeof(Empty) = 1
    

    首先需要明确,空类(包含非虚函数),其大小为1。
    为了能将class实例放到数组里,空类必须具有大小,否则数组sizeof将是灾难。
    不过空类作为基类时,为了对齐可能占用4各字节或以上,因此编译器有空基类优化。

    空基类优化:令非静态数据成员、无虚函数的基类实际占用0字节。

    现在,我们开始加入一个虚函数,再次查看类大小。

    class Empty {
    public:
        Empty() = default;
        ~Empty() = default;
        void hello() { std::cout << "hello world" << std::endl; }
        virtual void virtual_test() {}
    };
    // sizeof(Empty) = 8
    

    加入虚函数后,类大小从1字节增加至为8字节。
    这是因为,编译器在类中隐式插入了虚函数表指针(void *vptr),指针大小为8字节。

    关于编译器在背后做的事情,建议看<<深度探索C++对象模型>>(虽然看了就忘,但是比没看要好一些)。

    2 虚函数表指针(vptr)与虚函数表(vtbl)

    对于包含虚函数的类,编译器会为类创建相应的虚函数表(vtbl)。
    虚函数表中,主要存放类所对应的虚函数地址。
    在编译期间,编译器会在构造函数中,对vptr进行赋值,数值为vtbl的地址。
    伪代码如下所示:

    class Empty {
    public:
        Empty() {
            vtpr = (void*)&Empty::vtbl;
        }
    }
    

    改进一些,我们修改Empty类如下所示:

    class Empty {
    public:
        Empty() = default;
        virtual ~Empty() {}
        virtual void virtual_func1() {}
        virtual void virtual_func2() {}
    public:
        int m1 = 0x01020304, m2 = 0x04030201;
    };
    
    int main() {
        Empty empty;
        std::cout << empty.m1 << std::endl;
        return 0;
    }
    

    主要改进就是添加成员变量m1,m2,以及添加若干函数(包含虚函数)。
    使用gdb查看Empty实例的内存布局,具体如下所示:

    Empty实例内存布局

    由上图可知,Empty实例的内存布局为:

    1. vptr(红线部分,指向Empty的虚表);
    2. m1,m2。

    3 多态调用

    C++的三大特性是封装,继承以及多态,其中多态必须依靠虚函数实现。
    通俗点说,如果通过调用虚函数表指针(vtpr)找到虚函数表(vtbl)的入口并执行虚函数,则程序使用到了多态。
    举个例子:

    class Base {
    public:
        virtual void virtual_func() {}
    };
    
    int main() {
        Base *a = new Base();
        a->virtual_func();  // 多态调用
        Base b;
        b.virtual_func();   // 非多态调用
        Base *c = &b;
        c->virtual_func();  // 多态调用
        return 0;
    }
    

    为了验证注释中的观点,我们使用汇编代码进行佐证:

    虚函数调用汇编代码

    上图可以看出,三次调用virtual_func,汇编代码存在较大不同。
    原因是a,c实例调用virtual_func相对于b实例调用virtual_func,多了需要去虚表(vtbl)中查找virtual_func函数入口的过程。

    4 内存布局

    下文将分别从单继承,多继承以及菱形继承三点阐述虚表的内存布局(使用g++导出内存布局)。

    4.1 单继承

    class A
    {
        int ax;
        virtual void f0() {}
    };
    class B : public A
    {
        int bx;
        virtual void f1() {}
    };
    class C : public B
    {
        int cx;
        void f0() override {}
        virtual void f2() {}
    };
    

    内存布局如下所示:

    Vtable for A
    A::vtable for A: 3 entries
    0     (int (*)(...))0                   // 类型转换偏移量
    8     (int (*)(...))(& typeinfo for A)  // 运行时类型信息(Run-Time Type Identification,RTTI)
    16    (int (*)(...))A::f0               // 虚函数f0地址
    
    Class A
       size=16 align=8
       base size=12 base align=8
    A (0x0x7f753a178960) 0
        vptr=((& A::vtable for A) + 16)
    
    Vtable for B
    B::vtable for B: 4 entries
    0     (int (*)(...))0                   // 类型转换偏移量
    8     (int (*)(...))(& typeinfo for B)  // 运行时类型信息(Run-Time Type Identification,RTTI)
    16    (int (*)(...))A::f0               // 虚函数f0地址(未override基类函数,因此继承自A)
    24    (int (*)(...))B::f1               // 虚函数f1地址
    
    Class B
       size=16 align=8
       base size=16 base align=8
    B (0x0x7f753a00e1a0) 0
        vptr=((& B::vtable for B) + 16)
      A (0x0x7f753a178a20) 0
          primary-for B (0x0x7f753a00e1a0)
    
    Vtable for C
    C::vtable for C: 5 entries
    0     (int (*)(...))0                   // 类型转换偏移量
    8     (int (*)(...))(& typeinfo for C)  // 运行时类型信息(Run-Time Type Identification,RTTI)
    16    (int (*)(...))C::f0               // 虚函数f0地址
    24    (int (*)(...))B::f1               // 虚函数f1地址(未override基类函数,因此继承自B)
    32    (int (*)(...))C::f2               // 虚函数f2地址
    
    Class C
       size=24 align=8
       base size=20 base align=8
    C (0x0x7f753a00e208) 0
        vptr=((& C::vtable for C) + 16)
      B (0x0x7f753a00e270) 0
          primary-for C (0x0x7f753a00e208)
        A (0x0x7f753a178ae0) 0
            primary-for B (0x0x7f753a00e270)
    

    此处需要明确,Class A/B/C均有对应的虚表。
    虚表主要包含三类信息:

    • 类型转换偏移量;
    • 运行时类型信息(Run-Time Type Identification,RTTI);
    • 虚函数地址(可以包含多项),具体信息详见注释部分。

    4.2 多继承

    class A {
        int ax;
        virtual void f0() {}
    };
    class B {
        int bx;
        virtual void f1() {}
    };
    class C : public A, public B {
        virtual void f0() override {}
        virtual void f1() override {}
    };
    

    得到类内存布局如下所示:

    // 因为类A与类B比较简单,因此省略内存布局(可参考单继承内存布局)
    
    Vtable for C
    C::vtable for C: 7 entries
    0     (int (*)(...))0
    8     (int (*)(...))(& typeinfo for C)
    16    (int (*)(...))C::f0
    24    (int (*)(...))C::f1
    32    (int (*)(...))-16                             // 类型转换偏移量
    40    (int (*)(...))(& typeinfo for C)              // 运行时类型信息(Run-Time Type Identification,RTTI)
    48    (int (*)(...))C::non-virtual thunk to C::f1()
    
    Class C
       size=32 align=8
       base size=28 base align=8
    C (0x0x7f9ce2bde310) 0
        vptr=((& C::vtable for C) + 16)
      A (0x0x7f9ce2d37ae0) 0
          primary-for C (0x0x7f9ce2bde310)
      B (0x0x7f9ce2d37b40) 16
          vptr=((& C::vtable for C) + 48)
    

    代码中,类C继承自类A以及类B,内存布局发生了较大的变化(添加了末尾三行)。
    g++的内存布局比较晦涩,使用clang导出内存布局(基本一致),会比较直观:

    *** Dumping AST Record Layout
             0 | struct C
             0 |   struct A (primary base)
             0 |     (A vtable pointer)
             8 |     int ax
            16 |   struct B (base)
            16 |     (B vtable pointer)
            24 |     int bx
               | [sizeof=32, dsize=28, align=8,
               |  nvsize=28, nvalign=8]
    

    由clang的内存布局可知,类C的实例中包含类A与类B的虚指针。
    这是因为A与B完全独立,虚函数f0与f1之间没有顺序关系,相对于基类有着相同的起始位置偏移量。
    因此在类C中,类A与类B的虚表信息必须保存在两个不相交的区域中,使用两个虚指针对其进行索引。

                                                    C Vtable (7 entities)
                                                    +--------------------+
    struct C                                        | offset_to_top (0)  |
    object                                          +--------------------+
        0 - struct A (primary base)                 |     RTTI for C     |
        0 -   vptr_A -----------------------------> +--------------------+
        8 -   int ax                                |       C::f0()      |
       16 - struct B                                +--------------------+
       16 -   vptr_B ----------------------+        |       C::f1()      |
       24 -   int bx                       |        +--------------------+
       28 - int cx                         |        | offset_to_top (-16)|
    sizeof(C): 32    align: 8              |        +--------------------+
                                           |        |     RTTI for C     |
                                           +------> +--------------------+
                                                    |    Thunk C::f1()   |
                                                    +--------------------+
    

    上图比较形象的描绘了虚指针,对应虚表的内容。
    首先解释offset_to_top: 基类转换到派生类时,this指针加上偏移量即可获得实际类型的地址。
    至于Thunk:

    (1) 在B &b = c的场景中,引用的起始地址在C+16处,如果直接调用f1时,会因为this指针多了16字节的偏移量导致错误;
    (2) Thunk提示this指针根据offset_to_top减去16字节偏移量,继而调用f1函数。

    Thunk解释说明,当基类引用持有派生类实例时,调用相应虚函数,会利用到多态特性。

    4.3 菱形继承

    class A {
    public:
        virtual void foo() {}
        virtual void bar() {}
    private:
        int ma;
    };
    class B : virtual public A {
    public:
        virtual void foo() override {}
    private:
        int mb;
    };
    class C : virtual public A {
    public:
        virtual void bar() override {}
    private:
        int mc;
    };
    class D : public B, public C {
    public:
        virtual void foo() override {}
        virtual void bar() override {}
    };
    
    

    基类A中添加了成员变量ma,是因为类A中若不包含成员变量,派生类B/C/D会被优化,较难理解。

    首先查看类B的内存布局:

    *** Dumping AST Record Layout
             0 | class B
             0 |   (B vtable pointer)
             8 |   int mb
            16 |   class A (virtual base)
            16 |     (A vtable pointer)
            24 |     int ma
               | [sizeof=32, dsize=28, align=8,
               |  nvsize=12, nvalign=8]
    

    需要注意,此时类B中包含两个虚指针,且类A的虚指针起始位置为B+16。
    查看类B的虚表结构,如下所示:

    Vtable for 'B' (10 entries).
       0 | vbase_offset (16)
       1 | offset_to_top (0)
       2 | B RTTI
           -- (B, 0) vtable address --
       3 | void B::foo()
       4 | vcall_offset (0)
       5 | vcall_offset (-16)
       6 | offset_to_top (-16)
       7 | B RTTI
           -- (A, 16) vtable address --
       8 | void B::foo()
           [this adjustment: 0 non-virtual, -24 vcall offset offset]
       9 | void A::bar()
    

    此时,虚表头部增加了vbase_offset,这是因为在编译时,无法确定基类A在类B内存中的偏移量,因此需要在虚表中添加vbase_offset,标记运行时基类A在类B内存中的位置。
    此外,虚表中添加了两项vcall_offset,这是应对使用虚基类A的引用调用类B实例的虚函数时,每一个虚函数相对于this指针的偏移量都可能不同,因此需要记录在vcall_offset中。

    • vcall_offset (0): 对应A::bar();
    • vcall_offset (-16): 对应B::foo()。

    因此,当A引用调用B实例的A::bar函数时,因为this指针指向vptr_a,因此不需要进行调整;调用B::foo()时,因此foo函数被B重载,因此需要调整this指针指向vptr_b。

    查看类D的内存布局:

    *** Dumping AST Record Layout
             0 | class D
             0 |   class B (primary base)
             0 |     (B vtable pointer)
             8 |     int mb
            16 |   class C (base)
            16 |     (C vtable pointer)
            24 |     int mc
            32 |   class A (virtual base)
            32 |     (A vtable pointer)
            40 |     int ma
               | [sizeof=48, dsize=44, align=8,
               |  nvsize=28, nvalign=8]
    

    此时,需要注意因为使用虚继承,所以类A在类D中只有一份,共拥有三个虚指针。
    虚表内容相对较为复杂,不过基本可以参照类B的虚表进行解析,具体如下所示:

    Vtable for 'D' (15 entries).
       0 | vbase_offset (32)
       1 | offset_to_top (0)
       2 | D RTTI
           -- (B, 0) vtable address --
           -- (D, 0) vtable address --
       3 | void D::foo()
       4 | void D::bar()
       5 | vbase_offset (16)
       6 | offset_to_top (-16)
       7 | D RTTI
           -- (C, 16) vtable address --
       8 | void D::bar()
           [this adjustment: -16 non-virtual]
       9 | vcall_offset (-32)
      10 | vcall_offset (-32)
      11 | offset_to_top (-32)
      12 | D RTTI
           -- (A, 32) vtable address --
      13 | void D::foo()
           [this adjustment: 0 non-virtual, -24 vcall offset offset]
      14 | void D::bar()
           [this adjustment: 0 non-virtual, -32 vcall offset offset]
    

    5 扩展

    C++的虚表,以及运行时的内存模型是很复杂的问题,在编写的过程中也是不断的刷新自己的认知。
    下面提供一些方式,dump出内存中对象的内存模型,和类型的虚表结构。

    使用clang编译器:clang++ -cc1 -emit-llvm -fdump-record-layouts -fdump-vtable-layouts main.cpp.

    使用gcc编译器:

    g++ -fdump-class-hierarchy -c main.cpp
    // g++ dump的内容比较晦涩,因此需要使用c++ filt导出具有可读性的文档
    cat [g++导出的文档] | c++filt -n > [具有一定可读性的输出文档]
    

    本文内存布局部分,参考于:https://zhuanlan.zhihu.com/p/41309205一文。

    PS:
    如果您觉得我的文章对您有帮助,请关注我的微信公众号,谢谢!
    程序员打怪之路

  • 相关阅读:
    用属性封装 Session 及 VIewState 的存取
    正则表达式的一些重要概念
    通用权限的思路。只是一个简单的思路。
    IBATISNETNET 1.3 开发指南系列文章
    Serializable===net对象序列化
    使用Asp.Net构建安全网站
    用汽车售票系统谈数据库结构设计
    图文描述Vs2005制作WEB应用程序安装包的方法[E8.Net正式用户可以找我们获取全部代码参考]
    《基于.NET平台的分层架构实战》系列文章索引
    javascript中outerHTML innerHTML innerTEXT 三者的区别
  • 原文地址:https://www.cnblogs.com/jason1990/p/12585744.html
Copyright © 2020-2023  润新知