重構(Refactoring):不是重寫,而是在不改行為下讓程式變得更好改

重構(Refactoring):是在不改變外部可觀察行為的前提下,改善程式內部結構。做法是把大改動拆成很多小步,每一步都用測試確認行為沒有被破壞,讓程式變得更容易理解、修改與延伸。

很多人說「這段程式該重構了」,其實腦中想的是另一件事:重寫。

把舊程式整段刪掉,換成新的架構、新的類別、新的命名,看起來很乾淨;但如果同時改了結構、行為、資料流程與介面,一旦出錯,就很難知道到底是哪一步造成的。

Martin Fowler 對重構的核心要求很嚴格:外部行為不變,內部結構變好。

也因此,安全重構依靠的是小步、測試與可回復,而不是工程師對程式多熟。

目錄 隱藏

先分清楚:重構不是修 Bug,也不是加新功能

重構最容易混亂的地方,是把不同類型的工作塞在同一次修改裡。

例如你在整理一個付款方法時,順手修正折扣計算、再加一個新的付款方式,最後測試失敗,就會同時面對三個問題:

  • 是不是原本結構改壞了?
  • 是不是 Bug 修錯?
  • 是不是新功能邏輯有問題?

更安全的做法,是把工作切成不同階段:

  1. 先讓既有測試通過。
  2. 只做結構重構。
  3. 確認行為完全相同。
  4. 提交一次。
  5. 再開始修 Bug 或新增功能。

這樣每一個變更都更容易理解,也更容易回滾。

安全重構的基本循環:綠燈 → 小改 → 再綠燈

一個簡單但很重要的工作節奏是:

  1. 先跑測試,確認目前是綠燈。
  2. 只做一個小型結構變更。
  3. 再跑測試。
  4. 仍然綠燈才繼續。
  5. 如果紅燈,優先回復上一個安全狀態。

這個節奏看起來很慢,實際上往往比一次改很多更快。

因為當測試失敗時,你只需要檢查剛剛最後那一個改動,而不是在幾百行差異裡找問題。

沒有測試時,不要直接開始大範圍重構

如果舊系統完全沒有測試,最大的風險是沒有回饋機制;程式碼醜反而不是第一順位。

這時應該先補足足以保護目前變更路徑的測試,再開始整理結構。

不需要一口氣追求整個系統高覆蓋率。先保護下一個真的要修改的區域即可。

如果遇到的是「沒測試又不敢動」的舊系統,可以先看Working Effectively with Legacy Code:沒測試又不敢動的舊程式,要怎麼安全修改。

程式碼異味(Code Smell):提醒你這裡值得檢查

程式碼異味不直接代表程式錯了,它是在提醒你:這裡的結構可能正在增加理解與修改成本。

常見的異味包括:

  • 方法太長。
  • 重複程式碼。
  • 一個類別負責太多事情。
  • 同一個小改動需要碰很多類別。
  • 方法大量使用另一個類別的資料。
  • 條件判斷愈疊愈深。
  • 同一組參數一直在不同方法間傳遞。

異味的價值,在於它讓「我覺得這段很亂」變成比較具體的診斷。

長方法(Long Method):最常見,也最適合先練習

方法愈長,讀者愈需要在腦中記住更多變數、分支與狀態。

一個很實用的判斷方式是:如果某一段程式需要靠註解才能說明「這一段在做什麼」,那段程式通常適合抽成一個有意義名稱的方法。

例如:

// 計算會員折扣

這段註解本身就已經暗示一個方法名稱:calculateMemberDiscount()。

抽取方法(Extract Method):重構最重要的基本功

抽取方法的目的,不只是讓函式變短。

它的價值,是把「這段程式在做什麼」用名稱表達出來,讓高階流程能像閱讀步驟一樣清楚。

例如原本一個方法裡同時處理:

  • 驗證訂單。
  • 計算折扣。
  • 計算運費。
  • 儲存訂單。
  • 寄送通知。

抽開之後,主方法就能直接顯示完整流程。

但也不要走到另一個極端:如果抽出來的方法名稱和裡面的程式一樣難懂,只是增加一次跳轉,就沒有幫助。

內聯方法(Inline Method):不是所有拆分都值得保留

有些方法只有一行,而且方法名稱和內容一樣清楚。

如果呼叫者每次都要跳到另一個檔案才知道它只是在回傳一個簡單判斷,那這層抽象可能沒有價值。

這時可以考慮內聯方法(Inline Method),把不必要的間接層拿掉。

所以重構的重點不是一路往下拆,而是讓每一個抽象都值得存在。

功能依戀(Feature Envy):方法放錯地方了嗎?

如果一個方法大量讀取另一個類別的欄位,反而很少使用自己的資料,就可能是功能依戀。

例如 Order 裡的 calculateShipping() 幾乎都在讀 ShippingPolicy 的資料,那麼這段邏輯可能更適合移到後者。

這時常用的是移動方法(Move Method)。

把行為放到它最常使用的資料附近,可以降低跨類別依賴,也減少一個需求改動牽動很多地方的機率。

散彈式修改(Shotgun Surgery):小改動卻要碰很多檔案

如果修改一個規則時,需要同時改五、六個類別,通常代表同一份設計知識散落太遠。

這就是散彈式修改(Shotgun Surgery)。

常見處理方式包括:

  • 移動方法或欄位。
  • 把相關邏輯集中到同一個類別。
  • 建立一個明確擁有該規則的物件。

這個問題也和技術債高度相關。如果你想看整體還債策略,可以延伸閱讀技術債怎麼還?不要大重寫,而是讓舊系統一點一點重新變得安全。

發散式修改(Divergent Change):一個類別有太多理由要改

另一種相反的訊號是:所有功能都塞在同一個大類別裡。

付款規則改要碰它、Email 改要碰它、報表改也要碰它。

這種情況通常表示責任過多,可以考慮抽取類別(Extract Class),把不同變更方向拆開。

判斷重點在於它有幾種不同理由需要被修改,行數只能當輔助訊號。

參數物件(Introduce Parameter Object):同一組參數一直一起出現

如果很多方法都反覆出現 startDate、endDate、timezone,這組資料其實可能已經形成一個概念。

把它們整理成像 DateRange 這類參數物件,可以帶來幾個好處:

  • 減少方法參數數量。
  • 讓這組資料有清楚名稱。
  • 相關驗證可以集中。
  • 未來新增欄位不必修改所有方法簽章。

基本型別迷戀(Primitive Obsession):重要概念只用字串或數字表示

如果 Email、金額、電話、會員等級都只是 String 或 int,商業規則很容易散落在各處。

例如「Email 必須符合格式」如果每個地方都自己驗證,就會出現不一致。

這時可以把資料值轉成物件,例如 EmailAddress、Money、DateRange。

目的在於把和該概念有關的規則集中,不是單純增加類別數量。

條件判斷太複雜時,先做條件分解

長條件式通常很難讀,尤其是 if 裡同時有很多布林運算。

條件分解(Decompose Conditional)的做法,是把:

  • 條件。
  • 成立時的行為。
  • 不成立時的行為。

分別抽成有名稱的方法。

讓讀者先看懂「意圖」,需要時才深入細節。

守門條款(Guard Clause):先處理例外,再回到主要流程

深層巢狀條件會讓主流程被大量縮排包住。

守門條款的思路是先處理特殊情況:

  • 資料不存在就先回傳。
  • 權限不足就先拒絕。
  • 狀態不合法就先停止。

把邊界條件提前處理後,主要流程可以保持平坦。

條件轉多型(Replace Conditional with Polymorphism):不是所有 switch 都要留下

如果系統一直根據「類型」決定不同計算方式,例如會員類型、員工類型、付款類型,而且每次新增一種類型都要修改同一個 switch,就可以考慮多型。

把每一種類型自己的行為放回對應物件,能避免條件越長越難維護。

但不是看到 switch 就一定要多型。如果分支少、變動低,而且邏輯清楚,保留簡單條件往往更好。

三次法則(Rule of Three):重複一次先觀察,第三次再抽象

看到兩段相似程式碼時,工程師很容易立刻抽象。

但有些相似只是暫時看起來一樣,未來會往不同方向演進。

三次法則(Rule of Three)提供一個簡單提醒:

  • 第一次:先寫。
  • 第二次:注意重複。
  • 第三次:如果仍然相同,再認真抽象。

這個規則只是提醒你別太早為假想共通性建立抽象,次數本身不是硬性規定。

預備式重構(Preparatory Refactoring):先把路鋪好,再加新功能

如果你準備新增功能,發現現有結構很難插入,不一定要直接硬塞。

可以先做一小段預備式重構:

  • 把責任移到比較合理的位置。
  • 抽出可以重用的方法。
  • 把條件簡化。
  • 建立新的介面。

先讓新功能的落點變得清楚,再開始新增行為。

這類重構很實用,因為它和真實需求綁在一起,不容易變成無目的的大掃除。

大範圍 API 修改,可以用平行變更降低風險

有些重構不能一個 Commit 就完成,例如一個被幾十個呼叫端使用的 API 要改名稱或參數。

這時可以用平行變更(Parallel Change):

  1. 先加入新的介面。
  2. 保留舊介面。
  3. 逐步把呼叫端移過去。
  4. 確認舊介面沒有使用者後,再移除。

這比一次改完所有呼叫端更適合正式環境。

抽象分支(Branch by Abstraction):大型重構也能小步前進

如果要替換一個核心模組,不一定要開一個半年都不能合併的巨大分支。

可以先建立抽象層,讓舊實作和新實作暫時都能存在,再逐步把流量或呼叫切過去。

這個方式的價值,是讓大型重構仍然可以保持小步、持續整合。

重構不要和效能最佳化混在一起

重構追求的是結構清楚;效能最佳化追求的是更快。

兩者如果同時做,很難判斷修改造成的影響。

更好的順序通常是:

  1. 先把程式整理到容易理解與測試。
  2. 用量測找出實際瓶頸。
  3. 只最佳化瓶頸。

清楚的程式也更容易做效能調整。

什麼時候不該重構?

重構不是任何時候都值得做。

幾種可以先不動的情況:

  • 這段程式很快就會被刪除。
  • 完全沒有測試,而且短期也無法建立安全網。
  • 現有程式已經壞到局部改善成本高於替換。
  • 目前最急的是生產事故,應先止血。

好的工程判斷,是知道什麼時候整理最有價值,而不是看到髒程式就立刻動手。

重構之後,要問的不是「變漂亮了嗎」

更實際的檢查問題是:

  • 下一個人比較容易看懂嗎?
  • 修改一個規則需要碰的地方變少了嗎?
  • 方法名稱能不能直接說出意圖?
  • 重要知識有沒有集中?
  • 測試仍然完整通過嗎?

如果只是檔案變多、方法變短,理解成本卻上升,那不一定是成功重構。

這個判斷和A Philosophy of Software Design:真正好的模組,應該把複雜藏在裡面很接近:抽象有沒有價值,要看它是否真的降低外部複雜度。

一張表快速對照常見異味與重構方式

異味 常見重構
長方法 抽取方法
重複程式碼 抽取共用方法/上移方法
功能依戀 移動方法
大型類別 抽取類別
同一組參數重複出現 引入參數物件
深層巢狀條件 守門條款/條件分解
依類型切換的大型 switch 條件轉多型
大量魔法數字 命名常數

常見問題

重構一定要有單元測試嗎?

至少要有足以保護目前行為的自動化測試。測試類型不一定只能是單元測試,也可以是特性測試、整合測試或其他可信回饋。

重構和重寫最大的差別是什麼?

重構用一連串小型、可驗證、行為不變的轉換改善結構;重寫則通常一次替換較大區塊,風險與未知都更高。

方法一定要少於 10 行嗎?

不必把行數當硬性規則。方法過長只是訊號,更重要的是它是否同時處理太多層次、太多責任,或讓讀者必須記太多狀態。

看到重複程式碼就一定要抽共用方法嗎?

不一定。先確認這兩段程式真的代表同一個概念,而且未來會一起變。太早抽象也會增加耦合。

重構是不是只能由資深工程師做?

不是。命名清楚、步驟小、測試完整的重構很適合團隊共同進行;反而「一次大改很多」才高度依賴個人經驗。

重構多久做一次比較好?

最適合的時機通常就是你要修改那段程式時。理解、修正、加功能的同時做小步整理,比固定半年一次大掃除更可持續。

重構的價值,是讓下一次修改更容易

重構不是一個獨立的美化專案,也不是工程師有空才做的清潔工作。

它的價值,是把每一次功能修改、程式錯誤修正與理解程式碼的過程,變成改善結構的機會。

當測試能保護行為、每一步都足夠小、每個異味都對應到清楚的結構問題,程式就不需要等到「爛到只能重寫」才處理。

好的重構結果,是讓明天要改的人更快知道該去哪裡、敢動哪裡,也更容易確認自己沒有把原本功能弄壞;今天看起來漂不漂亮反而是其次。

參考資料

Martin Fowler:Refactoring: Improving the Design of Existing Code。
Michael Feathers:Working Effectively with Legacy Code。

元氣站長
元氣站長

經營網路事業,賺取持續收入!
網路賺錢修煉,成就理想人生!