详解cpp虚函数机制
虚函数
多态
所谓多态,其实就是一个函数名具有多种状态,或者同一个接口表现出不同的行为。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
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
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
8struct A {
int data1, data2;
virtual void vfunc1() {}
virtual void vfunc2() {}
void func1();
void func2();
};
此类的成员和虚函数在内存中排布应该如下所示:
动态绑定
首先给出两个类的定义方便我们后面的代码展示:1
2
3
4
5
6
7
8
9
10
11struct Base {
virtual void func() {
puts("Base::func");
}
};
struct Derived : Base {
void func() override {
puts("Derived::func");
}
};
—— 分隔 ——
1 | int main() { |
首先第一份代码是创建两个对象 derived
和 base
。其中,base
是由 derived
强制转换而得到的。对于 derived
的调用结果毋庸置疑。而对于 base
,其调用 func 其实还是调用其本身的 func。虽然它是强制转换而得到的,但是这并不能改变它是一个 Base
对象的事实。
1 | int main() { |
将程序改写为如上。此时 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 之后的
- 调用的为虚函数
既然有动态绑定那也有静态绑定,也就是普通函数的调用,其调用是在编译期确定,不需要查表操作,因此调用速度是快于运行期查表的虚函数的。