以下文章翻譯自Ian Joyner所著的
《C++?? A Critique of C++ and Programming and Language Trends of the 1990s》
3/E【Ian Joyner 1996】
該篇文章已經(jīng)包含在Ian Joyner所寫的《Objects Unencapsulated 》一書中(目前已經(jīng)有了日文的翻譯版本),該書的介紹可參見于:
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
虛擬函數(shù)
在所有對C++的批評中,虛擬函數(shù)這一部分是最復(fù)雜的。這主要是由于C++中復(fù)雜的機制所引起的。雖然本篇文章認為多態(tài)(polymorphism)是實現(xiàn)面向?qū)ο缶幊蹋∣OP)的關(guān)鍵特性,但還是請你不要對此觀點(即虛擬函數(shù)機制是C++中的一大敗筆)感到有什么不安,繼續(xù)看下去,如果你僅僅想知道一個大概的話,那么你也可以跳過此節(jié)。【譯者注:建議大家還是看看這節(jié)會比較好】
在C++中,當子類改寫/重定義(override/redefine)了在父類中定義了的函數(shù)時,關(guān)鍵字virtual使得該函數(shù)具有了多態(tài)性,但是
virtual關(guān)鍵字也并不是必不可少的(只要在父類中被定義一次就行了)。編譯器通過產(chǎn)生動態(tài)分配(dynamic
dispatch)的方式來實現(xiàn)真正的多態(tài)函數(shù)調(diào)用。
這樣,在C++中,問題就產(chǎn)生了:如果設(shè)計父類的人員不能預(yù)見到子類可能會改寫哪個函數(shù),那么子類就不能使得這個函數(shù)具有多態(tài)性。這對于C++來說是一個很嚴重的缺陷,因為它減少了軟件組件(software
components)的彈性(flexibility),從而使得寫出可重用及可擴展的函數(shù)庫也變得困難起來。
C++同時也允許函數(shù)的重載(overload),在這種情況下,編譯器通過傳入的參數(shù)來進行正確的函數(shù)調(diào)用。在函數(shù)調(diào)用時所引用的實參類型必須吻合被重載的函數(shù)組(overloaded
functions)中某一個函數(shù)的形參類型。重載函數(shù)與重寫函數(shù)(具有多態(tài)性的函數(shù))的不同之處在于:重載函數(shù)的調(diào)用是在編譯期間就被決定了,而重寫函數(shù)的調(diào)用則是在運行期間被決定的。
當一個父類被設(shè)計出來時,程序員只能猜測子類可能會重載/重寫哪個函數(shù)。子類可以隨時重載任何一個函數(shù),但這種機制并不是多態(tài)。為了實現(xiàn)多態(tài),設(shè)計父類的程序員必須指定一個函數(shù)為virtual,這樣會告訴編譯器在類的跳轉(zhuǎn)表(class
jump
table)【譯者竊以為是vtable,即虛擬函數(shù)入口表】中建立一個分發(fā)入口。于是,對于決定什么事情是由編譯器自動完成,或是由其他語言的編譯器自動完成這個重任就放到了程序員的肩上。這些都是從最初的C++的實現(xiàn)中繼承下來的,而和一些特定的編譯器及聯(lián)結(jié)器無關(guān)。
對于重寫,我們有著三種不同的選擇,分別對應(yīng)于:“千萬別”,“可以”及“一定要”重寫:
1、重寫一個函數(shù)是被禁止的。子類必須使用已有的函數(shù)。
2、函數(shù)可以被重寫。子類可以使用已有的函數(shù),也可以使用自己寫的函數(shù),前提是這個函數(shù)必須遵循最初的界面定義,而且實現(xiàn)的功能盡可能的少及完善。
3、函數(shù)是一個抽象的函數(shù)。對于該函數(shù)沒有提供任何的實現(xiàn),每個子類都必須提供其各自的實現(xiàn)。
父類的設(shè)計者必須要決定1和3中的函數(shù),而子類的設(shè)計者只需要考慮2就行了。對于這些選擇,程序語言必須要提供直接的語法支持。
選項1:
C ++并不能禁止在子類中重寫一個函數(shù)。即使是被聲明為private
virtual的函數(shù)也可以被重寫。【Sakkinen92】中指出了即使在通過其他方法都不能訪問到private virtual函數(shù),子類也可以對其進行重寫。
如下所示,將輸出:class B
#include <stdio.h>
class A
{
private:
virtual void f()
{
printf("class A\n");
}
public:
void call_f()
{
f();
}
};
class B : public A
{
public:
void f()
{
printf("class B\n");
}
};
int main()
{
B b;
A* a = &b;
a->call_f();
return 0;
}
實現(xiàn)這種選擇的唯一方法就是不要使用虛擬函數(shù),但是這樣的話,函數(shù)就等于整個被替換掉了。首先,函數(shù)可能會在無意中被子類的函數(shù)給替換掉。在同一個scope中重新宣告一個函數(shù)將會導(dǎo)致名字沖突(name
clash);編譯器將會就此報告出一個“duplicate
declaration”的語法錯誤。允許兩個擁有同名的實體存在于同一個scope中將會導(dǎo)致語義的二義性(ambiguity)及其他問題(可參見于 name
overloading這節(jié))。
下面的例子闡明了第二個問題:
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(); file://calls B::nonvirt as you would eXPect
ap->nonvirt(); file://calls A::nonvirt even though this object is of type B
ap->virt(); file://calls B::virt, the correct version of the routine for B
objects
在這個例子里,B擴展或替換掉了A中的函數(shù)。B::nonvirt是應(yīng)該被B的對象調(diào)用的函數(shù)。在此處我們必須指出,C++給客戶端程序員(即使用我們這套繼承體系架構(gòu)的程序員)足夠的彈性來調(diào)用A::nonvirt或是B::nonvirt,但我們也可以提供一種更簡單,更直接的方式:提供給A::
nonvirt和B::nonvirt不同的名字。這可以使得程序員能夠正確地,顯式地調(diào)用想要c調(diào)用的函數(shù),而
不是陷入了上面的那種晦澀的,容易導(dǎo)致錯誤的陷阱中去。
具體方法如下:
class B: public A
{
public:
void b_nonvirt();
void virt();
}
B b;
B *bp = &b;
bp->nonvirt(); file://calls A::nonvirt
bp->b_nonvirt(); file://calls B::b_nonvirt
現(xiàn)在,B的設(shè)計者就可以直接的操縱B的接口了。程序要求B的客戶端(即調(diào)用B的代碼)能夠同時調(diào)用A::nonvirt和B::nonvirt,這點我們也做到了。就Object-Oriented
Design(OOD)來說,這是一個不錯的做法,因為它提供了健壯的接口定義(strongly defined
interface)【譯者認為:即不會引起調(diào)用歧義的接口】。C++允許客戶端程序員在類的接口處賣弄他們的技巧,借以對類進行擴展。在上例中所出現(xiàn)的就是設(shè)計B的程序員不能阻止其他程序員調(diào)用A::nonvirt。類B的對象擁有它們自己的nonvirt,但是即便如此,B的設(shè)計者也不能保證通過B的接口就一定能調(diào)用到正確版本的nonvirt。
C++同樣不能阻止系統(tǒng)中對其他處的改動不會影響到B。假設(shè)我們需要寫一個類C,在C
中我們要求nonvirt是一個虛擬的函數(shù)。于是我們就必須回到A中將nonvirt改為虛擬的。但這又將使得我們對于B::nonvirt所玩弄的技巧又失去了作用(想想看,為什么:D)。對于C需要一個virtual的需求(將已有的nonvirtual改為virtual)使得我們改變了父類,這又使得所有從父類繼承下來的子類也相應(yīng)地有了改變。這已經(jīng)違背了OOP擁有低耦合的類的理由,新的需求,改動應(yīng)該只產(chǎn)生局部的影響,而不是改變系統(tǒng)中其他地方,從而潛在地破壞了系統(tǒng)的已有部分。
另一個問題是,同樣的一條語句必須一直保持著同樣的語義。例如:對于諸如a->f()這樣的多態(tài)性語句的解釋,系統(tǒng)調(diào)用的是由最符合a所真正指向類型的那個f(),而不管對象的類型到底是A,還是A的子類。然而,對于C++的程序員來說,他們必須要清楚地了解當f()被定義成virtual或是non-virtual時,a->f()的真正涵義。所以,語句a->f()不能獨立于其實現(xiàn),而且隱藏的實現(xiàn)原理也不是一成不變的。對于f()聲明的一次改變將會相應(yīng)地改變調(diào)用它時的語義。與實現(xiàn)獨立意味著對于實現(xiàn)的改變不會改變語句的語義,或是執(zhí)行的語義。
如果在聲明中的改變導(dǎo)致相應(yīng)的語義改變,編譯器應(yīng)該能檢測到錯誤的產(chǎn)生。程序員應(yīng)該在聲明被改變的情況下保持語義的不變。這反映了軟件開發(fā)中的動態(tài)特性,在其中你將能發(fā)現(xiàn)程序文本的永久改變。
其他另一個與a->f()相應(yīng)的,語義不能被保持不變的例子是:構(gòu)造函數(shù)(可參考于C++ ARM, section 10.9c, p
232)。而Eiffel和Java則不存在這樣的問題。它們中所采用的機制簡單而又清晰,不會導(dǎo)致C++中所產(chǎn)生的那些令人吃驚的現(xiàn)象。在Java中,所有的方法都是虛擬的,為了讓一個方法【譯者注:對應(yīng)于C++的函數(shù)】不能被重寫,我們可以用final修飾符來修飾這個方法。
Eiffel允許程序員指定一個函數(shù)為frozen,在這種情況下,這個函數(shù)就不能在子類中被重寫。
選項2:
是使用現(xiàn)有的函數(shù)還是重寫一個,這應(yīng)該是由撰寫子類的程序員所決定的。在C++中,要想擁有這種能力則必須在父類中指定為virtual。對于OOD來說,你所決定不想作的與你所決定想作的同樣重要,你的決定應(yīng)該是越遲下越好。這種策略可以避免錯誤在系統(tǒng)前期就被包含進去。你作決定越早,你就越有可能被以后所證明是錯誤的假設(shè)所包圍;或是你所作的假設(shè)在一種情況下是正確的,然而在另一種情況下卻會出錯,從而使得你所寫出來的軟件比較脆弱,不具有重用性(reusable)【譯者注:軟件的可重用性對于軟件來說是一個很重要的特性,具體可以參考
《Object-Oriented Software Construct》中對于軟件的外部特性的敘述,P7, Reusability, Charpter 1.2
A REVIEW OF EXTERNAL FACTORS】。
C
++要求我們在父類中就要指定可能的多態(tài)性(這可以通過virtual來指定),當然我們也可以在繼承鏈中的中間的類導(dǎo)入virtual機制,從而預(yù)先判斷某個函數(shù)是否可以在子類中被重定義。
這種做法將導(dǎo)致問題的出現(xiàn):如那些并非真正多態(tài)的函數(shù)(not actually
polymorphic)也必須通過效率較低的table技術(shù)來被調(diào)用,而不像直接調(diào)用那個函數(shù)來的高效【譯者注:在文章的上下文中并沒有出現(xiàn)not actually
polymorphic特性的確切定義,根據(jù)我的理解,應(yīng)該是聲明為polymorphic,而實際上的動作并沒能體現(xiàn)polymorphic這樣的一種特性】。雖然這樣做并不會引起大量的花費(overhead),但我們知道,在OO程序中經(jīng)常會出現(xiàn)使用大量的、短小的、目標單一明確的函數(shù),如果將所有這些都累計下來,也會導(dǎo)致一個相當可觀的花費。C++中的
政策是這樣的:需要被重定義的函數(shù)必須被聲明為virtual。糟糕的是,C++同時也說了,
non-virtual函數(shù)不能被重定義,這使得設(shè)計使用子類的程序員就無法對于這些函數(shù)擁有自己的控制權(quán)。【譯者注:原作中此句顯得有待推敲,原文是這樣寫的:it
says that non-virtual routines cannot be redefined, 我猜測作者想表達的意思應(yīng)該是: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++擁有了簡單實現(xiàn)繼承及動態(tài)方法調(diào)用的特性,但一個C++的數(shù)據(jù)結(jié)構(gòu)并不能自動成為面向?qū)ο蟮摹7椒ㄕ{(diào)用決議(method
resolution)以及在子類中重寫一個函數(shù)操作的前提必須是這個函數(shù)/方法已經(jīng)在父類中被聲明為virtual。也就是說,必須在最初的類中我們就能預(yù)見到一個函數(shù)是否需要被重寫。不幸的是,類的撰寫者可能不會預(yù)期到需要定義一個特殊的子類,也可能不會知道那些操作將要在子類中被重寫。這意味著當子類被定義時,我們經(jīng)常需要回過頭去修改我們的父類,并且使得對于通過創(chuàng)建子類來重用已有的庫的限制極為嚴格,尤其是當這個庫的源代碼不能被獲得是更是如此。(當然,你也可以將所有的操作都定義為virtual,并愿意為此付出一些小小的內(nèi)存花費用于函數(shù)調(diào)用)【RBPEL91】
然而,讓程序員來處理virtual是一個錯誤的機制。編譯器應(yīng)該能夠檢測到多態(tài),并為此產(chǎn)生所必須的、潛在的實現(xiàn)virtual的代碼。讓程序員來決定
virtual與否對于程序員來說是增加了一個簿記工作的負擔。這也就是為什么C++只能算是一種弱的面向?qū)ο笳Z言(weak object-oriented
language):因為程序員必須時刻注意著一些底層的細節(jié)(low level details),而這些本來可以由編譯器自動處理的。
在C++中的另一個問題是錯誤的重寫(mistaken
overriding),父類中的函數(shù)可以在毫不知情的情況下被重寫。編譯器應(yīng)該對于同一個名字空間中的重定義報錯,除非編寫子類的程序員指出他是有意這么做的(即對于虛函數(shù)的重寫)。我們可以使用同一個名字,但是程序員必須清楚自己在干什么,并且顯式地聲明它,尤其是在將自己的程序與已經(jīng)存在的程序組件組裝成新的系統(tǒng)的情況下更要如此。除非程序員顯式地重寫已有的虛函數(shù),否則編譯器必須要給我們報告出現(xiàn)了名字被聲明多處(duplicate
declaration)的錯誤。然而,C++卻采用了Simula最初的做法,而這種方法到現(xiàn)在已經(jīng)得到了改良。其他的一些程序語言通過采用了更好的、更加顯式的方法,避免了錯誤重定義的出現(xiàn)。
解決方法就是virtual不應(yīng)該在父類中就被指定好。當我們需要運行時的動態(tài)綁定時,我們就在子類中指定需要對某個函數(shù)進行重寫。這樣做的好處在于:對于具有多態(tài)性的函數(shù),編譯器可以檢測其函數(shù)簽名(function
signature)的一致性;而對于重載的函數(shù),其函數(shù)簽名在某些方面本來就不一樣。第二個好處表現(xiàn)在,在程序的維護階段,能夠清楚地表達程序的最初意愿。而實際上后來的程序員卻經(jīng)常要猜測先前的程序員是不是犯了什么錯誤,選擇一個相同的名字,還是他本來就想重載這個函數(shù)。
在 Java中,沒有virtual這個關(guān)鍵字,所有的方法在底層都是多態(tài)的。當方法被定義為static,
private或是final時,Java直接調(diào)用它們而不是通過動態(tài)的查表的方式。這意味著在需要被動態(tài)調(diào)用時,它們卻是非多態(tài)性的函數(shù),Java的這種動態(tài)特性使得編譯器難以進行進一步的優(yōu)化。
Eiffel和Object
Pascal迎合了這個選項。在它們中,編寫子類的程序員必須指定他們所想進行的重定義動作。我們可以從這種做法中得到巨大的好處:對于以后將要閱讀這些程序的人及程序的將來維護者來說,可以很容易地找出來被重寫的函數(shù)。因而選項2最好是在子類中被實現(xiàn)。
Eiffel和Object Pascal都優(yōu)化了函數(shù)調(diào)用的方式:因為他們只需要產(chǎn)生那些真正多態(tài)的函數(shù)的調(diào)用分配表的入口項。對于怎樣做,我們將會在global
analysis這節(jié)中討論。
選項3:
純虛函數(shù)這樣的做法迎合了讓一個函數(shù)成為抽象的,從而子類在實例化時必須為其提供一個實現(xiàn)這樣的一個條件。沒有重寫這些函數(shù)的任何子類同樣也是抽象類。這個概念沒有錯,但是請你看一看pure
virtual functions這一節(jié),我們將在那節(jié)中對于這種術(shù)語及語法進行批判討論。
Java也擁有純虛方法(同樣Eiffel也有),實現(xiàn)方法是為該方法加上deffered標注。
結(jié)論:
virtual
的主要問題在于,它強迫編寫父類的程序員必須要猜測函數(shù)在子類中是否有多態(tài)性。如果這個需求沒有被預(yù)見到,或是為了優(yōu)化、避免動態(tài)調(diào)用而沒有被包含進去的話,那么導(dǎo)致的可能性就是極大的封閉,勝過了開放。在C++的實現(xiàn)中,virtual提高了重寫的耦合性,導(dǎo)致了一種容易產(chǎn)生錯誤的聯(lián)合。
Virtual是一種難以掌握的語法,相關(guān)的諸如多態(tài)、動態(tài)綁定、重定義以及重寫等概念由于面向于問題域本身,掌握起來就相對容易多了。虛擬函數(shù)的這種實現(xiàn)機制要求編譯器為其在class中建立起virtual
table入口,而global analysis并不是由編譯器完成的,所以一切的重擔都壓在了程序員的肩上了。多態(tài)是目的,虛擬機制就是手段。Smalltalk,
Objective-C, Java和Eiffel都是使用其他的一種不同的方法來實現(xiàn)多態(tài)的。
Virtual是一個例子,展示了C
++在OOP的概念上的混沌不清。程序員必須了解一些底層的概念,甚至要超過了解那些高層次的面向?qū)ο蟮母拍睢irtual把優(yōu)化留給了程序員;其他的方法則是由編譯器來優(yōu)化函數(shù)的動態(tài)調(diào)用,這樣做可以將那些不需要被動態(tài)調(diào)用的分配(即不需要在動態(tài)調(diào)用表中存在入口)100%地消除掉。對于底層機制,感興趣的應(yīng)該是那些理論家及編譯器實現(xiàn)者,一般的從業(yè)者則沒有必要去理解它們,或是通過使用它們來搞清楚高層的概念。在實踐中不得不使用它們是一件單調(diào)乏味的事情,并且還容易導(dǎo)致出錯,這阻止了軟件在底層技術(shù)及運行機制下(參見并發(fā)程序)的更好適應(yīng),降低了軟件的彈性及可重用性。