深入剖析 Java 中的委派模式(Delegation)【Thinking in Java筆記(6-2)】

深入剖析 Java 中的委派模式(Delegation)【Thinking in Java筆記(6-2)】
Photo by Christian Chen / Unsplash

對剛接觸 Java 的初學者來說,當你寫到第六章,意識到「繼承」有它的便利,但也隱藏著難以掌控的耦合風險,如果想部分重用功能,又不想被不需要的方法干擾,該如何在設計時保持彈性與可維護性?

本文將從「委派模式」的核心概念出發,透過多個真實場景的比喻、逐步拆解的程式碼範例,並輔以常見錯誤排解與最佳實務,引導你靈活運用委派,打造易擴充、低耦合的程式結構

什麼是委派模式?

委派模式(Delegation Pattern)是一種結構型設計模式,讓一個物件將其部分行為委託給另一個類別的實例來執行,而非直接繼承父類的所有行為。它的核心在於「物件組合(Composition)」與「接口包裝(Wrapper)」

實戰範例

委派的實現方式

想像飛船駕駛艙擁有油門、方向盤、推進器等多種控制介面;使用繼承時,就像把整個儀表板直接丟給子類。結果駕駛員得面對一大堆不相關的按鈕,而我們實際只想控制「前進」「後退」等少數功能

繼承作法

public class SpaceShipControls {
  void up(int velocity) {}
  void down(int velocity) {}
  void left(int velocity) {}
  void right(int velocity) {}
  void forward(int velocity) {}
  void back(int velocity) {}
  void turboBoost() {}
}

public class SpaceShip extends SpaceShipControls {
  private String name;
  public SpaceShip(String name) { this.name = name; }
  public String toString() { return name; }
  public static void main(String[] args) {
    SpaceShip protector = new SpaceShip("NSEA Protector");
    protector.forward(100);
  }
}
  • 語意不清:繼承暗示 SpaceShip 是一種 SpaceShipControls,讓人誤以為飛船本身具備控制器的行為。但在現實中,飛船只是「使用」這組控制功能;繼承扭曲了設計意圖,也讓程式結構不符合真實需求,日後要擴充或維護時,常常因為不符合直覺而出錯
  • 介面過度暴露:繼承機制會將父類別的所有方法一併攤平到子類,導致使用者在呼叫 SpaceShip 時,不僅能使用 forward()back(),也能隨意呼叫 up()down()left()right()turboBoost() 等本不想公開的功能,介面混亂且易引發誤用
  • 未來擴充困難:如果想為 turboBoost 增加額外參數或行為,必須直接修改 SpaceShipControls 類別本身,這不僅違反封裝原則,還可能影響到所有繼承或依賴該類別的程式碼,增加維護風險與出錯機會

使用委派模式改寫

透過委派,我們可以改寫上述範例,使 SpaceShip 包含一個 SpaceShipControls 的實例,並選擇性地暴露需要的方法。

public class SpaceShipDelegation {
  private String name;
  private SpaceShipControls controls = new SpaceShipControls();
  public SpaceShipDelegation(String name) {
    this.name = name;
  }
  // 委派方法
  public void back(int velocity) { controls.back(velocity); }
  public void down(int velocity) { controls.down(velocity); }
  public void forward(int velocity) { controls.forward(velocity); }
  public void left(int velocity) { controls.left(velocity); }
  public void right(int velocity) { controls.right(velocity); }
  public void turboBoost() { controls.turboBoost(); }
  public void up(int velocity) { controls.up(velocity); }
  public static void main(String[] args) {
    SpaceShipDelegation protector = new SpaceShipDelegation("NSEA Protector");
    protector.forward(100);
  }
}

在這個改寫的範例中:

  • 建立控制器實例:在 SpaceShipDelegation 類別的建構子中,呼叫 new SpaceShipControls(),並將結果賦值給 controls 欄位。這樣就完成了「擁有一個控制器」的基礎組合
  • 命名與初始化:同步在建構子中接收並設定 name 參數,讓每艘飛船都有自己的辨識名稱
  • 撰寫委派方法:以 public void forward(int velocity) 為例,方法內部僅呼叫 controls.forward(velocity),這一步看似多了一層包裝,但核心優勢在於:你可以控制哪些功能要對外公開如果稍後想把飛船改成『隱形飛船』,也可以在這裡加入額外邏輯,例如先檢查能量值,再執行 controls.forward()
  • 管理 API 範圍:只把 forward()back()up()…等你需要的方法寫進 SpaceShipDelegation。未寫的控制功能(像 turboBoost())就自動隱藏,不會暴露給使用者
  • 呼叫與執行:在 main() 方法裡,只和 SpaceShipDelegation 互動:
SpaceShipDelegation ship = new SpaceShipDelegation("NSEA Protector");
ship.forward(100);
使用者根本不需要知道底層 SpaceShipControls 存在,簡潔、直覺

想像你有一台功能強大的遙控車(含燈光、水炮、收音機),但朋友只想玩前進和後退。你不會把整台遙控器直接給他,而是特製一個簡化版遙控器,只留下「前進」「後退」兩顆按鍵。這就是委派:在封裝好複雜內部機制的同時,只對外顯示最核心的操作介面

深入理解委派模式

當你呼叫 SpaceShipDelegation.forward(100) 時,真正執行工作的其實是內部的 controls.forward(100)——就像透過電話操控智慧家電,你說「開燈」,電話那頭的開關才真正動作。委派模式的威力就在於:外部接口維持不變,但內部實作可以隨時替換或增強

  • 組合取代繼承:用一個 controls 物件來承擔控制邏輯,而不是讓 SpaceShip 繼承所有方法,避免將整個工具箱交給你手忙腳亂
  • 專注核心 API:只在 SpaceShipDelegation 中公開 forward()back()up() 等必要方法,其餘功能保持私有,介面乾淨且易於理解
  • 彈性更高:未來若要升級控制器,只要把 controls 換成新版本,就能一次帶來改進,呼叫程式碼完全不改變

優點

  • 單一焦點:只公開你需要的方法,不會被其他不相關功能干擾
  • 避免過度繼承:在某些情況下,繼承會導致類別之間的耦合度過高,使用委派可以降低這種風險
  • 彈性擴充:以後想新增功能,只要在類別中再寫一個委派方法,不用動到原本的控制器類

缺點

  • 額外的撰寫工作:需要手動撰寫委派的方法,可能會增加程式碼的量
  • 效能開銷:每次方法調用都需要經過一層轉發,可能會有輕微的效能損耗

何時使用委派模式?

  • 需要更好的控制權:當你需要對某些方法的存取進行限制或改變其行為時
  • 避免不必要的繼承:當繼承會導致語意不清或過度耦合時,委派是更好的選擇
  • 動態行為改變:當你需要在運行時更改物件的行為時,可以使用委派模式

實際應用範例

想像你正在開發一款音樂播放器 App,它的任務是對應不同使用者的播放需求:有些人喜歡 MP3,有些人下載了 WAV 檔案,甚至還可能遇到 OGG、FLAC 等格式。對初學者來說,如果把所有格式的解碼邏輯硬塞在同一個類別,不僅程式碼複雜,也難以維護

委派模式在此派上用場:你只要在 AudioPlayer 這個主要類別中保留「播放」這件核心功能,然後針對各種不同的解碼方式,化身成獨立的 AudioDecoder 物件來處理。這樣,當未來再新增新格式,或想對現有格式進行優化測試,都只要替換或擴充解碼器,AudioPlayer 本身毫無感知,程式碼一樣簡潔。以下示範如何用委派模式,把解碼的細節委託給不同的解碼器:

interface AudioDecoder {
  void decode(String fileName);
}

class MP3Decoder implements AudioDecoder {
  public void decode(String fileName) {
    System.out.println("Decoding MP3 file: " + fileName);
  }
}

class WAVDecoder implements AudioDecoder {
  public void decode(String fileName) {
    System.out.println("Decoding WAV file: " + fileName);
  }
}

public class AudioPlayer {
  private AudioDecoder decoder;
  public void setDecoder(AudioDecoder decoder) {
    this.decoder = decoder;
  }
  public void play(String fileName) {
    decoder.decode(fileName);
    System.out.println("Playing audio file: " + fileName);
  }
  public static void main(String[] args) {
    AudioPlayer player = new AudioPlayer();
    player.setDecoder(new MP3Decoder());
    player.play("song.mp3");

    player.setDecoder(new WAVDecoder());
    player.play("song.wav");
  }
}

在這個範例中:

  • 委派物件(客戶端)AudioPlayer 不直接實作解碼邏輯,而是負責接收播放請求,並將解碼工作「交給」另一個物件處理。這讓 AudioPlayer 顯得乾淨俐落,只專注在「播放流程」的協調
  • 解碼介面(抽象契約)AudioDecoder 定義了 decode(String fileName) 方法,是所有解碼器必須實現的契約。任何符合此接口的類別(如 MP3DecoderWAVDecoder)都能被 AudioPlayer 委派使用
  • 運行時切換(高彈性):透過 setDecoder(AudioDecoder decoder),程式在執行期間即可替換不同解碼器。例如要支援 OGG,只需實作 OggDecoder 並呼叫 player.setDecoder(new OggDecoder()),不需改動 AudioPlayer 的任何碼
  • 分離關注(Separation of Concerns)AudioPlayer 只管何時呼叫 decoder.decode()、何時顯示播放訊息;AudioDecoder 則專注於解碼流程,互不干擾,降低維護成本
  • 實際成效
    1. 新格式上線:只加一個新的 AudioDecoder 類別,老程式瞬間支援
    2. 測試容易:在單元測試中,給 AudioPlayer 傳入模擬的 AudioDecoder,就能測試播放流程,不用真的跑解碼程式

委派模式有效地將「播放」與「解碼」兩條邏輯分開包裝,讓系統更具可讀性、可維護性,也方便未來擴充各種音訊格式

小結

到這裡,你已經學會如何用委派模式,把「核心功能」和「輔助邏輯」分開包裝,讓程式碼清爽又好讀。記得:

  • 💡 拿捏重點:用委派,你只要挑真正需要的方法,讓類別看起來簡潔,使用起來也不會手忙腳亂
  • 💡 輕鬆維護:當需求變更時,只要替換或新增一個小小的委派物件,主程式幾乎不動,省下大量找 bug 的時間
  • 💡 隨心擴充:想加新功能?在自己的類裡偷偷加個包裝就好,底層邏輯不被波及,不怕手滑改壞了別人寫的程式