在C++中,如果一个类有虚函数,那么该类的对象在实例化后,会多一个指向虚函数表(virtual table, vtbl)的指针(vitrual table pointer, vptr)。虚函数表及虚函数调用是C++实现多态或者说动态分发(dynamic dispatch)的基础。本文首先介绍一下C++中的对象的虚函数指针以及类的虚函数表的结构,然后介绍了通过使用虚函数表实现动态分发的原理,最后讨论了虚函数调用的性能。

虚函数表及虚函数表指针


虚函数表可以理解为一个函数指针数组,数组中每一项都是虚函数的地址。下面以代码来说明虚函数表的布局,如下有三个类,分别有一些虚函数和普通函数,代码如下:

#include <iostream>
using namespace std;

class A
{
public:
    virtual void vfunc1()
    {
        cout << "A:vfunc1()" << endl;
    }
    virtual void vfunc2()
    {
        cout << "A:vfunc2()" << endl;
    }
    void func1()
    {
        cout << "A:func1()" << endl;
    }
    void func2()
    {
        cout << "A:func2()" << endl;
    }
    virtual ~A()
    {
        cout << "A::dtor" << endl;
    }

private:
    int m_data1, m_data2;
};

class B : public A
{
public:
    virtual void vfunc1()
    {
        cout << "B:vfunc1()" << endl;
    }
    void func2()
    {
        cout << "B:func2()" << endl;
    }

private:
    int m_data3;
};

class C : public B
{

public:
    virtual void vfunc1()
    {
        cout << "C:vfunc1()" << endl;
    }
    void func2()
    {
        cout << "C:func2()" << endl;
    }

private:
    int m_data1, m_data4;
};

int main()
{
    A* pa = new A();
    A* pb = new B();
    A* pc = new C();
    std::cout << "Hello World!\n";
}

对象A里面有两个普通方法func1和func2,对这两个方法的调用,在编译器编译代码的时候,就能获得方法的确切地址,因此可以直接把方法的地址编码到方法调用的地方,使用一个call指令并提供这个方法的地址就可以实现方法调用。对这些普通方法的调用是在编译时候就确定好的,所以这也叫静态分发(static dispatch)或者叫早绑定(early  binding)。

但对于对象A里面的以virtual修饰的虚方法,类B继承A之后,B有可能会重写A里面的虚方法。假如实例化了一个A的指针pa,但其指向的是类型B,那么当调用pa->vfunc1时,如果按照静态绑定,那么就会调用A::vfunc1方法,这不是我们想要的。因为pa指向的实际对象是B,且B恰好覆写了vfunc1,提供了自己的实现。因此B::vfunc1应该被调用。

考虑到子类可能会覆写父类的虚函数,当使用基类的指针或者引用去调用一个虚方法时,在编译时不能确定该调用哪个方法,编译器必须找到最确定的那个函数定义,这就是动态绑定(dynamic dispatch),或者叫晚绑定(late method binding)。

所以,对于每个包含有虚函数的类,编译器都会构造一个虚函数表(virtual table, vtable),这个表里包含有每个虚方法的调用指针。

▲ 类A、B和C的虚函数表

类的虚函数表记录了该类中所有虚方法的调用地址。子类如果覆写了父类的虚函数,那么子类的虚函数表只会存储覆写的虚函数。子类从父类继承但没有覆写的虚函数则指向父类的虚函数的调用地址,在上图中,可以看到对象B和C都重写了父类的虚函数vfunc1,所以他们的虚函数表中指向了自己覆写后的虚函数地址。虚函数表中的每个函数指针指向的是最新的被覆写过的虚函数地址。而通过继承而来没有覆写的虚函数vfunc2则指向了基类A中的vfunc2的地址。另外,在基类A中提供了虚析构函数,所以每个子类中都会有自己的虚析构函数。

在GCC中,可以通过以下指令生成类的结构图文件a0main.cpp.001l.class:

g++ -fdump-lang-class main.cpp

在生成的文件中,找到相关的类,可以看到他们的虚函数表的布局:

Vtable for A  //A的虚函数表
A::_ZTV1A: 6 entries
0     (int (*)(...))0             //top_offset 多继承情况下使用
8     (int (*)(...))(& _ZTI1A)  //RTTI类型,typeid(), dynamic_cast使用
16    (int (*)(...))A::vfunc1
24    (int (*)(...))A::vfunc2
32    (int (*)(...))A::~A
40    (int (*)(...))A::~A

Class A
   size=16 align=8              //对齐后的总大小,内存对齐大小
   base size=16 base align=8 //没有对齐的情况下的大小
A (0x0x78f9420) 0
    vptr=((& A::_ZTV1A) + 16)  //虚函数指针,它并不指向虚函数表的开头,而是指向虚函数表的第一个虚函数的地址

Vtable for B
B::_ZTV1B: 6 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI1B)
16    (int (*)(...))B::vfunc1
24    (int (*)(...))A::vfunc2
32    (int (*)(...))B::~B
40    (int (*)(...))B::~B

Class B
   size=24 align=8
   base size=20 base align=8
B (0x0x792c208) 0
    vptr=((& B::_ZTV1B) + 16)
A (0x0x78f9b40) 0
      primary-for B (0x0x792c208)

Vtable for C
C::_ZTV1C: 6 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI1C)
16    (int (*)(...))C::vfunc1
24    (int (*)(...))A::vfunc2
32    (int (*)(...))C::~C
40    (int (*)(...))C::~C

Class C
   size=32 align=8
   base size=28 base align=8
C (0x0x792c270) 0
    vptr=((& C::_ZTV1C) + 16)
B (0x0x792c2d8) 0
      primary-for C (0x0x792c270)
A (0x0x78f9c60) 0
        primary-for B (0x0x792c2d8)

在GCC中,可以看到虚函数表中有两个析构函数。对象的虚函数表指针指向的位置是虚函数表+16的位置,即虚函数表中第一个类定义的虚函数地址,因为是64位平台,前两个用来存储类型在多重继承时要用到的偏移信息top_offset信息,以及RTII运行时类型识别所需要的typeinfo。注意这里是单继承,如果是多继承,比如C同时继承A和B,则以上布局会有所不同。

在Clang中,可以使用如下命令查看vtable:

clang -Xclang -fdump-vtable-layouts -stdlib=libc++ -std=gnu++11 -c  main.cpp

输出结果如下:

yy@yangyangpc:~/cppstudy/writing/vtable_layout$ clang -Xclang -fdump-vtable-layouts -stdlib=libc++ -std=gnu++11 -c  main.cpp
Vtable for 'A' (6 entries).
   0 | offset_to_top (0)
   1 | A RTTI
       -- (A, 0) vtable address --
   2 | void A::vfunc1()
   3 | void A::vfunc2()
   4 | A::~A() [complete]
   5 | A::~A() [deleting]

VTable indices for 'A' (4 entries).
   0 | void A::vfunc1()
   1 | void A::vfunc2()
   2 | A::~A() [complete]
   3 | A::~A() [deleting]

Vtable for 'B' (6 entries).
   0 | offset_to_top (0)
   1 | B RTTI
       -- (A, 0) vtable address --
       -- (B, 0) vtable address --
   2 | void B::vfunc1()
   3 | void A::vfunc2()
   4 | B::~B() [complete]
   5 | B::~B() [deleting]

VTable indices for 'B' (3 entries).
   0 | void B::vfunc1()
   2 | B::~B() [complete]
   3 | B::~B() [deleting]

Vtable for 'C' (6 entries).
   0 | offset_to_top (0)
   1 | C RTTI
       -- (A, 0) vtable address --
       -- (B, 0) vtable address --
       -- (C, 0) vtable address --
   2 | void C::vfunc1()
   3 | void A::vfunc2()
   4 | C::~C() [complete]
   5 | C::~C() [deleting]

VTable indices for 'C' (3 entries).
   0 | void C::vfunc1()
   2 | C::~C() [complete]
   3 | C::~C() [deleting]

需要说明的是,虚函数表是对应类的,一个类只有一个虚函数表,该类的所有实例都共享一个相同的虚函数表。

在Visual Studio中,能更清楚的看到各个类对象的虚函数表,具体方法就是在上面的main函数的最后一条语句设置断点,然后在Local窗口中观察本地变量。

▲ 对象实例的虚函数表指针,指向的虚函数表信息。

从上面的地址分布也可以看到,对象的指针地址和虚函数表的地址的差别很大。虚函数表位于只读数据段(.rodata),即C++内存模型的常量区,虚函数代码则位于代码段(.text),即C++内存模型的代码区。

现在,当编译器创建一个A或B或C的实例的时候,如果发现类存在虚函数表,则会在创建的实例中通常是起始位置添加一个指向类的虚函数表的指针(virtual table pointer, vptr),这使得每个实例对象都会增加额外的1个指针大小(sizeof(vptr),32位系统占用4个字节,64位系统占用8个字节)。

利用vptr和vtbl就能执行动态分发了。当通过指针调用一个虚函数时,会查找该实例对象的虚函数表,然后根据调用的方法名在虚函数表中查找对应的方法地址,然后调用对应的方法。

▲ 对象实例的内存布局

在Clang中,可以通过以下命令查看对象的内存布局:

clang -Xclang -fdump-record-layouts -stdlib=libc++ -std=gnu++11 -c main.cpp

由于该输出的内容非常庞大,所以通常将他输出到一个文件中,然后继续分析:

clang -Xclang -fdump-record-layouts -stdlib=libc++ -std=gnu++11 -c main.cpp > layouts.txt

然后利用Vim打开该文件,然后进行查找,查找的方法位输入”/“然后接着输入要查找的内容,比如class A,对象A,B,C的输出结果如下:

*** Dumping AST Record Layout
         0 | class A
         0 |   (A vtable pointer)
         8 |   int m_data1
        12 |   int m_data2
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]

*** Dumping AST Record Layout
         0 | class B
         0 |   class A (primary base)
         0 |     (A vtable pointer)
         8 |     int m_data1
        12 |     int m_data2
        16 |   int m_data3
           | [sizeof=24, dsize=20, align=8,
           |  nvsize=20, nvalign=8]

*** Dumping AST Record Layout
         0 | class C
         0 |   class B (primary base)
         0 |     class A (primary base)
         0 |       (A vtable pointer)
         8 |       int m_data1
        12 |       int m_data2
        16 |     int m_data3
        20 |   int m_data1
        24 |   int m_data4
           | [sizeof=32, dsize=28, align=8,
           |  nvsize=28, nvalign=8]

通过VTable实现动态分发


要实现动态分发,必须同时满足以下三个条件:

  • 通过指针或引用调用
  • 向上转型,即通过指向基类的指针或引用。
  • 调用虚函数

 在上面部分,我们看到使用gcc的命令可以显示类的VTable。

g++ -fdump-lang-class main.cpp

在生成的结果中,这里只截取对象C的信息:

............................................................................................
Vtable for C                         //C的虚函数表
C::_ZTV1C: 6 entries
0     (int (*)(...))0                   //top_offset 多继承情况下使用
8     (int (*)(...))(& _ZTI1C)   //RTTI类型,typeid(), dynamic_cast使用
16    (int (*)(...))C::vfunc1
24    (int (*)(...))A::vfunc2
32    (int (*)(...))C::~C
40    (int (*)(...))C::~C

Class C
   size=32 align=8                    //对齐后的总大小,内存对齐大小
   base size=28 base align=8   //没有对齐的情况下的大小
C (0x0x792c270) 0
    vptr=((& C::_ZTV1C) + 16)   //虚函数指针,它并不指向虚函数表的开头,而是指向虚函数表的首地址+16,即第一个虚函数的地址
B (0x0x792c2d8) 0
      primary-for C (0x0x792c270)
A (0x0x78f9c60) 0
..............................................................................................
        primary-for B (0x0x792c2d8)

对象的虚函数表指针指向的是虚函数表的第一个虚函数的地址。在这个虚函数地址前面,还有两个地址,其中第2个地址存放的是类型信息。从上图可以看出,对象C的虚函数表里面存放了6个方法的地址,第一个是top_offset,它在多重继承里会有用,第二个是存放的类型信息,在RTII时会从这里获取信息。从第三个开始就是类里面定义的虚函数,最后两个是虚析构函数,在GCC中,虚函数表里会有两个虚析构函数。

所以我们可以手动获取到对象的虚函数表里的所有函数地址,并进行调用。比如以下代码:

typedef void (*Func)();
int main()
{
    A *pa = new C();
    // a为对象的起始地址,将d解释为8字节为单位的long long*指针
    long long *aptr = reinterpret_cast<long long *>(pa);
    // aptr的指向的第一个元素就是虚函数表指针,将虚函数表指针所指的地址,即虚函数表解析为8个字节为单位的指针数组。
    long long *avptr = reinterpret_cast<long long *>(*aptr);
    // avptr是虚函数表数组的首地址,以longlong为单位遍历每个虚函数,
    for (size_t i = 0; i < 2; i++)
    {
        Func vfunc = reinterpret_cast<Func>(avptr[i]);
        vfunc();
    }
    return 0;
}

代码初始化了一个A对象,但让其指向子类C。通常对象的第一个位置存放的是虚函数表指针。为此首先要将对象的位置解释为8个字节为单位的指针aptr。然后获取第一个8字节地址所指的对象*aptr,这个对象所指的就是类C的虚函数表的第一个虚函数地址,因此需要将它再解释为8个字节为单位的指针avptr,这样再遍历虚函数的时候就可以以数组下标的方式进行遍历了。需要注意的是以上代码我是在64位机器上编译的,一个指针占用8个字节,所以类型选择的是long long,如果在32位机器上,这里应该选择占用4个字节的类型比如long。在上面代码中,我们只调用类C里面定义的两个虚函数,如果调用析构函数会报错。上述代码在MSVC,GCC和Clang编译后,运行结果如下:

C:vfunc1()
A:vfunc2()

输出结果与虚函数表里的信息一致。

另外,我们也可以调用虚函数表里的相关的方法手动获取对象的运行时信息,和上面手动调用虚函数方法类似。不同的是,我们现在需要获取虚函数表指针之前的一个函数,因为默认的虚函数表指针指向的是类中定义的第一个虚函数,在这个之前的就是typeinfo信息,代码如下:

typedef void (*Func)();
int main()
{
    A *pa = new C();
    // a为对象的起始地址,将d解释为8字节为单位的long long*指针
    long long *aptr = reinterpret_cast<long long *>(pa);
    // aptr的指向的第一个元素就是虚函数表指针,将虚函数表指针所指的地址,即虚函数表解析为8个字节为单位的指针数组。
    long long *avptr = reinterpret_cast<long long *>(*aptr);
    // avptr是虚函数表数组的首地址,以longlong为单位遍历每个虚函数,
    for (size_t i = 0; i < 2; i++)
    {
        Func vfunc = reinterpret_cast<Func>(avptr[i]);
        vfunc();
    }
    //std::type_info *tyInfo = reinterpret_cast<std::type_info *>(*(((long long *)avptr) - 1));
    std::type_info *tyInfo = reinterpret_cast<std::type_info *>(avptr[-1]);
    cout << "typeinfo is:" << tyInfo->name() << endl;
    cout << "the result of typeid(*pa).name():" << typeid(*pa).name() << endl;
    return 0;
}

可以看到,avptr是虚函数表中指向的第一个虚函数的位置,我们向前通过下标操作-1(这个下标操作是操作符重载)即可获得typeinfo信息,然后进行适当的类型转换,就可以获得类型。上面的代码在GCC和Clang编译后(在MSVC下代码运行时会报错,可能是对象类型布局实现不同),运行结果如下:

.....
typeinfo is:1C
the result of typeid(*pa).name():1C

可以看到,手动调用虚函数里面的typeinfo信息跟直接调用typeid产生的结果是一样的。

需要注意的是,不同的编译器生成的虚函数表及布局会有所不同,但普遍的是对象的第一个字段是虚函数表指针,这个指针指向的是虚函数表中的第一个类定义的虚函数地址,至于这个地址之前的信息,比如top_offset,或者typeinfo这些,不同的编译器或平台生成的结构会不同,上面的代码在GCC和Clang编译器下是可以正常运行的,但在MSVC编译器下是会报错的。因为在第一个虚函数地址前面不一定是typeinfo信息,我暂时还没找到MSVC中vtable里面的类型信息。

在实际的调用中,对于指针调用虚函数,编译器会生成如下类似的代码,这里面最核心和关键的就是this指针。比如如下代码:

int main()
{
    B b;
    A a = (A)b;
    a.vfunc1();

    A *pa = new B();
    pa->vfunc1();

    pa = &b;
    pa->vfunc1();
    return 0;
}

在VSCode中,最后一行设置断点,然后运行调试,在调试模式下的“Debug Console”窗口中,输入一下命令,即可查看语句对应的汇编代码:

-exec disassemble /m main

逐语句对应的汇编代码如下:

Dump of assembler code for function main():
61	{
   0x00005555555551e9 <+0>:	endbr64 
   0x00005555555551ed <+4>:	push   rbp
   0x00005555555551ee <+5>:	mov    rbp,rsp
   0x00005555555551f1 <+8>:	push   rbx
   0x00005555555551f2 <+9>:	sub    rsp,0x48
   0x00005555555551f6 <+13>:	mov    rax,QWORD PTR fs:0x28
   0x00005555555551ff <+22>:	mov    QWORD PTR [rbp-0x18],rax
   0x0000555555555203 <+26>:	xor    eax,eax

62	    B b;
=> 0x0000555555555205 <+28>:	lea    rax,[rbp-0x30]
   0x0000555555555209 <+32>:	mov    rdi,rax
   0x000055555555520c <+35>:	call   0x5555555553de <_ZN1BC2Ev>

63	    A a = (A)b;
   0x0000555555555211 <+40>:	lea    rdx,[rbp-0x30]
   0x0000555555555215 <+44>:	lea    rax,[rbp-0x40]
   0x0000555555555219 <+48>:	mov    rsi,rdx
   0x000055555555521c <+51>:	mov    rdi,rax
   0x000055555555521f <+54>:	call   0x55555555540c <_ZN1AC2ERKS_>

64	    a.vfunc1();
   0x0000555555555224 <+59>:	lea    rax,[rbp-0x40]
   0x0000555555555228 <+63>:	mov    rdi,rax
   0x000055555555522b <+66>:	call   0x555555555306 <_ZN1A6vfunc1Ev>

65	
66	    A *pa = new B;
   0x0000555555555230 <+71>:	mov    edi,0x18
   0x0000555555555235 <+76>:	call   0x5555555550c0 <_Znwm@plt>
   0x000055555555523a <+81>:	mov    rbx,rax
   0x000055555555523d <+84>:	mov    rdi,rbx
   0x0000555555555240 <+87>:	call   0x5555555553de <_ZN1BC2Ev>
   0x0000555555555245 <+92>:	mov    QWORD PTR [rbp-0x48],rbx

67	    pa->vfunc1();
   0x0000555555555249 <+96>:	mov    rax,QWORD PTR [rbp-0x48]
   0x000055555555524d <+100>:	mov    rax,QWORD PTR [rax]
   0x0000555555555250 <+103>:	mov    rdx,QWORD PTR [rax]
   0x0000555555555253 <+106>:	mov    rax,QWORD PTR [rbp-0x48]
   0x0000555555555257 <+110>:	mov    rdi,rax
   0x000055555555525a <+113>:	call   rdx

68	
69	    pa = &b;
   0x000055555555525c <+115>:	lea    rax,[rbp-0x30]
   0x0000555555555260 <+119>:	mov    QWORD PTR [rbp-0x48],rax

70	    pa->vfunc1();
   0x0000555555555264 <+123>:	mov    rax,QWORD PTR [rbp-0x48]
   0x0000555555555268 <+127>:	mov    rax,QWORD PTR [rax]
   0x000055555555526b <+130>:	mov    rdx,QWORD PTR [rax]
   0x000055555555526e <+133>:	mov    rax,QWORD PTR [rbp-0x48]
   0x0000555555555272 <+137>:	mov    rdi,rax
   0x0000555555555275 <+140>:	call   rdx

71	    return 1;
   0x0000555555555277 <+142>:	mov    eax,0x1

72	}
   0x000055555555527c <+147>:	mov    rdx,QWORD PTR [rbp-0x18]
   0x0000555555555280 <+151>:	sub    rdx,QWORD PTR fs:0x28
   0x0000555555555289 <+160>:	je     0x555555555290 <main()+167>
   0x000055555555528b <+162>:	call   0x5555555550e0 <__stack_chk_fail@plt>
   0x0000555555555290 <+167>:	mov    rbx,QWORD PTR [rbp-0x8]
   0x0000555555555294 <+171>:	leave  
   0x0000555555555295 <+172>:	ret    

End of assembler dump.

这里需要注意的实call指令,它表示一个方法调用,这些调用包括调用对象的默认构造函数,普通函数,虚函数等等。当通过对象直接调用虚方法时,它采用的实静态分发,比如下面这句:

64	    a.vfunc1();
   0x0000555555555224 <+59>:	lea    rax,[rbp-0x40]
   0x0000555555555228 <+63>:	mov    rdi,rax
   0x000055555555522b <+66>:	call   0x555555555306 <_ZN1A6vfunc1Ev>

它的调用地址是一个固定的地址。就是编译器在编译代码的时候,就已经获取到了目标方法的固定地址。

对于通过指针调用的虚方法,它的指令如下:

67	    pa->vfunc1();
   0x0000555555555249 <+96>:	mov    rax,QWORD PTR [rbp-0x48]
   0x000055555555524d <+100>:	mov    rax,QWORD PTR [rax]
   0x0000555555555250 <+103>:	mov    rdx,QWORD PTR [rax]
   0x0000555555555253 <+106>:	mov    rax,QWORD PTR [rbp-0x48]
   0x0000555555555257 <+110>:	mov    rdi,rax
   0x000055555555525a <+113>:	call   rdx

可以看到,最终调用的是64位累加寄存器RAX存储的函数指针,而RAX中的地址在编译器无法确定,是个变化的值。所以,只能在运行期计算后得到RAX中的地址值。累加寄存器中RAX中的地址值是变化的,在运行期才能确定。静态调用的函数地址直接被写死,而多态调用需要在运行期确定具体的调用函数,所以,静态调用一般要比多态调用速度更快。

这就是为什么在C++中被指定的对象的真实类型在每一个特定执行点之前,是无法在编译器解析的,只有通过指针和引用才能完成。相反,如果处理的只是一个类型的实例,它在编译时期就已经完全定义好了。
 

对下面的代码:

int main()
{
    A *pa = new B();
    pa->vfunc1();
    return 0;
}

首先,pa是一个指针,首先找到pa指向的对象,然后程序认为vfunc1是虚函数,所以使用pa->__vptr获取对象B的虚函数表,在B的虚函数表中查找vfunc1的位置(一般通过查找表进行查找),最后执行B::vfunc1的调用。

虚函数调用的效率


所以与调用非虚函数相比,调用虚函数要慢,原因在于调用虚函数需要三个步骤,首先找到指针或者引用指向的对象,然后要通过vptr找到对应的虚函数表,最后在虚函数表中找到对应的要调用的虚方法。而通过指针或者引用调用普通方法或者直接通过对象调用普通方法只需要二个或者一个步骤。 

虚函数表导致的那一次函数间接调用并不浪费时间,所以虚函数的开销并不在重定向上,这一次重定向基本上不影响程序性能。虚函数通常通过虚函数表来实现,在虚表中存储函数指针,实际调用时需要间接访问,这需要多一点时间。然而这并不是虚函数速度慢的主要原因,真正原因是编译器在编译时通常并不知道它将要调用哪个函数,所以它不能被内联优化和其它很多优化,因此就会增加很多无意义的指令(准备寄存器、调用函数、保存状态等),而且如果虚函数有很多实现方法,那分支预测的成功率也会降低很多,分支预测错误也会导致程序性能下降。

C++虚函数调用,可以看到大约和一次L3读差不多,比C里面的直接调用慢一倍左右,但比一次内存访问(在cache miss的情况下)要快不少,工程上大部分情况下虚函数调用不会成为性能瓶颈。

 

参考: