原帖地址:http://hi.baidu.com/daping_zhang/blog/item/e87163d06c42818fa0ec9cfc.html
多态(Polymorphism)是面向对象的核心概念,本文以C++为例,讨论多态的具体实现。C++中多态可以分为基于继承和虚函数的动态多态以及基于模板的静态多态,如果没有特别指明,本文中出现的多态都是指前者,也就是基于继承和虚函数的动态多态。至于什么是多态,在面向对象中如何使用多态,使用多态的好处等等问题,如果大家感兴趣的话,可以找本面向对象的书来看看。
为了方便说明,下面举一个简单的使用多态的例子(From [1] ):
例子中使用到多态的代码以黑体标出了,它们一个很明显的特征就是通过一个基类的指针(或者引用)来调用不同子类的方法。
那么,现在的问题是,这个功能是怎样实现的呢?我们可以先来大概猜测一下:对于一般的方法调用,到了汇编代码这一层次的时候,一般都是使用 Call funcaddr 这样的指令进行调用,其中funcaddr是要调用函数的地址。按理来说,当我使用指针pShape来调用Draw的时候,编译器应该将Shape::Draw的地址赋给funcaddr,然后Call 指令就可以直接调用Shape::Draw了,这就跟用pShape来调用Shape::Erase一样。但是,运行结果却告诉我们,编译器赋给funcaddr的值却是Circle::Drawde的值。这就说明,编译器在对待Draw方法和Erase方法时使用了双重标准。那么究竟是谁有这么大的法力,使编译器这个铁面无私的判官都要另眼相看呢?virtual!!
Clever!!正是virtual这个关键字一手导演了这一出“乾坤大挪移”的好戏。说道这里,我们先要明确两个概念:静态绑定和动态绑定。
1、静态绑定(static bingding),也叫早期绑定,简单来说就是编译器在编译期间就明确知道所要调用的方法,并将该方法的地址赋给了Call指令的funcaddr。因此,运行期间直接使用Call指令就可调用到相应的方法。
2、动态绑定(dynamic binding),也叫晚期绑定,与静态绑定不同,在编译期间,编译器并不能明确知道究竟要调用的是哪一个方法,而这,要知道运行期间使用的具体是哪个对象才能决定。
好了,有了这两个概念以后,我们就可以说,virtual的作用就是告诉编译器:我要进行动态绑定!编译器当然会尊重你的意见,而且为了完成你这个要求,编译器还要做很多的事情:编译器自动在声明了virtual方法的类中插入一个指针vptr和一个数据结构VTable(vptr用以指向VTable;VTable是一个指针数组,里面存放着函数的地址),并保证二者遵守下面的规则:
1、VTable中只能存放声明为virtual的方法,其它方法不能存放在里面。在上面的例子中,Shape的VTable中就只有Draw,MoveTo和~Shape。方法Erase的地址并不能存放在VTable中。此外,如果方法是纯虚函数,如 Draw,那么同样要在VTable中保留相应的位置,但是由于纯虚函数没有函数体,因此该位置中并不存放Draw的地址,而是可以选择存放一个出错处理的函数的地址,当该位置被意外调用时,可以用出错函数进行相应的处理。
2、派生类的VTalbe中记录的从基类中继承下来的虚函数地址的索引号必须跟该虚函数在基类VTable中的索引号保持一致。如在上例中,如果在Shape的VTalbe中,Draw为 1 号, MoveTo 2 号,~Shape为 3 号,那么,不管这些方法在Circle中是按照什么顺序定义的,Circle的VTable中都必须保证Draw为 1 号,MoveTo为 2号。至于 3号,这里是~Circle。为什么不是~Shape啊?嘿嘿,忘啦,析构函数不会继承的。
3、vptr是由编译器自动插入生成的,因此编译器必须负责为其进行初始化。初始化的时间选在对象创建时,而地点就在构造函数中。因此,编译器必须保证每个类至少有一个构造函数,若没有,自动为其生成一个默认构造函数。
4、vptr通常放在对象的起始处,也就是Addr(obj) == Addr(obj.vptr)。
你看,天下果然没有免费的午餐,为了实现动态绑定,编译器要为我们默默干了这么多的脏话累活。如果你想体验一下编译器的辛劳,那么可以尝试用C语言模拟一下上面的行为,【1】中就有这么一个例子。好了,现在万事具备,只欠东风了。编译,连接,载入,GO!当程序执行到 pShape->Draw()的时候,上面的设施也开始起作用了。。
前面已经提到,晚期绑定时之所以不能确定调用哪个函数,是因为具体的对象不确定。好了,当运行到pShape->Draw()时,对象出来了,它由pShape指针标出。我们找到这个对象后,就可以找到它里面的vptr(在对象的起始处),有了vptr后,我们就找到了VTable,调用的函数就在眼前了。。等等,VTable中方法那么多,我究竟使用哪个呢?不用着急,编译器早已为我们做好了记录:编译器在创建VTable时,已经为每个virtual函数安排好了座次,并且把这个索引号记录了下来。因此,当编译器解析到pShape->Draw()的时候,它已经悄悄的将函数的名字用索引号来代替了。这时候,我们通过这个索引号就可以在VTable中得到一个函数地址,Call it!
在这里,我们就体会到为什么会有第二条规定了,通常,我们都是用基类的指针来引用派生类的对象,但是不管具体对象是哪个派生类的,我们都可以使用相同的索引号来取得对应的函数实现。
现实中有一个例子其实跟这个蛮像的:报警电话有110,119,120(VTable中不同的方法)。不同地方的人拨打不同的号码所产生的结果都是不一样的。譬如,在三环外的一个人(具体对象)跟一环内的一个人(另外一个具体对象)打119,最后调用的消防队肯定是不一样的,这就是多态了。这是怎么实现的呢,每个人都知道一个报警中心(VTable,里面有三个方法 110,119,120)。如果三环外的一个人需要火警抢险(一个具体对象)时,它就拨打119,但是他肯定不知道最后是哪一个消防队会出现的。这得有报警中心来决定,报警中心通过这个具体对象(例子中就是具体位置了)以及他说拨打的电话号码(可以理解成索引号),报警中心可以确定应该调度哪一个消防队进行抢险(不同的动作)。
这样,通过vptr和VTable的帮助,我们就实现了C++的动态绑定。当然,这仅仅是单继承时的情况,多重继承的处理要相对复杂一点,下面简要说一下最简单的多重继承的情况,至于虚继承的情况,有兴趣的朋友可以看看 Lippman的《Inside the C++ Object Model》,这里暂时就不展开了。(主要是自己还没搞清楚,况且现在多重继承都不怎么使用了,虚继承应用的机会就更少了)
首先,我要先说一下多重继承下对象的内存布局,也就是说该对象是如何存放本身的数据的。
在上面这个例子中,一个Dog对象在内存中的布局如下所示:
Dog |
Vptr1 |
Cute::i |
Vptr2 |
Pet::j |
Dog::z |
delete d;
}
好了,既然编译器帮我们自动完成了不同父类的地址转换,我们调用虚函数的过程也就跟单继承统一起来了:通过具体对象,找到vptr(通常指针的起始位置,因此Cute找到的是vptr1,而Pet找到的是vptr2),通过vptr,我们找到VTable,然后根据编译时得到的VTable索引号,我们取得相应的函数地址,接着就可以马上调用了。
在这里,顺便也提一下两个特殊的方法在多态中的特别之处吧:第一个是构造函数,在构造函数中调用虚函数是不会有多态行为的,例子如下:
第二个就是析构函数,使用多态的时候,我们经常使用基类的指针来引用派生类的对象,如果是动态创建的,对象使用完后,我们使用delete来释放对象。但是,如果我们不注意的话,会有意想不到的情况发生。
所以,如果一个类设计用来被继承的话,那么它的析构函数应该被声明为virtual的。
既然多态是面向对象的三大本质特征之一(其它两个是数据抽象和继承),那么C++为什么不将方法调用的默认方式设置为动态绑定,而要通过关键字virtual进行标记呢?Bruce Eckel在《Thinking in C++》中提到,这是由于历史原因造成的,C++是从C发展而来的,而C程序员最为关心的是性能问题,由于动态绑定比静态绑定多几条指令,性能有所下降,如果将动态绑定设定为默认方法调用方式,那么很多C程序员可能不会接受,因此,C++就将动态绑定定位成可选的,并且作出保证:If you don't use it, you don't pay for it(Stroustrup)。