“Java 引人注目的一項特性是代碼的重復使用或者再生。但最具革命意義的是,除代碼的復制和修改以外,我們還能做多得多的其他事情。”
在象C 那樣的程序化語言裡,代碼的重復使用早已可行,但效果不是特別顯著。與Java 的其他地方一樣,這個方案解決的也是與類有關的問題。我們通過創建新類來重復使用代碼,但卻用不著重新創建,可以直接使用別人已建好並調試好的現成類。
但這樣做必須保證不會干擾原有的代碼。
在新類裡簡單地創建原有類的對象。我們把這種方法叫作“合成”,因為新類由現有類的對象合並而成。我們只是簡單地重復利用代碼的功能,而不是采用它的形式。
第二種方法創建一個新類,將其作為現有類的一個“類型”。我們可以原樣采取現有類的形式,並在其中加入新代碼,同時不會對現有的類產生影響。這種行為叫作“繼承”
(Inheritance),涉及的大多數工作都是由編譯器完成的。對於面向對象的程序設計,“繼承”是最重要的基礎概念之一。
對於合成與繼承這兩種方法,大多數語法和行為都是類似的(因為它們都要根據現有的類型生成新類型)。
將深入學習這些代碼再生或者重復使用的機制。
為進行合成,只需在新類裡簡單地置入對象句柄即可。舉個例子來說,假定需要在一個對象裡容納幾個String對象、兩種基本數據類型以及屬於另一個類的一個對象。對於非基本類型的對象來說,只需將句柄置於新類即可;而對於基本數據類型來說,則需在自己的類中定義它們。
示例如下:
package com.toad6;
class WaterSource {
private Strings;
WaterSource() {
System.out.println("WaterSource()");
s =new String("Constructed");
}
public String toString() {returns; }
}
publicclass SprinklerSystem {
private Stringvalve1,valve2,valve3,valve4;
WaterSource source;
inti;
floatf;
void print() {
System.out.println("valve1 = " + valve1);
System.out.println("valve2 = " + valve2);
System.out.println("valve3 = " + valve3);
System.out.println("valve4 = " + valve4);
System.out.println("i = " + i);
System.out.println("f = " + f);
System.out.println("source = " + source);
}
publicstaticvoid main(String[] args) {
SprinklerSystem x =new SprinklerSystem();
x.print();
}
} ///:~
輸出如下:
valve1= null
valve2= null
valve3= null
valve4= null
i= 0
f= 0.0
source= null
WaterSource內定義的一個方法是比較特別的:toString()。大家不久就會知道,每種非基本類型的對象都有一個toString()方法。若編譯器本來希望一個String,但卻獲得某個這樣的對象,就會調用這個方法。所以在下面這個表達式中:
System.out.println("source = " + source) ;
編譯器會發現我們試圖向一個WaterSource添加一個String 對象("source =")。這對它來說是不可接受的,因為我們只能將一個字串“添加”到另一個字串,所以它會說:“我要調用toString(),把source 轉換成字串!”經這樣處理後,它就能編譯兩個字串,並將結果字串傳遞給一個System.out.println()。每次隨同自己創建的一個類允許這種行為的時候,都只需要寫一個 toString()方法。
在類內作為字段使用的基本數據會初始化成零。但對象句柄會初始化成null。而且假若試圖為它們中的任何一個調用方法,就會產生一次“違例”。這種結果實際是相當好的(而且很有用),我們可在不丟棄一次違例的前提下,仍然把它們打印出來。
編譯器並不只是為每個句柄創建一個默認對象,因為那樣會在許多情況下招致不必要的開銷。如希望句柄得到初始化,可在下面這些地方進行:
(1) 在對象定義的時候。這意味著它們在構建器調用之前肯定能得到初始化。
(2) 在那個類的構建器中。
(3) 緊靠在要求實際使用那個對象之前。這樣做可減少不必要的開銷——假如對象並不需要創建的話。
示例如下:
package com.toad6;
class Soap {
private Strings;
Soap(){
System.out.println("Soap()");
s =new String("Constructed");
}
public String toString() {
returns;
}
}
publicclass Bath {
private Strings1 =new String("Happy"),s2 ="Happy",s3,s4;
Soapcastille;
inti;
floattoy;
Bath(){
System.out.println("InsideBath()");
s3 =new String("Joy");
i = 47;
toy = 3.14f;
castille =new Soap();
}
void print() {
// Delayedinitialization:
if (s4 == null)
s4 =new String("Joy");
System.out.println("s1 = " + s1);
System.out.println("s2 = " + s2);
System.out.println("s3 = " + s3);
System.out.println("s4 = " + s4);
System.out.println("i = " + i);
System.out.println("toy = " + toy);
System.out.println("castille =" + castille);
}
publicstaticvoid main(String[] args) {
Bathb =new Bath();
b.print();
}
} // /:~
輸出如下:
InsideBath()
Soap()
s1= Happy
s2= Happy
s3= Joy
s4= Joy
i= 47
toy= 3.14
castille= Constructed
在Bath 構建器中,在所有初始化開始之前執行了一個語句。如果不在定義時進行初始化,仍然不能保證能在將一條消息發給一個對象句柄之前會執行任何初始化——除非出現不可避免的運行期違例。
調用print()時,它會填充s4,使所有字段在使用之前都獲得正確的初始化。
繼承與Java(以及其他OOP 語言)非常緊密地結合在一起。
創建一個類時肯定會進行繼承,因為若非如此,會從Java的標准根類 Object 中繼承。
用於合成的語法是非常簡單且直觀的。但為了進行繼承,必須采用一種全然不同的形式。
在類主體的起始花括號之前,需要放置一個關鍵字extends,在後面跟隨“基礎類”的名字。若采取這種做法,就可自動獲得基礎類的所有數據成員以及方法。
示例如下:
package com.toad6;
class Cleanser {
private Strings =new String("Cleanser");
publicvoid append(Stringa) {
s +=a;
}
publicvoid dilute() {
append(" dilute()");
}
publicvoid apply() {
append(" apply()");
}
publicvoid scrub() {
append(" scrub()");
}
publicvoid print() {
System.out.println(s);
}
publicstaticvoid main(String[] args) {
Cleanserx =new Cleanser();
x.dilute();
x.apply();
x.scrub();
x.print();
}
}
publicclass Detergentextends Cleanser {
// Change a method:
publicvoid scrub() {
append("Detergent.scrub()");
super.scrub();// Call base-classversion
}
// Add methods to theinterface:
publicvoid foam() {
append(" foam()");
}
// Test the newclass:
publicstaticvoid main(String[] args) {
Detergentx =new Detergent();
x.dilute();
x.apply();
x.scrub();
x.foam();
x.print();
System.out.println("Testing baseclass:");
Cleanser.main(args);
}
} // /:~
輸出如下:
Cleanserdilute() apply() Detergent.scrub() scrub() foam()
Testingbase class:
Cleanserdilute() apply() scrub()
無論 Cleanser 還是Detergent 都包含了一個main()方法。我們可為自己的每個類都創建一個main()。通常建議大家象這樣進行編寫代碼,使自己的測試代碼能夠封裝到類內。即便在程序中含有數量眾多的類,但對於在命令行請求的public 類,只有main()才會得到調用。所以在這種情況下,當我們使用“java Detergent”的時候,調用的是Degergent.main()——即使Cleanser 並非一個public類。采用這種將main()置入每個類的做法,可方便地為每個類都進行單元測試。而且在完成測試以後,毋需將main()刪去;可把它保留下來,用於以後的測試。
需要著重強調的是Cleanser 中的所有類都是public屬性。倘若省略所有訪問指示符,則成員默認為“友好的”。這樣一來,就只允許對包成員進行訪問。在這個包內,任何人都可使用那些沒有訪問指示符的方法。Detergent 將不會遇到任何麻煩。然而,假設來自另外某個包的類准備繼承Cleanser,它就只能訪問那些public 成員。所以在計劃繼承的時候,一個比較好的規則是將所有字段都設為private,並將所有方法都設為public(protected 成員也允許衍生出來的類訪問它)。當然,在一些特殊的場合,我們仍然必須作出一些調整,但這並不是一個好的做法。
注意Cleanser 在它的接口中含有一系列方法:append(),dilute(),apply(),scrub()以及print()。由於Detergent 是從Cleanser 衍生出來的(通過 extends關鍵字),所以它會自動獲得接口內的所有這些方法——即使我們在Detergent 裡並未看到對它們的明確定義。這樣一來,就可將繼承想象成“對接口的重復利用”或者“接口的再生”。
在scrub()裡看到的那樣,可以獲得在基礎類裡定義的一個方法,並對其進行修改。在這種情況下,我們通常想在新版本裡調用來自基礎類的方法。但在 scrub()裡,不可只是簡單地發出對scrub()的調用。那樣便造成了遞歸調用,我們不願看到這一情況。為解決這個問題,Java 提供了一個 super 關鍵字,它引用當前類已從中繼承的一個“超類”(Superclass)。所以表達式super.scrub()調用的是方法scrub()的基礎類版本。
進行繼承時,我們並不限於只能使用基礎類的方法。亦可在衍生出來的類裡加入自己的新方法。這時采取的做法與在普通類裡添加其他任何方法是完全一樣的:只需簡單地定義它即可。extends關鍵字提醒我們准備將新方法加入基礎類的接口裡,對其進行“擴展”。foam()便是這種做法的一個產物。
在Detergent.main()裡,我們可看到對於Detergent 對象,可調用Cleanser 以及Detergent 內所有可用的方法(如foam())。
基礎類及衍生類,不再是以前的一個,所以在想象衍生類的結果對象時,可能會產生一些迷惑。
從外部看,似乎新類擁有與基礎類相同的接口,而且可包含一些額外的方法和字段。但繼承並非僅僅簡單地復制基礎類的接口了事。創建衍生類的一個對象時,它在其中包含了基礎類的一個“子對象”。這個子對象就象我們根據基礎類本身創建了它的一個對象。從外部看,基礎類的子對象已封裝到衍生類的對象裡了。
基礎類子對象應該正確地初始化,而且只有一種方法能保證這一點:在構建器中執行初始化,通過調用基礎類構建器,後者有足夠的能力和權限來執行對基礎類的初始化。在衍生類的構建器中,Java 會自動插入對基礎類構建器的調用。
示例如下:
package com.toad6;
class Art {
Art(){
System.out.println("Artconstructor");
}
}
class Drawingextends Art {
Drawing(){
System.out.println("Drawingconstructor");
}
}
publicclass Cartoonextends Drawing {
Cartoon(){
System.out.println("Cartoonconstructor");
}
publicstaticvoid main(String[] args) {
Cartoonx =new Cartoon();
}
} // /:~
輸出:
Artconstructor
Drawingconstructor
Cartoon constructor
構建是在基礎類的“外部”進行的,所以基礎類會在衍生類訪問它之前得到正確的初始化。 即使沒有為 Cartoon()創建一個構建器,編譯器也會為我們自動合成一個默認構建器,並發出對基礎類構建器的調用。
上述例子有自己默認的構建器;也就是說,它們不含任何自變量。編譯器可以很容易地調用它們,因為不存在具體傳遞什麼自變量的問題。如果類沒有默認的自變量,或者想調用含有一個自變量的某個基礎類構建器,必須明確地編寫對基礎類的調用代碼。這是用 super 關鍵字以及適當的自變量列表實現的.
示例如下:
package com.toad6;
class Game {
Game(inti) {
System.out.println("Gameconstructor");
}
}
class BoardGameextends Game {
BoardGame(inti) {
super(i);
System.out.println("BoardGameconstructor");
}
}
publicclass Chessextends BoardGame {
Chess() {
super(11);
System.out.println("Chessconstructor");
}
publicstaticvoid main(String[] args) {
Chess x =new Chess();
}
} ///:~
輸出如下:
Gameconstructor
BoardGameconstructor
Chessconstructor
如果不調用 BoardGames()內的基礎類構建器,編譯器就會報告自己找不到Games()形式的一個構建器。除此以外,在衍生類構建器中,對基礎類構建器的調用是必須做的第一件事情(如操作失當,編譯器會向我們指出)。
大家可以嘗試把 super語句注釋。
編譯器會強迫我們在衍生類構建器的主體中首先設置對基礎類構建器的調用。這意味著在它之前不能出現任何東西。這同時也會防止衍生類構建器捕獲來自一個基礎類的任何違例事件。有時會為我們造成不便。
許多時候都要求將合成與繼承兩種技術結合起來使用。下面這個例子展示了如何同時采用繼承與合成技術,從而創建一個更復雜的類,同時進行必要的構建器初始化工作:
示例如下:
package com.toad6;
class Plate {
Plate(inti) {
System.out.println("Plateconstructor");
}
}
class DinnerPlateextends Plate {
DinnerPlate(inti) {
super(i);
System.out.println(
"DinnerPlateconstructor");
}
}
class Utensil {
Utensil(inti) {
System.out.println("Utensil constructor");
}
}
class Spoonextends Utensil {
Spoon(inti) {
super(i);
System.out.println("Spoonconstructor");
}
}
class Forkextends Utensil {
Fork(inti) {
super(i);
System.out.println("Forkconstructor");
}
}
class Knifeextends Utensil {
Knife(inti) {
super(i);
System.out.println("Knifeconstructor");
}
}
//A cultural way of doing something:
class Custom {
Custom(inti) {
System.out.println("Customconstructor");
}
}
publicclass PlaceSettingextends Custom {
Spoon sp;
Fork frk;
Knife kn;
DinnerPlate pl;
PlaceSetting(inti) {
super(i + 1);
sp =new Spoon(i + 2);
frk =new Fork(i + 3);
kn =new Knife(i + 4);
pl =new DinnerPlate(i + 5);
System.out.println(
"PlaceSettingconstructor");
}
publicstaticvoid main(String[] args) {
PlaceSetting x =new PlaceSetting(9);
}
} ///:~
輸出如下:
Customconstructor
Utensilconstructor
Spoonconstructor
Utensilconstructor
Forkconstructor
Utensilconstructor
Knifeconstructor
Plateconstructor
DinnerPlateconstructor
PlaceSettingconstructor
編譯器會強迫我們對基礎類進行初始化,並要求我們在構建器最開頭做這一工作,但它並不會監視我們是否正確初始化了成員對象。所以對此必須特別加以留意。
Java 不具備象C++的“破壞器”那樣的概念。在 C++中,一旦破壞(清除)一個對象,就會自動調用破壞器方法。之所以將其省略,大概是由於在Java中只需簡單地忘記對象,不需強行破壞它們。垃圾收集器會在必要的時候自動回收內存。
垃圾收集器大多數時候都能很好地工作,但在某些情況下,我們的類可能在自己的存在時期采取一些行動,而這些行動要求必須進行明確的清除工作。我們並不知道垃圾收集器什麼時候才會顯身,或者說不知它何時會調用。所以一旦希望為一個類清除什麼東西,必須寫一個特別的方法,明確、專門地來做這件事情。同時,還要讓客戶程序員知道他們必須調用這個方法。而在所有這一切的後面,(違例控制)要詳細解釋的那樣,必須將這樣的清除代碼置於一個 finally從句中,從而防范任何可能出現的違例事件。
下面介紹的是一個計算機輔助設計系統的例子,它能在屏幕上描繪圖形:
package com.toad6;
importjava.util.*;
class Shape {
Shape(inti) {
System.out.println("Shapeconstructor");
}
void cleanup() {
System.out.println("Shapecleanup");
}
}
class Circleextends Shape {
Circle(inti) {
super(i);
System.out.println("Drawing aCircle");
}
void cleanup() {
System.out.println("Erasing aCircle");
super.cleanup();
}
}
class Triangleextends Shape {
Triangle(inti) {
super(i);
System.out.println("Drawing aTriangle");
}
void cleanup() {
System.out.println("Erasing aTriangle");
super.cleanup();
}
}
class Lineextends Shape {
privateintstart,end;
Line(intstart,intend) {
super(start);
this.start = start;
this.end = end;
System.out.println("Drawing a Line:" + start +", " +end);
}
void cleanup() {
System.out.println("Erasing a Line:" + start +", " +end);
super.cleanup();
}
}
publicclass CADSystemextends Shape {
private Circlec;
private Trianglet;
private Line[]lines =new Line[10];
CADSystem(inti) {
super(i + 1);
for (intj = 0; j < 10;j++)
lines[j] =new Line(j,j *j);
c =new Circle(1);
t =new Triangle(1);
System.out.println("Combinedconstructor");
}
void cleanup() {
System.out.println("CADSystem.cleanup()");
t.cleanup();
c.cleanup();
for (inti = 0; i
lines[i].cleanup();
super.cleanup();
}
publicstaticvoid main(String[] args) {
CADSystemx =new CADSystem(47);
try {
// Code and exceptionhandling...
}finally {
x.cleanup();
}
}
} // /:~
輸出如下:
Shapeconstructor
Shapeconstructor
Drawinga Line: 0, 0
Shapeconstructor
Drawinga Line: 1, 1
Shapeconstructor
Drawinga Line: 2, 4
Shapeconstructor
Drawinga Line: 3, 9
Shapeconstructor
Drawinga Line: 4, 16
Shapeconstructor
Drawinga Line: 5, 25
Shapeconstructor
Drawinga Line: 6, 36
Shapeconstructor
Drawinga Line: 7, 49
Shapeconstructor
Drawinga Line: 8, 64
Shapeconstructor
Drawinga Line: 9, 81
Shapeconstructor
Drawinga Circle
Shapeconstructor
Drawinga Triangle
Combinedconstructor
CADSystem.cleanup()
Erasinga Triangle
Shapecleanup
Erasinga Circle
Shapecleanup
Erasinga Line: 0, 0
Shapecleanup
Erasinga Line: 1, 1
Shapecleanup
Erasinga Line: 2, 4
Shapecleanup
Erasinga Line: 3, 9
Shapecleanup
Erasinga Line: 4, 16
Shapecleanup
Erasinga Line: 5, 25
Shapecleanup
Erasinga Line: 6, 36
Shapecleanup
Erasinga Line: 7, 49
Shapecleanup
Erasinga Line: 8, 64
Shapecleanup
Erasinga Line: 9, 81
Shapecleanup
Shapecleanup
這個系統中的所有東西都屬於某種 Shape(幾何形狀)。Shape本身是一種 Object(對象),因為它是從根類明確繼承的。每個類都重新定義了Shape 的cleanup()方法,同時還要用super 調用那個方法的基礎類版本。盡管對象存在期間調用的所有方法都可負責做一些要求清除的工作,但對於特定的Shape 類——Circle(圓)、Triangle(三角形)以及Line(直線),它們都擁有自己的構建器,能完成“作圖”(draw)任務。
每個類都有它們自己的cleanup()方法,用於將非內存的東西恢復回對象存在之前的景象。 在main()中,可看到兩個新關鍵字:try和finally。其中,try關鍵字指出後面跟隨的塊(由花括號定界)是一個“警戒區”。也就是說,它會受到特別的待遇。其中一種待遇就是:該警戒區後面跟隨的finally從句的代碼肯定會得以執行——不管try塊到底存不存在(通過違例控制技術,try 塊可有多種不尋常的應用)。在這裡,finally從句的意思是“總是為 x 調用cleanup(),無論會發生什麼事情”。這些關鍵字將在第9 章進行全面、完整的解釋。
在自己的清除方法中,必須注意對基礎類以及成員對象清除方法的調用順序——假若一個子對象要以另一個為基礎。通常,應采取與C++編譯器對它的“破壞器”采取的同樣的形式:首先完成與類有關的所有特殊工作(可能要求基礎類元素仍然可見),然後調用基礎類清除方法。
許多情況下,清除可能並不是個問題;只需讓垃圾收集器盡它的職責即可。但一旦必須由自己明確清除,就必須特別謹慎,並要求周全的考慮。
垃圾收集器可能永遠不會得到調用。即使得到調用,它也可能以自己願意的任何順序回收對象。除此以外,Java 1.0實現的垃圾收集器機制通常不會調用finalize()方法。除內存的回收以外,其他任何東西都最好不要依賴垃圾收集器進行回收。若想明確地清除什麼,請制作
自己的清除方法,而且不要依賴finalize()。然而正如以前指出的那樣,可強迫Java1.1 調用所有收尾模塊(Finalizer)。
只有C++程序員可能才會驚訝於名字的隱藏,因為它的工作原理與在C++裡是完全不同的。如果Java 基礎類有一個方法名被“過載”使用多次,在衍生類裡對那個方法名的重新定義就不會隱藏任何基礎類的版本。所以無論方法在這一級還是在一個基礎類中定義,過載都會生效。
示例:
package com.toad6;
class Homer {
char doh(charc) {
System.out.println("doh(char)");
return'd';
}
float doh(floatf) {
System.out.println("doh(float)");
return 1.0f;
}
}
class Milhouse {
}
class Bartextends Homer {
void doh(Milhousem) {
}
}
class Hide {
publicstaticvoid main(String[] args) {
Bartb =new Bart();
b.doh(1);// doh(float)used
b.doh('x');
b.doh(1.0f);
b.doh(new Milhouse());
}
} // /:~
輸出如下:
doh(float)
doh(char)
doh(float)
很少會用與基礎類裡完全一致的簽名和返回類型來覆蓋同名的方法,否則會使人感到迷惑(這正是C++不允許那樣做的原因,所以能夠防止產生一些不必要的錯誤)。
無論合成還是繼承,都允許我們將子對象置於自己的新類中。大家或許會奇怪兩者間的差異,以及到底該如何選擇。
如果想利用新類內部一個現有類的特性,而不想使用它的接口,通常應選擇合成。也就是說,我們可嵌入一個對象,使自己能用它實現新類的特性。但新類的用戶會看到我們已定義的接口,而不是來自嵌入對象的接口。考慮到這種效果,我們需在新類裡嵌入現有類的private 對象。
有些時候,我們想讓類用戶直接訪問新類的合成。也就是說,需要將成員對象的屬性變為public。成員對象會將自身隱藏起來,所以這是一種安全的做法。而且在用戶知道我們准備合成一系列組件時,接口就更容易理解。
car(汽車)對象是一個很好的例子:
package com.toad6;
class Engine {
publicvoid start() {
}
publicvoid rev() {
}
publicvoid stop() {
}
}
class Wheel {
publicvoid inflate(intpsi) {
System.out.println("inflate");
}
}
class Window {
publicvoid rollup() {
System.out.println("rollup");
}
publicvoid rolldown() {
}
}
class Door {
public Windowwindow =new Window();
publicvoid open() {
}
publicvoid close() {
}
}
publicclass Car {
public Engineengine =new Engine();
public Wheel[]wheel =new Wheel[4];
public Doorleft =new Door(),right =new Door();// 2-door
Car(){
for (inti = 0; i < 4;i++)
wheel[i] =new Wheel();
System.out.println("create fourwheels");
}
publicstaticvoid main(String[] args) {
Carcar =new Car();
car.left.window.rollup();
car.wheel[0].inflate(72);
}
} // /:~
輸出如下:
createfour wheels
rollup
inflate
由於汽車的裝配是故障分析時需要考慮的一項因素(並非只是基礎設計簡單的一部分),所以有助於客戶程序員理解如何使用類,而且類創建者的編程復雜程度也會大幅度降低。
如選擇繼承,就需要取得一個現成的類,並制作它的一個特殊版本。通常,這意味著我們准備使用一個常規用途的類,並根據特定的需求對其進行定制。只需稍加想象,就知道自己不能用一個車輛對象來合成一輛汽車——汽車並不“包含”車輛;相反,它“屬於”車輛的一種類別。“屬於”關系是用繼承來表達的,而“包含”關系是用合成來表達的。
理解了繼承的概念,protected 這個關鍵字最後終於有了意義。在理想情況下,private 成員隨時都是“私有”的,任何人不得訪問。但在實際應用中,經常想把某些東西深深地藏起來,但同時允許訪問衍生類的成員。protected 關鍵字可幫助我們做到這一點。它的意思是“它本身是私有的,但可由從這個類繼承的任何東西或者同一個包內的其他任何東西訪問”。也就是說,Java 中的protected 會成為進入“友好”狀態。
我們采取的最好的做法是保持成員的private 狀態——無論如何都應保留對基 礎的實施細節進行修改的權利。可通過protected 方法允許類的繼承者進行受到控制的訪問
package com.toad6;
importjava.util.*;
class Villain {
privateinti;
protectedint read() {
returni;
}
protectedvoid set(intii) {
i =ii;
}
public Villain(intii) {
i =ii;
}
publicint value(intm) {
returnm *i;
}
}
publicclass Orcextends Villain {
privateintj;
public Orc(intjj) {
super(jj);
j =jj;
}
publicvoid change(intx) {
set(x);
}
publicstaticvoid main(String[] args) {
Orcorc=new Orc(10);
orc.set(20);
intc=orc.read();
System.out.println(c);
}
} // /:~
輸出如下:
20
change()擁有對 set()的訪問權限,因為它的屬性是protected(受到保護的)。
繼承的一個好處是它支持“累積開發”,允許我們引入新的代碼,同時不會為現有代碼造成錯誤。這樣可將新錯誤隔離到新代碼裡。通過從一個現成的、功能性的類繼承,同時增添成員新的數據成員及方法(並重新定義現有方法),我們可保持現有代碼原封不動(另外有人也許仍在使用它),不會為其引入自己的編程錯誤。一旦出現錯誤,就知道它肯定是由於自己的新代碼造成的。這樣一來,與修改現有代碼的主體相比,改正錯誤所需的時間和精力就可以少很多。
類的隔離效果非常好,這是許多程序員事先沒有預料到的。甚至不需要方法的源代碼來實現代碼的再生。最多只需要導入一個包(這對於繼承和合並都是成立的)。
記住這樣一個重點:程序開發是一個不斷遞增或者累積的過程,就象人們學習知識一樣。當然可根據要求進行盡可能多的分析,但在一個項目的設計之初,誰都不可能提前獲知所有的答案。如果能將自己的項目看作一個有機的、能不斷進步的生物,從而不斷地發展和改進它,就有望獲得更大的成功以及更直接的反饋。
盡管繼承是一種非常有用的技術,但在某些情況下,特別是在項目穩定下來以後,仍然需要從新的角度考察自己的類結構,將其收縮成一個更靈活的結構。請記住,繼承是對一種特殊關系的表達,意味著“這個新類屬於那個舊類的一種類型”。我們的程序不應糾纏於一些細樹末節,而應著眼於創建和操作各種類型的對象,用它們表達出來自“問題空間”的一個模型
繼承最值得注意的地方就是它沒有為新類提供方法。繼承是對新類和基礎類之間的關系的一種表達。
可這樣總結該關系:“新類屬於現有類的一種類型”。
這種表達並不僅僅是對繼承的一種形象化解釋,繼承是直接由語言提供支持的。
由於繼承意味著基礎類的所有方法亦可在衍生出來的類中使用,所以我們發給基礎類的任何消息亦可發給衍生類。
若Instrument 類有一個play()方法,則Wind 設備也會有這個方法。這意味著我們能肯定地認為一個Wind 對象也是Instrument的一種類型。
下面這個例子揭示出編譯器如何提供對這一概念的支持:
package com.toad6;
importjava.util.*;
class Instrument {
publicvoid play() {
System.out.println("Instrument.play()");
}
staticvoid tune(Instrumenti) {
// ...
i.play();
}
}
// Wind objects are instruments
// because they have the sameinterface:
class Windextends Instrument {
publicstaticvoid main(String[] args) {
Windflute =new Wind();
Instrument.tune(flute);// Upcasting
}
} // /:~
輸出如下:
Instrument.play()
tune()方法,它能接受一個Instrument句柄。但在 Wind.main()中,tune()方法是通過為其賦予一個Wind 句柄來調用的。由於Java 對類型檢查特別嚴格,所以大家可能會感到很奇怪,為什麼接收一種類型的方法也能接收另一種類型呢?但是,我們一定要認識到一個Wind 對象也是一個Instrument對象。而且對於不在Wind 中的一個Instrument(樂器),沒有方法可以由tune()調用。在tune()中,代碼適用於Instrument以及從 Instrument 衍生出來的任何東西。在這裡,我們將從一個Wind句柄轉換成一個Instrument 句柄的行為叫作“上溯造型”。
之所以叫作這個名字,除了有一定的歷史原因外,也是由於在傳統意義上,類繼承圖的畫法是根位於最頂部,再逐漸向下擴展(當然,可根據自己的習慣用任何方法描繪這種圖)。因素,Wind.java 的繼承圖就象
下面這個樣子:
由於造型的方向是從衍生類到基礎類,箭頭朝上,所以通常把它叫作“上溯造型”,即Upcasting。上溯造型肯定是安全的,因為我們是從一個更特殊的類型到一個更常規的類型。換言之,衍生類是基礎類的一個超集。它可以包含比基礎類更多的方法,但它至少包含了基礎類的方法。進行上溯造型的時候,類接口可能出現的唯一一個問題是它可能丟失方法,而不是贏得這些方法。這便是在沒有任何明確的造型或者其他特殊標注的情況下,編譯器為什麼允許上溯造型的原因所在。
也可以執行下溯造型,但這時會面臨一種困境。
在面向對象的程序設計中,創建和使用代碼最可能采取的一種做法是:將數據和方法統一封裝到一個類裡,並且使用那個類的對象。有些時候,需通過“合成”技術用現成的類來構造新類。而繼承是最少見的一種做法。因此,盡管繼承在學習OOP的過程中得到了大量的強調,但並不意味著應該盡可能地到處使用它。相反,使用它時要特別慎重。只有在清楚知道繼承在所有方法中最有效的前提下,才可考慮它。為判斷自己到底應該選用合成還是繼承,一個最簡單的辦法就是考慮是否需要從新類上溯造型回基礎類。若必須上溯,就需要繼承。但如果不需要上溯造型,就應提醒自己防止繼承的濫用。
要記住經常問自己“我真的需要上溯造型嗎”,對於合成還是繼承的選擇就不應該是個太大的問題。
final 關鍵字的最一般的意思就是聲明“這個東西不能改變”。之所以要禁止改變,可能是考慮到兩方面的因素:設計或效率。由於這兩個原因頗有些區別,所以也許會造成final 關鍵字的誤用。
討論final 關鍵字的三種應用場合:數據、方法以及類。
許多程序設計語言都有自己的辦法告訴編譯器某個數據是“常數”。常數主要應用於下述兩個方面:
(1) 編譯期常數,它永遠不會改變
(2) 在運行期初始化的一個值,我們不希望它發生變化
對於編譯期的常數,編譯器(程序)可將常數值“封裝”到需要的計算過程裡。也就是說,計算可在編譯期間提前執行,從而節省運行時的一些開銷。在 Java 中,這些形式的常數必須屬於基本數據類型(Primitives),而且要用final關鍵字進行表達。在對這樣的一個常數進行定義的時候,必須給出一個值。
無論static還是final字段,都只能存儲一個數據,而且不得改變。
若隨同對象句柄使用final,而不是基本數據類型,它的含義就稍微讓人有點兒迷糊了。
對於基本數據類型,final 會將值變成一個常數;但對於對象句柄,final 會將句柄變成一個常數。進行聲明時,必須將句柄初始化到一個具體的對象。而且永遠不能將句柄變成指向另一個對象。然而,對象本身是可以修改的。Java對此未提供任何手段,可將一個對象直接變成一個常數(但是,我們可自己編寫一個類,使其中的對象具有“常數”效果)。這一限制也適用於數組,它也屬於對象。
示例如下:
package com.toad6;
class Value {
inti = 1;
}
publicclass FinalData {
// Can becompile-time constants
finalinti1 = 9;
staticfinalintI2 = 99;
// Typical publicconstant:
publicstaticfinalintI3 = 39;
// Cannot becompile-time constants:
finalinti4 = (int) (Math.random() * 20);
staticfinalinti5 = (int) (Math.random() * 20);
Valuev1 =new Value();
final Valuev2 =new Value();
staticfinal Valuev3 =new Value();
// ! final Value v4;//Pre-Java 1.1 Error:
// no initializer
// Arrays:
finalint[]a = { 1, 2, 3, 4, 5, 6 };
publicvoid print(Stringid) {
System.out.println(id + ": " +"i4 = " +i4 +", i5 = " +i5);
}
publicstaticvoid main(String[] args) {
FinalDatafd1 =new FinalData();
// ! fd1.i1++; //Error: can't change value
fd1.v2.i++;// Object isn't constant!
fd1.v1 =new Value();// OK -- not final
for (inti = 0; i
fd1.a[i]++;// Object isn't constant!
// ! fd1.v2 = newValue(); // Error: Can't
// ! fd1.v3 = newValue(); // change handle
// ! fd1.a = newint[3];
fd1.print("fd1");
System.out.println("Creating newFinalData");
FinalDatafd2 =new FinalData();
fd1.print("fd1");
fd2.print("fd2");
}
} // /:~
輸出如下:
fd1:i4 = 13, i5 = 18
Creatingnew FinalData
fd1:i4 = 13, i5 = 18
fd2:i4 = 5, i5 = 18
由於i1和I2都是具有final屬性的基本數據類型,並含有編譯期的值,所以它們除了能作為編譯期的常數使用外,在任何導入方式中也不會出現任何不同。I3是我們體驗此類常數定義時更典型的一種方式:public表示它們可在包外使用;Static強調它們只有一個;而final 表明它是一個常數。注意對於含有固定初始化值(即編譯期常數)的 fianl static基本數據類型,它們的名字根據規則要全部采用大寫。
也要注意i5 在編譯期間是未知的,所以它沒有大寫。
不能由於某樣東西的屬性是final,就認定它的值能在編譯時期知道。i4 和i5 向大家證明了這一點。它們在運行期間使用隨機生成的數字。例子的這一部分也向大家揭示出將final 值設為static 和非static 之間的差異。只有當值在運行期間初始化的前提下,這種差異才會揭示出來。因為編譯期間的值被編譯器認為是相同的。
注意對於fd1和fd2來說,i4的值是唯一的,但 i5的值不會由於創建了另一個FinalData 對象而發生改變。那是因為它的屬性是static,而且在載入時初始化,而非每創建一個對象時初始化。
從v1 到v4 的變量向我們揭示出final句柄的含義。正如大家在main()中看到的那樣,並不能認為由於v2屬於final,所以就不能再改變它的值。然而,我們確實不能再將v2綁定到一個新對象,因為它的屬性是final。這便是final對於一個句柄的確切含義。我們會發現同樣的含義亦適用於數組,後者只不過是另一種類型的句柄而已。將句柄變成final 看起來似乎不如將基本數據類型變成final那麼有用。
Java 1.1 允許我們創建“空白final”,它們屬於一些特殊的字段。盡管被聲明成 final,但卻未得到一個初始值。無論在哪種情況下,空白 final都必須在實際使用前得到正確的初始化。而且編譯器會主動保證這一規定得以貫徹。然而,對於final關鍵字的各種應用,空白final具有最大的靈活性。舉個例子來說,位於類內部的一個final 字段現在對每個對象都可以有所不同,同時依然保持其“不變”的本質。
下面列出一個例子:
package com.toad6;
class Poppet {
}
class BlankFinal {
finalinti = 0; // Initialized final
finalintj;// Blank final
final Poppetp;// Blank final handle
// Blank finals MUSTbe initialized
// in theconstructor:
BlankFinal(){
j = 1;// Initialize blank final
p =new Poppet();
}
BlankFinal(intx) {
j =x;// Initialize blank final
p =new Poppet();
}
publicstaticvoid main(String[] args) {
BlankFinalbf =new BlankFinal();
}
} // /:~
現在強行要求我們對final 進行賦值處理——要麼在定義字段時使用一個表達 式,要麼在每個構建器中。這樣就可以確保final 字段在使用前獲得正確的初始化。
Java 1.1 允許我們將自變量設成 final 屬性,方法是在自變量列表中對它們進行適當的聲明。這意味著在一個方法的內部,我們不能改變自變量句柄指向的東西。
如下所示:
package com.toad6;
class Gizmo {
publicvoid spin() {}
}
publicclass FinalArguments {
void with(final Gizmo g) {
//! g =new Gizmo(); // Illegal -- g is final
g.spin();
}
void without(Gizmog) {
g =new Gizmo();// OK -- g not final
g.spin();
}
// voidf(finalint i) { i++; } // Can't change
// Youcan only read from a final primitive:
int g(finalinti) { returni + 1; }
publicstaticvoid main(String[] args) {
FinalArguments bf =new FinalArguments();
bf.without(null);
//bf.with(null);
}
} ///:~
注意此時仍然能為final 自變量分配一個null(空)句柄,同時編譯器不會捕獲它。這與我們對非final 自變量采取的操作是一樣的。
方法f()和g()向我們展示出基本類型的自變量為 final 時會發生什麼情況:我們只能讀取自變量,不可改變它。
之所以要使用final 方法,可能是出於對兩方面理由的考慮。
第一個是為方法“上鎖”,防止任何繼承類改變它的本來含義。設計程序時,若希望一個方法的行為在繼承期間保持不變,而且不可被覆蓋或改寫,就可以采取這種做法。
采用final方法的第二個理由是程序執行的效率。將一個方法設成 final 後,編譯器就可以把對那個方法的所有調用都置入“嵌入”調用裡。只要編譯器發現一個final 方法調用,就會(根據它自己的判斷)忽略為執行方法調用機制而采取的常規代碼插入方法(將自變量壓入堆棧;跳至方法代碼並執行它;跳回來;清除堆棧自變量;最後對返回值進行處理)。
相反,它會用方法主體內實際代碼的一個副本來替換方法調用。這樣做可避免方法調用時的系統開銷。當然,若方法體積太大,那麼程序也會變得雍腫,可能受到到不到嵌入代碼所帶來的任何性能提升。因為任何提升都被花在方法內部的時間抵消了。Java 編譯器能自動偵測這些情況,並頗為“明智”地決定是否嵌入一個final 方法。然而,最好還是不要完全相信編譯器能正確地作出所有判斷。通常,只有在方法的代碼量非常少,或者想明確禁止方法被覆蓋的時候,才應考慮將一個方法設為final。
類內所有private方法都自動成為final。由於我們不能訪問一個private方法,所以它絕對不會被其他方法覆蓋(若強行這樣做,編譯器會給出錯誤提示)。可為一個 private方法添加final 指示符,但卻不能為那個方法提供任何額外的含義。
如果說整個類都是final(在它的定義前冠以final關鍵字),就表明自己不希望從這個類繼承,或者不允許其他任何人采取這種操作。換言之,出於這樣或那樣的原因,我們的類肯定不需要進行任何改變;或者出於安全方面的理由,我們不希望進行子類化(子類處理)。
除此以外,我們或許還考慮到執行效率的問題,並想確保涉及這個類各對象的所有行動都要盡可能地有效。
package com.toad6;
class SmallBrain {}
finalclass Dinosaur {
inti = 7;
intj = 1;
SmallBrain x =new SmallBrain();
void f() {System.out.println("f()");}
}
//! class Further extendsDinosaur{}
// error: Cannot extend final class 'Dinosaur'
publicclass Jurassic {
publicstaticvoid main(String[] args) {
Dinosaur n =new Dinosaur();
n.f();
n.i = 40;
n.j++;
}
} ///:~
注意數據成員既可以是 final,也可以不是,取決於我們具體選擇。應用於final 的規則同樣適用於數據成員,無論類是否被定義成final。將類定義成final後,結果只是禁止進行繼承——沒有更多的限制。然而,由於它禁止了繼承,所以一個 final類中的所有方法都默認為final。因為此時再也無法覆蓋它們。所以與我們將一個方法明確聲明為final 一樣,編譯器此時有相同的效率選擇。
可為final 類內的一個方法添加final 指示符,但這樣做沒有任何意義。
設計一個類時,往往需要考慮是否將一個方法設為final。可能會覺得使用自己的類時執行效率非常重要,沒有人想覆蓋自己的方法。這種想法在某些時候是正確的。
但要慎重作出自己的假定。通常,我們很難預測一個類以後會以什麼樣的形式再生或重復利用。常規用途的類尤其如此。若將一個方法定義成 final,就可能杜絕了在其他程序員的項目中對自己的類進行繼承的途徑,因為我們根本沒有想到它會象那樣使用。
標准Java 庫是闡述這一觀點的最好例子。其中特別常用的一個類是 Vector。如果我們考慮代碼的執行效率,就會發現只有不把任何方法設為final,才能使其發揮更大的作用。我們很容易就會想到自己應繼承和覆蓋如此有用的一個類,但它的設計者卻否定了我們的想法。
但我們至少可以用兩個理由來反駁他們。首先,Stack(堆棧)是從Vector 繼承來的,亦即Stack“是”一個 Vector,這種說法是不確切的。其次,對於Vector 許多重要的方法,如addElement()以及elementAt()等,它們都變成了 synchronized(同步的)。這會造成顯著的性能開銷,可能會把final 提供的性能改善抵銷得一干二淨。因此,程序員不得不猜測到底應該在哪裡進行優化。在標准庫裡居然采用了如此笨拙的設計,真不敢想象會在程序員裡引發什麼樣的情緒。
另一個值得注意的是Hashtable(散列表),它是另一個重要的標准類。該類沒有采用任何final 方法。顯然一些類的設計人員與其他設計人員有著全然不同的素質(注意比較
Hashtable 極短的方法名與Vecor 的方法名)。對類庫的用戶來說,這顯然是不應該如此輕易就能看出的。一個產品的設計變得不一致後,會加大用戶的工作量。這也從另一個側面強調了代碼設計與檢查時需要很強的責任心。
在許多傳統語言裡,程序都是作為啟動過程的一部分一次性載入的。隨後進行的是初始化,再是正式執行程序。在這些語言中,必須對初始化過程進行慎重的控制,保證 static數據的初始化不會帶來麻煩。比如在一個static 數據獲得初始化之前,就有另一個 static數據希望它是一個有效值,那麼在 C++中就會造成問題。
Java 則沒有這樣的問題,因為它采用了不同的裝載方法。由於 Java 中的一切東西都是對象,所以許多活動變得更加簡單,這個問題便是其中的一例。每個對象的代碼都存在於獨立的文件中。除非真的需要代碼,否則那個文件是不會載入的。通常,我們可認為除非那個類的一個對象構造完畢,否則代碼不會真的載入。由於static 方法存在一些細微的歧義,所以也能認為“類代碼在首次使用的時候載入”。
首次使用的地方也是static 初始化發生的地方。裝載的時候,所有static對象和 static代碼塊都會按照本來的順序初始化(亦即它們在類定義代碼裡寫入的順序)。當然,static 數據只會初始化一次。
有必要對整個初始化過程有所認識,其中包括繼承,對這個過程中發生的事情有一個整體性的概念。
觀察下述代碼:
package com.toad6;
class Insect {
inti = 9;
intj;
Insect(){
prt("i = " +i +", j = " +j);
j = 39;
}
staticintx1 = prt("staticInsect.x1 initialized");
staticint prt(Strings) {
System.out.println(s);
return 47;
}
}
publicclass Beetleextends Insect {
intk =prt("Beetle.kinitialized");
Beetle(){
prt("k = " +k);
prt("j = " +j);
}
staticintx2 = prt("staticBeetle.x2 initialized");
staticint prt(Strings) {
System.out.println(s);
return 63;
}
publicstaticvoid main(String[] args) {
prt("Beetleconstructor");
Beetleb =new Beetle();
}
} // /:~
輸出如下:
staticInsect.x1 initialized
staticBeetle.x2 initialized
Beetleconstructor
i= 9, j = 0
Beetle.kinitialized
k= 63
j= 39
對Beetle 運行Java 時,發生的第一件事情是裝載程序到外面找到那個類。在裝載過程中,裝載程序注意它有一個基礎類(即extends 關鍵字要表達的意思),所以隨之將其載入。無論是否准備生成那個基礎類的一個對象,這個過程都會發生(請試著將對象的創建代碼當作注釋標注出來)。
若基礎類含有另一個基礎類,則另一個基礎類隨即也會載入,以此類推。接下來,會在根基礎類(此時是Insect)執行 static 初始化,再在下一個衍生類執行,以此類推。保證這個順序是非常關鍵的,因為衍生類的初始化可能要依賴於對基礎類成員的正確初始化。
此時,必要的類已全部裝載完畢,所以能夠創建對象。首先,這個對象中的所有基本數據類型都會設成它們的默認值,而將對象句柄設為null。隨後會調用基礎類構建器。在這種情況下,調用是自動進行的。但也完全可以用super 來自行指定構建器調用(就象在Beetle()構建器中的第一個操作一樣)。基礎類的構建采用與衍生類構建器完全相同的處理過程。基礎順構建器完成以後,實例變量會按本來的順序得以初始化。最後,執行構建器剩余的主體部分。
無論繼承還是合成,都可以在現有類型的基礎上創建一個新類型。但在典型情況下,我們通過合成來實現現有類型的“再生”或“重復使用”,將其作為新類型基礎實施過程的一部分使用。但如果想實現接口的“再生”,就應使用繼承。由於衍生或派生出來的類擁有基礎類的接口,所以能夠將其“上溯造型”為基礎類。對於下一章要講述的多形性問題,這一點是至關重要的。
盡管繼承在面向對象的程序設計中得到了特別的強調,但在實際啟動一個設計時,最好還是先考慮采用合成技術。只有在特別必要的時候,才應考慮采用繼承技術。
合成顯得更加靈活。但是,通過對自己的成員類型應用一些繼承技巧,可在運行期准確改變那些成員對象的類型,由此可改變它們的行為。
盡管對於快速項目開發來說,通過合成和繼承實現的代碼再生具有很大的幫助作用。但在允許其他程序員完全依賴它之前,一般都希望能重新設計自己的類結構。我們理想的類結構應該是每個類都有自己特定的用途。它們不能過大(如集成的功能太多,則很難實現它的再生),也不能過小(造成不能由自己使用,或者不能增添新功能)。最終實現的類應該能夠方便地再生。