深入探討 Java 的協變回傳型別與繼承設計【Thinking in Java筆記(7-3)】

深入探討 Java 的協變回傳型別與繼承設計【Thinking in Java筆記(7-3)】
Photo by Scott Lord / Unsplash

你是不是也想過:為什麼覆寫方法時不能直接回傳子類別? 這就是 協變回傳型別(Covariant Return Type)要解決的痛點!同時,繼承 雖然是物件導向的萬用瑞士刀,用過頭卻可能把專案切成義大利麵。這篇文章會用零基礎友善的範例陪你拆招,一邊問你:什麼時候該拉出繼承這把刀?又有哪種場景改用組合(Composition)更保險?邊看邊思考吧

協變回傳型別(Covariant Return Types):你點菜,我端 子類別

如果餐廳菜單只承諾「給你一份主食」,但我要的是「奶油燉飯」而不是白飯,該怎麼辦? 在 Java 5 以前,覆寫方法就像這位固執店長——只能端出跟菜單上一模一樣的「主食」型別

Java 5 把店長升級,加入 Covariant Return Type:子類別覆寫方法時,可以回傳更細緻的子類別型別。呼叫端既拿到穩定的「主食」契約,又能享受「奶油燉飯」的真滋味

Java SE5(也就是 Java 5)引入了協變回傳型別的概念,這意味著在衍生類別中覆寫基礎類別的方法時,可以回傳基礎類別回傳型別的子類別。

class Grain {
  public String toString() { return "Grain"; }
}

class Wheat extends Grain {
  public String toString() { return "Wheat"; }
}

class Mill {
  Grain process() { return new Grain(); }
}

class WheatMill extends Mill {
  Wheat process() { return new Wheat(); }
}

public class CovariantReturn {
  public static void main(String[] args) {
    Mill m = new Mill();
    Grain g = m.process();
    System.out.println(g);
    m = new WheatMill();
    g = m.process();
    System.out.println(g);
  }
} /* Output:
Grain
Wheat
*/

為什麼編譯器沒翻桌? 因為 Wheat 本來就是 Grain 的子類別,回傳「更專精的型別」依舊安全。呼叫端若想使用 Wheat 的獨家功能,再自行向下轉型即可

如果 WheatMill.process() 回傳 Potato 呢?編譯器會不會立刻舉紅牌?

逐步拆餐券——剖析程式流程

  1. 建磨坊 (new Mill):變數 m 只保證「磨得出主食 (Grain)」,此時選的是一般磨坊
  2. 第一次磨穀 (m.process()):如約端出 Grain,沒有驚喜
  3. 換店 (m = new WheatMill()):同一張變數券指向「小麥專門店」,因為 WheatMillMill 的子類
  4. 第二次磨穀 (m.process()):呼叫點長得一樣,實際執行的是 WheatMill.process(),回傳 Wheat——更精準卻仍屬於 Grain 家族

想像你手上只拿到一張寫著「主食」的兌換券:
進便當店換到白飯(Grain),走進義式餐廳卻能換到燉飯(Wheat)。 票面合約沒變,菜色卻更對味——這就是 協變回傳型別 的魔法

一句話結論: 協變回傳型別 = 介面穩定 回傳 更精準 的子類,呼叫端免轉型,意圖秒懂

繼承設計:要「變身」還是「換裝」

學完多型,你可能產生「萬物皆可 extends」的錯覺,彷彿繼承是免費的 copy‑paste 鍊金術。但先問自己:真的需要再養一支子類? 每多拉一節繼承鏈,就在程式碼上刺青 —— 需求一轉彎,痛哭的是自己

更聰明的做法是先想 組合(Composition):把現成物件當樂高拼起來,想換就拔、沒戲就拆,程式碼不必背祖宗十八代的債

組合 像隨插隨拔的樂高,執行期想換型別就換
繼承 則在編譯期就寫死型別,靈活度瞬間腰斬

原始劇本 —— 繼承

class Actor {
  void act() {}
}

class HappyActor extends Actor {
  public void act() { print("HappyActor"); }
}

class SadActor extends Actor {
  public void act() { print("SadActor"); }
}

優點

  1. 呼叫端只要握著 Actor actor,安心喊 開演 (actor.act()) 就行——背後到底是 HappyActor 還是 SadActor 完全不用管
  2. 每個子類只需寫好自己的 act() 腳本,型別系統確保「演員不會忘詞」;讀程式的人也能秒懂:看到 HappyActor 就知道會笑場

缺點

  1. 多一種情緒就得長出一個 AngryActorBoredActor…,類別檔案像養兔子一樣瞬間繁殖,繼承樹立刻變成萬花筒
  2. 若日後要在 act() 裡加字幕或統計次數?你得在 每一隻 子類 patch 同一段邏輯,讓維護成本像滾雪球靠向你

換裝舞台 —— 組合

class Stage {
  private Actor actor = new HappyActor();
  public void change() { actor = new SadActor(); }
  public void performPlay() { actor.act(); }
}

public class Transmogrify {
  public static void main(String[] args) {
    Stage stage = new Stage();
    stage.performPlay();
    stage.change();
    stage.performPlay();
  }
} /* Output:
HappyActor
SadActor
*/

此時 Stage 擁有has‑a)一位 Actor ,像舞台租了一名臨時演員,想換人隨叫隨到;自己並 不是is‑a)演員本體,無須背負表演細節。 透過 change(),我們能在執行期直接喊「CUT!」換角不換舞台,繼承樹原封不動

何時選誰?

場景建議做法原因
子類永遠是父類「更精準」版本繼承Square is‑a Rectangle
行為需在 執行期 切換組合Logger 白天寫檔、夜晚丟網路
希望對外固定 API,裡面替換實作繼承使用者不必改呼叫方式
行為像 LEGO 要貼貼換換組合省下重造類別大樓
繼承像「變身」: 一次整形、永久定案
組合像「換裝」: 想脫就脫、隨時更換

純替換與擴充(Substitution vs. Extension)

你把手機殼換成不同顏色,裡面功能沒變——這叫「純替換」。如果你在手機上外掛遊戲手把,功能多了,這就是「擴充」

純替換 (Substitution)

  • 子類只 覆寫 現有方法,不新增成員
  • 向上轉型後,呼叫任何方法都安全,父類永遠「接得住」
  • 常見場景:同一 API 下替換策略、演算法

在這種設計中,基礎類別可以接收發送給衍生類別的任何訊息,因為它們具有完全相同的介面。我們只需將衍生類別向上轉型為基礎類別,並透過多型來處理

擴充 (Extension)

  • 子類除了覆寫,還加新武器
  • 向上轉型會隱形掉這些新方法;想用得先向下轉型
  • 適合「基礎功能 + 特色加值」的需求

但需要注意的是,當我們向上轉型為基礎類別時,子類別中擴充的部分將無法被訪問。如果我們需要使用子類別中特有的方法,必須進行向下轉型

選擇指南

你要做什麼?用純替換用擴充
保持 API 穩定,只改行為
API 要長新手、功能要升級
呼叫端不想碰 instanceof / cast
能接受必要時向下轉型

向下轉型與執行時型別識別(RTTI)

你會把大型 SUV 擠進機車專用車位嗎?勉強停得下,下一秒可能被拖吊或刮花。向下轉型(Down‑cast)也一樣:編譯器暫時放行,執行時卻可能丟出 ClassCastException

什麼是向上 / 向下轉型?

動作比喻安全性
向上轉型 Child → Parent把 "單車" 說成 "交通工具"100% 安全 — 子類永遠是父類的一員
向下轉型 Parent → Child把 "交通工具" 直接當 "單車"有風險 — 可能其實是汽車,踩踏板直接報廢

Java 如何保護你? — RTTI 關卡

  • 編譯期 允許你寫下轉型語法,因為理論上可行
  • 執行期 JVM 會幫你查身份證:
    • 若物件真的是目標子類 ⇒ 通行
    • 否則丟 ClassCastException,程式直接罷工

instanceof:先問再做

在轉型前先跑 身份驗證:這行為跟夜店保全一樣,先看證件,再決定放不放人進來。少了這一步,會不會爆 ClassCastException 就全靠運氣

傳統寫法(Java 5 以前)

if (obj instanceof MoreUseful) {
    MoreUseful mu = (MoreUseful) obj; // 手動 cast
    mu.u();                           // 安心使用子類 API
} else {
    // 不是想要的型別,另行處理或忽略
}
  • instanceof 回傳 boolean,告訴你物件是不是指定子類
  • 條件通過後再進行 (MoreUseful) obj 顯式轉型,JVM 這時已確定安全,不會噴錯

進階用法:Pattern Matching(Java 16+)

if (obj instanceof MoreUseful mu) {  // 在判斷式直接宣告變數
    mu.u();                          // 省掉一次手動 cast
}
  • obj instanceof MoreUseful mu 一旦為真,JVM 會自動把 obj 解構成變數 mu,作用域侷限在 if‑block 內
  • 好處:少打一對括號,少一次重複名稱,閱讀性與型別安全同時升級

先問再坐、不問就爆 — 永遠先驗證型別,再放心 cast/使用子類方法

完整示例 & 兩種結果

class Useful {
  public void f() {}
  public void g() {}
}

class MoreUseful extends Useful {
  public void f() {}
  public void g() {}
  public void u() {}
  public void v() {}
  public void w() {}
}	

public class RTTI {
  public static void main(String[] args) {
    Useful[] x = {
      new Useful(),
      new MoreUseful()
    };
    x[0].f();
    x[1].g();
    // Compile time: method not found in Useful:
    //! x[1].u();
    ((MoreUseful)x[1]).u(); // Downcast/RTTI
    ((MoreUseful)x[0]).u(); // Exception thrown
  }
}
  • 第一個元素是 Useful,被守衛攔下
  • 第二個元素是 MoreUseful,成功通過並呼叫子類方法

懶人心法

  1. 能不上轉就不上轉:頂多多寫幾個方法,少掉一堆 cast 煩惱
  2. 必須下轉前先 instanceof:就像先看箱子裡是不是貓,再決定要不要餵貓糧
  3. 拋例外要友善:捕捉 ClassCastException 並回報可讀訊息,千萬別讓使用者只看到一長串 stack trace

JVM 透過 RTTI 幫你把關,但還是先看清楚再下手,別把哈士奇硬塞進貓砂盆

結尾:三句口訣

  1. 協變回傳型別介面不動,回傳更精準
  2. 繼承 vs. 組合is‑a 用繼承,has‑a 拼樂高
  3. 向下轉型 + RTTI先問再做,不問就爆
把「型別」當合約、把「物件」當演員:台詞能改、衣服能換,但舞台別塌才是贏家