什麼是重構?#

作者在 1999 年閱讀了 Martin Fowler 的經典著作 Refactoring,稱此書為第一本將程式碼呈現為可塑之物的書籍——不同於當時大多數書籍只展示最終形態的程式碼,這本書展示了如何將糟糕的程式碼清理乾淨。Fowler 的名言精準地點出了核心精神:

Any fool can write code that a computer can understand. Good programmers write code that humans can understand.

作者以自己的話重新定義重構:

重構是一連串小型變更,在不改變軟體行為的前提下改善其結構——每次變更後都透過通過完整的測試套件來證明行為未變。

這個定義包含兩個關鍵要點:

  • 保持行為不變:重構後,軟體的行為必須與重構前完全一致。唯一能證明行為未改變的方式,就是持續通過一套完整的測試
  • 每個單獨的重構都很小:小到什麼程度?作者的判斷準則是:小到不需要使用除錯器(debugger)

Rule 15:避免使用除錯器。 如果害怕需要進入除錯器,就把變更拆分成更小、更安全的片段。如果最終仍然進入了除錯器,就調整你的恐懼閾值,朝更謹慎的方向偏移。

重構的目的與時機#

  • 重構的目的是清理程式碼
  • 重構的流程就是 Red -> Green -> Refactor 循環
  • 重構是一種持續性活動,而不是排程或計畫中的活動——每次繞過 Red -> Green -> Refactor 迴圈時都應進行
  • 當需要較大規模的設計變更時,不要停止新功能開發,而是在每次迴圈中多做一點額外的重構,逐步實現變更,同時持續交付業務價值

基本工具箱(The Basic Toolkit)#

作者最常使用的幾個重構手法,大多可由 IDE 自動化完成。

Rename(重新命名)#

  • 命名是困難的,好名字往往需要迭代式改進
  • 專案年輕時應勇於實驗名稱、頻繁重新命名——隨著專案老化,更改名稱會越來越困難(越多人記住了舊名稱)
  • 搜尋最佳名稱的過程往往會對程式碼的分區方式產生深遠的正面影響——你會因為名稱的改變而重新組織 class 與 method 的歸屬

Extract Method(擷取方法)#

這可能是最重要的重構手法,也是保持程式碼乾淨與組織良好的最重要機制。

作者建議遵循 Extract ’til You Drop 的紀律,追求兩個目標:

  1. 每個函式只做一件事
  2. 程式碼應該讀起來像寫得好的散文

判斷一個函式是否只做一件事的方法:從中再也擷取不出任何函式。持續擷取直到無法再擷取為止。

你可能擔心大量的小函式會使程式碼意圖模糊。但事實恰恰相反——意圖會變得更加清晰,抽象層次也會變得清楚分明。善用 namespace、class、inner class 等工具來建立結構化的命名層次。

命名長度的反比原則

  • 函式名稱的長度應與其所在作用域的大小成反比
  • Public 函式的名稱應較短
  • Private 函式的名稱應較長,因為它們的用途更加專業精確

擷取出的函式通常只從一個地方被呼叫,名稱可能是完整的子句甚至句子,這會讓程式碼讀起來像:

if (employeeShouldHaveFullBenefits())
   AddFullBenefitsToEmployee();

Extract Method 也是實現 Stepdown Rule 的方式——函式中的每一行應在同一抽象層次,且比函式名稱低一個層次。

Extract Variable(擷取變數)#

Extract Variable 是 Extract Method 的得力助手——為了擷取方法,往往需要先擷取變數。

作者以保齡球記分程式為例,展示了重構序列:

#手法說明
1Extract Variableg.roll(1) 中的 1 擷取為變數 pins
2Extract Variable20 擷取為變數 n
3移動變數將兩個變數移到 for 迴圈上方
4Extract Method將 for 迴圈擷取為 rollMany 函式
5Inline將已完成使命的變數內聯回去

另一個常見用途是建立解釋性變數(Explanatory Variable)

boolean isEligibleForEarlyRetirement = employee.age > 60 &&
                                        employee.salary > 150000;
if (isEligibleForEarlyRetirement)
    ScheduleForEarlyRetirement(employee);

Extract Field(擷取欄位)#

這個重構不常使用,但一旦使用,往往能引導程式碼走向重大改善。它通常從一個失敗的 Extract Method 開始。

作者以一個 COVID 新案例報告程式為例:一個龐大的 makeReport 方法包含所有邏輯。嘗試從中擷取方法時,IDE 需要傳入過多參數並返回不想要的值。

Figure 5.1: Extract Method dialog

解決方法:先將方法內的區域變數擷取為類別的欄位(fields),例如 totalCasesstateCountscounties。擷取為欄位後:

  • 不再需要將這些變數作為參數傳遞
  • 不再需要從擷取出的方法中返回這些值
  • 可以自由地繼續擷取和重新命名

經過一系列重構後,程式碼從一個巨大方法變成多個語意清晰的小方法:calculateCountiescalculateRollingAveragecalculateSumOfCasesmakeHeadermakeCountyDetailsmakeStateTotals 等。

flowchart TD
    A["巨大的 makeReport 方法"] --> B["Extract Field<br/>區域變數 → 類別欄位"]
    B --> C["Extract Method<br/>擷取出多個小方法"]
    C --> D["Rename<br/>賦予語意清晰的名稱"]
    D --> E1["calculateCounties"]
    D --> E2["calculateRollingAverage"]
    D --> E3["calculateSumOfCases"]
    D --> E4["makeHeader"]
    D --> E5["makeCountyDetails"]
    D --> E6["makeStateTotals"]

    style A fill:#fbb,stroke:#333
    style E1 fill:#bfb,stroke:#333
    style E2 fill:#bfb,stroke:#333
    style E3 fill:#bfb,stroke:#333
    style E4 fill:#bfb,stroke:#333
    style E5 fill:#bfb,stroke:#333
    style E6 fill:#bfb,stroke:#333

Extract Superclass(擷取超類別)#

在上述範例中,作者進一步發現報告格式化與資料計算的程式碼不應在同一個 class 中——這違反了 Single Responsibility Principle,因為報告格式與計算邏輯會因不同原因而改變。

解決方案是使用 Extract Superclass 重構,將計算邏輯提升到 NewCasesCalculator 超類別,NewCasesReporter 繼承自它,只負責格式化。

classDiagram
    class NewCasesCalculator {
        +calculateCounties()
        +calculateRollingAverage()
        +calculateSumOfCases()
        <<計算邏輯>>
    }
    class NewCasesReporter {
        +makeHeader()
        +makeCountyDetails()
        +makeStateTotals()
        +makeReport()
        <<格式化輸出>>
    }
    NewCasesCalculator <|-- NewCasesReporter : 繼承

整個重構序列的起點是 Extract Field。一個看似簡單的重構手法,卻能引導出重大的結構改善。

魔術方塊的比喻(Rubik’s Cube)#

作者將重構比作解魔術方塊

  • 魔術方塊有一套「操作」,每個操作保持大部分位置不變,只以可預測的方式改變特定位置
  • 只要學會三四個操作,就能逐步將方塊推向可解決的狀態
  • 你知道的操作越多、越熟練,解題就越快越直接
  • 但如果操作失誤一步,方塊就會「崩潰」成隨機狀態,必須從頭開始

重構程式碼也是如此——你熟悉的重構越多,就越容易隨心所欲地推、拉、伸展程式碼。

flowchart TD
    A["選擇重構操作"] --> B["執行小步驟"]
    B --> C{"測試通過?"}
    C -->|"是"| D["繼續推進"]
    D --> A
    C -->|"否"| E["「崩潰」回到隨機狀態"]
    E --> F["從頭開始"]
    F --> A

    style C fill:#f9f,stroke:#333,stroke-width:2px
    style D fill:#bfb,stroke:#333
    style E fill:#fbb,stroke:#333

沒有測試的重構,崩潰幾乎是必然的。

重構的紀律(The Disciplines)#

Tests(測試)#

第一條也是最重要的紀律:測試、測試、再測試。要安全可靠地重構,你需要一套你以生命信賴的測試套件。

Quick Tests(快速測試)#

  • 測試需要快速——如果測試要跑幾小時甚至幾分鐘,重構就無法順暢進行
  • 作者建議組織測試套件,使得可以快速執行與當前重構相關的測試子集,將測試時間從分鐘縮短到亞秒級
  • 每隔一小時左右執行完整套件以確保沒有漏洞

Break Deep One-to-One Correspondences(打破深層一對一對應)#

  • 在模組和元件層級,測試的設計會映射生產程式碼的設計(一對一對應),以便快速執行相關子集
  • 但在模組和元件層級以下,要刻意打破這種一對一對應,以避免脆弱測試(Fragile Tests)
  • 速度的好處遠大於高層級耦合的成本

Refactor Continuously(持續重構)#

作者以烹飪做類比:做菜時隨手清洗用過的器具,而不是讓它們堆積在水槽中。重構也是如此——不要等待,隨時重構。保持 Red -> Green -> Refactor 迴圈在腦海中旋轉,每幾分鐘就繞一圈,防止混亂堆積到令人生畏的程度。

Refactor Mercilessly(無情重構)#

這是 Kent Beck 在 Extreme Programming 中的箴言。紀律很簡單:重構時要勇敢。不要害怕嘗試,不要猶豫不前。把程式碼當作黏土,你就是雕塑家。

對程式碼的恐懼是心智殺手,是通往黑暗面的道路。一旦踏上黑暗之路,它將永遠主宰你的命運。

Keep the Tests Passing!(保持測試通過!)#

  • 永遠不要讓測試處於失敗狀態超過幾分鐘
  • 如果重構需要數小時或數天才能完成,就分成小塊進行,保持所有測試通過,同時繼續其他活動
  • 大型資料結構變更的策略:建立新的資料結構來映射舊的內容,然後逐步將各部分程式碼從舊結構遷移到新結構,同時保持測試通過
  • 在此過程中可以同時新增功能和修復 bug,無需要求特別的重構時間
  • 這可能需要數週甚至數月,但系統在任何時候都可以部署到生產環境
flowchart TD
    A["建立新資料結構"] --> B["映射舊內容到新結構"]
    B --> C["逐步遷移各部分程式碼"]
    C --> D{"測試通過?"}
    D -->|"是"| E{"遷移完成?"}
    E -->|"否"| C
    E -->|"是"| F["移除舊資料結構"]
    D -->|"否"| G["修正後重試"]
    G --> D

    style D fill:#f9f,stroke:#333,stroke-width:2px
    style F fill:#bfb,stroke:#333

Leave Yourself an Out(為自己留退路)#

  • 飛行員在飛入天氣可能不佳的區域時,總是確保有一條逃脫路線
  • 重構時也是如此——開始一系列可能走入死胡同的重構前,先標記你的原始碼倉庫
  • git reset --hard 可以是你的好朋友

結論#

  • 培養一套你經常使用的重構手法,並對其他手法有良好的工作知識
  • 如果使用的 IDE 提供重構操作,確保你詳細理解它們
  • 沒有測試的重構毫無意義——即使 IDE 的自動化重構有時也會出錯
  • 頻繁重構、無情重構、永遠不要為了重構而道歉
  • 永遠、永遠不要請求重構的許可