🎯 什麼情境該想到我
當你「非改這段舊程式不可,但它沒有測試,你不知道改下去會壞掉什麼」的時候。
書中把這個處境講得很直白——動手前要先回答三個問題(p.6):
「(1) 我們要進行哪些修改?(2) 我們如何得知已經正確地完成了修改?(3) 我們如何得知沒有破壞任何(既有的)東西?」
而遺留程式碼最惡毒的地方,是這三個問題會咬住自己的尾巴。書中稱為 遺留程式碼的困境(p.14):
「我們在修改程式碼時,應當有測試在周圍『護』著。而為了將這些測試安置妥當,我們往往又得先去修改程式碼。」
順帶一提,「不要動它就不會壞」不是解法。書中說避免修改會帶來三個後果(p.6):既有的類別和方法越來越龐大、越來越難以理解;人會對「修改程式碼」這件事生疏,連把大類別拆成小類別都變得棘手;最後是恐懼心理——而且「通常他們並不知道他們的恐懼到底有多重,直到他們學到更好的技術,而恐懼心理也隨之減退」。
⚙️ 怎麼用
步驟 0:先確認你在做哪一種修改
書開頭列出修改軟體的四個起因(p.2):
- 添加新特性
- 修正 bug
- 改善設計
- 優化資源使用
重點不在分類本身,而在於這四種修改在結構、功能、資源使用上各自改變什麼、保持什麼(p.5)。共通點是:要改的永遠只是一小塊,要保持不變的永遠是絕大部分(p.5,圖 1-1「行為保持」)。
「保留既有行為不變是軟體開發中最具挑戰性的任務之一。即使是在改變主要特性時,通常也有很多行為是必須保留不變的。」(p.6)
而真正的難處是:「問題在於我們通常並不知道在修改的過程中哪些行為存在被連帶改變的風險」(p.5)。所以「要想安全地進行修改,關鍵就在於『理解』」(p.5–6)。
步驟 1–5:遺留程式碼修改演算法(p.15–16)
書中原文標題就叫「遺留程式碼修改算法」,全書其餘章節都是在教你怎麼執行這五步:
「以下算法可以用於對遺留程式碼基進行修改:(1) 確定改動點;(2) 找出測試點;(3) 解依賴;(4) 編寫測試;(5) 修改、重構。」(p.15–16)
逐步展開(p.16–17,括號內是書自己指向的章節):
- 確定改動點 —— 改動點和架構關係很緊,「前者敏感地依賴後者」。如果你對設計的理解程度不足以讓你覺得是在正確的地點修改,先去讀第 16、17 章。(p.16)
- 找出測試點 —— 遺留程式碼裡這件事往往不容易,見第 11、12 章。(p.16)
- 解依賴 —— 依賴是測試最明顯的障礙,表現在兩個方面:難以在測試用具中實例化目標物件、難以在測試用具中運行方法。理想上,為了安置測試而做的解依賴動作本身也該受測試保護,「然而這往往只是個奢望」。第一刀(第一處改動)怎麼下得更安全 → 第 23 章;一般依賴問題 → 第 9、10 章;解依賴技術目錄 → 第 25 章;如果卡住是因為大型方法裡的依賴沒解開 → 第 22 章;如果解得開但建置時間太長 → 第 7 章。(p.16)
- 編寫測試 —— 「為遺留程式碼編寫的測試和為新程式碼編寫的測試有些許不同」,見第 13 章。(p.16)
- 修改、重構 —— 作者提倡用測試驅動的開發方式往遺留程式碼中添加特性(第 8 章);而且為了輔助加特性所寫的那些測試,常常反過來能「保護」你去做一些重構(第 20、22、21 章)。(p.17)
作者也提醒這些章節教的只是「嬰兒階段」,不是教你怎麼把設計變得理想乾淨,而是「比原先的設計可維護性更好一些」——「但你可別低估這一點」(p.17)。
這個流程是複利的(p.16):
「隨著時間的推移,程式碼基的受測試部分將會變得越來越大,就好像在海裡不斷生長的島嶼一樣。」
漸漸地島嶼變成陸地,最終你在一塊全面由測試覆蓋的大陸板塊上工作。
為什麼第 3 步(解依賴)非做不可:感知與分離
這是全書第 3 章的主題,也是解依賴的兩個動機(p.18):
「通常,如果我們想要將測試安置到位,有兩個理由去進行解依賴:感知和分離。
(1) 感知:當我們無法訪問到程式碼計算出的值時,就需要通過解依賴來『感知』這些值。
(2) 分離:當我們無法將哪怕一小塊程式碼放入到測試用具中去運行時,就需要通過解依賴將這塊程式碼『分離』出來。」
哪個更嚴重?書說沒有明確答案,「一般來說兩者都需要」(p.19)。但兩者的技術樣貌不同:分離的途徑是多種多樣的(書後列了一整串解依賴技術),而關於感知的技術卻有最突出的一種(p.19)——就是「偽裝成合作者」:讓一個偽物件頂替真正的合作者,測試就能從偽物件身上看到被測物件到底做了什麼(p.19–21)。偽物件的怪異之處在於它有「兩張臉」:一張臉實作被測物件看得到的介面,另一張臉提供只給測試用的查詢方法(p.22–23)。若要寫的偽物件太多,可以考慮更高級的仿物件——「仿物件就是在內部進行斷言檢查的偽物件」(p.23)。
這一章結尾也點名了全書的三個關鍵概念(p.17):
「接下來的兩章則包含了一些關於遺留程式碼工作的三個關鍵概念:感知、分離和接縫的背景知識。」
為什麼要用測試當安全網(p.8–12)
書把改軟體的方式分成兩種(p.8):
「對系統進行改動有兩種主要方式。我喜歡將它們分別稱為編輯並祈禱和覆蓋並修改。遺憾的是,前一種方式幾乎可算是業界的標準做法。」
「編輯並祈禱」看起來像是「小心下手」,但作者說「安全性並不取決於你的細心程度」——精湛的軟體改動就像精湛的外科手術,除了細心之外還要有深厚的技術(p.8)。「覆蓋並修改」則是在改動時張開一張安全網,像斗篷一樣蓋在要改的程式碼上面,「覆蓋軟體即意味著用測試來覆蓋它」(p.8)。
書用了一個很好的比喻——軟體夾鉗(p.9):
「能夠起到檢測改動的作用的測試就好比是為我們的程式碼上了一把夾鉗,使程式碼的行為被固定起來。於是當進行改動時,我們得以知道在一個特定的時間只改動某一處的行為。」
而且反饋要快才有用。書用兩個場景對比:等系統層級的回歸測試要等一整個晚上,還不確定是不是你造成的;有單元測試則是「就在幾分鐘前我們犯了一個錯誤,反轉了一個條件邏輯,然而單元測試迅速給出了失敗的結果,於是我們得以在大約一分鐘內糾正了所犯的錯誤」(p.10)。所以判準是(p.11):
好的單元測試「(1) 運行快;(2) 能幫助我們定位問題所在。」
「一個需要耗時十分之一秒才能執行完的單元測試就已算是一個慢的單元測試了。」
書明確把四種測試排除在「單元測試」之外(p.12):與資料庫有交互的、進行了網路間通信的、調用了檔案系統的、需要你對環境作特定準備(如編輯設定檔)才能運行的。書強調這不是說它們是壞的,只是要跟真正的單元測試區分開來,「因為這樣你就能夠知道哪些測試是你可以(在你進行程式碼修改的時候)快速運行的」(p.12)。
卡關特例:要改的是一個巨型方法(第 22 章)
當第 3 步解依賴卡住是因為方法太大時,書有一整章專門處理。所謂巨型方法是(p.232):
「所謂『巨型』方法就是指那些龐大複雜到你碰都不想去碰一下的方法。巨型方法可能包含成百上千行程式碼,其中還到處都是縮排,搞得你幾乎無法瀏覽。」
先問能不能繞過:很多時候可以用新生方法、新生類手法避開對長方法動刀(p.232,但作者說「就算你逃掉了,也應該為不得不如此感到羞愧」)。
分辨你面對的是哪種(p.232–234):
- 項目列表式方法:幾乎沒有縮排,一串一串的程式碼區塊。看似能一段段提取,但「往往在一個區段聲明的區域變數會在另一個區段被用到」,所以不是複製貼上那麼簡單。
- 鋸齒狀方法:具有單個龐大縮排區塊。判斷法:把方法內的程式碼區塊都按縮排格式排好,「如果結果程式碼讓你感到暈頭轉向,那麼就是了」。
- 實務上多半是兩者的混合體。
分岔點:你有沒有自動重構工具?(p.234)「在對長方法進行重構時,有沒有重構工具會帶來很大的區別。」
A. 有工具(p.236–238)
「在沒有測試的情況下進行自動重構時,一定要只用工具進行(其中不要摻雜手工修改)。而在一系列的自動重構完成之後,往往就可以將測試安置到位,並用這些測試來驗證你所進行的任何手動修改了。」(p.236)
這階段要避免語句重排、表達式分解這類修改(就算很簡單也不行);變數重命名如果工具不支援,就推遲到後面再做。提取時你的兩個主要目標是(p.236):
「(1) 將程式碼中的邏輯部分從尷尬的依賴中分離出來。(2) 引入接縫,以後在重構時才能更容易地將測試安置到位。」
工具自動起的方法名很怪沒關係——「你可以把它們當成一個好的開始」,重點是它們引入了接縫,之後就能靠這些接縫解依賴(p.237–238)。用工具先完成「許多粗糙的工作」,細節等其他測試安置到位之後再做(p.238)。
B. 沒有工具(p.238–244)——這時「測試便成了最強的工具」(p.238)。書先列出手動提取方法最常見的三種錯誤(p.238):忘記把變數傳給提取出來的方法(誤以為它該是區域變數)、給新方法起了一個會覆寫基底類別同名方法的名字、傳參或接受回傳值時弄錯(回傳錯的值,或型別錯)。然後給四種手法:
-
引入感知變數(p.239–241):在類別裡臨時加一個變數,用它來感知待重構方法內的條件是否仍然成立,據此寫測試,重構完成後再把變數刪掉。「使用感知變數時最好將變數放在被重構的類別中,直到一系列的重構完成之後再將它們刪除」——好處是能看到自己為這一系列提取所寫的全部測試,想換提取方式時可以輕鬆撤銷(p.241)。它也是把鋸齒狀方法逐步「反鋸齒」的利器(p.241)。
-
只提取你所了解的(p.241–242):一開始邁小步,找那些不用測試也能放心提取的小塊。作者定義的「小塊」是兩到三行、最多五行,且「一塊你能夠容易地給它想出名字的程式碼」。關鍵指標是耦合數——「傳進傳出你所提取的方法的值的總數」(成員變數的存取不算)。耦合數越小越安全,0 耦合數的提取是最安全的;耦合數大於 0 時通常搭配一個感知變數會有好處。提取完別忘了給新方法補幾個測試。「積跬步以至千里」(p.242)。
-
依賴收集(p.243):當重要行為和其他行為纏在一起時——
「首先你編寫測試來保護你要保護的邏輯。然後,你提取出你的測試所沒有覆蓋到的部分。」
這麼一來,你至少確信保護住了重要的行為。
-
分解出方法物件(p.243–244):建立一個類別,其唯一職責就是做原來巨型方法所做的工作;原方法的參數變成該類別建構函式的參數,原方法的程式碼則放進該類別中一個負責執行的方法裡。程式碼搬進新類別後就好重構了:可以把區域變數做成成員變數,讓它們充當感知變數。與「引入感知變數」不同的是,這裡的感知變數同時也是產品程式碼要用的變數,「這就意味著你寫出來的測試會一直可用」。
提取時的四條策略(p.244–246):
- 主幹提取:把條件和分支體分開提取,之後想重組邏輯會容易一些;提取出來的是程式碼的主幹——控制結構以及對其他方法的委託。
- 序列發現:把條件和分支體一起提取,好處是容易從程式碼中識別出一個操作序列。
- 兩者搭配的偏好:「面對項目列表式方法,我往往會使用序列發現,而鋸齒狀方法則是主幹提取。」但作者也說自己常在兩者之間來回。(p.245)
- 優先提取到當前類別中:就算你覺得這塊程式碼該屬於別的類別(線索是你想給它的名字用到了別的類別的變數名),也先別直接提取過去。「笨拙的名字可以先用著」,因為它讓你做的是能夠輕易撤銷的提取;等好的修改自然浮現時再搬(p.245–246)。
- 時刻準備重新提取:做了一些提取之後常會發現更好的切法,這時撤銷一兩個提取重來是最佳做法。前面的提取不算白做,「事實上它們帶給了你一些非常重要的認識」(p.246)。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
不是「先重構再說」:這套流程的前提是沒有測試。有測試的情況下該做的是小步重構;沒有測試時,你的第一刀必須是為了「把測試安置到位」而下的。書中對重構的定義是「在不改變軟體行為的前提下改善其設計」,而它成立的理由正是「如果我們編寫測試以確保現有行為不變,並在重構過程中的每一小步都小心驗證其行為的不變性」(p.4)。
-
解依賴會留疤,要接受(p.15):
「當在遺留程式碼中解依賴時,你常常不得不暫時將自己的審美感放在一旁。有些依賴能夠乾淨俐落地解除,而有些從設計的角度來看最終還是解決得不那麼完滿。」
書用手術刀口作比喻:刀口縫合後可能變成疤痕,但疤痕之下的東西已經得到治愈;以後若能用測試覆蓋疤痕四周(當初解依賴的點),就可以把疤痕也抹掉。
-
可能會為了測試而讓程式碼「暫時變醜」(p.15):例如為某個方法引入一個產品程式碼並不嚴格需要的形參,或以古怪的方式把類別分裂開。書的建議是:當改動可能引入錯誤時,保守地進行改動是不二之選;不保守可以立刻解決問題,但要看風險有多大。
-
沒有測試時做自動重構,不能摻手工修改(p.236)。把「已知為安全的修改」和「可能不安全的修改」完全分離開來,這件事本身就是價值。
-
依賴收集本質上是駝鳥戰術(p.243):「你保護了一組行為,而同時在無保護的情況下修改其他程式碼。」它成立的前提是「並非所有的行為都是平等的」,你有能力識別出哪些行為更重要。如果你分不出輕重,別用。
-
別指望用大型測試代替單元測試(p.10–11):大型測試有三個問題——錯誤定位困難(測試離被測者越遠,越難確定失敗意味著什麼)、執行時間長(「需要太長時間運行的測試,結果往往是無法運行」)、覆蓋難以對應(難以看出某段程式碼與用來測試它的值之間的聯繫)。書並沒有否定高層測試,它「可以用來一下子就確定一組類別的行為」,而且這樣做往往能讓你更容易為單個類別寫測試(p.12)。
-
巨型方法別一開始就大卸八塊(p.246):「這樣一種漸進式的方法比起一開始就想將巨型方法大卸八塊的做法要好得多。後者常常並不像它看上去那麼容易;而且也不安全,一不小心就會忘記一些細節,而細節卻是程式碼中必不可少的成分。」
🔗 相關工具
- 工具-接縫 —— 第 3 步「解依賴」的核心概念;書自己說感知、分離、接縫是遺留程式碼工作的三個關鍵概念(p.17),而巨型方法提取的目標之一就是「引入接縫」(p.236)
- 工具-特徵測試 —— 第 4 步「編寫測試」要寫的東西:先把現有行為原樣釘住
- 工具-打破依賴以便測試 —— 第 3 步的具體手法清單;書在 p.14 就示範了用「介面提取」與「樸素化參數」兩種手法,解開一個回應類別對資料庫連線物件與上層框架物件的依賴
- 工具-時間緊迫時的安全修改 —— 當你連跑完這五步的時間都沒有時的退路
- 工具-影響分析 —— 支援第 1 步「確定改動點」與第 2 步「找出測試點」:判斷改動的連帶影響落在哪裡
- 工具-小步重構(重構)—— 分界線:重構假設你已經有測試(「如果我們編寫測試以確保現有行為不變…」p.4);這張卡處理的是還沒有測試的情況。先用本流程把安全網織出來,再切換到小步重構
- 回到來源:修改程式碼的藝術