-
揭秘虚函数多态的实现细节
添加时间:2013-6-1 点击量:1、什么是虚函数
简单地说:那些被virtual关键字润饰的成员函数就是虚函数。其首要感化就是实现多态性。
多态性是面向对象的核心:它的首要的思惟就是可以采取多种情势的才能,经由过程一个用户名字或者用户接口完成不合的实现。凡是多态性被简单的描述为“一个接口,多个实现”。在C++里面具体的发挥解析为经由过程基类指针接见派生类的函数和办法。看下面这段简单的代码:
1 class A
2 {
3 public:
4 void print(){cout << this is A << endl;}
5 };
6
7 class B
8 {
9 public:
10 void print(){cout << this is B << endl;}
11 };
12
13 int main()
14 {
15 A a;
16 B b;
17 a.print();
18 b.print();
19 }输出的成果分别是This is A和This is B。但这是否真正做到了多态呢?没有!多态的关键点是用指向基类的指针来调用派生类对象。
1 int main()
2 {
3 A a;
4 B b;
5 A p1 = &a;
6 B p2 = &b;
7 p1->print();
8 p2->print();
9 }输出的成果是两个This is A。为什么呢?p2明明指向的是class B的对象但却调用class A的print()函数,这不是我们所期望的成果,那么怎么解决这个题目呢?此时就须要虚函数:
1 class A
2 {
3 public:
4 virtual void print(){cout << This is A << endl;}
5 };
6
7 class B : public A
8 {
9 public:
10 void print(){cout << This is B << endl;}
11 };此刻,class A的成员函数print()已经成了虚函数,那么class B的print()成了虚函数了么?是的!我们只须要把基类的成员函数设为virtual,其派生类的响应的函数也会主动变为虚函数。所以,class B的print()也成了虚函数。那么对于在派生类的响应函数前是否要用virtual关键字润饰,这个是小我的习惯题目了。
从头运行之前的代码,输出的成果就是This is A和This is B。
简单总结就是:基类的指针在操纵派生类对象时,会按照不合的具体对象类型,调用相对应的函数(虚函数).
2、联编
在具体申明虚函数多态是怎么实现之前,我们先懂得下联编的概念——就是将模块或者函数归并在一路生成可履行代码的处理惩罚过程,同时对每个模块或者函数调用分派内存地址,并且对外部接见也分派正确的内存地址。遵守联编所进行的阶段不合,可分为静态和动态两种。
静态联编:在编译阶段就将函数实现和函数调用接洽关系起来称之为静态联编,静态联编在编译阶段就必须懂得所有的函数或模块履行所须要的检测信息,它对函数的选择是基于指向对象的指针(或者引用)的类型,
动态联编:在法度履行的时辰才进行这种接洽关系称之为动态联编,动态联编对成员函数的选择不是基于指针或者引用,而是基于对象类型,不合的对象类型将做出不合的编译成果。C说话中,所有的联编都是静态联编。C++中一般景象下联编也是静态联编,然则一旦涉及到多态性和虚函数就必须应用动态联编。
3、揭秘动态联编
编译器到底做了什么实现虚函数的动态联编呢?事实上编译器对每个包含虚函数的类创建了一个表(vtable),我们称之为虚表。在vtable中,编译器放置特定类的虚函数地址,在每个带有虚函数的类中,编译器诡秘地置一指针,称为vpointer(常缩写为vptr),指向这个对象的vtable。经由过程基类指针做虚函数调用是(即多态调用),编译器静态地插入取得这个vptr,并在vtable表中查找函数地址的代码,如许就能调用正确的函数使动态联编产生。为每个类设置vtable、初始化vptr、为虚函数调用插入代码,所有这些都是主动产生的,多以我们不必愁闷这些。哄骗虚函数,这个对象合适的函数就能被调用,哪怕在编译器还不知道这个对象的特定类型的景象下。(《Thinking in C++》)
在任何类中不存在显示的类型信息,可对象中必须存放类信息,不然类型不成能在运行时建树。那这个类信息时什么呢?我们来看下面几个类:
1 class A
2 {
3 public:
4 void fun1() const{}
5 int fun2() const{return a;}
6 private:
7 int a;
8 };
9
10 class B
11 {
12 public:
13 virtual void fun1() const{}
14 int fun2() const{return a;}
15 private:
16 int a;
17 };
18
19 class C
20 {
21 public:
22 virtual void fun1() const{}
23 virtual int fun2() const {return a;}
24 private:
25 int a;
26 };以上三个类中:
A类没有虚函数,sizeof(A) = 4,类A的长度就是其成员变量整型a的长度;
B类有一个虚函数,sizeof(B) = 8;
C类有两个虚函数,sizeof(C) = 8;有一个虚函数和有两个虚函数的类的长度没有差别,其实它们的长度就是A的长度加一个void指针的长度,它反应出,若是有一个或多个虚函数,编译器在这个布局中插入一个指针(vptr)。在B和C之间没有差别。这是因为vptr指向一个存放地址的表,只须要一个指针,因为所有虚函数地址都包含在这个表中。
这个vptr就可以看作类的类型信息。
那我们来看看编译器是怎么建树vptr指向的这个虚函数表的。先看下面两个类:
1 class Base
2 {
3 pubic:
4 void bfun(){}
5 virtual void vfun1(){}
6 virtual int vfun2(){}
7 private:
8 int a;
9 };
10
11 class Derived : public Base
12 {
13 public:
14 void dfun(){}
15 virtual void vfun1(){}
16 virtual int vfun3(){}
17 private:
18 int b;
19 };两个类vptr指向的虚函数表(vtable)分别如下:
Base类 Derived类
vptr——>| &Base::vfun1 | vptr——>| &Derived::vfun1 |
| &Base::vfun2 | | &Base::vfun2 |
| &Base::vfun3 |
每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就为这个类创建一个vtable(如上所示),在这个表中,编译器放置了在这个类中或在它的基类中所有已声明为virtual的函数的地址。若是在这个派生类中没有对在基类中声明为virtual的函数进行从头定义,编译器就应用基类的这个虚函数地址(在Derived的vtable中,vfun2的进口就是这种景象。)然后编译器在这个类中放置vptr。当应用简单持续时,对于每个对象只有一个vptr。vptr必须被初始化为指向响应的vtable,这在机关函数中产生。
一旦vptr被初始化为指向响应的vtable,对象就“知道”它本身是什么类型。但只有当虚函数被调用时这种自我认知才有效。
VPTR经常位于对象的开首,编译器能很轻易地取到VPTR的值,从而断定VTABLE的地位。VPTR总指向VTABLE的开端地址,所有基类和它的子类的虚函数地址(子类本身定义的虚函数除外)在VTABLE中存储的地位老是雷同的,如上方Base类和Derived类的vtable中 vfun1和vfun2的地址老是按雷同的次序存储。编译器知道vfun1位于vptr处,vfun2位于vptr+1处,是以在用基类指针调用虚函数时,编译器起首获取指针指向对象的类型信息(vptr),然后就去调用虚函数。如一个Base类指针pBase指向了一个Derived对象,那 pBase->vfun2()被编译器翻译为 vptr+1 的调用,因为虚函数vfun2的地址在vtable中位于索引为1的地位上。同理,pBase->vfun3()被编译器翻译为vptr+2的调用。这就是所谓的晚绑定。
我们来看一下虚函数调用的汇编代码,以加深懂得。1 void test(Base pBase)
2 {
3 pBase->vfun2();
4 };
5
6 int main(int argc, char argv[])
7 {
8 Derived td;
9 test(&td);
10 return 0;
11 }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]。
所有随风而逝的都属于昨天的,所有历经风雨留下来的才是面向未来的。—— 玛格丽特·米切尔 《飘》