物件導向程式設計(OOP)有個標誌性的特性——繼承(Inheritance)。我們習慣用「狗是動物」、「主管是員工」這類 is-a(是一種) 的關係建立類別階層,看起來非常直觀,也是大多數人學 OOP 的第一堂課。
然而,繼承用錯了地方,很容易蓋出一棟「拆不掉的水泥大樓」——每新增一個需求,就得在原有結構上再疊一層,最終讓整個系統僵化難以維護。
根據 GoF(四人幫)在《Design Patterns》一書中的說法:
「偏好以物件組合取代類別繼承」(Favor object composition over class inheritance)
這是書中最核心的兩大設計原則之一。GoF 的作者 Erich Gamma 在訪談中也特別強調,設計師往往過度依賴繼承,而「繼承會把父類別的實作細節暴露給子類別」,進而破壞了封裝性。
值得一提的是,在 Day 8 我們討論里氏替換原則(LSP)時,已經看過繼承的陷阱——當企鵝被迫繼承「會飛的鳥」時,子類別根本無法替換父類別,最終在執行時期拋出例外。組合優於繼承(Composition over Inheritance)正是從另一個角度面對同一類問題:繼承是強耦合的 is-a 關係,組合是靈活的 has-a 關係。
接下來我們透過一個人事系統的例子,看看繼承鏈如何一步步走向爆炸。
// 不好的範例:用繼承描述員工職能
class Employee {
constructor(public name: string) {}
work(): string {
return `${this.name} 正在工作`;
}
}
// 第一層:主管繼承員工
class Manager extends Employee {
manage(): string {
return `${this.name} 正在管理團隊`;
}
}
// 第二層:資深主管繼承主管
class SeniorManager extends Manager {
strategize(): string {
return `${this.name} 正在制定跨部門策略`;
}
}
// 需求來了:Tech Lead(會寫程式的主管)——這在業界很常見
// 問題:要怎麼設計這個類別?
// 方案一:從 Manager 繼承,再加上 code()
class CodingManager extends Manager {
code(): string {
// 僵化點:code() 的邏輯跟下面的 CodingSeniorManager 完全重複!
return `${this.name} 正在寫程式`;
}
}
// 需求繼續來:資深主管也要寫程式
// 困境更大:CodingManager 和 SeniorManager 都繼承自 Manager,
// 但大多數語言(包含 TypeScript)不支援多重繼承,
// 所以我們只好再建一個新的子類別
class CodingSeniorManager extends SeniorManager {
code(): string {
// 僵化點:又複製了一份完全相同的 code() 邏輯,違反 DRY 原則!
return `${this.name} 正在寫程式`;
}
}
// 下一波需求:有個員工負責 UI 設計,但同時也帶領小團隊
// 還要再建 DesignManager?
// 那有設計能力又會寫程式的主管呢?DesignCodingManager?
// 每種新的能力組合都需要一個新的繼承層,這棟大樓永遠蓋不完!
// 使用範例——看起來正常,但隱患已埋下
const alice = new CodingManager("Alice");
console.log(alice.work()); // Alice 正在工作
console.log(alice.manage()); // Alice 正在管理團隊
console.log(alice.code()); // Alice 正在寫程式
const bob = new CodingSeniorManager("Bob");
console.log(bob.code()); // Bob 正在寫程式(與 alice.code() 邏輯完全相同,卻是兩份程式碼)
上面的設計存在幾個明顯的問題:
code() 方法的邏輯在 CodingManager 和 CodingSeniorManager 中完全重複,違反了 DRY 原則這時候我們可以發現,繼承適合描述「穩定的 is-a 本質關係」,但拿來描述「可以靈活組合的職能」就會讓設計變得非常脆弱。
核心思路是把每種職能拆成獨立的介面與實作物件(CanManage、CanCode),員工類別透過持有這些能力物件(has-a)來獲得對應的行為,而非透過繼承鏈往上爬。
// 修正範例:以介面與組合拼裝員工能力
// 改動重點 1:將每種「能力」定義為獨立的介面
interface CanWork {
work(): string;
}
interface CanManage {
manage(): string;
}
interface CanCode {
code(): string;
}
interface CanStrategize {
strategize(): string;
}
// 改動重點 2:為每種能力建立獨立的實作物件
// 每個物件只負責一件事,邏輯集中在一處,不再四處複製
class WorkingAbility implements CanWork {
constructor(private name: string) {}
work(): string {
return `${this.name} 正在工作`;
}
}
class ManagingAbility implements CanManage {
constructor(private name: string) {}
manage(): string {
return `${this.name} 正在管理團隊`;
}
}
class CodingAbility implements CanCode {
constructor(private name: string) {}
code(): string {
return `${this.name} 正在寫程式`;
}
}
class StrategyAbility implements CanStrategize {
constructor(private name: string) {}
strategize(): string {
return `${this.name} 正在制定跨部門策略`;
}
}
// 改動重點 3:員工類別透過「組合」能力物件來獲得職能,而非繼承
// 一般員工:只有工作能力
class Employee implements CanWork {
private workingAbility: WorkingAbility;
constructor(public name: string) {
this.workingAbility = new WorkingAbility(name);
}
work(): string {
return this.workingAbility.work();
}
}
// 主管:工作能力 + 管理能力(組合兩個獨立物件,不建立繼承層)
class Manager implements CanWork, CanManage {
private workingAbility: WorkingAbility;
private managingAbility: ManagingAbility;
constructor(public name: string) {
this.workingAbility = new WorkingAbility(name);
this.managingAbility = new ManagingAbility(name);
}
work(): string {
return this.workingAbility.work();
}
manage(): string {
return this.managingAbility.manage();
}
}
// 改動重點 4:Tech Lead 只需要組合所需的三種能力,不必新建繼承層
// 而且 CodingAbility 的程式碼只存在一份,不再重複!
class TechLead implements CanWork, CanManage, CanCode {
private workingAbility: WorkingAbility;
private managingAbility: ManagingAbility;
private codingAbility: CodingAbility;
constructor(public name: string) {
this.workingAbility = new WorkingAbility(name);
this.managingAbility = new ManagingAbility(name);
this.codingAbility = new CodingAbility(name);
}
work(): string {
return this.workingAbility.work();
}
manage(): string {
return this.managingAbility.manage();
}
code(): string {
return this.codingAbility.code();
}
}
// 資深主管:工作 + 管理 + 策略(三種能力自由組合)
class SeniorManager implements CanWork, CanManage, CanStrategize {
private workingAbility: WorkingAbility;
private managingAbility: ManagingAbility;
private strategyAbility: StrategyAbility;
constructor(public name: string) {
this.workingAbility = new WorkingAbility(name);
this.managingAbility = new ManagingAbility(name);
this.strategyAbility = new StrategyAbility(name);
}
work(): string {
return this.workingAbility.work();
}
manage(): string {
return this.managingAbility.manage();
}
strategize(): string {
return this.strategyAbility.strategize();
}
}
// 改動重點 5:會寫程式的資深主管,直接組合四種能力即可
// 不需要新建繼承層,更不需要重複 code() 的邏輯
class CodingSeniorManager implements CanWork, CanManage, CanCode, CanStrategize {
private workingAbility: WorkingAbility;
private managingAbility: ManagingAbility;
private codingAbility: CodingAbility;
private strategyAbility: StrategyAbility;
constructor(public name: string) {
this.workingAbility = new WorkingAbility(name);
this.managingAbility = new ManagingAbility(name);
this.codingAbility = new CodingAbility(name);
this.strategyAbility = new StrategyAbility(name);
}
work(): string {
return this.workingAbility.work();
}
manage(): string {
return this.managingAbility.manage();
}
code(): string {
return this.codingAbility.code();
}
strategize(): string {
return this.strategyAbility.strategize();
}
}
// 使用範例——每種角色的能力清清楚楚,型別系統保證安全
const alice = new TechLead("Alice");
console.log(alice.work()); // Alice 正在工作
console.log(alice.manage()); // Alice 正在管理團隊
console.log(alice.code()); // Alice 正在寫程式
const bob = new SeniorManager("Bob");
console.log(bob.manage()); // Bob 正在管理團隊
console.log(bob.strategize()); // Bob 正在制定跨部門策略
const charlie = new CodingSeniorManager("Charlie");
console.log(charlie.code()); // Charlie 正在寫程式
console.log(charlie.strategize()); // Charlie 正在制定跨部門策略
// 若未來要新增「設計能力」,只需新建 DesigningAbility 物件即可,
// 現有的任何類別都不需要修改!
CanManage、CanCode 等)定義為獨立介面,讓 TypeScript 的型別系統在編譯時期確保實作完整性ManagingAbility、CodingAbility 等小型物件可以被任意員工類別組合,完全消除程式碼重複——code() 的邏輯只存在一份DesigningAbility,完全不需修改任何現有類別CanManage 的物件都能被安全地互換,完全符合里氏替換原則的精神這時候我們可以發現,組合讓我們像在玩樂高一樣——每塊積木(能力物件)都是獨立且可重複使用的,要組出任何形狀(員工角色)只需要挑選合適的積木拼在一起,完全不需要為每種形狀重新塑造一塊新的水泥。
「組合優於繼承」並不是要我們完全拋棄繼承,而是提醒我們在選擇設計方案時多想一步:
ManagingAbility、CodingAbility 等小型物件各自獨立,撰寫單元測試更簡單直觀需要注意的是,組合也不是萬靈丹。若過度拆分,類別數量會暴增,程式碼的追蹤路徑也會更複雜。設計時仍需要依據實際情境判斷最合適的方案。
值得一提的是,這個原則在許多著名的設計模式中都有體現。後續我們會看到裝飾器模式(Decorator,Day 33)——它讓我們動態地把新行為「包裹」到既有物件上;以及策略模式(Strategy,Day 36)——它讓演算法可以像積木一樣被抽換。這兩個模式都是組合思維的具體應用,屆時我們再來深入探討。
綜合以上所述,我們成功用組合取代了僵硬的繼承鏈,讓每種職能都成為獨立、可重複使用的積木,員工角色只需把需要的積木組合在一起即可。下次面對「繼承還是組合?」的抉擇時,先問自己:我在蓋水泥大樓,還是在玩樂高?