C++虚拟函数实现

来源:互联网 发布:java配置数据库连接池 编辑:程序博客网 时间:2024/04/29 09:01

 

 

一、动态联编与多态

联编就是将模块或者函数合并在一起,以生成可执行代码的处理过程,同时对每个模块或者函数调用分配内存地址,并且对外部访问也分配正确的内存地址。按照联编所进行的阶段不同,可分为两种不同的联编方法:静态联编和动态联编。在编译阶段就将函数实现和函数调用关联起来称之为静态联编,静态联编在编译阶段就必须了解所有的函数或模块执行所需要检测的信息,它对函数的选择是基于指向对象的指针(或者引用)的类型。反之在程序执行的时候才进行这种关联称之为动态联编。动态联编对成员函数的选择不是基于指针或者引用,而是基于对象类型,不同的对象类型将做出不同的编译结果。C语言中,所有的联编都是静态联编。C++中一般情况下联编也是静态联编,但是一旦涉及到多态性和虚函数就必须使用动态联编。

多态性的最主要思想就是可以通过多种形式,用一个用户名字或者用户接口完成不同的实现。通常多态性被简单的描述为“一个接口,多个实现”。在C++里面具体的表现为通过基类指针访问派生类的函数和方法。

下面我们看一个静态联编的例子,这种静态联编导致了我们不希望的结果。

//1.cpp

#include <iostream.h>

class shape

{

public:

    void draw(){cout<<"I am shape"<<endl;}

    void fun(){draw();}

};

 

class circle:public shape

{

public:

    void draw(){cout<<"I am circle"<<endl;}

};

 

void main()

{

   shape* oneshape = new circle();

   oneshape->fun();

}

程序的输出结果我们希望是"I am circle",但事实上却输出了"I am shape"的结果,造成这个结果的原因是静态联编。静态联编需要在编译时候就确定函数的实现,但事实上编译器在仅仅知道shape的地址时候无法获取正确的调用函数,它所知道的仅是shape::draw(),最终结果只能是draw操作束缚到shape类上。产生"I am shape"的结果就不足为奇了。

为了能够引起动态联编,我们只需要将需要动态联编的函数声明为虚函数即可。动态联编只对虚函数起作用。只要这个基类中直接的或者间接(从上上层继承)的包含虚函数,我们在通过基类而且只有通过基类访问派生类的时候,动态联编将自动唤醒。下面我们将上面的程序稍微改一下。

//2.cpp

#include <iostream.h>

class shape

{

  public:

    virtual void draw(){cout<<"I am shape"<<endl;}

    void fun(){draw();}

};

 

class circle:public shape

{

  public:

    void draw(){cout<<"I am circle"<<endl;}

};

 

void main()

{

   class circle  oneshape;

   fun(&oneshape);

 }

程序行得到了正确的"I am circle"。代VC6.0行。

 

到目前为止我们不清楚动态联编的执行机制,但可以做个猜测。正如上面所说,如果函数的实际对象类型不同,联编结果也应该不同。在静态联编中,执行时无法通过基类知道需要联编的子对象的确切类型。在1.cppshape的派生类既可能是circle,也可能是其余的rectangle或者square等等,到底应该静态联编哪一个呢。动态联编在编译的时候应该也是不知道联编的确切对象类型的,(如果知道的话就成了静态联编了),因此它只能通过一定的机制,使得在执行时候能够找到和调用正确的函数体。可以想象,为了达到这个目的,一些相关信息应该封装在对象自身中。这些信息有点象身份证明,标识自己,这样在动态联编的时候,编译器可以根据这些标记找到相应的函数体。

实际上的动态联编过程是什么样的呢。

 

型信息

明我的猜想,我用下面的一个程序测试,下面的程序将取普通的和包含虚函数的的字大小。程序代如下。

//3.cpp

#include <iostream.h>

class shape_novirtual

{

    int a;

 public:

    void  draw(){cout<<"shape_novirtual::draw()"<<endl;}

 };

 

class shape_virtual1

{

     int a;

 public:

     virtual void draw(){cout<<"shape_virtual::draw()"<<endl;}

 };

 

class shape_virtual2

{

    int a;

  public:

     virtual void draw(){cout<<"shape_virtual2::draw()"<<endl;}

     virtual void draw1(){cout<<"shape_virtual2::draw1()"<<endl;}

 };

 

void main()

{

   cout<<"sizeof(int)"<<sizeof(int)<<endl;

   cout<<"sizeof(class shape_novirtual):"<<sizeof(shape_novirtual)<<endl;

   cout<<"sizeof(void*):"<<sizeof(void*)<<endl;

   cout<<"sizeof(class shape_virtual):"<<sizeof(shape_virtual)<<endl;

   cout<<"sizeof(class shape_virtual2):"<<sizeof(shape_virtual2)<<endl;

 }

 

VC6.0中运行果如下:

sizeof(int)4

sizeof(class shape_novirtual):4

sizeof(void*):4

sizeof(class shape_virtual1):8

sizeof(class shape_virtual2):8

Press any key to continue

从上面可以看出,没有虚函数的类shape_novirtual的大小为4,正好为其成员变量int a的大小。而带有虚函数的类shape_virtual1shape_virtual2的大小除了int a的大小还多出了4格个字节的大小,这个大小正好是void*指针的大小。到现在为止我们基本上可以说带有虚函数的对象自身确实插入了一些指针信息,而且这个指针信息并不随着虚函数的增加而增大。

如果我们将每个类的成员变量int a去掉,运行结果就会变成下面的情况。

sizeof(int)4

sizeof(class shape_novirtual):1

sizeof(void*):4

sizeof(class shape_virtual1):4

sizeof(class shape_virtual2):4

上面的运行结果应该让人感到例外。既然size(int)4,现在没有了这个成员变量,类shape_novirtual应该字节大小为0,但事实上C++编译器不允许对象为零长度。试想一个长度为0的对象在内存中怎么存放?怎么获取它的地址?为了避免这种情况,C++强制给这种类插入一个缺省成员,长度为1。如果有自定义的变量,变量将取代这个缺省成员。

 

三 虚函数表VTAB

动态联编过程跟我们猜测的大致相同。编译器在执行过程中遇到virtual关键字的时候,将自动安装动态联编需要的机制,首先为这些包含virtual函数的类(注意不是类的实例)--即使是祖先类包含虚函数而本身没有--建立一张虚拟函数表VTABLE。在这些虚拟函数表中,编译器将依次按照函数声明次序放置类的特定虚函数的地址。同时在每个带有虚函数的类中放置一个称之为vpointer的指针,简称vptr,这个指针指向这个类的VTABLE

关于虚拟函数表,有几点必须声明清楚:

1. 每个类只有一个虚函数表,如果该类没有虚函数,则不存在虚拟函数表。

2.编译时,编译器在含有虚函数的类中加上一个指向虚函数表的指针vptr

3. 从一个类别诞生的每一个对象,将获取该类别中的vptr指针,这个指针同样指向类的VTABLE

因此类、对象、VTABLE的层次结构可用下图表示。其中X类和Y类的对象的指针都指向了X,Y的虚拟函数表,同时X,Y类自身也包含了指向虚拟函数的指针。

 

 

为了方便问题说明,我们将2.cpp例子进行扩展,扩展程序如下。

//4.cpp

#include <iostream.h >

class Shape

    {

  public:

virtual void Draw()

  {

        cout<<"shape::draw()"<<endl;

}

 

virtual void Area()

{

      cout<<"shape::area()"<<endl;

}

 

void Fun()

{

Draw();Area();

}

    };

 

class Circle: public Shape

    {

public:

  void Draw()

{

cout<<" Circle::Draw()"<<endl;

}

 

  virtual void Adjust()

{

cout<<" Circle::Adjust()"<<endl;

}

    };

 

void main()

{

  Shape oneshape;

  oneshape.Fun();     

  Circle circleShape;

  Shape& baseshape = circleShape;

  baseshape.Fun();

}

编译器在编译上面这段代码时,为ShapeCircle两个类各建立一个VTABLE表,这些表根据自身记录的虚函数信息,依次填充派生类对象和基类对象中的所有虚函数地址。虚函数表是类级别的,类的所有对象共享同一个虚函数表。可以生成类Shape的两个对象,然后通过观察对象的地址、虚函数表指针地址、虚函数表地址、及虚函数表中的条目的值(即所指向的函数地址)来进行验证。

如果派生类本身没有重新定义基类的虚函数,那么就用基类的虚函数地址进行填充,即这种情况下派生类中的虚函数地址和基类的同名虚函数的函数地址是一样的。这样如果调用一个派生类中不存在的方法时,就能够自动调用基类的方法。然后编译器在每个类中放置一个vptr,一般置于对象的起始位置,继而在对象的构造函数中将vptr初始化为本类的VTABLE的地址。整个结果布局如下。

  

图一

一中的CircleVTABLE中的Area() 就是用基的虚函数地址填充的。C++ 编译程序按下面的步骤进行工作:

    建立虚函数表,如果没有虚函数不建立。

    暂时接虚函数,而是将各个虚函数的地址放入虚函数表中。

    直接接各静函数。

些工作做完之后,模块图二:

 

生了oneshapecircleshape两个象,它们vptr指向各自VTABLE,在oneshape.fun()fun函数的this指向了oneshape象,fun()之后程序继续执this->draw(),由于oneshapevptr指向shapeVTABLE这样就从VTABLE中得到需要定的函数地址,并接起来。同this->area()oneshape象而接到相的函数上,如三。

 

在我们执baseshape.fun()函数。

circle  circleshape;

shape&  baseshape=circleshape;

baseshape.fun();

函数进入fun函数之后,函数的this指针将指向baseshape对象,另一方面baseshape指向一个circleshape,因此this实际指向的为circleshape对象,而circleshapevptr指针指向circle类的虚拟函数表,这样编译器将从虚拟表中取出circle::draw()circle::area()的地址,进行连接。因为circle本身没有重新定义area()方法,因此编译器使用shapearea()方法。如图四。

 

遵循上面的思路,基于基类的指针总能找到正确的子类对象的实现。但是象上面的this->draw是怎么编译的呢。

 

编译内幕

 

在上面的程序中,this指针不同,从而连接到不同的fun函数。那么C++如何编译这些指令呢。道理在于:所有的派生类的虚拟函数表的顺序与基类的顺序是一样的,对于基类中不存在方法再按照声明次序进行排放。这样不管是shape还是circle或者从shape又继承出来的其余的类它们的虚拟函数表的第一项总是draw函数的地址,然后是area的地址。对于circle类,下面的才是adjust的地址。因此不管对于shape还是circlethis->draw总是编译成 call this->VTABLE[0]; this->area()总是翻译成 call this->VTABLE[1]; 程序到真正运行时候将会发现this的真正指向的对象,如果是shape,则调用shape->VTABLE[0],如果是circle,则调用circle->VTABLE[1]

看下面的个例子。

 #include <iostream.h>

 class shape

{

  public:

    virtual void draw(){cout<<"shape::draw()"<draw();//OK

    virtual void area(){cout<<"shape::area()"<<endl;}

    void fun(){draw();area();}

 };

 

class circle:public shape

{

 public:

    void draw(){cout<<"circle::draw()"<<endl;}

    virtual void adjust(){cout<<"circle::adjust()"<<endl;}

 };

 

void main()

{

  shape* oneshape;

  oneshape=new circle;

  oneshape->draw();//OK

  oneshape->adjust();//错误,编译器无法通

 

  circle* circleshape;

  circleshape->adjust();

 }

在编译期,由于oneshapeshape类型的,因此将检查shape的虚拟函数表,发现VTABLE[0]draw函数的地址,于是翻译成p->VTABLE[0]。未来执行期间,p 实际上指向的是circle对象,因此真正调用的为circle->VTABLE[0]处的函数,即circle::draw

对于adjust函数,编译器也会去检查shapeVTABLE,结果编译器无法找到adjust函数,因此编译无法通过。对于circleshape,因为它是circle类型的,因此它将会检查circleVTABLE,得知VTABLE[2]处为adjust的地址,因此编译器翻译成call circleshape->VTABLE[2],真正执行时候circleshapecircle类型,因此它将绑定circleVTABLE[2]处的函数即circle:: adjust()。 就这样,编译器借助虚拟函数表实现了动态联编的过程,从而使多态的实现有了可能。因此说虚拟函数表是多态性的幕后功臣一点也不为过。

 =======================================================

说明:本文转载自张中庆的文章《动态联编 c++》,对原文有小幅改动。

原创粉丝点击