所有成功的軟體都會被修改。神話中,程式碼在初始版本交付後才會大幅修改; 現實中,程式碼在初始開發期間就會經歷大量演化。現代開發實踐更是以程式碼為中心,使演化程度比以往更高。
24.1 軟體演化的類型#
軟體演化(Software Evolution)如同生物演化——有些變異是有益的,有些則不然。
區分軟體演化類型的關鍵在於:修改之後程式品質是提升還是下降?
- 若用邏輯膠帶和迷信來修復錯誤,品質會劣化
- 若將修改視為收緊原始設計的機會,品質會提升
軟體演化的基本法則(The Cardinal Rule of Software Evolution):演化應當改善程式的內部品質。
建構期 vs. 維護期#
建構期間的修改通常由原始開發者進行,系統尚未上線,犯錯的代價較低,風格可以更自由。 維護期間則面對生產環境的壓力,修改必須更加謹慎。
演化的哲學#
演化既是風險也是趨近完美的機會。當你必須修改時,應努力讓程式碼變得更好,使未來的修改更容易。 你在開始寫程式時所知的,永遠比寫完之後少——善用每一次修改的機會。
24.2 重構簡介#
重構(Refactoring) 是 Martin Fowler 所定義的:「在不改變程式可觀察行為的前提下,對軟體內部結構所做的修改,使其更容易理解、更易於修改。」
重構的理由(Code Smells)#
以下是常見的「壞味道」(Smells),暗示程式碼需要重構:
| 壞味道 | 說明 |
|---|---|
| 重複的程式碼 | 違反 DRY 原則(Don’t Repeat Yourself) |
| 過長的子程式 | 在物件導向中,超過一個螢幕的子程式極少是必要的 |
| 迴圈過長或巢狀過深 | 迴圈內部適合抽取為獨立子程式 |
| 類別內聚力差 | 一個類別負責不相關的職責,應拆分 |
| 類別介面的抽象層次不一致 | 介面隨時間變形,成為拼湊的怪物 |
| 參數列表過長 | 暗示子程式介面的抽象未經深思熟慮 |
| 類別內的修改各自獨立 | 暗示應拆成多個類別 |
| 修改需要同時更動多個類別 | 程式碼組織不良 |
| 繼承階層須平行修改 | 每次建立子類別都得在另一個類別也建立子類別 |
| case 敘述須平行修改 | 考慮改用繼承與多型 |
| 相關資料未組織成類別 | 反覆操作同一組資料,應封裝為類別 |
| 子程式更多使用其他類別的功能 | 考慮搬移到該類別 |
| 基本型別被過度使用 | 如用 int 表示金額,應建立 Money 類別 |
| 類別幾乎不做事 | 考慮將職責併入其他類別後刪除 |
| 傳遞流浪資料(Tramp Data) | 資料只是為了傳給下一個子程式而存在 |
| 中間人物件什麼都不做 | 考慮直接呼叫目標類別 |
| 類別之間過度親密 | 應加強封裝(Encapsulation) |
| 子程式命名不佳 | 趁早更名,越晚成本越高 |
| 資料成員是 public | 應隱藏在存取子程式後面 |
| 子類別只用到父類別的一小部分 | 考慮用 has-a 取代 is-a |
| 用註解解釋難懂的程式碼 | 「不要註解壞程式碼——重寫它」 |
| 使用全域變數 | 重新審視是否有更乾淨的做法 |
| 呼叫子程式前後需要設置/拆卸程式碼 | 檢查介面抽象是否正確 |
| 程式碼看似「未來可能會用到」 | 專家共識是不要寫推測性程式碼,把目前的程式碼寫清楚就好 |
24.3 特定的重構手法#
mindmap
root((重構手法))
資料層級
取代魔術數字
重新命名變數
基本型別轉換為類別
敘述層級
分解布林表達式
用多型取代 case
使用 Null 物件
子程式層級
抽取子程式
分離查詢與修改
合併相似子程式
類別實作層級
上移或下移成員
抽取為子類別
合併到父類別
類別介面層級
搬移子程式
隱藏委派
封裝公開成員變數
系統層級
提供工廠方法
建立權威參考來源
例外與錯誤碼互換資料層級重構(Data-Level)#
| 手法 | 說明 |
|---|---|
| 取代魔術數字 | 用具名常數取代 |
| 重新命名變數 | 用更清楚的名稱 |
| 內聯化/引入中間變數 | 將中間變數內聯化(Inline),或反過來引入中間變數以增加可讀性 |
| 抽取為子程式 | 將表達式抽取以消除重複 |
| 拆分多用途變數 | 將多用途變數拆為多個單一用途變數 |
| 基本型別轉換為類別 | 如 Money、Temperature |
| 型別代碼轉換為類別或列舉 | 或帶有子類別的繼承結構 |
| 陣列轉換為物件 | - |
| 封裝集合 | 回傳唯讀集合,提供新增/移除方法 |
| 用資料類別取代傳統紀錄 | - |
敘述層級重構(Statement-Level)#
| 手法 | 說明 |
|---|---|
| 分解布林表達式 | 引入命名良好的中間變數 |
| 移入具名布林函式 | 將複雜布林表達式移入具名函式 |
| 合併條件式中重複的片段 | - |
| 取代迴圈控制變數 | 用 break 或 return 取代 |
| 立即回傳 | 知道答案就回傳,避免巢狀 if-then-else 的層層展開 |
| 用多型取代 case 敘述 | 取代重複的 case 敘述 |
| 使用 Null 物件 | 取代 null 檢查 |
子程式層級重構(Routine-Level)#
| 手法 | 說明 |
|---|---|
| 抽取子程式(Extract Method) | 最常用的重構之一 |
| 內聯化子程式 | 將簡單子程式的程式碼內聯化 |
| 轉換為類別 | 將過長子程式轉換為類別 |
| 取代複雜演算法 | 用簡單演算法取代 |
| 新增或移除參數 | - |
| 分離查詢與修改操作 | Command-Query Separation |
| 合併相似子程式 | 用參數區分差異 |
| 拆分依賴參數的子程式 | - |
| 傳遞整個物件或特定欄位 | 取決於介面抽象的語意 |
| 封裝向下轉型 | Encapsulate Downcasting |
類別實作重構(Class Implementation)#
| 手法 | 說明 |
|---|---|
| 值物件與參考物件之間互轉 | 依物件大小與使用模式決定 |
| 用資料初始化取代虛擬子程式 | 子類別僅差在常數值時適用 |
| 上移或下移成員 | 在繼承階層中移動子程式、欄位、建構函式 |
| 抽取為子類別 | 將特化程式碼抽取為子類別 |
| 合併到父類別 | 將相似程式碼合併到父類別 |
類別介面重構(Class Interface)#
| 手法 | 說明 |
|---|---|
| 搬移子程式 | 將子程式搬移到另一個類別 |
| 拆分或消除類別 | 將一個類別拆分為兩個;或將功能不足的類別消除 |
| 隱藏委派/移除中間人 | Hide Delegate 或 Remove Middleman |
| 委派與繼承互換 | Replace Inheritance with Delegation,或反過來 |
| 引入外來子程式或延伸類別 | Foreign Routine 或 Extension Class |
| 封裝公開的成員變數 | 移除不可變欄位的 Set() 方法 |
| 隱藏不對外使用的子程式 | - |
| 合併父類別與子類別 | 實作非常相似時適用 |
系統層級重構(System-Level)#
| 手法 | 說明 |
|---|---|
| 建立權威參考來源 | 為無法控制的資料建立(如鏡像 GUI 控制項的資料) |
| 類別關聯方向轉換 | 在單向與雙向之間轉換 |
| 提供工廠方法 | Factory Method 取代簡單建構函式 |
| 例外與錯誤碼互換 | 依錯誤處理策略保持一致 |
24.4 安全的重構#
重構是強大的工具,但若誤用會造成比好處更多的傷害。
安全重構的守則#
| # | 守則 | 說明 |
|---|---|---|
| 1 | 備份起始程式碼 | 存入版本控制或備份目錄 |
| 2 | 保持重構規模小 | 確保完全理解每次變更的影響 |
| 3 | 一次只做一個重構 | 重構後重新編譯與測試,再進行下一個 |
| 4 | 列出步驟清單 | 從 A 點到 B 點的重構計畫 |
| 5 | 設立暫存區(Parking Lot) | 記錄重構途中發現但不急於處理的想法 |
| 6 | 頻繁建立檢查點 | 避免走入死胡同時無法回退 |
| 7 | 善用編譯器警告 | 設定最嚴格的警告層級 |
| 8 | 重新測試 | 搭配回歸測試,並新增測試案例來驗證新程式碼 |
| 9 | 審查變更 | 小修改的錯誤率反而更高 |
研究顯示,修改 1-5 行程式碼時的出錯機率最高。一個組織在引入單行變更的審查後,錯誤率從 55% 降到 2%。 對待簡單的修改,就像對待複雜的修改一樣謹慎。
不適合重構的時機#
- 不要用重構掩飾「邊寫邊修」——重構針對的是可運作的程式碼。對壞掉的程式碼東改西改不是重構,那是亂改(Hacking)
- 避免用重構代替重寫——如果程式碼需要大規模翻修,考慮從頭重新設計與實作
24.5 重構策略#
重構同樣適用 80/20 法則——把時間花在能帶來 80% 效益的 20% 重構上。
| 時機 | 說明 |
|---|---|
| 新增子程式時 | 檢查相關子程式是否組織良好 |
| 新增類別時 | 趁機重構關聯密切的既有類別 |
| 修正缺陷時 | 利用對程式碼的理解,改善可能有類似問題的其他程式碼 |
| 針對高錯誤率模組 | 團隊裡人人害怕碰的程式碼,正是最需要重構的地方 |
| 針對高複雜度模組 | 研究顯示,專注於最高複雜度的模組能顯著改善品質 |
| 在維護環境中 | 不需要修改的程式碼不必重構,但碰到的程式碼要讓它比原來更好 |
策略:定義乾淨程式碼與髒亂程式碼之間的介面
對於老舊系統,可將程式碼區分為三個區域:
- 混亂的現實世界(Messy Real World)——未經整理的遺留程式碼
- 介面層(Interface)——隔離乾淨程式碼與髒亂程式碼
- 整潔的理想世界(Nice Tidy Ideal World)——經過重構的高品質程式碼
每次碰到髒亂程式碼時,就將它提升至目前的編碼標準,逐步將程式碼搬移到「理想世界」那一側。
更多資源#
- Fowler, Martin. Refactoring: Improving the Design of Existing Code (1999)——重構的權威指南,包含本章摘要的各項重構手法的詳細討論與逐步程式碼範例
- 重構的過程與修正缺陷有許多共通之處,可參閱 Section 23.3
- 重構的風險管理與程式碼調校類似,可參閱 Section 25.6
要點#
- 程式碼變更在初始開發與發布後都是必然的事實
- 軟體在修改過程中可能改善也可能劣化;軟體演化的基本法則是內部品質應隨演化而提升
- 成功重構的關鍵之一是學會辨識眾多警示訊號(Smells)
- 另一個關鍵是熟悉大量具體的重構手法
- 最後一個關鍵是擁有安全重構的策略——某些做法比其他做法更好
- 開發期間的重構是改善程式的最佳時機,善用這些機會