本章概要
- 構造器和多態
- 構造器調用順序
- 繼承和清理
- 構造器內部多態方法的行為
- 協變返回類型
- 使用繼承設計
- 替代 vs 擴展
- 向下轉型與運行時類型信息
構造器和多態
通常,構造器不同于其他類型的方法。在涉及多態時也是如此。盡管構造器不具有多態性(事實上人們會把它看作是隱式聲明的靜態方法),但是理解構造器在復雜層次結構中運作多態還是非常重要的。理解這個可以幫助你避免一些不愉快的困擾。
構造器調用順序
在“初始化和清理”和“復用”兩章中已經簡單地介紹過構造器的調用順序,但那時還沒有介紹多態。
在派生類的構造過程中總會調用基類的構造器。初始化會自動按繼承層次結構上移,因此每個基類的構造器都會被調用到。這么做是有意義的,因為構造器有著特殊的任務:檢查對象是否被正確地構造。由于屬性通常聲明為 private,你必須假定派生類只能訪問自己的成員而不能訪問基類的成員。只有基類的構造器擁有恰當的知識和權限來初始化自身的元素。因此,必須得調用所有構造器;否則就不能構造完整的對象。這就是為什么編譯器會強制調用每個派生類中的構造器的原因。如果在派生類的構造器主體中沒有顯式地調用基類構造器,編譯器就會默默地調用無參構造器。如果沒有無參構造器,編譯器就會報錯(當類中不含構造器時,編譯器會自動合成一個無參構造器)。
下面的例子展示了組合、繼承和多態在構建順序上的作用:
// polymorphism/Sandwich.java
// Order of constructor calls
// {java polymorphism.Sandwich}
package polymorphism;class Meal {Meal() {System.out.println("Meal()");}
}class Bread {Bread() {System.out.println("Bread()");}
}class Cheese {Cheese() {System.out.println("Cheese()");}
}class Lettuce {Lettuce() {System.out.println("Lettuce()");}
}class Lunch extends Meal {Lunch() {System.out.println("Lunch()");}
}class PortableLunch extends Lunch {PortableLunch() {System.out.println("PortableLunch()");}
}public class Sandwich extends PortableLunch {private Bread b = new Bread();private Cheese c = new Cheese();private Lettuce l = new Lettuce();public Sandwich() {System.out.println("Sandwich()");}public static void main(String[] args) {new Sandwich();}
}
輸出:
Meal()
Lunch()
PortableLunch()
Bread()
Cheese()
Lettuce()
Sandwich()
這個例子用其他類創建了一個復雜的類。每個類都在構造器中聲明自己。重要的類是 Sandwich,它反映了三層繼承(如果算上 Object 的話,就是四層),包含了三個成員對象。
從創建 Sandwich 對象的輸出中可以看出對象的構造器調用順序如下:
- 基類構造器被調用。這個步驟被遞歸地重復,這樣一來類層次的頂級父類會被最先構造,然后是它的派生類,以此類推,直到最底層的派生類。
- 按聲明順序初始化成員。
- 調用派生類構造器的方法體。
構造器的調用順序很重要。當使用繼承時,就已經知道了基類的一切,并可以訪問基類中任意 public 和 protected 的成員。這意味著在派生類中可以假定所有的基類成員都是有效的。在一個標準方法中,構造動作已經發生過,對象其他部分的所有成員都已經創建好。
在構造器中必須確保所有的成員都已經構建完。唯一能保證這點的方法就是首先調用基類的構造器。接著,在派生類的構造器中,所有你可以訪問的基類成員都已經初始化。另一個在構造器中能知道所有成員都是有效的理由是:無論何時有可能的話,你應該在所有成員對象(通過組合將對象置于類中)定義處初始化它們(例如,例子中的 b、c 和 l)。如果遵循這條實踐,就可以幫助確保所有的基類成員和當前對象的成員對象都已經初始化。
不幸的是,這不能處理所有情況,在下一節會看到。
繼承和清理
在使用組合和繼承創建新類時,大部分時候你無需關心清理。子對象通常會留給垃圾收集器處理。如果你存在清理問題,那么必須用心地為新類創建一個 dispose()
方法(這里用的是我選擇的名稱,你可以使用更好的名稱)。由于繼承,如果有其他特殊的清理工作的話,就必須在派生類中重寫 dispose()
方法。當重寫 dispose()
方法時,記得調用基類的 dispose()
方法,否則基類的清理工作不會發生:
// polymorphism/Frog.java
// Cleanup and inheritance
// {java polymorphism.Frog}
package polymorphism;class Characteristic {private String s;Characteristic(String s) {this.s = s;System.out.println("Creating Characteristic " + s);}protected void dispose() {System.out.println("disposing Characteristic " + s);}
}class Description {private String s;Description(String s) {this.s = s;System.out.println("Creating Description " + s);}protected void dispose() {System.out.println("disposing Description " + s);}
}class LivingCreature {private Characteristic p = new Characteristic("is alive");private Description t = new Description("Basic Living Creature");LivingCreature() {System.out.println("LivingCreature()");}protected void dispose() {System.out.println("LivingCreature dispose");t.dispose();p.dispose();}
}class Animal extends LivingCreature {private Characteristic p = new Characteristic("has heart");private Description t = new Description("Animal not Vegetable");Animal() {System.out.println("Animal()");}@Overrideprotected void dispose() {System.out.println("Animal dispose");t.dispose();p.dispose();super.dispose();}
}class Amphibian extends Animal {private Characteristic p = new Characteristic("can live in water");private Description t = new Description("Both water and land");Amphibian() {System.out.println("Amphibian()");}@Overrideprotected void dispose() {System.out.println("Amphibian dispose");t.dispose();p.dispose();super.dispose();}
}public class Frog extends Amphibian {private Characteristic p = new Characteristic("Croaks");private Description t = new Description("Eats Bugs");public Frog() {System.out.println("Frog()");}@Overrideprotected void dispose() {System.out.println("Frog dispose");t.dispose();p.dispose();super.dispose();}public static void main(String[] args) {Frog frog = new Frog();System.out.println("Bye!");frog.dispose();}
}
輸出:
層級結構中的每個類都有 Characteristic 和 Description 兩個類型的成員對象,它們必須得被銷毀。銷毀的順序應該與初始化的順序相反,以防一個對象依賴另一個對象。對于屬性來說,就意味著與聲明的順序相反(因為屬性是按照聲明順序初始化的)。對于基類(遵循 C++ 析構函數的形式),首先進行派生類的清理工作,然后才是基類的清理。這是因為派生類的清理可能調用基類的一些方法,所以基類組件這時得存活,不能過早地被銷毀。輸出顯示了,Frog 對象的所有部分都是按照創建的逆序銷毀的。
盡管通常不必進行清理工作,但萬一需要時,就得謹慎小心地執行。
Frog 對象擁有自己的成員對象,它創建了這些成員對象,并且知道它們能存活多久,所以它知道何時調用 dispose()
方法。然而,一旦某個成員對象被其它一個或多個對象共享時,問題就變得復雜了,不能只是簡單地調用 dispose()
。這里,也許就必須使用_引用計數_來跟蹤仍然訪問著共享對象的對象數量,如下:
// polymorphism/ReferenceCounting.java
// Cleaning up shared member objects
class Shared {private int refcount = 0;private static long counter = 0;private final long id = counter++;Shared() {System.out.println("Creating " + this);}public void addRef() {refcount++;}protected void dispose() {if (--refcount == 0) {System.out.println("Disposing " + this);}}@Overridepublic String toString() {return "Shared " + id;}
}class Composing {private Shared shared;private static long counter = 0;private final long id = counter++;Composing(Shared shared) {System.out.println("Creating " + this);this.shared = shared;this.shared.addRef();}protected void dispose() {System.out.println("disposing " + this);shared.dispose();}@Overridepublic String toString() {return "Composing " + id;}
}public class ReferenceCounting {public static void main(String[] args) {Shared shared = new Shared();Composing[] composing = {new Composing(shared),new Composing(shared),new Composing(shared),new Composing(shared),new Composing(shared),};for (Composing c: composing) {c.dispose();}}
}
輸出:
static long counter 跟蹤所創建的 Shared 實例數量,還提供了 id 的值。counter 的類型是 long 而不是 int,以防溢出(這只是個良好實踐,對于本書的所有示例,counter 不會溢出)。id 是 final 的,因為它的值在初始化時確定后不應該變化。
在將一個 shared 對象附著在類上時,必須記住調用 addRef()
,而 dispose()
方法會跟蹤引用數,以確定在何時真正地執行清理工作。使用這種技巧需要加倍細心,但是如果需要清理正在共享的對象,你沒有太多選擇。
構造器內部多態方法的行為
構造器調用的層次結構帶來了一個困境。如果在構造器中調用了正在構造的對象的動態綁定方法,會發生什么呢?
在普通的方法中,動態綁定的調用是在運行時解析的,因為對象不知道它屬于方法所在的類還是類的派生類。
如果在構造器中調用了動態綁定方法,就會用到那個方法的重寫定義。然而,調用的結果難以預料因為被重寫的方法在對象被完全構造出來之前已經被調用,這使得一些 bug 很隱蔽,難以發現。
從概念上講,構造器的工作就是創建對象(這并非是平常的工作)。在構造器內部,整個對象可能只是部分形成——只知道基類對象已經初始化。如果構造器只是構造對象過程中的一個步驟,且構造的對象所屬的類是從構造器所屬的類派生出的,那么派生部分在當前構造器被調用時還沒有初始化。然而,一個動態綁定的方法調用向外深入到繼承層次結構中,它可以調用派生類的方法。如果你在構造器中這么做,就可能調用一個方法,該方法操縱的成員可能還沒有初始化——這肯定會帶來災難。
下面例子展示了這個問題:
// polymorphism/PolyConstructors.java
// Constructors and polymorphism
// don't produce what you might expect
class Glyph {void draw() {System.out.println("Glyph.draw()");}Glyph() {System.out.println("Glyph() before draw()");draw();System.out.println("Glyph() after draw()");}
}class RoundGlyph extends Glyph {private int radius = 1;RoundGlyph(int r) {radius = r;System.out.println("RoundGlyph.RoundGlyph(), radius = " + radius);}@Overridevoid draw() {System.out.println("RoundGlyph.draw(), radius = " + radius);}
}public class PolyConstructors {public static void main(String[] args) {new RoundGlyph(5);}
}
輸出:
Glyph() before draw()
RoundGlyph.draw(), radius = 0
Glyph() after draw()
RoundGlyph.RoundGlyph(), radius = 5
Glyph 的 draw()
被設計為可重寫,在 RoundGlyph 這個方法被重寫。但是 Glyph 的構造器里調用了這個方法,結果調用了 RoundGlyph 的 draw()
方法,這看起來正是我們的目的。輸出結果表明,當 Glyph 構造器調用了 draw()
時,radius 的值不是默認初始值 1 而是 0。這可能會導致在屏幕上只畫了一個點或干脆什么都不畫,于是我們只能干瞪眼,試圖找到程序不工作的原因。
前一小節描述的初始化順序并不十分完整,而這正是解決謎團的關鍵所在。初始化的實際過程是:
- 在所有事發生前,分配給對象的存儲空間會被初始化為二進制 0。
- 如前所述調用基類構造器。此時調用重寫后的
draw()
方法(是的,在調用 RoundGraph 構造器之前調用),由步驟 1 可知,radius 的值為 0。 - 按聲明順序初始化成員。
- 最終調用派生類的構造器。
這么做有個優點:所有事物至少初始化為 0(或某些特殊數據類型與 0 等價的值),而不是僅僅留作垃圾。這包括了通過組合嵌入類中的對象引用,被賦予 null。如果忘記初始化該引用,就會在運行時出現異常。觀察輸出結果,就會發現所有事物都是 0。
另一方面,應該震驚于輸出結果。邏輯方面我們已經做得非常完美,然而行為仍不可思議的錯了,編譯器也沒有報錯(C++ 在這種情況下會產生更加合理的行為)。像這樣的 bug 很容易被忽略,需要花很長時間才能發現。
因此,編寫構造器有一條良好規范:做盡量少的事讓對象進入良好狀態。如果有可能的話,盡量不要調用類中的任何方法。在基類的構造器中能安全調用的只有基類的 final 方法(這也適用于可被看作是 final 的 private 方法)。這些方法不能被重寫,因此不會產生意想不到的結果。你可能無法永遠遵循這條規范,但應該朝著它努力。
協變返回類型
Java 5 中引入了協變返回類型,這表示派生類的被重寫方法可以返回基類方法返回類型的派生類型:
// polymorphism/CovariantReturn.java
class Grain {@Overridepublic String toString() {return "Grain";}
}class Wheat extends Grain {@Overridepublic String toString() {return "Wheat";}
}class Mill {Grain process() {return new Grain();}
}class WheatMill extends Mill {@OverrideWheat process() {return new Wheat();}
}public class CovariantReturn {public static void main(String[] args) {Mill m = new Mill();Grain g = m.process();System.out.println(g);m = new WheatMill();g = m.process();System.out.println(g);}
}
輸出:
Grain
Wheat
關鍵區別在于 Java 5 之前的版本強制要求被重寫的 process()
方法必須返回 Grain 而不是 Wheat,即使 Wheat 派生自 Grain,因而也應該是一種合法的返回類型。協變返回類型允許返回更具體的 Wheat 類型。
使用繼承設計
學習過多態之后,一切看似都可以被繼承,因為多態是如此巧妙的工具。這會給設計帶來負擔。事實上,如果利用已有類創建新類首先選擇繼承的話,事情會變得莫名的復雜。
更好的方法是首先選擇組合,特別是不知道該使用哪種方法時。組合不會強制設計是繼承層次結構,而且組合更加靈活,因為可以動態地選擇類型(因而選擇相應的行為),而繼承要求必須在編譯時知道確切類型。下面例子說明了這點:
// polymorphism/Transmogrify.java
// Dynamically changing the behavior of an object
// via composition (the "State" design pattern)
class Actor {public void act() {}
}class HappyActor extends Actor {@Overridepublic void act() {System.out.println("HappyActor");}
}class SadActor extends Actor {@Overridepublic void act() {System.out.println("SadActor");}
}class Stage {private Actor actor = new HappyActor();public void change() {actor = new SadActor();}public void performPlay() {actor.act();}
}public class Transmogrify {public static void main(String[] args) {Stage stage = new Stage();stage.performPlay();stage.change();stage.performPlay();}
}
輸出:
HappyActor
SadActor
Stage 對象中包含了 Actor 引用,該引用被初始化為指向一個 HappyActor 對象,這意味著 performPlay()
會產生一個特殊行為。但是既然引用可以在運行時與其他不同的對象綁定,那么它就可以被替換成對 SadActor 的引用,performPlay()
的行為隨之改變。這樣你就獲得了運行時的動態靈活性(這被稱為狀態模式)。與之相反,我們無法在運行時才決定繼承不同的對象;那在編譯時就完全決定好了。
有一條通用準則:使用繼承表達行為的差異,使用屬性表達狀態的變化。在上個例子中,兩者都用到了。通過繼承得到的兩個不同類在 act()
方法中表達了不同的行為,Stage 通過組合使自己的狀態發生變化。這里狀態的改變產生了行為的改變。
替代 vs 擴展
采用“純粹”的方式創建繼承層次結構看上去是最清晰的方法。即只有基類的方法才能在派生類中被重寫,就像下圖這樣:
這被稱作純粹的“is - a"關系,因為類的接口已經確定了它是什么。繼承可以確保任何派生類都擁有基類的接口,絕對不會少。如果按圖上這么做,派生類將只擁有基類的接口。
純粹的替代意味著派生類可以完美地替代基類,當使用它們時,完全不需要知道這些子類的信息。也就是說,基類可以接收任意發送給派生類的消息,因為它們具有完全相同的接口。只需將派生類向上轉型,不要關注對象的具體類型。所有一切都可以通過多態處理。
按這種方式思考,似乎只有純粹的“is - a”關系才是唯一明智的做法,其他任何設計只會導致混亂且注定失敗。這其實也是個陷阱。一旦按這種方式開始思考,就會轉而發現繼承擴展接口(遺憾的是,extends 關鍵字似乎慫恿我們這么做)才是解決特定問題的完美方案。這可以稱為“is - like - a” 關系,因為派生類就像是基類——它有著相同的基本接口,但還具有需要額外方法實現的其他特性:
雖然這是一種有用且明智的方法(依賴具體情況),但是也存在缺點。派生類中接口的擴展部分在基類中不存在(不能通過基類訪問到這些擴展接口),因此一旦向上轉型,就不能通過基類調用這些新方法:
如果不向上轉型,就不會遇到這個問題。但是通常情況下,我們需要重新查明對象的確切類型,從而能夠訪問該類型中的擴展方法。下一節說明如何做到這點。
向下轉型與運行時類型信息
由于向上轉型(在繼承層次中向上移動)會丟失具體的類型信息,那么為了重新獲取類型信息,就需要在繼承層次中向下移動,使用_向下轉型_。
向上轉型永遠是安全的,因為基類不會具有比派生類更多的接口。因此,每條發送給基類接口的消息都能被接收。但是對于向下轉型,你無法知道一個形狀是圓,它有可能是三角形、正方形或其他一些類型。
為了解決這個問題,必須得有某種方法確保向下轉型是正確的,防止意外轉型到一個錯誤類型,進而發送對象無法接收的消息。這么做是不安全的。
在某些語言中(如 C++),必須執行一個特殊的操作來獲得安全的向下轉型,但是在 Java 中,每次轉型都會被檢查!所以即使只是進行一次普通的加括號形式的類型轉換,在運行時這個轉換仍會被檢查,以確保它的確是希望的那種類型。如果不是,就會得到 ClassCastException (類轉型異常)。這種在運行時檢查類型的行為稱作運行時類型信息。下面例子展示了 RTTI 的行為:
// polymorphism/RTTI.java
// Downcasting & Runtime type information (RTTI)
// {ThrowsException}
class Useful {public void f() {}public void g() {}
}class MoreUseful extends Useful {@Overridepublic void f() {}@Overridepublic void g() {}public void u() {}public void v() {}public void w() {}
}public class RTTI {public static void main(String[] args) {Useful[] x = {new Useful(),new MoreUseful()};x[0].f();x[1].g();// Compile time: method not found in Useful://- x[1].u();((MoreUseful) x[1]).u(); // Downcast/RTTI((MoreUseful) x[0]).u(); // Exception thrown}
}
輸出:
Exception in thread "main"
java.lang.ClassCastException: Useful cannot be cast to
MoreUseful
at RTTI.main
正如前面類圖所示,MoreUseful 擴展了 Useful 的接口。而 MoreUseful 也繼承了 Useful,所以它可以向上轉型為 Useful。在 main()
方法中可以看到這種情況的發生。因為兩個對象都是 Useful 類型,所以對它們都可以調用 f()
和 g()
方法。如果試圖調用 u()
方法(只存在于 MoreUseful 中),就會得到編譯時錯誤信息。
為了訪問 MoreUseful 對象的擴展接口,就得嘗試向下轉型。如果轉型為正確的類型,就轉型成功。否則,就會得到 ClassCastException 異常。你不必為這個異常編寫任何特殊代碼,因為它指出了程序員在程序的任何地方都可能犯的錯誤。{ThrowsException} 注釋標簽告知本書的構建系統:在運行程序時,預期拋出一個異常。
RTTI 不僅僅包括簡單的轉型。例如,它還提供了一種方法,使你可以在試圖向下轉型前檢查所要處理的類型。“類型信息”一章中會詳細闡述運行時類型信息的方方面面。