「重構是一種對軟體內部結構的改善,目的是在不改變軟體的可見行為的情況下,使其更易理解,修改成本更低。」——馬丁·福勒(Martin Fowler)

本章聚焦重構的「如何做」;關於「何時該還債、優先級如何排」請見 技術債務管理

為什麼要重構?#

對專案的價值#

  1. 保持程式碼品質 - 防止程式碼腐化到無法維護
  2. 持續演進 - 無法一開始就設計完美,需求在變化
  3. 避免過度設計 - 遇到問題再重構,有的放矢

對個人的價值#

  1. 鍛煉設計能力 - 應用設計原則與模式的最佳練習場
  2. 成就感 - 將爛程式碼變好的滿足感
  3. 能力證明 - 重構能力是衡量程式設計師水平的有效指標

「初級工程師在維護程式碼,高級工程師在設計程式碼,資深工程師在重構程式碼」

重構什麼?#

大型重構#

對頂層設計的重構:

  • 系統架構
  • 模組劃分
  • 類別之間的關係
  • 程式碼分層

手段:分層、模組化、解耦、抽象可複用組件

特點

  • 改動範圍大
  • 影響面廣
  • 需要有計劃、有組織地進行
  • 需要資深工程師主導

小型重構#

對程式碼細節的重構:

  • 規範命名
  • 消除重複程式碼
  • 拆分過長的函式
  • 消除過大的類別

手段:應用編碼規範

特點

  • 改動集中
  • 風險較低
  • 隨時可做

什麼時候重構?#

不要等到程式碼爛到無法維護才重構!

持續重構#

將重構作為開發的一部分,而不是獨立的大規模活動。

flowchart TD
    A[日常開發流程] --> B[修改功能時]
    A --> C[Code Review 時]
    A --> D[閒暇時]

    B --> E[順手重構相關程式碼]
    C --> F[發現問題立即修正]
    D --> G[主動改善可最佳化的程式碼]

    E --> H[保持程式碼品質]
    F --> H
    G --> H

持續重構意識#

技術在更新、需求在變化、人員在流動,程式碼品質總會下降。時刻保持持續重構意識比任何技巧都重要。

如何重構?#

大型重構策略#

  1. 制定計劃 - 明確目標和範圍
  2. 分階段進行 - 每階段完成一小部分
  3. 保持可執行 - 每次提交程式碼都能正常執行
  4. 控制影響範圍 - 考慮兼容性,必要時寫過渡程式碼

每個階段最好控制在一天內完成,避免與新功能開發衝突。

小型重構策略#

  • 隨時進行
  • 可借助靜態分析工具(CheckStyle、FindBugs、PMD)
  • Code Review 時發現即修正

安全重構的保障:單元測試#

為什麼需要單元測試?#

重構改變的是程式碼結構,不改變行為。如何確保行為不變?

答案:充分的單元測試覆蓋

// 重構前:驗證 IP 地址
public boolean isValidIp(String ip) {
    // 使用正則表達式
    return ip.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$");
}

// 重構後:使用更清晰的邏輯
public boolean isValidIp(String ip) {
    String[] parts = ip.split("\\.");
    if (parts.length != 4) return false;
    for (String part : parts) {
        int value = Integer.parseInt(part);
        if (value < 0 || value > 255) return false;
    }
    return true;
}

// 單元測試確保行為一致
@Test
void testValidIp() {
    assertTrue(isValidIp("192.168.1.1"));
    assertTrue(isValidIp("0.0.0.0"));
    assertTrue(isValidIp("255.255.255.255"));
    assertFalse(isValidIp("256.1.1.1"));
    assertFalse(isValidIp("1.2.3"));
}

什麼是好的單元測試?#

FIRST 原則

原則說明
Fast執行速度快
Independent測試之間獨立
Repeatable可重複執行
Self-validating自動判斷成敗
Timely及時編寫

提高程式碼可測試性#

flowchart LR
    subgraph 難以測試
        direction TB
        T1[Transaction] -->|new| U1[UserRepo]
        T1 -->|靜態呼叫| I1[IdGenerator]
        T1 -->|全域變數| R1[Runtime]
    end

    subgraph 可測試
        direction TB
        T2[Transaction] -.->|注入| U2[UserRepo]
        T2 -.->|注入| I2[IdValidator]
        T2 -.->|注入| R2[MemoryChecker]
    end

    難以測試 -->|依賴注入重構| 可測試

難以測試的程式碼

public class Transaction {
    public boolean execute(String id, Long buyerId) {
        // 直接 new 物件,無法替換為 mock
        User buyer = new UserRepo().getUser(buyerId);
        // 直接呼叫靜態方法
        if (!IdGenerator.isValid(id)) {
            return false;
        }
        // 使用全域變數
        if (Runtime.getRuntime().freeMemory() < 1024) {
            return false;
        }
        return true;
    }
}

可測試的程式碼

public class Transaction {
    private UserRepo userRepo;
    private IdValidator idValidator;
    private MemoryChecker memoryChecker;

    // 依賴注入
    public Transaction(UserRepo userRepo,
                       IdValidator idValidator,
                       MemoryChecker memoryChecker) {
        this.userRepo = userRepo;
        this.idValidator = idValidator;
        this.memoryChecker = memoryChecker;
    }

    public boolean execute(String id, Long buyerId) {
        User buyer = userRepo.getUser(buyerId);
        if (!idValidator.isValid(id)) {
            return false;
        }
        if (!memoryChecker.hasEnoughMemory()) {
            return false;
        }
        return true;
    }
}

可測試性設計要點

  1. 使用依賴注入 - 便於替換為 mock 物件
  2. 避免靜態方法 - 或封裝成可注入的物件
  3. 避免全域變數 - 不可預測的狀態影響測試
  4. 避免在建構函式中執行複雜邏輯

解耦合技巧#

為什麼要解耦?#

高耦合程式碼的問題:

  • 改一處影響多處
  • 難以理解
  • 難以測試
  • 難以複用

解耦方法#

flowchart LR
    subgraph 耦合
        A1[模組 A] -->|直接依賴| B1[模組 B]
    end

    subgraph 解耦
        A2[模組 A] --> I[介面層]
        I --> B2[模組 B]
    end

    耦合 -->|重構| 解耦

1. 封裝與抽象

// 耦合:直接使用實作類別
RedisClient client = new RedisClient();

// 解耦:使用介面
Cache cache = new RedisCache();

2. 中間層

// 耦合:模組 A 直接依賴模組 B
A -> B

// 解耦:通過介面層
A -> Interface <- B

3. 依賴注入

// 耦合:內部創建依賴
class OrderService {
    private UserService userService = new UserService();
}

// 解耦:外部注入依賴
class OrderService {
    private UserService userService;
    public OrderService(UserService userService) {
        this.userService = userService;
    }
}

4. 事件驅動

// 耦合:直接呼叫
orderService.createOrder();
paymentService.processPayment();
inventoryService.reduceStock();

// 解耦:發布事件
eventBus.publish(new OrderCreatedEvent());
// 各服務訂閱事件自行處理

避免過度設計#

什麼是過度設計?#

  • 為不存在的需求預先設計
  • 為極小概率的場景做複雜處理
  • 盲目應用設計模式
  • 過度抽象和封裝

如何避免?#

YAGNI 原則:You Ain’t Gonna Need It

  1. 只解決當前問題 - 預留擴展點,但不提前實作
  2. 簡單方案優先 - 能用簡單方式解決就不要複雜化
  3. 持續重構 - 需要時再演化設計

簡單與直觀是永恆的解決方案#

// 過度設計:為了可能的擴展引入工廠 + 策略
interface PaymentStrategy { }
class PaymentStrategyFactory { }
class WechatPaymentStrategy implements PaymentStrategy { }
class AlipayPaymentStrategy implements PaymentStrategy { }

// 簡單直觀:目前只有一種支付方式
class PaymentService {
    void pay(Order order) {
        wechatApi.pay(order);
    }
}
// 等需要多種支付方式時再重構

接縫與遺留碼安全重構#

前面的安全保障建立在「已有測試」之上。但遺留碼最大的困境正是:它沒有測試,而且因為糾纏的依賴,連測試都寫不出來。

先用特性化測試罩住#

當你不確定一段程式碼「應該」做什麼,只知道它「目前」做什麼時,先寫 characterization test(特性化測試)把現狀凍結起來。

// 不知道正確輸出該是什麼,就先讓測試告訴你目前輸出
@Test
void characterize() {
    String actual = legacyReport(sampleInput);
    // 先填一個猜的值,跑一次,把實際輸出貼回來
    assertEquals("PASTE_ACTUAL_OUTPUT_HERE", actual);
}

它的目的不是驗證「對不對」,而是「重構後行為有沒有變」。有了這張網,才敢動手。

接縫(Seam):在不改行為下插入測試點#

接縫是「可以改變程式行為、卻不必在該處動刀」的位置。找到接縫,就能把寫死的依賴替換成可控的替身。

接縫類型替換時機Java 典型做法
物件接縫(Object Seam)執行期new 出來的依賴改成建構子注入,測試傳 mock
編譯期接縫編譯期以介面取代具體類別,編譯時換實作
連結期接縫連結/載入期換掉 classpath 上的實作(測試用替身 jar)

物件接縫最常用,也最安全:

// 沒有接縫:依賴寫死在內部,無從替換
class OrderService {
    boolean place(Order o) {
        return new PaymentGateway().charge(o); // 寫死
    }
}

// 開出物件接縫:注入依賴,測試可傳替身
class OrderService {
    private final PaymentGateway gateway;
    OrderService(PaymentGateway gateway) { this.gateway = gateway; }

    boolean place(Order o) {
        return gateway.charge(o);
    }
}

抽芽方法:在舊碼裡長出可測的新碼#

當你必須在一大坨難測的舊方法裡加新邏輯,不要直接塞進去。改用 Sprout Method(抽芽方法):把新邏輯寫成一個全新的、可獨立測試的方法,舊方法只負責呼叫它。

// 舊方法又長又難測,但新規則可以「抽芽」成獨立方法
class Invoice {
    void post() {
        // ...原有大量難測邏輯...
        applyLateFee(entries); // 新增:呼叫抽芽出來的新方法
        // ...原有邏輯...
    }

    // 新方法乾淨、無糾纏依賴,可直接單元測試
    List<Entry> applyLateFee(List<Entry> entries) {
        // 新規則的純邏輯
        return ...;
    }
}

舊碼維持原樣(風險最低),新邏輯卻從第一行起就被測試覆蓋。隨著一次次抽芽,可測的孤島逐漸連成片。

重構過程中聞到的各種壞味道(神秘命名、過長函式、特性嫉妒…),可對照 程式碼壞味道 查找對應手法。

重構實戰清單#

重構時機檢查

需要重構的信號

  • 函式超過 50 行
  • 類別超過 500 行
  • 參數超過 5 個
  • 重複程式碼出現三次以上
  • 深層嵌套超過 3 層
  • 難以為類別取名
  • 修改一處需要改多處
重構步驟
  1. 確保有測試覆蓋
  2. 小步修改
  3. 每步都能編譯執行
  4. 頻繁提交
  5. 保持行為不變
常見重構手法
手法適用情況
提取方法函式過長
內聯方法方法體比方法名更清晰
提取類別類別職責過多
內聯類別類別幾乎沒做什麼事
移動方法方法在錯誤的類別中
重命名名稱不能表達意圖
參數物件參數過多
使用多型取代條件大量 if-else 或 switch

破窗效應:一旦有人往專案裡堆砌爛程式碼,就會有更多人跟進。資深工程師要負起責任,保持程式碼品質處於良好狀態。