深入探討 Java 的協變回傳型別與繼承設計【Thinking in Java筆記(7-3)】
你是不是也想過:為什麼覆寫方法時不能直接回傳子類別? 這就是 協變回傳型別(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
呢?編譯器會不會立刻舉紅牌?
逐步拆餐券——剖析程式流程
- 建磨坊 (
new Mill
):變數m
只保證「磨得出主食 (Grain)」,此時選的是一般磨坊 - 第一次磨穀 (
m.process()
):如約端出Grain
,沒有驚喜 - 換店 (
m = new WheatMill()
):同一張變數券指向「小麥專門店」,因為WheatMill
是Mill
的子類 - 第二次磨穀 (
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"); }
}
優點:
- 呼叫端只要握著
Actor actor
,安心喊 開演 (actor.act()
) 就行——背後到底是HappyActor
還是SadActor
完全不用管 - 每個子類只需寫好自己的
act()
腳本,型別系統確保「演員不會忘詞」;讀程式的人也能秒懂:看到HappyActor
就知道會笑場
缺點:
- 多一種情緒就得長出一個
AngryActor
、BoredActor
…,類別檔案像養兔子一樣瞬間繁殖,繼承樹立刻變成萬花筒 - 若日後要在
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
,成功通過並呼叫子類方法
懶人心法
- 能不上轉就不上轉:頂多多寫幾個方法,少掉一堆
cast
煩惱 - 必須下轉前先
instanceof
:就像先看箱子裡是不是貓,再決定要不要餵貓糧 - 拋例外要友善:捕捉
ClassCastException
並回報可讀訊息,千萬別讓使用者只看到一長串 stack trace
JVM 透過 RTTI 幫你把關,但還是先看清楚再下手,別把哈士奇硬塞進貓砂盆
結尾:三句口訣
- 協變回傳型別 → 介面不動,回傳更精準
- 繼承 vs. 組合 → is‑a 用繼承,has‑a 拼樂高
- 向下轉型 + RTTI → 先問再做,不問就爆
把「型別」當合約、把「物件」當演員:台詞能改、衣服能換,但舞台別塌才是贏家
Comments ()