前言
C++ 中的虚函数表作用主要是实现多态机制,当父类指针指向子类对象时,通过父类的指针调用子类的成员函数,可以实现在运行时有 “多种形态”,这是一种泛型技术。所谓泛型技术,就是通过用不变的代码来实现可变的算法,比如:模板技术,虚函数技术。
在网上存在很多解析虚函数表的博客,但是说法并不统一,也可能是我在理解上有偏差,但确实在阐述父类,子类,虚函数表,虚表指针这些概念时稍有不慎就会词不达意,因此我想以最简洁明了的语言表述这一概念,先说明一下,因为 C++ 虚函数表的组织方式是编译器实现的细节,C++ 标准并没有强制规定,所以在不同编译器不同版本下会存在差异。我使用的是主要是 gcc version 9.4.0(文章最后也有一些 Visual Studio 2019 的虚函数表内存调试分析)。
虚函数表
虚函数 (Virtual Function) 是通过一张虚函数表 (Virtual Table) 来实现的,可以理解为是一个数组记录了虚函数的地址,这张表会在程序的编译阶段生成,并放在只读数据段。
当且仅当使用指针调用虚函数时会进行访问虚函数表指针,然后查找虚函数表并进行虚函数的调用。因此 C++ 的编译器应该是保证虚函数表的指针存在于对象实例的最前面,这是为了保证取到虚函数表时可以有最高性能。对象继承之间的关系和虚函数表的形式有很大关系,大抵可以分为以下几种情况,一般继承 (无虚函数覆盖),一般继承 (有虚函数覆盖),多重继承 (无虚函数覆盖),多重继承 (有虚函数覆盖),下面我会一一讲解。
一般继承 (无虚函数覆盖)
如以下代码为例:
因为只要有虚函数的类就会有虚函数表指针和一个在编译期间存储于只读数据段的本类所有对象共享的虚函数表,那么 B 继承 A 那么在 B 的对象的前 8 个字节 (x64) 会存在一个虚表指针,在 B 的虚函数表中存储的有 4 个元素,为什么是四个?不应该是只有两个个虚函数的地址吗?且听我来解释!
第一个元素是一个偏移量 top_offset,第二个元素是类的标识 typeinfo ,第三个元素 A::fun (), 第四个元素 B::fun1 ().top_offset 是在多重继承时调整 this 指针用的 (后面会讲解),typeinfo 是用以标识当前类的。我们可以在编译时使用 - fdump-lang-class 选项,如下:
会在当前目录下生成一个名为 test1.cpp.001l.class 的文件里面记录了类的组织结构以及虚函数表的布局,
可以看到当子类继承父类的虚函数时,确确实实会在内存中生成自己的一张虚函数表。
一般继承 (有虚函数覆盖)
直接上图,可以看到与上面不同之处在于,此时出现了覆盖,在 B 虚函数表中重写的函数将原本 A 虚函数表中的同名函数的地址进行覆盖了。
多重继承 (无虚函数覆盖)
如图为类 C 的虚函数表,可见,当多重继承时,C 类会生成本类独属的虚函数表,并且只会生成一个,此时在类 C 产生的对象布局内部应该是这样,(使用 clang++ 的对象内存分析的工具,命令:clang++ -Xclang -fdump-record-layouts -c test1.cpp >log)
结合两张图可以知道,C 继承 A 时会继承一个虚函数表指针,继承 B 时也会继承一个虚函数表指针,A vatable pointer 和 Bvtable pointer 指向一张虚函数表的不同偏移处,并且类 C 新增的虚函数会被记录到主虚表的后面 A vatable pointer 指向和 B vatable pointer 指向我已经在上图标记。我们也可以自己通过指针偏移来验证下:
输出为小端字节序,但是不要紧,反转之后就是真实的数据,比较 C 的两个虚表指针相差 32 字节,刚好对应了上表,成功打印出”hello” 说明函数调用成功,偏移量也正确为 - 16。
多重继承 (有虚函数覆盖)
使用 - fdump-lang-class 产生如图:
类 C 中的三个函数各有特色,先看 class C::fun () 函数,这是重写的 class A::fun () 此时原本存在于主虚表中的 class A::fun () 直接被覆盖为 class C::fun (). 再看 class C::fun2 (), 这是在类 C 中新添加的虚函数,类 A 与类 B 中并无此函数,可以看到被添加了主虚表的最后,在类 C 的虚函数表中,类 C 的虚函数表会和第一个继承的基类的虚函数表混合称为类 C 的主虚表,也就是说在子类中新添加的虚函数都会被直接添加到主虚表最后。
class C::fun1 () 这是重写的 class B::fun1 (), 只要是我们在类 C 中定义的函数,无论是重写还是自定义都会出现在主虚表中,在次级虚表 B 处的地址起始也是 class C::fun1 (),由于被重写因此由 class B::fun1 () 变为了 class C::fun1 ()。
来解释一下_ZThn16_N1C4fun1Ev 是什么意思?
在 C++ 的虚函数表中_ZThn16_N1C4fun1Ev 表示一个 thunk,用于调整 this 指针并调用正确的函数。_Z 是 C++ 的名称修饰的起始标记,Thn8 表示这是一个 thunk (Th), 需要将 this 指针减去 8 字节 (n 表示负偏移)。N1C4fun1Ev 表示 C::fun1 (void)(N 开始嵌套名称,1C 是类名 C,4fun1 是函数名 fun1,Ev 表示参数 void)。
_ZThn16_N1C4fun1Ev 有什么用呢?
当通过第二个基类的指针指向子类对象时,要调用被覆盖的虚函数 fun1 时,此时需要将 this 指针从基类子对象的地址调整为完整的 C 类对象地址。那 thunk 技术就是实现了这一操作。
比如现在我们使用 B*p = new C (). 并用 p->fun1 () 时,此时 p 指针记录的地址应该是经过类型擦除或者说是隐式转换的,它并不指向 C 在内存中的起始地址,而是起始地址 + sizeof (A), 如下:
但是现在我们要用 b 去调用被子类重写了的 fun1 (),在 fun1 中可能访问的对象内存空间的全部内容,但是此时 this 指针指向的并不是对象内存的起始地址,这肯定会发生问题的。而 thunk 就在 b->fun1 () 之前,将 this 指针调整为了对象 c 的内存起始地址,在执行完 b->fun1 () 之后又进行反向操作,将 this 指针回退。
总结
- 以下全部总结的环境是在 gcc version 9.4.0 版本。
- 子类中是可能存在多个虚函数表指针的,多继承时继承几个有虚函数的基类就会有几个虚函数表指针,每个虚函数表指针都指向同一张物理虚函数表的不同偏移处,指向的都是从基类继承来的逻辑虚函数表中的第一个虚函数的地址。
- 只要有虚函数的类就会在编译时期在只读数据段为虚函数表划分虚拟地址空间。而且类中只有物理上的一张虚函数表,可能从逻辑上我们可以看成是主虚表,次级虚表…. 并且逻辑上的每张虚表都由三部分构成,top_offset (当前指针指向的对象地址距离对象起始地址的偏移,由子类继承父类的顺序决定),typeinfo (类的标识) 以及虚函数指针,这些条目都被强转成了 int (*)(…) 类型的函数指针。
- 派生类并不会产生独自的逻辑虚函数表,而是和第一个继承的基类共用一个逻辑虚函数表称为主虚表,当派生类新增虚函数时就会被添加到主虚表的末尾,当派生类重写虚函数时,此时在主虚表中会基类重写了的虚函数地址,并且之前从基类继承而来的逻辑虚函数表中的地址会被覆盖。(在说具体一些,如果被重写的是主虚表中的函数,那么只是将原本函数覆盖,如果重写的是次级虚表中的函数,那么就会将之前次级虚表中的条目以 thunk 技术覆盖,并且会添加到主虚表中,也就是说此时虚函数表中存在两个条目指向同一个虚函数地址。) 比如下图中的 fun1 () 函数。
- 在虚函数表中的 typeinfo 是用于动态类型识别的 RTTI,比如我们使用 dynamic_cast 或者 type_info 进行类型判断时会使用。
- 整个多态的流程如下:以 b->fun1 () 为例
将派生类的地址赋值给基类,此时会发生类型擦除,基类 b 指向基类子对象在派生类中的地址位置,并非对象 c 的起始地址。b->fun1 () 在编译阶段时,如果编译器发现 fun1 () 并非虚函数那么就会将 fun1 () 地址进行确定,此时地址就在编译阶段确定了。如果是虚函数,那么实际调用的函数地址是在运行阶段确定的,通过获取基类指针指向的地址的前四个字节得到虚函数表指针,访问虚函数表指针获取记录在虚函数表中的虚函数的地址。
多态的实现,类型擦除是手段,虚表指针是纽带,虚函数表是基础。
Visual Studio 2019
在 Visual Studio 2019 中每一个虚函数表中都是以 0 作为结尾,并且允许一个个类中存在多个虚函数表,我们可以使用 Visual Studio 2019 提供的开发者工具来查看虚函数表的内存布局。
打开 Developer Command Prompt for VS 2019,然后 cd 进入 Visual Studio 2019 所使用文件的目录中,输入 cl /d1 reportSingleClassLayoutBase “源 4.cpp” 命令。
可以看到如下代码的 Base 类的虚函数的内存布局
确实是存在两个虚函数表,每个虚函数表的开头第一个元素是 this 指针的偏移值。Base 类和第一个继承的类的虚函数表组合而成主虚表,重写的虚函数会覆盖之前虚函数表中的虚函数地址,新增函数会添加到主虚表最后,但是有一点是与 gcc 明显不同的是,在 base 类中重写类 B 的虚函数后,并没有将重写的虚函数添加到主虚表中,但是在 gcc 中是添加了的。也就是说将主虚表和次级虚表进行了完全的分割。