文章目錄
品味Java子類型多態的魅力
Posted on 2004-11-04 by yo2解未知數-2
“polymorphism(多態)”一詞來自希臘語,意為“多種形式”。多數Java程式員把多態看作對象的一種能力,使其能調用正確的方法版本。儘管如此,這種面向實現的觀點導致了多態的神奇功能,勝於僅僅把多態看成純粹的概念。
Java中的多態總是子類型的多態。幾乎是機械式產生了一些多態的行為,使我們不去考慮其中涉及的類型問題。本文研究了一種面向類型的對象觀點,分析了如何將對象能夠表現的行為和對象即將表現的行為分離開來。拋開Java中的多態都是來自繼承的概念,我們仍然可以感到,Java中的介面是一組沒有公用代碼的對象共用實現。
多態的分類
多態在物件導向語言中是個很普遍的概念.雖然我們經常把多態混為一談,但實際上有四種不同類型的多態。在開始正式的子類型多態的細節討論前,然我們先來看看普通物件導向中的多態。
Luca Cardelli和Peter Wegner(On Understanding Types, Data Abstraction, and Polymorphism一文的作者, 文章參考資源連結)把多態分為兩大類----特定的和通用的----四小類:強制的,重載的,參數的和包含的。他們的結構如下:
在這樣一個體系中,多態表現出多種形式的能力。通用多態引用有相同結構類型的大量對象,他們有著共同的特徵。特定的多態涉及的是小部分沒有相同特徵的對象。四種多態可做以下描述:
強制的:一種隱式做類型轉換的方法。
重載的:將一個標誌符用作多個意義。
參數的:為不同類型的參數提供相同的操作。
包含的:類內含項目關聯性的抽象操作。
我將在講述子類型多態前簡單介紹一下這幾種多態。
強制的多態
強制多態隱式的將參數按某種方法,轉換成編譯器認為正確的類型以避免錯誤。在以下的運算式中,編譯器必須決定二元運算子‘+’所應做的工作:
2.0 + 2.0
2.0 + 2
2.0 + 2
第一個運算式將兩個double的運算元相加;Java中特別聲明了這種用法。
第二個運算式將double型和int相加。Java中沒有明確定義這種運算。不過,編譯器隱式的將第二個運算元轉換為double型,並作double型的加法。做對程式員來說十分方便,否則將會拋出一個編譯錯誤,或者強製程序員顯式的將int轉換為double。
第三個運算式將double與一個String相加。Java中同樣沒有定義這樣的操作。所以,編譯器將double轉換成String類型,並將他們做串聯。
強制多態也會發生在方法調用中。假設類Derived繼承了類Base,類C有一個方法,原型為 m(Base),在下面的代碼中,編譯器隱式的將Derived類的對象derived轉化為Base類的對象。這種隱式的轉換使m(Base)方法使用所有能轉換成Base類的所有參數。
C c = new C();
Derived derived = new Derived();
c.m( derived );
並且,隱式的強制轉換,可以避免類型轉換的麻煩,減少編譯錯誤。當然,編譯器仍然會優先驗證符合定義的物件類型。
重載的多態
重載允許用相同的運算子或方法,去表示截然不同的意義。‘+’在上面的程式中有兩個意思:兩個 double型的數相加;兩個串相連。另外還有整型相加,長整型,等等。這些運算子的重載,依賴於編譯器根據上下文做出的選擇。以往的編譯器會把運算元隱式轉換為完全符合操作符的類型。雖然Java明確支援重載,但不支援使用者定義的操作符重載。
Java支援使用者定義的函數重載。一個類中可以有相同名字的方法,這些方法可以有不同的意義。這些重載的方法中,必須滿足參數數目不同,相同位置上的參數類型不同。這些不同可以協助編譯器區分不同版本的方法。
編譯器以這種唯一表示的特徵來表示不同的方法,比用名字表示更為有效。據此,所有的多態行為都能編譯通過。
強制和重載的多態都被分類為特定的多態,因為這些多態都是在特定的意義上的。這些被劃入多態的特性給程式員帶來了很大的方便。強制多態排除了麻煩的類型和編譯錯誤。重載多態像一塊糖,允許程式員用相同的名字表示不同的方法,很方便。
參數的多態
多重參數變形允許把許多類型抽象成單一的表示。例如,List抽象類別中,描述了一組具有同樣特徵的對象,提供了一個通用的模板。你可以通過指定一種類型以重用這個抽象類別。這些參數可以是任何使用者定義的類型,大量的使用者可以使用這個抽象類別,因此多重參數變形毫無疑問的成為最強大的多態。
乍一看,上面抽象類別好像是java.util.List的功能。然而,Java實際上並不支援真正的安全類型風格的多重參數變形,這也是java.util.List和java.util的其他集合類是用原始的java.lang.Object寫的原因(參考我的文章A Primordial Interface? 以獲得更多細節)。Java的單根繼承方式解決了部分問題,但沒有發揮出多重參數變形的全部功能。Eric Allen有一篇精彩的文章“Behold the Power of Parametric Polymorphism”,描述了Java通用類型的需求,並建議給Sun的Java規格需求#000014號文檔Add Generic Types to the Java Programming Language.(參考資源連結)
包含的多態
包含多態通過值的類型和集合的內含項目關聯性實現了多態的行為.在包括Java在內的眾多物件導向語言中,內含項目關聯性是子類型的。所以,Java的包含多態是子類型的多態。
在早期,Java開發人員們所提及的多態就特指子類型的多態。通過一種面向類型的觀點,我們可以看到子類型多態的強大功能。以下的文章中我們將仔細探討這個問題。為簡明起見,下文中的多態均指包含多態。
面向類型觀點
圖1的UML類圖給出了類和類型的簡單繼承關係,以便於解釋多態機制。模型中包含5種類型,4個類和一個介面。雖然UML中稱為類圖,我把它看成類型圖。如Thanks Type and Gentle Class, 一文中所述,每個類和介面都是一種使用者定義的類型。按獨立實現的觀點(如面向類型的觀點),中的每個矩形代表一種類型。從實現方法看,四種類型運用了類的結構,一種運用了介面的結構。
圖1:示範代碼的UML類圖
以下的代碼實現了每個使用者定義的資料類型,我把實現寫得很簡單。
/* Base.java */
public class Base
{
public String m1()
{
return Base.m1();
}
public String m2( String s )
{
return Base.m2( + s + );
}
}
/* IType.java */
interface IType
{
String m2( String s );
String m3();
}
/* Derived.java */
public class Derived
extends Base
implements IType
{
public String m1()
{
return Derived.m1();
}
public String m3()
{
return Derived.m3();
}
}
/* Derived2.java */
public class Derived2
extends Derived
{
public String m2( String s )
{
return Derived2.m2( + s + );
}
public String m4()
{
return Derived2.m4();
}
}
/* Separate.java */
public class Separate
implements IType
{
public String m1()
{
return Separate.m1();
}
public String m2( String s )
{
return Separate.m2( + s + );
}
public String m3()
{
return Separate.m3();
}
}
用這樣的型別宣告和類的定義,圖2從概念的觀點描述了Java指令。
Derived2 derived2 = new Derived2();
圖2 :Derived2 對象上的引用
上文中聲明了derived2這個對象,它是 Derived2類的。圖2種的最頂層把Derived2引用描述成一個集合的視窗,雖然其下的Derived2對象是可見的。這裡為每個 Derived2類型的操作留了一個孔。Derived2對象的每個操作都去映射適當的代碼,按照上面的代碼所描述的那樣。例如,Derived2對象映射了在Derived中定義的m1()方法。而且還重載了Base類的m1()方法。一個Derived2的引用變數無權訪問Base類中被重載的 m1()方法。但這並不意味著不可以用super.m1()的方法調用去使用這個方法。關係到derived2這個引用的變數,這個代碼是不合適的。 Derived2的其他的操作映射同樣表明了每種類型操作的代碼執行。
既然你有一個Derived2對象,可以用任何一個Derived2類型的變數去引用它。1所示,Derived, Base和IType都是Derived2的基類。所以,Base類的引用是很有用的。圖3描述了以下語句的概念觀點。
Base base = derived2;
圖3:Base類引用附於Derived2對象之上
雖然Base類的引用不用再訪問m3()和m4(),但是卻不會改變它Derived2對象的任何特徵及操作映射。無論是變數derived2還是base,其調用m1()或m2(String)所執行的代碼都是一樣的。
String tmp;
// Derived2 reference (Figure 2)
tmp = derived2.m1(); // tmp is Derived.m1()
tmp = derived2.m2( Hello ); // tmp is Derived2.m2( Hello )
// Base reference (Figure 3)
tmp = base.m1(); // tmp is Derived.m1()
tmp = base.m2( Hello ); // tmp is Derived2.m2( Hello )
兩個引用之所以調用同一個行為,是因為Derived2對象並不知道去調用哪個方法。對象只知道什麼時候調用,它隨著繼承實現的順序去執行。這樣的順序決定了Derived2對象調用Derived裡的m1() 方法,並調用Derived2裡的m2(String)方法。這種結果取決於對象本身的類型,而不是引用的類型。
儘管如此,但不意味著你用derived2和base引用的效果是完全一樣的。3所示,Base的引用只能看到Base類型擁有的操作。所以,雖然Derived2有對方法m3()和m4()的映射,但是變數base不能訪問這些方法。
String tmp;
// Derived2 reference (Figure 2)
tmp = derived2.m3(); // tmp is Derived.m3()
tmp = derived2.m4(); // tmp is Derived2.m4()
// Base reference (Figure 3)
tmp = base.m3(); // Compile-time error
tmp = base.m4(); // Compile-time error
運行期的Derived2對象保持了接受m3()和m4()方法的能力。類型的限制使Base的引用不能在編譯期調用這些方法。編譯期的類型檢查像一套鎧甲,保證了運行期對象只能和正確的操作進行相互作用。換句話說,類型定義了對象間相互作用的邊界。
多態的依附性
類型的一致性是多態的核心。對象上的每一個引用,靜態類型檢查器都要確認這樣的依附和其對象的層次是一致的。當一個引用成功的依附於另一個不同的對象時,有趣的多態現象就產生了。(嚴格的說,物件類型是指類的定義。)你也可以把幾個不同的引用依附於同一個對象。在開始更有趣的情境前,我們先來看一下下面的情況為什麼不會產生多態。
多個引用依附於一個對象
圖2和圖3描述的例子是把兩個及兩個以上的引用依附於一個對象。雖然Derived2對象在被依附之後仍保持了變數的類型,但是,圖3中的Base類型的引用依附之後,其功能減少了。結論很明顯:把一個基類的引用依附於衍生類別的對象之上會減少其能力。
一個開發這怎麼會選擇減少對象能力的方案呢?這種選擇是間接的。假設有一個名為ref的引用依附於一個包含如下方法的類的對象:
public String poly1( Base base )
{
return base.m1();
}
用一個Derived2的參數調用poly(Base)是符合參數類型檢查的:
ref.poly1( derived2 );
方法調用把一個本地Base類型的變數依附在一個引入的對象上。所以,雖然這個方法只接受Base類型的參數,但Derived2對象仍是允許的。開發這就不必選擇丟失功能的方案。從人眼在通過Derived2 對象時所看到的情況,Base類型引用的依附導致了功能的喪失。但從執行的觀點看,每一個傳入poly1(Base)的參數都認為是Base的對象。執行機並不在乎有多個引用指向同一個對象,它只注重把指向另一個對象的引用傳給方法。這些對象的類型不一致並不是主要問題。執行器只關心給運行時的對象找到適當的實現。面向類型的觀點展示了多態的巨大能力。
附於多個對象的引用
讓我們來看一下發生在poly1(Base)中的多態行為。下面的代碼建立了三個對象,並通過引用傳給poly1(Base):
Derived2 derived2 = new Derived2();
Derived derived = new Derived();
Base base = new Base();
String tmp;
tmp = ref.poly1( derived2 ); // tmp is Derived.m1()
tmp = ref.poly1( derived ); // tmp is Derived.m1()
tmp = ref.poly1( base ); // tmp is Base.m1()
poly1(Base)的實現代碼是調用傳進來的參數的m1()方法。圖3和圖4展示了把三個類的對象傳給方法時,面向類型的所使用的體繫結構。
圖4:將Base引用指向Derived類,以及Base對象
請注意每個圖中方法m1()的映射。圖3中,m1()調用了Derived類的代碼;上面代碼中的注釋標明了ploy1(Base)調用Derived.m1()。圖4中Derived對象調用的仍然是 Derived類的m1()方法。最後,圖4中,Base對象調用的m1()是Base類中定義的代碼。
多態的魅力何在?再來看一下poly1(Base)的代碼,它可以接受任何屬於Base類範疇的參數。然而,當他收到一個Derived2的對象時,它實際上卻調用了Derived版本的方法。當你根據 Base類派生出其他類時,如Derived,Derived2,poly1(Base)都可以接受這些參數,並作出選擇調用合適的方法。多態允許你在完成poly1(Base)後擴充它的用途。
這看起來當然很神奇。基本的理解展示了多態的內部工作原理。在面向類型的觀點中,底層的對象所實現的代碼是非實質性的。重要的是,類型檢查器會在編譯期間為每個引用選擇合適的代碼以實現其方法。多態使開發人員運用面向類型的觀點,不考慮實現的細節。這樣有助於把類型和實現分離(實際用處是把介面和實現分離)。
對象介面
多態依賴於類型和實現的分離,多用來把介面和實現分離。但下面的觀點好像把Java的關鍵字interface搞得很糊塗。
更為重要的使開發人員們怎樣理解短語“the interface to an object,典型地,根據上下文,這個短語的意思是指一切對象類中所定義的方法,至一切對象公開的方法。這種傾向於以實現為中心的觀點較之於面向類型的觀點來說,使我們更加註重於對象在運行期的能力。圖3中,引用面板的對象表面被標誌成Derived2 Object。這個面板上列出了Derived2對象的所有可用的方法。但是要理解多態,我們必須從實現這一層次上解放出來,並注意面向類型的透視圖中被標為Base Reference的面板。在這一層意思上,引用變數的類型指明了一個對象的表面。這隻是一個表面,不是介面。在類型一致的原則下,我們可以用面向類型的觀點,為一個對象依附多個引用。對interface to an object這個短語的理解沒有確定的理解。
在類型概念中,the interface to an object refers 引用了面向類型觀點的最大可能----2的情形。把一個基類的引用指向相同的對象縮小了這樣的觀點----3所示。類型概念能使人獲得把對象間的相互作用同實現細節分離的要領。相對於一個對象的介面,面向類型的觀點更鼓勵人們去使用一個對象的引用。參考型別規定了對象間的相互作用。當你考慮一個對象能做什麼的時候,只需搞明白他的類型,而不需要去考慮他的實現細節。
Java介面
以上所談到的多態行為用到了類的繼承關係所建立起來的子類型關係。Java介面同樣支援使用者定義的類型,相對地,Java的介面機制啟動了建立在類型階層上的多態行為。假設一個名為ref的引用變數,並使其指向一個包含一下方法的類對象:
public String poly2( IType iType )
{
return iType.m3();
}
為了弄明白poly2(IType)中的多態,以下的代碼從不同的類建立兩個對象,並分別把他們傳給poly2(IType):
Derived2 derived2 = new Derived2();
Separate separate = new Separate();
String tmp;
tmp = ref.poly2( derived2 ); // tmp is Derived.m3()
tmp = ref.poly2( separate ); // tmp is Separate.m3()
上面的代碼類似於關於poly1(Base)中的多態的討論。poly2(IType)的實現代碼是調用每個對象的本地版本的m3()方法。如同以前,代碼的注釋表明了每次調用所返回的CString類型的結果。圖5表明了兩次調用poly2(IType)的概念結構:
圖5:指向Derived2和Separate對象的IType引用
方法poly1(Base)和poly2(IType)中所表現的多態行為的相似之處可以從透視圖中直接看出來。把我們在實現在一層上的理解再提高一層,就可以看到這兩段代碼的技巧。基類的引用指向了作為參數傳進的類,並且按照類型的限制調用對象的方法。引用既不知道也不關心執行哪一段代碼。編譯期間的子類型關係檢查保證了通過的對象有能力在被調用的時候選擇合適的實現代碼。
然而,他們在實現層上有一個重要的差別。在 poly1(Base)的例子中(圖3和圖4),Base-Derived-Derived2的類繼承結構為子類型關係的建立提供了條件,並決定了方法去調用哪段代碼。在poly2(IType)的例子中(5),則是完全不同的動態發生的。Derived2和Separate不共用任何實現的層次,但是他們還是通過IType的引用展示了多態的行為。
這樣的多態行為使Java的介面的功能的重大意義顯得很明顯。圖1中的UML類圖說明了Derived是Base和IType的子類型。通過完全脫離實現細節的類型的定義方法,Java實現了多類型繼承,並且不存在Java所禁止的多繼承所帶來的煩人的問題。完全脫離實現層次的類可以按照Java介面實現分組。在圖1中,介面IType和 Derived,Separate以及這類型的其他子類型應該劃為一組。
按照這種完全不同於實現層次的分類方法,Java的介面機制是多態變得很方便,哪怕不存在任何共用的實現或者複寫的方法。5所示,一個IType的引用,用多態的方法訪問到了Derived2和Separate對象的m3()方法。
再次探討對象的介面
注意圖5中的Derived2和Separate對象的對 m1()的映射方法。如前所述,每一個對象的介面都包含方法m1()。但卻沒有辦法用這兩個對象使方法m1()表現出多態的行為。每一個對象佔有一個 m1()方法是不夠的。必須存在一個可以操作m1()方法的類型,通過這個類型可以看到對象。這些對象似乎是共用了m1()方法,但在沒有共同基類的條件下,多態是不可能的。通過對象的介面來看多態,會把這個概念搞混。
結論
從全文所述的物件導向多態所建立起來的子類型多態,你可以清楚地認識到這種面向類型的觀點。如果你想理解子類型多態的思想,就應該把注意力從實現的細節轉移到類型的上。類型把對象分成組,並且管理著這些對象的介面。類型的繼承階層決定了實現多態所需的類型關係。
有趣的是,實現的細節並不影響子類型多態的階層。類型決定了對象調用什麼方法,而實現則決定了對象怎麼執行這個方法。也就是說,類型表明了責任,而負責實施的則是具體的實現。將實現和類型分離後,我們好像看到了這兩個部分在一起跳舞,類型決定了他的舞伴和舞蹈的名字,而實現則是舞蹈動作的設計師。