核心困境#
在 legacy code 中工作時,我們面臨一個根本的兩難:現在付出代價,還是以後付出代價(Pay now or pay later)。寫測試需要時間,但不寫測試的代價會隨時間累積。變更通常會聚集在同一區域,今天改的程式碼很快就會再被改到。
程式碼是你的家,你必須住在裡面。不要因為趕時間就放棄品質。
當你無法將 class 放入 test harness 時,可以透過撰寫全新的、經過測試的程式碼來進行變更。本章介紹四種技巧:Sprout Method、Sprout Class、Wrap Method、Wrap Class。
這些技巧讓你在系統中加入經過測試的程式碼,但它們不會測試呼叫端的程式碼。使用時要謹慎。
Sprout Method#
當你需要為系統新增功能,而該功能可以完全用新程式碼來表達時,將程式碼寫在一個新方法中,從需要的地方呼叫它。
範例#
原始的 TransactionGate 類別:
public class TransactionGate {
public void postEntries(List entries) {
for (Iterator it = entries.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
entry.postDate();
}
transactionBundle.getListManager().add(entries);
}
}需求:在 post 之前過濾重複的 entries。不好的做法是直接在方法中混入新邏輯:
public void postEntries(List entries) {
List entriesToAdd = new LinkedList();
for (Iterator it = entries.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
if (!transactionBundle.getListManager().hasEntry(entry)) {
entry.postDate();
entriesToAdd.add(entry);
}
}
transactionBundle.getListManager().add(entriesToAdd);
}更好的做法是使用 Sprout Method,將去重邏輯抽取為獨立方法:
public class TransactionGate {
List uniqueEntries(List entries) {
List result = new ArrayList();
for (Iterator it = entries.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
if (!transactionBundle.getListManager().hasEntry(entry)) {
result.add(entry);
}
}
return result;
}
public void postEntries(List entries) {
List entriesToAdd = uniqueEntries(entries);
for (Iterator it = entriesToAdd.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
entry.postDate();
}
transactionBundle.getListManager().add(entriesToAdd);
}
}步驟#
- 找出需要變更的位置
- 如果變更可以用一段語句序列表達,寫下對新方法的呼叫並先註解掉
- 確定需要從原方法傳入的區域變數,作為參數
- 確定新方法是否需要回傳值給原方法
- 使用 TDD 開發新方法
- 取消註解,啟用呼叫
優缺點#
缺點:
- 放棄了對原方法和 class 的改善機會
- 原方法可能變得不好理解(為何有一個孤立的新方法呼叫?)
優點:
- 新程式碼與舊程式碼有清楚的分界
- 新程式碼可以獨立測試
- 影響的變數更容易追蹤
當 class 依賴太深無法建構時,考慮使用 Pass Null 或將 sprout 做成 public static method。
Sprout Class#
當你需要新增功能,但無法將現有 class 放入 test harness,甚至無法使用 Sprout Method 時,可以建立一個全新的 class 來承載變更。
範例#
一個巨大的報表產生器 QuarterlyReportGenerator 需要新增 HTML 表頭:
using namespace std;
class QuarterlyReportTableHeaderProducer {
public:
string makeHeader();
};
string QuarterlyReportTableHeaderProducer::makeHeader() {
return "<tr><td>Department</td><td>Manager</td>"
"<td>Profit</td><td>Expenses</td></tr>";
}進一步可以抽取共同的 interface:
class HTMLGenerator {
public:
virtual ~HTMLGenerator() = 0;
virtual string generate() = 0;
};
class QuarterlyReportTableHeaderGenerator : public HTMLGenerator {
public:
virtual string generate();
};
class QuarterlyReportGenerator : public HTMLGenerator {
public:
virtual string generate();
};步驟#
- 找出需要變更的位置
- 為新 class 想一個好名字,寫下建立物件和呼叫方法的程式碼(先註解掉)
- 確定需要從原方法傳入的區域變數,作為 constructor 參數
- 確定新 class 是否需要回傳值
- 使用 TDD 開發新 class
- 取消註解,啟用呼叫
優缺點#
優點:
- 讓你更有信心地推進,比起直接修改更安全
- 在 C++ 中不需修改現有 header 檔案,降低編譯負擔
缺點:
- 增加概念複雜度 (conceptual complexity)
- 新加入的人可能不理解為何有這些小 class
兩種情境導致 Sprout Class#
- 新增全新的職責 – 例如
TaxCalculator中新增日期檢查,這可能本該是新 class - 無法將現有 class 放入 test harness – 被迫將功能放到新 class 中
Wrap Method#
在既有方法中添加行為很容易,但這會造成 temporal coupling(時間耦合):把不相關的事情綁在一起,只因為它們需要同時發生。
形式一:重新命名原方法#
public class Employee {
private void dispatchPayment() {
Money amount = new Money();
for (Iterator it = timecards.iterator(); it.hasNext(); ) {
Timecard card = (Timecard)it.next();
if (payPeriod.contains(date)) {
amount.add(card.getHours() * payRate);
}
}
payDispatcher.pay(this, date, amount);
}
public void pay() {
logPayment();
dispatchPayment();
}
private void logPayment() {
...
}
}原本的 pay() 被重新命名為 dispatchPayment() 並設為 private。新的 pay() 方法同時呼叫 log 和原邏輯。對客戶端來說完全透明。
形式二:新增獨立方法#
public class Employee {
public void makeLoggedPayment() {
logPayment();
pay();
}
public void pay() {
...
}
private void logPayment() {
...
}
}步驟(形式一)#
- 找出需要變更的方法
- 重新命名該方法,建立一個同名同簽名的新方法
- 在新方法中呼叫舊方法
- 使用 TDD 開發新功能的方法,從新方法呼叫它
優缺點#
優點:
- 不增加既有方法的大小
- 新功能明確地獨立於既有功能
缺點:
- 可能導致不好的命名(如
dispatchPayment其實包含計算薪資) - 新功能必須在舊功能之前或之後執行,不能穿插其中
Wrap Class#
Wrap Class 是 Wrap Method 的 class 層級版本。用另一個 class 包裹原有 class,新增行為。
Decorator 模式#
class LoggingEmployee extends Employee {
public LoggingEmployee(Employee e) {
employee = e;
}
public void pay() {
logPayment();
employee.pay();
}
private void logPayment() {
...
}
}這就是 Decorator Pattern。包裝的 class 擁有與被包裝 class 相同的 interface,客戶端不需要知道差異。
適合用於有多個現有呼叫端的情境。可以使用 Extract Implementer 或 Extract Interface 來建立共用的 interface。
非 Decorator 形式#
當只有少數地方需要 log 時,可以不用 decorator:
class LoggingPayDispatcher {
private Employee e;
public LoggingPayDispatcher(Employee e) {
this.e = e;
}
public void pay() {
employee.pay();
logPayment();
}
private void logPayment() {
...
}
}步驟#
- 找出需要變更的方法
- 建立一個 class,接受原 class 作為 constructor 參數
- 使用 TDD 在新 class 上建立方法,該方法呼叫新功能和原 class 的方法
- 在需要的地方建立 wrapper 實例
何時使用 Wrap Class#
- 要新增的行為完全獨立,不想用不相關的低層級行為污染既有 class
- Class 已經大到無法忍受,wrap 可以作為未來拆分的起點
Sprout vs. Wrap 的選擇#
| 情境 | 建議技巧 |
|---|---|
| 既有方法的演算法清晰,新增的是明確的獨立步驟 | Sprout Method |
| 新功能與既有功能同等重要,希望最終形成高階演算法 | Wrap Method |
| 無法將 class 放入 test harness | Sprout Class 或 Wrap Class |
| 有多個呼叫端需要透明地新增行為 | Wrap Class (Decorator) |
總結#
這些技巧讓你在不先將既有 class 放入測試的情況下進行變更。它們在新責任與舊責任之間建立清楚的分界。雖然從設計角度看,這些做法不是最優雅的,但它們讓你開始累積測試。隨著時間推移,人們會厭倦繞道而行,開始將那些大的、未測試的 class 放入 test harness。