Understanding dynamic/static binding in C++

绑定(binding)的含义就是把类的对象和成员函数连接在一起。当我们让类 C 的对象 obj 调用成员函数 func() 时,我们就说对象 obj 和 func() 绑定在了一起。如果这个过程发生在编译期,那么就被称为静态绑定(static binding,early binding or compile time binding),如果在发生在运行时,那么就称为动态绑定(dynamic binding,late binding or dynamic dispatch)。虚函数和非虚函数的一个区别就是非虚函数是编译期绑定的,而虚函数是动态绑定的。

我们可以来看一个复杂一点的例子:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include <stdio.h>

class Base
{
public:
void print() {
printf("Base: print\n");
}

void callPrint() {
print();
}

virtual void vPrint() {
printf("Base: virtual print\n");
}

virtual void vPrintNotOverride() {
printf("Base: virtual print not override\n");
}

void vCallPrint() {
vPrint();
vPrintNotOverride();
}
};

class Derived : public Base
{
public:
void print() {
printf("Derived: print\n");
}

void vPrint() override {
printf("Derived: virtual print\n");
}
};

int main(int argc, char const *argv[])
{
Base b;
Derived d1;
Derived d2;

Base *pb_d1 = &d1;
Derived *pd_d1 = &d1;

b.callPrint(); // Base: print
b.vCallPrint(); // Base: virtual print
// Base: virtual print not override

d1.callPrint(); // Base: print
d1.vCallPrint(); // Derived: virtual print
// Base: virtual print not override

pb_d1->print(); // Base: print
pb_d1->callPrint(); // Base: print
pb_d1->vCallPrint(); // Derived: virtual print
// Base: virtual print not override

pd_d1->print(); // Derived: print
pd_d1->callPrint(); // Base: print
pd_d1->vCallPrint(); // Derived: virtual print
// Base: virtual print not override
return 0;
}

下面的图大致描述了上面代码的内存结构。左边表示对象 b, d1 和 d2 在内存数据段占用连续三块区域,中间的 v_table 存储在内存静态区,右边是函数实现,存储在内存代码段。


在上面这个例子中,Base class 和 Derived class 都有 virtual member function,因此这两个类都有对应的 virtual function table(v-table),也就是下图中的 Base::v_ableDerived::v_table。相应的,它们的实例 b, d1 和 d2 也都有一个隐藏的指针 __v_ptr 指向对应的 v-table 的首地址。

下面先来看一下静态绑定。

d1.print(), d1.callPrint(), pb_d1->callPrint()pd_d1->callPrint() 等调用没有 virtual 修饰的函数都属于静态绑定,编译器在编译时根据调用对象或者指针的类型来确定相应的调用函数。Base 类中 print 没有 virtual 修饰,表明它的绑定是在编译期确定的,在 callPrint 调用 print 时,print 函数的地址是写死在 callPrint 中的。因为 Derived 类没有覆写 Base 类中的 callPrint 函数,所以它们共享 Base 类的 callPrint 代码,所以都会调用 Base 的 print 函数。

其他以 v 开头的函数都是被 virtual 修饰的虚函数,在调用时需要动态绑定。实际调用的函数取决于调用对象的类型。如果基类指针指向衍生类的一个对象,在调用虚函数时,程序会根据被指向的对象类型判断出应该调用衍生类中覆写的函数。

那么为什么需要在运行时来绑定呢?下面这个小例子可以很容易的说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(int argc, char const *argv[])
{
Base b, *ptr;
Derived d;

if (std::cin.get() == 'b') {
ptr = &b;
} else {
ptr = &d1;
}
ptr->vPrint(); // (Depend on user input)
ptr->print(); // Base: print

return 0;
}

在编译 ptr->vPrint() 这一行时,编译器并不能确定用户输入的是不是 'b',因此也不能确定 ptr 指向的是 Base 还是 Derived 对象,进而也不能确定具体调用的是哪一个 vPrint() 函数。

为了解决这个问题,C++ 引入了 virtual function table(v-table)的概念。每一个包含 virtual function 的 class 或者从其继承而来的 class 都要维护一个 v-table(本质上就是一个函数指针数组)。而这一类 class 的实例都要在最前端存储一个指针 __v_ptr (64 位程序要占用 8 Byte)指向其类型的 v-table 的首地址。编译器在编译 Derived class 时,要先拷贝 Base classv-table,如果 Derived class 中有覆写 Base class 中的 virtual function(如例子中的 Derived::vPrint 函数),要将 Derived classv-tablepf_vprint 指针替换为 Derived::vPrint 的代码首地址。每次调用 virtual 函数时,都要先去查看调用对象的 __v_ptr 指针,找到 v-table,遍历 v-table 最终找到需要调用的 virtual function 的地址。这种机制确保了被调用的 virtual function 一定是继承层级最接近的。

在上面的例子中,不管是 d1.vCallPrint()pb_d1->vCallPrint() 还是 pd_d1->vCallPrint(),都要执行下面的步骤:

  1. vCallPrint 没有被 virtual 修饰,静态绑定,直接访问 Base/Derived 在代码段中共享的 vCallPrint 函数
  2. vCallPrint 调用 vPrintvPrint 是 virtual function,要去查找调用对象 d1 的 __v_ptr ,找到了 Derived::v_tablepf_v_print 函数指针
  3. 调用 Derived::v_tablepf_v_print 指向的 Derived::vPrint 函数
  4. 打印 “Derived: virtual print”
  5. vPrintNotOverridevPrint 类似,不过由于 Derived 没有覆写,pf_v_print_not 指向的是 Base::vPrintNotOverride 函数

上面就是静态绑定和动态绑定的一些基本区别。在使用 virtual function 时需要注意的是:

  1. 会造成额外的内存访问 (v-table)
  2. 额外的内存空间(__v_ptr
  3. virtual function 会程序行为不可预测,迫使 CPU 流水线停止(某些架构可以防止这种情况)
  4. 目前绝大多数编译器都不能对 virtual function 优化

因此当 virtual function 中代码数量较少(少于 25 FLOPS)或者频繁被调用时,都要慎用。