🎯 什麼情境該想到我

當你「好幾個流程長得幾乎一樣,只有中間幾步不同」的時候——
不同格式的匯出、不同來源的 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 包裝),否則每個實作各自處理會不一致。
  • 不要為了消除兩個函式的重複就引入它。兩個相似的流程,直接重複是合理的;三個以上再考慮抽象。

🔗 相關工具