🎯 什麼情境該想到我

當你「一段程式碼其實在依序處理兩件不同的事情」(例如先把輸入解析成某種結構、再拿它去計算結果)的時候——把它拆成前後兩個清楚的階段,各自處理一件事、各有自己的中間資料結構,就能分別理解與修改。

⚙️ 怎麼用(步驟 / 公式)

意圖:把一段依序做兩件不同事情的程式碼,拆成前後兩個獨立階段,中間用一個資料結構銜接。

動作要點(小步安全進行):

  1. 把第二階段的程式碼用提煉函式 Extract Function抽成獨立函式。
  2. 編譯、測試。
  3. 引入一個「中間資料結構」,作為抽出函式的參數傳入。
  4. 檢查第二階段用到的每個參數:若它其實屬於第一階段的產物,就把它搬進中間資料結構。
  5. 對第一階段做提煉函式 Extract Function,讓它回傳這個中間資料結構。
  6. 於是形成「第一階段產出中間資料 → 第二階段消費中間資料」的清楚兩段式流程。

★ before / after(Go)

before——一個函式同時在「解析輸入」和「用解析結果計算」:

func PriceOrder(spec string) (float64, error) {
    parts := strings.Split(spec, "-")            // ← 階段一:解字串
    product := parts[0]
    qty, _ := strconv.Atoi(parts[1])
 
    base := catalog[product].Price * float64(qty)   // ← 階段二:算錢
    discount := math.Max(0, float64(qty-500)) * catalog[product].Price * 0.05
    return base - discount, nil
}

問題:想測「數量 600 的折扣算得對不對」,你被迫先組一個字串。

after——中間放一個資料結構,把兩個階段切開:

// ★ 中間資料結構:階段一的輸出 = 階段二的輸入
type OrderData struct {
    Product string
    Qty     int
}
 
func parseOrder(spec string) (OrderData, error) { … }              // 只管解析
func priceOrder(d OrderData, cat Catalog) (float64, error) { … }   // ★ 純函式,好測
 
func PriceOrder(spec string, cat Catalog) (float64, error) {
    d, err := parseOrder(spec)
    if err != nil { return 0, fmt.Errorf("parse order: %w", err) }
    return priceOrder(d, cat)
}

★ 訊號:怎麼認出「這裡有兩個階段」

  1. 函式的前半和後半用的資料幾乎不重疊——前半只碰 spec,後半只碰 product/qty。
  2. 想測後半,卻被迫餵前半的輸入格式(最可靠的訊號)。
  3. 前半在做格式的事(字串、JSON、HTTP、CLI flag),後半在做領域的事。

★★ 這就是 functional core, imperative shell

拆分階段做到極致,就是把程式分成兩層:

內核(core)外殼(shell)
做什麼決策、計算、規則I/O、DB、HTTP、時鐘、隨機
性質純函式:同樣輸入→同樣輸出有副作用
怎麼測★ 直接給值、比對值,不需要任何 mock少量整合測試
// ✅ shell 負責取資料與寫結果,core 只負責決定
func (s *Service) Settle(ctx context.Context, id string) error {
    inv, err := s.repo.Load(ctx, id)            // shell:I/O
    if err != nil { return err }
 
    plan := computeSettlement(inv, s.now())     // ★ core:純函式,時間也是傳進來的
    return s.repo.Apply(ctx, plan)              // shell:I/O
}

★ 把 time.Now() 變成傳入的參數,是這個手法最高槓桿的一次應用——
它讓「月底結帳」「閏年」「跨時區」這些情境變成可以直接寫測試的普通輸入。

和相鄰概念的界線

差別
提煉函式只是抽出一段;拆分階段一定會產生一個中間資料結構
管道與過濾器同一個想法擴大到跨行程/跨服務的訊息流
以管道取代迴圈拆的是迴圈內的步驟,不是整個函式的階段

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 若兩件事本來就緊密交織、拆開反而要傳一堆狀態來回,硬拆會更亂。

  • 只有一個目的、沒有明顯前後兩件事的程式碼,不需要拆。

  • ★ 只有兩個階段真的獨立時才拆。如果階段二還要回頭問階段一的原始輸入,中間結構就會長出 RawInput string 這種欄位——那表示切點錯了。

  • 中間資料結構不要變成 純資料類別。它應該帶著自己的驗證與衍生查詢(d.Valid()、d.Total())。

  • 多一層對映的成本是真的:兩個階段各自的型別都要維護,欄位增減要改兩處。階段很薄的時候不划算。

  • 不要拆成三、四個階段就停不下來。每個階段之間都要一個型別;五階段 = 四個中間型別 = 讀者要追四次對映。

  • shell 也需要測試。純核心測得再漂亮,repo.Load 傳錯參數一樣是線上事故——外殼靠少量整合測試守住。

🔗 相關工具