C++批判
以下文章翻譯自Ian Joyner所著的
《C++?? A Critique of C++ and Programming and Language Trends of the 1990s》 3/E【Ian Joyner 1996】
該篇文章已經包含在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
虛擬函數
在所有對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函數,子類也可以對其進行重寫。
如下所示,將輸出: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;
}
實現這種選擇的唯一方法就是不要使用虛擬函數,但是這樣的話,函數就等于整個被替換掉了。首先,函數可能會在無意中被子類的函數給替換掉。在同一個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(); 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中的函數。B::nonvirt是應該被B的對象調用的函數。在此處我們必須指出,C++給客戶端程序員(即使用我們這套繼承體系架構的程序員)足夠的彈性來調用A::nonvirt或是B::nonvirt,但我們也可以提供一種更簡單,更直接的方式:提供給A:: nonvirt和B::nonvirt不同的名字。這可以使得程序員能夠正確地,顯式地調用想要c調用的函數,而
不是陷入了上面的那種晦澀的,容易導致錯誤的陷阱中去。
具體方法如下:
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
現在,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%地消除掉。對于底層機制,感興趣的應該是那些理論家及編譯器實現者,一般的從業者則沒有必要去理解它們,或是通過使用它們來搞清楚高層的概念。在實踐中不得不使用它們是一件單調乏味的事情,并且還容易導致出錯,這阻止了軟件在底層技術及運行機制下(參見并發程序)的更好適應,降低了軟件的彈性及可重用性。
全局分析
【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)。
保證類型安全的聯結屬性(type-safe linkage)
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++允許在參數類型不同的前提下重載函數。重載的函數與具有多態性的函數(即虛函數)不同處在于:調用正確的被重載函數實體是在編譯期間就被決定了的;而對于具有多態性的函數來說,是通過運行期間的動態綁定來調用我們想調用的那個函數實體。多態性是通過重定義(或重寫)這種方式達成的。請不要被重載 (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
但是,min和max都是特殊的函數,它們可以接受兩個或者更多的同一類型的參數,并且還可以作用在任意長度的數組上。因此,在Eiffel中,對于這種情況最常見的代碼形式看起來就像這樣:
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,而僅僅是重載了它,在這種情況下,缺省值沒有任何作用。
再來看看這個例子,絕對讓你抓狂,猜猜看輸出的i和j值是多少?
#include <stdio.h>
class PARENT
{
public:
virtual int doIt( int v, int av = 10 )
{
return v * v;
}
};
class CHILD : public PARENT
{
public:
int doIt( int v, int av = 20 )
{
return v * av;
}
};
int main()
{
PARENT *p = new CHILD();
int i = p->doIt(3);
printf("i = %d\n", i);
CHILD* q = new CHILD();
int j = q->doIt(3);
printf("j = %d\n", j);
return 0;
}
Java也提供了方法重載,不同的方法可以擁有同樣的名字及不同的簽名。
在Eiffel中沒有引入新的技術,而是使用范型化、繼承及重定義等。Eiffel提供了協變式的簽名方式,這意味著在子類的函數中不需要完全符合父類中的簽名,但是通過Eiffel的強類型檢測技術可以使得它們彼此相匹配。
繼承的本質
繼承關系是一種耦合度很高的關系,它與組合及一般化(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].