Thinking in Java(6-1) Java 組合與繼承語法詳解
在 Java 程式設計中,理解組合(Composition)與繼承(Inheritance)的語法是打造健全且可維護程式碼的基石。本篇文章將深入探討這兩種技術,並解釋物件引用、基礎類別初始化,以及委派模式等相關概念。
組合語法(Composition Syntax)
組合是透過在新類別中包含現有物件的引用來實現的。這種方式允許我們在不繼承其介面的情況下,使用現有類別的功能。
class WaterSource {
private String s;
WaterSource() {
System.out.println("WaterSource()");
s = "Constructed";
}
public String toString() { return s; }
}
public class SprinklerSystem {
private String valve1, valve2, valve3, valve4;
private WaterSource source = new WaterSource();
private int i;
private float f;
public String toString() {
return
"valve1 = " + valve1 + " " +
"valve2 = " + valve2 + " " +
"valve3 = " + valve3 + " " +
"valve4 = " + valve4 + "\n" +
"i = " + i + " " + "f = " + f + " " +
"source = " + source;
}
public static void main(String[] args) {
SprinklerSystem sprinklers = new SprinklerSystem();
System.out.println(sprinklers);
}
} /* Output:
WaterSource()
valve1 = null valve2 = null valve3 = null valve4 = null
i = 0 f = 0.0 source = Constructed
*/
在上述範例中,SprinklerSystem
類別包含了一個 WaterSource
物件的引用。我們透過覆寫 toString()
方法,讓當前類別的物件能夠以易讀的方式輸出。
關於 toString()
方法
- 自動調用:每個非基本資料類型的物件都有一個
toString()
方法。當編譯器需要一個String
但你只有一個物件時,編譯器會自動調用該物件的toString()
方法。 - 範例說明:在
SprinklerSystem.toString()
中,"source = " + source
會觸發source
的toString()
方法,因為我們試圖將一個String
與一個物件相加。
初始化物件引用
編譯器不會為每個物件引用自動建立預設對象,因為這可能會帶來不必要的性能負擔。我們有幾種方式來初始化這些引用:
- 在定義時初始化:直接在聲明變數時給予初始值。
- 在建構子中初始化:在類別的建構子中進行初始化。
- 惰性初始化(Lazy Initialization):在實際需要使用物件之前才進行初始化,這有助於提升性能。
- 實例初始化區塊(Instance Initialization Block):使用
{}
包裹的程式碼塊,可以在建構子之前執行。
以下是更複雜的範例,展示了上述各種初始化方式:
class Soap {
private String s;
Soap() {
print("Soap()");
s = "Constructed";
}
public String toString() { return s; }
}
public class Bath {
private String // Initializing at point of definition:
s1 = "Happy",
s2 = "Happy",
s3, s4;
private Soap castille;
private int i;
private float toy;
public Bath() {
print("Inside Bath()");
s3 = "Joy";
toy = 3.14f;
castille = new Soap();
}
// Instance initialization:
{ i = 47; }
public String toString() {
if(s4 == null) // Delayed initialization:
s4 = "Joy";
return
"s1 = " + s1 + "\n" +
"s2 = " + s2 + "\n" +
"s3 = " + s3 + "\n" +
"s4 = " + s4 + "\n" +
"i = " + i + "\n" +
"toy = " + toy + "\n" +
"castille = " + castille;
}
public static void main(String[] args) {
Bath b = new Bath();
System.out.println(b);
}
} /* Output:
Inside Bath()
Soap()
s1 = Happy
s2 = Happy
s3 = Joy
s4 = Joy
i = 47
toy = 3.14
castille = Constructed
*/
繼承語法(Inheritance Syntax)
繼承允許我們創建一個新類別,這個新類別不僅包含基礎類別的所有屬性和方法,還可以新增或覆寫現有的方法。
- 基本語法:使用
extends
關鍵字來指定要繼承的基礎類別。 - 自動繼承:如果沒有明確指定,所有類別都會默認繼承自 Java 的根類別
Object
。
以下是一個繼承的範例:
class Cleanser {
private String s = "Cleanser";
public void append(String a) { s += a; }
public void dilute() { append(" dilute()"); }
public void apply() { append(" apply()"); }
public void scrub() { append(" scrub()"); }
public String toString() { return s; }
public static void main(String[] args) {
Cleanser x = new Cleanser();
x.dilute(); x.apply(); x.scrub();
print(x);
}
}
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();
print(x);
print("Testing base class:");
Cleanser.main(args);
}
} /* Output:
Cleanser dilute() apply() Detergent.scrub() scrub() foam()
Testing base class:
Cleanser dilute() apply() scrub()
*/
使用 super
關鍵字
- 意義:
super
關鍵字用於引用直接父類別的成員方法或變數。 - 範例:在
Detergent.scrub()
方法中,我們使用super.scrub()
來調用基礎類別的scrub()
方法,避免了無限遞迴的問題。
初始化基礎類別
在建立子類別的物件時,基礎類別的建構子會首先被調用。這是因為子類別的建構子可能需要依賴基礎類別的初始化。
class Art {
Art() { System.out.println("Art constructor"); }
}
class Drawing extends Art {
Drawing() { System.out.println("Drawing constructor"); }
}
public class Cartoon extends Drawing {
public Cartoon() { System.out.println("Cartoon constructor"); }
public static void main(String[] args) {
Cartoon x = new Cartoon();
}
} /* Output:
Art constructor
Drawing constructor
Cartoon constructor
*/
在這個範例中,物件的建立順序是從最基礎的類別開始,依序向外擴展。這確保了基礎類別的初始化在子類別使用它之前完成。
帶參數的建構子
如果基礎類別沒有無參數的建構子,或者我們需要調用基礎類別的特定建構子,必須在子類別的建構子中明確地使用 super
關鍵字來調用。
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();
}
} /* Output:
Game constructor
BoardGame constructor
Chess constructor
*/
委派模式(Delegation)
委派(Delegation)是一種設計模式,允許我們在新類別中包含一個成員物件,並將該物件的方法暴露出來,就像繼承一樣。雖然 Java 並沒有提供直接的語法支援,但我們可以透過手動的方式來實現。
為何使用委派模式?
- 控制權:委派允許我們對暴露的方法有更多的控制,因為我們可以選擇性地提供某些方法,而不必全部繼承。
- 避免過度繼承:在某些情況下,繼承會導致類別之間的耦合度過高,使用委派可以降低這種風險。
- 更靈活的設計:委派使我們可以組合多個物件的功能,而不受限於單一繼承的限制。
委派的實現方式
我們可以透過在新類別中包含一個現有物件的實例,然後在新類別中定義方法,這些方法內部實際上是調用成員物件的對應方法。
以下是使用繼承的方式實現的範例:
public class SpaceShipControls {
void up(int velocity) {}
void down(int velocity) {}
void left(int velocity) {}
void right(int velocity) {}
void forward(int velocity) {}
void back(int velocity) {}
void turboBoost() {}
}
public class SpaceShip extends SpaceShipControls {
private String name;
public SpaceShip(String name) { this.name = name; }
public String toString() { return name; }
public static void main(String[] args) {
SpaceShip protector = new SpaceShip("NSEA Protector");
protector.forward(100);
}
}
在上述範例中,SpaceShip
繼承了 SpaceShipControls
,因此可以直接使用其所有方法。然而,這意味著 SpaceShip
是一種 SpaceShipControls
,這在語意上可能不太合適。
使用委派模式改寫
透過委派,我們可以改寫上述範例,使 SpaceShip
包含一個 SpaceShipControls
的實例,並選擇性地暴露需要的方法。
public class SpaceShipDelegation {
private String name;
private SpaceShipControls controls = new SpaceShipControls();
public SpaceShipDelegation(String name) {
this.name = name;
}
// 委派方法
public void back(int velocity) { controls.back(velocity); }
public void down(int velocity) { controls.down(velocity); }
public void forward(int velocity) { controls.forward(velocity); }
public void left(int velocity) { controls.left(velocity); }
public void right(int velocity) { controls.right(velocity); }
public void turboBoost() { controls.turboBoost(); }
public void up(int velocity) { controls.up(velocity); }
public static void main(String[] args) {
SpaceShipDelegation protector = new SpaceShipDelegation("NSEA Protector");
protector.forward(100);
}
}
在這個改寫的範例中:
- 成員物件:
SpaceShipDelegation
包含了一個SpaceShipControls
的實例controls
。 - 委派方法:我們在
SpaceShipDelegation
中定義了與SpaceShipControls
相同的方法,這些方法內部實際上是調用controls
的對應方法。 - 語意更清晰:
SpaceShipDelegation
並不是一種SpaceShipControls
,而是擁有一個控制裝置,這更符合實際情況。
深入理解委派模式
委派模式的關鍵在於將行為委派給另一個物件。這使得我們可以在不改變介面的情況下,更改內部的實現。例如,如果未來我們需要更換控制裝置,只需替換成員物件,而不必改變類別的介面。
優點
- 高可維護性:修改或替換委派的物件,不會影響使用該類別的其他部分。
- 靈活性:可以動態地決定要委派哪些方法,甚至可以在運行時改變委派的物件。
- 避免繼承的缺點:繼承有時會造成類別之間過度緊密的耦合,委派可以減少這種風險。
缺點
- 額外的撰寫工作:需要手動撰寫委派的方法,可能會增加程式碼的量。
- 效能開銷:每次方法調用都需要經過一層轉發,可能會有輕微的效能損耗。
何時使用委派模式?
- 需要更好的控制權:當你需要對某些方法的存取進行限制或改變其行為時。
- 避免不必要的繼承:當繼承會導致語意不清或過度耦合時,委派是更好的選擇。
- 動態行為改變:當你需要在運行時更改物件的行為時,可以使用委派模式。
實際應用範例
假設我們有一個音樂播放器應用程式,需要支援多種音訊格式。我們可以定義一個 AudioPlayer
類別,並使用委派模式來處理不同的音訊格式。
interface AudioDecoder {
void decode(String fileName);
}
class MP3Decoder implements AudioDecoder {
public void decode(String fileName) {
System.out.println("Decoding MP3 file: " + fileName);
}
}
class WAVDecoder implements AudioDecoder {
public void decode(String fileName) {
System.out.println("Decoding WAV file: " + fileName);
}
}
public class AudioPlayer {
private AudioDecoder decoder;
public void setDecoder(AudioDecoder decoder) {
this.decoder = decoder;
}
public void play(String fileName) {
decoder.decode(fileName);
System.out.println("Playing audio file: " + fileName);
}
public static void main(String[] args) {
AudioPlayer player = new AudioPlayer();
player.setDecoder(new MP3Decoder());
player.play("song.mp3");
player.setDecoder(new WAVDecoder());
player.play("song.wav");
}
}
在這個範例中:
- 委派物件:
AudioPlayer
委派了解碼的功能給AudioDecoder
。 - 靈活性:可以在運行時改變
decoder
,以支援不同的音訊格式。 - 分離關注點:
AudioPlayer
不需要知道解碼的細節,只需調用decoder.decode()
。
結合使用組合與繼承
在實際應用中,我們經常需要同時使用組合和繼承來達到最佳的設計效果。
class Plate {
Plate(int i) {
print("Plate constructor");
}
}
class DinnerPlate extends Plate {
DinnerPlate(int i) {
super(i);
print("DinnerPlate constructor");
}
}
class Utensil {
Utensil(int i) {
print("Utensil constructor");
}
}
class Spoon extends Utensil {
Spoon(int i) {
super(i);
print("Spoon constructor");
}
}
class Fork extends Utensil {
Fork(int i) {
super(i);
print("Fork constructor");
}
}
class Knife extends Utensil {
Knife(int i) {
super(i);
print("Knife constructor");
}
}
// A cultural way of doing something:
class Custom {
Custom(int i) {
print("Custom constructor");
}
}
public class PlaceSetting extends Custom {
private Spoon sp;
private Fork frk;
private Knife kn;
private DinnerPlate pl;
public 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);
print("PlaceSetting constructor");
}
public static void main(String[] args) {
PlaceSetting x = new PlaceSetting(9);
}
} /* Output:
Custom constructor
Utensil constructor
Spoon constructor
Utensil constructor
Fork constructor
Utensil constructor
Knife constructor
Plate constructor
DinnerPlate constructor
PlaceSetting constructor
*/
在這個範例中,PlaceSetting
同時繼承了 Custom
類別,並組合了多個餐具物件。我們需要注意的是,編譯器不會自動初始化成員物件,因此必須手動在建構子中進行初始化。
名稱遮蔽(Name Hiding)
在繼承中,如果基礎類別有多個同名但參數不同的方法(方法多載),在子類別中定義同名方法並不會遮蔽基礎類別的其他版本。
class Homer {
char doh(char c) {
print("doh(char)");
return 'd';
}
float doh(float f) {
print("doh(float)");
return 1.0f;
}
}
class Milhouse {}
class Bart extends Homer {
void doh(Milhouse m) {
print("doh(Milhouse)");
}
}
public class Hide {
public static void main(String[] args) {
Bart b = new Bart();
b.doh(1);
b.doh('x');
b.doh(1.0f);
b.doh(new Milhouse());
}
} /* Output:
doh(float)
doh(char)
doh(float)
doh(Milhouse)
*/
在這個例子中,Bart
新增了一個接受 Milhouse
物件的方法 doh(Milhouse m)
,但並未遮蔽 Homer
類別中的其他 doh()
方法。
使用 @Override
註解
為了避免意外的名稱遮蔽,Java 提供了 @Override
註解,用於明確表示我們希望覆寫基礎類別的方法。如果編譯器發現沒有相應的方法可供覆寫,會產生錯誤。
// {CompileTimeError} (Won't compile)
// Message: method does not override a method from its superclass
class Lisa extends Homer {
@Override void doh(Milhouse m) {
System.out.println("doh(Milhouse)");
}
}
在組合與繼承之間選擇
- 組合(Has-a):適用於想要在新類別中使用現有類別的功能,但不需要其介面的情況。
- 繼承(Is-a):適用於新類別需要成為基礎類別的一種特殊形式的情況。
組合範例
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
public 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);
}
}
在這個範例中,Car
類別組合了 Engine
、Wheel
、Window
和 Door
等物件,構成了複雜的物件模型。
總結
理解組合與繼承的語法和應用場景,對於編寫高效、可維護的 Java 程式至關重要。組合允許我們在新類別中使用現有物件的功能,而繼承則讓我們能夠建立基礎類別的特化版本。委派模式則提供了一種靈活的方法來結合兩者的優點。
關鍵要點:
- 組合:使用現有物件的功能,適合 "Has-a" 關係。
- 繼承:建立基礎類別的特化版本,適合 "Is-a" 關係。
- 委派:在新類別中包含現有物件,並暴露其方法,取得繼承的便利性和組合的靈活性。
- 初始化:注意物件引用的初始化時機和方式,確保物件在使用前已正確建立。
- 名稱遮蔽:了解方法覆寫與多載的差異,使用
@Override
註解來避免潛在的錯誤。
透過對這些概念的深入理解,我們可以在開發中做出更明智的設計選擇,提高程式的品質和可維護性。
Comments ()