iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
自我挑戰組

程式碼門診:診斷壞味道、開出重構處方系列 第 11 篇

別再蓋家族大樓,改玩樂高 - 組合優於繼承 (Composition over Inheritance)

  • 分享至 

  • xImage
  •  

簡單介紹

物件導向程式設計(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 關係。

接下來我們透過一個人事系統的例子,看看繼承鏈如何一步步走向爆炸。

TypeScript 不好的範例

問題:繼承鏈越疊越高,新需求讓階層爆炸

// 不好的範例:用繼承描述員工職能

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() 邏輯完全相同,卻是兩份程式碼)

問題分析

上面的設計存在幾個明顯的問題:

  1. 階層爆炸:每出現一種新的「能力組合」需求,就必須新增一個子類別,類別數量呈指數成長
  2. 程式碼重複:code() 方法的邏輯在 CodingManager 和 CodingSeniorManager 中完全重複,違反了 DRY 原則
  3. 強耦合性:子類別與父類別緊密耦合,修改父類別的實作細節很容易影響所有子類別
  4. 無法靈活組合:TypeScript(以及絕大多數 OOP 語言)不支援多重繼承,當一個員工需要同時具備三種以上的職能時,繼承完全無從應對

這時候我們可以發現,繼承適合描述「穩定的 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 物件即可,
// 現有的任何類別都不需要修改!

改進重點說明

  1. 能力介面化:將每種職能(CanManage、CanCode 等)定義為獨立介面,讓 TypeScript 的型別系統在編譯時期確保實作完整性
  2. 能力物件可重複使用:ManagingAbility、CodingAbility 等小型物件可以被任意員工類別組合,完全消除程式碼重複——code() 的邏輯只存在一份
  3. 新增組合不影響既有程式碼:若再新增「設計能力」,只需建立 DesigningAbility,完全不需修改任何現有類別
  4. 符合開放封閉原則(OCP):系統對擴充開放、對修改封閉——新的能力組合透過「新增」而非「修改」來實現
  5. 與 Day 8 LSP 呼應:因為各能力物件遵守介面契約,任何實作了 CanManage 的物件都能被安全地互換,完全符合里氏替換原則的精神

這時候我們可以發現,組合讓我們像在玩樂高一樣——每塊積木(能力物件)都是獨立且可重複使用的,要組出任何形狀(員工角色)只需要挑選合適的積木拼在一起,完全不需要為每種形狀重新塑造一塊新的水泥。

總結

「組合優於繼承」並不是要我們完全拋棄繼承,而是提醒我們在選擇設計方案時多想一步:

  1. is-a 才用繼承:「麻雀是鳥」這類本質上穩定的分類關係,繼承是合理的選擇
  2. has-a 就用組合:「這個員工具備管理能力」這類職能或行為,組合更靈活、更易維護
  3. 繼承像水泥,組合像樂高:繼承一旦定型就難以拆改;組合讓每個能力塊可以獨立演化、自由排列組合
  4. 小物件更容易測試:ManagingAbility、CodingAbility 等小型物件各自獨立,撰寫單元測試更簡單直觀

需要注意的是,組合也不是萬靈丹。若過度拆分,類別數量會暴增,程式碼的追蹤路徑也會更複雜。設計時仍需要依據實際情境判斷最合適的方案。

值得一提的是,這個原則在許多著名的設計模式中都有體現。後續我們會看到裝飾器模式(Decorator,Day 33)——它讓我們動態地把新行為「包裹」到既有物件上;以及策略模式(Strategy,Day 36)——它讓演算法可以像積木一樣被抽換。這兩個模式都是組合思維的具體應用,屆時我們再來深入探討。

綜合以上所述,我們成功用組合取代了僵硬的繼承鏈,讓每種職能都成為獨立、可重複使用的積木,員工角色只需把需要的積木組合在一起即可。下次面對「繼承還是組合?」的抉擇時,先問自己:我在蓋水泥大樓,還是在玩樂高?

參考資料

上一篇
依賴抽象而非具體實作 - 依賴倒置原則 (Dependency Inversion Principle)
下一篇
別把手伸進別人的口袋 - 迪米特法則與 Tell, Don't Ask (Law of Demeter & Tell, Don't Ask)
系列文
程式碼門診:診斷壞味道、開出重構處方 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言