編碼 · 設計模式
工廠方法模式詳解與範例
簡答
工廠方法是一種建立型設計模式,它定義一個用來建立物件的方法,但讓子類別決定要實例化哪個具體類別。基底類別針對產品介面撰寫邏輯,並呼叫工廠方法(例如 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 隱藏起來,不讓用戶端看到。
工廠方法符合開放封閉原則嗎?
符合。新增產品時,只要新增一個產品類別和一個建立者子類別,不必更動建立者既有的邏輯。
工廠方法一定要是抽象的嗎?
不一定。基底類別可以提供預設實作,回傳標準的產品,只有子類別需要不同產品時才覆寫。