深入研究虚函数和vtable

 

深入研究虚函数和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*)函数,就必须重载使用。把它定义为虚函数(实际上,在MFCOnDraw不仅是虚函数,还是纯虚函数),可以保证时刻调用的是用户自己编写的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.04个字节恰好是一个指针,或者是一个整数所占的空间。

        那么这多出来的四个字节究竟起到了什么作用?

        Visual C++打开前面的示例程序,在main函数中p->Function1(); 一句前面按F9设断点,按F5开始调试,输入一个小写c,程序停到了我们设的断点上。找到Debug工具条,按Disassembly按钮,如图所示:

        我们看到了反汇编后的代码。由上图可见,对Function1Function2的调用反汇编后生成的代码截然不同。Function1不是虚函数,因此对它的调用仅仅被编译成为一条call指令,转向Parent::Function1子程序;而Function2是虚函数,它的代码要复杂一些,我们来仔细分析:

45:       p->Function2();   

004012CA   mov      eax,dword ptr [ebp-4]

// eax就是我们的p指针

004012CD   mov      edx,dword ptr [eax]

// edxchild对象头部四个字节

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中仅仅存放虚函数的入口地址,而不是跳转到虚函数的指令。具体的一些细节,篇幅所限,我们这里不再讨论,希望有兴趣的朋友能继续研究。

你可能感兴趣的:(Visual,C++)