重構:此類別太大
從真實(有缺陷)程式碼庫重構的範例。
在本文中,我將逐步說明從真實程式碼庫重構的步驟。這並非要展示完美,而是呈現現實。
2020 年 4 月 14 日
這是關於重構的故事。這是 TDD 紅綠重構循環[1] 中的第三個項目,也是我們一直都在做的事情,對吧?除了我們沒有這樣做之外。
我有一個不受控的程式碼庫,因為疏於重構而受苦,所以我一直在讓它恢復正常。在本文中,我將採用一個過大的類別,並讓它變小。
問題大綱
這個故事始於一項無聊的家務事。我寫了一些個人會計軟體 - Reconciliate。它可以在命令列上執行,並執行下列動作
- 載入我自己的逗號分隔資料
- 我最近的銀行和信用卡交易記錄。
- 我預測的每月和年度交易(根據中央試算表中的資料)。
- 任何未對帳的先前記錄資料。
- 載入第三方逗號分隔資料(來自銀行和信用卡公司)。
- 將第三方資料與我自己的資料對帳。
- 將所有內容寫回中央試算表。
我有一些需要修正的錯誤和一些需要新增的功能。但我典型的作業流程是在把最小的孩子哄睡後,在深夜抵達筆電前,在我自己就寢前只有少許時間,而且常常是在我上次看到程式碼的幾週後。在這種情況下,很容易想到像「好吧,我知道這段程式碼很亂,但我現在沒有時間深入了解並修正它...」之類的事情。
顯然這並非理想狀態。
特別有一個類別 - ReconciliationIntro
類別 - 每當我看它時都會讓我頭痛。它龐大且複雜,而且不可能「裝進我的腦袋裡」[2]。這造成了惡性循環:「因為這段程式碼很難理解,所以重構會花費比我擁有的更多時間和精力,所以我會忍受更長的時間 - 即使這表示我甚至無法再進行小幅變更,因為我需要花很長的時間才能了解程式碼的目前狀態並決定變更應進行的位置。」
例如,我想新增處理另一張信用卡的功能。在許多地方,我使用 多型 和 策略模式 來將每張信用卡的獨特行為整齊地封裝起來。但由於程式碼設計不良,以及缺乏明確的 脈絡分離,ReconciliationIntro
類別是一個地方,如果我新增另一張信用卡,我會讓一個已經很臃腫的類別變得更糟。它包含四種資料類型(銀行入帳、銀行出帳、信用卡 1 和信用卡 2)的四個重複程式碼路徑,如果我現在新增信用卡 3,我最終會遵循相同的反模式。
我的整體目標是透過策略模式 [3],用更通用的程式碼取代這段程式碼。但有四個問題阻礙著我
- 這個大型類別(
ReconciliationIntro
)承擔了過多的責任。 - 有很多私有的巢狀程式碼,因為它沒有公開介面,所以很難進行單元測試。
- 在
ReconciliationIntro
中,有一個大型方法做了太多事。 ReconciliationIntro
中有幾個方法使用相同的重複模式,但細節不同。
我計畫按照上述順序解決所有這些問題。這個 準備性重構 將使我能夠輕鬆地封裝每張信用卡/帳戶的行為。正如 Kent Beck 所說,"讓變更變得容易,然後進行容易的變更。"。
本文旨在解決上述清單中的第一個問題:這個類別太大。
為何重構?
我無法輕易地推論 ReconciliationIntro
類別,因為它有太多責任。它最初被設計為軟體的入口大廳,它所做的只是顯示一些訊息,然後實例化執行主要工作的類別。但隨著時間的推移,許多其他程式碼已偷偷潛入。我想讓新增另一張信用卡變得更容易,所以我將從將這個大型類別分解成更小的類別開始。
好處將有幾個
我該如何執行?
在我看來,重構(以及撰寫新程式碼)時最重要的原則是以微小的步驟進行。我有幾個相關的目標
- 在每個步驟中,我希望程式碼編譯且測試執行。
- 如果變更導致任何測試失敗,我希望能夠立即修復它們。透過進行小幅變更和提交,我可以看出哪個變更導致測試失敗,而且我只需要回溯/檢查少量程式碼就能找出問題。
- 我可以記住我腦中的位置。著手進行相對簡單的重構非常容易,但最後發現它對您原本的意圖產生了影響。在這個時候,如果您沒有養成在每個步驟中讓程式碼編譯且測試通過的習慣,您可能會發現自己迷失在一個難以逃脫的兔子洞中:您的程式碼無法編譯,您的測試甚至無法執行,更不用說通過了。
為了讓這項工作發揮作用,我需要讓重構的程式碼受到測試的涵蓋。這在我開始重構之前已經完成,儘管值得注意的是,我在未來重構中的目標之一是讓程式碼更具可測試性。
馬丁·福勒的《重構》 是一本推薦的進階讀物,它闡述了一些基本的重構原則。他也強調了以微小的步驟進行以及在每次小幅提交後建置程式碼/執行測試的價值。
除了這些基本原則之外,我將努力遵循以下概述的一系列邏輯步驟。
1. 重新排列方法
我將從使用 區域 開始,將 ReconciliationIntro
類別 中的所有方法重新排列到有意義的分組中 (提交 f2d9932) [4]

圖 1:將方法重新排列到區域後 ReconciliationIntro
現在我已將方法分組到感覺像是一組合理的 情境 中,我想將這些方法提取到不同的類別中。但從何開始?檔案載入程式碼包含最多的重複,且造成最多的困擾。我最終想讓這段程式碼更通用,但首先我想將它提取出來,以便在不受干擾的情況下查看它。我將提取一個新的 FileLoader
類別。其他分組也會變成不同的類別,但它們會簡單許多,因此我將在這篇文章中主要關注檔案載入程式碼。
2. 分析檔案載入方法之間的關聯
到目前為止,我所做的只是在同一個類別中移動一些程式碼。我在提交之前重新建置程式碼並執行所有測試,但除非我笨手笨腳,否則我預期我之前的提交應該是微不足道的。我需要對接下來的動作再三思。
我使用其中一個區域來識別要提取到新的 FileLoader
類別中的方法,但我如何確定這會奏效?是否有任何隱藏的依賴關係?我會透過畫出我想移動的方法之間的關係來找出答案。要移動的方法以藍色標示。

圖 3:要移動的檔案載入方法(縮寫)
我馬上就能看出這不是一個嚴格的獨立環境:有些藍色方法會呼叫回我想要保留在個別類別中的方法(以黑色和綠色顯示)。我有幾種方法可以處理這個問題,我會在下方討論。但現在我已經可以定義行動計畫了。
行動計畫
等我完成後,原始的龐大 ReconciliationIntro
類別將會被分解成精簡版加上五個新類別,如下面的圖表所示。請注意,區域和類別之間並非一對一的對應關係,因為稍後當我進行步驟 7時,我會將我的第一個群組進一步細分為更精細的界線。

圖 4:行動計畫
我的整體目標是將這個龐大的類別分解成較小的類別。在上述步驟 1 和 2 中,我使用區域和圖表來識別程式碼的不同區域,然後分析一些方法之間的關係。這為我提供了足夠的資訊,讓我開始思考如何以微小的步驟來處理這項工作。
以下摘要說明了產生的計畫,然後在下方詳細說明。我透過思考如何使用盡可能小的步驟來進行,進而制定了這個計畫。我所做的通常是設定好一切,這樣下一個變更就會盡可能小而簡單。我進行小變更以利進行更多小變更,這再次遵循Kent Beck 的格言:「讓變更變得容易,然後進行容易的變更。」
根據你的情況,你可能不會遵循相同的計畫,但如果你不確定如何進行,這是一個不錯的範本。你的優先事項是為自己設定安全的增量變更
-
將類別中的所有方法分組成合理的群組,如上方所述。
-
找出檔案載入程式碼如何連接到
ReconciliationIntro
類別中其他程式碼,如 上方 所述。 -
有些方法會保留在父類別中,但目前會由那些正在移動的方法呼叫。需要進行一些修改才能讓這項工作順利進行。
-
新的
FileLoader
類別需要涵蓋測試。要移動的程式碼的測試已經存在,但它們將移到新的FileLoaderTests
類別中。 -
遵循小步驟原則,在移動其他方法之前,我將只移動兩個方法(一個公開方法和一個由它呼叫的私有方法)。
-
這是一個令人興奮的地方。所有我感興趣的檔案載入程式碼終於可以找到新家了!
-
一旦我處理完檔案載入程式碼,我將為其他程式碼區域建立更多新類別。
新的 FileLoader
類別將負責載入各種來源的逗號分隔資料,並將它們合併起來,以便進行對帳。這個檔案載入程式碼是目前最令人頭痛的地方,所以這是我將最詳細描述的部分。您可以在 這裡 看到重構前的原始程式碼,但如果您追蹤該連結,您將只會發現它太大而無法放入您的腦袋中!精簡的重構後版本在 這裡。
所以,現在我可以繼續執行計畫
3. 修改保留的方法
我已找出兩個方法 - Set_path
和 Recursively_ask_for_budgeting_months
- 它們是由檔案載入方法呼叫的

圖 5:由檔案載入方法呼叫的方法(縮寫)
只要這些方法與檔案載入類別的耦合不那麼緊密,我就可以
- 讓它們公開,這樣我就可以從新的
FileLoader
類別呼叫它們。 - 將用戶端呼叫從檔案載入程式碼移出,並改為移到其他本機
ReconciliationIntro
方法中。
我將對 Recursively_ask_for_budgeting_months
使用第一種方法,對 Set_path
使用第二種方法,這將使我進入以下情況

圖 6:移動後的檔案載入方法(縮寫)
請注意,在圖表中標記為 FileLoader
方法的方法在這個步驟結束時仍將位於 ReconciliationIntro
類別中,但這將 使 我能夠將它們移到 FileLoader
類別中,這將在 步驟 5 和 步驟 6 中發生。
Recursively_ask_for_budgeting_months
最終會成為另一個類別中的公共方法,但目前我只想確定我可以在其他地方呼叫它。結果它已經是公開的,這樣才能進行測試。這本身就是一種程式碼異味 - 它表示將其作為單獨類別的公共介面的部分會更好。
從檔案載入程式碼呼叫的另一個方法是 Set_path
。這會變更內部路徑變數的值,所以我會選擇選項 2:我會個別呼叫它,並透過參數將結果資料傳遞到檔案載入方法。
請注意,這些通常可能不會保持為單獨的提交(我可以使用微提交,然後將它們壓縮成較大的提交),但我保持小型提交完整,以使步驟清晰。我在每個步驟編譯並執行測試
-
修改呼叫方法 (
Create_pending_csvs
),使其採用參數,但最初給它一個預設值 (提交 acc3519) [4],以便程式碼仍然編譯private void Create_pending_csvs() { // Some code }
⇓private void Create_pending_csvs(string path = "") { // Some code }
-
在呼叫
Create_pending_csvs
之前個別呼叫Set_path
。採用結果的新_path
成員變數(請參閱 側邊欄),並將其傳遞到Create_pending_csvs
(提交 c5ebc2f) [4]case "1": { Create_pending_csvs(); } break;
⇓case "1": { Set_path(); Create_pending_csvs(_path); } break;
-
從
Create_pending_csvs
中移除對Set_path
的呼叫,並使用傳入值,而不是成員變數 (commit 6df8f97) [4]private void Create_pending_csvs(string path = "") { try { Set_path(); var pending_csv_file_creator = new PendingCsvFileCreator(_path);
⇓private void Create_pending_csvs(string path = "") { try { Set_path(); var pending_csv_file_creator = new PendingCsvFileCreator(path);
-
最後,從
Create_pending_csvs
參數中移除預設值,從而強制所有客戶端傳入值 (commit 2be56ea) [4]。請注意,按此順序執行操作,我讓程式碼隨時編譯private void Create_pending_csvs(string path = "") { // Some code }
⇓private void Create_pending_csvs(string path = "") { // Some code }
4. 為新的 FileLoader
類別建立涵蓋測試
我要移動的方法的第一個選擇是 Bank_and_bank_out_ _Merge_bespoke_data_with_pending_file
。我要做的第一件事是將任何涵蓋測試複製到即將建立的 FileLoader
類別的新測試類別中。這個方法已經有一個測試 - M_MergeBespokeDataWithPendingFile_ WillAddMostRecentCredCardDirectDebits - 其工作是確保此方法將新的直接扣款資料正確合併到「待處理」檔案中(該檔案建立為包含所有新的交易資料)。
請注意,我只會移動測試,而不是撰寫新的測試。人們可能非常習慣 TDD [1] 的概念,即在撰寫程式碼之前撰寫測試,他們假設你需要在每次處理程式碼時撰寫新的測試。在重構時,通常不會這樣。理想情況下,我的功能已經涵蓋測試,而我在重構時並未變更功能。因此,我不會撰寫新的測試,而是使用現有的測試來驗證功能仍按原先預期運作。
這是檢閱此測試的好時機:我已經有一段時間沒有撰寫它,因此我應該能夠快速找出它是否有意義。我希望我的測試清晰且易於閱讀,它們應作為我的系統行為的文件。我注意到的第一件事是它包含一個斷言方法 - Assert_direct_debit_details_are_correct
- 其名稱不適當。「正確」的定義是什麼?我重新撰寫測試以使其更易於閱讀,這涉及相當多的變更。為了讓這篇內容易於消化,我不會深入探討所做的變更,但您可以在 commit f090f26 和 commit 6a6cece 中檢視它們。 [4]
現在我已重構測試,我將它複製到新的測試類別中,以及一些相關的私有輔助方法。請注意,儘管此測試和其他測試注定要在新的 FileLoader
類別上執行,但它們仍會作用於舊的 ReconciliationIntro
類別,直到我確定我的新測試類別具備所需的一切為止。另請注意,在所有內容安全地移動之前,我的測試程式碼會重複。
我首先在與原始檔案相同的檔案中建立新的測試類別 (提交 491c795) [4],以便於查看我正在複製什麼。然後我可以讓 Resharper [5] 和 Visual Studio 將所有內容移到新的檔案中 (提交 c9317c0) [4]。

圖 7:移動 BBO 測試
5. 建立新的 FileLoader
類別並移動兩個方法
現在我的測試類別已啟動並執行,我可以建立新的 FileLoader
類別。
在鏈的最底層的方法,也就是 我的方法樹 中最低的葉子,是 Bank_and_bank_out__Add_most_recent_credit_card_direct_debits
。這是一個沒有獨立測試的私有方法(它透過公開呼叫方法進行測試),所以我將移動它及其呼叫者(Bank_and_bank_out__Merge_bespoke_data_with_pending_file
)。這將是我要移動到新類別中的前兩個方法。
同樣地,我將逐步移動,以避免我的測試變為紅色,並隨時保持我的程式碼建置。在以下每個步驟之後,我都會確保程式碼建置,並且測試通過。
-
這是我的起始情況。已建立一個
FileLoaderTests
類別,但它正在測試仍存在於ReconciliationIntro
類別中的程式碼圖 8:新的 FileLoader 類別第 1 部分 (縮寫)
-
我從建立一個新的
FileLoader
類別開始。我的檔案載入方法之一 (Bank_and_bank_out__ Add_most_recent_credit_card_direct_debits
) 是私有的,而且在這個程序的最後也會是私有的。它只會被即將在新的類別中緊接在它之後的方法呼叫。它沒有個別的測試涵蓋,所以我只要將它複製到新的類別中,並在呼叫者被移動時準備好並等待即可 (請參閱 提交 0341476) [4]。但我需要暫時讓它公開圖 9:新的 FileLoader 類別第 2 部分 (縮寫)
-
現在,我可以在
ReconciliationIntro
類別中建立新的FileLoader
類別的執行個體,並呼叫它的新公開方法,而不是舊的私有方法。我也可以刪除舊的私有方法 (請參閱 提交 bde2ae2) [4]圖 10:新的 FileLoader 類別第 3 部分 (縮寫)
-
將原始呼叫者複製到新的類別中 (請參閱 提交 f0a5a59) [4]。請注意,在這個時候它是重複的
圖 11:新的 FileLoader 類別第 4 部分 (縮寫)
-
將我正在測試的物件從
ReconciliationIntro
執行個體變更為新的FileLoader
執行個體。將測試指向原始呼叫者的新副本。請注意,由於呼叫它的私有方法也已複製,因此我的測試將通過 (請參閱 提交 3d573e3) [4]圖 12:新的 FileLoader 類別第 5 部分 (縮寫)
-
現在,我可以從
ReconciliationIntro
類別直接呼叫原始呼叫者 (請參閱 提交 77c0b14) [4]圖 13:新的 FileLoader 類別第 6 部分 (縮寫)
-
再次讓原本私有的方法變為私有,並刪除原始呼叫者。我也會刪除舊的測試類別,因為它的所有測試現在都已複製到
FileLoaderTests
中。(請參閱 提交 27f1a59) [4]圖 14:新的 FileLoader 類別第 7 部分 (縮寫)
6. 將其他方法移到新的 FileLoader
類別
現在我可以移動所有其他方法。我會逐一處理,從最簡單的方法開始,並注意依賴關係(方法之間以及任何成員資料)。我需要考慮以下事項
- 我要重新命名任何方法嗎?
- 我要用新物件取代任何參數清單嗎?
- 有任何多餘的參數嗎?
- 任何內部嵌套的呼叫者會變更狀態嗎?這會造成什麼影響?
在移動這些呼叫者之前,我可以將它們內聯到鏈中的較低層級,但如果這樣做,我會中斷將它們呼叫為公開方法的測試。因此,我單獨移動它們。我按照以下說明的順序執行,一次處理一個,並從鏈中的最後一個開始,也就是以下樹狀圖中最外層的葉子

圖 15:FileLoader 方法樹 (縮寫)
對於每個方法,我使用以下方法
- 在目標類別中建立方法的副本,保留原處。將新方法設為公開。
- 呼叫新方法,而不是原始方法。
- 將任何涵蓋測試複製到新的測試類別,並確保它們測試新程式碼。
- 刪除舊方法和舊測試。
我已經移動了 Bank_and_bank_out__ Merge_bespoke_data_with_pending_file
和 Bank_and_bank_out__ Add_most_recent_credit_card_direct_debits
(commit 0341476 到 commit 27f1a59),現在我對其他每個方法重複相同的動作 (commit 7cd53f6 到 commit 7ab95f2) [4]。這次我不會提交每個微小的步驟,但我仍然會執行相同的步驟。在每個步驟之後,我都會確保程式碼建置成功,而且測試通過(除了我故意讓測試失敗的情況)。
我稍早重構了一些測試,我可以對遵循相同模式的測試重複這些變更。對於某些方法,移動非常簡單,因為它們沒有測試涵蓋範圍。這就是我執行此重構的原因之一,以使該程式碼更容易進行測試。
7. 萃取更多新類別
在一開始新增區域時,我發現了一些取得預算月份功能,它會建立自己的明確內容,因此我將這些方法抽取到一個新的 BudgetingMonthService
類別中。這非常快速且簡單,因為這些方法只有一個公開的進入點(請參閱提交 6103f0b)[4]。
ReconciliationIntro
仍然太大,但所有方法都互相呼叫,而且現在我花更多時間讓自己重新熟悉程式碼後,我不確定我剩下的兩個區域 使用者說明和輸入
和 除錯試算表操作
是否是分割剩餘程式碼的最佳方式。為了幫助自己思考,我使用試算表快速說明呼叫層級。

圖 16:呼叫層級
這讓我看到有三個獨立的程式碼區域,而不是兩個:使用者說明
、收集檔案/路徑資訊
和除錯模式切換程式碼
。
我移除原始區域 (提交 4c57927),並用四個新區域 (提交 3446a54) [4] 取代它們。我重新排列方法以符合新的區域,這些方法現在將轉換為三個新類別:Communicator
、PathSetter
和 DebugModeSwitcher
(FileLoader
和 BudgetingMonthService
未顯示在此圖表中,因為它們已經被抽取出來)

圖 17:識別最終 ReconciliationIntro 類別
我使用與 BudgetingMonthService
相同的原則,逐步且安全地建立這些新類別,並在它們發揮作用後移除新區域(請參閱提交 7f464a4至提交 7e118c1)。
值得注意的是,在從較大的類別中抽取新類別時,我希望它們是獨立的。因此,我故意避免有時會出現的反模式,其中抽取的類別會自動注入為原始類別建構函式的相依性。
PathSetter
類別並非微不足道 - 請參閱提交 2921220至提交 398539a[4]。這是因為路徑設定程式碼目前有點曲折,我在步驟 3 的最後注意到這一點。透過將此程式碼抽取到一個獨立的類別中,並賦予它自己的明確內容,我已經讓這個程式碼變得更好一點 - 但它仍然需要一些關注。
最後,我的 ReconciliationIntro
類別更 簡潔易懂,而原本的 41 個方法也已減少為三個:Start
、Reconciliate
和 Do_matching

圖 18:最後的 ReconciliationIntro 類別
回顧
我的類別太大了。我養成了不良的編碼習慣,我想在新增任何新功能之前先停止並改善它。
為了修復我過大的類別,我採取了以下動作
- 重新排列方法為明智的分組,以協助識別新類別。
- 分析檔案載入方法之間的關係,以便找出如何建立一個新的
FileLoader
類別,並與程式碼的其他部分保持最小的關聯。 - 修改保留的方法,以便它們可以由正在移動的方法呼叫(如果需要)。
- 為新的
FileLoader
類別建立覆蓋測試,方法是建立一個新的FileLoaderTests
類別。 - 建立新的
FileLoader
類別並移動兩個方法. - 將其他方法移動到新的
FileLoader
類別. - 萃取更多新類別.
我 以極小的步驟移動,而且我在每一步都編譯並執行測試。現在我有一個更小的類別,它會從其他幾個也更小的類別呼叫功能,而且每個類別都更容易放入我的腦海中。
接下來是什麼?
請注意,本文在重構過程中結束,因此如果你想在繼續執行後續步驟之前查看程式碼的相關狀態,你需要 檢出 提交 6103f0b。另請注意,我在本文中於該提交 之後 完成了 步驟 7 的大部分(說明 在此)。
在 commit 6103f0b 中,我已將檔案載入程式碼拉出到 FileLoader
類別,並更清楚地了解主要挑戰。以下四個方法(在 這裡 可見)明顯有問題
Load_bank_and_bank_in
Load_bank_and_bank_out
Load_cred_card1_and_cred_card1_in_out
Load_cred_card2_and_cred_card2_in_out
它們有以下問題
- 有許多重複 - 一目了然,它們看起來相同。
- 它們太長了。
- 它們在內部建立物件,並以緊密相依的方式將它們傳遞給彼此,這無法測試。
這些都是我想解決的問題,但在進行更多重構之前,我確實需要針對這些方法進行一些測試。那是我的下一步。我的願望是寫一篇後續文章,但我已經學到教訓,不要對這類事情做出承諾,所以目前這是一個讀者的練習。
致謝
非常感謝以下人員,他們非常親切地閱讀了本文的早期草稿,並提供了寶貴的回饋和建議:Paula Paul、Martin Fowler、Priti Biyani、Riccardo Novaglia、Dan Terhorst-North、Kevlin Henney、Steve Freeman、Jon Skeet、Sal Freudenberg、Joe Ray、Chris Shepherd、Luke Morton、Scott Giminiani、Richard Foster、Marcos Bezerra、Sam Carrington。
註腳
1: TDD 代表測試驅動開發,一種確保所有程式碼單元都經過測試,且測試描述系統行為的技術。這是透過在撰寫程式碼讓這些測試通過之前撰寫測試來完成的。還有很多可以說的,但這不是本文的重點。您可以在 這裡 閱讀更多相關資訊。
2: 「裝得下我的腦袋」是 Dan Terhorst-North 的 軟體更快 書中的一個模式(仍在進行中的作品)。他談到能夠透過「裝得下你的腦袋」來理解任何程式碼區塊的重要性。這個名稱來自 James Lewis,而 Dan 在 這場演講 中描述了它,但 Dan 告訴我這個概念可能源自 Alistair Jones。
3: 重構策略模式:此重構的目標很大一部分是要啟用 策略模式。關於「重構為模式」的業務有一整本 由 Joshua Kerievsky 編寫的書,如果您想了解更多,這本書值得一讀。正如 Martin Fowler 所說:「許多人表示,他們發現重構方法是學習模式的更好方式,因為您可以逐步看到問題和解決方案的交互作用。」
4: 提交連結:不要覺得有義務追蹤提交連結!有關這方面的更多資訊,請參閱 如何閱讀本文 側邊欄。
5: Resharper 是常用的 Visual Studio 延伸模組,用於程式碼編輯等事項。但是它不是免費的,而且正逐漸被原生 Visual Studio 工具取代。
重大修訂
2020 年 4 月 14 日:首次發布