cv.cm

編碼 · 設計模式

工廠方法模式詳解與範例

閱讀約 6 分鐘

簡答

工廠方法是一種建立型設計模式,它定義一個用來建立物件的方法,但讓子類別決定要實例化哪個具體類別。基底類別針對產品介面撰寫邏輯,並呼叫工廠方法(例如 createTransport())而不是直接使用 new。這樣一來,只要新增一個子類別就能加入新的產品類型,不必修改使用這些產品的程式碼。

意圖

定義一個用來建立物件的介面,但讓子類別決定要實例化哪個類別。工廠方法讓類別把實例化的工作延後交給子類別。它是《設計模式》(Gang of Four,簡稱 GoF)一書中五種建立型模式之一,也是其中唯一以類別為範圍的模式,因為它依賴繼承。

它解決的問題

想像一個通知模組:它會格式化訊息、送出訊息,並記錄結果。起初只寄電子郵件,所以程式碼直接呼叫 new EmailChannel()。後來產品需要簡訊,接著又要推播通知。每多一個管道,就得修改同一個類別並多加一個 if 分支,即使格式化與記錄這兩個步驟完全沒有變。

傳送的流程是穩定的,只有管道的種類會變。工廠方法把這個唯一會變的決定,也就是要建立哪個類別,集中到一個可以覆寫的方法中。

結構與參與者

參與者職責範例中的對應
產品(Product)工廠方法所建立之物件的介面。Channel
具體產品(ConcreteProduct)實作產品介面。EmailChannel、SmsChannel
建立者(Creator)宣告工廠方法,並包含使用產品的邏輯。Notifier
具體建立者(ConcreteCreator)覆寫工廠方法,回傳特定的具體產品。EmailNotifier、SmsNotifier

TypeScript 實作

interface Channel {
  send(to: string, text: string): string;
}

class EmailChannel implements Channel {
  send(to: string, text: string): string { return `email to ${to}: ${text}`; }
}

class SmsChannel implements Channel {
  send(to: string, text: string): string { return `sms to ${to}: ${text.slice(0, 20)}`; }
}

abstract class Notifier {
  // The factory method: subclasses choose the concrete product.
  protected abstract createChannel(): Channel;
  // Shared logic that works with any Channel.
  notify(to: string, text: string): void {
    const channel = this.createChannel();
    const result = channel.send(to, text.trim());
    console.log("[log]", result);
  }
}

class EmailNotifier extends Notifier {
  protected createChannel(): Channel { return new EmailChannel(); }
}

class SmsNotifier extends Notifier {
  protected createChannel(): Channel { return new SmsChannel(); }
}

const notifiers: Notifier[] = [new EmailNotifier(), new SmsNotifier()];
for (const n of notifiers) n.notify("ada", " Your order has shipped today ");
// [log] email to ada: Your order has shipped today
// [log] sms to ada: Your order has shipp

現在要加入推播通知,只需要撰寫 PushChannel 和 PushNotifier。notify 方法以及所有呼叫它的程式碼都不必更動。

在套用這個模式之前,建立物件的決定被埋在共用的邏輯裡:

class Notifier {
  constructor(private kind: "email" | "sms") {}
  notify(to: string, text: string): void {
    // every new channel edits this method
    const result = this.kind === "email"
      ? `email to ${to}: ${text}`
      : `sms to ${to}: ${text.slice(0, 20)}`;
    console.log("[log]", result);
  }
}

較輕量的替代做法:傳入工廠函式

工廠方法使用的是繼承。在 TypeScript 中,常常可以改用組合達到同樣的彈性,也就是把建立物件的函式傳進建構函式。這樣就不必為每個產品各寫一個子類別:

interface Channel { send(to: string, text: string): string; }
class Notifier {
  constructor(private createChannel: () => Channel) {}
  notify(to: string, text: string): void {
    console.log("[log]", this.createChannel().send(to, text));
  }
}
const push = new Notifier(() => ({ send: (to, text) => `push to ${to}: ${text}` }));
push.notify("ada", "Hello"); // [log] push to ada: Hello

當每個建立者本來就因為其他理由有自己的類別,或是框架要求你擴充某個基底類別時,就使用子類別版本。當各個變化版本之間唯一的差別只是它們建立的物件時,就使用函式版本。

何時使用、何時不該用

在以下情況使用工廠方法:

  • 類別含有穩定的演算法,但無法事先知道必須建立哪些具體物件。
  • 你在撰寫框架或函式庫,希望使用者能透過繼承來掛上自己的產品類別。
  • 你想把某個型別的建立邏輯集中在同一個地方,而不是讓 new 呼叫四處散落。

在以下情況應避免使用:

  • 只有一種產品類型,也沒有出現第二種的跡象。直接使用 new 更清楚。
  • 唯一的理由只是為了方便測試。透過建構函式傳入依賴會更簡單。
  • 你會建立一組與產品一對一對應、卻沒有其他邏輯的平行建立者階層。

實際應用

  • Java 集合:Iterable.iterator() 只宣告一次,每個集合類別(ArrayList、HashSet 等)再各自覆寫它,回傳自己的迭代器類別。GoF 一書就是以這種用法來說明工廠方法如何達成多型的迭代。
  • JavaScript 的可迭代物件:每個可迭代物件都會實作 [Symbol.iterator](),這個方法會回傳一個適合該集合的新迭代器。
  • UI 框架常讓子類別覆寫建立元件、儲存格或檢視的方法,而基底類別則掌控版面配置與生命週期。

相關模式

模式差異
抽象工廠透過一個含有多個建立方法的物件,建立整個系列的相關產品。它的方法通常是以工廠方法來實作。
樣板方法工廠方法經常被樣板方法呼叫,就像上面的 notify 呼叫 createChannel 一樣。
原型透過複製實例來建立物件,而不是靠繼承建立者,因此不需要平行的建立者階層。
建造者重點在於一步一步建構單一的複雜物件,而不是選擇要建立哪個類別。

常見問題

工廠方法和抽象工廠有什麼差別?

工廠方法是由子類別覆寫、用來建立單一產品的一個方法。抽象工廠則是擁有多個建立方法的物件,可產生必須搭配使用的一整個系列相關產品。

簡單的工廠函式就是工廠方法嗎?

不完全是。像 createShape(kind) 這種內部用 switch 判斷的函式,通常稱為簡單工廠。GoF 的工廠方法依賴子類別覆寫建立方法,不過兩者都把 new 隱藏起來,不讓用戶端看到。

工廠方法符合開放封閉原則嗎?

符合。新增產品時,只要新增一個產品類別和一個建立者子類別,不必更動建立者既有的邏輯。

工廠方法一定要是抽象的嗎?

不一定。基底類別可以提供預設實作,回傳標準的產品,只有子類別需要不同產品時才覆寫。

全部編碼教學