声明:
以下文章翻译自 Ian Joyner 所著的 C++?? A Critique of C++ and Programming and Language Trends of the 1990s 3/E【Ian Joyner 1996】原著版权属于 Ian Joyner, 征得 Ian Joyner 本人的同意,cber得以将该文翻译成中文。因此,本文的中文版权应该属于cber;-)
前言: 【译者所写的】
要想彻底的掌握一种语言,不但需要知道它的长处有哪些,而且需要知道它的不足之处又有哪些。这样我们才能用好这门语言,也才能说我们自己掌握了这门语言。
在所有对C++的批评中,虚拟函数这一部分是最复杂的。这主要是由于C++中复杂的机制所引起的。虽然本篇文章认为多态(polymorphism)是实现面向对象编程(OOP)的关键特性,但还是请你不要对此观点(即虚拟函数机制是C++中的一大败笔)感到有什么不安,继续看下去,如果你仅仅想知道一个大概的话,那么你也可以跳过此节。 【译者注:建议大家还是看看这节会比较好】
在 C++中, 当子类改写/重定义 (override/redefine) 了在父类中定义了的函数时, 关键字virtual使得该函数具有了多态性,但是 virtual关键字也并不是必不可少的(只要在父类中被定义一次就行了) 。编译器通过产生动态分配(dynamic dispatch)的方式来实现真正的多态函数调用。
这样,在 C++中,问题就产生了:如果设计父类的人员不能预见到子类可能会改写哪个函数,那么子类就不能使得这个函数具有多态性。这对于C++来说是一个很严重的缺陷,因为它减少了软件组件(software components)的弹性(flexibility),从而使得写出可重用及可扩展的函数库也变得困难起来。
C++同时也允许函数的重载(overload) ,在这种情况下,编译器通过传入的参数来进行正确的函数调用。在函数调用时所引用的实参类型必须吻合被重载的函数组(overloaded functions)中某一个函数的形参类型。重载函数与重写函数(具有多态性的函数)的不同之处在于:重载函数的调用是在编译期间就被决定了,而重写函数的调用则是在运行期间被决定的。
当一个父类被设计出来时,程序员只能猜测子类可能会重载/重写哪个函数。子类可以随时重载任何一个函数,但这种机制并不是多态。为了实现多态,设计父类的程序员必须指定一个函数为 virtual,这样会告诉编译器在类的跳转表(class jump table) 【译者窃以为是 vtable,即虚拟函数入口表】中建立一个分发入口。于是,对于决定什么事情是由编译器自动完成,或是由其他语言的编译器自动完成这个重任就放到了程序员的肩上。这些都是从最初的 C++的实现中继承下来的,而和一些特定的编译器及联结器无关。
对于重写,我们有着三种不同的选择,分别对应于: “千万别” , “可以”及“一定要”重写:
1、重写一个函数是被禁止的。子类必须使用已有的函数;
2、函数可以被重写。子类可以使用已有的函数,也可以使用自己写的函数,前提是这个函数必须遵循最初的界面定义,而且实现的功能尽可能的少及完善;
3、函数是一个抽象的函数。对于该函数没有提供任何的实现,每个子类都必须提供其各自的实现。
父类的设计者必须要决定1 和 3中的函数,而子类的设计者只需要考虑 2就行了。对于这些选择,程序语言必须要提供直接的语法支持。
选项 1
C++并不能禁止在子类中重写一个函数。 即使是被声明为private virtual的函数也可以被重写。【Sakkinen92】中指出了即使在通过其他方法都不能访问到 private virtual函数,子类也可以对其进行重写。 【译者注:Sakkinen92 我也没看过,但经我简单的测试,确实可以在子类中重写父类中的private virtual函数】 实现这种选择的唯一方法就是不要使用虚拟函数,但是这样的话,函数就等于整个被替换掉了。首先,函数可能会在无意中被子类的函数给替换掉。在同一个 scope 中重新宣告一个函数将会导致名字冲突(name clash) ;编译器将会就此报告出一个“duplicate declaration”的语法错误。允许两个拥有同名的实体存在于同一个 scope中将会导致语义的二义性(ambiguity)及其他问题(可参见于name overloading这节) 。
下面的例子阐明了第二个问题:
class A
{
public:
void nonvirt();
virtual void virt();
};
class B : publicA
{ public:
void nonvirt();
void virt();
};
A a;
B b;
A *ap = &b;
B *bp = &b;
bp->nonvirt();
//calls B::nonvirt as you would expect
ap->nonvirt();
//calls A::nonvirt even though this object is of type B
ap->virt();
//calls B::virt, the correct version of the routine for B objects
在这个例子里, B 扩展或替换掉了 A中的函数。 B::nonvirt 是应该被 B的对象调用的函数。在此处我们必须指出,C++给客户端程序员(即使用我们这套继承体系架构的程序员)足够的弹性来调用 A::nonvirt 或是 B::nonvirt,但我们也可以提供一种更简单,更直接的方式:提供给A::nonvirt和B::nonvirt 不同的名字。这可以使得程序员能够正确地,显式地调用想要调用的函数,而不是陷入了上面的那种晦涩的,容易导致错误的陷阱中去。具体方法如下:
class B: public A
{
public:
void b_nonvirt();
void virt();
}
B b;
B *bp = &b;
bp->nonvirt();
//calls A::nonvirt
bp->b_nonvirt();
//calls B::b_nonvirt
现在,B的设计者就可以直接的操纵 B的接口了。程序要求B 的客户端(即调用B 的代码)能够同时调用A::nonvirt和B::nonvirt, 这点我们也做到了。 就Object-Oriented Design(OOD)来说,这是一个不错的做法,因为它提供了健壮的接口定义(strongly defined interface) 【译者认为:即不会引起调用歧义的接口】 。C++允许客户端程序员在类的接口处卖弄他们的技巧,借以对类进行扩展。在上例中所出现的就是设计 B 的程序员不能阻止其他程序员调用 A::nonvirt。类 B 的对象拥有它们自己的 nonvirt,但是即便如此,B 的设计者也不能保证通过 B 的接口就一定能调用到正确版本的 nonvirt。
C++同样不能阻止系统中对其他处的改动不会影响到 B。假设我们需要写一个类 C,在 C 中我们要求 nonvirt 是一个虚拟的函数。于是我们就必须回到 A 中将 nonvirt 改为虚拟的。但这又将使得我们对于 B::nonvirt所玩弄的技巧又失去了作用(想想看,为什么:D) 。对于 C需要一个 virtual 的需求(将已有的 nonvirtual 改为 virtual)使得我们改变了父类,这又使得所有从父类继承下来的子类也相应地有了改变。这已经违背了OOP 拥有低耦合的类的理由,新的需求,改动应该只产生局部的影响,而不是改变系统中其他地方,从而潜在地破坏了系统的已有部分。
另一个问题是,同样的一条语句必须一直保持着同样的语义。例如:对于诸如 a->f()这样的多态性语句的解释,系统调用的是由最符合 a所真正指向类型的那个 f(),而不管对象的类型到底是 A,还是A 的子类。然而,对于C++的程序员来说,他们必须要清楚地了解当f()被定义成virtual或是non-virtual 时,a->f()的真正涵义。所以,语句a->f()不能独立于其实现,而且隐藏的实现原理也不是一成不变的。对于f()的宣告的一次改变将会相应地改变调用它时的语义。与实现独立意味着对于实现的改变不会改变语句的语义,或是执行的语义。
如果在宣告中的改变导致相应的语义的改变,编译器应该能检测到错误的产生。程序员应该在宣告被改变的情况下保持语义的不变。这反映了软件开发中的动态特性,在其中你将能发现程序文本的永久改变。
其他另一个与a->f()相应的, 语义不能被保持不变的例子是: 构造函数 (可参考于C++ ARM, section 10.9c, p 232) 。而Eiffel和 Java则不存在这样的问题。它们中所采用的机制简单而又清晰,不会导致C++中所产生的那些令人吃惊的现象。在 Java中,所有的一起都是虚拟的,为了让一个方法【译者注:对应于 C++的函数】不能被重写,我们可以用 final修饰符来修饰这个方法。
Eiffel允许程序员指定一个函数为 frozen,在这种情况下,这个函数就不能在子类中被重写。
选项 2
是使用现有的函数还是重写一个,这应该是由撰写子类的程序员所决定的。在 C++中,要想拥有这种能力则必须在父类中指定为 virtual。对于 OOD 来说,你所决定不想作的与你所决定想作的同样重要,你的决定应该是越迟下越好。这种策略可以避免错误在系统前期就被包含进去。你作决定越早,你就越有可能被以后所证明是错误的假设所包围;或是你所作的假设在一种情况下是正确的,然而在另一种情况下却会出错,从而使得你所写出来的软件比较脆弱,不具有重用性(reusable) 【译者注:软件的可重用性对于软件来说是一个很重要的特性,具体可以参考《Object-Oriented Software Construct》中对于软件的外部特性的叙述,P7, Reusability, Charpter 1.2 A REVIEW OF EXTERNAL FACTORS】 。
C++要求我们在父类中就要指定可能的多态性(这可以通过 virtual 来指定) ,当然我们也可以在继承链中的中间的类导入 virtual机制,从而预先判断某个函数是否可以在子类中被重定义。这种做法将导致问题的出现:如那些并非真正多态的函数(not actually polymorphic)也必须通过效率较低的 table 技术来被调用,而不像直接调用那个函数来的高效【译者注:在文章的上下文中并没有出现not actually polymorphic 特性的确切定义, 根据我的理解, 应该是声明为polymorphic,而实际上的动作并没能体现polymorphic 这样的一种特性】 。虽然这样做并不会引起大量的花费(overhead) ,但我们知道,在OO 程序中经常会出现使用大量的、短小的、目标单一明确的函数,
如果将所有这些都累计下来,也会导致一个相当可观的花费。C++中的政策是这样的:需要被重定义的函数必须被声明为 virtual。糟糕的是,C++同时也说了,non-virtual 函数不能被重定义,这使得设计使用子类的程序员就无法对于这 些函数拥有自己的控制权。 【译者注:原作中此句显得有待推敲,原文是这样写的:it says that non-virtual routines cannot be redefined, 我猜测作者想表达的意思应该是:If you have defined a non-virtual routine in base, then it cannot be virtual in the base whether you redefined it as virtual in descendant.】
Rumbaugh 等人对于 C++中的虚拟机制的批评如下:C++拥有了简单实现继承及动态方法调用的特性, 但一个 C++的数据结构并不能自动成为面向对象的。 方法调用决议 (method resolution)以及在子类中重写一个函数操作的前提必须是这个函数/方法已经在父类中被声明为 virtual。 也就是说,必须在最初的类中我们就能预见到一个函数是否需要被重写。不幸的是,类的撰写者可能不会预期到需要定义一个特殊的子类,也可能不会知道那些操作将要在子类中被重写。这意味着当子类被定义时,我们经常需要回过头去修改我们的父类,并且使得对于通过创建子类来重用已有的库的限制极为严格,尤其是当这个库的源代码不能被获得是更是如此。 (当然,你也可以将所有的操作都定义为 virtual,并愿意为此付出一些小小的内存花费用于函数调用) 【RBPEL91】
然而,让程序员来处理 virtual是一个错误的机制。编译器应该能够检测到多态,并为此产生所必须的、潜在的实现 virtual的代码。让程序员来决定 virtual与否对于程序员来说是增加了一个簿记工作的负担。这也就是为什么 C++只能算是一种弱的面向对象语言(weak object-oriented language) :因为程序员必须时刻注意着一些底层的细节(low level details) ,而这些本来可以由编译器自动处理的。
在 C++中的另一个问题是错误的重写(mistaken overriding) ,父类中的函数可以在毫不知情的情况下被重写。编译器应该对于同一个名字空间中的重定义报错,除非编写子类的程序员指出他是有意这么做的(即对于虚函数的重写) 。我们可以使用同一个名字,但是程序员必须清楚自己在干什么,并且显式地声明它,尤其是在将自己的程序与已经存在的程序组件组装成新的系统的情况下更要如此。除非程序员显式地重写已有的虚函数,否则编译器必须要给我们报告出现了名字被声明多处(duplicate declaration)的错误。然而,C++却采用了 Simula最初的做法,而这种方法到现在已经得到了改良。其他的一些程序语言通过采用了更好的、更加显式的方法,避免了错误重定义的出现。
解决方法就是 virtual不应该在父类中就被指定好。当我们需要运行时的动态绑定时,我们就在子类中指定需要对某个函数进行重写。这样做的好处在于:对于具有多态性的函数,编译器可以检测其函数签名(function signature)的一致性;而对于重载的函数,其函数签名在某些方面本来就不一样。第二个好处表现在,在程序的维护阶段,能够清楚地表达程序的最初意愿。而实际上后来的程序员却经常要猜测先前的程序员是不是犯了什么错误,选择一个相同的名字,还是他本来就想重载这个函数。
在Java中,没有virtual这个关键字,所有的方法在底层都是多态的。当方法被定义为 static, private 或是 final 时,Java 直接调用它们而不是通过动态的查表的方式。这意味着在需要被动态调用时,它们却是非多态性的函数,Java的这种动态特性使得编译器难以进行进一步的优化。Eiffel 和 Object Pascal 迎合了这个选项。在它们中,编写子类的程序员必须指定他们所想进行的重定义动作。我们可以从这种做法中得到巨大的好处:对于以后将要阅读这些程序的人及程序的将来维护者来说,可以很容易地找出来被重写的函数。因而选项 2最好是在子类中被实现。
Eiffel 和 Object Pascal 都优化了函数调用的方式:因为他们只需要产生那些真正多态的函数的调用分配表的入口项。对于怎样做,我们将会在global analysis这节中讨论。
选项 3
纯虚函数这样的做法迎合了让一个函数成为抽象的,从而子类在实例化时必须为其提供一个实现这样的一个条件。没有重写这些函数的任何子类同样也是抽象类。这个概念没有错,但是请你看一看pure virtual functions这一节,我们将在那节中对于这种术语及语法进行批判讨论。
Java也拥有纯虚方法(同样Eiffel也有),实现方法是为该方法加上 deffered标注。
结论:
virtual的主要问题在于,它强迫编写父类的程序员必须要猜测函数在子类中是否有多态性。如果这个需求没有被预见到,或是为了优化、避免动态调用而没有被包含进去的话,那么导致的可能性就是极大的封闭,胜过了开放。在C++的实现中,virtual提高了重写的耦合性,导致了一种容易产生错误的联合。
Virtual是一种难以掌握的语法,相关的诸如多态、动态绑定、重定义以及重写等概念由于面向于问题域本身,掌握起来就相对容易多了。虚拟函数的这种实现机制要求编译器为其在 class中建立起 virtual table入口,而 global analysis并不是由编译器完成的,所以一切的重担都压在了程序员的肩上了。多态是目的,虚拟机制就是手段。Smalltalk, Objective-C, Java 和 Eiffel 都是使用其他的一种不同的方法来实现多态的。
Virtual 是一个例子,展示了 C++在 OOP 的概念上的混沌不清。程序员必须了解一些底层的概念,甚至要超过了解那些高层次的面向对象的概念。Virtual把优化留给了程序员;其他的方法则是由编译器来优化函数的动态调用,这样做可以将那些不需要被动态调用的分配(即不需要在动态调用表中存在入口)100%地消除掉。对于底层机制,感兴趣的应该是那些理论家及编译器实现者,一般的从业者则没有必要去理解它们,或是通过使用它们来搞清楚高层的概念。在实践中不得不使用它们是一件单调乏味的事情,并且还容易导致出错,这阻止了软件在底层技术及运行机制下(参见并发程序)的更好适应,降低了软件的弹性及可重用性。