組合與繼承:從生活場景看 Java【Thinking in Java筆記(6-1)】

組合與繼承:從生活場景看 Java【Thinking in Java筆記(6-1)】
Photo by Tatiana Diakova / Unsplash

你有沒有想過:為什麼程式裡常看到 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)」比喻

  1. 宣告時初始化 (立即擁有空購物車):程式一載入 Shop 類別就創建一個空購物車,讓你隨時能 addItem(),不必再做其他準備
class Shop {
  private ShoppingCart cart = new ShoppingCart();
}
  1. 建構子中初始化 (使用者登入後建立購物車):直到 new Shop() 建構完成(如使用者登入),才 new ShoppingCart(),確保只有活躍用戶才占用資源
class Shop {
  private ShoppingCart cart;
  Shop() {
    cart = new ShoppingCart(); // 使用者一登入,就為他準備好購物車
  }
}
  1. 惰性初始化 (Lazy) (第一次加入商品時才建立):拖進購物車按鈕時才真正產生實例,避免一開始就浪費空間
class ShoppingSession {
  private ShoppingCart cart;
  public ShoppingCart getCart() {
    if (cart == null)
      cart = new ShoppingCart(); // 第一次調用時才 new
    return cart;
  }
}
  1. 實例初始化區塊 (類別加載時預先載入推薦清單):在任何建構子之前,初始化區塊就先跑,幫你一次性載入「精選推薦」
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
*/
  1. 欄位定義時初始化 (預先注水):就像一進浴室就注滿溫水 (s1 = "Happy", s2 = "Happy"),確保每次泡澡都能立刻享受舒適,不會拿到 null 冷水驚嚇
  2. 實例初始化區塊 (預熱水龍頭){ i = 47; } 於所有建構子執行前統一初始化,就像在每次泡澡前,先微微打開水龍頭讓溫水開始預熱
  3. 建構子內初始化 (注入溫泉粉):就像你踏進浴缸後,親手倒入一包溫泉粉 (s3toy),讓浴缸瞬間變身迷你溫泉,再擠出香皂來洗澡 (new Soap()),確保每次都有溫泉般的舒緩體驗
  4. 惰性初始化 (加贈精油包):就像當浴室迎來賓客,或你第一次呼叫 toString() 時,才額外加贈一包精油包 (s4),省下不必要的初始化開銷

執行順序檢視

  • 首先觸發 i = 47(實例區塊)
  • 接著進入 Bath() 建構子體
  • 呼叫 print("Inside Bath()")new Soap()
  • 最後在 toString() 中完成 s4 的惰性設定

透過此輸出可清晰驗證不同初始化策略的執行時機與欄位狀態

繼承語法(Inheritance Syntax):當「是」比「有」更合適

當一個類別真的是另一個類別的「進階版本」時,就該考慮繼承。繼承就像先拿到父類別的工具箱(屬性和方法),然後在此基礎上加裝新配件或改造現有功能

什麼時候用繼承?

  1. 語意檢查SportsCar 「是一種」Car,才用 extends
  2. 重用需求:子類想拿父類的方法和屬性,減少重複程式碼
  3. 耦合考量:繼承耦合度高,子類得對父類實作細節瞭若指掌

基本語法:

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 類別:

  1. Override(覆寫):在子類的 scrub() 中先呼叫 append(" Detergent.scrub()"),再用 super.scrub() 加上原本的刷洗,就像在普通海綿上先噴香氛,再進行原生刷洗
  2. 延伸介面:新增 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(...),編譯器會預設呼叫父類的無參建構子,但若不存在就會編譯失敗,或者初始化邏輯跑錯順序
  1. 存取父類別欄位super.field 可以讀取或修改父類別成員(若為 protectedpublic
  2. 避免無限遞迴:若在覆寫方法中改為直接呼叫同名方法(如 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
*/
  1. 當你 new Cartoon() 時,程式先呼叫最上層父類的 Art(),就像蓋房子先打地基,印出 Art constructor
  2. 接著呼叫中間層父類 Drawing(),就像架設房子的結構骨架,印出 Drawing constructor
  3. 最後執行子類 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
*/

步驟拆解

  1. 啟動建構:呼叫 new Chess(),在記憶體劃分空間並準備跑 Chess() 建構子
  2. 子類別給票Chess() 第一行的 super(11),先跳進 BoardGame(int level)。同時,把 level = 11 傳給父類
  3. 父類別再給票BoardGame(int) 內第一行 super(level),接著呼叫 Game(int level),印出:Game constructor - level 11
  4. 建構父類別:回到 BoardGame(int) 建構子本體,印出:BoardGame constructor - level 11
  5. 完成子類建構:最後回到 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 Spoonnew Forknew Knifenew DinnerPlate,就像把各種餐具一一擺上桌,形成完整的擺設
  • 執行流程
    1. super(i+1) → 呼叫 Custom 建構子,印出 Custom constructor
    2. new Spoon(i+2)Utensil constructorSpoon constructor
    3. new Fork(i+3)Utensil constructorFork constructor
    4. new Knife(i+4)Utensil constructorKnife constructor
    5. new DinnerPlate(i+5)Plate constructorDinnerPlate constructor
    6. 最後印出 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,天然繼承了汽車的引擎、車輪及行駛能力,再加裝渦輪或改裝外觀。繼承耦合度高,需謹慎使用

快速判斷

  1. 詢問自己:XXX 是不是那類型的一種?
  2. 答案是「是」,用繼承;否則只要「借用」功能,就用組合
// 組合:Car 有一個 Engine
class Car {
  private Engine engine = new Engine();
  // ...
}

// 繼承:SportsCar 是一種 Car
class SportsCar extends Car {
  public void boost() { /* 新增渦輪加速 */ }
}

這種策略能讓你的設計既保持靈活性(組合),也在需要時享受重用優勢(繼承)

結論:打好組合與繼承基礎,迎向更靈活的委派

回顧本章重點:

  • 組合 (Has‑a):借用現有物件功能,維持低耦合與易替換,就像烤肉架使用瓦斯桶
  • 繼承 (Is‑a):創建特殊版本,重用父類行為並透過 overridesuper(...) 擴充,就像豪華車型基於標準車款打造
  • 初始化策略與順序:根據需求選擇欄位、建構子、實例區塊或惰性初始化,並掌握 super(...) 的調用流程,確保每層都正確打好基礎

下章預告:我們將深入 委派模式 (Delegation),學習如何在多重功能與動態替換時,靈活分工與轉發,為你的程式設計注入更強的彈性與可維護性!