现代C++和内存模型视角下理解虚函数

现代C++和内存模型视角下理解虚函数

虚函数速度慢在哪里

  • 函数调用多一层: 虚函数调用比普通函数多了一个查询虚表,获取虚函数入口的步骤,比普通的函数调用要更耗时。
  • 难做编译器优化: 具体调用哪个虚函数是运行时状态决定的,编译器很难做一些优化,比如PGO,自动inline,RVO等,当然这个在某些特殊情况下不是的,可以看”2.虚函数不一定是运行期才绑定
  • Cache Miss: 调用虚函数的过程,要访问虚表的内存,这部分内存很可能距离局部数据较远,会导致更高概率的cache miss,影响部分性能
  • 分支预测失败: 查找虚函数表时,需要根据函数指针去匹配对应的函数是否是目标虚函数,匹配的过程带来了分支,这会导致分支预测器预测失败概率变高,从而引发CPU流水线被冲刷。
    • 深入理解CPU的分支预测(Branch Prediction)模型 - 杨超越的文章 - 知乎 https://zhuanlan.zhihu.com/p/22469702
    • 如果没有分支预测器,处理器将会等待分支指令通过了指令流水线的执行阶段,才把下一条指令送入流水线的第一个阶段—取指令阶段(fetch stage)。这种技术叫做流水线停顿(pipeline stalled)或者流水线冒泡(pipeline bubbling)或者分支延迟间隙。这是早期的RISC体系结构处理器采用的应对分支指令的流水线执行的办法。而分支预测器猜测条件表达式两路分支中哪一路最可能发生,然后推测执行这一路的指令,来避免流水线停顿造成的时间浪费。如果后来发现分支预测错误,那么流水线中推测执行的那些中间结果全部放弃,重新获取正确的分支路线上的指令开始执行,这招致了程序执行的延迟。

虚函数不一定是运行期绑定

编译器很聪明,会帮你做静态

  • 虚函数运行期绑定的性质只有在指针或者引用下能用,通过值调用的虚函数是编译器静态绑定,是没有运行期绑定的性质的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    void f();
    void g();

    struct Base
    {
    virtual void virtualFun()
    {
    f();
    }
    };

    struct Derived : Base
    {
    virtual void virtualFun()
    {
    g();
    }
    };

    void fun(Base b)
    {
    b.virtualFun();
    }
  • final关键字:当对一个final关键字定义的类对象进行虚函数调用,此时虚函数也是确定的,即进行了静态绑定

  • 指定了限定符:p->Base::virtualFun(); 此时也不表现多态,故是不会在运行期去查找虚表的。

  • 生命周期比较局部的对象,从理论上讲,其虚函数调用完全可以在编译期就得到确定,一些编译器优化下也可以做到静态绑定

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    struct Base
    {
    virtual void virtualFun()
    {
    f();
    }
    };

    struct Derived : Base
    {
    virtual void virtualFun()
    {
    g();
    }
    };

    int main()
    {
    Derived * p = new Derived();
    Base * pb = p;
    pb->virtualFun();
    delete p;
    }

    image-20240612125401736

  • C++20 constexpr放宽了 new/delete 和虚函数,这意味着虚函数更不一定动态绑定了

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    struct Base
    {
    constexpr virtual ~Base() {}
    constexpr virtual int virtualFun()
    {
    return 1;
    }
    };

    struct Derived : Base
    {
    constexpr virtual ~Derived() {}
    constexpr virtual int virtualFun()
    {
    return 2;
    }
    };

    constexpr int f()
    {
    Base * p = new Derived();
    int r = p->virtualFun();
    delete p;
    return r;
    }

    int main()
    {
    constexpr int r = f();
    static_assert(r == 2, ""); // 静态断言通过!
    return r;
    }

简单来说动态类型确定了就没必要运行期绑定哦,相信编译器优化。

虚继承

解决菱形继承问题:

有这样一份代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Animal {
public:
virtual void eat();
};

class Mammal : public Animal {
public:
virtual void breathe();
};

class WingedAnimal : public Animal {
public:
virtual void flap();
};

// A bat is a winged mammal
class Bat : public Mammal, public WingedAnimal {
};

Bat bat;

​ 按照上面的定义,调用bat.eat()是有歧义的,因为在Bat中有两个Animal基类(间接的),所以所有的Bat对象都有两个不同的Animal基类的子对象。因此,尝试直接引用Bat对象的Animal子对象会导致错误,因为该继承是有歧义的:

使用虚继承:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Animal {
public:
virtual void eat();
};

// Two classes virtually inheriting Animal:
class Mammal : public virtual Animal {
public:
virtual void breathe();
};

class WingedAnimal : public virtual Animal {
public:
virtual void flap();
};

// A bat is still a winged mammal
class Bat : public Mammal, public WingedAnimal {
};

Bat::WingedAnimal中的Animal部分现在和Bat::Mammal中的Animal部分是相同的了,这也就是说Bat现在有且只有一个共享的Animal部分,所以对于Bat::eat()的调用就不再有歧义了。另外,直接将Bat实例分派给Animal实例的过程也不会产生歧义了,因为现在只存在一种可以转换为AnimalBat实体了。

​ 因为Mammal实例的起始地址和其Animal部分的内存偏移量直到程序运行分配内存时才会明确,所以虚继承应用给MammalWingedAnimal建立了虚表(vtable)指针(“vpointer”)。因此“Bat”包含vpointer, Mammal, vpointer, WingedAnimal, Bat, Animal。这里共有两个虚表指针,其中最派生类的对象地址所指向的虚表指针,指向了最派生类的虚表;另一个虚表指针指向了WingedAnimal的类的虚表。Animal虚继承而来。在上面的例子里,一个分配给Mammal,另一个分配给WingedAnimal。因此每个对象占用的内存增加了两个指针的大小,但却解决了Animal的歧义问题。所有Bat类的对象都包含这两个虚指针,但是每一个对象都包含唯一的Animal对象。假设一个类Squirrel声明继承了Mammal,那么Squirrel中的Mammal对象的虚指针和Bat中的Mammal对象的虚指针是不同的,尽管他们占用的内存空间大小是相同的。这是因为在内存中MammalAnimal的距离是相同的。虚表不同而实际上占用的空间相同

构造函数可以为虚函数吗

肯定是不可以的,因为虚表指针也是在构造函数里面初始化的,没有初始化的虚表指针无法调用虚函数。

析构函数可以为虚函数吗?

可以,更是应该被提倡。 如果我们需要删除一个指向派生类的基类指针时,应该把析构函数声明为虚函数。事实上,只要一个类有可能会被其它类所继承,就应该声明虚析构函数(哪怕该析构函数不执行任何操作)。

构造函数可以调用虚函数吗

不建议,但是可以:

派生类对象构造期间进入基类的构造函数时,对象类型变成了基类类型,而不是派生类类型。即在构造函数中调用虚函数,这个虚函数不会呈现出多态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include<iostream>
using namespace std;

class Base
{
public:
Base()
{
Fuction();
}

virtual void Fuction()
{
cout << "Base::Fuction" << endl;
}
};

class A : public Base
{
public:
A()
{
Fuction();
}

virtual void Fuction()
{
cout << "A::Fuction" << endl;
}
};

// 这样定义一个A的对象,会输出什么?
int main()
{
A a;
}
//output:
//Base::Fuction
//A::Fuction

析构函数可以调用虚函数吗

不建议,但是可以:

  • 当调用继承层次中某一层次的类的析构函数时其派生类部分已经析构掉,所以也不会呈现出多态。
  • 从派生类析构函数调用基类析构函数时,这个对象的派生类数据成员就被视为未定义的值,因此析构是按照从派生类到基类进行析构,派生类的析构函数中可能已经销毁了某些数据成员, 基类析构函数里要是用到这些值就很可能会出现问题。

关于虚表

一些需要知道的事情

1、虚函数表是Class Specific的,也就是针对一个类来说的,即它是属于一个类所有对象的,不是属于某一个对象特有的,是一个类所有对象共有的。

2、虚函数表是编译器来选择实现的,编译器的种类不同,可能实现方式不一样。 比如vptr放在内向内存布局中的何处并不确定,不过目前GCC 和MSVC编译器都是将vptr放在对象内存布局的最前面。所以下文只能给出一个实现的指导,而不是所有编译器都是这样的。

3、虚函数表vtable在Linux/Unix中存放在可执行文件的只读数据段中(.rodata),这与微软的编译器将虚函数表存放在常量段存在一些差别。虚函数表在编译期生成在.rodata只读数据段,在C++概念中五大分区的常量区。 虚函数表指针是在对象的内存里的,sizeof就会发现有虚函数就有虚表的类会大上 8 字节(64位下)

image-20230905232747852

虚表结构

先讨论一些常见例子中 对象是如何调用到对应的虚函数。

1.单继承且本身不存在虚函数的派生类内存布局:

1
2
3
4
5
6
7
8
9
10
11
12
class Base1 {
public:
int base1_1, base1_2;

virtual void base1_fun1() {} // 定义虚函数
virtual void base1_fun2() {}
};

class Derive1 : public Base1 { // Derive1 中不存在虚函数
public:
int derive1_1, derive1_2;
};

image-20240612150350130

2.单继承且存在基类虚函数重写的派生类内存布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base1 {
public:
int base1_1, base1_2;

virtual void base1_fun1() {}
virtual void base1_fun2() {}
};

class Derive1 : public Base1 {
public:
int derive1_1, derive1_2;

virtual void base1_fun1() {} // 派生类函数覆盖基类中同名函数
};

image-20240612150452989

3.单继承且派生类存在属于自己的虚函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base1 {
public:
int base1_1, base1_2;

virtual void base1_fun1() {}
virtual void base1_fun2() {}
};

class Derive1 : public Base1 {
public:
int derive1_1, derive1_2;

virtual void derive1_fun1() {} // 派生类存在属于自己的虚函数
};

image-20240612150521213

4.多继承且存在虚函数覆盖同时又存在自身定义的虚函数的派生类对象布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Base1 {
public:
int base1_1, base1_2;

virtual void base1_fun1() {}
virtual void base1_fun2() {}
};

class Base2 {
public:
int base2_1, base2_2;

virtual void base2_fun1() {}
virtual void base2_fun2() {}
};

class Derive1 : public Base1, public Base2 { // Derive 1 分别从 Base 1 和 Base2 继承过来
public:
int derive1_1, derive1_2;

virtual void base1_fun1() {}
virtual void base2_fun2() {}

virtual void derive1_fun1() {}
virtual void derive1_fun2() {}
};

image-20240612150659233

上文中只是简单的告诉大家虚函数是分布在哪儿个虚表中,实际上虚表的内容远不止virtual function pointers这些内容。

虚表中包含的内容有:

  • 紫色线框中的内容仅限于虚拟继承的情形(若无虚拟继承,则无此内容)
    • Virtual call offset:虚拟调用偏移。当一个class存在虚基类时,编译器便会在vtable中插入vcall offset,针对在虚基类或者虚基类的基类中声明的virtual function,为了通过虚基类调用virtual function所执行的this指针调整
    • virtual base offset:当一个class存在虚基类时,编译器便会在primary virtual table中安插相应的vbase offset,用于访问对象的虚基类子对象
  • “offset to top”是指到对象起始地址的偏移值,只有多重继承的情形才有可能不为0,单继承或无继承的情形都为0。
  • “RTTI information”是一个对象指针,它用于唯一地标识该类型。
  • “virtual function pointers”也就是我们之前理解的虚函数表,其中存放着虚函数指针列表。

image-20240612160451725

下面会介绍一些场景,来解释虚表结构中的各个部分是如何被应用的。

多继承又是如何找到虚函数的——Offset to top 与 Thunk

上面我们提到了offset to top,是指到对象起始地址的偏移值,只有多重继承的情形才有可能不为0,单继承或无继承的情形都为0。这也说明了多继承找到虚函数是通过offset to top实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct A
{
int ax;
virtual void f0() {}
};

struct B
{
int bx;
virtual void f1() {}
};

struct C : public A, public B
{
int cx;
void f0() override {}
void f1() override {}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
                                                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() |
+--------------------+

​ 在上图所示的布局中,CA作为主基类,也就是C将它虚函数“并入”A的虚函数表之中,并将A的虚指针作为C的内存起始地址。而类型B的虚指针vptr_B并不能直接指向虚表中的第4个实体,这是因为vptr_B所指向的虚表区域,在格式上必须也是一个完整的虚表。因此,需要为vptr_B创建对应的虚表放在虚表A的部分之后 。

​ 至此,如果一个类型A 的引用持有了实际类型为C的变量,调用虚函数时直接调用虚表指针A所至即可。

​ 但如果一个类型B 的引用持有了实际类型为C的变量,这个引用的起始地址在C+16处。当它调用由类型C重写的函数f1()时,如果直接使用this指针调用C::f1()会由于this指针的地址多出16字节的偏移量导致错误,所以这里还需要一个Thunk

​ 即在调用之前,this指针必须要被调整至正确的位置 。这里的Thunk起到的就是这个作用:首先将this 指针调整到正确的位置,即减少16字节偏移量,然后再去调用函数C::f1()

​ 到此我们说明了Thunk的作用,也知道了OffsetToTop表示实际类型起始地址到当前这个形式类型起始地址的偏移量。

虚拟继承——Virtual call offset/Virtual base offsets

​ 上述的模型中,对于派生类对象,它的基类相对于它的偏移量总是确定的,因此动态向下转换并不需要依赖额外的运行时信息。而虚继承破坏了这一条件。它表示虚基类相对于派生类的偏移量可以依实际类型不同而不同,且仅有一份拷贝,这使得虚基类的偏移量在运行时才可以确定。因此,我们需要对继承了虚基类的类型的虚表进行扩充,使其包含关于虚基类偏移量的信息。

虚拟继承下,class B 的内存布局不再是 class A 的内容在最前面然后紧接着 class B 的内容,而是先是 class B 的内容,然后再接着 class A 的内容 。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct A
{
int ax;
virtual void f0() {}
virtual void bar() {}
};

struct B : virtual public A /****************************/
{ /* */
int bx; /* A */
void f0() override {} /* v/ \v */
}; /* / \ */
/* B C */
struct C : virtual public A /* \ / */
{ /* \ / */
int cx; /* D */
void f0() override {} /* */
}; /****************************/

struct D : public B, public C
{
int dx;
void f0() override {}
};

首先对类型A的内存模型进行分析。由于虚继承影响的是子类,不会对父类造成影响,因此A的内存布局和虚表都没有改变。

1
2
3
4
5
6
7
8
9
10
                                                   A VTable
+------------------+
| offset_to_top(0) |
struct A +------------------+
object | RTTI for A |
0 - vptr_A --------------------------------> +------------------+
8 - int ax | A::f0() |
sizeof(A): 16 align: 8 +------------------+
| A::bar() |
+------------------+

B,C内存模型一样,这里以B为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
                                          B VTable
+---------------------+
| vbase_offset(16) |
+---------------------+
| offset_to_top(0) |
struct B +---------------------+
object | RTTI for B |
0 - vptr_B -------------------------> +---------------------+
8 - int bx | B::f0() |
16 - struct A +---------------------+
16 - vptr_A --------------+ | vcall_offset(0) |x--------+
24 - int ax | +---------------------+ |
| | vcall_offset(-16) |o----+ |
| +---------------------+ | |
| | offset_to_top(-16) | | |
| +---------------------+ | |
| | RTTI for B | | |
+--------> +---------------------+ | |
| Thunk B::f0() |o----+ |
+---------------------+ |
| A::bar() |x--------+
+---------------------+

​ 对于形式类型为B的引用,在编译时,无法确定它的基类A在内存中的偏移量。 因此,需要在虚表中额外再提供一个实体,表明运行时它的基类所在的位置,这个实体称为vbase_offset,位于offset_to_top上方。

​ 这个的作用在于,当D构造时,先构造A,然后是BC,在这个过程中:轮到构造B的时候怎么去找到已经构造好的A呢?通过virtual-base offset,告诉 this 指针偏移多少字节去拿。

​ 除此之外,如果在B中调用A声明且B没有重写的函数,由于A的偏移量无法在编译时确定,而这些函数的调用由必须在A的偏移量确定之后进行, 因此这些函数的调用相当于使用A的引用调用。也因此,当使用虚基类A的引用调用重载函数时 ,每一个函数对this指针的偏移量调整都可能不同,它们被记录在镜像位置的vcall_offset中。例如,调用A::bar()时,this指针指向的是vptr_A,正是函数所属的类A的位置,因此不需要调整,即vcall_offset(0);而B::f0()是由类型B实现的, 因此需要将this指针向前调整16字节。

  • 在派生类调用没有重写的虚基类中函数,需要用到vcall_offset
  • virtual-base offset 用于获取虚基类,在构造派生类时,可以通过这个信息告诉 this 指针偏移多少字节去拿已经构造好的虚基类。

​ 对于类型D,它的虚表更为复杂,但虚表中的实体我们都已熟悉。 以下为D的内存模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
                                          D VTable
+---------------------+
| vbase_offset(32) |
+---------------------+
struct D | offset_to_top(0) |
object +---------------------+
0 - struct B (primary base) | RTTI for D |
0 - vptr_B ----------------------> +---------------------+
8 - int bx | D::f0() |
16 - struct C +---------------------+
16 - vptr_C ------------------+ | vbase_offset(16) |
24 - int cx | +---------------------+
28 - int dx | | offset_to_top(-16) |
32 - struct A (virtual base) | +---------------------+
32 - vptr_A --------------+ | | RTTI for D |
40 - int ax | +---> +---------------------+
sizeof(D): 48 align: 8 | | D::f0() |
| +---------------------+
| | vcall_offset(0) |x--------+
| +---------------------+ |
| | vcall_offset(-32) |o----+ |
| +---------------------+ | |
| | offset_to_top(-32) | | |
| +---------------------+ | |
| | RTTI for D | | |
+--------> +---------------------+ | |
| Thunk D::f0() |o----+ |
+---------------------+ |
| A::bar() |x--------+
+---------------------+

RTTI与虚表—RTTI Information

RTTI是编译器提供的功能。打开RTTI编译选项,编译器的行为如下:

启用RTTI(默认设置)

  • 有虚函数的类,编译器自动生成RTTI相关的全局变量(符号为typeinfo for ClassName);
  • 没有虚函数的类,但使用了typeid和dynamic_cast, 编译器也会生成RTTI相关的全局变量;

其中RTTI对于有虚函数的类生成的全局变量typeinfo存储与虚表中, 虚表里面存放着 typeinfo 指针,指向实际的 typeinfo 内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
typeinfo for Child*:
.xword _ZTVN10__cxxabiv119__pointer_type_infoE+16
.xword typeinfo name for Child*
.word 0
.zero 4
.xword typeinfo for Child
typeinfo name for Child*:
.string "P5Child"
typeinfo for Child:
.xword _ZTVN10__cxxabiv121__vmi_class_type_infoE+16
.xword typeinfo name for Child
.word 0
.word 2
.xword typeinfo for Mother
.xword 2
.xword typeinfo for Father
.xword 2050
typeinfo name for Child:
.string "5Child"
typeinfo for Father:
.xword _ZTVN10__cxxabiv117__class_type_infoE+16
.xword typeinfo name for Father
typeinfo name for Father:
.string "6Father"
typeinfo for Mother:
.xword _ZTVN10__cxxabiv117__class_type_infoE+16
.xword typeinfo name for Mother
typeinfo name for Mother:
.string "6Mother"

这部分内容是紧接在虚表后面的。

正常的编译后,只会有第 9 行及之后的内容,这些是用来在运行时获取描述类的信息的。

Reference: