組合與繼承:從生活場景看 Java【Thinking in Java筆記(6-1)】
你有沒有想過:為什麼程式裡常看到 Car extends Vehicle
,卻又常看到 Car has-a Engine
?這到底有什麼差別?對程式新手來說,分不清「是一種」(Is‑a) 與「有一個」(Has‑a),常常寫出高耦合、錯誤初始化的程式。今天我們就用日常比喻,讓非軟體背景的小白也能輕鬆抓住重點
組合語法(Composition Syntax):有就用,不必成為它
想像你辦烤肉派對,烤肉架 (Grill) 和瓦斯桶 (GasTank) 分得清清楚楚:烤肉架只有架子和烤盤,不會自己生產瓦斯;它只是從瓦斯桶拿瓦斯來用。這就是「組合」:一個物件裡「有」另一個物件
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
),而不是自己變成水井- 當
new SprinklerSystem()
時,程式會先執行new WaterSource()
,印出一行「WaterSource() 建構完成」,再將它「裝進」灌溉系統。這就像先把可用的瓦斯桶搬進烤肉區,再準備烤肉 - 在
System.out.println(sys)
這行,Java 自動把source
物件轉成字串,其實是呼叫了source.toString()
,就像掃描 QR code,自動顯示內容
關於 toString()
方法
你有沒有想過,程式裡的物件要怎麼「變成文字」?當你在 println
裡寫:
System.out.println("source = " + source);
Java 其實偷偷對 source
呼叫了 toString()
,把物件狀態轉為可讀字串,然後一起印出
- 為什麼要覆寫
toString()
?:預設的toString()
只會印出類別名稱加一串亂碼(HashCode),根本看不出內容。自訂後,就能顯示有意義的資訊,讓排錯或日誌更易閱讀 - 隱式呼叫:只要把物件和字串串在一起,或直接
System.out.println(obj)
,Java 會自動跑obj.toString()
,就像自助結帳時,掃條碼就自動跳出價格 - 範例拆解:
"source = " + source // = "source = " + source.toString()
這樣一來,就能把 WaterSource
建構時設定的 "Ready"
狀態,直接插入輸出,讓你立刻知道水井準備好了
四種物件初始化方式:什麼時候該 "搬家具"
想像你在網路商店逛街,系統裡的購物車只有在你需要的時候才會產生;其他時候,它都不存在,節省資源又清爽。以下四種常見做法,搭配「購物車 (ShoppingCart)」比喻
- 宣告時初始化 (立即擁有空購物車):程式一載入
Shop
類別就創建一個空購物車,讓你隨時能addItem()
,不必再做其他準備
class Shop {
private ShoppingCart cart = new ShoppingCart();
}
- 建構子中初始化 (使用者登入後建立購物車):直到
new Shop()
建構完成(如使用者登入),才new ShoppingCart()
,確保只有活躍用戶才占用資源
class Shop {
private ShoppingCart cart;
Shop() {
cart = new ShoppingCart(); // 使用者一登入,就為他準備好購物車
}
}
- 惰性初始化 (Lazy) (第一次加入商品時才建立):拖進購物車按鈕時才真正產生實例,避免一開始就浪費空間
class ShoppingSession {
private ShoppingCart cart;
public ShoppingCart getCart() {
if (cart == null)
cart = new ShoppingCart(); // 第一次調用時才 new
return cart;
}
}
- 實例初始化區塊 (類別加載時預先載入推薦清單):在任何建構子之前,初始化區塊就先跑,幫你一次性載入「精選推薦」
class RecommendationEngine {
private List<String> recommendations;
{ recommendations = loadTopPicks(); } // 所有建構子跑前先執行
}
這些策略就像在商店裡:有時一進門就給你一張空的購物車,有時等你選好商品才拿車子,或是在你登錄後才幫你準備,都能依據需要靈活運用
以下是更複雜的範例,展示了上述各種初始化方式:
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
*/
- 欄位定義時初始化 (預先注水):就像一進浴室就注滿溫水 (
s1 = "Happy"
,s2 = "Happy"
),確保每次泡澡都能立刻享受舒適,不會拿到null
冷水驚嚇 - 實例初始化區塊 (預熱水龍頭):
{ i = 47; }
於所有建構子執行前統一初始化,就像在每次泡澡前,先微微打開水龍頭讓溫水開始預熱 - 建構子內初始化 (注入溫泉粉):就像你踏進浴缸後,親手倒入一包溫泉粉 (
s3
、toy
),讓浴缸瞬間變身迷你溫泉,再擠出香皂來洗澡 (new Soap()
),確保每次都有溫泉般的舒緩體驗 - 惰性初始化 (加贈精油包):就像當浴室迎來賓客,或你第一次呼叫
toString()
時,才額外加贈一包精油包 (s4
),省下不必要的初始化開銷
執行順序檢視:
- 首先觸發
i = 47
(實例區塊) - 接著進入
Bath()
建構子體 - 呼叫
print("Inside Bath()")
、new Soap()
- 最後在
toString()
中完成s4
的惰性設定
透過此輸出可清晰驗證不同初始化策略的執行時機與欄位狀態
繼承語法(Inheritance Syntax):當「是」比「有」更合適
當一個類別真的是另一個類別的「進階版本」時,就該考慮繼承。繼承就像先拿到父類別的工具箱(屬性和方法),然後在此基礎上加裝新配件或改造現有功能
什麼時候用繼承?
- 語意檢查:
SportsCar
「是一種」Car
,才用extends
- 重用需求:子類想拿父類的方法和屬性,減少重複程式碼
- 耦合考量:繼承耦合度高,子類得對父類實作細節瞭若指掌
基本語法:
class 子類名 extends 父類名 { /* 額外的方法與覆寫 */ }
若未明確使用extends
,所有類別預設繼承自Object
,可呼叫toString()
、equals()
等公共方法
範例:Cleanser 與 Detergent
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()
*/
在這個範例中,Detergent
類別:
- Override(覆寫):在子類的
scrub()
中先呼叫append(" Detergent.scrub()")
,再用super.scrub()
加上原本的刷洗,就像在普通海綿上先噴香氛,再進行原生刷洗 - 延伸介面:新增
foam()
方法,為Detergent
加入專屬泡沫功能,原本Cleanser
沒有的行為
實際流程:
Detergent x = new Detergent();
x.dilute(); // 繼承自 Cleanser
x.apply(); // 繼承自 Cleanser
x.scrub(); // 先香氛後刷洗
x.foam(); // 獨有泡泡功能
System.out.println(x);
輸出會結合基本清潔與新香氛泡泡,清楚對比 Cleanser
本身的行為與 Detergent
的擴充功能
建構子順序與 super(...)
你有沒有想過,當你 new
一個子類物件時,程式到底怎麼跑建構子?Java 會先把父類建構子跑完,才跑子類建構子,就像蓋房子要先打地基再蓋樓上。super(...)
就像提前交出的地基圖,確保父類先初始化
為什麼要呼叫 super(...)
?
- 父類只有帶參建構子:想像餐廳只給持有「貴賓券」的客人(參數)進門,沒有無參版本的「票」,子類就必須在第一行呼叫
super(參數)
,才能打開大門並完成父類初始化 - 遵守初始化順序:Java 規定先跑父類建構子、再跑子類建構子;若不寫
super(...)
,編譯器會預設呼叫父類的無參建構子,但若不存在就會編譯失敗,或者初始化邏輯跑錯順序
- 存取父類別欄位:
super.field
可以讀取或修改父類別成員(若為protected
或public
) - 避免無限遞迴:若在覆寫方法中改為直接呼叫同名方法(如
scrub()
而非super.scrub()
),會導致方法不斷呼叫自身,直至 StackOverflowError,就像對著平行的兩面鏡子一直看下去,永無止境
初始化基礎類別:打造穩固地基
想像你在蓋一座三層小屋,Java 會先打地基(最上層父類建構子),再一層一層往上蓋,最後才到子類建構子完成裝修。這套依序執行的規則,能確保每個層級都先建立穩固基礎,才有安全的環境讓子類發揮功能
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
*/
- 當你
new Cartoon()
時,程式先呼叫最上層父類的Art()
,就像蓋房子先打地基,印出Art constructor
。 - 接著呼叫中間層父類
Drawing()
,就像架設房子的結構骨架,印出Drawing constructor
- 最後執行子類
Cartoon()
,就像完成屋頂與室內裝潢,印出Cartoon constructor
注意:若父類別僅提供帶參數的建構子,子類必須以 super(參數)
顯式呼叫,否則編譯錯誤,因為找不到父類的無參數建構子
帶參數的建構子:沒有票,不能進場
想像父類是一家只對 VIP 開放的音樂會,只發「VIP 票」(參數) 給受邀嘉賓。子類在建構子第一行呼叫 super(參數)
,就像把門票交給保安,才能順利進入並完成父類初始化;若不交,就會被擋在門外,編譯器提示「找不到無參構造」,直接報錯
class Game {
Game(int level) {
System.out.println("Game constructor - level " + level);
}
}
class BoardGame extends Game {
BoardGame(int level) {
super(level);
System.out.println("BoardGame constructor - level " + level);
}
}
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 - level 11
BoardGame constructor - level 11
Chess constructor
*/
步驟拆解:
- 啟動建構:呼叫
new Chess()
,在記憶體劃分空間並準備跑Chess()
建構子 - 子類別給票:
Chess()
第一行的super(11)
,先跳進BoardGame(int level)
。同時,把level = 11
傳給父類 - 父類別再給票:
BoardGame(int)
內第一行super(level)
,接著呼叫Game(int level)
,印出:Game constructor - level 11
- 建構父類別:回到
BoardGame(int)
建構子本體,印出:BoardGame constructor - level 11
- 完成子類建構:最後回到
Chess()
建構子,印出:Chess constructor
在這個範例 super(level)
就是交出這張票,讓父類完成初始化並讓你進場,掌握這個模式後,遇到父類別只給參數版建構子,就能迅速寫出正確程式,避免編譯卡關
結合使用組合與繼承
在日常開發中,我們往往需要讓一個類別同時『是』某種基礎類型(Is‑a)和『擁有』其他物件(Has‑a)
拿 PlaceSetting 來說,它本身就是一個 Custom(定製風格)的實例,同時培養出 Spoon、Fork、Knife 和 DinnerPlate 等餐具,才能完成整套餐桌擺設
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
*/
- 繼承 (Is‑a):
PlaceSetting extends Custom
,先選定整體文化風格,就像先定好整套餐具的主題顏色和材質 - 組合 (Has‑a):在建構子裡
new Spoon
、new Fork
、new Knife
、new DinnerPlate
,就像把各種餐具一一擺上桌,形成完整的擺設 - 執行流程:
super(i+1)
→ 呼叫Custom
建構子,印出Custom constructor
new Spoon(i+2)
→Utensil constructor
、Spoon constructor
new Fork(i+3)
→Utensil constructor
、Fork constructor
new Knife(i+4)
→Utensil constructor
、Knife constructor
new DinnerPlate(i+5)
→Plate constructor
、DinnerPlate constructor
- 最後印出
PlaceSetting constructor
,完成整體初始化
這樣「先有整體藍圖 (父類) 再逐一組合 (成員物件)」的設計,既保有一致性,也能輕鬆替換或增加零件
名稱遮蔽(Name Hiding)
在繼承中,如果基礎類別有多個同名但參數不同的方法 (Method Overload),子類若新增同名新方法,只會「增加」該方法,不會隱藏父類的其他版本。就像餐廳菜單上已有不同口味的拿鐵,你再推出燕麥拿鐵,不會把原本的牛奶和豆漿拿鐵選項下架
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(char)
和 doh(float)
並未被移除或覆蓋。呼叫時,Java 會根據傳入參數自動匹配最適合的方法,不會因為子類新增方法而隱藏父類的重載版本
使用 @Override
註解
在子類覆寫父類方法時,筆誤或參數不匹配很容易導致實際上並未覆寫到想要的目標方法。Java 提供 @Override
註解,就像在文件上貼上醒目標籤,請編譯器幫你驗證「真的有這個方法可以覆寫嗎?」如果沒有匹配,就會在編譯時出現錯誤,讓你早一點發現問題
// {CompileTimeError} (Won't compile)
// 下面這段程式會編譯失敗,因為 Homer 並沒有 doh(Milhouse) 的方法
class Lisa extends Homer {
@Override void doh(Milhouse m) {
System.out.println("doh(Milhouse)");
}
}
使用 @Override
的三大好處:
- 錯誤檢查:防止方法名稱或參數打錯,編譯期就能發現未匹配錯誤,減少隱性 Bug
- 可讀性:讓其他程式閱讀者一眼就能分辨這是刻意覆寫,不是新方法
- 維護性:若父類方法簽名日後改動,
@Override
會提示衝突位置,幫助你掌握影響範圍,降低後續維護成本
在組合與繼承之間做抉擇
想像你在玩樂高積木:有時你會把不同模組(引擎、車輪、車門)拼裝在一起,組成一輛車;有時你想要的作品本身就是某種類型,例如從跑車模型衍生出賽道款,那就用繼承
- 組合 (Has‑a):當新類別需要別的物件功能,但它本身並不是那個物件;就像相機 App 有一個鏡頭 API,App 不是鏡頭。組合降低耦合度,能自由更換零件
- 繼承 (Is‑a):當新類別就是父類的特例;像跑車是一種Car,天然繼承了汽車的引擎、車輪及行駛能力,再加裝渦輪或改裝外觀。繼承耦合度高,需謹慎使用
快速判斷:
- 詢問自己:XXX 是不是那類型的一種?
- 答案是「是」,用繼承;否則只要「借用」功能,就用組合
// 組合:Car 有一個 Engine
class Car {
private Engine engine = new Engine();
// ...
}
// 繼承:SportsCar 是一種 Car
class SportsCar extends Car {
public void boost() { /* 新增渦輪加速 */ }
}
這種策略能讓你的設計既保持靈活性(組合),也在需要時享受重用優勢(繼承)
結論:打好組合與繼承基礎,迎向更靈活的委派
回顧本章重點:
- 組合 (Has‑a):借用現有物件功能,維持低耦合與易替換,就像烤肉架使用瓦斯桶
- 繼承 (Is‑a):創建特殊版本,重用父類行為並透過
override
與super(...)
擴充,就像豪華車型基於標準車款打造 - 初始化策略與順序:根據需求選擇欄位、建構子、實例區塊或惰性初始化,並掌握
super(...)
的調用流程,確保每層都正確打好基礎
下章預告:我們將深入 委派模式 (Delegation),學習如何在多重功能與動態替換時,靈活分工與轉發,為你的程式設計注入更強的彈性與可維護性!
Comments ()