C++批评系列


 
C++批评系列(C++?? A Critique of C++ and Programming and Language Trends of the 1990s)


要想彻底的掌握一种语言,不但需要知道它的长处有哪些,而且需要知道它的不足之处又有哪些。这样我们才能用好这门语言,避免踏入语言中的一些陷阱,更好地利用这门语言来为我们的工作所服务。Ian Joyner的这篇文章以及他所著的Objects Inencapsulated一书中,向我们充分的展示了C++的一些不足之处,我们应该充分借鉴于他已经完成的伟大工作,更好的了解C++,从而写出更加安全的C++代码来。

虚拟函数
全局分析
保证类型安全的连接属性
函数重载
继承的本质
多继承


C++批评系列:虚拟函数
Ian Joyner
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 : public A
{
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%地消除掉。对于底层机制,感兴趣的应该是那些理论家及编译器实现者,一般的从业者则没有必要去理解它们,或是通过使用它们来搞清楚高层的概念。在实践中不得不使用它们是一件单调乏味的事情,并且还容易导致出错,这阻止了软件在底层技术及运行机制下(参见并发程序)的更好适应,降低了软件的弹性及可重用性。




C++批评系列:全局分析
Ian Joyner
cber译

【P&S 94】中提到对于类型安全的检测来说有两种假设。一种是封闭式环境下的假设,此时程序中的各个部分在编译期间就能被确定,然后我们可以对于整个程序来进行类型检测。另一种是开放式环境下的假设,此时对于类型的检测是在单独的模块中进行的。对于实际开发和建立原型来说,第二种假设显得十分有效。然而,【P&S 94】中又提到,“当一种已经完成的软件产品到达了成熟期时,采用封闭式环境下的假设就可以被考虑了,因为这样可以使得一些比较高级的编译技术得以有了用武之处。只有在整个程序都被了解的情况下,我们才可能在其上面执行诸如全局寄存器分配、程序流程分析及无效代码检测等动作。”(附:【P&S 94】Jens Palsberg and Michael I. Schwartzbach, Object-Oriented Type Systems, Wiley 1994)

C++中的一个主要问题就是:对于程序的分析过程被编译器(工作于开放式环境下的假设)和链接器(依赖于十分有限的封闭式环境下的分析)给划分开了。封闭式环境下的或是全局的分析被采用的实质原因有两个方面:首先,它可以保证汇编系统的一致性;其次,它通过提供自动优化,减轻了程序员的负担。

程序员能够被减轻的主要负担是:设计父类的程序员不再需要(不得不)通过利用虚拟函数的修饰成份(virtual),来协助编译器建立起 vtable。正如我们在“虚拟函数”中所说,这样做将会影响到软件的弹性。Vtable不应该在一个单独的类被编译时就被建立起来,最好是在整个系统被装配在一起时一并被建立。在系统被装配(链接)时期,编译器和链接器协同起来,就可以完全决定一个函数是否需要在vtable中占有一席之地。除上述之外,程序员还可以自由地使用在其他模块中定义的一些在本地不可见的信息;并且程序员不再需要维护头文件的存在了。

在Eiffel和Object Pascal中,全局分析被应用于整个系统中,决定真正的多态性的函数调用,并且构造所需的vtable。在Eiffel中,这些是由编译器完成的。在 Object Pascal中,Apple扩展了链接器的功能,使之具有全局分析的能力。这样的全局分析在C/Unix环境下很难被实现,所以在C++中,它也没有被包含进去,使得负担被留给了程序员。

为了将这个负担从程序员身上移除,我们应该将全局分析的功能内置于链接器中。然而,由于C++一开始的版本是作为一个Cfront预处理器实现的,对于链接器所做的任何必要的改动不能得到保证。C++的最初实现版本看起来就像一个拼凑起来的东西,到处充满着漏洞。【译者认为:这也太过分了吧:)】C++的设计严格地受限于其实现技术,而不是其他(例如没有采用好的程序语言设计原理等),因为那样就需要新的编译器和链接器了。也就是说,现在的C++发展严格地受限于其最初的试验性质的产品。

我现在确信这种技术上的依赖关系(即C++依赖于早先的C)严重地损害了C++,使之不是一个完整意义上的面向对象的高级语言。一个高级语言可以将簿记工作从程序员身上接手过去,交给编译器去完成,这也是高级语言的主要目的。缺乏全局(或是封闭式环境下的)分析是C++的一个主要不足,这使得C++在和Eiffel之类的语言相比时显得十分地不足。由于Eiffel坚持系统层次上的有效性及全局分析,这意味着Eiffel要比C++显得有雄心多了,但这也是Eiffel产品为什么出现地这么缓慢的主要原因。

Java只有在需要时才动态地载入软件的部分,并将它们链接起来成为一个可以运行的系统。也因而使得静态的编译期间的全局分析变成不可能的了(因为Java被设计成为一个动态的语言)。然而,Java假设所有的方法都是virtual的,这也就是为什么Java和Eiffel是完全不同的工具的一个原因。关于Eiffel,可以参见于Dynamic Linking in Eiffel(DLE)。




C++批评系列:保证类型安全的连接属性
Ian Joyner
cber译

C++ARM中解释说type-safe linkage并不能100%的保证类型安全。既然它不那100%的保证类型安全,那么它就肯定是不安全的。统计分析显示:即便在很苛刻的情况下,C++ 出现单独的O-ring错误的可能性也只有0.3%。但我们一旦将6种这样的可能导致出错的情况联合起来放在一起,出错的几率就变得大为可观了。在软件中,我们经常能够看到一些错误的起因就是其怪异的联合。OO的一个主要目的就是要减少这种奇怪的联合出现。

大多数问题的起因都是一些难以察觉的错误,而不是那些简单明了的错误导致问题的产生。而且在通常的情况下,不到真正的临界时期,这样的错误一般都很难被检测到,但我们不能由此就低估了这种情况的严肃性。有许多的计划都依赖于其操作的正确性,如太空计划、财政结算等。在这些计划中采用不安全的解决方案是一种不负责任的做法,我们应该严厉禁止类似情况的出现。

C++在type-safe linkage上相对于C来说有了巨大的进步。在C中,链接器可以将一个带有参数的诸如f(p1,...)这样的函数链接到任意的函数f()上面,而这个 f()甚至可以没有参数或是带有不同的参数都行。这将会导致程序在运行时出错。由于C++的type-safe linkage机制是一种在链接器上实做的技巧,对于这样的不一致性,C++将统统拒绝。

C++ARM将这样的情况概括如下──“处理所有的不一致性->这将使得C++得以100%的保证类型安全->这将要求对链接器的支持或是机制(环境)能够允许编译器访问在其他编译单元里面的信息”。

那么为什么市面上的C++编译器(至少AT&T的是如此)不提供访问其他编译单元中的信息的能力呢?为什么到现在也没有一种特殊的专门为C++设计的链接器出现,可以100%的保证类型安全呢?答案是C++缺乏一种全局分析的能力(在上一节中我们讨论过)。另外,在已有的程序组件外构造我们的系统已经是一种通用的Unix软件开发方式,这实现了一定的重用,然而它并不能为面向对象方式的重用提供真正的弹性及一致性。

在将来,Unix可能会被面向对象的操作系统给替代,这样的操作系统足够的“开放”并且能够被合适地裁剪用以符合我们的需求。通过使用管道(pipe)及标志(flag),Unix下的软件组件可以被重复利用以提供所需的近似功能。这种方法在一定的情况下行之有效,并且颇负效率(如小型的内部应用,或是用以进行快速原型研究),但对于大规模、昂贵的、或是对于安全性要求很高的应用来说,采取这样的开发方法就不再适合了。在过去的十年中,集成的软件(即不采用外部组件开发的软件)的优点已经得到了认同。传统的Unix系统不能提供这样的优点。相比而言,集成的系统更加的复杂,对于开发它们的开发人员有着更多的要求,但是最终用户(end user)要求的就是这样的软件。将所有的东西拙劣的放置于一起构成的系统是不可接受的。现在,软件开发的重心已经转到组件式软件开发上面来了,如公共领域的OpenDoc或是Microsoft的OLE。

对于链接来说,更进一步的问题出现在:不同的编译单元和链接系统可能会使用不同的名字编码方式。这个问题和type-safe linkage有关,不过我们将会在“重用性及兼容性”这节讲述之。

Java使用了一种不同的动态链接机制,这种机制被设计的很好,没有使用到Unix的链接器。Eiffel则不依赖于Unix或是其他平台上的链接器来检测这些问题,一切都由编译器完成。

Eiffel定义了一种系统层上的有效性(system-level validity)。一个Eiffel编译器也就因此需要进行封闭环境下的分析,而不是依赖于链接器上的技巧。你也可以就此认为Eiffel程序能够保证 100%的类型安全。对于Eiffel来说有一个缺点就是,编译器需要干的事情太多了。(通常我们会说的是它太“慢”了,但这不够精确)目前我们可以通过对于Eiffel提供一定的扩展来解决这个问题,如融冰技术(melting-ice technology),它可以使得我们对于系统的改动和测试可以在不需要每次都进行重新编译的情况下进行。

现在让我们来概括一下前两个小节 - 有两个原因使我们需要进行全局(或封闭环境下的)分析:一致性检测及优化。这样做可以减掉程序员身上大量的负担,而缺乏它是C++中的一个很大的不足。




C++批评系列:函数重载
Ian Joyner
cber译

C++允许在参数类型不同的前提下重载函数。重载的函数与具有多态性的函数(即虚函数)不同处在于:调用正确的被重载函数实体是在编译期间就被决定了的;而对于具有多态性的函数来说,是通过运行期间的动态绑定来调用我们想调用的那个函数实体。多态性是通过重定义(或重写)这种方式达成的。请不要被重载(overloading)和重写(overriding)所迷惑。重载是发生在两个或者是更多的函数具有相同的名字的情况下。区分它们的办法是通过检测它们的参数个数或者类型来实现的。重载与CLOS中的多重分发(multiple dispatching)不同,对于参数的多重分发是在运行期间多态完成的。

【Reade 89】中指出了重载与多态之间的不同。重载意味着在相同的上下文中使用相同的名字代替出不同的函数实体(它们之间具有完全不同的定义和参数类型)。多态则只具有一个定义体,并且所有的类型都是由一种最基本的类型派生出的子类型。C. Strachey指出,多态是一种参数化的多态,而重载则是一种特殊的多态。用以判断不同的重载函数的机制就是函数标示(function signature)。

重载在下面的例子中显得很有用:

max( int, int )
max( real, real )

这将确保相对于类型int和real的最佳的max函数实体被调用。但是,面向对象的程序设计为该函数提供了一个变量,对象本身被被当作一个隐藏的参数传递给了函数(在C++中,我们把它称为this)。由于这样,在面向对象的概念中又隐式地包含了一种对等的但却更有更多限制的形式。对于上述讨论的一个简单例子如下:

int i, j;
real r, s;
i.max(j);
r.max(s);

但如果我们这样写:i.max(r),或是r.max(j),编译器将会告诉我们在这其中存在着类型不匹配的错误。当然,通过重载运算符的操作,这样的行为是可以被更好地表达如下:

i max j 或者
r max s

il:COMPARABLE_LIST[INTEGER]
rl:COMPARABLE_LIST[REAL]

i := il.max
r := rl.max

上面的例子显示,面向对象的编程典范(paradigm),特别是和泛型化(genericity)结合在一起时,也可以达到函数重载的效果而不需要C+ +中的函数重载那样的声明形式。然而是C++使得这种概念更加一般化。C++这样作的好处在于,我们可以通过不止一个的参数来达到重载的目的,而不是仅使用一个隐藏的当前对象作为参数这样的形式。

另外一个我们需要考虑的因素是,决定(resolved)哪个重载函数被调用是在编译阶段完成的事情,但对于重写来说则推后到了运行期间。这样看起来好像重载能够使我们获得更多性能上的好处。然而,在全局分析的过程中编译器可以检测函数min和max是否处在继承的最末端,然后就可以直接的调用它们(如果是的话)。这也就是说,编译器检查到了对象i和r,然后分析对应于它们的max函数,发现在这种情况下没有任何多态性被包含在内,于是就为上面的语句产生了直接调用max的目标代码。与此相反的是,如果对象n被定义为一个NUMBER,NUMBER又提供一个抽象的max函数声明(我们所用的 REAL.max和INTERGER.max都是从它继承来的),那么编译器将会为此产生动态绑定的代码。这是因为n既可能是INTEGER,也有可能是 REAL。

现在你是不是觉得C++的这种方法(即通过提供不同的参数来实现函数的重载)很有用?不过你还必须明白,面向对象的程序设计对此有着种种的限制,存在着许多的规则。C++是通过指定参数必须与基类相符合的方式实现它的。传入函数中的参数只能是基类,或是基类的派生类。例如:

A.f( B someB ) {...}
class B ...;
class D : public B ...;
A a;
D d;
a.f( d );

其中d必须与类'B'相符,编译器会检测这些。

通过不同的函数签名(signature)来实现函数重载的另一种可行的方法是,给不同的函数以不同的名字,以此来使得它们的签名不同。我们应该使用名字来作为区分不同实体(entities)的基础。编译器可以交叉检测我们提供的实参是否符合于指定的函数需要的形参。这同时也导致了软件更好的自记录(self-document)。从相似的名字选择出一个给指定的实体通常都不会很容易,但它的好处确实值得我们这样去做。

[Wiener95]中提供了一个例子用以展示重载虚拟函数可能出现的问题:

class Parent
{
public:
virutal int doIt( int v )
{
return v * v;
}
};

class Child: public Parent
{
public:
int doIt( int v, int av = 20 )
{
return v * av;
}
};

int main()
{
int i;
Parent *p = new Child();
i = p->doIt(3);
return 0;
}

当程序执行完后i会等于多少呢?有人可能会认为是60,然而结果却是9。这是因为在Child中doIt的签名与在Parent中的不一致,它并没有重写Parent中的doIt,而仅仅是重载了它,在这种情况下,缺省值没有任何作用。

Java也提供了方法重载,不同的方法可以拥有同样的名字及不同的签名。

在Eiffel中没有引入新的技术,而是使用泛型化、继承及重定义等。Eiffel提供了协变式的签名方式,这意味着在子类的函数中不需要完全符合父类中的签名,但是通过Eiffel的强类型检测技术可以使得它们彼此相匹配。




C++批评系列:继承的本质
Ian Joyner
cber译

继承关系是一种耦合度很高的关系,它与组合及一般化(genericity)一样,提供了OO中的一种基本方法,用以将不同的软件组件组合起来。一个类的实例同时也是那个类的所有的祖先的实例。为了保证面向对象设计的有效性,我们应该保存下这种关系的一致性。在子类中的每一次重新定义都应该与在其祖先类中的最初定义进行一致性检查。子类中应该保存下其祖先类的需求。如果存在着不能被保存的需求,就说明了系统的设计有错误,或者是在系统中此处使用继承是不恰当的。由于继承是面向对象设计的基础,所以才会要求有一致性检测。C++中对于非虚拟函数重载的实现, 意味着编译器将不会为其进行一致性检测。C++并没有提供面向对象设计的这方面的保证。

继承被分成“语法”继承和“语义”继承两部分。Saake等人将其描述如下:“语法继承表示为结构或方法定义的继承,并且因此与代码的重复使用(以及重写被继承方法的代码)联系起来。语义继承表示为对对象语义(即对象自己)的继承,。这种继承形式可以从语义的数据模型中被得知,在此它被用于代表在一个应用程序的若干个角色中出现的一个对象。”[SJE 91]。Saake等人集中研究了继承的语义形式。通过是行为还是语义的继承方式的判断,表示了对象在系统中所扮的角色。

然而,Wegner相信代码继承更具有实际的价值。他将语法与语义继承之间的区别表示为代码和行为上的区别[Weg 91](p43)。他认为这样的划分不会引起一方与另一方的兼容,并且还经常与另一方不一致。Wegner同样也提出这样的问题:“应该怎样抑制对继承属性的修改?”代码继承为模块化(modularisation)提供一个基础。行为继承则依赖于“is-a”关系。这两种继承方式在合适处都十分有用。它们都要求进行一致性的检测,这与实际上的有意义的继承密不可分。

看起来在语义保持关系中那些限制最多的形式中,继承似乎是其中最强的形式;子类应该保存祖先类中的所有假设。

Meyer [Meyer 96a and 96b]也对继承技术进行了分类。在他的分类法中,他指出了继承的12种用法。这些分析也给我们怎么使用继承提供了一个很好的判断标准,如:什么时候应该使用继承,什么时候不应该它。

软件组件就象七巧板一样。当我们组装七巧板时,每一块板的形状必须要合适,但更重要地是,最终拼出的图像必须要有意义,能够被说得通。而将软件组件组合起来就更困难了。七巧板只是需要将原本是完整的一幅图像重新组合起来。而对软件组件的组合会得到什么样的结果,是我们不可能预见到的。更糟的是,七巧板的每一块通常是由不同的程序员产生的,这样当整个的系统被组合起来时,对于它们的吻合程度的要求就更高了。

C++中的继承像是一块七巧板,所有的板块都能够组合在一起,但是编译器却没有办法检测最终的结果是否有意义。换句话说,C++仅为类和继承提供了语法,而非语义。可重用的C++函数库的缓慢出现,暗示了C++可能会尽可能地不支持可重用性。相反的是,Java、Eiffel和 Object Pascal都与函数库包装在一起出现。Object Pascal与MacApp应用软件框架联系非常紧密。Java也从与Java API的耦合中解脱出来,取而代之的是一个包容广泛的函数库。Eiffel也同样是与一个极其全面的函数库集成在一起,该函数库甚至比Java的还要大。事实上函数库的概念已经成为一个优先于Eiffel语言本身的工程,用以对所有在计算机科学中通用的结构进行重新分类,得到一个常用的分类法。 [Meyer 94].





C++批评系列:继承的本质
Ian Joyner
cber译

Eiffel和C++都提供了多继承的机制。但Java却没有,因为它认为多继承会导致许多问题的出现。不过Java提供了接口 (interface)作为一种替换机制,它类似于Objective C中的协议(protocol)。Sun宣称接口可以提供多继承所能提供的所有特性。

Sun所宣称的“多继承会带来许多的问题”这个观点是对的,尤其是在C++中用以实现多继承的方法更能说明这一点。那些看起来似乎使用多继承会比单继承更简单的理由,现在都以被证明是毫无意义。例如,如何制订对于从两个类之上继承得到的具有相同名字的数据项之间的策略?它们之间是否兼容?如果是的话,那他们是否应该被合并成为一个实体?如果不兼容,那应该如何区分它们?……这样的列表可以列出很长很长。

Java的接口机制也可以用以实现多继承,但它也有一个很重要的不同之处(与C++相比):继承中的接口必须是抽象的。由于使用接口并没有任何的实作,这就消除了需要从不同实作之间选择的可能。Java允许在接口中声明具有常数字段。当需要多继承时,他们就合并成为一个实体,这样也就不会导致歧义的产生。但是,当这些常数具有不同的值时,又有什么会发生呢?

由于Java不支持多继承,我们就不可以像在C++和Eiffel中那样使用混合(mixin)了。混合是一种特性,它可以把从不同的类中得到的不同的非抽象的函数放到一起形成一个新的复杂的类。例如,我们可能希望从不同的源代码中导入一些utility函数。然而,我们也可以通过使用组合而不是继承来达到同样的效果,因此,这也就不会对Java构成一个重要的攻击了。

Eiffel在解决多继承问题时并没有导入一个单独的接口机制。

有些人可能认为,相对于多继承来说,单继承更优雅一些。这是一个很特别的观点。

BETA [Madsen 93]就属于认为“多继承不优雅”的那一种:“Beta中没有多继承,这主要是因为(对于多继承)缺乏一个深刻的理论上的理解,并且当前的(对于多继承的)建议在技术上看来也非常复杂”。他们引用了Flavors(一种可以将类混合在一起的语言)为证据。与Madsen相比,Flavors中的多继承与其顺序有关,也就是说,继承自(A,B)和继承自(B,A)是不一样的。

Ada95是另一种不支持多继承的语言。Ada95支持单继承,并把它叫做标记类型扩展(tagged type extension)。

另外一些人认为,对于某些特殊模型下的问题,多继承可以提供优雅的解法,因此为之付出的努力也是值得的。虽然上面所列出的关于多继承的问题列表并不完善,它仍然显示:与多继承相关的问题是可以被系统地辨识出来的,而一旦问题被确认,它们也就可以被优雅地解决。当[Sakkinen 92]对于多继承研究到达一个很深的程度后,它就得出了上述定义。

Eiffel中采用的方法是,多继承会引发一些有趣的且有挑战性的问题,然后再优雅地解决它们。程序员所需做的所有决定都被限制在类的继承子句中。它包括使用renaming来保证众多从继承中得来的同名特性最终成为具有不同名字的特性,对于继承而来的特性所施展的新的export策略: redefining和undefining,以及用来消除歧义的select。在所有的情况下,编译器都会为我们做好这一切,为了使得语义清晰而不管是选择使用fork或是join,程序员都具有完全的控制权。

C++中相对Eiffel来说有着另外一种不同的用于消除歧义的机制。在Eiffel中,在renames子句中,特性间必须有着不同的名字。在C++中,可以使用域解析操作符’::’来区分成员。Eiffel的做法好处在于,歧义在声明中就被消除掉了。Eiffel的继承子句相对C++ 的来说要复杂不少,但它的代码也显得更简单,更稳固,并更具弹性。这也就是声明方法与操作符方法相比的好处所在。在C++中,每次当我们碰到在多个成员间具有歧义时,我们必须在代码中使用域解析操作符。这经使得代码变得混乱不堪,影响其延展性,如果有其他地方的改变会影响歧义时,我们可能就需要在歧义可能出现的每个地方改变已有的代码。

依照[Stroustrup 94]中12.8节所说,ANSI委员会考虑过使用renaming,但是这个提议被委员会中的一个成员所阻塞掉了,他坚持让委员会中的其他成员用两周时间来好好地考虑这个问题。在12.8节中给出的例子显示了在没有显示的renaming的前提下,如何做可以得到同样的效果。问题在于,如果这都需要那些专家们使用两周来考虑如何实现,那留给我们的空间又有多少呢?

域解析操作符并不只是被用来消除多继承所带来的歧义。由于设计良好的语言可以避免歧义的出现,因此域解析操作符也就是一个丑陋的,加深复杂性的实作手法。

在C++中,“如何来声明多继承中的父类们”是一个很复杂的问题。它影响到了建构函数被调用的次序,当程序员确实想从子类转到父类时也会导致问题的出现。然而,我们也可以把这个称为不好的程序设计风格。

C++和Eiffel的另一个不同之处在于直接的重复继承,Eiffel中允许:

class B inherit A, A end



class B : public A, public A { };

却不被C++认可。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值