欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

虚函数表与多态的认知

程序员文章站 2022-03-25 16:56:09
详细阐述C++的虚函数以及虚表,通过解析内存布局加强对虚表实现的认知 ......

虚函数表与多态

虚函数表与多态,是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实例的内存布局为:

  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:
如果您觉得我的文章对您有帮助,请关注我的微信公众号,谢谢!
虚函数表与多态的认知