概述#

這些模式描述如何以小步驟改變系統的設計,甚至是大幅度的改變。

在 TDD 中,重構的使用方式很有趣。一般而言,重構不能在任何情況下改變程式的語義。但在 TDD 中,我們在乎的「情況」是已經通過的測試。因此,我們可以在 TDD 中將常數替換為變數,並心安理得地稱之為重構,因為它沒有改變通過的測試集合。

重點: 這種「觀察等價性」(observational equivalence)要求你有足夠的測試,使得「相對於測試的重構」等同於「相對於所有可能測試的重構」。不能說「我知道有問題,但測試都通過了所以我就提交了」——你需要寫更多測試。

Reconcile Differences(調和差異)#

問題: 如何統一兩段看起來相似的程式碼?

解法: 逐漸讓它們靠近,只有在完全相同時才統一。

重構可能令人緊張。簡單的重構很明顯——機械式地正確提取方法,幾乎不會改變系統行為。但有些重構要求你仔細檢查控制流和資料值,一長串推理讓你相信即將做的改變不會改變任何答案。這種「信仰之躍」式的重構正是我們想透過小步驟和具體回饋來避免的。

這個重構發生在所有層級:

層級動作
兩個迴圈結構相似使它們相同,然後合併
兩個條件分支相似使它們相同,然後消除條件
兩個方法相似使它們相同,然後消除一個
兩個類別相似使它們相同,然後消除一個

技巧: 有時需要反向思考——先想最後一步如何簡單化,再往回推。例如要移除子類別,最簡單的最後一步是子類別什麼都不包含,這樣超類別就能無行為變化地替換它。逐一清空子類別,清空後將參考替換為超類別。

Isolate Change(隔離變更)#

問題: 如何改變多部分方法或物件中的一個部分?

解法: 先隔離需要改變的部分。

這像是手術:除了要開刀的部分,整個病人都被蓋住。覆蓋讓外科醫生只有固定的一組變數要處理。

隔離變更的可能方式包括:Extract Method(最常見)、Extract Object 和 Method Object。

補充: 隔離變更並做出改變後,結果可能簡單到你可以撤銷隔離。但不要自動做這些改變——要平衡額外方法的成本與在程式碼中多一個明確概念的價值。

Migrate Data(遷移資料)#

問題: 如何從一種表示遷移到另一種?

解法: 暫時複製資料。

內部到外部的版本(先改內部表示,再改外部介面):

  1. 新增一個新格式的實例變數
  2. 在設定舊格式的每個地方,同時設定新格式
  3. 在使用舊格式的每個地方,改用新格式
  4. 刪除舊格式
  5. 改變外部介面以反映新格式
flowchart TD
    A["1. 新增新格式的實例變數"] --> B["2. 設定舊格式時<br/>同時設定新格式"]
    B --> C["3. 使用舊格式的地方<br/>改用新格式"]
    C --> D["4. 刪除舊格式"]
    D --> E["5. 改變外部介面<br/>反映新格式"]

外部到內部的版本(先改 API):

  1. 新增一個新格式的參數
  2. 從新格式參數轉換為舊格式內部表示
  3. 刪除舊格式參數
  4. 將舊格式的使用替換為新格式
  5. 刪除舊格式

TestSuite 實作 One to Many 為例:

class TestSuite:
  def add(self, test):
    self.test= test
  def run(self, result):
    self.test.run(result)

開始複製資料——先初始化集合:

def __init__(self):
  self.tests= []

每次設定 test 時,也加入集合:

def add(self, test):
  self.test= test
  self.tests.append(test)

改用集合(因為目前只有一個元素,這是重構——保持語義):

def run(self, result):
  for test in self.tests:
    test.run(result)

刪除不再使用的實例變數:

def add(self, test):
  self.tests.append(test)

Extract Method(提取方法)#

問題: 如何讓又長又複雜的方法更易讀?

解法: 將其中一小部分轉為獨立方法,然後呼叫新方法。

步驟:

  1. 找到方法中適合作為獨立方法的區域(迴圈的主體、整個迴圈、條件分支都是常見的提取候選)
  2. 確保沒有對提取區域範圍外的臨時變數進行賦值
  3. 從舊方法複製程式碼到新方法,編譯
  4. 對新方法中使用的每個臨時變數或原始方法的參數,新增為新方法的參數
  5. 從原始方法呼叫新方法

使用時機:

  • 理解複雜程式碼時:「這段在做什麼?該怎麼稱呼它?」半小時後程式碼變好看了,你也更理解了發生什麼事
  • 消除重複:兩個方法有部分相同、部分不同時,提取相似的部分為方法

補充: 將方法拆成極小的片段有時會做過頭。當你看不到前進的方向時,常常可以用 Inline Method 把所有程式碼放回一處,重新思考該提取什麼。

Inline Method(內聯方法)#

問題: 如何簡化過度扭曲或分散的控制流?

解法: 將方法呼叫替換為方法本身。

步驟:

  1. 複製方法
  2. 將方法貼在方法呼叫的位置上
  3. 將所有形式參數替換為實際參數(注意有副作用的表達式,如 reader.getNext(),應先賦值給區域變數)

使用時機:

以書中 Part I 的 Bank.reduce() 為例:

public void testSimpleAddition() {
  Money five= Money.dollar(5);
  Expression sum= five.plus(five);
  Bank bank= new Bank();
  Money reduced= bank.reduce(sum, "USD");
  assertEquals(Money.dollar(10), reduced);
}

內聯 Bank.reduce() 後看看效果:

public void testSimpleAddition() {
  Money five= Money.dollar(5);
  Expression sum= five.plus(five);
  Bank bank= new Bank();
  Money reduced= sum.reduce(bank, "USD");
  assertEquals(Money.dollar(10), reduced);
}

Inline Method 的用途:

  • 探索控制流:重構時在物件之間移動邏輯,看到有潛力的方向時用重構來嘗試
  • 拉回過度抽象:當被自己的聰明才智沖昏頭時,內聯幾層抽象,看看真正發生了什麼,然後根據實際需求重新抽象

Extract Interface(提取介面)#

問題: 如何在 Java 中引入操作的第二個實作?

解法: 建立一個包含共享操作的介面。

步驟:

  1. 宣告一個介面(有時現有類別的名稱應該是介面的名稱,此時先重新命名類別)
  2. 讓現有類別實作該介面
  3. 將必要的方法加入介面,必要時擴大類別中方法的可見性
  4. 盡可能將型別宣告從類別改為介面

使用時機:

  • 從第一個實作移到第二個時:有 Rectangle,想加 Oval,建立 Shape 介面
  • 引入 Crash Test Dummy 或其他 Mock Object 時:命名通常更困難

技巧: 抗拒將介面命名為 IFile 而類別保持 File 的誘惑。停下來想想是否有更深的理解——也許介面應該叫 File,類別叫 DiskFile,因為類別假設位元在磁碟上。

Move Method(搬移方法)#

問題: 如何將方法移到它所屬的地方?

解法: 將方法加入它所屬的類別,然後呼叫它。

步驟:

  1. 複製方法
  2. 將方法(適當命名)貼入目標類別,編譯
  3. 若方法中參考了原始物件,新增參數傳入原始物件;若參考了原始物件的變數,以參數傳入;若設定了原始物件的變數,則放棄
  4. 將原始方法的主體替換為呼叫新方法

範例——計算面積本來在 Shape 中:

// Shape
int width= bounds.right() - bounds.left();
int height= bounds.bottom() - bounds.top();
int area= width * height;

看到 bounds(一個 Rectangle)被發送了四個訊息,是時候搬移了:

// Rectangle
public int area() {
  int width= this.right() - this.left();
  int height= this.bottom() - this.top();
  return width * height;
}

// Shape
int area= bounds.area();

Move Method 的三大優點:

  • 容易發現需求:不需要深入理解程式碼意義,看到對另一個物件發送兩個以上訊息就可以行動
  • 機制快速且安全
  • 結果常常令人豁然開朗:「Rectangle 不做計算 ⋯⋯ 啊,我懂了,這樣更好」

Method Object(方法物件)#

問題: 如何表示需要多個參數和區域變數的複雜方法?

解法: 將方法變成一個物件。

步驟:

  1. 建立一個物件,參數與方法相同
  2. 將區域變數也設為物件的實例變數
  3. 建立一個 run() 方法,主體與原始方法相同
  4. 在原始方法中,建立新物件並呼叫 run()

使用時機:

  • 為系統添加全新邏輯做準備:先將第一種計算方式建立為 Method Object,然後用獨立的小規模測試撰寫新方式
  • 簡化無法用 Extract Method 處理的程式碼:當程式碼區塊有大量臨時變數和參數,每次嘗試提取都要帶著五六個東西時,Method Object 提供新的命名空間,可以在不傳任何東西的情況下提取方法

Add Parameter(新增參數)#

問題: 如何為方法新增參數?

步驟:

  1. 若方法在介面中,先將參數加入介面
  2. 新增參數
  3. 利用編譯器錯誤告訴你需要修改哪些呼叫端

使用時機:

  • 通常是擴展步驟:第一個測試案例不需要參數就能運行,但新情況需要考慮更多資訊
  • 也可以是資料表示遷移的一部分:先新增參數,刪除舊參數的所有使用,再刪除舊參數

Method Parameter to Constructor Parameter(方法參數移至建構子參數)#

問題: 如何將參數從方法移到建構子?

步驟:

  1. 在建構子中新增參數
  2. 新增同名的實例變數
  3. 在建構子中設定該變數
  4. 逐一將對「參數」的參考改為 this.parameter
  5. 當不再有方法參數的參考時,從方法和所有呼叫端刪除該參數
  6. 移除現在多餘的 this.
  7. 正確重新命名變數

技巧: 當你在同一個物件的多個不同方法中傳遞相同的參數時,就可以透過在建構子中傳遞一次來簡化 API(消除重複)。反過來也成立——如果實例變數只在一個方法中使用,可以反向執行此重構。