C ++ 虚函数 (转)

来源:互联网 发布:彗星dns优化 编辑:程序博客网 时间:2024/05/16 12:54

第一节、一道简单的虚函数的面试题
题目要求:写出下面程序的运行结果?

1、当上述程序中的函数p()不是虚函数,那么程序的运行结果是如何?即如下代码所示:

class A
{
public:
void p()
{
cout << “A” << endl;
}
};

class B : public A
{
public:
void p()
{
cout << “B” << endl;
}
};

没有虚函数,就不是多态了,虚函数是运行时多态的基础(函数重载和运算符重载是 编译时多态的基础)

我们知道,在构造一个类的对象时,如果它有基类,那么首先将构造基类的对象,然后才构造派生类自己的对象。如上,A* a=new A,调用默认构造函数构造基类A对象,然后调用函数p(),a->p();输出A,这点没有问题。
然后,A * b = new B;,构造了派生类对象B,B由于是基类A的派生类对象,所以会先构造基类A对象,然后再构造派生类对象,但由于当程序中函数是非虚函数调用时,B类对象对函数p()的调用时在编译时就已静态确定了,所以,不论基类指针b最终指向的是基类对象还是派生类对象,只要后面的对象调用的函数不是虚函数,那么就直接无视,而调用基类A的p()函数。

class A
{
public:
virtual void p()
{
cout << “A” << endl;
}
};

class B : public A
{
public:
virtual void p()
{
cout << “B” << endl;
}
};

int main()
{
A * a = new A;
A * b = new B;
a->p();
b->p();
delete a;
delete b;
return 0;
}

那么程序的输出结果将是A B。

第三节、虚函数的原理与本质
我们已经知道,虚(virtual)函数的一般实现模型是:每一个类(class)有一个虚表(virtual table),内含该class之中有作用的虚(virtual)函数的地址,然后每个对象有一个vptr,指向虚表(virtual table)的所在。

每一个类有一个虚表,每一个类的对象有一个指向虚表的指针vptr

请允许我援引自深度探索c++对象模型一书上的一个例子:

class Point {
public:
virtual ~Point();

virtual Point& mult( float ) = 0;

float x() const { return _x; } //非虚函数,不作存储,这个函数会转化为一个全局函数,而对象作为第一个参数,隐式的传给这个函数
virtual float y() const { return 0; }
virtual float z() const { return 0; }
// …

protected:
Point( float x = 0.0 );
float _x;
};

1、在Point的对象pt中,有两个东西,一个是数据成员_x,一个是_vptr_Point。其中_vptr_Point指向着virtual table point,而virtual table(虚表)point中存储着以下东西:

virtual ~Point()被赋值slot 1,
mult() 将被赋值slot 2.
y() is 将被赋值slot 3
z() 将被赋值slot 4.
class Point2d : public Point {
public:
Point2d( float x = 0.0, float y = 0.0 )
: Point( x ), _y( y ) {}
~Point2d(); //1

//改写base class virtual functions
Point2d& mult( float ); //2
float y() const { return _y; } //3

protected:
float _y;
};

2、在Point2d的对象pt2d中,有三个东西,首先是继承自基类pt对象的数据成员_x,然后是pt2d对象本身的数据成员_y,最后是_vptr_Point。其中_vptr_Point指向着virtual table point2d。由于Point2d继承自Point,所以在virtual table point2d中存储着:改写了的其中的~Point2d()、Point2d& mult( float )、float y() const,以及未被改写的Point::z()函数。

class Point3d: public Point2d {
public:
Point3d( float x = 0.0,
float y = 0.0, float z = 0.0 )
: Point2d( x, y ), _z( z ) {}
~Point3d();

// overridden base class virtual functions
Point3d& mult( float );
float z() const { return _z; }

// … other operations …
protected:
float _z;
};

3、在Point3d的对象pt3d中,则有四个东西,一个是_x,一个是_vptr_Point,一个是_y,一个是_z。其中_vptr_Point指向着virtual table point3d。由于point3d继承自point2d,所以在virtual table point3d中存储着:已经改写了的point3d的~Point3d(),point3d::mult()的函数地址,和z()函数的地址,以及未被改写的point2d的y()函数地址。

ok,上述1、2、3所有情况的详情,请参考下图。

这里写图片描述

(图:virtual table(虚表)的布局:单一继承情况)

本文,日后可能会酌情考虑增补有关内容。ok,更多,可参考深度探索c++对象模型一书第四章。
最近几章难度都比较小,是考虑到狂想曲有深有浅的原则,后续章节会逐步恢复到相应难度。

第四节、虚函数的布局与汇编层面的考察

一道试题:

#include <iostream>using namespace std;  class Base   {  public:  int m_base;  virtual void f() { cout << "Base::f" << endl; }  virtual void g() { cout << "Base::g" << endl; }  };  class Derive : public Base  {  int m_derived;  };  typedef void(*Fun)(void);  void main()  {    Base *d = new Derive;    Fun pFun = (Fun)*((int*)*(int*)(d)+0);    printf("&(Base::f): 0x%x /n", &(Base::f));    printf("&(Base::g):0x%x /n", &(Base::g));    printf("pFun: 0x%x /n", pFun);    pFun();  } 
 在打印的时候发现pFun的地址和 &(Base::f)的地址竟然不一样太奇述的布局如下怪了?经过一番深入研究,终于把这个问题弄明白了。下面就来一步步进行剖析。  根据VC的虚函数的布局机制,上述的布局如下:

这里写图片描述

    然后我们再细细的分析第一种方式:     Fun pFun = (Fun)*((int*)*(int*)(d)+0);

d是一个类对象的地址。而在32位机上指针的大小是4字节,因此(int)(&d)取得的是vfptr,即虚表的地址。从而((int)(int)(&d)+0)是虚表的第1项,也就是Base::f()的地址。事实上我们得到了验证,程序运行结果如下:
这里写图片描述

这说明虚表的第一项确实是虚函数的地址,上面的VC虚函数的布局也确实木有问题。

  但是,接下来就引发了一个问题,为什么&(Base::F)和PFun的值会不一样呢?既然PFun的值是虚函数f的地址,那&(Base::f)又是什么呢?带着这个问题,我们进行了反汇编。

printf(“&(Base::f): 0x%x /n”, &(Base::f));

00401068  mov         edi,dword ptr [__imp__printf (4020D4h)] 0040106E  push        offset Base::`vcall'{0}' (4013A0h) 00401073  push        offset string "&(Base::f): 0x%x /n" (40214Ch) 00401078  call        edi

printf(“&(Base::g): 0x%x /n”, &(Base::g));

0040107A  push        offset Base::`vcall'{4}' (4013B0h) 0040107F  push        offset string "&(Base::g): 0x%x /n" (402160h) 00401084  call        edi

那么从上面我们可以清楚的看到:

 Base::f 对应于Base::`vcall'{0}' (4013A0h)  Base::g对应于Base::`vcall'{4}' (4013B0h)

那么Base::vcall'{0}'和Base::vcall’{4}’到底是什么呢,继续进行反汇编分析

Base::`vcall’{0}’:

004013A0  mov         eax,dword ptr [ecx] 004013A2  jmp         dword ptr [eax]   ......

Base::`vcall’{4}’:

004013B0  mov         eax,dword ptr [ecx] 004013B2  jmp         dword ptr [eax+4] 第一句中, 由于ecx是this指针, 而在VC中一般虚表指针是类的第一个成员, 所以它是把vfptr, 也就是虚表的地址存到了eax中. 第二句

相当于取了虚表的某一项。对于Base::f跳转到Base::vcall'{0}',取了虚表的第1项;对于Base::g跳转到Base::vcall’{4}’,取了虚表第2项。由此都能够正确的获得虚函数的地址。

  由此我们可以看出,vc对此的解决方法是由编译器加入了一系列的内部函数"vcall". 一个类中的每个虚函数都有一个唯一与之对应的vcall函数,通过特定的vcall函数跳转到虚函数表中特定的表项。 更深一步的进行讨论,考虑多态的情况,将代码改写如下:
#include <iostream>using namespace std;  class Base   {  public:  virtual void f() { cout << "Base::f" << endl; }  virtual void g() { cout << "Base::g" << endl; }  };  class Derive : public Base{  public:  virtual void f() { cout << "Derive::f" << endl; }  virtual void g() { cout << "Derive::g" << endl; }  };  typedef void(*Fun)(void);  void main()   {      Base *d = new Derive;      Fun pFun = (Fun)*((int*)*(int*)(d)+0);      printf("&(Base::f): 0x%x /n", &(Base::f));      printf("&(Base::g): 0x%x /n", &(Base::g));      printf("&(Derive::f): 0x%x /n", &(Derive::f));      printf("&(Derive::g): 0x%x /n", &(Derive::g));      printf("pFun: 0x%x /n", pFun);      pFun();  } 

打印的时候表现出来了多态的性质:
这里写图片描述

分析可知原因如下:
这里写图片描述

  这是因为类Derive的虚函数表的各项对应的值进行了改写(rewritting),原来指向Based::f()的地址变成了指向Derive::f(),原来指向Based::g()的地址现在编变成了指向Derive::g()。

反汇编代码如下:

printf(“&(Derive::f): 0x%x /n”, &(Derive::f));

00401086  push        offset Base::`vcall'{0}' (4013B0h) 0040108B  push        offset string "&(Derive::f): 0x%x /n" (40217Ch) 00401090  call        esi

printf(“&(Derive::g): 0x%x /n”, &(Derive::g));

00401092  push        offset Base::`vcall'{4}' (4013C0h) 00401097  push        offset string "&(Derive::g): 0x%x /n" (402194h) 

0040109C call esi

  因此虽然此时Derive::f依然对应Base::`vcall'{0}',而 Derive::g依然对应Base::`vcall'{4}',但是由于每个类有一个虚函数表,因此跳转到的虚表的位置也发生了改变,同时因为进行了改写,虚表中的每个slot项的值也不一样。

稍微总结一下:

在VC中有两种方法调用虚函数,一种是通过虚表,另外一种是通过vcall thunk的方式

通过虚表的方式:

   base *d = new Derive;   d->f();           004115FA  mov         eax,dword ptr [d]          004115FD  mov         edx,dword ptr [eax]           004115FF  mov         esi,esp           00411601  mov         ecx,dword ptr [d]          00411604  mov         eax,dword ptr [edx]          00411606  call        eax           00411608  cmp         esi,esp          0041160A  call        @ILT+470(__RTC_CheckEsp) (4111DBh)

这种方式的应用环境是通过类对象的指针或引用来调用虚函数

通过vcall thunk的方式:

   typedef void (Base::* func1)( void );   base *d = new Derive;   func1 pFun1 = &Base::f;   (d->*pFun1)();            004115A9  mov         dword ptr [pFun1],offset Base::`vcall'{0}' (4110C3h)            004115B0  mov         esi,esp            004115B2  lea          ecx,[d]            004115B5  call          dword ptr [pFun1]            004115B8  cmp         esi,esp            004115BA  call        @ILT+460(__RTC_CheckEsp) (4111D1h)

这种方式对应的应用环境是通过类成员函数的指针来调用虚函数

4.2 透视C++对象模型

一、c++对象模型

在C++中,有两种类数据成员:静态和非静态,以及三种类成员函数:静态、非静态和虚拟。比如下面的class Point的声明:

class Point
pulic:
Point (float xval)
virtual ~point();
float x() const;
static int PointCount();
protected:
virtual ostream& print (ostream &os) const;
float _x;
static int _point_count;
}
C++对象模型对内存空间和存取时间做了优化。在此模型中,在cfront2.0编译器中,非静态数据成员配置于每一个类对象内;静态数据成员则存放于所有类对象之外(比如存放在data segment中);虚函数则分两个步骤来进行支持:

  1. 每一个类 产生一个虚表,除第1个slot指向类的type_info外,虚表中的每个slot为一个虚函数的指针。

  2. 每一个类对象添加一个指针数据成员vptr,指向相关的虚表

以上面的Point类为例,对象模型如下:
这里写图片描述

二、含有虚基类的多重继承

值得注意的是,虚表已经成为一种公认的方法支持虚函数,而对于虚基类的支持不同编译器有自己的方式:在VC在每个类中增加了一个虚基类表,同时在添加一个指针数据成员指向这个虚基类表;而有些编译器如cfront喜欢在虚表中放置虚基类的offset。比如Point2d,Point3d类的声明:

class Point2d
{
public:

void operator += (const Point2d& rhs)
{
_x += rhs.x();
_y += rhs.y();
}
protected:
float _x, _y;
}
class Point3d: public virtual Point2d
{
public:

void operator += (const Point3d& rhs)
{
Point2d::operator +=( rhs)
_z += rhs.z();
}
protected:
float _z
}
在cfront中的布局如下:
这里写图片描述

采用上述布局,则在cfront实现模型之下,因为类的数据成员(比如基类,此例中派生类的数据成员_z的offset没变)operator运算符必须转换成如下形式:

    //虚拟c++码   (this + _vptr_Point3d[-1])->_x += (&rhs + rhs._vptr_Point3d[-1])->_x ;   (this + _vptr_Point3d[-1])->_y += (&rhs + rhs._vptr_Point3d[-1])->_y ;   _z += rhs._z

三、对象的大小

在实际计算类的大小的过程中,可能和我们想象的不同,主要可能是以下两个方面造成的:
  1. 因为需要支持虚函数、虚基类所增加的指针数据成员(比如虚表指针vptr、虚基类指针bptr。在32位机器上,指针的大小均为4个字节)。

  2. 字节对齐。每个类按照数据成员大小的最大值进行字节对齐。

按这种计算方法,使用cfront的对象实现模型:Point2d对象的大小为4+4+4=12字节,Point3d对象的字节为16字节。

第五节、虚函数表的详解

本节全部内容来自淄博的共享,非常感谢。

一般继承(无虚函数覆盖)
下面,再让我们来看看继承时的虚函数表是什么样的。假设有如下所示的一个继承关系:
这里写图片描述

请注意,在这个继承关系中,子类没有重载任何父类的函数。那么,在派生类的实例中,

对于实例: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->g1(); //编译出错

任何妄图使用父类指针想调用子类中的未覆盖父类的成员函数的行为都会被编译器视为非法,即基类指针不能调用子类自己定义的成员函数。所以,这样的程序根本无法编译通过。
但在运行时,我们可以通过指针的方式访问虚函数表来达到违反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();
}

对上面粗体部分的解释(@a && x):

  1. (int*)(&d)取vptr地址,该地址存储的是指向vtbl的指针
  2. (int*)(int)(&d)取vtbl地址,该地址存储的是虚函数表数组
  3. (Fun)((int)(int)(&d) +0),取vtbl数组的第一个元素,即Base中第一个虚函数f的地址
  4. (Fun)((int)(int)(&d) +1),取vtbl数组的第二个元素(这第4点,如下图所示)。

下图也能很清晰的说明一些东西(@5):

这里写图片描述

ok,再来看一个问题,如果一个子类重载的虚拟函数为privete,那么通过父类的指针可以访问到它吗?
““

include

class B
{
public:
virtual void fun()
{
std::cout << “base fun called”;
};
};

class D : public B
{
private:
virtual void fun()
{
std::cout << “driver fun called”;
};
};

int main(int argc, char* argv[])
{
B* p = new D();
p->fun();
return 0;
}
“`
运行时会输出 driver fun called

从这个实验,可以更深入的了解虚拟函数编译时的一些特征:
在编译虚拟函数调用的时候,例如p->fun(); 只是按其静态类型来处理的, 在这里p的类型就是B,不会考虑其实际指向的类型(动态类型)。
也就是说,碰到p->fun();编译器就当作调用B的fun来进行相应的检查和处理。
因为在B里fun是public的,所以这里在“访问控制检查”这一关就完全可以通过了。
然后就会转换成(*p->vptr[1])(p)这样的方式处理, p实际指向的动态类型是D,
所以p作为参数传给fun后(类的非静态成员函数都会编译加一个指针参数,指向调用该函数的对象,我们平常用的this就是该指针的值), 实际运行时p->vptr[1]则获取到的是D::fun()的地址,也就调用了该函数, 这也就是动态运行的机理。

为了进一步的实验,可以将B里的fun改为private的,D里的改为public的,则编译就会出错。
C++的注意条款中有一条” 绝不重新定义继承而来的缺省参数值”
(Effective C++ Item37, never redefine a function’s inherited default parameter value) 也是同样的道理。

可以再做个实验
class B
{
public:
virtual void fun(int i = 1)
{
std::cout << “base fun called, ” << i;
};
};

class D : public B
{
private:
virtual void fun(int i = 2)
{
std::cout << “driver fun called, ” << i;
};
};

则运行会输出driver fun called, 1

关于这一点,Effective上讲的很清楚“virtual 函数系动态绑定, 而缺省参数却是静态绑定”,
也就是说在编译的时候已经按照p的静态类型处理其默认参数了,转换成了(*p->vptr[1])(p, 1)这样的方式。

补遗

一个类如果有虚函数,不管是几个虚函数,都会为这个类声明一个虚函数表,这个虚表是一个含有虚函数的类的,不是说是类对象的。一个含有虚函数的类,不管有多少个数据成员,每个对象实例都有一个虚指针,在内存中,存放每个类对象的内存区,在内存区的头部都是先存放这个指针变量的(准确的说,应该是:视编译器具体情况而定),从第n(n视实际情况而定)个字节才是这个对象自己的东西。

下面再说下通过基类指针,调用虚函数所发生的一切:
One *p;
p->disp();

1、上来要取得类的虚表的指针,就是要得到,虚表的地址。存放类对象的内存区的前四个字节其实就是用来存放虚表的地址的。
2、得到虚表的地址后,从虚表那知道你调用的那个函数的入口地址。根据虚表提供的你要找的函数的地址。并调用函数;你要知道,那个虚表是一个存放指针变量的数组,并不是说,那个虚表中就是存放的虚函数的实体。

转自:

http://blog.csdn.net/zhanglei8893/article/details/6329971

http://blog.csdn.net/v_july_v/article/details/6446364

0 0