“對於面向對象的程序設計語言,多型性是第三種最基本的特徵(前兩種是數據抽象和繼承。”
“多態性”(Polymorphism)從另一個角度將接口從具體的實現細節中分離出來,亦即實現了“是什麼”與“怎樣做”兩個模塊的分離。利用多態性的概念,代碼的組織以及可讀性均能獲得改善。此外,還能創建“易於擴展”的程序。無論在項目的創建過程中,還是在需要加入新特性的時候,它們都可以方便地“成長”。
通過合併各種特徵與行為,封裝技術可創建出新的數據類型。通過對具體實現細節的隱藏,可將接口與實現細節分離,使所有細節成為private
(私有)。這種組織方式使那些有程序化編程背景人感覺頗為舒適。但多態性卻涉及對“類型”的分解。通過上一章的學習,大家已知道通過繼承可將一個對象當作它自己的類型或者它自己的基類型對待。這種能力是十分重要的,因為多個類型(從相同的基類型中派生出來)可被當作同一種類型對待。而且只需一段代碼,即可對所有不同的類型進行同樣的處理。利用具有多態性的方法調用,一種類型可將自己與另一種相似的類型區分開,只要它們都是從相同的基類型中派生出來的。這種區分是通過各種方法在行為上的差異實現的,可通過基類實現對那些方法的調用。
在這一章中,大家要由淺入深地學習有關多態性的問題(也叫作動態綁定、推遲綁定或者運行期綁定)。同時舉一些簡單的例子,其中所有無關的部分都已剝除,只保留與多態性有關的代碼。
在第6章,大家已知道可將一個對象作為它自己的類型使用,或者作為它的基類型的一個對象使用。取得一個對象引用,並將其作為基類型引用使用的行為就叫作“向上轉換”——因為繼承樹的畫法是基類位於最上方。
但這樣做也會遇到一個問題,如下例所示(若執行這個程序遇到麻煩,請參考第3章的3.1.2小節“賦值”):
//: Music.java
// Inheritance & upcasting
package c07;
class Note {
private int value;
private Note(int val) { value = val; }
public static final Note
middleC = new Note(0),
cSharp = new Note(1),
cFlat = new Note(2);
} // Etc.
class Instrument {
public void play(Note n) {
System.out.println("Instrument.play()");
}
}
// Wind objects are instruments
// because they have the same interface:
class Wind extends Instrument {
// Redefine interface method:
public void play(Note n) {
System.out.println("Wind.play()");
}
}
public class Music {
public static void tune(Instrument i) {
// ...
i.play(Note.middleC);
}
public static void main(String[] args) {
Wind flute = new Wind();
tune(flute); // Upcasting
}
} ///:~
其中,方法Music.tune()
接收一個Instrument
引用,同時也接收從Instrument
派生出來的所有東西。當一個Wind
引用傳遞給tune()
的時候,就會出現這種情況。此時沒有轉換的必要。這樣做是可以接受的;Instrument
裡的接口必須存在於Wind
中,因為Wind
是從Instrument
裡繼承得到的。從Wind
向Instrument
的向上轉換可能“縮小”那個接口,但不可能把它變得比Instrument
的完整接口還要小。
這個程序看起來也許顯得有些奇怪。為什麼所有人都應該有意忘記一個對象的類型呢?進行向上轉換時,就可能產生這方面的疑惑。而且如果讓tune()
簡單地取得一個Wind
引用,將其作為自己的參數使用,似乎會更加簡單、直觀得多。但要注意:假如那樣做,就需為系統內Instrument
的每種類型寫一個全新的tune()
。假設按照前面的推論,加入Stringed
(絃樂)和Brass
(銅管)這兩種Instrument
(樂器):
//: Music2.java
// Overloading instead of upcasting
class Note2 {
private int value;
private Note2(int val) { value = val; }
public static final Note2
middleC = new Note2(0),
cSharp = new Note2(1),
cFlat = new Note2(2);
} // Etc.
class Instrument2 {
public void play(Note2 n) {
System.out.println("Instrument2.play()");
}
}
class Wind2 extends Instrument2 {
public void play(Note2 n) {
System.out.println("Wind2.play()");
}
}
class Stringed2 extends Instrument2 {
public void play(Note2 n) {
System.out.println("Stringed2.play()");
}
}
class Brass2 extends Instrument2 {
public void play(Note2 n) {
System.out.println("Brass2.play()");
}
}
public class Music2 {
public static void tune(Wind2 i) {
i.play(Note2.middleC);
}
public static void tune(Stringed2 i) {
i.play(Note2.middleC);
}
public static void tune(Brass2 i) {
i.play(Note2.middleC);
}
public static void main(String[] args) {
Wind2 flute = new Wind2();
Stringed2 violin = new Stringed2();
Brass2 frenchHorn = new Brass2();
tune(flute); // No upcasting
tune(violin);
tune(frenchHorn);
}
} ///:~
這樣做當然行得通,但卻存在一個極大的弊端:必須為每種新增的Instrument2
類編寫與類緊密相關的方法。這意味著第一次就要求多得多的編程量。以後,假如想添加一個象tune()
那樣的新方法或者為Instrument
添加一個新類型,仍然需要進行大量編碼工作。此外,即使忘記對自己的某個方法進行重載設置,編譯器也不會提示任何錯誤。這樣一來,類型的整個操作過程就顯得極難管理,有失控的危險。
但假如只寫一個方法,將基類作為參數使用,而不是使用那些特定的派生類,豈不是會簡單得多?也就是說,如果我們能不顧派生類,只讓自己的代碼與基類打交道,那麼省下的工作量將是難以估計的。
這正是“多態性”大顯身手的地方。然而,大多數程序員(特別是有程序化編程背景的)對於多態性的工作原理仍然顯得有些生疏。
對於Music.java
的困難性,可通過運行程序加以體會。輸出是Wind.play()
。這當然是我們希望的輸出,但它看起來似乎並不願按我們的希望行事。請觀察一下tune()
方法:
public static void tune(Instrument i) {
// ...
i.play(Note.middleC);
}
它接收Instrument
引用。所以在這種情況下,編譯器怎樣才能知道Instrument
引用指向的是一個Wind
,而不是一個Brass
或Stringed
呢?編譯器無從得知。為了深入了理解這個問題,我們有必要探討一下“綁定”這個主題。
將一個方法調用同一個方法主體連接到一起就稱為“綁定”(Binding)。若在程序運行以前執行綁定(由編譯器和鏈接程序,如果有的話),就叫作“早期綁定”。大家以前或許從未聽說過這個術語,因為它在任何程序化語言裡都是不可能的。C編譯器只有一種方法調用,那就是“早期綁定”。
上述程序最令人迷惑不解的地方全與早期綁定有關,因為在只有一個Instrument
引用的前提下,編譯器不知道具體該調用哪個方法。
解決的方法就是“後期綁定”,它意味著綁定在運行期間進行,以對象的類型為基礎。後期綁定也叫作“動態綁定”或“運行期綁定”。若一種語言實現了後期綁定,同時必須提供一些機制,可在運行期間判斷對象的類型,並分別調用適當的方法。也就是說,編譯器此時依然不知道對象的類型,但方法調用機制能自己去調查,找到正確的方法主體。不同的語言對後期綁定的實現方法是有所區別的。但我們至少可以這樣認為:它們都要在對象中安插某些特殊類型的信息。
Java中綁定的所有方法都採用後期綁定技術,除非一個方法已被聲明成final
。這意味著我們通常不必決定是否應進行後期綁定——它是自動發生的。
為什麼要把一個方法聲明成final
呢?正如上一章指出的那樣,它能防止其他人覆蓋那個方法。但也許更重要的一點是,它可有效地“關閉”動態綁定,或者告訴編譯器不需要進行動態綁定。這樣一來,編譯器就可為final
方法調用生成效率更高的代碼。
知道Java裡綁定的所有方法都通過後期綁定具有多態性以後,就可以相應地編寫自己的代碼,令其與基類溝通。此時,所有的派生類都保證能用相同的代碼正常地工作。或者換用另一種方法,我們可以“將一條消息發給一個對象,讓對象自行判斷要做什麼事情。”
在面向對象的程序設計中,有一個經典的“形狀”例子。由於它很容易用可視化的形式表現出來,所以經常都用它說明問題。但很不幸的是,它可能誤導初學者認為OOP只是為圖形化編程設計的,這種認識當然是錯誤的。
形狀例子有一個基類,名為Shape
;另外還有大量派生類型:Circle
(圓形),Square
(方形),Triangle
(三角形)等等。大家之所以喜歡這個例子,因為很容易理解“圓屬於形狀的一種類型”等概念。下面這幅繼承圖向我們展示了它們的關係:
向上轉換可用下面這個語句簡單地表現出來:
Shape s = new Circle();
在這裡,我們創建了Circle
對象,並將結果引用立即賦給一個Shape
。這表面看起來似乎屬於錯誤操作(將一種類型分配給另一個),但實際是完全可行的——因為按照繼承關係,Circle
屬於Shape
的一種。因此編譯器認可上述語句,不會向我們提示一條出錯消息。
當我們調用其中一個基類方法時(已在派生類裡覆蓋):
s.draw();
同樣地,大家也許認為會調用Shape
的draw()
,因為這畢竟是一個Shape
引用。那麼編譯器怎樣才能知道該做其他任何事情呢?但此時實際調用的是Circle.draw()
,因為後期綁定已經介入(多態性)。
下面這個例子從一個稍微不同的角度說明了問題:
//: Shapes.java
// Polymorphism in Java
class Shape {
void draw() {}
void erase() {}
}
class Circle extends Shape {
void draw() {
System.out.println("Circle.draw()");
}
void erase() {
System.out.println("Circle.erase()");
}
}
class Square extends Shape {
void draw() {
System.out.println("Square.draw()");
}
void erase() {
System.out.println("Square.erase()");
}
}
class Triangle extends Shape {
void draw() {
System.out.println("Triangle.draw()");
}
void erase() {
System.out.println("Triangle.erase()");
}
}
public class Shapes {
public static Shape randShape() {
switch((int)(Math.random() * 3)) {
default: // To quiet the compiler
case 0: return new Circle();
case 1: return new Square();
case 2: return new Triangle();
}
}
public static void main(String[] args) {
Shape[] s = new Shape[9];
// Fill up the array with shapes:
for(int i = 0; i < s.length; i++)
s[i] = randShape();
// Make polymorphic method calls:
for(int i = 0; i < s.length; i++)
s[i].draw();
}
} ///:~
針對從Shape
派生出來的所有東西,Shape
建立了一個通用接口——也就是說,所有(幾何)形狀都可以描繪和刪除。派生類覆蓋了這些定義,為每種特殊類型的幾何形狀都提供了獨一無二的行為。
在主類Shapes
裡,包含了一個static
方法,名為randShape()
。它的作用是在每次調用它時為某個隨機選擇的Shape
對象生成一個引用。請注意向上轉換是在每個return
語句裡發生的。這個語句取得指向一個Circle
,Square
或者Triangle
的引用,並將其作為返回類型Shape
發給方法。所以無論什麼時候調用這個方法,就絕對沒機會了解它的具體類型到底是什麼,因為肯定會獲得一個單純的Shape
引用。
main()
包含了Shape
引用的一個數組,其中的數據通過對randShape()
的調用填入。在這個時候,我們知道自己擁有Shape
,但不知除此之外任何具體的情況(編譯器同樣不知)。然而,當我們在這個數組裡步進,併為每個元素調用draw()
的時候,與各類型有關的正確行為會魔術般地發生,就象下面這個輸出示例展示的那樣:
Circle.draw()
Triangle.draw()
Circle.draw()
Circle.draw()
Circle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Square.draw()
當然,由於幾何形狀是每次隨機選擇的,所以每次運行都可能有不同的結果。之所以要突出形狀的隨機選擇,是為了讓大家深刻體會這一點:為了在編譯的時候發出正確的調用,編譯器毋需獲得任何特殊的情報。對draw()
的所有調用都是通過動態綁定進行的。
現在,讓我們仍然返回樂器(Instrument
)示例。由於存在多態性,所以可根據自己的需要向系統里加入任意多的新類型,同時毋需更改true()
方法。在一個設計良好的OOP程序中,我們的大多數或者所有方法都會遵從tune()
的模型,而且只與基類接口通信。我們說這樣的程序具有“擴展性”,因為可以從通用的基類繼承新的數據類型,從而新添一些功能。如果是為了適應新類的要求,那麼對基類接口進行操縱的方法根本不需要改變,對於樂器例子,假設我們在基類里加入更多的方法,以及一系列新類,那麼會出現什麼情況呢?下面是示意圖:
所有這些新類都能與老類——tune()
默契地工作,毋需對tune()
作任何調整。即使tune()
位於一個獨立的文件裡,而將新方法添加到Instrument
的接口,tune()
也能正確地工作,不需要重新編譯。下面這個程序是對上述示意圖的具體實現:
//: Music3.java
// An extensible program
import java.util.*;
class Instrument3 {
public void play() {
System.out.println("Instrument3.play()");
}
public String what() {
return "Instrument3";
}
public void adjust() {}
}
class Wind3 extends Instrument3 {
public void play() {
System.out.println("Wind3.play()");
}
public String what() { return "Wind3"; }
public void adjust() {}
}
class Percussion3 extends Instrument3 {
public void play() {
System.out.println("Percussion3.play()");
}
public String what() { return "Percussion3"; }
public void adjust() {}
}
class Stringed3 extends Instrument3 {
public void play() {
System.out.println("Stringed3.play()");
}
public String what() { return "Stringed3"; }
public void adjust() {}
}
class Brass3 extends Wind3 {
public void play() {
System.out.println("Brass3.play()");
}
public void adjust() {
System.out.println("Brass3.adjust()");
}
}
class Woodwind3 extends Wind3 {
public void play() {
System.out.println("Woodwind3.play()");
}
public String what() { return "Woodwind3"; }
}
public class Music3 {
// Doesn't care about type, so new types
// added to the system still work right:
static void tune(Instrument3 i) {
// ...
i.play();
}
static void tuneAll(Instrument3[] e) {
for(int i = 0; i < e.length; i++)
tune(e[i]);
}
public static void main(String[] args) {
Instrument3[] orchestra = new Instrument3[5];
int i = 0;
// Upcasting during addition to the array:
orchestra[i++] = new Wind3();
orchestra[i++] = new Percussion3();
orchestra[i++] = new Stringed3();
orchestra[i++] = new Brass3();
orchestra[i++] = new Woodwind3();
tuneAll(orchestra);
}
} ///:~
新方法是what()
和adjust()
。前者返回一個String
引用,同時返回對那個類的說明;後者使我們能對每種樂器進行調整。
在main()
中,當我們將某樣東西置入Instrument3
數組時,就會自動向上轉換到Instrument3
。
可以看到,在圍繞tune()
方法的其他所有代碼都發生變化的同時,tune()
方法卻絲毫不受它們的影響,依然故我地正常工作。這正是利用多態性希望達到的目標。我們對代碼進行修改後,不會對程序中不應受到影響的部分造成影響。此外,我們認為多態性是一種至關重要的技術,它允許程序員“將發生改變的東西同沒有發生改變的東西區分開”。
現在讓我們用不同的眼光來看看本章的頭一個例子。在下面這個程序中,方法play()
的接口會在被覆蓋的過程中發生變化。這意味著我們實際並沒有“覆蓋”方法,而是使其“重載”。編譯器允許我們對方法進行重載處理,使其不報告出錯。但這種行為可能並不是我們所希望的。下面是這個例子:
//: WindError.java
// Accidentally changing the interface
class NoteX {
public static final int
MIDDLE_C = 0, C_SHARP = 1, C_FLAT = 2;
}
class InstrumentX {
public void play(int NoteX) {
System.out.println("InstrumentX.play()");
}
}
class WindX extends InstrumentX {
// OOPS! Changes the method interface:
public void play(NoteX n) {
System.out.println("WindX.play(NoteX n)");
}
}
public class WindError {
public static void tune(InstrumentX i) {
// ...
i.play(NoteX.MIDDLE_C);
}
public static void main(String[] args) {
WindX flute = new WindX();
tune(flute); // Not the desired behavior!
}
} ///:~
這裡還向大家引入了另一個易於混淆的概念。在InstrumentX
中,play()
方法採用了一個int
(整數)數值,它的標識符是NoteX
。也就是說,即使NoteX
是一個類名,也可以把它作為一個標識符使用,編譯器不會報告出錯。但在WindX
中,play()
採用一個NoteX
引用,它有一個標識符n
。即便我們使用play(NoteX NoteX)
,編譯器也不會報告錯誤。這樣一來,看起來就象是程序員有意覆蓋play()
的功能,但對方法的類型定義卻稍微有些不確切。然而,編譯器此時假定的是程序員有意進行“重載”,而非“覆蓋”。請仔細體會這兩個術語的區別。“重載”是指同一樣東西在不同的地方具有多種含義;而“覆蓋”是指它隨時隨地都只有一種含義,只是原先的含義完全被後來的含義取代了。請注意如果遵守標準的Java命名規範,參數標識符就應該是noteX
,這樣可把它與類名區分開。
在tune
中,InstrumentX i
會發出play()
消息,同時將某個NoteX
成員作為參數使用(MIDDLE_C
)。由於NoteX
包含了int
定義,重載的play()
方法的int
版本會得到調用。同時由於它尚未被“覆蓋”,所以會使用基類版本。
輸出是:
InstrumentX.play()
在我們所有樂器(Instrument
)例子中,基類Instrument
內的方法都肯定是“偽”方法。若去調用這些方法,就會出現錯誤。那是由於Instrument
的意圖是為從它派生出去的所有類都創建一個通用接口。
之所以要建立這個通用接口,唯一的原因就是它能為不同的子類型作出不同的表示。它為我們建立了一種基本形式,使我們能定義在所有派生類裡“通用”的一些東西。為闡述這個觀念,另一個方法是把Instrument
稱為“抽象基類”(簡稱“抽象類”)。若想通過該通用接口處理一系列類,就需要創建一個抽象類。對所有與基類聲明的簽名相符的派生類方法,都可以通過動態綁定機制進行調用(然而,正如上一節指出的那樣,如果方法名與基類相同,但參數不同,就會出現重載現象,那或許並非我們所願意的)。
如果有一個象Instrument
那樣的抽象類,那個類的對象幾乎肯定沒有什麼意義。換言之,Instrument
的作用僅僅是表達接口,而不是表達一些具體的實現細節。所以創建一個Instrument
對象是沒有意義的,而且我們通常都應禁止用戶那樣做。為達到這個目的,可令Instrument
內的所有方法都顯示出錯消息。但這樣做會延遲信息到運行期,並要求在用戶那一面進行徹底、可靠的測試。無論如何,最好的方法都是在編譯期間捕捉到問題。
針對這個問題,Java專門提供了一種機制,名為“抽象方法”。它屬於一種不完整的方法,只含有一個聲明,沒有方法主體。下面是抽象方法聲明時採用的語法:
abstract void X();
包含了抽象方法的一個類叫作“抽象類”。如果一個類裡包含了一個或多個抽象方法,類就必須指定成abstract
(抽象)。否則,編譯器會向我們報告一條出錯消息。
若一個抽象類是不完整的,那麼一旦有人試圖生成那個類的一個對象,編譯器又會採取什麼行動呢?由於不能安全地為一個抽象類創建屬於它的對象,所以會從編譯器那裡獲得一條出錯提示。通過這種方法,編譯器可保證抽象類的“純潔性”,我們不必擔心會誤用它。
如果從一個抽象類繼承,而且想生成新類型的一個對象,就必須為基類中的所有抽象方法提供方法定義。如果不這樣做(完全可以選擇不做),則派生類也會是抽象的,而且編譯器會強迫我們用abstract
關鍵字標誌那個類的“抽象”本質。
即使不包括任何abstract
方法,亦可將一個類聲明成“抽象類”。如果一個類沒必要擁有任何抽象方法,而且我們想禁止那個類的所有實例,這種能力就會顯得非常有用。
Instrument
類可很輕鬆地轉換成一個抽象類。只有其中一部分方法會變成抽象方法,因為使一個類抽象以後,並不會強迫我們將它的所有方法都同時變成抽象。下面是它看起來的樣子:
下面是我們修改過的“管絃”樂器例子,其中採用了抽象類以及方法:
//: Music4.java
// Abstract classes and methods
import java.util.*;
abstract class Instrument4 {
int i; // storage allocated for each
public abstract void play();
public String what() {
return "Instrument4";
}
public abstract void adjust();
}
class Wind4 extends Instrument4 {
public void play() {
System.out.println("Wind4.play()");
}
public String what() { return "Wind4"; }
public void adjust() {}
}
class Percussion4 extends Instrument4 {
public void play() {
System.out.println("Percussion4.play()");
}
public String what() { return "Percussion4"; }
public void adjust() {}
}
class Stringed4 extends Instrument4 {
public void play() {
System.out.println("Stringed4.play()");
}
public String what() { return "Stringed4"; }
public void adjust() {}
}
class Brass4 extends Wind4 {
public void play() {
System.out.println("Brass4.play()");
}
public void adjust() {
System.out.println("Brass4.adjust()");
}
}
class Woodwind4 extends Wind4 {
public void play() {
System.out.println("Woodwind4.play()");
}
public String what() { return "Woodwind4"; }
}
public class Music4 {
// Doesn't care about type, so new types
// added to the system still work right:
static void tune(Instrument4 i) {
// ...
i.play();
}
static void tuneAll(Instrument4[] e) {
for(int i = 0; i < e.length; i++)
tune(e[i]);
}
public static void main(String[] args) {
Instrument4[] orchestra = new Instrument4[5];
int i = 0;
// Upcasting during addition to the array:
orchestra[i++] = new Wind4();
orchestra[i++] = new Percussion4();
orchestra[i++] = new Stringed4();
orchestra[i++] = new Brass4();
orchestra[i++] = new Woodwind4();
tuneAll(orchestra);
}
} ///:~
可以看出,除基類以外,實際並沒有進行什麼改變。
創建抽象類和方法有時對我們非常有用,因為它們使一個類的抽象變成明顯的事實,可明確告訴用戶和編譯器自己打算如何用它。
interface
(接口)關鍵字使抽象的概念更深入了一層。我們可將其想象為一個“純”抽象類。它允許創建者規定一個類的基本形式:方法名、參數列表以及返回類型,但不規定方法主體。接口也包含了基本數據類型的數據成員,但它們都默認為static
和final
。接口只提供一種形式,並不提供實現的細節。
接口這樣描述自己:“對於實現我的所有類,看起來都應該象我現在這個樣子”。因此,採用了一個特定接口的所有代碼都知道對於那個接口可能會調用什麼方法。這便是接口的全部含義。所以我們常把接口用於建立類和類之間的一個“協議”。有些面向對象的程序設計語言採用了一個名為protocol
(協議)的關鍵字,它做的便是與接口相同的事情。
為創建一個接口,請使用interface
關鍵字,而不要用class
。與類相似,我們可在interface
關鍵字的前面增加一個public
關鍵字(但只有接口定義於同名的一個文件內);或者將其省略,營造一種“友好的”狀態。
為了生成與一個特定的接口(或一組接口)相符的類,要使用implements
(實現)關鍵字。我們要表達的意思是“接口看起來就象那個樣子,這兒是它具體的工作細節”。除這些之外,我們其他的工作都與繼承極為相似。下面是樂器例子的示意圖:
具體實現了一個接口以後,就獲得了一個普通的類,可用標準方式對其進行擴展。
可決定將一個接口中的方法聲明明確定義為public
。但即便不明確定義,它們也會默認為public
。所以在實現一個接口的時候,來自接口的方法必須定義成public
。否則的話,它們會默認為“友好的”,而且會限制我們在繼承過程中對一個方法的訪問——Java編譯器不允許我們那樣做。
在Instrument
例子的修改版本中,大家可明確地看出這一點。注意接口中的每個方法都嚴格地是一個聲明,它是編譯器唯一允許的。除此以外,Instrument5
中沒有一個方法被聲明為public
,但它們都會自動獲得public
屬性。如下所示:
//: Music5.java
// Interfaces
import java.util.*;
interface Instrument5 {
// Compile-time constant:
int i = 5; // static & final
// Cannot have method definitions:
void play(); // Automatically public
String what();
void adjust();
}
class Wind5 implements Instrument5 {
public void play() {
System.out.println("Wind5.play()");
}
public String what() { return "Wind5"; }
public void adjust() {}
}
class Percussion5 implements Instrument5 {
public void play() {
System.out.println("Percussion5.play()");
}
public String what() { return "Percussion5"; }
public void adjust() {}
}
class Stringed5 implements Instrument5 {
public void play() {
System.out.println("Stringed5.play()");
}
public String what() { return "Stringed5"; }
public void adjust() {}
}
class Brass5 extends Wind5 {
public void play() {
System.out.println("Brass5.play()");
}
public void adjust() {
System.out.println("Brass5.adjust()");
}
}
class Woodwind5 extends Wind5 {
public void play() {
System.out.println("Woodwind5.play()");
}
public String what() { return "Woodwind5"; }
}
public class Music5 {
// Doesn't care about type, so new types
// added to the system still work right:
static void tune(Instrument5 i) {
// ...
i.play();
}
static void tuneAll(Instrument5[] e) {
for(int i = 0; i < e.length; i++)
tune(e[i]);
}
public static void main(String[] args) {
Instrument5[] orchestra = new Instrument5[5];
int i = 0;
// Upcasting during addition to the array:
orchestra[i++] = new Wind5();
orchestra[i++] = new Percussion5();
orchestra[i++] = new Stringed5();
orchestra[i++] = new Brass5();
orchestra[i++] = new Woodwind5();
tuneAll(orchestra);
}
} ///:~
代碼剩餘的部分按相同的方式工作。我們可以自由決定向上轉換到一個名為Instrument5
的“普通”類,一個名為Instrument5
的“抽象”類,或者一個名為Instrument5
的“接口”。所有行為都是相同的。事實上,我們在tune()方
法中可以發現沒有任何證據顯示Instrument5
到底是個“普通”類、“抽象”類還是一個“接口”。這是做是故意的:每種方法都使程序員能對對象的創建與使用進行不同的控制。
接口只是比抽象類“更純”的一種形式。它的用途並不止那些。由於接口根本沒有具體的實現細節——也就是說,沒有與存儲空間與“接口”關聯在一起——所以沒有任何辦法可以防止多個接口合併到一起。這一點是至關重要的,因為我們經常都需要表達這樣一個意思:“x
從屬於a
,也從屬於b
,也從屬於c
”。在C++中,將多個類合併到一起的行動稱作“多重繼承”,而且操作較為不便,因為每個類都可能有一套自己的實現細節。在Java中,我們可採取同樣的行動,但只有其中一個類擁有具體的實現細節。所以在合併多個接口的時候,C++的問題不會在Java中重演。如下所示:
在一個派生類中,我們並不一定要擁有一個抽象或具體(沒有抽象方法)的基類。如果確實想從一個非接口繼承,那麼只能從一個繼承。剩餘的所有基本元素都必須是“接口”。我們將所有接口名置於implements
關鍵字的後面,並用逗號分隔它們。可根據需要使用多個接口,而且每個接口都會成為一個獨立的類型,可對其進行向上轉換。下面這個例子展示了一個“具體”類同幾個接口合併的情況,它最終生成了一個新類:
//: Adventure.java
// Multiple interfaces
import java.util.*;
interface CanFight {
void fight();
}
interface CanSwim {
void swim();
}
interface CanFly {
void fly();
}
class ActionCharacter {
public void fight() {}
}
class Hero extends ActionCharacter
implements CanFight, CanSwim, CanFly {
public void swim() {}
public void fly() {}
}
public class Adventure {
static void t(CanFight x) { x.fight(); }
static void u(CanSwim x) { x.swim(); }
static void v(CanFly x) { x.fly(); }
static void w(ActionCharacter x) { x.fight(); }
public static void main(String[] args) {
Hero i = new Hero();
t(i); // Treat it as a CanFight
u(i); // Treat it as a CanSwim
v(i); // Treat it as a CanFly
w(i); // Treat it as an ActionCharacter
}
} ///:~
從中可以看到,Hero
將具體類ActionCharacter
同接口CanFight
,CanSwim
以及CanFly
合併起來。按這種形式合併一個具體類與接口的時候,具體類必須首先出現,然後才是接口(否則編譯器會報錯)。
請注意fight()
的簽名在CanFight
接口與ActionCharacter
類中是相同的,而且沒有在Hero
中為fight()
提供一個具體的定義。接口的規則是:我們可以從它繼承(稍後就會看到),但這樣得到的將是另一個接口。如果想創建新類型的一個對象,它就必須是已提供所有定義的一個類。儘管Hero
沒有為fight()
明確地提供一個定義,但定義是隨同ActionCharacter
來的,所以這個定義會自動提供,我們可以創建Hero
的對象。
在類Adventure
中,我們可看到共有四個方法,它們將不同的接口和具體類作為自己的參數使用。創建一個Hero
對象後,它可以傳遞給這些方法中的任何一個。這意味著它們會依次向上轉換到每一個接口。由於接口是用Java設計的,所以這樣做不會有任何問題,而且程序員不必對此加以任何特別的關注。
注意上述例子已向我們揭示了接口最關鍵的作用,也是使用接口最重要的一個原因:能向上轉換至多個基類。使用接口的第二個原因與使用抽象基類的原因是一樣的:防止客戶程序員製作這個類的一個對象,以及規定它僅僅是一個接口。這樣便帶來了一個問題:到底應該使用一個接口還是一個抽象類呢?若使用接口,我們可以同時獲得抽象類以及接口的好處。所以假如想創建的基類沒有任何方法定義或者成員變量,那麼無論如何都願意使用接口,而不要選擇抽象類。事實上,如果事先知道某種東西會成為基類,那麼第一個選擇就是把它變成一個接口。只有在必須使用方法定義或者成員變量的時候,才應考慮採用抽象類。
利用繼承技術,可方便地為一個接口添加新的方法聲明,也可以將幾個接口合併成一個新接口。在這兩種情況下,最終得到的都是一個新接口,如下例所示:
//: HorrorShow.java
// Extending an interface with inheritance
interface Monster {
void menace();
}
interface DangerousMonster extends Monster {
void destroy();
}
interface Lethal {
void kill();
}
class DragonZilla implements DangerousMonster {
public void menace() {}
public void destroy() {}
}
interface Vampire
extends DangerousMonster, Lethal {
void drinkBlood();
}
class HorrorShow {
static void u(Monster b) { b.menace(); }
static void v(DangerousMonster d) {
d.menace();
d.destroy();
}
public static void main(String[] args) {
DragonZilla if2 = new DragonZilla();
u(if2);
v(if2);
}
} ///:~
DangerousMonster
是對Monster
的一個簡單的擴展,最終生成了一個新接口。這是在DragonZilla
裡實現的。
Vampire
的語法僅在繼承接口時才可使用。通常,我們只能對單獨一個類應用extends
(擴展)關鍵字。但由於接口可能由多個其他接口構成,所以在構建一個新接口時,extends
可能引用多個基礎接口。正如大家看到的那樣,接口的名字只是簡單地使用逗號分隔。
由於置入一個接口的所有字段都自動具有static
和final
屬性,所以接口是對常數值進行分組的一個好工具,它具有與C或C++的enum
非常相似的效果。如下例所示:
//: Months.java
// Using interfaces to create groups of constants
package c07;
public interface Months {
int
JANUARY = 1, FEBRUARY = 2, MARCH = 3,
APRIL = 4, MAY = 5, JUNE = 6, JULY = 7,
AUGUST = 8, SEPTEMBER = 9, OCTOBER = 10,
NOVEMBER = 11, DECEMBER = 12;
} ///:~
注意根據Java命名規則,擁有固定標識符的static final
基本數據類型(亦即編譯期常數)都全部採用大寫字母(用下劃線分隔單個標識符裡的多個單詞)。
接口中的字段會自動具備public
屬性,所以沒必要專門指定。
現在,通過導入 c07.*
或 c07.Months
,我們可以從包的外部使用常數——就象對其他任何包進行的操作那樣。此外,也可以用類似Months.JANUARY
的表達式對值進行引用。當然,我們獲得的只是一個int
,所以不象C++的enum
那樣擁有額外的類型安全性。但與將數字強行編碼(硬編碼)到自己的程序中相比,這種(常用的)技術無疑已經是一個巨大的進步。我們通常把“硬編碼”數字的行為稱為“魔術數字”,它產生的代碼是非常難以維護的。
如確實不想放棄額外的類型安全性,可構建象下面這樣的一個類(註釋①):
//: Month2.java
// A more robust enumeration system
package c07;
public final class Month2 {
private String name;
private Month2(String nm) { name = nm; }
public String toString() { return name; }
public final static Month2
JAN = new Month2("January"),
FEB = new Month2("February"),
MAR = new Month2("March"),
APR = new Month2("April"),
MAY = new Month2("May"),
JUN = new Month2("June"),
JUL = new Month2("July"),
AUG = new Month2("August"),
SEP = new Month2("September"),
OCT = new Month2("October"),
NOV = new Month2("November"),
DEC = new Month2("December");
public final static Month2[] month = {
JAN, JAN, FEB, MAR, APR, MAY, JUN,
JUL, AUG, SEP, OCT, NOV, DEC
};
public static void main(String[] args) {
Month2 m = Month2.JAN;
System.out.println(m);
m = Month2.month[12];
System.out.println(m);
System.out.println(m == Month2.DEC);
System.out.println(m.equals(Month2.DEC));
}
} ///:~
①:是Rich Hoffarth的一封E-mail觸發了我這樣編寫程序的靈感。
這個類叫作Month2
,因為標準Java庫裡已經有一個Month
。它是一個final
類,並含有一個private
構造器,所以沒有人能從它繼承,或製作它的一個實例。唯一的實例就是那些final static
對象,它們是在類本身內部創建的,包括:JAN
,FEB
,MAR
等等。這些對象也在month
數組中使用,後者讓我們能夠按數字挑選月份,而不是按名字(注意數組中提供了一個多餘的JAN
,使偏移量增加了1,也使December
確實成為12月)。在main()
中,我們可注意到類型的安全性:m
是一個Month2
對象,所以只能將其分配給Month2
。在前面的Months.java
例子中,只提供了int
值,所以本來想用來代表一個月份的int
變量可能實際獲得一個整數值,那樣做可能不十分安全。
這兒介紹的方法也允許我們交換使用==
或者equals()
,就象main()
尾部展示的那樣。
接口中定義的字段會自動具有static
和final
屬性。它們不能是“空白final
”,但可初始化成非常數表達式。例如:
//: RandVals.java
// Initializing interface fields with
// non-constant initializers
import java.util.*;
public interface RandVals {
int rint = (int)(Math.random() * 10);
long rlong = (long)(Math.random() * 10);
float rfloat = (float)(Math.random() * 10);
double rdouble = Math.random() * 10;
} ///:~
由於字段是static
的,所以它們會在首次裝載類之後、以及首次訪問任何字段之前獲得初始化。下面是一個簡單的測試:
//: TestRandVals.java
public class TestRandVals {
public static void main(String[] args) {
System.out.println(RandVals.rint);
System.out.println(RandVals.rlong);
System.out.println(RandVals.rfloat);
System.out.println(RandVals.rdouble);
}
} ///:~
當然,字段並不是接口的一部分,而是保存於那個接口的static
存儲區域中。
在Java 1.1中,可將一個類定義置入另一個類定義中。這就叫作“內部類”。內部類對我們非常有用,因為利用它可對那些邏輯上相互聯繫的類進行分組,並可控制一個類在另一個類裡的“可見性”。然而,我們必須認識到內部類與以前講述的“組合”方法存在著根本的區別。
通常,對內部類的需要並不是特別明顯的,至少不會立即感覺到自己需要使用內部類。在本章的末尾,介紹完內部類的所有語法之後,大家會發現一個特別的例子。通過它應該可以清晰地認識到內部類的好處。
創建內部類的過程是平淡無奇的:將類定義置入一個用於封裝它的類內部(若執行這個程序遇到麻煩,請參見第3章的3.1.2小節“賦值”):
//: Parcel1.java
// Creating inner classes
package c07.parcel1;
public class Parcel1 {
class Contents {
private int i = 11;
public int value() { return i; }
}
class Destination {
private String label;
Destination(String whereTo) {
label = whereTo;
}
String readLabel() { return label; }
}
// Using inner classes looks just like
// using any other class, within Parcel1:
public void ship(String dest) {
Contents c = new Contents();
Destination d = new Destination(dest);
}
public static void main(String[] args) {
Parcel1 p = new Parcel1();
p.ship("Tanzania");
}
} ///:~
若在ship()
內部使用,內部類的使用看起來和其他任何類都沒什麼分別。在這裡,唯一明顯的區別就是它的名字嵌套在Parcel1
裡面。但大家不久就會知道,這其實並非唯一的區別。
更典型的一種情況是,一個外部類擁有一個特殊的方法,它會返回指向一個內部類的引用。就象下面這樣:
//: Parcel2.java
// Returning a handle to an inner class
package c07.parcel2;
public class Parcel2 {
class Contents {
private int i = 11;
public int value() { return i; }
}
class Destination {
private String label;
Destination(String whereTo) {
label = whereTo;
}
String readLabel() { return label; }
}
public Destination to(String s) {
return new Destination(s);
}
public Contents cont() {
return new Contents();
}
public void ship(String dest) {
Contents c = cont();
Destination d = to(dest);
}
public static void main(String[] args) {
Parcel2 p = new Parcel2();
p.ship("Tanzania");
Parcel2 q = new Parcel2();
// Defining handles to inner classes:
Parcel2.Contents c = q.cont();
Parcel2.Destination d = q.to("Borneo");
}
} ///:~
若想在除外部類非static
方法內部之外的任何地方生成內部類的一個對象,必須將那個對象的類型設為外部類名.內部類名
,就象main()
中展示的那樣。
迄今為止,內部類看起來仍然沒什麼特別的地方。畢竟,用它實現隱藏顯得有些大題小做。Java已經有一個非常優秀的隱藏機制——只允許類成為“友好的”(只在一個包內可見),而不是把它創建成一個內部類。
然而,當我們準備向上轉換到一個基類(特別是到一個接口)的時候,內部類就開始發揮其關鍵作用(從用於實現的對象生成一個接口引用具有與向上轉換至一個基類相同的效果)。這是由於內部類隨後可完全進入不可見或不可用狀態——對任何人都將如此。所以我們可以非常方便地隱藏實現細節。我們得到的全部回報就是一個基類或者接口的引用,而且甚至有可能不知道準確的類型。就象下面這樣:
//: Parcel3.java
// Returning a handle to an inner class
package c07.parcel3;
abstract class Contents {
abstract public int value();
}
interface Destination {
String readLabel();
}
public class Parcel3 {
private class PContents extends Contents {
private int i = 11;
public int value() { return i; }
}
protected class PDestination
implements Destination {
private String label;
private PDestination(String whereTo) {
label = whereTo;
}
public String readLabel() { return label; }
}
public Destination dest(String s) {
return new PDestination(s);
}
public Contents cont() {
return new PContents();
}
}
class Test {
public static void main(String[] args) {
Parcel3 p = new Parcel3();
Contents c = p.cont();
Destination d = p.dest("Tanzania");
// Illegal -- can't access private class:
//! Parcel3.PContents c = p.new PContents();
}
} ///:~
現在,Contents
和Destination
代表可由客戶程序員使用的接口(記住接口會將自己的所有成員都變成public
屬性)。為方便起見,它們置於單獨一個文件裡,但原始的Contents
和Destination
在它們自己的文件中是相互public
的。
在Parcel3
中,一些新東西已經加入:內部類PContents
被設為private
,所以除了Parcel3
之外,其他任何東西都不能訪問它。PDestination
被設為protected
,所以除了Parcel3
,Parcel3
包內的類(因為protected
也為包賦予了訪問權;也就是說,protected
也是“友好的”),以及Parcel3
的繼承者之外,其他任何東西都不能訪問PDestination
。這意味著客戶程序員對這些成員的認識與訪問將會受到限制。事實上,我們甚至不能向下轉換到一個private
內部類(或者一個protected
內部類,除非自己本身便是一個繼承者),因為我們不能訪問名字,就象在classTest
裡看到的那樣。所以,利用private
內部類,類設計人員可完全禁止其他人依賴類型編碼,並可將具體的實現細節完全隱藏起來。除此以外,從客戶程序員的角度來看,一個接口的範圍沒有意義的,因為他們不能訪問不屬於公共接口類的任何額外方法。這樣一來,Java編譯器也有機會生成效率更高的代碼。
普通(非內部)類不可設為private
或protected
——只允許public或者“友好的”。
注意Contents
不必成為一個抽象類。在這兒也可以使用一個普通類,但這種設計最典型的起點依然是一個“接口”。
至此,我們已基本理解了內部類的典型用途。對那些涉及內部類的代碼,通常表達的都是“單純”的內部類,非常簡單,且極易理解。然而,內部類的設計非常全面,不可避免地會遇到它們的其他大量用法——假若我們在一個方法甚至一個任意的作用域內創建內部類。有兩方面的原因促使我們這樣做:
(1) 正如前面展示的那樣,我們準備實現某種形式的接口,使自己能創建和返回一個引用。
(2) 要解決一個複雜的問題,並希望創建一個類,用來輔助自己的程序方案。同時不願意把它公開。
在下面這個例子裡,將修改前面的代碼,以便使用:
(1) 在一個方法內定義的類
(2) 在方法的一個作用域內定義的類
(3) 一個匿名類,用於實現一個接口
(4) 一個匿名類,用於擴展擁有非默認構造器的一個類
(5) 一個匿名類,用於執行字段初始化
(6) 一個匿名類,通過實例初始化進行構建(匿名內部類不可擁有構造器)
所有這些都在innerscopes
包內發生。首先,來自前述代碼的通用接口會在它們自己的文件裡獲得定義,使它們能在所有的例子裡使用:
//: Destination.java
package c07.innerscopes;
interface Destination {
String readLabel();
} ///:~
由於我們已認為Contents
可能是一個抽象類,所以可採取下面這種更自然的形式,就象一個接口那樣:
//: Contents.java
package c07.innerscopes;
interface Contents {
int value();
} ///:~
儘管是含有具體實現細節的一個普通類,但Wrapping
也作為它所有派生類的一個通用“接口”使用:
//: Wrapping.java
package c07.innerscopes;
public class Wrapping {
private int i;
public Wrapping(int x) { i = x; }
public int value() { return i; }
} ///:~
在上面的代碼中,我們注意到Wrapping
有一個要求使用參數的構造器,這就使情況變得更加有趣了。
第一個例子展示瞭如何在一個方法的作用域(而不是另一個類的作用域)中創建一個完整的類:
//: Parcel4.java
// Nesting a class within a method
package c07.innerscopes;
public class Parcel4 {
public Destination dest(String s) {
class PDestination
implements Destination {
private String label;
private PDestination(String whereTo) {
label = whereTo;
}
public String readLabel() { return label; }
}
return new PDestination(s);
}
public static void main(String[] args) {
Parcel4 p = new Parcel4();
Destination d = p.dest("Tanzania");
}
} ///:~
PDestination
類屬於dest()
的一部分,而不是Parcel4
的一部分(同時注意可為相同目錄內每個類內部的一個內部類使用類標識符PDestination
,這樣做不會發生命名的衝突)。因此,PDestination
不可從dest()
的外部訪問。請注意在返回語句中發生的向上轉換——除了指向基類Destination
的一個引用之外,沒有任何東西超出dest()
的邊界之外。當然,不能由於類PDestination
的名字置於dest()
內部,就認為在dest()
返回之後PDestination
不是一個有效的對象。
下面這個例子展示瞭如何在任意作用域內嵌套一個內部類:
//: Parcel5.java
// Nesting a class within a scope
package c07.innerscopes;
public class Parcel5 {
private void internalTracking(boolean b) {
if(b) {
class TrackingSlip {
private String id;
TrackingSlip(String s) {
id = s;
}
String getSlip() { return id; }
}
TrackingSlip ts = new TrackingSlip("slip");
String s = ts.getSlip();
}
// Can't use it here! Out of scope:
//! TrackingSlip ts = new TrackingSlip("x");
}
public void track() { internalTracking(true); }
public static void main(String[] args) {
Parcel5 p = new Parcel5();
p.track();
}
} ///:~
TrackingSlip
類嵌套於一個if
語句的作用域內。這並不意味著類是有條件創建的——它會隨同其他所有東西得到編譯。然而,在定義它的那個作用域之外,它是不可使用的。除這些以外,它看起來和一個普通類並沒有什麼區別。
下面這個例子看起來有些奇怪:
//: Parcel6.java
// A method that returns an anonymous inner class
package c07.innerscopes;
public class Parcel6 {
public Contents cont() {
return new Contents() {
private int i = 11;
public int value() { return i; }
}; // Semicolon required in this case
}
public static void main(String[] args) {
Parcel6 p = new Parcel6();
Contents c = p.cont();
}
} ///:~
cont()
方法同時合併了返回值的創建代碼,以及用於表示那個返回值的類。除此以外,這個類是匿名的——它沒有名字。而且看起來似乎更讓人摸不著頭腦的是,我們準備創建一個Contents
對象:
return new Contents()
但在這之後,在遇到分號之前,我們又說:“等一等,讓我先在一個類定義裡再耍一下花招”:
return new Contents() {
private int i = 11;
public int value() { return i; }
};
這種奇怪的語法要表達的意思是:“創建從Contents
派生出來的匿名類的一個對象”。由new
表達式返回的引用會自動向上轉換成一個Contents
引用。匿名內部類的語法其實要表達的是:
class MyContents extends Contents {
private int i = 11;
public int value() { return i; }
}
return new MyContents();
在匿名內部類中,Contents
是用一個默認構造器創建的。下面這段代碼展示了基類需要含有參數的一個構造器時做的事情:
//: Parcel7.java
// An anonymous inner class that calls the
// base-class constructor
package c07.innerscopes;
public class Parcel7 {
public Wrapping wrap(int x) {
// Base constructor call:
return new Wrapping(x) {
public int value() {
return super.value() * 47;
}
}; // Semicolon required
}
public static void main(String[] args) {
Parcel7 p = new Parcel7();
Wrapping w = p.wrap(10);
}
} ///:~
也就是說,我們將適當的參數簡單地傳遞給基類構造器,在這兒表現為在new Wrapping(x)
中傳遞x
。匿名類不能擁有一個構造器,這和在調用super()
時的常規做法不同。
在前述的兩個例子中,分號並不標誌著類主體的結束(和C++不同)。相反,它標誌著用於包含匿名類的那個表達式的結束。因此,它完全等價於在其他任何地方使用分號。
若想對匿名內部類的一個對象進行某種形式的初始化,此時會出現什麼情況呢?由於它是匿名的,沒有名字賦給構造器,所以我們不能擁有一個構造器。然而,我們可在定義自己的字段時進行初始化:
//: Parcel8.java
// An anonymous inner class that performs
// initialization. A briefer version
// of Parcel5.java.
package c07.innerscopes;
public class Parcel8 {
// Argument must be final to use inside
// anonymous inner class:
public Destination dest(final String dest) {
return new Destination() {
private String label = dest;
public String readLabel() { return label; }
};
}
public static void main(String[] args) {
Parcel8 p = new Parcel8();
Destination d = p.dest("Tanzania");
}
} ///:~
若試圖定義一個匿名內部類,並想使用在匿名內部類外部定義的一個對象,則編譯器要求外部對象為final
屬性。這正是我們將dest()
的參數設為final
的原因。如果忘記這樣做,就會得到一條編譯期出錯提示。
只要自己只是想分配一個字段,上述方法就肯定可行。但假如需要採取一些類似於構造器的行動,又應怎樣操作呢?通過Java 1.1的實例初始化,我們可以有效地為一個匿名內部類創建一個構造器:
//: Parcel9.java
// Using "instance initialization" to perform
// construction on an anonymous inner class
package c07.innerscopes;
public class Parcel9 {
public Destination
dest(final String dest, final float price) {
return new Destination() {
private int cost;
// Instance initialization for each object:
{
cost = Math.round(price);
if(cost > 100)
System.out.println("Over budget!");
}
private String label = dest;
public String readLabel() { return label; }
};
}
public static void main(String[] args) {
Parcel9 p = new Parcel9();
Destination d = p.dest("Tanzania", 101.395F);
}
} ///:~
在實例初始化模塊中,我們可看到代碼不能作為類初始化模塊(即if
語句)的一部分執行。所以實際上,一個實例初始化模塊就是一個匿名內部類的構造器。當然,它的功能是有限的;我們不能對實例初始化模塊進行重載處理,所以只能擁有這些構造器的其中一個。
迄今為止,我們見到的內部類好象僅僅是一種名字隱藏以及代碼組織方案。儘管這些功能非常有用,但似乎並不特別引人注目。然而,我們還忽略了另一個重要的事實。創建自己的內部類時,那個類的對象同時擁有指向封裝對象(這些對象封裝或生成了內部類)的一個鏈接。所以它們能訪問那個封裝對象的成員——毋需取得任何資格。除此以外,內部類擁有對封裝類所有元素的訪問權限(註釋②)。下面這個例子闡示了這個問題:
//: Sequence.java
// Holds a sequence of Objects
interface Selector {
boolean end();
Object current();
void next();
}
public class Sequence {
private Object[] o;
private int next = 0;
public Sequence(int size) {
o = new Object[size];
}
public void add(Object x) {
if(next < o.length) {
o[next] = x;
next++;
}
}
private class SSelector implements Selector {
int i = 0;
public boolean end() {
return i == o.length;
}
public Object current() {
return o[i];
}
public void next() {
if(i < o.length) i++;
}
}
public Selector getSelector() {
return new SSelector();
}
public static void main(String[] args) {
Sequence s = new Sequence(10);
for(int i = 0; i < 10; i++)
s.add(Integer.toString(i));
Selector sl = s.getSelector();
while(!sl.end()) {
System.out.println((String)sl.current());
sl.next();
}
}
} ///:~
②:這與C++“嵌套類”的設計頗有不同,後者只是一種單純的名字隱藏機制。在C++中,沒有指向一個封裝對象的鏈接,也不存在默認的訪問權限。
其中,Sequence
只是一個大小固定的對象數組,有一個類將其封裝在內部。我們調用add()
,以便將一個新對象添加到Sequence
末尾(如果還有地方的話)。為了取得Sequence
中的每一個對象,要使用一個名為Selector
的接口,它使我們能夠知道自己是否位於最末尾(end()
),能觀看當前對象(current() Object
),以及能夠移至Sequence
內的下一個對象(next() Object
)。由於Selector
是一個接口,所以其他許多類都能用它們自己的方式實現接口,而且許多方法都能將接口作為一個參數使用,從而創建一般的代碼。
在這裡,SSelector
是一個私有類,它提供了Selector
功能。在main()
中,大家可看到Sequence
的創建過程,在它後面是一系列字符串對象的添加。隨後,通過對getSelector()
的一個調用生成一個Selector
。並用它在Sequence
中移動,同時選擇每一個項目。
從表面看,SSelector
似乎只是另一個內部類。但不要被表面現象迷惑。請注意觀察end()
,current()
以及next()
,它們每個方法都引用了o
。o
是個不屬於SSelector
一部分的引用,而是位於封裝類裡的一個private
字段。然而,內部類可以從封裝類訪問方法與字段,就象已經擁有了它們一樣。這一特徵對我們來說是非常方便的,就象在上面的例子中看到的那樣。
因此,我們現在知道一個內部類可以訪問封裝類的成員。這是如何實現的呢?內部類必須擁有對封裝類的特定對象的一個引用,而封裝類的作用就是創建這個內部類。隨後,當我們引用封裝類的一個成員時,就利用那個(隱藏)的引用來選擇那個成員。幸運的是,編譯器會幫助我們照管所有這些細節。但我們現在也可以理解內部類的一個對象只能與封裝類的一個對象聯合創建。在這個創建過程中,要求對封裝類對象的引用進行初始化。若不能訪問那個引用,編譯器就會報錯。進行所有這些操作的時候,大多數時候都不要求程序員的任何介入。
為正確理解static
在應用於內部類時的含義,必須記住內部類的對象默認持有創建它的那個封裝類的一個對象的引用。然而,假如我們說一個內部類是static
的,這種說法卻是不成立的。static
內部類意味著:
(1) 為創建一個static
內部類的對象,我們不需要一個外部類對象。
(2) 不能從static
內部類的一個對象中訪問一個外部類對象。
但在存在一些限制:由於static
成員只能位於一個類的外部級別,所以內部類不可擁有static
數據或static
內部類。
倘若為了創建內部類的對象而不需要創建外部類的一個對象,那麼可將所有東西都設為static
。為了能正常工作,同時也必須將內部類設為static
。如下所示:
//: Parcel10.java
// Static inner classes
package c07.parcel10;
abstract class Contents {
abstract public int value();
}
interface Destination {
String readLabel();
}
public class Parcel10 {
private static class PContents
extends Contents {
private int i = 11;
public int value() { return i; }
}
protected static class PDestination
implements Destination {
private String label;
private PDestination(String whereTo) {
label = whereTo;
}
public String readLabel() { return label; }
}
public static Destination dest(String s) {
return new PDestination(s);
}
public static Contents cont() {
return new PContents();
}
public static void main(String[] args) {
Contents c = cont();
Destination d = dest("Tanzania");
}
} ///:~
在main()
中,我們不需要Parcel10
的對象;相反,我們用常規的語法來選擇一個static
成員,以便調用將引用返回Contents
和Destination
的方法。
通常,我們不在一個接口裡設置任何代碼,但static
內部類可以成為接口的一部分。由於類是“靜態”的,所以它不會違反接口的規則——static
內部類只位於接口的命名空間內部:
//: IInterface.java
// Static inner classes inside interfaces
interface IInterface {
static class Inner {
int i, j, k;
public Inner() {}
void f() {}
}
} ///:~
在本書早些時候,我建議大家在每個類裡都設置一個main()
,將其作為那個類的測試床使用。這樣做的一個缺點就是額外代碼的數量太多。若不願如此,可考慮用一個static
內部類容納自己的測試代碼。如下所示:
//: TestBed.java
// Putting test code in a static inner class
class TestBed {
TestBed() {}
void f() { System.out.println("f()"); }
public static class Tester {
public static void main(String[] args) {
TestBed t = new TestBed();
t.f();
}
}
} ///:~
這樣便生成一個獨立的、名為TestBed$Tester
的類(為運行程序,請使用java TestBed$Tester
命令)。可將這個類用於測試,但不需在自己的最終發行版本中包含它。
若想生成外部類對象的引用,就要用一個點號以及一個this
來命名外部類。舉個例子來說,在Sequence.SSelector
類中,它的所有方法都能產生外部類Sequence
的存儲引用,方法是採用Sequence.this
的形式。結果獲得的引用會自動具備正確的類型(這會在編譯期間檢查並核實,所以不會出現運行期的開銷)。
有些時候,我們想告訴其他某些對象創建它某個內部類的一個對象。為達到這個目的,必須在new
表達式中提供指向其他外部類對象的一個引用,就象下面這樣:
//: Parcel11.java
// Creating inner classes
package c07.parcel11;
public class Parcel11 {
class Contents {
private int i = 11;
public int value() { return i; }
}
class Destination {
private String label;
Destination(String whereTo) {
label = whereTo;
}
String readLabel() { return label; }
}
public static void main(String[] args) {
Parcel11 p = new Parcel11();
// Must use instance of outer class
// to create an instances of the inner class:
Parcel11.Contents c = p.new Contents();
Parcel11.Destination d =
p.new Destination("Tanzania");
}
} ///:~
為直接創建內部類的一個對象,不能象大家或許猜想的那樣——採用相同的形式,並引用外部類名Parcel11
。此時,必須利用外部類的一個對象生成內部類的一個對象:
Parcel11.Contents c = p.new Contents();
因此,除非已擁有外部類的一個對象,否則不可能創建內部類的一個對象。這是由於內部類的對象已同創建它的外部類的對象“默默”地連接到一起。然而,如果生成一個static
內部類,就不需要指向外部類對象的一個引用。
由於內部類構造器必須同封裝類對象的一個引用聯繫到一起,所以從一個內部類繼承的時候,情況會稍微變得有些複雜。這兒的問題是封裝類的“祕密”引用必須獲得初始化,而且在派生類中不再有一個默認的對象可以連接。解決這個問題的辦法是採用一種特殊的語法,明確建立這種關聯:
//: InheritInner.java
// Inheriting an inner class
class WithInner {
class Inner {}
}
public class InheritInner
extends WithInner.Inner {
//! InheritInner() {} // Won't compile
InheritInner(WithInner wi) {
wi.super();
}
public static void main(String[] args) {
WithInner wi = new WithInner();
InheritInner ii = new InheritInner(wi);
}
} ///:~
從中可以看到,InheritInner
只對內部類進行了擴展,沒有擴展外部類。但在需要創建一個構造器的時候,默認對象已經沒有意義,我們不能只是傳遞封裝對象的一個引用。此外,必須在構造器中採用下述語法:
enclosingClassHandle.super();
它提供了必要的引用,以便程序正確編譯。
若創建一個內部類,然後從封裝類繼承,並重新定義內部類,那麼會出現什麼情況呢?也就是說,我們有可能覆蓋一個內部類嗎?這看起來似乎是一個非常有用的概念,但“覆蓋”一個內部類——好象它是外部類的另一個方法——這一概念實際不能做任何事情:
//: BigEgg.java
// An inner class cannot be overriden
// like a method
class Egg {
protected class Yolk {
public Yolk() {
System.out.println("Egg.Yolk()");
}
}
private Yolk y;
public Egg() {
System.out.println("New Egg()");
y = new Yolk();
}
}
public class BigEgg extends Egg {
public class Yolk {
public Yolk() {
System.out.println("BigEgg.Yolk()");
}
}
public static void main(String[] args) {
new BigEgg();
}
} ///:~
默認構造器是由編譯器自動組合的,而且會調用基類的默認構造器。大家或許會認為由於準備創建一個BigEgg
,所以會使用Yolk
的“被覆蓋”版本。但實際情況並非如此。輸出如下:
New Egg()
Egg.Yolk()
這個例子簡單地揭示出當我們從外部類繼承的時候,沒有任何額外的內部類繼續下去。然而,仍然有可能“明確”地從內部類繼承:
//: BigEgg2.java
// Proper inheritance of an inner class
class Egg2 {
protected class Yolk {
public Yolk() {
System.out.println("Egg2.Yolk()");
}
public void f() {
System.out.println("Egg2.Yolk.f()");
}
}
private Yolk y = new Yolk();
public Egg2() {
System.out.println("New Egg2()");
}
public void insertYolk(Yolk yy) { y = yy; }
public void g() { y.f(); }
}
public class BigEgg2 extends Egg2 {
public class Yolk extends Egg2.Yolk {
public Yolk() {
System.out.println("BigEgg2.Yolk()");
}
public void f() {
System.out.println("BigEgg2.Yolk.f()");
}
}
public BigEgg2() { insertYolk(new Yolk()); }
public static void main(String[] args) {
Egg2 e2 = new BigEgg2();
e2.g();
}
} ///:~
現在,BigEgg2.Yolk
明確地擴展了Egg2.Yolk
,而且覆蓋了它的方法。方法insertYolk()
允許BigEgg2
將它自己的某個Yolk
對象向上轉換至Egg2
的y
引用。所以當g()
調用y.f()
的時候,就會使用f()
被覆蓋版本。輸出結果如下:
Egg2.Yolk()
New Egg2()
Egg2.Yolk()
BigEgg2.Yolk()
BigEgg2.Yolk.f()
對Egg2.Yolk()
的第二個調用是BigEgg2.Yolk
構造器的基類構造器調用。調用
g()
的時候,可發現使用的是f()
的被覆蓋版本。
由於每個類都會生成一個.class
文件,用於容納與如何創建這個類型的對象有關的所有信息(這種信息產生了一個名為Class
對象的元類),所以大家或許會猜到內部類也必須生成相應的.class
文件,用來容納與它們的Class
對象有關的信息。這些文件或類的名字遵守一種嚴格的形式:先是封裝類的名字,再跟隨一個$
,再跟隨內部類的名字。例如,由InheritInner.java
創建的.class
文件包括:
InheritInner.class
WithInner$Inner.class
WithInner.class
如果內部類是匿名的,那麼編譯器會簡單地生成數字,把它們作為內部類標識符使用。若內部類嵌套於其他內部類中,則它們的名字簡單地追加在一個$
以及外部類標識符的後面。
這種生成內部名稱的方法除了非常簡單和直觀以外,也非常“健壯”,可適應大多數場合的要求(註釋③)。由於它是Java的標準命名機制,所以產生的文件會自動具備“與平臺無關”的能力(注意Java編譯器會根據情況改變內部類,使其在不同的平臺中能正常工作)。
③:但在另一方面,由於$
也是Unix外殼的一個元字符,所以有時會在列出.class
文件時遇到麻煩。對一家以Unix為基礎的公司——Sun——來說,採取這種方案顯得有些奇怪。我的猜測是他們根本沒有仔細考慮這方面的問題,而是認為我們會將全部注意力自然地放在源碼文件上。
到目前為止,大家已接觸了對內部類的運作進行描述的大量語法與概念。但這些並不能真正說明內部類存在的原因。為什麼Sun要如此麻煩地在Java 1.1裡添加這樣的一種基本語言特性呢?答案就在於我們在這裡要學習的“控制框架”。
一個“應用程序框架”是指一個或一系列類,它們專門設計用來解決特定類型的問題。為應用應用程序框架,我們可從一個或多個類繼承,並覆蓋其中的部分方法。我們在覆蓋方法中編寫的代碼用於定製由那些應用程序框架提供的常規方案,以便解決自己的實際問題。“控制框架”屬於應用程序框架的一種特殊類型,受到對事件響應的需要的支配;主要用來響應事件的一個系統叫作“由事件驅動的系統”。在應用程序設計語言中,最重要的問題之一便是“圖形用戶界面”(GUI),它幾乎完全是由事件驅動的。正如大家會在第13章學習的那樣,Java 1.1 AWT屬於一種控制框架,它通過內部類完美地解決了GUI的問題。
為理解內部類如何簡化控制框架的創建與使用,可認為一個控制框架的工作就是在事件“就緒”以後執行它們。儘管“就緒”的意思很多,但在目前這種情況下,我們卻是以計算機時鐘為基礎。隨後,請認識到針對控制框架需要控制的東西,框架內並未包含任何特定的信息。首先,它是一個特殊的接口,描述了所有控制事件。它可以是一個抽象類,而非一個實際的接口。由於默認行為是根據時間控制的,所以部分實現細節可能包括:
//: Event.java
// The common methods for any control event
package c07.controller;
abstract public class Event {
private long evtTime;
public Event(long eventTime) {
evtTime = eventTime;
}
public boolean ready() {
return System.currentTimeMillis() >= evtTime;
}
abstract public void action();
abstract public String description();
} ///:~
希望Event
(事件)運行的時候,構造器即簡單地捕獲時間。同時ready()
告訴我們何時該運行它。當然,ready()
也可以在一個派生類中被覆蓋,將事件建立在除時間以外的其他東西上。
action()
是事件就緒後需要調用的方法,而description()
提供了與事件有關的文字信息。
下面這個文件包含了實際的控制框架,用於管理和觸發事件。第一個類實際只是一個“助手”類,它的職責是容納Event
對象。可用任何適當的集合替換它。而且通過第8章的學習,大家會知道另一些集合可簡化我們的工作,不需要我們編寫這些額外的代碼:
//: Controller.java
// Along with Event, the generic
// framework for all control systems:
package c07.controller;
// This is just a way to hold Event objects.
class EventSet {
private Event[] events = new Event[100];
private int index = 0;
private int next = 0;
public void add(Event e) {
if(index >= events.length)
return; // (In real life, throw exception)
events[index++] = e;
}
public Event getNext() {
boolean looped = false;
int start = next;
do {
next = (next + 1) % events.length;
// See if it has looped to the beginning:
if(start == next) looped = true;
// If it loops past start, the list
// is empty:
if((next == (start + 1) % events.length)
&& looped)
return null;
} while(events[next] == null);
return events[next];
}
public void removeCurrent() {
events[next] = null;
}
}
public class Controller {
private EventSet es = new EventSet();
public void addEvent(Event c) { es.add(c); }
public void run() {
Event e;
while((e = es.getNext()) != null) {
if(e.ready()) {
e.action();
System.out.println(e.description());
es.removeCurrent();
}
}
}
} ///:~
EventSet
可容納100個事件(若在這裡使用來自第8章的一個“真實”集合,就不必擔心它的最大尺寸,因為它會根據情況自動改變大小)。index
(索引)在這裡用於跟蹤下一個可用的空間,而next
(下一個)幫助我們尋找列表中的下一個事件,瞭解自己是否已經循環到頭。在對getNext()
的調用中,這一點是至關重要的,因為一旦運行,Event
對象就會從列表中刪去(使用removeCurrent()
)。所以getNext()
會在列表中向前移動時遇到“空洞”。
注意removeCurrent()
並不只是指示一些標誌,指出對象不再使用。相反,它將引用設為null
。這一點是非常重要的,因為假如垃圾收集器發現一個引用仍在使用,就不會清除對象。若認為自己的引用可能象現在這樣被掛起,那麼最好將其設為null
,使垃圾收集器能夠正常地清除它們。
Controller
是進行實際工作的地方。它用一個EventSet
容納自己的Event
對象,而且addEvent()
允許我們向這個列表加入新事件。但最重要的方法是run()
。該方法會在EventSet
中遍歷,搜索一個準備運行的Event
對象——ready()
。對於它發現ready()
的每一個對象,都會調用action()
方法,打印出description()
,然後將事件從列表中刪去。
注意在迄今為止的所有設計中,我們仍然不能準確地知道一個“事件”要做什麼。這正是整個設計的關鍵;它怎樣“將發生變化的東西同沒有變化的東西區分開”?或者用我的話來講,“改變的意圖”造成了各類Event
對象的不同行動。我們通過創建不同的Event
子類,從而表達出不同的行動。
這裡正是內部類大顯身手的地方。它們允許我們做兩件事情:
(1) 在單獨一個類裡表達一個控制框架應用的全部實現細節,從而完整地封裝與那個實現有關的所有東西。內部類用於表達多種不同類型的action()
,它們用於解決實際的問題。除此以外,後續的例子使用了private
內部類,所以實現細節會完全隱藏起來,可以安全地修改。
(2) 內部類使我們具體的實現變得更加巧妙,因為能方便地訪問外部類的任何成員。若不具備這種能力,代碼看起來就可能沒那麼使人舒服,最後不得不尋找其他方法解決。
現在要請大家思考控制框架的一種具體實現方式,它設計用來控制溫室(Greenhouse
)功能(註釋④)。每個行動都是完全不同的:控制燈光、供水以及溫度自動調節的開與關,控制響鈴,以及重新啟動系統。但控制框架的設計宗旨是將不同的代碼方便地隔離開。對每種類型的行動,都要繼承一個新的Event
內部類,並在action()
內編寫相應的控制代碼。
④:由於某些特殊原因,這對我來說是一個經常需要解決的、非常有趣的問題;原來的例子在《C++ Inside & Out》一書裡也出現過,但Java提供了一種更令人舒適的解決方案。
作為應用程序框架的一種典型行為,GreenhouseControls
類是從Controller
繼承的:
//: GreenhouseControls.java
// This produces a specific application of the
// control system, all in a single class. Inner
// classes allow you to encapsulate different
// functionality for each type of event.
package c07.controller;
public class GreenhouseControls
extends Controller {
private boolean light = false;
private boolean water = false;
private String thermostat = "Day";
private class LightOn extends Event {
public LightOn(long eventTime) {
super(eventTime);
}
public void action() {
// Put hardware control code here to
// physically turn on the light.
light = true;
}
public String description() {
return "Light is on";
}
}
private class LightOff extends Event {
public LightOff(long eventTime) {
super(eventTime);
}
public void action() {
// Put hardware control code here to
// physically turn off the light.
light = false;
}
public String description() {
return "Light is off";
}
}
private class WaterOn extends Event {
public WaterOn(long eventTime) {
super(eventTime);
}
public void action() {
// Put hardware control code here
water = true;
}
public String description() {
return "Greenhouse water is on";
}
}
private class WaterOff extends Event {
public WaterOff(long eventTime) {
super(eventTime);
}
public void action() {
// Put hardware control code here
water = false;
}
public String description() {
return "Greenhouse water is off";
}
}
private class ThermostatNight extends Event {
public ThermostatNight(long eventTime) {
super(eventTime);
}
public void action() {
// Put hardware control code here
thermostat = "Night";
}
public String description() {
return "Thermostat on night setting";
}
}
private class ThermostatDay extends Event {
public ThermostatDay(long eventTime) {
super(eventTime);
}
public void action() {
// Put hardware control code here
thermostat = "Day";
}
public String description() {
return "Thermostat on day setting";
}
}
// An example of an action() that inserts a
// new one of itself into the event list:
private int rings;
private class Bell extends Event {
public Bell(long eventTime) {
super(eventTime);
}
public void action() {
// Ring bell every 2 seconds, rings times:
System.out.println("Bing!");
if(--rings > 0)
addEvent(new Bell(
System.currentTimeMillis() + 2000));
}
public String description() {
return "Ring bell";
}
}
private class Restart extends Event {
public Restart(long eventTime) {
super(eventTime);
}
public void action() {
long tm = System.currentTimeMillis();
// Instead of hard-wiring, you could parse
// configuration information from a text
// file here:
rings = 5;
addEvent(new ThermostatNight(tm));
addEvent(new LightOn(tm + 1000));
addEvent(new LightOff(tm + 2000));
addEvent(new WaterOn(tm + 3000));
addEvent(new WaterOff(tm + 8000));
addEvent(new Bell(tm + 9000));
addEvent(new ThermostatDay(tm + 10000));
// Can even add a Restart object!
addEvent(new Restart(tm + 20000));
}
public String description() {
return "Restarting system";
}
}
public static void main(String[] args) {
GreenhouseControls gc =
new GreenhouseControls();
long tm = System.currentTimeMillis();
gc.addEvent(gc.new Restart(tm));
gc.run();
}
} ///:~
注意light
(燈光)、water
(供水)、thermostat
(調溫)以及rings
都隸屬於外部類GreenhouseControls
,所以內部類可以毫無阻礙地訪問那些字段。此外,大多數action()
方法也涉及到某些形式的硬件控制,這通常都要求發出對非Java代碼的調用。
大多數Event
類看起來都是相似的,但Bell
(鈴)和Restart
(重啟)屬於特殊情況。Bell
會發出響聲,若尚未響鈴足夠的次數,它會在事件列表裡添加一個新的Bell
對象,所以以後會再度響鈴。請注意內部類看起來為什麼總是類似於多重繼承:Bell
擁有Event
的所有方法,而且也擁有外部類GreenhouseControls
的所有方法。
Restart
負責對系統進行初始化,所以會添加所有必要的事件。當然,一種更靈活的做法是避免進行“硬編碼”,而是從一個文件裡讀入它們(第10章的一個練習會要求大家修改這個例子,從而達到這個目標)。由於Restart()
僅僅是另一個Event
對象,所以也可以在Restart.action()
裡添加一個Restart
對象,使系統能夠定期重啟。在main()
中,我們需要做的全部事情就是創建一個GreenhouseControls
對象,並添加一個Restart
對象,令其工作起來。
這個例子應該使大家對內部類的價值有一個更加深刻的認識,特別是在一個控制框架裡使用它們的時候。此外,在第13章的後半部分,大家還會看到如何巧妙地利用內部類描述一個圖形用戶界面的行為。完成那裡的學習後,對內部類的認識將上升到一個前所未有的新高度。
同往常一樣,構造器與其他種類的方法是有區別的。在涉及到多態性的問題後,這種方法依然成立。儘管構造器並不具有多態性(即便可以使用一種“虛擬構造器”——將在第11章介紹),但仍然非常有必要理解構造器如何在複雜的分級結構中以及隨同多態性使用。這一理解將有助於大家避免陷入一些令人不快的糾紛。
構造器調用的順序已在第4章進行了簡要說明,但那是在繼承和多態性問題引入之前說的話。
用於基類的構造器肯定在一個派生類的構造器中調用,而且逐漸向上鏈接,使每個基類使用的構造器都能得到調用。之所以要這樣做,是由於構造器負有一項特殊任務:檢查對象是否得到了正確的構建。一個派生類只能訪問它自己的成員,不能訪問基類的成員(這些成員通常都具有private
屬性)。只有基類的構造器在初始化自己的元素時才知道正確的方法以及擁有適當的權限。所以,必須令所有構造器都得到調用,否則整個對象的構建就可能不正確。那正是編譯器為什麼要強迫對派生類的每個部分進行構造器調用的原因。在派生類的構造器主體中,若我們沒有明確指定對一個基類構造器的調用,它就會“默默”地調用默認構造器。如果不存在默認構造器,編譯器就會報告一個錯誤(若某個類沒有構造器,編譯器會自動組織一個默認構造器)。
下面讓我們看看一個例子,它展示了按構建順序進行組合、繼承以及多態性的效果:
//: Sandwich.java
// Order of constructor calls
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()");
}
}
class Sandwich extends PortableLunch {
Bread b = new Bread();
Cheese c = new Cheese();
Lettuce l = new Lettuce();
Sandwich() {
System.out.println("Sandwich()");
}
public static void main(String[] args) {
new Sandwich();
}
} ///:~
這個例子在其他類的外部創建了一個複雜的類,而且每個類都有一個構造器對自己進行了宣佈。其中最重要的類是Sandwich
,它反映出了三個級別的繼承(若將從Object
的默認繼承算在內,就是四級)以及三個成員對象。在main()
裡創建了一個Sandwich
對象後,輸出結果如下:
Meal()
Lunch()
PortableLunch()
Bread()
Cheese()
Lettuce()
Sandwich()
這意味著對於一個複雜的對象,構造器的調用遵照下面的順序:
(1) 調用基類構造器。這個步驟會不斷重複下去,首先得到構建的是分級結構的根部,然後是下一個派生類,等等。直到抵達最深一層的派生類。
(2) 按聲明順序調用成員初始化模塊。
(3) 調用派生構造器的主體。
構造器調用的順序是非常重要的。進行繼承時,我們知道關於基類的一切,並且能訪問基類的任何public
和protected
成員。這意味著當我們在派生類的時候,必須能假定基類的所有成員都是有效的。採用一種標準方法,構建行動已經進行,所以對象所有部分的成員均已得到構建。但在構造器內部,必須保證使用的所有成員都已構建。為達到這個要求,唯一的辦法就是首先調用基類構造器。然後在進入派生類構造器以後,我們在基類能夠訪問的所有成員都已得到初始化。此外,所有成員對象(亦即通過組合方法置於類內的對象)在類內進行定義的時候(比如上例中的b
,c
和l
),由於我們應儘可能地對它們進行初始化,所以也應保證構造器內部的所有成員均為有效。若堅持按這一規則行事,會有助於我們確定所有基類成員以及當前對象的成員對象均已獲得正確的初始化。但不幸的是,這種做法並不適用於所有情況,這將在下一節具體說明。
通過“組合”方法創建新類時,永遠不必擔心對那個類的成員對象的收尾工作。每個成員都是一個獨立的對象,所以會得到正常的垃圾收集以及收尾處理——無論它是不是不自己某個類一個成員。但在進行初始化的時候,必須覆蓋派生類中的finalize()
方法——如果已經設計了某個特殊的清除進程,要求它必須作為垃圾收集的一部分進行。覆蓋派生類的finalize()
時,務必記住調用finalize()
的基類版本。否則,基類的初始化根本不會發生。下面這個例子便是明證:
//: Frog.java
// Testing finalize with inheritance
class DoBaseFinalization {
public static boolean flag = false;
}
class Characteristic {
String s;
Characteristic(String c) {
s = c;
System.out.println(
"Creating Characteristic " + s);
}
protected void finalize() {
System.out.println(
"finalizing Characteristic " + s);
}
}
class LivingCreature {
Characteristic p =
new Characteristic("is alive");
LivingCreature() {
System.out.println("LivingCreature()");
}
protected void finalize() {
System.out.println(
"LivingCreature finalize");
// Call base-class version LAST!
if(DoBaseFinalization.flag)
try {
super.finalize();
} catch(Throwable t) {}
}
}
class Animal extends LivingCreature {
Characteristic p =
new Characteristic("has heart");
Animal() {
System.out.println("Animal()");
}
protected void finalize() {
System.out.println("Animal finalize");
if(DoBaseFinalization.flag)
try {
super.finalize();
} catch(Throwable t) {}
}
}
class Amphibian extends Animal {
Characteristic p =
new Characteristic("can live in water");
Amphibian() {
System.out.println("Amphibian()");
}
protected void finalize() {
System.out.println("Amphibian finalize");
if(DoBaseFinalization.flag)
try {
super.finalize();
} catch(Throwable t) {}
}
}
public class Frog extends Amphibian {
Frog() {
System.out.println("Frog()");
}
protected void finalize() {
System.out.println("Frog finalize");
if(DoBaseFinalization.flag)
try {
super.finalize();
} catch(Throwable t) {}
}
public static void main(String[] args) {
if(args.length != 0 &&
args[0].equals("finalize"))
DoBaseFinalization.flag = true;
else
System.out.println("not finalizing bases");
new Frog(); // Instantly becomes garbage
System.out.println("bye!");
// Must do this to guarantee that all
// finalizers will be called:
System.runFinalizersOnExit(true);
}
} ///:~
DoBasefinalization
類只是簡單地容納了一個標誌,向分級結構中的每個類指出是否應調用super.finalize()
。這個標誌的設置建立在命令行參數的基礎上,所以能夠在進行和不進行基類收尾工作的前提下查看行為。
分級結構中的每個類也包含了Characteristic
類的一個成員對象。大家可以看到,無論是否調用了基類收尾模塊,Characteristi
c成員對象都肯定會得到收尾(清除)處理。
每個被覆蓋的finalize()
至少要擁有對protected
成員的訪問權力,因為Object
類中的finalize()
方法具有protected
屬性,而編譯器不允許我們在繼承過程中消除訪問權限(“友好的”比“受到保護的”具有更小的訪問權限)。
在Frog.main()
中,DoBaseFinalization
標誌會得到配置,而且會創建單獨一個Frog
對象。請記住垃圾收集(特別是收尾工作)可能不會針對任何特定的對象發生,所以為了強制採取這一行動,System.runFinalizersOnExit(true)
添加了額外的開銷,以保證收尾工作的正常進行。若沒有基類初始化,則輸出結果是:
not finalizing bases
Creating Characteristic is alive
LivingCreature()
Creating Characteristic has heart
Animal()
Creating Characteristic can live in water
Amphibian()
Frog()
bye!
Frog finalize
finalizing Characteristic is alive
finalizing Characteristic has heart
finalizing Characteristic can live in water
從中可以看出確實沒有為基類·調用收尾模塊。但假如在命令行加入finalize
參數,則會獲得下述結果:
Creating Characteristic is alive
LivingCreature()
Creating Characteristic has heart
Animal()
Creating Characteristic can live in water
Amphibian()
Frog()
bye!
Frog finalize
Amphibian finalize
Animal finalize
LivingCreature finalize
finalizing Characteristic is alive
finalizing Characteristic has heart
finalizing Characteristic can live in water
儘管成員對象按照與它們創建時相同的順序進行收尾,但從技術角度說,並沒有指定對象收尾的順序。但對於基類,我們可對收尾的順序進行控制。採用的最佳順序正是在這裡採用的順序,它與初始化順序正好相反。按照與C++中用於“析構器”相同的形式,我們應該首先執行派生類的收尾,再是基類的收尾。這是由於派生類的收尾可能調用基類中相同的方法,要求基類組件仍然處於活動狀態。因此,必須提前將它們清除(析構)。
構造器調用的分級結構(順序)為我們帶來了一個有趣的問題,或者說讓我們進入了一種進退兩難的局面。若當前位於一個構造器的內部,同時調用準備構建的那個對象的一個動態綁定方法,那麼會出現什麼情況呢?在原始的方法內部,我們完全可以想象會發生什麼——動態綁定的調用會在運行期間進行解析,因為對象不知道它到底從屬於方法所在的那個類,還是從屬於從它派生出來的某些類。為保持一致性,大家也許會認為這應該在構造器內部發生。
但實際情況並非完全如此。若調用構造器內部一個動態綁定的方法,會使用那個方法被覆蓋的定義。然而,產生的效果可能並不如我們所願,而且可能造成一些難於發現的程序錯誤。
從概念上講,構造器的職責是讓對象實際進入存在狀態。在任何構造器內部,整個對象可能只是得到部分組織——我們只知道基類對象已得到初始化,但卻不知道哪些類已經繼承。然而,一個動態綁定的方法調用卻會在分級結構裡“向前”或者“向外”前進。它調用位於派生類裡的一個方法。如果在構造器內部做這件事情,那麼對於調用的方法,它要操縱的成員可能尚未得到正確的初始化——這顯然不是我們所希望的。
通過觀察下面這個例子,這個問題便會昭然若揭:
//: PolyConstructors.java
// Constructors and polymorphism
// don't produce what you might expect.
abstract class Glyph {
abstract void draw();
Glyph() {
System.out.println("Glyph() before draw()");
draw();
System.out.println("Glyph() after draw()");
}
}
class RoundGlyph extends Glyph {
int radius = 1;
RoundGlyph(int r) {
radius = r;
System.out.println(
"RoundGlyph.RoundGlyph(), radius = "
+ radius);
}
void draw() {
System.out.println(
"RoundGlyph.draw(), radius = " + radius);
}
}
public class PolyConstructors {
public static void main(String[] args) {
new RoundGlyph(5);
}
} ///:~
在Glyph
中,draw()
方法是“抽象的”(abstract
),所以它可以被其他方法覆蓋。事實上,我們在RoundGlyph
中不得不對其進行覆蓋。但Glyph
構造器會調用這個方法,而且調用會在RoundGlyph.draw()
中止,這看起來似乎是有意的。但請看看輸出結果:
Glyph() before draw()
RoundGlyph.draw(), radius = 0
Glyph() after draw()
RoundGlyph.RoundGlyph(), radius = 5
當Glyph
的構造器調用draw()
時,radius
的值甚至不是默認的初始值1,而是0。這可能是由於一個點號或者屏幕上根本什麼都沒有畫而造成的。這樣就不得不開始查找程序中的錯誤,試著找出程序不能工作的原因。
前一節講述的初始化順序並不十分完整,而那是解決問題的關鍵所在。初始化的實際過程是這樣的:
(1) 在採取其他任何操作之前,為對象分配的存儲空間初始化成二進制零。
(2) 就象前面敘述的那樣,調用基類構造器。此時,被覆蓋的draw()
方法會得到調用(的確是在RoundGlyph
構造器調用之前),此時會發現radius
的值為0,這是由於步驟(1)造成的。
(3) 按照原先聲明的順序調用成員初始化代碼。
(4) 調用派生類構造器的主體。
採取這些操作要求有一個前提,那就是所有東西都至少要初始化成零(或者某些特殊數據類型與“零”等價的值),而不是僅僅留作垃圾。其中包括通過“組合”技術嵌入一個類內部的對象引用。如果假若忘記初始化那個引用,就會在運行期間出現異常事件。其他所有東西都會變成零,這在觀看結果時通常是一個嚴重的警告信號。
在另一方面,應對這個程序的結果提高警惕。從邏輯的角度說,我們似乎已進行了無懈可擊的設計,所以它的錯誤行為令人非常不可思議。而且沒有從編譯器那裡收到任何報錯信息(C++在這種情況下會表現出更合理的行為)。象這樣的錯誤會很輕易地被人忽略,而且要花很長的時間才能找出。
因此,設計構造器時一個特別有效的規則是:用盡可能簡單的方法使對象進入就緒狀態;如果可能,避免調用任何方法。在構造器內唯一能夠安全調用的是在基類中具有final
屬性的那些方法(也適用於private
方法,它們自動具有final
屬性)。這些方法不能被覆蓋,所以不會出現上述潛在的問題。
學習了多態性的知識後,由於多態性是如此“聰明”的一種工具,所以看起來似乎所有東西都應該繼承。但假如過度使用繼承技術,也會使自己的設計變得不必要地複雜起來。事實上,當我們以一個現成類為基礎建立一個新類時,如首先選擇繼承,會使情況變得異常複雜。
一個更好的思路是首先選擇“組合”——如果不能十分確定自己應使用哪一個。組合不會強迫我們的程序設計進入繼承的分級結構中。同時,組合顯得更加靈活,因為可以動態選擇一種類型(以及行為),而繼承要求在編譯期間準確地知道一種類型。下面這個例子對此進行了闡釋:
//: Transmogrify.java
// Dynamically changing the behavior of
// an object via composition.
interface Actor {
void act();
}
class HappyActor implements Actor {
public void act() {
System.out.println("HappyActor");
}
}
class SadActor implements Actor {
public void act() {
System.out.println("SadActor");
}
}
class Stage {
Actor a = new HappyActor();
void change() { a = new SadActor(); }
void go() { a.act(); }
}
public class Transmogrify {
public static void main(String[] args) {
Stage s = new Stage();
s.go(); // Prints "HappyActor"
s.change();
s.go(); // Prints "SadActor"
}
} ///:~
在這裡,一個Stage
對象包含了指向一個Actor
的引用,後者被初始化成一個HappyActor
對象。這意味著go()
會產生特定的行為。但由於引用在運行期間可以重新與一個不同的對象綁定或結合起來,所以SadActor
對象的引用可在a中得到替換,然後由go()
產生的行為發生改變。這樣一來,我們在運行期間就獲得了很大的靈活性。與此相反,我們不能在運行期間換用不同的形式來進行繼承;它要求在編譯期間完全決定下來。
一條常規的設計準則是:用繼承表達行為間的差異,並用成員變量表達狀態的變化。在上述例子中,兩者都得到了應用:繼承了兩個不同的類,用於表達act()
方法的差異;而Stage
通過組合技術允許它自己的狀態發生變化。在這種情況下,那種狀態的改變同時也產生了行為的變化。
學習繼承時,為了創建繼承分級結構,看來最明顯的方法是採取一種“純粹”的手段。也就是說,只有在基類或“接口”中已建立的方法才可在派生類中被覆蓋,如下面這張圖所示:
可將其描述成一種純粹的“屬於”關係,因為一個類的接口已規定了它到底“是什麼”或者“屬於什麼”。通過繼承,可保證所有派生類都只擁有基類的接口。如果按上述示意圖操作,派生出來的類除了基類的接口之外,也不會再擁有其他什麼。
可將其想象成一種“純替換”,因為派生類對象可為基類完美地替換掉。使用它們的時候,我們根本沒必要知道與子類有關的任何額外信息。如下所示:
也就是說,基類可接收我們發給派生類的任何消息,因為兩者擁有完全一致的接口。我們要做的全部事情就是從派生向上轉換,而且永遠不需要回過頭來檢查對象的準確類型是什麼。所有細節都已通過多態性獲得了完美的控制。
若按這種思路考慮問題,那麼一個純粹的“屬於”關係似乎是唯一明智的設計方法,其他任何設計方法都會導致混亂不清的思路,而且在定義上存在很大的困難。但這種想法又屬於另一個極端。經過細緻的研究,我們發現擴展接口對於一些特定問題來說是特別有效的方案。可將其稱為“類似於”關係,因為擴展後的派生類“類似於”基類——它們有相同的基礎接口——但它增加了一些特性,要求用額外的方法加以實現。如下所示:
儘管這是一種有用和明智的做法(由具體的環境決定),但它也有一個缺點:派生類中對接口擴展的那一部分不可在基類中使用。所以一旦向上轉換,就不可再調用新方法:
若在此時不進行向上轉換,則不會出現此類問題。但在許多情況下,都需要重新核實對象的準確類型,使自己能訪問那個類型的擴展方法。在後面的小節裡,我們具體講述了這是如何實現的。
由於我們在向上轉換(在繼承結構中向上移動)期間丟失了具體的類型信息,所以為了獲取具體的類型信息——亦即在分級結構中向下移動——我們必須使用 “向下轉換”技術。然而,我們知道一個向上轉換肯定是安全的;基類不可能再擁有一個比派生類更大的接口。因此,我們通過基類接口發送的每一條消息都肯定能夠接收到。但在進行向下轉換的時候,我們(舉個例子來說)並不真的知道一個幾何形狀實際是一個圓,它完全可能是一個三角形、方形或者其他形狀。
為解決這個問題,必須有一種辦法能夠保證向下轉換正確進行。只有這樣,我們才不會冒然轉換成一種錯誤的類型,然後發出一條對象不可能收到的消息。這樣做是非常不安全的。
在某些語言中(如C++),為了進行保證“類型安全”的向下轉換,必須採取特殊的操作。但在Java中,所有轉換都會自動得到檢查和核實!所以即使我們只是進行一次普通的括弧轉換,進入運行期以後,仍然會毫無留情地對這個轉換進行檢查,保證它的確是我們希望的那種類型。如果不是,就會得到一個ClassCastException
(類轉換異常)。在運行期間對類型進行檢查的行為叫作“運行期類型識別”(RTTI)。下面這個例子向大家演示了RTTI的行為:
//: RTTI.java
// Downcasting & Run-Time Type
// Identification (RTTI)
import java.util.*;
class Useful {
public void f() {}
public void g() {}
}
class MoreUseful extends Useful {
public void f() {}
public 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
}
} ///:~
和在示意圖中一樣,MoreUseful
(更有用的)對Useful
(有用的)的接口進行了擴展。但由於它是繼承來的,所以也能向上轉換到一個Useful
。我們可看到這會在對數組x
(位於main()
中)進行初始化的時候發生。由於數組中的兩個對象都屬於Useful
類,所以可將f()
和g()
方法同時發給它們兩個。而且假如試圖調用u()
(它只存在於MoreUseful
),就會收到一條編譯期出錯提示。
若想訪問一個MoreUseful
對象的擴展接口,可試著進行向下轉換。如果它是正確的類型,這一行動就會成功。否則,就會得到一個ClassCastException
。我們不必為這個異常編寫任何特殊的代碼,因為它指出的是一個可能在程序中任何地方發生的一個編程錯誤。
RTTI的意義遠不僅僅反映在轉換處理上。例如,在試圖向下轉換之前,可通過一種方法瞭解自己處理的是什麼類型。整個第11章都在講述Java運行期類型識別的方方面面。
“多態性”意味著“不同的形式”。在面向對象的程序設計中,我們有相同的外觀(基類的通用接口)以及使用那個外觀的不同形式:動態綁定或組織的、不同版本的方法。
通過這一章的學習,大家已知道假如不利用數據抽象以及繼承技術,就不可能理解、甚至去創建多態性的一個例子。多態性是一種不可獨立應用的特性(就象一個switch
語句),只可與其他元素協同使用。我們應將其作為類總體關係的一部分來看待。人們經常混淆Java其他的、非面向對象的特性,比如方法重載等,這些特性有時也具有面向對象的某些特徵。但不要被愚弄:如果以後沒有綁定,就不成其為多態性。
為使用多態性乃至面嚮對象的技術,特別是在自己的程序中,必須將自己的編程視野擴展到不僅包括單獨一個類的成員和消息,也要包括類與類之間的一致性以及它們的關係。儘管這要求學習時付出更多的精力,但卻是非常值得的,因為只有這樣才可真正有效地加快自己的編程速度、更好地組織代碼、更容易做出包容面廣的程序以及更易對自己的代碼進行維護與擴展。
(1) 創建Rodent
(齧齒動物):Mouse
(老鼠),Gerbil
(鼴鼠),Hamster
(大頰鼠)等的一個繼承分級結構。在基類中,提供適用於所有Rodent
的方法,並在派生類中覆蓋它們,從而根據不同類型的Rodent
採取不同的行動。創建一個Rodent
數組,在其中填充不同類型的Rodent
,然後調用自己的基類方法,看看會有什麼情況發生。
(2) 修改練習1,使Rodent
成為一個接口。
(3) 改正WindError.java
中的問題。
(4) 在GreenhouseControls.java
中,添加Event
內部類,使其能打開和關閉風扇。