“Java引人注目的一項特性是代碼的重複使用或者複用。但最具革命意義的是,除代碼的複製和修改以外,我們還能做多得多的其他事情。”
在象C那樣的程序化語言裡,代碼的重複使用早已可行,但效果不是特別顯著。與Java的其他地方一樣,這個方案解決的也是與類有關的問題。我們通過創建新類來重複使用代碼,但卻用不著重新創建,可以直接使用別人已建好並調試好的現成類。
但這樣做必須保證不會干擾原有的代碼。在這一章裡,我們將介紹兩個達到這一目標的方法。第一個最簡單:在新類裡簡單地創建原有類的對象。我們把這種方法叫作“組合”,因為新類由現有類的對象合併而成。我們只是簡單地重複利用代碼的功能,而不是採用它的形式。
第二種方法則顯得稍微有些技巧。它創建一個新類,將其作為現有類的一個“類型”。我們可以原樣採取現有類的形式,並在其中加入新代碼,同時不會對現有的類產生影響。這種魔術般的行為叫作“繼承”(Inheritance),涉及的大多數工作都是由編譯器完成的。對於面向對象的程序設計,“繼承”是最重要的基礎概念之一。它對我們下一章要講述的內容會產生一些額外的影響。
對於組合與繼承這兩種方法,大多數語法和行為都是類似的(因為它們都要根據現有的類型生成新類型)。在本章,我們將深入學習這些代碼複用或者重複使用的機制。
就以前的學習情況來看,事實上已進行了多次“組合”操作。為進行組合,我們只需在新類裡簡單地置入對象引用即可。舉個例子來說,假定需要在一個對象裡容納幾個String
對象、兩種基本數據類型以及屬於另一個類的一個對象。對於非基本類型的對象來說,只需將引用置於新類即可;而對於基本數據類型來說,則需在自己的類中定義它們。如下所示(若執行該程序時有麻煩,請參見第3章3.1.2小節“賦值”):
//: SprinklerSystem.java
// Composition for code reuse
package c06;
class WaterSource {
private String s;
WaterSource() {
System.out.println("WaterSource()");
s = new String("Constructed");
}
public String toString() { return s; }
}
public class SprinklerSystem {
private String valve1, valve2, valve3, valve4;
WaterSource source;
int i;
float f;
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);
}
public static void main(String[] args) {
SprinklerSystem x = new SprinklerSystem();
x.print();
}
} ///:~
WaterSource
內定義的一個方法是比較特別的:toString()
。大家不久就會知道,每種非基本類型的對象都有一個toString()
方法。若編譯器本來希望一個String
,但卻獲得某個這樣的對象,就會調用這個方法。所以在下面這個表達式中:
System.out.println("source = " + source) ;
編譯器會發現我們試圖向一個WaterSource
添加一個String
對象(source =
)。這對它來說是不可接受的,因為我們只能將一個字符串“添加”到另一個字符串,所以它會說:“我要調用toString()
,把source
轉換成字符串!”經這樣處理後,它就能編譯兩個字符串,並將結果字符串傳遞給一個System.out.println()
。每次隨同自己創建的一個類允許這種行為的時候,都只需要寫一個toString()
方法。
如果不深究,可能會草率地認為編譯器會為上述代碼中的每個引用都自動構造對象(由於Java的安全和謹慎的形象)。例如,可能以為它會為WaterSource
調用默認構造器,以便初始化source
。打印語句的輸出事實上是:
valve1 = null
valve2 = null
valve3 = null
valve4 = null
i = 0
f = 0.0
source = null
在類內作為字段使用的基本數據會初始化成零,就象第2章指出的那樣。但對象引用會初始化成null
。而且假若試圖為它們中的任何一個調用方法,就會產生一次“異常”。這種結果實際是相當好的(而且很有用),我們可在不丟棄一次異常的前提下,仍然把它們打印出來。
編譯器並不只是為每個引用創建一個默認對象,因為那樣會在許多情況下招致不必要的開銷。如希望引用得到初始化,可在下面這些地方進行:
(1) 在對象定義的時候。這意味著它們在構造器調用之前肯定能得到初始化。
(2) 在那個類的構造器中。
(3) 緊靠在要求實際使用那個對象之前。這樣做可減少不必要的開銷——假如對象並不需要創建的話。
下面向大家展示了所有這三種方法:
//: Bath.java
// Constructor initialization with composition
class Soap {
private String s;
Soap() {
System.out.println("Soap()");
s = new String("Constructed");
}
public String toString() { return s; }
}
public class Bath {
private String
// Initializing at point of definition:
s1 = new String("Happy"),
s2 = "Happy",
s3, s4;
Soap castille;
int i;
float toy;
Bath() {
System.out.println("Inside Bath()");
s3 = new String("Joy");
i = 47;
toy = 3.14f;
castille = new Soap();
}
void print() {
// Delayed initialization:
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);
}
public static void main(String[] args) {
Bath b = new Bath();
b.print();
}
} ///:~
請注意在Bath
構造器中,在所有初始化開始之前執行了一個語句。如果不在定義時進行初始化,仍然不能保證能在將一條消息發給一個對象引用之前會執行任何初始化——除非出現不可避免的運行期異常。
下面是該程序的輸出:
Inside Bath()
Soap()
s1 = Happy
s2 = Happy
s3 = Joy
s4 = Joy
i = 47
toy = 3.14
castille = Constructed
調用print()
時,它會填充s4
,使所有字段在使用之前都獲得正確的初始化。
繼承與Java(以及其他OOP語言)非常緊密地結合在一起。我們早在第1章就為大家引入了繼承的概念,並在那章之後到本章之前的各章裡不時用到,因為一些特殊的場合要求必須使用繼承。除此以外,創建一個類時肯定會進行繼承,因為若非如此,會從Java的標準根類Object
中繼承。
用於組合的語法是非常簡單且直觀的。但為了進行繼承,必須採用一種全然不同的形式。需要繼承的時候,我們會說:“這個新類和那個舊類差不多。”為了在代碼裡表面這一觀念,需要給出類名。但在類主體的起始花括號之前,需要放置一個關鍵字extends
,在後面跟隨“基類”的名字。若採取這種做法,就可自動獲得基類的所有數據成員以及方法。下面是一個例子:
//: Detergent.java
// Inheritance syntax & properties
class Cleanser {
private String s = new String("Cleanser");
public void append(String a) { s += a; }
public void dilute() { append(" dilute()"); }
public void apply() { append(" apply()"); }
public void scrub() { append(" scrub()"); }
public void print() { System.out.println(s); }
public static void main(String[] args) {
Cleanser x = new Cleanser();
x.dilute(); x.apply(); x.scrub();
x.print();
}
}
public class Detergent extends Cleanser {
// Change a method:
public void scrub() {
append(" Detergent.scrub()");
super.scrub(); // Call base-class version
}
// Add methods to the interface:
public void foam() { append(" foam()"); }
// Test the new class:
public static void main(String[] args) {
Detergent x = new Detergent();
x.dilute();
x.apply();
x.scrub();
x.foam();
x.print();
System.out.println("Testing base class:");
Cleanser.main(args);
}
} ///:~
這個例子向大家展示了大量特性。首先,在Cleanser append()
方法裡,字符串同一個s
連接起來。這是用+=
運算符實現的。同+
一樣,+=
被Java用於對字符串進行“重載”處理。
其次,無論Cleanser
還是Detergent
都包含了一個main()
方法。我們可為自己的每個類都創建一個main()
。通常建議大家象這樣進行編寫代碼,使自己的測試代碼能夠封裝到類內。即便在程序中含有數量眾多的類,但對於在命令行請求的public
類,只有main()
才會得到調用。所以在這種情況下,當我們使用java Detergent
的時候,調用的是Degergent.main()
——即使Cleanser
並非一個public
類。採用這種將main()
置入每個類的做法,可方便地為每個類都進行單元測試。而且在完成測試以後,毋需將main()
刪去;可把它保留下來,用於以後的測試。
在這裡,大家可看到Deteregent.main()
對Cleanser.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會自動插入對基類構造器的調用。下面這個例子向大家展示了對這種三級繼承的應用:
//: Cartoon.java
// Constructor calls during inheritance
class Art {
Art() {
System.out.println("Art constructor");
}
}
class Drawing extends Art {
Drawing() {
System.out.println("Drawing constructor");
}
}
public class Cartoon extends Drawing {
Cartoon() {
System.out.println("Cartoon constructor");
}
public static void main(String[] args) {
Cartoon x = new Cartoon();
}
} ///:~
該程序的輸出顯示了自動調用:
Art constructor
Drawing constructor
Cartoon constructor
可以看出,構建是在基類的“外部”進行的,所以基類會在派生類訪問它之前得到正確的初始化。
即使沒有為Cartoon()
創建一個構造器,編譯器也會為我們自動生成一個默認構造器,併發出對基類構造器的調用。
(1) 含有參數的構造器
上述例子有自己默認的構造器;也就是說,它們不含任何參數。編譯器可以很容易地調用它們,因為不存在具體傳遞什麼參數的問題。如果類沒有默認的參數,或者想調用含有一個參數的某個基類構造器,必須明確地編寫對基類的調用代碼。這是用super
關鍵字以及適當的參數列表實現的,如下所示:
//: Chess.java
// Inheritance, constructors and arguments
class Game {
Game(int i) {
System.out.println("Game constructor");
}
}
class BoardGame extends Game {
BoardGame(int i) {
super(i);
System.out.println("BoardGame constructor");
}
}
public class Chess extends BoardGame {
Chess() {
super(11);
System.out.println("Chess constructor");
}
public static void main(String[] args) {
Chess x = new Chess();
}
} ///:~
如果不調用BoardGames()
內的基類構造器,編譯器就會報告自己找不到Games()
形式的一個構造器。除此以外,在派生類構造器中,對基類構造器的調用是必須做的第一件事情(如操作失當,編譯器會向我們指出)。
(2) 捕獲基本構造器的異常
正如剛才指出的那樣,編譯器會強迫我們在派生類構造器的主體中首先設置對基類構造器的調用。這意味著在它之前不能出現任何東西。正如大家在第9章會看到的那樣,這同時也會防止派生類構造器捕獲來自一個基類的任何異常事件。顯然,這有時會為我們造成不便。
許多時候都要求將組合與繼承兩種技術結合起來使用。下面這個例子展示瞭如何同時採用繼承與組合技術,從而創建一個更復雜的類,同時進行必要的構造器初始化工作:
//: PlaceSetting.java
// Combining composition & inheritance
class Plate {
Plate(int i) {
System.out.println("Plate constructor");
}
}
class DinnerPlate extends Plate {
DinnerPlate(int i) {
super(i);
System.out.println(
"DinnerPlate constructor");
}
}
class Utensil {
Utensil(int i) {
System.out.println("Utensil constructor");
}
}
class Spoon extends Utensil {
Spoon(int i) {
super(i);
System.out.println("Spoon constructor");
}
}
class Fork extends Utensil {
Fork(int i) {
super(i);
System.out.println("Fork constructor");
}
}
class Knife extends Utensil {
Knife(int i) {
super(i);
System.out.println("Knife constructor");
}
}
// A cultural way of doing something:
class Custom {
Custom(int i) {
System.out.println("Custom constructor");
}
}
public class PlaceSetting extends Custom {
Spoon sp;
Fork frk;
Knife kn;
DinnerPlate pl;
PlaceSetting(int i) {
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(
"PlaceSetting constructor");
}
public static void main(String[] args) {
PlaceSetting x = new PlaceSetting(9);
}
} ///:~
儘管編譯器會強迫我們對基類進行初始化,並要求我們在構造器最開頭做這一工作,但它並不會監視我們是否正確初始化了成員對象。所以對此必須特別加以留意。
Java不具備象C++的“析構器”那樣的概念。在C++中,一旦析構(清除)一個對象,就會自動調用析構器方法。之所以將其省略,大概是由於在Java中只需簡單地忘記對象,不需強行析構它們。垃圾收集器會在必要的時候自動回收內存。
垃圾收集器大多數時候都能很好地工作,但在某些情況下,我們的類可能在自己的存在時期採取一些行動,而這些行動要求必須進行明確的清除工作。正如第4章已經指出的那樣,我們並不知道垃圾收集器什麼時候才會顯身,或者說不知它何時會調用。所以一旦希望為一個類清除什麼東西,必須寫一個特別的方法,明確、專門地來做這件事情。同時,還要讓客戶程序員知道他們必須調用這個方法。而在所有這一切的後面,就如第9章(異常控制)要詳細解釋的那樣,必須將這樣的清除代碼置於一個finally
從句中,從而防範任何可能出現的異常事件。
下面介紹的是一個計算機輔助設計系統的例子,它能在屏幕上描繪圖形:
//: CADSystem.java
// Ensuring proper cleanup
import java.util.*;
class Shape {
Shape(int i) {
System.out.println("Shape constructor");
}
void cleanup() {
System.out.println("Shape cleanup");
}
}
class Circle extends Shape {
Circle(int i) {
super(i);
System.out.println("Drawing a Circle");
}
void cleanup() {
System.out.println("Erasing a Circle");
super.cleanup();
}
}
class Triangle extends Shape {
Triangle(int i) {
super(i);
System.out.println("Drawing a Triangle");
}
void cleanup() {
System.out.println("Erasing a Triangle");
super.cleanup();
}
}
class Line extends Shape {
private int start, end;
Line(int start, int end) {
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();
}
}
public class CADSystem extends Shape {
private Circle c;
private Triangle t;
private Line[] lines = new Line[10];
CADSystem(int i) {
super(i + 1);
for(int j = 0; j < 10; j++)
lines[j] = new Line(j, j*j);
c = new Circle(1);
t = new Triangle(1);
System.out.println("Combined constructor");
}
void cleanup() {
System.out.println("CADSystem.cleanup()");
t.cleanup();
c.cleanup();
for(int i = 0; i < lines.length; i++)
lines[i].cleanup();
super.cleanup();
}
public static void main(String[] args) {
CADSystem x = new CADSystem(47);
try {
// Code and exception handling...
} finally {
x.cleanup();
}
}
} ///:~
這個系統中的所有東西都屬於某種Shape
(幾何形狀)。Shape
本身是一種Object
(對象),因為它是從根類明確繼承的。每個類都重新定義了Shape
的cleanup()
方法,同時還要用super
調用那個方法的基類版本。儘管對象存在期間調用的所有方法都可負責做一些要求清除的工作,但對於特定的Shape
類——Circle
(圓)、Triangle
(三角形)以及Line
(直線),它們都擁有自己的構造器,能完成“作圖”(draw
)任務。每個類都有它們自己的cleanup()
方法,用於將非內存的東西恢復回對象存在之前的景象。
在main()
中,可看到兩個新關鍵字:try
和finally
。我們要到第9章才會向大家正式引薦它們。其中,try
關鍵字指出後面跟隨的塊(由花括號定界)是一個“警戒區”。也就是說,它會受到特別的待遇。其中一種待遇就是:該警戒區後面跟隨的finally
從句的代碼肯定會得以執行——不管try
塊到底存不存在(通過異常控制技術,try
塊可有多種不尋常的應用)。在這裡,finally
從句的意思是“總是為x
調用cleanup()
,無論會發生什麼事情”。這些關鍵字將在第9章進行全面、完整的解釋。
在自己的清除方法中,必須注意對基類以及成員對象清除方法的調用順序——假若一個子對象要以另一個為基礎。通常,應採取與C++編譯器對它的“析構器”採取的同樣的形式:首先完成與類有關的所有特殊工作(可能要求基類元素仍然可見),然後調用基類清除方法,就象這兒演示的那樣。
許多情況下,清除可能並不是個問題;只需讓垃圾收集器盡它的職責即可。但一旦必須由自己明確清除,就必須特別謹慎,並要求周全的考慮。
(1) 垃圾收集的順序
不能指望自己能確切知道何時會開始垃圾收集。垃圾收集器可能永遠不會得到調用。即使得到調用,它也可能以自己願意的任何順序回收對象。除此以外,Java 1.0實現的垃圾收集器機制通常不會調用finalize()
方法。除內存的回收以外,其他任何東西都最好不要依賴垃圾收集器進行回收。若想明確地清除什麼,請製作自己的清除方法,而且不要依賴finalize()
。然而正如以前指出的那樣,可強迫Java1.1調用所有收尾模塊(Finalizer
)。
只有C++程序員可能才會驚訝於名字的隱藏,因為它的工作原理與在C++裡是完全不同的。如果Java基類有一個方法名被“重載”使用多次,在派生類裡對那個方法名的重新定義就不會隱藏任何基類的版本。所以無論方法在這一級還是在一個基類中定義,重載都會生效:
//: Hide.java
// Overloading a base-class method name
// in a derived class does not hide the
// base-class versions
class Homer {
char doh(char c) {
System.out.println("doh(char)");
return 'd';
}
float doh(float f) {
System.out.println("doh(float)");
return 1.0f;
}
}
class Milhouse {}
class Bart extends Homer {
void doh(Milhouse m) {}
}
class Hide {
public static void main(String[] args) {
Bart b = new Bart();
b.doh(1); // doh(float) used
b.doh('x');
b.doh(1.0f);
b.doh(new Milhouse());
}
} ///:~
正如下一章會講到的那樣,很少會用與基類裡完全一致的簽名和返回類型來覆蓋同名的方法,否則會使人感到迷惑(這正是C++不允許那樣做的原因,所以能夠防止產生一些不必要的錯誤)。
無論組合還是繼承,都允許我們將子對象置於自己的新類中。大家或許會奇怪兩者間的差異,以及到底該如何選擇。
如果想利用新類內部一個現有類的特性,而不想使用它的接口,通常應選擇組合。也就是說,我們可嵌入一個對象,使自己能用它實現新類的特性。但新類的用戶會看到我們已定義的接口,而不是來自嵌入對象的接口。考慮到這種效果,我們需在新類裡嵌入現有類的private
對象。
有些時候,我們想讓類用戶直接訪問新類的組合。也就是說,需要將成員對象的屬性變為public
。成員對象會將自身隱藏起來,所以這是一種安全的做法。而且在用戶知道我們準備組合一系列組件時,接口就更容易理解。car
(汽車)對象便是一個很好的例子:
//: Car.java
// Composition with public objects
class Engine {
public void start() {}
public void rev() {}
public void stop() {}
}
class Wheel {
public void inflate(int psi) {}
}
class Window {
public void rollup() {}
public void rolldown() {}
}
class Door {
public Window window = new Window();
public void open() {}
public void close() {}
}
public class Car {
public Engine engine = new Engine();
public Wheel[] wheel = new Wheel[4];
public Door left = new Door(),
right = new Door(); // 2-door
Car() {
for(int i = 0; i < 4; i++)
wheel[i] = new Wheel();
}
public static void main(String[] args) {
Car car = new Car();
car.left.window.rollup();
car.wheel[0].inflate(72);
}
} ///:~
由於汽車的裝配是故障分析時需要考慮的一項因素(並非只是基礎設計簡單的一部分),所以有助於客戶程序員理解如何使用類,而且類創建者的編程複雜程度也會大幅度降低。
如選擇繼承,就需要取得一個現成的類,並製作它的一個特殊版本。通常,這意味著我們準備使用一個常規用途的類,並根據特定的需求對其進行定製。只需稍加想象,就知道自己不能用一個車輛對象來組合一輛汽車——汽車並不“包含”車輛;相反,它“屬於”車輛的一種類別。“屬於”關係是用繼承來表達的,而“包含”關係是用組合來表達的。
現在我們已理解了繼承的概念,protected
這個關鍵字最後終於有了意義。在理想情況下,private
成員隨時都是“私有”的,任何人不得訪問。但在實際應用中,經常想把某些東西深深地藏起來,但同時允許訪問派生類的成員。protected
關鍵字可幫助我們做到這一點。它的意思是“它本身是私有的,但可由從這個類繼承的任何東西或者同一個包內的其他任何東西訪問”。也就是說,Java中的protected
會成為進入“友好”狀態。
我們採取的最好的做法是保持成員的private
狀態——無論如何都應保留對基 礎的實現細節進行修改的權利。在這一前提下,可通過protected
方法允許類的繼承者進行受到控制的訪問:
//: Orc.java
// The protected keyword
import java.util.*;
class Villain {
private int i;
protected int read() { return i; }
protected void set(int ii) { i = ii; }
public Villain(int ii) { i = ii; }
public int value(int m) { return m*i; }
}
public class Orc extends Villain {
private int j;
public Orc(int jj) { super(jj); j = jj; }
public void change(int x) { set(x); }
} ///:~
可以看到,change()
擁有對set()
的訪問權限,因為它的屬性是protected
(受到保護的)。
繼承的一個好處是它支持“累積開發”,允許我們引入新的代碼,同時不會為現有代碼造成錯誤。這樣可將新錯誤隔離到新代碼裡。通過從一個現成的、功能性的類繼承,同時增添成員新的數據成員及方法(並重新定義現有方法),我們可保持現有代碼原封不動(另外有人也許仍在使用它),不會為其引入自己的編程錯誤。一旦出現錯誤,就知道它肯定是由於自己的新代碼造成的。這樣一來,與修改現有代碼的主體相比,改正錯誤所需的時間和精力就可以少很多。
類的隔離效果非常好,這是許多程序員事先沒有預料到的。甚至不需要方法的源代碼來實現代碼的複用。最多隻需要導入一個包(這對於繼承和合並都是成立的)。
大家要記住這樣一個重點:程序開發是一個不斷遞增或者累積的過程,就象人們學習知識一樣。當然可根據要求進行儘可能多的分析,但在一個項目的設計之初,誰都不可能提前獲知所有的答案。如果能將自己的項目看作一個有機的、能不斷進步的生物,從而不斷地發展和改進它,就有望獲得更大的成功以及更直接的反饋。
儘管繼承是一種非常有用的技術,但在某些情況下,特別是在項目穩定下來以後,仍然需要從新的角度考察自己的類結構,將其收縮成一個更靈活的結構。請記住,繼承是對一種特殊關係的表達,意味著“這個新類屬於那個舊類的一種類型”。我們的程序不應糾纏於一些細樹末節,而應著眼於創建和操作各種類型的對象,用它們表達出來自“問題空間”的一個模型。
繼承最值得注意的地方就是它沒有為新類提供方法。繼承是對新類和基類之間的關係的一種表達。可這樣總結該關係:“新類屬於現有類的一種類型”。
這種表達並不僅僅是對繼承的一種形象化解釋,繼承是直接由語言提供支持的。作為一個例子,大家可考慮一個名為Instrument
的基類,它用於表示樂器;另一個派生類叫作Wind
。由於繼承意味著基類的所有方法亦可在派生出來的類中使用,所以我們發給基類的任何消息亦可發給派生類。若Instrument
類有一個play()
方法,則Wind
設備也會有這個方法。這意味著我們能肯定地認為一個Wind
對象也是Instrument
的一種類型。下面這個例子揭示出編譯器如何提供對這一概念的支持:
//: Wind.java
// Inheritance & upcasting
import java.util.*;
class Instrument {
public void play() {}
static void tune(Instrument i) {
// ...
i.play();
}
}
// Wind objects are instruments
// because they have the same interface:
class Wind extends Instrument {
public static void main(String[] args) {
Wind flute = new Wind();
Instrument.tune(flute); // Upcasting
}
} ///:~
這個例子中最有趣的無疑是tune()
方法,它能接受一個Instrument
引用。但在Wind.main()
中,tune()
方法是通過為其賦予一個Wind
引用來調用的。由於Java對類型檢查特別嚴格,所以大家可能會感到很奇怪,為什麼接收一種類型的方法也能接收另一種類型呢?但是,我們一定要認識到一個Wind
對象也是一個Instrument
對象。而且對於不在Wind
中的一個Instrument
(樂器),沒有方法可以由tune()
調用。在tune()
中,代碼適用於Instrument
以及從Instrument
派生出來的任何東西。在這裡,我們將從一個Wind
引用轉換成一個Instrument
引用的行為叫作“向上轉換”。
之所以叫作這個名字,除了有一定的歷史原因外,也是由於在傳統意義上,類繼承圖的畫法是根位於最頂部,再逐漸向下擴展(當然,可根據自己的習慣用任何方法描繪這種圖)。因素,Wind.java
的繼承圖就象下面這個樣子:
由於轉換的方向是從派生類到基類,箭頭朝上,所以通常把它叫作“向上轉換”,即Upcasting
。向上轉換肯定是安全的,因為我們是從一個更特殊的類型到一個更常規的類型。換言之,派生類是基類的一個超集。它可以包含比基類更多的方法,但它至少包含了基類的方法。進行向上轉換的時候,類接口可能出現的唯一一個問題是它可能丟失方法,而不是贏得這些方法。這便是在沒有任何明確的轉換或者其他特殊標註的情況下,編譯器為什麼允許向上轉換的原因所在。
也可以執行向下轉換,但這時會面臨第11章要詳細講述的一種困境。
(1) 再論組合與繼承
在面向對象的程序設計中,創建和使用代碼最可能採取的一種做法是:將數據和方法統一封裝到一個類裡,並且使用那個類的對象。有些時候,需通過“組合”技術用現成的類來構造新類。而繼承是最少見的一種做法。因此,儘管繼承在學習OOP的過程中得到了大量的強調,但並不意味著應該儘可能地到處使用它。相反,使用它時要特別慎重。只有在清楚知道繼承在所有方法中最有效的前提下,才可考慮它。為判斷自己到底應該選用組合還是繼承,一個最簡單的辦法就是考慮是否需要從新類向上轉換回基類。若必須上溯,就需要繼承。但如果不需要向上轉換,就應提醒自己防止繼承的濫用。在下一章裡(多態性),會向大家介紹必須進行向上轉換的一種場合。但只要記住經常問自己“我真的需要向上轉換嗎”,對於組合還是繼承的選擇就不應該是個太大的問題。
由於語境(應用環境)不同,final
關鍵字的含義可能會稍微產生一些差異。但它最一般的意思就是聲明“這個東西不能改變”。之所以要禁止改變,可能是考慮到兩方面的因素:設計或效率。由於這兩個原因頗有些區別,所以也許會造成final
關鍵字的誤用。
在接下去的小節裡,我們將討論final
關鍵字的三種應用場合:數據、方法以及類。
許多程序設計語言都有自己的辦法告訴編譯器某個數據是“常數”。常數主要應用於下述兩個方面:
(1) 編譯期常數,它永遠不會改變
(2) 在運行期初始化的一個值,我們不希望它發生變化
對於編譯期的常數,編譯器(程序)可將常數值“封裝”到需要的計算過程裡。也就是說,計算可在編譯期間提前執行,從而節省運行時的一些開銷。在Java中,這些形式的常數必須屬於基本數據類型(Primitives),而且要用final
關鍵字進行表達。在對這樣的一個常數進行定義的時候,必須給出一個值。
無論static
還是final
字段,都只能存儲一個數據,而且不得改變。
若隨同對象引用使用final
,而不是基本數據類型,它的含義就稍微讓人有點兒迷糊了。對於基本數據類型,final
會將值變成一個常數;但對於對象引用,final
會將引用變成一個常數。進行聲明時,必須將引用初始化到一個具體的對象。而且永遠不能將引用變成指向另一個對象。然而,對象本身是可以修改的。Java對此未提供任何手段,可將一個對象直接變成一個常數(但是,我們可自己編寫一個類,使其中的對象具有“常數”效果)。這一限制也適用於數組,它也屬於對象。
下面是演示final
字段用法的一個例子:
//: FinalData.java
// The effect of final on fields
class Value {
int i = 1;
}
public class FinalData {
// Can be compile-time constants
final int i1 = 9;
static final int I2 = 99;
// Typical public constant:
public static final int I3 = 39;
// Cannot be compile-time constants:
final int i4 = (int)(Math.random()*20);
static final int i5 = (int)(Math.random()*20);
Value v1 = new Value();
final Value v2 = new Value();
static final Value v3 = new Value();
//! final Value v4; // Pre-Java 1.1 Error:
// no initializer
// Arrays:
final int[] a = { 1, 2, 3, 4, 5, 6 };
public void print(String id) {
System.out.println(
id + ": " + "i4 = " + i4 +
", i5 = " + i5);
}
public static void main(String[] args) {
FinalData fd1 = new FinalData();
//! fd1.i1++; // Error: can't change value
fd1.v2.i++; // Object isn't constant!
fd1.v1 = new Value(); // OK -- not final
for(int i = 0; i < fd1.a.length; i++)
fd1.a[i]++; // Object isn't constant!
//! fd1.v2 = new Value(); // Error: Can't
//! fd1.v3 = new Value(); // change handle
//! fd1.a = new int[3];
fd1.print("fd1");
System.out.println("Creating new FinalData");
FinalData fd2 = new FinalData();
fd1.print("fd1");
fd2.print("fd2");
}
} ///:~
由於i1
和I2
都是具有final
屬性的基本數據類型,並含有編譯期的值,所以它們除了能作為編譯期的常數使用外,在任何導入方式中也不會出現任何不同。I3
是我們體驗此類常數定義時更典型的一種方式:public
表示它們可在包外使用;Static
強調它們只有一個;而final
表明它是一個常數。注意對於含有固定初始化值(即編譯期常數)的fianl static
基本數據類型,它們的名字根據規則要全部採用大寫。也要注意i5
在編譯期間是未知的,所以它沒有大寫。
不能由於某樣東西的屬性是final
,就認定它的值能在編譯時期知道。i4
和i5
向大家證明了這一點。它們在運行期間使用隨機生成的數字。例子的這一部分也向大家揭示出將final
值設為static
和非static
之間的差異。只有當值在運行期間初始化的前提下,這種差異才會揭示出來。因為編譯期間的值被編譯器認為是相同的。這種差異可從輸出結果中看出:
fd1: i4 = 15, i5 = 9
Creating new FinalData
fd1: i4 = 15, i5 = 9
fd2: i4 = 10, i5 = 9
注意對於fd1
和fd2
來說,i4
的值是唯一的,但i5
的值不會由於創建了另一個FinalData
對象而發生改變。那是因為它的屬性是static
,而且在載入時初始化,而非每創建一個對象時初始化。
從v1
到v4
的變量向我們揭示出final
引用的含義。正如大家在main()
中看到的那樣,並不能認為由於v2
屬於final
,所以就不能再改變它的值。然而,我們確實不能再將v2
綁定到一個新對象,因為它的屬性是final
。這便是final
對於一個引用的確切含義。我們會發現同樣的含義亦適用於數組,後者只不過是另一種類型的引用而已。將引用變成final
看起來似乎不如將基本數據類型變成final
那麼有用。
(2) 空白final
Java 1.1允許我們創建“空白final
”,它們屬於一些特殊的字段。儘管被聲明成final
,但卻未得到一個初始值。無論在哪種情況下,空白final
都必須在實際使用前得到正確的初始化。而且編譯器會主動保證這一規定得以貫徹。然而,對於final
關鍵字的各種應用,空白final
具有最大的靈活性。舉個例子來說,位於類內部的一個final
字段現在對每個對象都可以有所不同,同時依然保持其“不變”的本質。下面列出一個例子:
//: BlankFinal.java
// "Blank" final data members
class Poppet { }
class BlankFinal {
final int i = 0; // Initialized final
final int j; // Blank final
final Poppet p; // Blank final handle
// Blank finals MUST be initialized
// in the constructor:
BlankFinal() {
j = 1; // Initialize blank final
p = new Poppet();
}
BlankFinal(int x) {
j = x; // Initialize blank final
p = new Poppet();
}
public static void main(String[] args) {
BlankFinal bf = new BlankFinal();
}
} ///:~
現在強行要求我們對final
進行賦值處理——要麼在定義字段時使用一個表達式,要麼在每個構造器中。這樣就可以確保final
字段在使用前獲得正確的初始化。
(3) final
參數
Java 1.1允許我們將參數設成final
屬性,方法是在參數列表中對它們進行適當的聲明。這意味著在一個方法的內部,我們不能改變參數引用指向的東西。如下所示:
//: FinalArguments.java
// Using "final" with method arguments
class Gizmo {
public void spin() {}
}
public class FinalArguments {
void with(final Gizmo g) {
//! g = new Gizmo(); // Illegal -- g is final
g.spin();
}
void without(Gizmo g) {
g = new Gizmo(); // OK -- g not final
g.spin();
}
// void f(final int i) { i++; } // Can't change
// You can only read from a final primitive:
int g(final int i) { return i + 1; }
public static void 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
關鍵字),就表明自己不希望從這個類繼承,或者不允許其他任何人採取這種操作。換言之,出於這樣或那樣的原因,我們的類肯定不需要進行任何改變;或者出於安全方面的理由,我們不希望進行子類化(子類處理)。
除此以外,我們或許還考慮到執行效率的問題,並想確保涉及這個類各對象的所有行動都要儘可能地有效。如下所示:
//: Jurassic.java
// Making an entire class final
class SmallBrain {}
final class Dinosaur {
int i = 7;
int j = 1;
SmallBrain x = new SmallBrain();
void f() {}
}
//! class Further extends Dinosaur {}
// error: Cannot extend final class 'Dinosaur'
public class Jurassic {
public static void 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
(同步的)。正如在第14章要講到的那樣,這會造成顯著的性能開銷,可能會把final提供的性能改善抵銷得一乾二淨。因此,程序員不得不猜測到底應該在哪裡進行優化。在標準庫里居然採用瞭如此笨拙的設計,真不敢想象會在程序員裡引發什麼樣的情緒。
另一個值得注意的是Hashtable
(散列表),它是另一個重要的標準類。該類沒有采用任何final
方法。正如我們在本書其他地方提到的那樣,顯然一些類的設計人員與其他設計人員有著全然不同的素質(注意比較Hashtable
極短的方法名與Vector
的方法名)。對類庫的用戶來說,這顯然是不應該如此輕易就能看出的。一個產品的設計變得不一致後,會加大用戶的工作量。這也從另一個側面強調了代碼設計與檢查時需要很強的責任心。
在許多傳統語言裡,程序都是作為啟動過程的一部分一次性載入的。隨後進行的是初始化,再是正式執行程序。在這些語言中,必須對初始化過程進行慎重的控制,保證static
數據的初始化不會帶來麻煩。比如在一個static
數據獲得初始化之前,就有另一個static
數據希望它是一個有效值,那麼在C++中就會造成問題。
Java則沒有這樣的問題,因為它採用了不同的裝載方法。由於Java中的一切東西都是對象,所以許多活動變得更加簡單,這個問題便是其中的一例。正如下一章會講到的那樣,每個對象的代碼都存在於獨立的文件中。除非真的需要代碼,否則那個文件是不會載入的。通常,我們可認為除非那個類的一個對象構造完畢,否則代碼不會真的載入。由於static
方法存在一些細微的歧義,所以也能認為“類代碼在首次使用的時候載入”。
首次使用的地方也是static
初始化發生的地方。裝載的時候,所有static
對象和static
代碼塊都會按照本來的順序初始化(亦即它們在類定義代碼裡寫入的順序)。當然,static
數據只會初始化一次。
我們有必要對整個初始化過程有所認識,其中包括繼承,對這個過程中發生的事情有一個整體性的概念。請觀察下述代碼:
//: Beetle.java
// The full process of initialization.
class Insect {
int i = 9;
int j;
Insect() {
prt("i = " + i + ", j = " + j);
j = 39;
}
static int x1 =
prt("static Insect.x1 initialized");
static int prt(String s) {
System.out.println(s);
return 47;
}
}
public class Beetle extends Insect {
int k = prt("Beetle.k initialized");
Beetle() {
prt("k = " + k);
prt("j = " + j);
}
static int x2 =
prt("static Beetle.x2 initialized");
static int prt(String s) {
System.out.println(s);
return 63;
}
public static void main(String[] args) {
prt("Beetle constructor");
Beetle b = new Beetle();
}
} ///:~
該程序的輸出如下:
static Insect.x initialized
static Beetle.x initialized
Beetle constructor
i = 9, j = 0
Beetle.k initialized
k = 63
j = 39
對Beetle
運行java
時,發生的第一件事情是裝載程序到外面找到那個類。在裝載過程中,裝載程序注意它有一個基類(即extends
關鍵字要表達的意思),所以隨之將其載入。無論是否準備生成那個基類的一個對象,這個過程都會發生(請試著將對象的創建代碼當作註釋標註出來,自己去證實)。
若基類含有另一個基類,則另一個基類隨即也會載入,以此類推。接下來,會在根基類(此時是Insect
)執行static
初始化,再在下一個派生類執行,以此類推。保證這個順序是非常關鍵的,因為派生類的初始化可能要依賴於對基類成員的正確初始化。
此時,必要的類已全部裝載完畢,所以能夠創建對象。首先,這個對象中的所有基本數據類型都會設成它們的默認值,而將對象引用設為null
。隨後會調用基類構造器。在這種情況下,調用是自動進行的。但也完全可以用super
來自行指定構造器調用(就象在Beetle()
構造器中的第一個操作一樣)。基類的構建採用與派生類構造器完全相同的處理過程。基礎順構造器完成以後,實例變量會按本來的順序得以初始化。最後,執行構造器剩餘的主體部分。
無論繼承還是組合,我們都可以在現有類型的基礎上創建一個新類型。但在典型情況下,我們通過組合來實現現有類型的“複用”或“重複使用”,將其作為新類型基礎實現過程的一部分使用。但如果想實現接口的“複用”,就應使用繼承。由於派生或派生出來的類擁有基類的接口,所以能夠將其“向上轉換”為基類。對於下一章要講述的多態性問題,這一點是至關重要的。
儘管繼承在面向對象的程序設計中得到了特別的強調,但在實際啟動一個設計時,最好還是先考慮採用組合技術。只有在特別必要的時候,才應考慮採用繼承技術(下一章還會講到這個問題)。組合顯得更加靈活。但是,通過對自己的成員類型應用一些繼承技巧,可在運行期準確改變那些成員對象的類型,由此可改變它們的行為。
儘管對於快速項目開發來說,通過組合和繼承實現的代碼複用具有很大的幫助作用。但在允許其他程序員完全依賴它之前,一般都希望能重新設計自己的類結構。我們理想的類結構應該是每個類都有自己特定的用途。它們不能過大(如集成的功能太多,則很難實現它的複用),也不能過小(造成不能由自己使用,或者不能增添新功能)。最終實現的類應該能夠方便地複用。
(1) 用默認構造器(空參數列表)創建兩個類:A
和B
,令它們自己聲明自己。從A
繼承一個名為C
的新類,並在C
內創建一個成員B
。不要為C
創建一個構造器。創建類C
的一個對象,並觀察結果。
(2) 修改練習1,使A
和B
都有含有參數的構造器,則不是採用默認構造器。為C
寫一個構造器,並在C
的構造器中執行所有初始化工作。
(3) 使用文件Cartoon.java
,將Cartoon
類的構造器代碼變成註釋內容標註出去。解釋會發生什麼事情。
(4) 使用文件Chess.java
,將Chess
類的構造器代碼作為註釋標註出去。同樣解釋會發生什麼。