C++虚函数及虚函数表解析
虚函数的定义:
虚函数必须是类的⾮静态成员函数(且⾮构造函数),其访问权限是public(可以定义为private or proteceted,但是对于多态来说,没有意义。),在基类的类定义中定义虚函数的⼀般形式:
virtual 函数返回值类型虚函数名(形参表)
{ 函数体 }
虚函数的作⽤是实现动态联编,也就是在程序的运⾏阶段动态地选择合适的成员函数,在定义了虚函数后,
可以在基类的派⽣类中对虚函数重新定义(形式也是:virtual 函数返回值类型虚函数名(形参表){ 函数体 }),在派⽣类中重新定义的函数应与虚函数具有相同的形参个数和形参类型。以实现统⼀的接⼝,不同定义过程。如果在派⽣类中没有对虚函数重新定义,则它继承其基类的虚函数。当程序发现虚函数名前的关键字virtual后,会⾃动将其作为动态联编处理,即在程序运⾏时动态地选择合适的成员函数。
实现动态联编需要三个条件:
1、必须把需要动态联编的⾏为定义为类的公共属性的虚函数。
2、类之间存在⼦类型关系,⼀般表现为⼀个类从另⼀个类公有派⽣⽽来。
3、必须先使⽤基类指针指向⼦类型的对象,然后直接或者间接使⽤基类指针调⽤虚函数。
定义虚函数的限制:
(1)⾮类的成员函数不能定义为虚函数,类的成员函数中静态成员函数和构造函数也不能定义为虚函数,但可以将析构函数定义为虚函数。实际上,优秀的程序员常常把基类的析构函数定义为虚函数。因为,将基类的析构函数定义为虚函数后,当利⽤delete删除⼀个指向派⽣类定义的对象指针时,系统会调⽤相应的类的析构函数。⽽不将析构函数定义为虚函数时,只调⽤基类的析构函数。
(2)只需要在声明函数的类体中使⽤关键字“virtual”将函数声明为虚函数,⽽定义函数时不需要使⽤关键字“virtual”。
(3)如果声明了某个成员函数为虚函数,则在该类中不能出现和这个成员函数同名并且返回值、参数个数、参数类型都相同的⾮虚函数。在以该类为基类的派⽣类中,也不能出现这种⾮虚的同名同返回值同参数个数同参数类型函数。
为什么虚函数必须是类的成员函数:
虚函数诞⽣的⽬的就是为了实现多态,在类外定义虚函数毫⽆实际⽤处。
为什么类的静态成员函数不能为虚函数:
如果定义为虚函数,那么它就是动态绑定的,也就是在派⽣类中可以被覆盖的,这与静态成员函数的定义(:在内存中只有⼀份拷贝;通过类名或对象引⽤访问静态成员)本⾝就是相⽭盾的。
为什么构造函数不能为虚函数:
因为如果构造函数为虚函数的话,它将在执⾏期间被构造,⽽执⾏期则需要对象已经建⽴,构造函数所完成的就是为了建⽴合适的对象,因此在没有构建好的对象上不可能执⾏多态(虚函数的⽬的就在于实现多态性)的⼯作。在继承体系中,构造的顺序就是从基类到派⽣类,其⽬的就在于确保对象能够成功地构建。构造函数同时承担着虚函数表的建⽴,如果它本⾝都是虚函数的话,如何确保vtbl的构建成功呢?
注意:当基类的构造函数内部有虚函数时,会出现什么情况呢?结果是在构造函数中,虚函数机制不起作⽤了,调⽤虚函数如同调⽤⼀般的成员函数⼀样。当基类的析构函数内部有虚函数时,⼜如何⼯作呢?与构造函数相同,只有“局部”的版本被调⽤。但是,⾏为相同,原因是不⼀样的。构造函数只能调⽤“局部”版本,是因为调⽤时还没有派⽣类版本的信息。析构函数则是因为派⽣类版本的信息已经不可
靠了。我们知道,析构函数的调⽤顺序与构造函数相反,是从派⽣类的析构函数到基类的析构函数。当某个类的析构函数被调⽤时,其派⽣类的析构函数已经被调⽤了,相应的数据也已被丢失,如果再调⽤虚函数的派⽣类的版本,就相当于对⼀些不可靠的数据进⾏操作,这是⾮常危险的。因此,在析构函数中,虚函数机制也是不起作⽤的。
C++中的虚函数的作⽤主要是实现了多态的机制。关于多态,简⽽⾔之就是⽤⽗类型别的指针指向其⼦类的实例,然后通过⽗类的指针调⽤实际⼦类的成员函数。这种技术可以让⽗类的指针有“多种形态”,这是⼀种泛型技术。所谓泛型技术,说⽩了就是试图使⽤不变的代码(Or 不变的接⼝)来实现可变的算法。⽐如:模板技术,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的实例来得到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; 的虚函数表如下:(overload(重载)和 override(重写),重载就是所谓的名同⽽签名不同,重写就是对⼦类对虚函数的重新实现。)
我们可以看到下⾯⼏点:
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++。这篇⽂章也不例外。通过上⾯的讲述,相信我们对虚函数表有⼀个⽐较细致的了解了。⽔可载⾈,亦可覆⾈。下⾯,让我们来看看我们可以⽤虚函数表来⼲点什么坏事吧。
⼀、尝试:通过⽗类型的指针(指向⼦类对象)访问⼦类⾃⼰的虚函数
我们知道,⼦类没有重载⽗类的虚函数是⼀件毫⽆意义的事情。因为多态也是要基于函数重载的。虽
然在上⾯的图中我们可以看到⼦类的虚表中有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++中那些危险的东西。不然,这是⼀种搬起⽯头砸⾃⼰脚的编程语⾔。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论