Programming 版 (精华区)
发信人: zhangyan (塑料脑袋镀浆糊), 信区: Programming
标 题: C++的不足之处系列(一)
发信站: 哈工大紫丁香 (2001年04月26日09:18:48 星期四), 站内信件
以下文章翻译自Ian Joyner所著的
《C++?? A Critique of C++ and Programming and Language Trends of the 1990s》
3/E【Ian Joyner 1996】
原著版权属于Ian Joyner,
征得Ian Joyner本人的同意,我得以将该文翻译成中文。因此,本文的中文版权应该属
于我;-)
该文章的英文及中文版本都用于非商业用途,你可以随意地复制和转贴它。不过最好在
转贴它时加上我的这段声明。
如有人或机构想要出版该文,请最好联系原著版权所有人及我。
该篇文章已经包含在Ian Joyner所写的《Objects Unencapsulated 》一书中(目前已经
有了日文的翻译版本),该书的介绍可参见于:
http://www.prenhall.com/allbooks/ptr_0130142697.html
http://efsa.sourceforge.net/cgi-bin/view/Main/ObjectsUnencapsulated
http://www.accu.org/bookreviews/public/reviews/o/o002284.htm
Ian Joyner的联系方式:
i.joyner@acm.org
我的联系方式:
cber@email.com.cn
前言:【译者所写的】
要想彻底的掌握一种语言,不但需要知道它的长处有哪些,而且需要知道它的不足之处
又有哪些。这样我们才能用好这门语言,也才能说我们自己掌握了这门语言。
C++的不足之处讨论系列(一)
虚拟函数
在所有对C++的批评中,虚拟函数这一部分是最复杂的。这主要是由于C++中复杂的机制
所引起的。虽然本篇文章认为多态(polymorphism)是实现面向对象编程(OOP)的关键
特性,但还是请你不要对此观点(即虚拟函数机制是C++中的一大败笔)感到有什么不安
,继续看下去,如果你仅仅想知道一个大概的话,那么你也可以跳过此节。【译者注:
建议大家还是看看这节会比较好】
在C++中,当子类改写/重定义(override/redefine)了在父类中定义了的函数时,关
键字virtual使得该函数具有了多态性,但是virtual关键字也并不是必不可少的(只要
在父类中被定义一次就行了)。编译器通过产生动态分配(dynamic dispatch)的方式
来实现真正的多态函数调用。
这样,在C++中,问题就产生了:如果设计父类的人员不能预见到子类可能会改写哪个
函数,那么子类就不能使得这个函数具有多态性。这对于C++来说是一个很严重的缺陷,
因为它减少了软件组件(software components)的弹性(flexibility),从而使得写出
可重用及可扩展的函数库也变得困难起来。
C++同时也允许函数的重载(overload),在这种情况下,编译器通过传入的参数来进
行正确的函数调用。在函数调用时所引用的实参类型必须吻合被重载的函数组(overlo
aded 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 obje
cts
在这个例子里,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 Desi
gn(OOD)来说,这是一个不错的做法,因为它提供了健壮的接口定义(strongly define
d 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 EXTE
RNAL FACTORS】。
C++要求我们在父类中就要指定可能的多态性(这可以通过virtual来指定),当然我们
也可以在继承链中的中间的类导入virtual机制,从而预先判断某个函数是否可以在子类
中被重定义。这种做法将导致问题的出现:如那些并非真正多态的函数(not actually
polymorphic)也必须通过效率较低的table技术来被调用,而不像直接调用那个函数来
的高效【译者注:在文章的上下文中并没有出现not actually polymorphic特性的确切
定义,根据我的理解,应该是声明为polymorphic,而实际上的动作并没能体现polymor
phic这样的一种特性】。虽然这样做并不会引起大量的花费(overhead),但我们知道
,在OO程序中经常会出现使用大量的、短小的、目标单一明确的函数,如果将所有这些
都累计下来,也会导致一个相当可观的花费。C++中的政策是这样的:需要被重定义的函
数必须被声明为virtual。糟糕的是,C++同时也说了,non-virtual函数不能被重定义,
这使得设计使用子类的程序员就无法对于这 些函数拥有自己的控制权。【译者注:原作
中此句显得有待推敲,原文是这样写的:it says that non-virtual routines cannot
be redefined, 我猜测作者想表达的意思应该是:If you have defined a non-virtu
al routine in base, then it cannot be virtual in the base whether you redefi
ned it as virtual in descendant.】
Rumbaugh等人对于C++中的虚拟机制的批评如下:C++拥有了简单实现继承及动态方法调
用的特性,但一个C++的数据结构并不能自动成为面向对象的。方法调用决议(method
resolution)以及在子类中重写一个函数操作的前提必须是这个函数/方法已经在父类中
被声明为virtual。也就是说,必须在最初的类中我们就能预见到一个函数是否需要被重
写。不幸的是,类的撰写者可能不会预期到需要定义一个特殊的子类,也可能不会知道
那些操作将要在子类中被重写。这意味着当子类被定义时,我们经常需要回过头去修改
我们的父类,并且使得对于通过创建子类来重用已有的库的限制极为严格,尤其是当这
个库的源代码不能被获得是更是如此。(当然,你也可以将所有的操作都定义为virtua
l,并愿意为此付出一些小小的内存花费用于函数调用)【RBPEL91】
然而,让程序员来处理virtual是一个错误的机制。编译器应该能够检测到多态,并为
此产生所必须的、潜在的实现virtual的代码。让程序员来决定virtual与否对于程序员
来说是增加了一个簿记工作的负担。这也就是为什么C++只能算是一种弱的面向对象语言
(weak object-oriented language):因为程序员必须时刻注意着一些底层的细节(l
ow 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并不是由编译器完成的
,所以一切的重担都压在了程序员的肩上了。多态是目的,虚拟机制就是手段。Smallt
alk, Objective-C, Java和Eiffel都是使用其他的一种不同的方法来实现多态的。
Virtual是一个例子,展示了C++在OOP的概念上的混沌不清。程序员必须了解一些底层
的概念,甚至要超过了解那些高层次的面向对象的概念。Virtual把优化留给了程序员;
其他的方法则是由编译器来优化函数的动态调用,这样做可以将那些不需要被动态调用
的分配(即不需要在动态调用表中存在入口)100%地消除掉。对于底层机制,感兴趣的
应该是那些理论家及编译器实现者,一般的从业者则没有必要去理解它们,或是通过使
用它们来搞清楚高层的概念。在实践中不得不使用它们是一件单调乏味的事情,并且还
容易导致出错,这阻止了软件在底层技术及运行机制下(参见并发程序)的更好适应,
降低了软件的弹性及可重用性。
--
※ 来源:·哈工大紫丁香 bbs.hit.edu.cn·[FROM: 202.118.170.172]
Powered by KBS BBS 2.0 (http://dev.kcn.cn)
页面执行时间:217.113毫秒