虚函数

多态

所谓多态,其实就是一个函数名具有多种状态,或者同一个接口表现出不同的行为。c++ 的多态主要有两种:

  • 编译时多态:重载函数,模板
  • 运行时多态:继承和虚函数

此处我们讨论的也就是运行时多态——虚函数

虚函数表

一个虚函数表不是属于某个对象,而是属于一个类的,就像静态成员变量/方法一样。当一个类中包含一个及以上的虚函数时,此时其就会生成一张虚函数表。如果有子类继承此基类,即使子类没有重写父类任何的虚函数,仍然会生成一张虚表(和父类一样)。下列代码可验证:

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
#include <iostream>

struct Base {
virtual void func() {
puts("Base virtual func");
}
};

struct Derived : Base {

};

int main() {
Base base;
Derived derived;

using ptr_t = unsigned long long*;

auto base_vptr = reinterpret_cast<ptr_t>(&base);
auto derived_vptr = reinterpret_cast<ptr_t>(&derived);

std::cout << std::hex;
std::cout << (*base_vptr) << '\n'; // 0x7ff734f4bf10
std::cout << (*derived_vptr) << '\n'; // 0x7ff734f4bf40

return 0;
}

通过上面这个例子,我们已经可以知道三个点:

  • 虚函数的地址是存放在虚函数表当中的。
  • 每个对象里面会存储一个虚指针,指向对应的虚函数表。
  • 虚函数表是在编译阶段就被确定的。

接下来我们探寻虚函数机制里面的几个细节

虚函数指针是放在哪里的?

先上结论,放在每个对象开头的位置。观察以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

struct Foo1 {
int a, b;
};

struct Foo2 {
int a, b;

virtual void func() {}
};

int main() {
std::cout << offsetof(Foo1, a) << ' ' << offsetof(Foo1, b) << '\n';
// 0 4
std::cout << offsetof(Foo2, a) << ' ' << offsetof(Foo2, b) << '\n';
// 8 12

return 0;
}

可以看出,同为两个类成员 a, b 的偏移量,为何多了一个虚函数开头就多占用了八个字节。这就是我们 vptr 占用的内存(我的编译器是 msvc 64位,指针大小位八字节)。

含虚函数的类的内存排布

这下我们就能大概写出一个含虚函数的类的内存排布了,如:

1
2
3
4
5
6
7
8
struct A {
int data1, data2;

virtual void vfunc1() {}
virtual void vfunc2() {}
void func1();
void func2();
};

此类的成员和虚函数在内存中排布应该如下所示:

动态绑定

首先给出两个类的定义方便我们后面的代码展示:

1
2
3
4
5
6
7
8
9
10
11
struct Base {
virtual void func() {
puts("Base::func");
}
};

struct Derived : Base {
void func() override {
puts("Derived::func");
}
};

—— 分隔 ——

1
2
3
4
5
6
7
8
9
int main() {
Derived derived;
derived.func();

Base base = (Base)derived;
base.func();

return 0;
}

首先第一份代码是创建两个对象 derivedbase。其中,base 是由 derived 强制转换而得到的。对于 derived 的调用结果毋庸置疑。而对于 base,其调用 func 其实还是调用其本身的 func。虽然它是强制转换而得到的,但是这并不能改变它是一个 Base 对象的事实。

1
2
3
4
5
6
7
8
9
10
int main() {
Base* base = new Base;
base->func();

Derived derived;
base = &derived;
base->func();

return 0;
}

将程序改写为如上。此时 base 是一个类 Base 的指针,之后指向了一个类 Derived 的对象。在 base 调用 func 的时候,发现其是一个指针,且调用的 func 是虚函数,那么其就会通过 base->vptr 找到指向对象的虚指针,而其指向的是一个 Derived 类型对象,那么这个虚指针也应该指向的是类 Derived 的虚函数表。因此,就调用到了正确的 Derived::func

那么是如何从虚函数表中找到 func 的地址的:

首先我们需要明确:虚函数表的内容是编译期确定的,然后通过类对象指针 p 找到虚指针 vptr,再找到虚函数表中的第 n 个内容(此 n 为编译期确定)。然后对其调用,并传入对象指针 p,也就是隐式的 this 指针。换成代码可以等价为:

1
2
(*p->vptr[n])(p)
(*(p->vptr)[n])(p)

虚函数的调用也是运行时动态查表确定的,不过此处不严谨,毕竟还有值调用或者 final 关键字优化的情况。

动态绑定主要得符合以下三项:

  • 使用指针进行调用
  • 指针是 up-cast 之后的
  • 调用的为虚函数

既然有动态绑定那也有静态绑定,也就是普通函数的调用,其调用是在编译期确定,不需要查表操作,因此调用速度是快于运行期查表的虚函数的。