深入研究虚函数和vtable
国防科技大学计算机学院 褚瑞
在面向对象的C++语言中,虚函数(virtual function)是一个非常重要的概念。因为它充分体现了面向对象思想中的继承和多态性这两大特性,在C++语言里应用极广。比如在微软的MFC类库中,你会发现很多函数都有virtual关键字,也就是说,它们都是虚函数。难怪有人甚至称虚函数是C++语言的精髓。
那么,什么是虚函数呢,我们先来看看微软的解释:
虚函数是指一个类中你希望重载的成员函数,当你用一个基类指针或引用指向一个继承类对象的时候,你调用一个虚函数,实际调用的是继承类的版本。
——摘自MSDN
这个定义说得不是很明白。MSDN中还给出了一个例子,但是它的例子也并不能很好的说明问题。我们自己编写这样一个例子:
#include "stdio.h"
#include "conio.h"
class Parent
{
public:
char data[20];
void Function1();
virtual void Function2(); // 这里声明Function2是虚函数
}parent;
void Parent::Function1()
{
printf("This is parent,function1\n");
}
void Parent::Function2()
{
printf("This is parent,function2\n");
}
class Child:public Parent
{
void Function1();
void Function2();
} child;
void Child::Function1()
{
printf("This is child,function1\n");
}
void Child::Function2()
{
printf("This is child,function2\n");
}
int main(int argc, char* argv[])
{
Parent *p; // 定义一个基类指针
if(_getch()=='c') // 如果输入一个小写字母c
p=&child; // 指向继承类对象
else
p=&parent; // 否则指向基类对象
p->Function1(); // 这里在编译时会直接给出Parent::Function1()的
入口地址。
p->Function2(); // 注意这里,执行的是哪一个Function2?
return 0;
}
用任意版本的Visual C++或Borland C++编译并运行,输入一个小写字母c,得到下面的结果:
This is parent,function1
This is child,function2
为什么会有第一行的结果呢?因为我们是用一个Parent类的指针调用函数Fuction1(),虽然实际上这个指针指向的是Child类的对象,但编译器无法知道这一事实(直到运行的时候,程序才可以根据用户的输入判断出指针指向的对象),它只能按照调用Parent类的函数来理解并编译,所以我们看到了第一行的结果。
那么第二行的结果又是怎么回事呢?我们注意到,Function2()函数在基类中被virtual关键字修饰,也就是说,它是一个虚函数。虚函数最关键的特点是“动态联编”,它可以在运行时判断指针指向的对象,并自动调用相应的函数。如果我们在运行上面的程序时任意输入一个非c的字符,结果如下:
This is parent,function1
This is parent,function2
请注意看第二行,它的结果出现了变化。程序中仅仅调用了一个Function2()函数,却可以根据用户的输入自动决定到底调用基类中的Function2还是继承类中的Function2,这就是虚函数的作用。我们知道,在MFC中,很多类都是需要你继承的,它们的成员函数很多都要重载,比如编写MFC应用程序最常用的CView::OnDraw(CDC*)函数,就必须重载使用。把它定义为虚函数(实际上,在MFC中OnDraw不仅是虚函数,还是纯虚函数),可以保证时刻调用的是用户自己编写的OnDraw。虚函数的重要用途在这里可见一斑。
在了解虚函数的基础之上,我们考虑这样的问题:一个基类指针必须知道它所指向的对象是基类还是继承类的示例,才能在调用虚函数时“自动”决定应该调用哪个版本,它是如何知道的?有些讲C++的书上提到,这种“动态联编”的机制是通过一个“vtable”实现的,vtable是什么?微软在关于COM的文档里这样描述:
vtable是指一张函数指针表,如同C++中类的实现一样,vtable中的指针指向一个对象支持的接口成员函数。
——摘自MSDN
很遗憾,微软这次还是没有把问题说清楚,当然,上面的文档本来就是关于COM的,与我们关心的问题不同。
那么vtable是什么?我们先来看看下面的实验:
在前面的示例程序中加一句 printf(“%d”,sizeof(Child)); 运行,然后去掉Function2()前的virtual关键字,再运行,得到这样的结果:当Function2定义成虚函数的时候,结果是24,否则结果是20。也就是说,如果Function2不是虚函数,一个Child类的示例所占空间的大小仅仅是它的成员变量data数组的大小,如果Function2是虚函数,结果多了4个字节。我们使用的是32位的Visual C++ 6.0,4个字节恰好是一个指针,或者是一个整数所占的空间。
那么这多出来的四个字节究竟起到了什么作用?
用Visual C++打开前面的示例程序,在main函数中p->Function1(); 一句前面按F9设断点,按F5开始调试,输入一个小写c,程序停到了我们设的断点上。找到Debug工具条,按Disassembly按钮,如图所示:
我们看到了反汇编后的代码。由上图可见,对Function1和Function2的调用反汇编后生成的代码截然不同。Function1不是虚函数,因此对它的调用仅仅被编译成为一条call指令,转向Parent::Function1子程序;而Function2是虚函数,它的代码要复杂一些,我们来仔细分析:
45: p->Function2();
004012CA mov eax,dword ptr [ebp-4]
// eax就是我们的p指针
004012CD mov edx,dword ptr [eax]
// edx取child对象头部四个字节
004012CF mov esi,esp
004012D1 mov ecx,dword ptr [ebp-4]
// 可能要检查栈,不管它
004012D4 call dword ptr [edx]
// 注意这里,调用了child对象头部的一个函数指针
004012D6 cmp esi,esp
004012D8 call __chkesp (004013b0)
这里最关键的一句是call dword ptr[edx],edx是child对象头部,前面我们分析过了,child对象共有24字节,其中成员变量占用20字节,还有4个字节作用未知。现在从这段汇编代码上看,那4个字节很可能就是child对象开头的这个函数指针,因为编译器并不知道我们的成员变量data是做什么用的,更不可能把data的任何一部分当成一个函数指针来处理。
那么这个函数指针会跳转到那里去呢?我们按F10单步运行到这个call指令,然后按F11跟进去:
00401032 jmp Parent::Function2 (0040bfe0)
00401037 jmp Parent::Parent (004010d0)
→ 0040103C jmp Child::Function2 (00401250)
00401041 jmp Child::Child (004011c0)
光标停在了第三行,40103C的地方,执行这里的jmp指令后,又跳转到Child::Function2的位置,从而得到我们上面所看到的结果。
这并不是最终的结论,我们看看40103C周围的几行代码,连续几行全都是jmp指令,这是什么程序结构?有汇编语言编程经验的朋友可能会想起来了,这是一张入口表,分别存放着到几个重要函数的跳转指令!我们再回去看看微软对于vtable的描述:vtable是指一张函数指针表,(如同C++中类的实现一样,)vtable中的指针指向(一个对象支持的接口)成员函数。打括号的字不要看,这句话的主干就是:vtable是一张函数指针表,指向成员函数。种种事实证明,上面的四行代码就是我们要找的这个vtable!
现在我们应该对虚函数的原理有一个认识了。每个虚函数都在vtable中占了一个表项,保存着一条跳转到它的入口地址的指令(实际上就是保存了它的入口地址)。当一个包含虚函数的对象(注意,不是对象的指针)被创建的时候,它在头部附加一个指针,指向vtable中相应位置。调用虚函数的时候,不管你是用什么指针调用的,它先根据vtable找到入口地址再执行,从而实现了“动态联编”。而不像普通函数那样简单地跳转到一个固定地址。
以上结论仅仅是针对Visual C++ 6.0编译器而言的,对于其他编译器,具体实现并不完全相同,但都大同小异。著名的“绿色兵团”杂志上撰文介绍,Linux平台上的GNU C++编译器就把指向vtable的指针放在对象尾部而不是头部,而且vtable中仅仅存放虚函数的入口地址,而不是跳转到虚函数的指令。具体的一些细节,篇幅所限,我们这里不再讨论,希望有兴趣的朋友能继续研究。