虚函数实现原理

首先娱乐一下,看看这道学龄前儿童智力题,学龄前哦~真心伤不起!

      反正我看完之后是直接泪奔了,这道题目不是文艺儿童都做不出啊……

      

      好,进入主题,今天的主题是:没有蛀牙!呃,开个玩笑!

      首先看第一道题目:有1000个一模一样的瓶子,其中有999瓶是普通的水,有一瓶是毒药。任何喝下毒药的生物都会在一个星期后死亡。现在,你只有10只小白鼠和一个星期的时间,如何检验出哪个瓶子里有毒药?

      其实一般出给面试者的时候,为了题目的开放性,并不会去固定小白鼠的数量,而是问,至少需要多少只小白鼠?(当然是越少越好咯,小白鼠也是生命啊)。这是一个涉及计算机基础科学的智力类题目。主要是考面试者的发散思维能力,因此并不要求面试者能第一时间内完全做正确。

      首先可以简单想到的是,需要999只,一只喝一瓶,然后等结果,哪个死了就是对应的瓶子里面的水有毒,都没死,那就是唯一的那一瓶没有喂给小白鼠的水有毒,但是这种方法用的小白鼠太多了。但是也不是完全没好处,是实验中平均死亡小白鼠最少的办法。

      再发散一下,用二分法迅速逼近结果,第一步,把1000瓶水分成A,B两堆,每堆500瓶,然后混合A堆500瓶的水,给一只小白鼠喝,小白鼠没死,则把B堆再分成C,D两堆(如果小白鼠死了,则把A堆再分成C,D两堆),把C堆250瓶的水混合一下给一只小白鼠喝,然后等结果……以此类推,你们懂的,一直这样二分下去,确定最终的结果,需要10只小白鼠。但是这样解答有个问题,就是等的时间太长了,不符合题目要求。而且如果运气不好,10只小白鼠可能全死了,小白鼠真惨。

      看来需要再次发散,根据题意,我们只能喂一次小白鼠(一次可以喂多只)。我们首先可以先把这个问题的规模减小,如果是2瓶水,那1只小白鼠就够了,如果是4瓶水呢?假设是A、B、C、D、四瓶水,我们可以这样做A+B混合喂给一个小白鼠1号,A+C喂给一个小白鼠2号,如果1号和2号都死了,那么就是A有毒,如果都没死,那是D有毒,如果只有1号死了,那么是B有毒,如果只有2号死了,那么是C有毒。嗯,有点意思了。给一只小白鼠喂水后最终的结果是生或者死两种状态,也就是说2只小白鼠最多可以表示的状态是4种(根据排列组合原理)。那么要表示1000种状态需要多少只小白鼠?10只,因为2的10次方是1024>1000,剩下的问题是怎么组合这些瓶子来给小白鼠喂水了。

      首先,把1000瓶水从0到999编号,然后用10位二进制来表示这些瓶子,10只小白鼠从1-10编号

      0 0 0 0 0 0 0 0 0 0    0号水瓶

      0 0 0 0 0 0 0 0 0 1    1号水瓶

      0 0 0 0 0 0 0 0 1 0    2号水瓶

      0 0 0 0 0 0 0 0 1 1    3号水瓶

      0 0 0 0 0 0 0 1 0 0    4号水瓶

      0 0 0 0 0 0 0 1 0 1    5号水瓶

      0 0 0 0 0 0 0 1 1 0    6号水瓶

      ……

      1 1 1 1 1 0 0 1 1 1    999号水瓶

 

      1 2 3 4 5 6 7 8 9 10    小白鼠

      然后把1-10号小白鼠按上面的10个二进制位对好,每只小白鼠喂水规则是:如果某个瓶子的对应2进制位为1则喂给该小白鼠,如果对应的2进制为0则不喂,譬如10号小白鼠,则会喝到1号水瓶,3号水瓶,5号水瓶,……和999号水瓶的混合水。喂完之后就是等结果了。

      死了的小白鼠标记1,没死的标记0。如果只有10号小白鼠死了,那么就是0 0 0 0 0 0 0 0 0 1,结果是1号水瓶有毒。如果是8,9号小白鼠死了那么就是0 0 0 0 0 0 0 1 1 0,结果是6号水瓶有毒,如果都没死,那就是0号水瓶有毒了,显然上面的混合方法里面0号水瓶的水没有喂给任何一只小白鼠。为什么这样?其实很容易想明白,有毒的水喂了的小白鼠都死了,其它的都没有死,就这么简单。一般在面试的时候没搞这么大的数据,一般是4只老鼠,16瓶水(真心是考思维过程)。

      其实这个方法有一个理论算法支撑:Bloom Filter算法。有兴趣的可以查查资料。

      附带说再多说一句,小白鼠真惨。



面试官常常拿这个题目考那种简历上面写着精通C++的面试者(当然,一般是应届生)。首先准备一页白纸,在它的A面写上这样一段代码:

      class A
      {
      public:
            void function(){printf("Hello World");}

      };

      ……

      A* p1 = NULL;
      p1->function();

      ……

 

      在它的B面写上同样一段代码,只有一点点差异:

      class A
      {
      public:
            virtualvoid function(){printf("Hello World");}
      };

      ……

      A* p1 = NULL;
      p1->function();

      ……

      然后这样开始问,首先拿着A面的程序给面试者,问它程序会怎么样,面试者可能的答案是:一种是程序直接crash,二种是程序输出Hello World,运行正常,三种是程序输出Hello World,然后crash;然后把纸翻过来,再问面试者,程序会怎么样。如果面试者能把这个问题回答正确,并且解释清楚(关键是解释清楚),说明面试者的C++基础还是不错的。

      这个题目的真实答案,就不公布了,知道原因了,很简单,可以看下下面这段程序的汇编代码(一目了然):

      #include <stdio.h>
      class A
      {
      public:
            void function(){printf("Hello World");}

      };

      class B
      {
      public:
            virtual void function(){printf("Hello World");}
      };

      int _tmain(int argc, _TCHAR* argv[])
      {
            A* p1 = NULL;
            p1->function();

            B* p2 = NULL;
            p2->function();

      };

      A* p1 = NULL;
004113FC  mov         dword ptr [p1],0 
      
p1->function();
00411403  mov         ecx,dword ptr [p1] 
00411406  call        A::function (4110E6h)

      B* p2 = NULL;
0041140B  mov         dword ptr [p2],0 
      
p2->function();
00411412  mov         eax,dword ptr [p2] 
00411415  mov         edx,dword ptr [eax] 
00411417  mov         esi,esp 
00411419  mov         ecx,dword ptr [p2] 
0041141C  mov         eax,dword ptr [edx] 
0041141E  call        eax  
00411420  cmp         esi,esp

说明:

情况A(不是虚函数):输出Hello World,程序运行正常;
情况B(有虚函数):程序直接crash;

原因是:不是虚函数的情况,this指针当成第一个参数传入函数(一般是通过ecx寄存器),主要里面没有使用这个this指针,函数运行是没有问题,如果用到了this指针,譬如访问了对象的成员变量,同样会crash。
而虚函数的情况,就不一样了,这种情况下,对象的第一个成员是虚函数表指针(指向虚函数表,虚表指针的位置可能不不同的编译器有差异,但原理一样)。调用虚函数的时候,首先是通过对象地址(this指针)获得第一个成员(虚表指针)的值,然后再通过虚表指针+偏移获得相应虚函数的位置,再调用虚函数,其实这是C++多态的原理,因此这个地方因为对象指针为NULL,因此去空地址取虚表指针,自然crash了。

要完全理解需要理解C++虚函数实现原理。


本文转自:http://blog.csdn.net/magictong/article/details/7407728#comments


##################################

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

    简单地说,那些被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 A和class B的print()这个接口,可以看出这两个class因个体的差异而采用了不同的策略,输出的结果也是我们预料中的,分别是This is A和This 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 A的print()函数,这不是我们所期望的结果,那么解决这个问题就需要用到虚函数

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 B的print()成了虚函数了吗?回答是Yes,我们只需在把基类的成员函数设为virtual,其派生类的相应的函数也会自动变为虚函数。所以,class B的print()也成了虚函数。那么对于在派生类的相应函数前是否需要用virtual关键字修饰,那就是你自己的问题了。

现在重新运行main2的代码,这样输出的结果就是This is A和This 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,每个类都有自己的vtbl,vtbl的作用就是保存自己类中虚函数的地址,我们可以把vtbl形象地看成一个数组,这个数组的每个元素存放的就是虚函数的地址,请看图

通过上图,可以看到这两个vtbl分别为class A和class 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 A和class B来说,他们的vptr指针存放在何处呢?其实这个指针就放在他们各自的实例对象里。由于class A和class B都没有数据成员,所以他们的实例对象里就只有一个vptr指针。


##########################################

C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的 成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法。比如:模板技 术,RTTI技术,虚函数技术,要么是试图做到在编译时决议,要么试图做到运行时决议。

关于虚函数的使用方法,我在这里不做过多的阐述。大家可以看看相关的C++的书籍。在这篇文章中,我只想从虚函数的实现机制上面为大家 一个清晰的剖析。

当然,相同的文章在网上也出现过一些了,但我总感觉这些文章不是很容易阅读,大段大段的代码,没有图片,没有详细的说明,没有比较,没有举一反三。不利于学习和阅读,所以这是我想写下这篇文章的原因。也希望大家多给我提意见。

言归正传,让我们一起进入虚函数的世界。

虚函数表

对C++ 了解的人都应该知道虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。 在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了 这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。

这里我们着重看一下这张虚函数表。在C++的标准规格说明书中说到,编译器必需要保证虚函数表的指针存在于对象实例中最前面的位置(这是为 了保证正确取到虚函数的偏移量)。 这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。

听我扯了那么多,我可以感觉出来你现在可能比以前更加晕头转向了。 没关系,下面就是实际的例子,相信聪明的你一看就明白了。

假设我们有这样的一个类:

class Base {

public:

virtual void f() { cout << "Base::f" << endl; }

virtual void g() { cout << "Base::g" << endl; }

virtual void h() { cout << "Base::h" << endl; }

};

按照上面的说法,我们可以通过Base的实例来得到虚函数表。 下面是实际例程:

typedef void(*Fun)(void);

Base b;

Fun pFun = NULL;

cout << "虚函数表地址:" << (int*)(&b) << endl;

cout << "虚函数表 — 第一个函数地址:" << (int*)*(int*)(&b) << endl;

// Invoke the first virtual function

pFun = (Fun)*((int*)*(int*)(&b));

pFun();

实际运行经果如下:(Windows XP+VS2003, Linux 2.6.22 + GCC 4.1.3)

虚函数表地址:0012FED4

虚函数表 — 第一个函数地址:0044F148

Base::f

通过这个示例,我们可以看到,我们可以通过强行把&b转成int *,取得虚函数表的地址,然后,再次取址就可以得到第一个虚函数的地址了,也就是Base::f(),这在上面的程序中得到了验证(把int* 强制转成了函数指针)。通过这个示例,我们就可以知道如果要调用Base::g()和Base::h(),其代码如下:

(Fun)*((int*)*(int*)(&b)+0); // Base::f()

(Fun)*((int*)*(int*)(&b)+1); // Base::g()

(Fun)*((int*)*(int*)(&b)+2); // Base::h()

这个时候你应该懂了吧。什么?还是有点晕。也是,这样的代码看着太乱了。没问题,让我画个图解释一下。如下所示:

注意:在上面这个图中,我在虚函数表的最后多加了一个结点,这是虚函数表的结束结点,就像字符串的结束符“/0”一样,其标志了虚函数表的 结束。这个结束标志的值在不同的编译器下是不同的。在WinXP+VS2003下,这个值是NULL。而在Ubuntu 7.10 + Linux 2.6.22 + GCC 4.1.3下,这个值是如果1,表示还有下一个虚函数表,如果值是0,表示是最后一个虚函数表。

下面,我将分别说明“无覆盖”和“有覆盖”时的虚函数表的样子。没有覆盖父类的虚函数是毫无意义的。我之所以要讲述没有覆盖的情况,主要目的是为了给一个对比。在比较之下,我们可以更加清楚地知道其内部的具体实现。

一般继承(无虚函数覆盖)

下面,再让我们来看看继承时的虚函数表是什么样的。假设有如下所示的一个继承关系:

请注意,在这个继承关系中,子类没有重载任何父类的函数。那么,在派生类的实例中,其虚函数表如下所示:

对于实例:Derive d; 的虚函数表如下:

我们可以看到下面几点:

1)虚函数按照其声明顺序放于表中。

2)父类的虚函数在子类的虚函数前面。

我相信聪明的你一定可以参考前面的那个程序,来编写一段程序来验证。

一般继承(有虚函数覆盖)

覆盖父类的虚函数是很显然的事情,不然,虚函数就变得毫无意义。下面,我们来看一下,如果子类中有虚函数重载了父类的虚函数,会是一个什么样子?假设,我们有下面这样的一个继承关系。

为了让大家看到被继承过后的效果,在这个类的设计中,我只覆盖了父类的一个函数:f()。那么,对于派生类的实例,其虚函数表会是下面的一个样子:

我们从表中可以看到下面几点,

1)覆盖的f()函数被放到了虚表中原来父类虚函数的位置。

2)没有被覆盖的函数依旧。

这样,我们就可以看到对于下面这样的程序,

Base *b = new Derive();

b->f();

由b所指的内存中的虚函数表的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时,是Derive::f()被调用了。这就实现了多态。

多重继承(无虚函数覆盖)

下面,再让我们来看看多重继承中的情况,假设有下面这样一个类的继承关系。注意:子类并没有覆盖父类的函数。

对于子类实例中的虚函数表,是下面这个样子:

我们可以看到:

1) 每个父类都有自己的虚表。

2) 子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)

这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。

多重继承(有虚函数覆盖)

下面我们再来看看,如果发生虚函数覆盖的情况。

下图中,我们在子类中覆盖了父类的f()函数。

下面是对于子类实例中的虚函数表的图:

我们可以看见,三个父类虚函数表中的f()的位置被替换成了子类的函数指针。这样,我们就可以任一静态类型的父类来指向子类,并调用子类的f()了。如:

Derive d;

Base1 *b1 = &d;

Base2 *b2 = &d;

Base3 *b3 = &d;

b1->f(); //Derive::f()

b2->f(); //Derive::f()

b3->f(); //Derive::f()

b1->g(); //Base1::g()

b2->g(); //Base2::g()

b3->g(); //Base3::g()

安全性

每次写C++的文章,总免不了要批判一下C++。这篇文章也不例外。通过上面的讲述,相信我们对虚函数表有一个比较细致的了解了。水可载舟,亦可覆舟。下面,让我们来看看我们可以用虚函数表来干点什么坏事吧。

一、通过父类型的指针访问子类自己的虚函数

我们知道,子类没有重载父类的虚函数是一件毫无意义的事情。因为多态也是要基于函数重载的。虽然在上面的图中我们可以看到Base1的虚表中有Derive的虚函数,但我们根本不可能使用下面的语句来调用子类的自有虚函数:

Base1 *b1 = new Derive();

b1->f1(); //编译出错

任何妄图使用父类指针想调用子类中的未覆盖父类的成员函数 的行为都会被编译器视为非法,所以,这样的程序根本无法编译通过。但在运行时,我们可以通过指针的方式访问虚函数表来达到违反C++语义的行为。(关于这方面的尝试,通过阅读后面附录的代码,相信你可以做到这一点)

二、访问non-public 的虚函数

另外,如果父类的虚函数是private或是protected的,但这些非public的虚函数同样会存在于虚函数表中,所以,我们同样可以使用访问虚函数表的方式来访问这些non-public的虚函数,这是很容易做到的。

如:

class Base {

private:

virtual void f() { cout << "Base::f" << endl; }

};

class Derive : public Base{

};

typedef void(*Fun)(void);

void main() {

Derive d;

Fun pFun = (Fun)*((int*)*(int*)(&d)+0);

pFun();

}

结束语

C++这门语言是一门Magic的语言,对于程序员来说,我们似乎永远摸不清楚这门语言背着我们在干了什么。需要熟悉这门语言,我们就必需要了解C++里面的那些东西,需要去了解C++中那些危险的东西。不然,这是一种搬起石头砸自己脚的编程语言。


  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值