理解虚函数

来源:互联网 发布:javascript插件编写 编辑:程序博客网 时间:2024/05/17 01:56

虚函数联系到多态,多态联系到继承。所以本文中都是在继承层次上做文章。没了继承,什么都没得谈。

下面是对C++的虚函数这玩意儿的理解。

一, 什么是虚函数(如果不知道虚函数为何物,但有急切的想知道,那你就应该从这里开始)

简单地说,那些被virtual关键字修饰的成员函数,就是虚函数。虚函数的作用,用专业术语来解释就是实现多态性(Polymorphism),多态性是将接口与实现进行分离;用形象的语言来解释就是实现以共同的方法,但因个体差异而采用不同的策略。下面来看一段简单的代码

class A{

public:

void print(){ cout<<”This is A”<<endl;}

};

class B:public A{

public:

void print(){ cout<<”This is B”<<endl;}

};

int main(){  
//为了在以后便于区分,我这段main()代码叫做main1

A a;

B b;

a.print();

b.print();

}

通过class Aclass Bprint()这个接口,可以看出这两个class因个体的差异而采用了不同的策略,输出的结果也是我们预料中的,分别是This is AThis is B。但这是否真正做到了多态性呢?No,多态还有个关键之处就是一切用指向基类的指针或引用来操作对象。那现在就把main()处的代码改一改。


int main(){  
//main2

A a;

B b;

A* p1=&a;

A* p2=&b;

p1->print();

p2->print();

}

运行一下看看结果,哟呵,蓦然回首,结果却是两个This is A。问题来了,p2明明指向的是class B的对象但却是调用的class Aprint()函数,这不是我们所期望的结果,那么解决这个问题就需要用到虚函数


class A{

public:

virtual void print(){ cout<<”This is A”<<endl;} 
//现在成了虚函数了

};

class B:public A{

public:

void print(){ cout<<”This is B”<<endl;} 
//这里需要在前面加上关键字virtual吗?

};

毫无疑问,class A的成员函数print()已经成了虚函数,那么class Bprint()成了虚函数了吗?回答是Yes,我们只需在把基类的成员函数设为virtual,其派生类的相应的函数也会自动变为虚函数。所以,class Bprint()也成了虚函数。那么对于在派生类的相应函数前是否需要用virtual关键字修饰,那就是你自己的问题了。

现在重新运行main2的代码,这样输出的结果就是This is AThis is B了。

现在来消化一下,我作个简单的总结,指向基类的指针在操作它的多态类对象时,会根据不同的类对象,调用其相应的函数,这个函数就是虚函数。

二, 虚函数是如何做到的(如果你没有看过《Inside The C++ Object Model》这本书,但又急切想知道,那你就应该从这里开始)

虚函数是如何做到因对象的不同而调用其相应的函数的呢?现在我们就来剖析虚函数。我们先定义两个类

class A{  
//虚函数示例代码

public:

virtual void fun(){cout<<1<<endl;}

virtual void fun2(){cout<<2<<endl;}

};

class B:public A{

public:

void fun(){cout<<3<<endl;}

void fun2(){cout<<4<<endl;}

};

由于这两个类中有虚函数存在,所以编译器就会为他们两个分别插入一段你不知道的数据,并为他们分别创建一个表。那段数据叫做vptr指针,指向那个表。那个表叫做vtbl,每个类都有自己的vtblvtbl的作用就是保存自己类中虚函数的地址,我们可以把vtbl形象地看成一个数组,这个数组的每个元素存放的就是虚函数的地址,请看图

通过上图,可以看到这两个vtbl分别为class Aclass B服务。现在有了这个模型之后,我们来分析下面的代码

A *p=new A;

p->fun();

毫无疑问,调用了A::fun(),但是A::fun()是如何被调用的呢?它像普通函数那样直接跳转到函数的代码处吗?No,其实是这样的,首先是取出vptr的值,这个值就是vtbl的地址,再根据这个值来到vtbl这里,由于调用的函数A::fun()是第一个虚函数,所以取出vtbl第一个slot里的值,这个值就是A::fun()的地址了,最后调用这个函数。现在我们可以看出来了,只要vptr不同,指向的vtbl就不同,而不同的vtbl里装着对应类的虚函数地址,所以这样虚函数就可以完成它的任务。

而对于class Aclass B来说,他们的vptr指针存放在何处呢?其实这个指针就放在他们各自的实例对象里。由于class Aclass B都没有数据成员,所以他们的实例对象里就只有一个vptr指针。通过上面的分析,现在我们来实作一段代码,来描述这个带有虚函数的类的简单模型。

#include<iostream>

using namespace std;

//
将上面虚函数示例代码添加在这里

int main(){

void (*fun)(A*);

A *p=new B;

long lVptrAddr;

memcpy(&lVptrAddr,p,4);

memcpy(&fun,reinterpret_cast<long*>(lVptrAddr),4);

fun(p);

delete p;

system("pause");

}

VCDev-C++编译运行一下,看看结果是不是输出3,如果不是,那么太阳明天肯定是从西边出来。现在一步一步开始分析

void (*fun)(A*); 
这段定义了一个函数指针名字叫做fun,而且有一个A*类型的参数,这个函数指针待会儿用来保存从vtbl里取出的函数地址

A* p=new B; 
这个我不太了解,算了,不解释这个了

long lVptrAddr; 
这个long类型的变量待会儿用来保存vptr的值

memcpy(&lVptrAddr,p,4); 
前面说了,他们的实例对象里只有vptr指针,所以我们就放心大胆地把p所指的4bytes内存里的东西复制到lVptrAddr中,所以复制出来的4bytes内容就是vptr的值,即vtbl的地址

现在有了vtbl的地址了,那么我们现在就取出vtbl第一个slot里的内容

memcpy(&fun,reinterpret_cast<long*>(lVptrAddr),4); 
取出vtbl第一个slot里的内容,并存放在函数指针fun里。需要注意的是lVptrAddr里面是vtbl的地址,但lVptrAddr不是指针,所以我们要把它先转变成指针类型

fun(p); 
这里就调用了刚才取出的函数地址里的函数,也就是调用了B::fun()这个函数,也许你发现了为什么会有参数p,其实类成员函数调用时,会有个this指针,这个p就是那个this指针,只是在一般的调用中编译器自动帮你处理了而已,而在这里则需要自己处理。

delete p;
system("pause"); 这个我不太了解,算了,不解释这个了

如果调用B::fun2()怎么办?那就取出vtbl的第二个slot里的值就行了

memcpy(&fun,reinterpret_cast<long*>(lVptrAddr+4),4);
为什么是加4呢?因为一个指针的长度是4bytes,所以加4。或者memcpy(&fun,reinterpret_cast<long*>(lVptrAddr)+1,4);这更符合数组的用法,因为lVptrAddr被转成了long*型别,所以+1就是往后移sizeof(long)的长度

三, 以一段代码开始

#include<iostream>

using namespace std;

class A{  
//虚函数示例代码2

public:

virtual void fun(){ cout<<"A::fun"<<endl;}

virtual void fun2(){cout<<"A::fun2"<<endl;}

};

class B:public A{

public:

void fun(){ cout<<"B::fun"<<endl;}

void fun2(){ cout<<"B::fun2"<<endl;}

}; 
//end//虚函数示例代码
2

int main(){

void (A::*fun)(); 
//定义一个函数指针


A *p=new B;

fun=&A::fun;

(p->*fun)();

fun = &A::fun2;

(p->*fun)();

delete p;

system("pause");

}

你能估算出输出结果吗?如果你估算出的结果是A::funA::fun2,呵呵,恭喜恭喜,你中圈套了。其实真正的结果是B::funB::fun2,如果你想不通就接着往下看。给个提示,&A::fun&A::fun2是真正获得了虚函数的地址吗?

首先我们回到第二部分,通过段实作代码,得到一个通用的获得虚函数地址的方法

#include<iostream>

using namespace std;

//
将上面虚函数示例代码2”添加在这里

void CallVirtualFun(void* pThis,int index=0){

void (*funptr)(void*);

long lVptrAddr;

memcpy(&lVptrAddr,pThis,4);

memcpy(&funptr,reinterpret_cast<long*>(lVptrAddr)+index,4);

funptr(pThis); //
调用

}

int main(){

A* p=new B;

CallVirtualFun(p); 
//调用虚函数p->fun()

CallVirtualFun(p,1);//
调用虚函数
p->fun2()

system("pause");

}

现在我们拥有一个通用CallVirtualFun方法。


这个通用方法和第三部分开始处的代码有何联系呢?联系很大。由于A::fun()A::fun2()是虚函数,所以&A::fun&A::fun2获得的不是函数的地址,而是一段间接获得虚函数地址的一段代码的地址,我们形象地把这段代码看作那段CallVirtualFun。编译器在编译时,会提供类似于CallVirtualFun这样的代码,当你调用虚函数时,其实就是先调用的那段类似CallVirtualFun的代码,通过这段代码,获得虚函数地址后,最后调用虚函数,这样就真正保证了多态性。同时大家都说虚函数的效率低,其原因就是,在调用虚函数之前,还调用了获得虚函数地址的代码。

最后的说明:本文的代码可以用VC6Dev-C++4.9.8.0通过编译,且运行无问题。其他的编译器小弟不敢保证。其中,里面的类比方法只能看成模型,因为不同的编译器的低层实现是不同的。例如this指针,Dev-C++gcc就是通过压栈,当作参数传递,而VC的编译器则通过取出地址保存在ecx中。所以这些类比方法不能当作具体实现

/////////////////////////////////////////////////////////////////////////////////////////////

C++虚函数的原理

理解虚函数( virtual function )的几个关键点:

1.       理解早绑定(early binding)、晚绑定(late binding)。所谓early bindingOn compile time,就能明确一个函数调用是对哪个对象的哪个成员函数进行的,即编译时就晓得了确定的函数地址;所谓late bindingOn compile time,对函数(虚函数)的调用被搞成了:pObj->_vptr->vtable[],从而导致不到runtime,完全不知道实际函数地址。直到程序运行时,执行到这里,去vtable里拿到函数地址,才晓得。其实,原理很简单,只是单看这些名词的话会觉得好像很magic一样。

2.       理解虚函数赖以生存的底层机制:vptr + vtable。虚函数的运行时实现采用了VPTR/VTBL的形式,这项技术的基础:
①编译器在后台为每个包含虚函数的类产生一个静态函数指针数组(虚函数表),在这个类或者它的基类中定义的每一个虚函数都有一个相应的函数指针。
②每个包含虚函数的类的每一个实例包含一个不可见的数据成员vptr(虚函数指针),这个指针被构造函数自动初始化,指向类的vtbl(虚函数表)
③当客户调用虚函数的时候,编译器产生代码反指向到vptr,索引到vtbl中,然后在指定的位置上找到函数指针,并发出调用。

参考下面转载文章:

虚函数是在类中被声明为virtual的成员函数,当编译器看到通过指针或引用调用此类函数时,对其执行晚绑定,即通过指针(或引用)指向的类的类型信息来决定该函数是哪个类的。通常此类指针或引用都声明为基类的,它可以指向基类或派生类的对象。
 
多态指同一个方法根据其所属的不同对象可以有不同的行为(根据自己理解,不知这么说是否严谨)。

 举个例子说明虚函数、多态、早绑定和晚绑定:
  
李氏两兄妹(哥哥和妹妹)参加姓氏运动会(不同姓氏组队参加),哥哥男子项目比赛,妹妹参加女子项目比赛,开幕式有一个参赛队伍代表发言仪式,兄妹俩都想去露露脸,可只能一人去,最终他们决定到时抓阄决定,而组委会也不反对,它才不关心是哥哥还是妹妹来发言,只要派一个姓李的来说两句话就行。运动会如期举行,妹妹抓阄获得代表李家发言的机会,哥哥参加了男子项目比赛,妹妹参加了女子项目比赛。比赛结果就不是我们关心的了。
 
现在让我们来做个类比(只讨论与运动会相关的话题):
 
1)类的设计:
  
李氏兄妹属于李氏家族,李氏是基类(这里还是抽象的纯基类),李氏又派生出两个子类(李氏男和李氏女),李氏男会所有男子项目的比赛(李氏男的成员函数),李氏女会所有女子项目的比赛(李氏女的成员函数)。姓李的人都会发言(基类虚函数),李氏男和李氏女继承自李氏当然也会发言,只是男女说话声音不一样,内容也会又差异,给人感觉不同(李氏男和李氏女分别重新定义发言这个虚函数)。李氏两兄妹就是李氏男和李氏女两个类的实体。
 
2)程序设计:
 
李氏兄妹填写参赛报名表。
 
3)编译:
  
李氏兄妹的参赛报名表被上交给组委会(编译器),哥哥和妹妹分别参加男子和女子的比赛,组委会一看就明白了(早绑定),只是发言人选不明确,组委会看到报名表上写的是李家代表(基类指针),组委会不能确定到底是谁,就做了个备注:如果是男的,就是哥哥李某某;如果是女的,就是妹妹李某某(晚绑定)。组委会做好其它准备工作后,就等运动会开始了(编译完毕)。
 
4)程序运行:
 
运动会开始了(程序开始运行),开幕式上我们听到了李家妹妹的发言,如果是哥哥运气好抓阄胜出,我们将听到哥哥的发言(多态)。然后就是看到兄妹俩参加比赛了。。。

 但愿这个比喻说清楚了虚函数、多态、早绑定和晚绑定的概念和它们之间的关系。再说一下,早绑定指编译器在编译期间即知道对象的具体类型并确定此对象调用成员函数的确切地址;而晚绑定是根据指针所指对象的类型信息得到类的虚函数表指针进而确定调用成员函数的确切地址。
  

 2、揭密晚绑定的秘密

 编译器到底做了什么实现的虚函数的晚绑定呢?我们来探个究竟。

 编译器对每个包含虚函数的类创建一个表(称为V TA B L E)。在V TA B L E中,编译器放置特定类的虚函数地址。在每个带有虚函数的类中,编译器秘密地置一指针,称为v p o i n t e r(缩写为V P T R),指向这个对象的V TA B L E通过基类指针做虚函数调用时(也就是做多态调用时),编译器静态地插入取得这个V P T R,并在V TA B L E表中查找函数地址的代码,这样就能调用正确的函数使晚捆绑发生。为每个类设置V TA B L E、初始化V P T R、为虚函数调用插入代码,所有这些都是自动发生的,所以我们不必担心这些。利用虚函数,这个对象的合适的函数就能被调用,哪怕在编译器还不知道这个对象的特定类型的情况下。(C++编程思想》

————这段话红色加粗部分似乎有点问题,我个人的理解看后面的总结。

 在任何类中不存在显示的类型信息,可对象中必须存放类信息,否则类型不可能在运行时建立。那这个类信息是什么呢?我们来看下面几个类:

 class no_virtual
 {
 public:
     void fun1() const{}
     int  fun2() const { return a; }
 private:
     int a;
 }

 class one_virtual
 {
 public:
     virtual void fun1() const{}
     int  fun2() const { return a; }
 private:
     int a;
 }

 class two_virtual
 {
 public:
     virtual void fun1() const{}
     virtual int  fun2() const { return a; }
 private:
     int a;
 }

 以上三个类中:
 no_virtual
没有虚函数,sizeof(no_virtual)=4,类no_virtual的长度就是其成员变量整型a的长度;
 one_virtual
有一个虚函数,sizeof(one_virtual)=8
 two_virtual 
有两个虚函数,sizeof(two_virtual)=8 有一个虚函数和两个虚函数的类的长度没有区别,其实它们的长度就是no_virtual的长度加一个void指针的长度,它反映出,如果有一个或多个虚函数,编译器在这个结构中插入一个指针( V P T R)。在one_virtual  two_virtual之间没有区别。这是因为V P T R指向一个存放地址的表,只需要一个指针,因为所有虚函数地址都包含在这个表中。

 这个VPTR就可以看作类的类型信息。

 那我们来看看编译器是怎么建立VPTR指向的这个虚函数表的。先看下面两个类:
 class base
 {
 public:
     void bfun(){}
     virtual void vfun1(){}
     virtual int vfun2(){}
 private:
     int a;
 }

 class derived : public base
 {
 public:
     void dfun(){}
     virtual void vfun1(){}
     virtual int vfun3(){}
 private:
     int b;
 }

 两个类VPTR指向的虚函数表(VTABLE)分别如下:
 base

                       ——————
 VPTR——> |&base::vfun1 |
                       ——————
                  |&base::vfun2 |
                   ——————
       
 derived

                       ———————
 VPTR——> |&derived::vfun1 |
                       ———————
                   |&base::vfun2    |
                   ———————
                   |&derived::vfun3 |
                    ———————
     
  
每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就为这个类创建一个VTABLE,如上图所示。在这个表中,编译器放置了在这个类中或在它的基类中所有已声明为virtual的函数的地址。如果在这个派生类中没有对在基类中声明为virtual的函数进行重新定义,编译器就使用基类的这个虚函数地址。(在derivedVTABLE中,vfun2的入口就是这种情况。)然后编译器在这个类中放置VPTR。当使用简单继承时,对于每个对象只有一个VPTRVPTR必须被初始化为指向相应的VTABLE,这在构造函数中发生。
 
一旦VPTR被初始化为指向相应的VTABLE,对象就"知道"它自己是什么类型。但只有当虚函数被调用时这种自我认知才有用。

个人总结如下:
1
、从包含虚函数的类派生一个类时,编译器就为该类创建一个VTABLE。其每一个表项是该类的虚函数地址。
2
、在定义该派生类对象时,先调用其基类的构造函数,然后再初始化VPTR,最后再调用派生类的构造函数(从二进制的视野来看,所谓基类子类是一个大结构体,其中this指针开头的四个字节存放虚函数表头指针。执行子类的构造函数的时候,首先调用基类构造函数,this指针作为参数,在基类构造函数中填入基类的vptr,然后回到子类的构造函数,填入子类的vptr,覆盖基类填入的vptr。如此以来完成vptr的初始化。
3、在实现动态绑定时,不能直接采用类对象,而一定要采用指针或者引用。因为采用类对象传值方式,有临时基类对象的产生,而采用指针,则是通过指针来访问外部的派生类对象的VPTR来达到访问派生类虚函数的结果。

 VPTR 常常位于对象的开头,编译器能很容易地取到VPTR的值,从而确定VTABLE的位置。VPTR总指向VTABLE的开始地址,所有基类和它的子类的虚函数地址(子类自己定义的虚函数除外)在VTABLE中存储的位置总是相同的,如上面base类和derived类的VTABLEvfun1vfun2 的地址总是按相同的顺序存储。编译器知道vfun1位于VPTR处,vfun2位于VPTR+1处,因此在用基类指针调用虚函数时,编译器首先获取指针指向对象的类型信息(VPTR),然后就去调用虚函数。如一个base类指针pBase指向了一个derived对象,那pBase->vfun2 ()被编译器翻译为 VPTR+1 的调用,因为虚函数vfun2的地址在VTABLE中位于索引为1的位置上。同理,pBase->vfun3 ()被编译器翻译为 VPTR+2的调用。这就是所谓的晚绑定。

 我们来看一下虚函数调用的汇编代码,以加深理解。

 void test(base* pBase)
 {
  pBase->vfun2();
 }

 int main(int argc, char* argv[])
 {
  derived td;
  

 
  test(&td);
  
  return 0;
 }

 derived td;编译生成的汇编代码如下:
  mov DWORD PTR _td$[esp+24], OFFSET FLAT:??_7derived@@6B@ ; derived::`vftable'
  
由编译器的注释可知,此时PTR _td$[esp+24]中存储的就是derived类的VTABLE地址。
  
 test(&td);
编译生成的汇编代码如下:
  lea eax, DWORD PTR _td$[esp+24]    
  mov DWORD PTR __$EHRec$[esp+32], 0
  push eax
  call ?test@@YAXPAVbase@@@Z   ; test 
  
调用test函数时完成了如下工作:取对象td的地址,将其压栈,然后调用test

 pBase->vfun2();编译生成的汇编代码如下:
   mov ecx, DWORD PTR _pBase$[esp-4]
  mov eax, DWORD PTR [ecx]
  jmp DWORD PTR [eax+4]
   
首先从栈中取出pBase指针指向的对象地址赋给ecx,然后取对象开头的指针变量中的地址赋给eax,此时eax的值即为VPTR的值,也就是 VTABLE的地址。最后就是调用虚函数了,由于vfun2位于VTABLE的第二个位置,相当于 VPTR+1,每个函数指针是4个字节长,所以最后的调用被编译器翻译为 jmp DWORD PTR [eax+4]。如果是调用pBase->vfun1(),这句就该被编译为 jmp DWORD PTR [eax]

http://zhidao.baidu.com/link?url=VItUinDqryhP-cnp40jjLYJ08y7fZ_XpnrOaWEKQvhWnLMqdRCLsz8z3UwzKZ3h9gOWs1s2KYp1WpW3PJuw4kq

http://www.cnblogs.com/taoxu0903/archive/2008/02/04/1064234.html

 

原创粉丝点击