本文内容大部分来自:C++虚函数表深入探索(详细全面)
虚函数表简介
虚函数(Virtual Function
)是通过一张虚函数表来实现的。简称为 V-Table
。
在这个表中,主要是一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其真实反应实际的函数。
在有虚函数的类的实例中分配了指向这个表的指针的内存,当用父类的指针来操作一个子类的时候,这张虚函数表就可以指明了实际所应该调用的函数。
虚函数表存在的位置
如果对象存在虚函数,那么编译器就会生成一个指向虚函数表的指针,所有的虚函数都存在于这个表中。
虚函数表就可以理解为一个数组,每个单元用来存放虚函数的地址。
由于虚函数表是由编译器给我们生成的,那么编译器会把虚函数表安插在哪个位置呢?
下面可以简单的写一个示例来证明一下虚函数表的存在,以及观察它所存在的位置,先来看一下代码:
#include
using namespace std;
class A{
public:
int x;
virtual void b() {}
};
int main()
{
A* p = new A;
cout << "sizeof(A) = " << sizeof(A) << endl;
cout << p << endl;
cout << &p->x << endl;
return 0;
}
定义了一个类 A
,含有一个 x
和一个虚函数 b()
。
实例化一个对象,然后输出对象的地址和对象成员 x
的地址,可以通过对象地址和对象成员 x
的地址判断虚函数表的位置:
- 如果对象的地址和
x
的地址相同,那么就意味着编译器把虚函数表放在了末尾 - 如果两个地址不同,那么就意味着虚函数表是放在最前面的。
控制台输出:
sizeof(A) = 16
0x55d10364fc20
0x55d10364fc28
可以观察到结果是不同的,而且正好相差了 8 bytes
(64 位系统中一个指针类型的 sizeof()
)。
由此可见,编译器把生成的虚函数表放在了最前面。
获取虚函数表
既然虚函数表是真实存在的,那么我们能不能想办法获取到虚函数表呢?
其实我们可以通过指针的形式去获得,因为前面也提到了,我们可以把虚函数表看作是一个数组,每一个单元用来存放虚函数的地址,那么当调用的时候可以直接通过指针去调用所需要的函数就行了。
我们就类比这个思路,去获取一下虚函数表。
首先先定义两个类,一个是基类一个是派生类,代码如下:
#include
using namespace std;
class Base {
public:
virtual void a() { cout << "Base a()" << endl; }
virtual void b() { cout << "Base b()" << endl; }
virtual void c() { cout << "Base c()" << endl; }
};
class Derive : public Base {
public:
virtual void b() { cout << "Derive b()" << endl; } // 覆盖Base::b()
virtual void d() { cout << "Derive d()" << endl; }
};
// void a()
// void b()
// void c()
// 可以表示为如下的函数指针
typedef void(*Fun)(void);
现在我们设想一下 Derive
类中的虚函数表是什么样的,它应该是含有三个指针,分别指向基类的虚函数 a()
和 c()
以及自己的虚函数 b()
(因为基类和派生类中含有同名函数,被覆盖)和 e()
。
那么我们就用下面的方式来验证一下:
int main()
{
Derive* p = new Derive;
cout << "Derive对象所占的内存大小为:" << sizeof(d) << endl;
printf("sizeof(Fun) = %d\n", sizeof(Fun));
// 把 p 指针转换为 (Fun**) 指针
// 用来表示虚函数表
// vt_ptr 表示虚函数表数组
// *vt_ptr 表示虚函数表的函数指针
// **vt_ptr 表示虚函数的实际地址
Fun** vt_ptr = (Fun**)p;
cout << "-----------打印虚函数地址------------" << endl;
for (int i = 0; i < 5; i++) {
printf("vptr[%d] : %p\n", i, *(*vt_ptr + i));
}
cout << "-----------调用虚函数------------" << endl;
for (int i = 0; i < 4; i++) {
// 调用函数指针指向的虚函数
(*(*vt_ptr + i))();
}
return 0;
}
控制台输出:
Derive对象所占的内存大小为:8
sizeof(Fun) = 8
-----------打印虚函数地址------------
vptr[0] : 0x55f3b1dd5d3c
vptr[1] : 0x55f3b1dd5de4
vptr[2] : 0x55f3b1dd5dac
vptr[3] : 0x55f3b1dd5e1c
vptr[4] : (nil)
-----------调用虚函数------------
Base a()
Derive b()
Base c()
Derive d()
提示:
vptr[4] : (nil)
表示这一个内存地址没有引用虚函数。
每个类都有自己的虚函数表指针
,指向自己的虚函数表
。
如上图所示,Derive
的虚函数表就是继承了 Base
的虚函数表,然后自己的虚函数放在后面,因此这个虚函数表的顺序就是基类的虚函数表中的虚函数的顺序 + 自己的虚函数的顺序
。
【小知识】
因为 sizeof(long)
和 sizeof(Fun)
的值都是 8 bytes
。
因此如果我们只需要获得函数的地址的情况下,可以用 (long **)
表示虚函数表的类型。
如下代码也是可行的:
int main()
{
printf("sizeof(long) = %d\n", sizeof(long));
Derive* p = new Derive;
long** vt_ptr = (long**)p;
for (int i = 0; i < 5; i++) {
printf("vptr[%d] : %p\n", i, *(*vt_ptr + i));
}
return 0;
}
同理,我们把基类的虚函数表的内容也用这种方法获取出来,然后二者进行比较一下,看看是否是符合我们上面所说的那个情况。
先看一下测试代码:
int main()
{
cout << "-----------Base------------" << endl;
Base* q = new Base;
long** vt_ptr = (long**)q;
for (int i = 0; i < 3; i++) {
printf("vptr[%d] : %p\n", i, *(*vt_ptr + i));
}
Derive* p = new Derive;
vt_ptr = (long**)p;
cout << "---------Derive------------" << endl;
for (int i = 0; i < 4; i++) {
printf("vptr[%d] : %p\n", i, *(*vt_ptr + i));
}
return 0;
}
控制台输出:
-----------Base------------
vptr[0] : 0x55d6eed5ad46
vptr[1] : 0x55d6eed5ad7e
vptr[2] : 0x55d6eed5adb6
---------Derive------------
vptr[0] : 0x55d6eed5ad46
vptr[1] : 0x55d6eed5adee
vptr[2] : 0x55d6eed5adb6
vptr[3] : 0x55d6eed5ae26
可见基类中的三个指针分别指向 a()
,b()
,c()
虚函数地址,而派生类中的三个指针中第一个和第三个和基类中的相同,那么这就印证了上述我们所假设的情况,那么这也就是虚函数表。
多重继承的虚函数表
我们看看多重继承,也就是 Derive
类继承两个基类,先看一下代码:
#include
using namespace std;
class Base1 {
public:
virtual void a() { cout << "Base1 a()" << endl; }
virtual void b() { cout << "Base1 b()" << endl; }
};
class Base2 {
public:
virtual void c() { cout << "Base2 c()" << endl; }
virtual void d() { cout << "Base2 d()" << endl; }
};
class Derive : public Base1, public Base2 {
public:
virtual void a() { cout << "Derive a()" << endl; } // 覆盖Base1::a()
virtual void c() { cout << "Derive c()" << endl; } // 覆盖Base2::c()
virtual void e() { cout << "Derive e()" << endl; }
};
首先我们明确一个概念,对于多重继承的派生类来说,它含有多个虚函数指针。
对于上述代码而言,Derive
含有两个虚函数表指针。
首先我们先来看看这个多重继承的虚函数表示意图。
我们就用代码来实际的验证一下是否会存在两个虚函数指针,以及如果存在两个虚函数表,那么虚函数表是不是这个样子的。
来看下面的代码:
int main()
{
typedef void (*Func)();
Derive d;
cout << "Derive对象所占的内存大小为:" << sizeof(d) << endl;
cout << "\n---------第一个虚函数表-------------" << endl;
// 获取第一个虚函数表的指针
Func** vptr1 = (Func **)&d;
for (int i = 0; i < 4; i++) {
(*(*vptr1 + i))();
}
cout << "\n---------第二个虚函数表-------------" << endl;
printf("vptr1 = %p\n", vptr1);
printf("vptr1+1 = %p\n", vptr1+1);
// 获取第二个虚函数表指针 相当于跳过 8 个字节
Func** vptr2 = vptr1 + 1;
for (int i = 0; i < 2; ++ i) {
(*(*vptr2 + i))();
}
return 0;
}
控制台输出:
Derive对象所占的内存大小为:16
---------第一个虚函数表-------------
Derive a()
Base1 b()
Derive c()
Derive e()
---------第二个虚函数表-------------
vptr1 = 0x7ffd81ea7080
vptr1+1 = 0x7ffd81ea7088
Derive c()
Base2 d()
因为在包含一个虚函数表的时候,含有一个虚函数表指针,所占用的大小为 8
个字节,这里输出了 16
个字节,就说明 Derive
对象含有两个虚函数表指针。
然后我们通过获取到了这两个虚函数表,并调用其对应的虚函数,可以发现输出的结果和上面的示例图是相同的。
因此就证明了上述所说的结论是正确的。
虚函数表总结
简单的总结一下:
- 1)每一个基类都会有自己的虚函数表,派生类的虚函数表的数量根据继承的基类的数量来定。
- 2)派生类的虚函数表的顺序,和继承时的顺序相同。
- 3)派生类自己的虚函数放在第一个虚函数表的后面,顺序也是和定义时顺序相同。
- 4)对于派生类如果要覆盖父类中的虚函数,那么会在虚函数表中代替其位置。
通过 CLion 查看虚函数相关信息
1)在 CMakeLists.txt
中新增如下配置项。
# 指定 c++ 编译器为 g++
set (CMAKE_CXX_COMPILER /usr/bin/g++)
# -fdump-class-hierarchy 选项,可以用于输出C++程序的虚表结构(在当前目录下生成一个.class文件)
set(CMAKE_CXX_FLAGS -fdump-class-hierarchy)
2)编译上面的多继承代码。
3)在构建目录可以找到 -fdump-class-hierarchy
选项输出的 .class
文件。
4)查看 main.cpp.002t.class
文件,虚函数表内容如下所示:
Vtable for Derive
Derive::_ZTV6Derive: 10u entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI6Derive)
16 (int (*)(...))Derive::a
24 (int (*)(...))Base1::b
32 (int (*)(...))Derive::c
40 (int (*)(...))Derive::e
48 (int (*)(...))-8
56 (int (*)(...))(& _ZTI6Derive)
64 (int (*)(...))Derive::_ZThn8_N6Derive1cEv
72 (int (*)(...))Base2::d
Class Derive
size=16 align=8
base size=16 base align=8
Derive (0x0x7f050f6d64d0) 0
vptr=((& Derive::_ZTV6Derive) + 16u)
Base1 (0x0x7f050f693600) 0 nearly-empty
primary-for Derive (0x0x7f050f6d64d0)
Base2 (0x0x7f050f693660) 8 nearly-empty
vptr=((& Derive::_ZTV6Derive) + 64u)
这样我们能够大概的看到虚函数表在内存中的布局信息, 美中不足的是这个文件中显示的名字已经是被编译器 mangle
过的, 我们需要用 c++filt
这个工具 demangle
之后显示的信息才会更清晰。
我们可以在命令行键入 cat mem_model.cc.002t.class | c++filt
, 现在显示的就是一些更加清晰的信息:
Vtable for Derive
Derive::vtable for Derive: 10u entries
0 (int (*)(...))0
8 (int (*)(...))(& typeinfo for Derive)
16 (int (*)(...))Derive::a
24 (int (*)(...))Base1::b
32 (int (*)(...))Derive::c
40 (int (*)(...))Derive::e
48 (int (*)(...))-8
56 (int (*)(...))(& typeinfo for Derive)
64 (int (*)(...))Derive::non-virtual thunk to Derive::c()
72 (int (*)(...))Base2::d
Class Derive
size=16 align=8
base size=16 base align=8
Derive (0x0x7f050f6d64d0) 0
vptr=((& Derive::vtable for Derive) + 16u)
Base1 (0x0x7f050f693600) 0 nearly-empty
primary-for Derive (0x0x7f050f6d64d0)
Base2 (0x0x7f050f693660) 8 nearly-empty
vptr=((& Derive::vtable for Derive) + 64u)
Class Derive
有两个虚函数指针。
- 第一个虚函数指针是
vptr=((& Derive::vtable for Derive) + 16u)
,其指向16 (int (*)(...))Derive::a
位置。 - 第二个虚函数指针是
vptr=((& Derive::vtable for Derive) + 64u)
,其指向64 (int (*)(...))Derive::non-virtual thunk to Derive::c()
位置。
【Thunk 解释】
所谓 thunk
是一小段 assembly
代码,用来:
- (1)以适当的
offset
值调整this
指针。 - (2)跳到
virtual function
去。
例如,经由一个 Base2
指针调用 Derived::c()
,其相关的 thunk
可能看起来是下面这个样子:
this += sizeof( base1 ); // sizeof(base1) = 8
Derived::c( this );
测试不同类型的首地址:
int main()
{
Derive d;
printf("&d = %p\n", &d);
printf("(Base1*) &d = %p\n", (Base1*) &d);
printf("(Base2*) &d = %p\n", (Base2*) &d);
return 0;
}
控制台输出:
&d = 0x7ffe34e2eb90
(Base1*) &d = 0x7ffe34e2eb90
(Base2*) &d = 0x7ffe34e2eb98
(Base2*) &d
的首地址和 Derive
的首地址的 offset = 8
,和上文中的 Base2 (0x0x7f050f693660) 8 nearly-empty
中的 8
匹配。
参考
- C++ 继承 - 菜鸟教程
- C++ 多态
- C++多态到底是什么(通俗易懂)
- cmake之指定clang(++)编译器为默认编译器
- C/C++杂记:虚函数的实现的基本原理
- C/C++杂记:深入虚表结构
- C++虚函数表深入探索(详细全面)
- 虚函数 - 百度百科
- C++ 之 多态(非常非常重要,重点在后面)
- C++对象在64位机器上的内存布局
- 浅析C++类的内存布局