Sprout 與 Wrap:不動舊碼加行為 (Sprout & Wrap)

★★★★Draft

FromWorking Effectively with Legacy Code

📚 From the Books

Sprout 與 Wrap:不動舊碼加行為 (Sprout & Wrap)Legacy Code

有時你連把那個巨大類別放進測試框架的力氣都沒有,卻又非改不可。Feathers 的務實解法:不要在無測試的舊碼裡動刀,改成把新行為寫成全新、有測試的一塊,再從舊碼呼叫它。這就是 Sprout(長一段新碼出來)與 Wrap(包一層新碼上去)——它們在新舊責任之間畫一條清楚的界線,讓你今天就能開始累積測試。

🧠 為什麼需要它

遺留碼的兩難是「Pay now or pay later」:先把舊類別鋪好測試最乾淨,但你可能沒時間、依賴也深到建不起來。Sprout / Wrap 讓你繞過「先測舊碼」這一步,把新增的部分寫成獨立、可測的碼。

WARNING

這四招讓你加入「經過測試的新碼」,但它們不會測到呼叫它的舊碼。它們是起點,不是終點——目的是先讓球滾起來,日後有人受夠繞路,自然會回頭把大類別拉進測試。

⚖️ 四種手法

Sprout=「長出」一段新碼;Wrap=「包住」既有呼叫。各有 method / class 兩個層級。

Sprout Method — 把新邏輯長成一個獨立方法

需求:post 之前先過濾重複的 entries。把新邏輯攪進舊迴圈——那樣新舊碼糊在一起、又沒法單獨測。改成抽一個新方法,用 TDD 開發它,舊方法只是去呼叫它:

class TransactionGate {
  // ← 全新、可獨立測試的方法(用 TDD 寫出來)
  uniqueEntries(entries) {
    return entries.filter((e) => !this.bundle.listManager.hasEntry(e));
  }

  postEntries(entries) {
    const toAdd = this.uniqueEntries(entries); // ← 舊碼只多一行呼叫
    toAdd.forEach((e) => e.postDate());
    this.bundle.listManager.add(toAdd);
  }
}
  • 優點:新舊碼界線清楚、新碼獨立可測、受影響的變數好追。
  • 缺點:放棄了順手改善舊方法的機會;舊方法多一個「孤立的呼叫」,讀者可能困惑為何在此。
Sprout Class — 連 Sprout Method 都塞不進去時,開一個新類別

當現有類別根本無法放進測試框架(建構就拉進整個系統),連在裡面加方法都不划算,就把新職責放進一個全新類別

// 舊的巨大 QuarterlyReportGenerator 完全不進測試框架;
// 新表頭邏輯獨立成一個可測的小類別
class QuarterlyReportTableHeader {
  make() {
    return "<tr><td>Department</td><td>Manager</td><td>Profit</td></tr>";
  }
}
  • 兩種觸發情境:① 本來就該是獨立新職責;② 被舊類別的不可測性逼出來。
  • 優點:比直接改舊碼更有信心、更安全。
  • 缺點:增加概念複雜度——新人可能不懂為何冒出這些小類別。
Wrap Method — 在既有方法「前後」加一步,不改它本體

想在 pay() 發薪時順便記帳。直接在 pay() 裡加 logPayment() 會製造時間耦合(把不相干的事綁在一起只因要同時發生)。改法:把原方法改名藏起來,新方法同名同簽名,串起「記帳+原邏輯」:

class Employee {
  pay() {
    this.logPayment();      // 新行為
    this.dispatchPayment(); // ← 原本的 pay(),改名收進 private
  }

  dispatchPayment() { /* …原本 pay() 的內容,一字未改… */ }
  logPayment() { /* 新的、可測的行為 */ }
}

對呼叫端完全透明。優點:不撐大既有方法、新舊行為明確分離。缺點:新功能只能在舊功能「之前或之後」跑,無法穿插;命名有時被迫將就。

Wrap Class — 用一層新類別包住舊類別(= Decorator)

有多個呼叫端都要透明地加同一個行為,就上升到類別層級:新類別持有舊物件、實作相同介面,在委派前後插入新行為。這正是 Decorator 模式。

class LoggingEmployee {
  constructor(employee) {
    this.employee = employee; // 包住原物件
  }
  pay() {
    this.logPayment();     // 加上的行為
    this.employee.pay();   // 委派回原本的
  }
  logPayment() { /* … */ }
}
  • 何時用:新行為完全獨立、不想用低層雜訊污染舊類別;或舊類別已大到受不了,包一層當未來拆分的起點。
  • 多呼叫端時可先用 Extract Interface 抽出共用介面,讓 wrapper 與原類別可互換。

🌱 vs. 🎁 怎麼選

情境手法
舊方法演算法清楚,新增的是一個明確步驟Sprout Method
新功能與舊功能同等重要,想串成高階流程Wrap Method
現有類別無法放進測試框架Sprout / Wrap Class
多個呼叫端要透明地加同一行為Wrap Class (Decorator)

TIP

Sprout / Wrap 都用 TDD 開發那塊新碼——新碼一出生就有測試。它們不是最優雅的設計,但讓你在不敢碰舊碼的當下,仍能安全前進。

🔑 Takeaways

✍️ My Notes

No notes yet — jot your takeaways or Q&A here.

📖 Further Reading

🔗 Dive Deeper