🎯 什麼情境該想到我
當你「好幾個流程長得幾乎一樣,只有中間幾步不同」的時候——
不同格式的匯出、不同來源的 ETL、不同類型的報表產生。
★ 但如果你寫 Go:這個模式在 Go 裡「不存在」,因為它靠繼承。本頁一半在講 Go 該用什麼取代它。
⚙️ 怎麼用
意圖
定義一個演算法的骨架,把某些步驟延遲到子類別中實作。範本方法讓子類別可以在不改變演算法結構的前提下,重新定義演算法的某些步驟。
// 經典(Java)
abstract class DataExporter {
// ★ final —— 骨架不可被覆寫,這是「控制反轉」的關鍵
public final void export() {
var data = fetch(); // 固定
var t = transform(data); // ★ 抽象:子類決定
write(t); // ★ 抽象:子類決定
cleanup(); // ★ hook:子類可選擇性覆寫
}
protected abstract Data transform(Data d);
protected abstract void write(Data d);
protected void cleanup() {} // 預設空實作 = hook
}★ 這是「好萊塢原則」的典型體現:Don’t call us, we’ll call you.
父類控制流程,子類只填空(見 工具-好萊塢原則與依賴反轉)。
★ Go 沒有繼承 —— 三種取代方式
① 函式欄位(最直接的對應)
type Exporter struct {
Fetch func(ctx context.Context) (Data, error)
Transform func(Data) (Data, error)
Write func(context.Context, Data) error
Cleanup func() // ★ 可選的 hook
}
// ★ 骨架在這裡,而且是一個普通函式
func (e Exporter) Export(ctx context.Context) error {
if e.Cleanup != nil { defer e.Cleanup() }
d, err := e.Fetch(ctx)
if err != nil { return fmt.Errorf("fetch: %w", err) }
t, err := e.Transform(d)
if err != nil { return fmt.Errorf("transform: %w", err) }
return e.Write(ctx, t)
}
csvExporter := Exporter{Fetch: fetchFromDB, Transform: toCSV, Write: writeS3}② 介面 + 骨架函式(★ Go 最慣用的做法)
type Steps interface {
Fetch(context.Context) (Data, error)
Transform(Data) (Data, error)
Write(context.Context, Data) error
}
// ★ 骨架是一個自由函式,不屬於任何型別
func Export(ctx context.Context, s Steps) error {
d, err := s.Fetch(ctx)
if err != nil { return fmt.Errorf("fetch: %w", err) }
t, err := s.Transform(d)
if err != nil { return fmt.Errorf("transform: %w", err) }
return s.Write(ctx, t)
}③ 用嵌入模擬(★ 有陷阱,通常不該用)
type Base struct{}
func (b Base) Export() { b.Transform() } // ★ 這裡的 b 永遠是 Base!
func (b Base) Transform() { /* 預設 */ }
type CSV struct{ Base }
func (c CSV) Transform() { /* 想覆寫 */ }
CSV{}.Export() // ★ 呼叫的是 Base.Transform,不是 CSV.Transform★ Go 的嵌入不是繼承,沒有虛擬分派(virtual dispatch)。
嵌入型別的方法永遠呼叫自己的方法,不會分派到「外層」型別。
這是從 Java 轉 Go 最容易踩的坑之一(見 工具-Go嵌入機制)。→ 所以 Go 裡不要試圖用嵌入模擬範本方法。用 ① 或 ②。
TypeScript 的長相
TS 有 class 與繼承,可以照抄 Java 版。但更慣用的是函式組合:
type Steps<T> = {
fetch: () => Promise<T>;
transform: (d: T) => T;
write: (d: T) => Promise<void>;
cleanup?: () => void; // ★ 可選 hook
};
export async function exportData<T>(s: Steps<T>): Promise<void> {
try {
await s.write(s.transform(await s.fetch()));
} finally {
s.cleanup?.();
}
}★ 範本方法 vs 策略(必考的區分)
| 範本方法 | 策略模式 | |
|---|---|---|
| 機制 | 繼承(編譯期綁定) | 組合(執行期可換) |
| 替換的粒度 | 演算法中的幾個步驟 | 整個演算法 |
| 誰控制流程 | ★ 父類(骨架寫死) | Context(但演算法整個換掉) |
| 執行期可換 | ❌ | ✅ |
| Go 裡 | ★ 不存在(沒有繼承) | ✅ 天然 |
★ 在 Go 裡兩者會趨同:都變成「傳一個函式或介面進來」。
差別只剩下**「骨架有沒有被固定住」**——範本方法的骨架是固定的,策略是整段換掉。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
- ★ 這是「繼承的正當用途」中最有代表性的一個,但也因此在 Go 裡完全不適用。
從 Java 轉 Go 的人常常試圖用嵌入模擬它,然後被「沒有虛擬分派」咬到。 - ★ 骨架會變成枷鎖。一旦某個實作需要「跳過第 2 步」或「把第 3、4 步對調」,
範本方法就撐不住了——它假設所有實作的流程形狀完全一樣。
→ 實務上這個假設常常在第三個實作出現時就破了。 - ★ hook 太多 = 骨架沒有意義。如果 8 個步驟有 6 個是可覆寫的 hook,
那它其實沒有骨架,不如直接寫三個獨立的函式——重複一點點程式碼比錯誤的抽象便宜
(見 工具-DRY原則 對「錯誤抽象」的討論)。 - 繼承版的測試很痛:要測骨架就得建一個子類;子類與父類的耦合讓改動牽一髮動全身。
Go 的「骨架是自由函式」版本測起來簡單得多(直接傳一個假的Steps)。 - 錯誤處理要在骨架裡統一(見上面的
%w包裝),否則每個實作各自處理會不一致。 - 不要為了消除兩個函式的重複就引入它。兩個相似的流程,直接重複是合理的;三個以上再考慮抽象。
🔗 相關工具
- 策略模式 —— 組合版的替代方案,Go 裡的正解
- 工廠方法模式 —— 也常用在範本方法的某個步驟裡
- 工具-好萊塢原則與依賴反轉 —— 範本方法的理論基礎
- 工具-Go嵌入機制 —— ★ 為什麼 Go 的嵌入不能模擬它
- 工具-優先組合而非繼承 —— 為什麼現代設計傾向策略而非範本方法
- 工具-DRY原則 —— 錯誤的抽象比重複更貴
- 設計模式