📌 30 秒摘要(Layer 3)
作者對「遺留程式碼」給了一個刻意窄化的定義:沒有編寫相應測試的程式碼——問題不在舊,在不敢改。全書圍繞一個困境展開:要安置測試得先改程式碼,而要安全改程式碼又需要測試。破局的關鍵是接縫(可以在不修改該處程式碼的前提下改變行為的點)。書給的五步演算法是:確定改動點 → 找出測試點 → 解依賴 → 編寫測試 → 修改與重構(p.15–16)——其中第 4 步在遺留碼的情境下多半會用到特徵測試(把現況鎖住而非驗對錯)。第 25 章另附 24 種解依賴技術的完整型錄。
🗺 心智圖(Canvas)
修改程式碼的藝術
Link to original 🛠 修改程式碼的藝術
Working Effectively with Legacy Code
沒有測試的程式碼怎麼改
🧭 主流程
🎯 什麼情境該想到我
當你「非改這段舊程式不可,但它沒有測試,你不知道改下去會壞掉什麼」的時候。
書中把這個處境講得很直白——動手前要先回答三個問題(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);這張卡處理的是還沒有測試的情況。先用本流程把安全網織出來,再切換到小步重構
- 回到來源:修改程式碼的藝術
🔍 動手前
🎯 什麼情境該想到我
當你「踏進一份陌生的舊程式碼,光用眼睛讀怎麼讀都讀不懂,也沒有文件、沒人能問」的時候。
書講得很直白:很多人不肯用其他辦法,是因為他們正忙著想盡快把程式碼看懂——「把時間花在理解某某東西上面感覺就好像不是在用心工作一樣。要是可以迅速完成理解的過程,就可以接著投入『真正的』工作了」(p.172)。書說這麼想不對,因為他們「本可以只需花上一點點低技術含量的工夫就可以讓自己後面的工作處於一個更堅固的基石上了」(p.172)。
⚙️ 怎麼用
書分成兩層:看不懂「某一段碼」(第 16 章)和 看不懂「整個系統」(第 17 章)。
A. 看不懂某段程式碼(第 16 章,四招)
1. 註記/草圖(16.1,p.172)
「如果閱讀程式碼還是搞不清的話,你可以畫草圖並作一些註記。把最近看到的重要的地方寫下來。如果你看到它們之間存在著某個聯繫,就在它們之間畫一條線。」(p.172)
- 不必是 UML 或函式呼叫圖那種正式東西;書中那張圖是作者「畫在一個備忘錄的反面的」(p.172)。
- 事情真的變複雜了,再考慮「把圖畫得正式、乾淨一點」(p.172)。
- 它的價值有兩個:「幫助我們從另一個角度來看待問題」、以及在理解特別複雜的東西時「對於記錄我們的思考狀態也是極有幫助的」(p.172)。
- 推廣方式是傳染而不是強迫:「根本無需強迫你的團隊去使用它,你所需做的就是,等到你去協助某個希望理解某塊程式碼的人的時候,在解釋的時候通過畫草圖來幫助你解釋程式碼的意思」(p.173)。
2. 清單標註(16.2,p.173)
第一步是把程式碼印出來(p.173),然後照你「到底想要理解什麼」選標註方式:
你想幹嘛 書給的標註手法 頁 分離職責 用一個記號把屬於一起的幾段/幾行圈成同一組,「可能的話,使用多種顏色」 p.173–174 理解一個很長的方法 把程式碼區塊對齊:從區塊開頭到結尾畫一條線,或在結尾用註解標明它對應哪個條件語句/迴圈。對齊的最簡單辦法是從內到外——往下讀,遇到第一個結束處先標記,再回溯標記與它配對的開頭,然後繼續 p.174 想拆掉長方法 把想提取出來的程式碼圈起來,並註上它的耦合數 p.174 想知道改動會影響什麼 在準備改的那行旁做記號 → 在每個可能被它影響的變數和方法呼叫旁做記號 → 再往外一層……「盡量多做幾次直到你看清影響是如何從修改點傳播開來的」 p.174 最後一項書直接說了報酬:「通過這一過程,你就會對自己所需要測試的東西有一個更好的認識」(p.174)。
3. 草稿式重構(16.3,p.174)
「認識程式碼的最佳技術就是重構。你走進程式碼,一番搗鼓之後,程式碼變得更清晰了。」(p.174)
做法:從版本控制系統取出程式碼,別去管測試,只管提取方法、搬移變數,用你喜歡的方式重構——「但這麼做了之後你可得記住別再把這些程式碼輸入到伺服器上去了」(p.174)。就是丟棄式的。作者說同事原本覺得浪費時間,「但半個小時之後他的看法完完全全改變了」(p.174)。
4. 刪除不用的程式碼(16.4,p.175)
「如果你正在看的程式碼讓你感到迷惑,而且你敢斷定這段程式碼中有些部分並沒有被使用到,那麼就刪掉它們吧。它們除了擋路之外沒帶來任何正面效應。」(p.175)
覺得刪掉可惜?「版本控制系統正是替你做這事情的」(p.175)。
B. 看不懂整個系統(第 17 章,三招)
先認清阻礙。書列了團隊搞不懂自己架構的三個原因(p.176):系統太複雜、要有整體認識得花很長時間;系統太複雜、乃至於根本沒有所謂的整體認識;團隊「就像繃緊的彈簧一樣,光顧著埋頭解決一個又一個的緊急情況」。
而且不能只靠架構師:「架構師不是少數人所專有的,而必須是大家的」(p.176)。書給的算術很直接——20 人的團隊只有 3 人懂架構細節,「則要麼這 3 人需要多做許多額外的工作來讓其餘 17 人都能夠跟上,要麼就等著其餘 17 人因對大局不熟而犯錯誤吧」(p.177)。
5. 講述系統的故事(17.1,p.177)
至少要兩個人。像唱雙簧那樣,一個人問「該系統的架構是怎樣的?」,另一個人回答——「他應該盡量只使用兩到三個概念就把系統的架構解釋清楚」(p.177)。假設對方對系統一無所知,用寥寥數句講清楚設計由哪些部分構成、它們之間如何互動;講完最本質的,再選第二重要的,直到核心設計都說明白(p.177)。會很不舒服,這是正常的:你簡化著講的時候,「你可能會感覺自己在撒謊似的,因為你覺得自己並沒有把事情說全面」(p.177)。但書說重點在於——「你講的這個簡化版本能夠描述一個易於理解的架構」(p.177),而且它「能強迫你去思考系統當中重要的東西是哪些」(p.177)。
它的實際用途是當選型的尺:在考慮修改時,「你會注意到其中有些修改與你們講述的故事更為一致」;有兩種方式達成同一目標時,「系統的故事就能夠幫助你判斷出哪種方式能夠導向一個更易理解的系統」(p.178)。書用一個測試框架的例子演示了整輪操作:先講一段簡短描述,再逐條列出「這段描述省掉了什麼」,然後拿兩種加新特性的方案去對照哪個讓故事變得更不真實(p.178–180)。
還有兩個附帶好處:這個簡單「故事」「就像是有了一個路標一樣,當你想要弄清哪兒才是添加一個特性的正確地點時它能夠幫你指明方向。而且,它還能夠消除你對系統的畏懼感」(p.177)。
6. Naked CRC(17.2,p.180)
CRC 是「類(Class)」「職責(Responsibility)」「協作(Collaboration)」(p.180)——原本是在索引卡片上寫下類名、職責、以及與它互動的類(p.180)。Naked CRC 則是「除了不是把東西寫在卡片上之外,它幾乎跟 CRC 一樣」(p.180–181)。做法:描述系統的人「使用一組空白的索引卡片,把它們一個個攤在桌上」,靠移動卡片、指卡片等動作,來傳達系統中的典型物件以及它們之間如何互動(p.181)。書示範了用它講一個線上投票系統:邊擺卡片邊說「每個會話上都有兩個連接,一個接入,一個接出」、「當一個伺服器端會話發起時,它會向選票管理器註冊」……(p.181)。
為什麼有效:「它使得一個系統的各部分成為真實可觸摸的東西。關鍵在於,藉助卡片,你可以使用肢體動作來表達系統的各部分之間是如何互動的。這往往能令複雜的場景變得更易掌握。」(p.182)
兩條原則(p.182):
- 卡片代表實例,而非類;
- 用疊在一起的卡片來表示「一組實例」。
7. 反省你們的交流或討論(17.3,p.182)
「聽聽那些關於你們的設計的討論。你們在交流過程中使用的概念與實際出現在程式碼中的概念是一樣的嗎?」(p.183)
書舉的例子:團隊討論了半天「資源加鎖/解鎖策略」,卻有人直接把碼塞進既有程式碼、用陣列存計數——作者說「等等,不就是鎖策略嗎?幹嘛不創建一個 LockingPolicy 類,然後在它裡面維護計數器呢?」(p.183)。
若討論用的概念和程式碼裡的概念對不起來,答案「往往由兩部分構成:程式碼不允許被修改成那團隊的理解一致的樣子,或者團隊需要換個角度去理解它」(p.183)。書的收尾是一句話:「人們在談論設計時會努力試圖讓其他人理解被談論的設計。放一些理解在程式碼裡面吧。」(p.183)
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
- 草稿式重構有兩個風險(p.174–175):一是「我們在重構的時候可能會犯一些惡劣的錯誤,從而使得我們誤解程式碼的行為」,這種錯誤認識之後真正重構時會來找麻煩;二是重構完之後「我們的思維可能就會固定在重構出來的系統的樣子上了」,日後可能因此「錯過許多很好的想法」。
- 草稿式重構的產出不可入庫:書明講「你可得記住別再把這些程式碼輸入到伺服器上去了」(p.174)。
- 系統的故事一定是簡化的、也一定不完整——這不是問題。「如果系統實際上並不像我們把它簡化成的那樣,是不是就意味著什麼糟糕的事情呢?不是的。一個系統在不斷成長的過程中總會變得越來越複雜。我們的簡化描述只是為了給自己引路。」(p.179)
- 而且要「試著以多種不同的方式來講述」(p.178);同一個系統別人可能有「一個不同但同樣有效的認識」(p.178)。
- 草圖不必求正式,UML 沒有錯,但「圓圈和線條,還有那些不在場人就無法理解的各種形狀的圖形也是有用的。圖只是工具」(p.173)。
- 要做局部草圖之前,先看整體:「當開始要給一個系統做局部草圖的時候,通常你可能會想先花點時間來從整體上理解系統」(p.173)——也就是先做 B 組再回頭做 A 組。
- 第 17 章這些技巧要常常做才有用:「如果你常在團隊裡實踐這些技術,就會發現它們能夠使你的團隊始終保持對架構的關注……因為通常我們很難對不常想到的事情保持關注」(p.177)。
- 沒有速效解:面對「應用毫無結構可言」這種狀況,「並沒有什麼輕而易舉的解決辦法」(p.176)。
🔗 相關工具
- 工具-遺留程式碼修改流程 —— 理解只是起手式,整套改動流程在這裡
- 工具-影響分析 —— 讀懂之後才有辦法判斷改動會傳播到哪;書把「在程式碼上做記號追影響」直接列為理解手法之一(p.174)
- 工具-特徵測試 —— 用測試把現況行為逼出來,也是一種理解手段
- 工具-心智圖 —— 同屬「畫出來以建立整體認識」的手法,可與註記/草圖搭配
🎯 什麼情境該想到我
- 要對沒有測試的舊碼動刀,先想清楚「測哪些方法才夠」。書上說最簡單的答案是為所要修改的每個方法都寫測試,但「一個地方的改動可能會影響到其他地方的行為:除非有測試『坐鎮』否則我們可能永遠也不知道自己的修改造成了什麼影響」(p.127)。
- 要加一個新特性,卻發現得同時改三四個緊密相關的類,每一個都得花好幾個鐘頭才進得了測試用具——書問「真的必須得一個一個的來解開所有這些依賴嗎?那可不一定」(p.144)。
- 面對特別錯綜複雜的舊碼,得先花點功夫「推測一下代碼修改會產生哪些影響,以便找到編寫測試的最佳地點」(p.127)。
- 想判斷一段設計好不好——影響結構圖若「散」、終點很多,通常代表不好測也不好懂(p.130、p.141)。
⚙️ 怎麼用
1. 畫影響草圖
書把這種圖稱為影響草圖(p.130);譯註提到「後文也有稱『影響結構圖』、『影響結構示意圖』的,意思一樣」(p.130 註)。
作圖規則書上寫得很輕:「為每個可能會被影響到的變量以及每個返回值可能改變的方法畫一個單獨的橢圓……並從它們出發畫一個箭頭指向那些因它們的改變而在運行期改變的東西」(p.130)。變數來自同一個物件或不同物件都不重要。
畫的時候「你得確保找到了所考察的類的所有客戶端。如果你的類有一個基類或派生類,那麼得注意一下它們裡面是不是還有沒有被注意到的客戶代碼」(p.134)。
2. 兩個推理方向
- 往回推(找原因):從某個地點值的異常出發,推斷是哪些物件對它產生影響——書說這其實是個調試問題,「得從問題的現象一路推到它的源頭」(p.132)。
- 前向推測(找測試點):書用整整一節(11.2「前向推測」)講這個方向。寫特徵測試時「這個過程是反過來的……面對一組對象,試圖搞清如果它們停止工作的話『下游』會發生什麼情況」(p.132)。第一步是「推斷出哪兒可以探測到我們的修改所帶來的影響」,知道在哪能探測到之後,「在編寫測試的時候便可以在這些地方進行選擇了」(p.137)。
3. 記住影響傳播的三種途徑(p.138)
- 調用方使用被調用函數的返回值。
- 修改傳參傳進來的物件,且後者接下來會被使用到。
- 修改後面會被用到的靜態或全域資料。
書提醒第 3 種是「代碼影響代碼的最難以覺察的方式」;第 2 種則因為多數語言預設傳的是物件的「句柄」而非物件本身,「任何方法都可以通過它們接受到的句柄來修改相應對象的狀態」(p.137–138)。書也提到「有些語言中也有其他途徑」,例如面向方面的語言(p.138)。
4. 六步啟發式(p.138,書中原文的尋找順序)
- 確定一個將要修改的方法。
- 如果該方法有返回值,查看它的調用方。
- 看看該方法是否修改了什麼值。是則查看其他使用了這些值的方法,以及使用了這些方法的方法。
- 別忘了查看父類和子類,它們也可能使用了這些實例變數和方法。
- 查看這些方法的參數,看看你要修改的代碼是否使用了某參數物件或它的方法所返回的物件。
- 找出到目前為止被你所找出的任何方法修改的全域變數和靜態資料。
推測時最重要的籌碼是「對編程語言的認識」——每門語言都有所謂的防火牆,即「能夠阻止影響繼續傳播的語言結構」,知道它們就清楚什麼時候不必穿越它們去追溯影響(p.138)。但書也警告:有些語言特性看似能保證「不會改」,實際上卻能被別的關鍵字繞過去(書舉的是 C++ 中修飾方法宣告的常性關鍵字,遇上父類中某個可變修飾的成員宣告就失效),「像這類能夠被『繞過去』的語言特性都要加以注意」(p.139–140)。書給的重點只有一句:「了解你所用的語言。」(p.140)
5. 找攔截點
「給定一處修改,在程序中存在某些點能夠探測到該修改的影響,我們把這些點稱為攔截點。」(p.145)
「最佳切入點莫過於先確定需要進行修改的點,並從這些修改點開始一路向外追蹤影響。每個可以探測影響的點都是一個攔截點,但並非每個都是最佳攔截點,在整個過程中你都需要自己進行判斷」(p.145)。
大多數情況下,最佳攔截點就是被修改類上的一個公共方法——「這類攔截點容易尋找,也容易使用,但有時候並非最佳的選擇」(p.147)。
6. 一次改多處時,退後一層去找匯點 ★
改一堆擠在一塊的東西時,書建議的辦法是「退一層測試」:「『退後一層』從而找到一個地點能夠同時給多處修改編寫測試。比如要對一系列私有方法進行修改,只需為某一個公有方法編寫測試就行了」(p.144)。
而書中所說在代碼中所能找到的最佳攔截點,就是匯點(p.144):
「匯點是影響結構圖中的隘口和交通要衝,在匯點處編寫測試的好處就是,只需針對少數幾個方法編寫測試,就能夠達到探測大量其他方法的改動的目的。」(p.149)
書說匯點就是「在單一地點探測所有影響」的地方,「在這類地點編寫的測試能夠覆蓋大量的修改。若能在設計中找到匯點的話,你的工作就會輕鬆許多」(p.149)。
這就是「要不要把相關的類都解依賴」的判準:與其給每個類分別寫測試,「更高效的做法是試著找出一個能夠用來刻畫這塊代碼的特徵的高層攔截點。這麼做的好處有兩點。首先我們需要進行的解依賴可能減少了,另外我們的『軟體夾鉗』所夾住的代碼塊也更大」(p.147)。有了刻畫這組類特徵的測試,重構就得到更多守護——改 A、B 兩個類的結構時,可以拿 C 的測試當作不變式(p.147–148)。
找匯點的第二個辦法:找出方法或類「被使用方式之間的共同之處」。某個方法可能有三個使用者,但這不代表三者的使用方式各不相同。關鍵問題是——「如果破壞該方法,在那個地方能否感知到?」如果它在一組物件上的使用方式都是一樣的,「則只需在其中一處測試即可」(p.150)。
7. 用簡化影響圖來反過來改設計
書示範了一個很小的改動:原本兩個公開方法各自直接讀取同一份資料,改成其中一個在內部改呼叫另一個,影響結構圖立刻變形——「這只是一個小小的改動,然而帶來的影響卻是顯著的」,因為測試外層那個方法時「也就連帶測試了」被它呼叫的那個(p.142)。結論是:「在消除了一點點的代碼重複之後,我們往往能夠得到一張『終點』更少的影響結構圖。而後者則往往能夠令你的測試決策更容易」(p.142)。
書也指出「衡量軟體好壞的標準之一便是,看看該軟體對外部世界的相當複雜的影響能否由代碼內的一組相對簡單得多的影響所構成。任何改動,只要能夠使代碼的影響結構圖簡單化,就能夠使其更易理解和維護」(p.130)。
匯點還可以拿來判斷設計好壞:「一個匯點其實就相當於一個自然封裝邊界。發現一個匯點就相當於發現了一個『漏斗口』,一大塊代碼的影響都得從這個口經過」(p.151)。影響結構圖也能用來從一個龐大的類裡發現潛在的新類——看圖中的自然封裝邊界,替邊界內那組方法/變數想個名字,那就是要分離出來的新類的名字(p.151)。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
- 攔截點離修改點越近越好(p.147)。理由一是安全性:從修改點到攔截點,途中每一步「都好比是邏輯論證過程中的一步……論證過程中的步驟越多,我們就越難判斷論證的正確性」;理由二是離得近的地方安置測試通常比較容易。步驟一多,「通常你得在大腦裡面模擬代碼運行來確定一個測試是否覆蓋了某塊遙遠的功能」。
- 匯點是由具體的修改點決定的(p.149)。換一組修改點,匯點就可能不一樣;書的例子裡加了一個新方法後匯點「沒有絲毫變動」,但再加一處改動後就「不再存在一個單一的攔截點了」——此時把兩個方法合起來看仍算匯點,因為兩個方法比起要觸及的八個方法/變數還是經濟。
- 找不到匯點時:「辦法之一便是重新考量我們的修改點。問問自己是不是太『貪』了,考慮能否一次僅為其中一兩個修改點尋找匯點。最後,要是實在沒法找出匯點的話,那就按就近原則直接給每個修改編寫測試吧」(p.150)。
- 匯點的陷阱:在匯點寫的測試容易「緩慢但逐步地演化為『迷你型』的集成測試」,最終變成「龐大而笨重的、不知何年何月才能運行完成的『單元測試』」(p.152)。新寫的代碼要盡可能單獨而孤立地測;但既有代碼「情況就反過來了」,先切一塊下來用測試鞏固它,之後再為區域內每個類寫更窄的單元測試,「最終當這些單元測試全都完成,原先在匯點編寫的測試也就可以功成身退了」(p.152)。
- 高層測試不能替代單元測試:「高層測試雖說是個重要的工具,但並不能替代單元測試。而是為最終將單元測試安置到位而進行的鋪墊」(p.144)。
- 解依賴會破壞封裝,這是真的(p.143)。書坦承「打破封裝會令代碼中的影響推測變難」,但「一旦一個類有了測試用例,就可以使用這些測試用例來更為直接地進行影響推測」。作者的取捨很明白:「當它們真的發生衝突時,我會傾向於選擇測試覆蓋」,而且「封裝本身也並不是最終目的,而是幫助理解代碼的工具」。
- 在匯點圈出的安全區可能是假的:完成特徵測試後你在應用中造出了一個「小綠洲」,但書提醒「請當心,這片『綠洲』有可能只是一個虛幻的海市蜃樓」(p.152)。
- 這套推測「可以是非正式的,也可以稍微嚴密一些,借助一點草圖來進行」(p.143)——不必每次都畫全圖。書自己也說當年希望有 IDE 能自動列出影響清單,「或許有一天人們會開發出這樣的工具。但在那一天到來之前我們還是得學習如何在沒有工具的情況下僅憑大腦去推測代碼修改的影響」(p.128)。
🔗 相關工具
- 工具-遺留程式碼修改流程 —— 上層流程;影響分析是其中「找測試點」那一步
- 工具-特徵測試 —— 找到攔截點/匯點之後,在那裡寫的就是特徵測試(書中特徵測試見 p.153)
- 工具-打破依賴以便測試 —— 匯點的價值正在於「需要進行的解依賴可能減少了」(p.147)
- 回連 修改程式碼的藝術
🪡 破局關鍵
引述說明
本卡引述全部出自第 4 章「接縫模型」(p.25–40)。原書程式碼與英文識別字在手上的文字層辨識不可靠,因此不放程式碼;引文中原本是識別字的地方一律改寫成〔中文描述〕並以方括號標示。頁碼為書內頁碼。
🎯 什麼情境該想到我
當你面對一段「綁死、難測、不敢動」的舊程式,找不到下手點時。
第 4 章的第一句就是這個困境(p.25):
「幾乎每個嘗試過為既有程式碼編寫測試的人都會發現既有程式碼對測試是多麼的不友好。這並非一種局限於特定程式或語言的現象,而是普遍存在的。一般而言程式語言對測試的支持不太好。」
書說要得到易測試的程式「似乎只有兩條路:一條路是邊開發邊編寫測試,另一條路則是在先期花點時間試著將易測試性納入整體的設計考量」,而後者「在過去並沒有取得多大的成功」(p.25)。
而且解依賴的工作量跟原本設計得好不好沒有必然關係(p.26):
「當你開始試圖將單個的類從系統中剝離出來以便能夠將它納入單元測試中去時,你會發現你得常常先進行一系列的解依賴工作。有趣的是,不管系統原本的設計有多麼良好,你還是要常常做上許多工作。」
書把整章的問題壓縮成一個具體場景(p.26–27):有一個函式,其中一行呼叫了會跟另一個子系統互動的全域函式,你想在測試中執行整個函式但避開那一行——
「這裡的關鍵在於既要在測試中避免呼叫到〔那個錯誤回報函式〕又不能讓最終的產品程式碼呼叫不到它。」(p.27)
「對我來說這個問題有多個解,這就引入了接縫這一概念。」(p.27)
⚙️ 怎麼用
1. 接縫的定義(p.27,書中原封不動重複於 p.31)
接縫
「接縫(seam),顧名思義,就是指程式中的一些特殊的點,在這些點上你無需作任何修改就可以達到改動程式行為的目的。」2. 為什麼接縫對測試重要(p.28–29)
書自己先問「為什麼要關心接縫這個概念?它有什麼好處呢?」,然後答(p.28):
「在為遺留程式碼編寫測試時,一個最大的挑戰就是解依賴。運氣好的話這些依賴可能較小、較局部化,然而碰到極端情況時,你可能得對付大量的、在程式碼基中分布得到處都是的依賴。」
接縫就是拿來找出「程式碼基中既有的可利用因素」(p.29):
「倘若能夠將接縫處的行為取代掉,我們就等於有選擇性地排除了某些依賴。我們還可以將被依賴方替換為其他程式碼,以此來感知被測試程式碼對被依賴方的要求或影響,並針對這些要求或影響來編寫測試。往往這麼做了以後我們就能夠獲得足夠的測試以便採取更進一步的舉動。」
書也交代了它為什麼要換一種視角看程式:把程式看成一長串文本、逐行檢查的做法,在要把類剝離出來測試時「就不能再幫上什麼忙了」(p.26)。因為「就算一個軟體的各個部分看起來是獨立的,它們也會常常以一些微妙的、不易覺察的方式互相依賴著」(p.26)。
3. 每個接縫都有一個激活點(p.31–32)
書講完第一種接縫後立刻補上這個概念,而且是先把接縫的定義重貼一次才講的(p.31):
「關於接縫,還有一個重要的地方,即,每個接縫都有個所謂的激活點(enabling point)。」(p.31)
激活點
「每個接縫都有一個激活點,在這些點上你可以決定使用哪種行為。」(p.32)注意書的說法是「每個接縫都有一個激活點」,不是「有激活點的才算接縫」。激活點之所以必要,是因為接縫的整個前提是「該處不改」(p.31):
「當然,我們不能僅僅為了測試就真的去修改其所在之處的程式碼,源程式碼在產品階段和測試階段應當是完全一樣的。」
書接著說,前面那個例子裡想改變的是〔資料庫更新函式〕呼叫點處的行為,「為了利用起該處的接縫,我們得在其他某個地方進行改動」(p.31)。
也就是說:接縫是「行為被改變的地方」,激活點是「你實際動手的地方」,兩者分開。書在後面每講一個接縫例子,都會再問一次「激活點又在哪裡?」(p.33、p.37、p.38)。
4. 接縫的類型(4.3 節「接縫類型」,p.29 起)
分類的依據是編譯流程的階段(p.29):
「對於不同的程式語言,可用的接縫類型也不同。考察它們的最佳途徑是觀察該語言的程式碼被轉換至機器碼的過程的各個階段,其中每個顯著的階段都蘊涵著不同種類的接縫。」
書展示的是主要的幾種,不是完整清單:對象接縫「只不過是許許多多不同種類的接縫中的一種」(p.28);到了 p.38 也說「到目前為止我們所展示的接縫類型都是一些主要的。你可以在許多程式語言中見到它們的身影」。
(1)預處理期接縫(4.3.1,p.29–31)
- 適用語言:「事實上,只有寥寥幾門語言在編譯前會有另一個處理階段(預處理),C 與 C++ 就是這其中最常見的代表。」(p.29)
- 做法:書的例子是一個直接呼叫資料庫函式的 C 程式,該函式會直接跟資料庫溝通,「除非能用另一個實現來將〔它〕原先的實現給替換掉,否則就無法感知該函式的行為」(p.30)。做法是引入一個標頭檔,在裡面用巨集重新定義該函式,改成把參數記錄到變數裡。「我們之所以能夠這麼做是全靠 C 預處理指令 include 提供的一個接縫,利用這個接縫我們得以在(程式碼)文本被編譯之前將它替換掉。」(p.31)
- 激活點:「其激活點就是 TESTING 這個預處理符號的定義。」(p.31)
- 限制:書自己先數落過預處理(p.30)——「在產品程式碼中過度使用預處理並不是個好主意,因為它會降低程式碼的清晰性」;條件編譯指令「幾乎等於是在強迫你在同一份源程式碼中維護多個不同的程式」;巨集「它們的運作機制只不過是文本替換,因此很容易就可以製造出隱藏了極度隱晦的 bug 的巨集」。
- 但書仍給它正面評價:「姑且先把這些考慮擱在一邊,我對 C 和 C++ 中具有預處理功能還是感到高興的,因為它給程式中帶來了更多的接縫。」(p.30)以及(p.31):「預處理期接縫是個非常強大的手段。我不認為我會真的希望像 Java 以及其他更現代的語言中有這個功能,然而對於 C 和 C++ 來說則不同,因為它起到了彌補這兩門語言中的一些其他測試障礙的作用,所以還是有好處的。」
(2)連接期接縫(4.3.2,p.32–34)
- 原理:「在許多語言系統中,編譯並非建置過程的最後一步」,編譯器產出的是中間表示,再由連接器「對每個呼叫進行決議以便最終能得到一個可運行的完整程式」(p.32)。
- 適用語言:「對於 C 和 C++ 這類語言來說的確存在著一個單獨的連接器……而在 Java 以及類似的語言中則是編譯器在幕後負責進行連接過程。」(p.32)而且不限於此:「不管你的語言的編譯系統是用哪種方式來進行符號引用決議的,你一般都可以利用它來替換一個程式的某些部分。」(p.32)
- 動態連接的做法(Java 例):建立同名類、放到另一個目錄,並令 classpath 指向該目錄,「從而誘使編譯器去發現從而連接到你寫的另一個版本」(p.33)。書明講這是測試專用:「雖說這種技巧用在產品程式碼中可能會帶來混亂,但用在測試中卻是個相當實用的解依賴手段。」(p.33)
- 接縫在哪裡?書答:在那個方法中建立解析物件的那行呼叫處。激活點在哪裡?「答案是 classpath。」(p.33)
- 靜態連接的做法:「利用這種連接的接縫的最簡單的途徑是為你想要替換其實現的所有類和函式建立另一個單獨的庫檔案,然後,當你進行測試時,就可以通過修改建置腳本檔案來引導你的建置系統去連接到供測試之用的庫檔案了,而另一方面,在產品階段的建置中,則可以修改建置腳本使得程式連接到產品程式碼的庫。」(p.33)
- 何時值得:「這種做法可能要花點工夫,但倘若你的程式碼基中到處散布著對某個第三方庫的呼叫的話,這樣做就是值得的了。」(p.33)書舉的例子是一個到處直接呼叫繪圖庫的 CAD 應用(p.33–34)——這種程式碼「想要驗證這些程式碼是否做了你想要它們做的事情,唯一的途徑就是運行它們並觀察電腦螢幕上畫出來的圖形」(p.34)。
- 做法是替那個庫做一個 stub 庫連接進去;只想解依賴的話裡面全放空函式即可,有回傳值的函式則「通常你可以選擇返回一個代表成功的值或者返回某個型別的預設值」(p.34)。
- 限制(很重要):書自陳繪圖庫「有點兒不那麼典型」,但仍是好例子——「原因之一就是它幾乎是個純粹的命令型介面。換句話說,一般是你去呼叫其庫函式來命令它們做某些事情,而並不請求它們回饋什麼資訊。遇到後一種情況會比較難對付,因為當試圖測試你的程式碼時,為這種函式編寫的 stub 版本一般來說不能簡單地返回預設值。」(p.34)
- 目的偏向分離:「使用連接期接縫的目的之一是分離。當然你也可以實現感知,只不過後者需要多花點工夫。」(p.34)書給的做法是引入額外的資料結構來記錄對這些庫函式的呼叫。
- (書中把 stub 保留不譯;譯註 p.34 說 stub 一般是指「一小段(可能是由某種工具自動生成的)程式碼(可能是二進制的),用來占據某個位置,以達到某個特定目的」。)
(3)對象接縫(p.28、p.37–38)
- 定義:「上面討論的這類接縫我把它們稱之為對象接縫。遇到此類接縫時,我們可以在無需修改呼叫函式的情況下改變被呼叫的函式。」(p.28)
- 適用語言:「對象接縫存在於面向對象語言中,它們只不過是許許多多不同種類的接縫中的一種。」(p.28)
- 書開頭那個例子怎麼解(p.27–28):全域函式呼叫不掉——那就在類中加一個簽名一樣的成員函式(虛擬),內部轉呼叫全域版本。「以上這一更動不會改變程式行為……這裡雖說引入了一個小小的間接層,但最終呼叫到的還是同樣的函式。」接著子類化該類並覆寫這個方法,測試中改為建立子類物件,「就能夠有效地將〔原本那行呼叫〕的行為屏蔽掉」(p.28)。
- 一般形式(p.37):一個方法呼叫傳進來的物件上的方法時,「我們無需修改〔那行呼叫〕所在的方法就可以達到改變這行呼叫所實際幹的事情的目的」;激活點則是「〔該方法〕的參數列表。通過給出不同型別的物件作為其參數,我們可以根據測試需要任意改變〔方法內〕呼叫的〔那個方法〕的行為」。
- 微妙的一種(p.37):連私有靜態方法的呼叫處也可以是對象接縫——「具體做法是刪掉〔該方法〕定義前的 static 關鍵字,並將其存取權限從私有改為受保護的,這麼一來我們就可以在測試中對〔外層類別〕進行子類化並重寫其〔該方法〕了。」
- 激活點是物件的建立處(p.39):「激活點則在對象的建立處。可以建立一個〔原本的類別〕物件,也可以建立〔它〕的某個為測試而寫的並重寫了〔那個函式〕的子類化版本。」
5. 同一個呼叫點通常有多種接縫可選(p.38–39)
書回頭把開場的例子重掃一遍,指出同一行呼叫上同時存在三種接縫:
- 連接期接縫(p.38)——做一個含該函式 stub 版本(一般是空函式)的庫並連接過去;「這一接縫的激活點應該是專案的 makefile 檔案,或者 IDE 裡的某些設定」。
- 預處理期接縫(p.38)——加一個 include 並定義一個同名巨集,測試時才激活;激活點是「一個預處理巨集定義來控制這個巨集是否被定義」。
- 對象接縫(p.39)——定義一個同名虛擬函式,激活點在物件的建立處。
「很令人驚訝,居然有這麼多途徑能達到這一目的,不用修改下面這個方法,就可以替換掉〔那個函式〕呼叫處的行為。」(p.39)
6. 怎麼選(p.39)
「當想要將某段程式碼置入測試中時,選擇正確型別的接縫很重要。一般來說,如果你用的是面向對象語言,則對象接縫是最佳選擇。預處理期接縫以及連接期接縫某些時候是有用的,但它們沒有對象接縫那麼清楚明顯。此外依賴於這兩種接縫的測試可能會難以維護。我個人傾向於將預處理期接縫和連接期接縫這兩種接縫保留到程式碼中到處浸透著依賴並且沒有其他更好的方案可選的情況下。」
🧪 我實際套用的紀錄
- 2026-07-14:(待填)
⚠️ 注意
- 接縫的重點是「不改那裡」:源程式碼在產品階段和測試階段應當完全一樣(p.31);要動的是激活點。
- 為什麼不直接把依賴改掉就好? 書自己問了這題(p.38):「如果我們不喜歡某處依賴,幹嘛不直接進到程式碼中去修改一通把它改掉呢?沒錯,有時候這的確可行,然而當你為那些特別髒亂的遺留程式碼安放測試時,往往最好的途徑是盡量少去修改其程式碼。如果知道你的語言所支援的接縫型別,並知道怎樣去使用它們,則通常可以更安全地將測試安置妥當。」
- 接縫型別是語言決定的:沒有預處理階段的語言就沒有預處理期接縫(p.29)。
- 這是一種看程式碼的方式,不只是一招技巧(p.39):「當習慣了以『接縫之眼』來看待程式碼時,就能更容易看出如何測試某段程式碼以及如何組織新程式碼的結構以令其更具『測試友好性』。」
- 書寫這章的動機是可移植性:作者說這種看待程式碼的方式「在我遇到新的不熟悉的程式語言時給了我幫助」,因為書無法涵蓋所有語言(p.25)。
🔗 相關工具
- 工具-打破依賴以便測試 —— 接縫是概念層,那邊是實際把依賴拆開的手法
- 工具-特徵測試 —— 接縫換上測試替身之後要寫什麼測試:先把現有行為原樣釘住,當改動的安全網
- 工具-遺留程式碼修改流程 —— 接縫是「解依賴」這一步的核心概念
- 回到來源:修改程式碼的藝術
🎯 什麼情境該想到我
當某段程式因為相依了資料庫、外部服務、或建構時就造出具體物件,而無法放進測試用具時。
書把這件事拆成兩章:第 9 章「無法將類放入測試用具中」(連物件都建不出來)和 第 10 章「無法在測試用具中運行方法」(能實例化之後,方法還是跑不了)。這張卡是這兩章的症狀對照表;每個症狀對應的具體手法在第 25 章的型錄裡(見下方連結)。
第 9 章開場就把姿態放低(p.89):
「本章將要討論的是一個困難的問題。如果在測試用具中實例化一個類總是那麼容易的話,本書也就會簡短得多了。遺憾的是情況並非如此。」
⚙️ 怎麼用
起手式:最佳做法就是試一試(p.90)
「要想知道能否在測試用具中實例化一個類,最佳途徑就是試一試。編寫一個測試用例並在裡面建立該類的物件。編譯器會告訴你要令程式碼工作起來還需要哪些東西。」
書把這種測試叫建構測試:「編寫這類測試時我通常並不在裡面放置任何斷言,而只是試著建立物件。最後,當終於能夠在測試用具中建構某類的物件時,我通常會刪掉該測試,或將它重新命名以便能夠用來做一些更為實質性的測試。」(p.90)
A. 無法把類放進測試用具(第 9 章)
書列出四種最常見的問題(p.89):
「(1) 無法輕易建立該類的物件。
(2) 當該類位於測試用具中時,測試用具無法輕易通過編譯建置。
(3) 我們需要用到的建構函式具有副作用。
(4) 建構函式中有一些要緊的工作,我們需要感知到它們。」接著用七個症狀場景展開:
症狀(節) 長相 書給的對策 9.1 令人惱火的參數(p.89–95) 建構函式要你傳一個很難造的東西(例:一個建構時就會連上伺服器的連線物件;耗時,而且伺服器並不總是處於服務狀態) 對該參數型別用 介面提取(285 頁)+做一個輕便的偽類(p.92);或傳 Null(p.93–94);若參數型別中的問題依賴不是硬編碼在其建構函式裡,可用 子類化並重寫方法(314 頁)(p.95) 9.2 隱藏依賴(p.95–98) 建構函式自己造了一個難搞的資源(例:郵件服務),依賴藏在建構函式裡 參數化建構函式(297 頁)把依賴「外在化」,作者傾向盡可能用它;書列的其他選項是 提取並重寫獲取方法(278 頁)、提取並重寫工廠方法(276 頁)、替換實例變數(317 頁)(p.98) 9.3 建構塊(p.98–100) 建構函式裡先造一批物件、再用它們造另一批,你要感知的東西被嵌在這串建立過程中間 有能安全提取方法的重構工具就用 提取並重寫工廠方法(276 頁);C++ 不行——建構函式中對虛擬函式的呼叫不會被決議到衍生類(p.99)——改用 替換實例變數(317 頁),再對被換掉的物件用 介面提取 或 實現提取 造偽物件(p.100) 9.4 惱人的全域依賴(p.100–107) 到處都在用全域變數/單件,測試前得先把它們設到適當狀態 簡單情況:參數化建構函式(297 頁)、參數化方法(301 頁)、提取並重寫呼叫(275 頁)(p.100);單件則先加一個能替換靜態實例的設置方法(p.102),或改加一個「重置以供測試」的方法(p.103);單件在幕後做壞事(如與資料庫通訊)就用 子類化並重寫方法(314 頁)造偽單件(p.104);依賴廣到不行,就對單件做 介面提取(或較省事的 實現提取,281 頁)並把應用中相關引用全改成介面——這整個重構過程即 引入靜態設置方法(292 頁)(p.106) 9.5 可怕的包含依賴(p.107–110) C++ 的標頭檔一層層包進來,測試根本編不起來 在同一目錄下建測試檔(有預處理時通常比較容易);把原始檔的 include 一個一個加進來,看是否真的需要那些依賴;對難搞的依賴寫「偽定義」,並把偽定義剪到一個單獨標頭檔以便在多個原始檔重複使用(p.108–109) 9.6「洋蔥」參數(p.110–112) 要造 A 得先造 B,要造 B 得先造 C……物件裡面包著物件 先問「對於那些傳給建構函式的參數,我們真的需要它們嗎?」不需要就傳 Null;只需要一些基本行為就用 介面提取 或 實現提取(p.111)。Java 可以只為子類建一個含有父類方法的介面,不必整條體系都抽;C++ 沒有 Java 那樣的介面,得為純虛函式補一個轉發給父類的定義體(p.111–112) 9.7 化名參數(p.112–114) 參數型別在一個繼承體系裡,抽介面會逼你順著體系一路往下建介面 別硬抽——「接口的確是解依賴的利器,但如果出現了介面與類幾乎一一對應的情形,設計就變得混亂起來了」(p.113)。先問「為什麼這個依賴是糟糕的」;如果某個類只是有某幾個地方有問題,就用 子類化並重寫方法 只切斷那幾個地方(p.113–114)。若壞依賴和需要的邏輯混在一起,得先做方法提取(p.114) 幾個書中反覆強調的判斷:
- 偽類長得很醜是正常的(p.93):「〔偽造的連線類〕看起來有點古怪:它的方法要麼是空的要麼就是簡單地返回 null。……這樣一個類似乎違反了所有的良好準則。但你要看到,實際上並非如此。對於一個用來使得測試可行的類,規則是有所不同的。〔它〕中的程式碼並非產品程式碼。」
- 測試程式碼不需要具有產品程式碼那麼高的品質(p.93,方框):「一般來說,如果將變數設為公有能夠使測試的編寫更為容易的話,我是不會介意這麼做的,儘管這會破壞封裝。不過就算是測試程式碼也應當是乾淨、易於理解和修改的。」
- 傳 Null 的用法與適用範圍(p.94,方框):「在編寫測試時,如果你發現某個物件需要的某參數難以構造,便可以考慮傳遞一個 null。這樣一來如果這個參數在測試運行的過程中被用到了的話,程式碼就會拋出一個異常,而測試用具則會捕獲這個異常。」但「在 Java 和 C# 中使用起來沒什麼問題,其實只要是那些會對運行期使用空引用拋出異常的語言,這項技術都適用。但這也意味著在 C 和 C++ 中使用這項技術並不是個好主意,除非你知道運行時系統會檢測出空指標的使用。」另外「不到萬不得已千萬別在產品程式碼中傳遞 null」,要避免在程式中傳遞 null 可考慮空物件模式(p.94–95)。
- 參數化建構函式不會逼所有呼叫方跟著改(p.97):這是人們想不到用它的一個原因,但書說這種想法並不正確——先把建構函式本體提取到一個新方法(提取時運用簽名保持,249 頁,因此沒有測試保護也相當安全),再額外提供一個簽名與原先一模一樣的建構函式,「客戶程式碼便可以維持原樣,無需關心我們作了哪些改動」。
- 替換實例變數是最後手段(p.100):「除非萬不得已,否則我是不願使用替換實例變數手法的。這一做法很可能帶來一些資源管理方面的問題。」C++ 要先把舊物件處理掉(p.99);而且「別在產品程式碼中使用這一技術」(p.99)。作者仍會在 C++ 用它,因為提取並重寫工廠方法在 C++ 的建構函式中用不了(p.100)。
- 單件為什麼難搞、以及可以放鬆到什麼程度(p.102–104):「單件模式的核心理念便是使人們無法在應用中建立一個以上單件類的實例。這在產品程式碼中或許是件好事,然而到了測試中可能就成了一場災難:一套測試中的每個測試在某種程度上都應當被看成是一個小型的應用,互相之間應當完全隔離開來。」書列出「真的需要唯一實例」的三個常見原因(現實世界只有一個、建立多個會導致嚴重問題、建立多個會用掉過多資源),然後指出「人們常常會為了建立全域變數而使用單件模式」——若是後者,「其實並沒有什麼理由一定要保持其單件性」,把建構函式改設為受保護的、公用的或包作用域的即可。要補保護就在建置系統加檢查(搜尋所有原始檔,確保那個測試用設置方法沒被非測試程式碼呼叫),或做執行期檢測(p.104)。若改用子類化的做法,建構函式設為受保護即可,不必設成公用(p.105)。
- 靜態設置方法 vs 重置方法怎麼選(p.103,方框):如果單件類的公用方法允許你在測試中任意設置其狀態,「重置以供測試」的方法就行得通;如果沒有這些公用方法、或它用了一些影響其狀態的外部資源,「引入靜態設置方法就是個較好的選擇了」。
- 9.5 那一招要留給極端情況(p.110):這種寫偽定義的做法「有幾個非常嚴重的缺點」——測試得單獨建置成一個程式、並沒有在語言層面把依賴解開、重複定義只要測試還在就必須保留。「這一技術適合留給類非常巨大且依賴非常嚴重的時候。」
B. 類建得出來,但方法跑不了(第 10 章)
實例化通常只是第一步,但有時可以跳過它直接進第二步(p.115):待修改的方法沒用到多少實例變數 → 暴露靜態方法(273 頁);方法相當長且難於對付 → 分解出方法對象(261 頁),把程式碼搬到一個較容易實例化的類裡。
書列出的問題(p.115):
「・無法在測試中存取那個方法。比如說,它可能是私有的,或者有其他可存取性限制。
・無法輕易地呼叫那個方法,因為很難建構呼叫它所需的參數。
・那個方法可能會產生糟糕的副作用(如修改資料庫、發射一枚巡航導彈,等等),因而無法在測試用具中運行它。
・我們可能會需要通過該方法所使用的某些物件來進行感知。」
症狀(節) 長相 書給的對策 10.1 隱藏的方法(p.115–118) 要改的方法是私有的(或有其他存取限制) 先看能不能透過公用方法測;正面答案是把它設為公用;不方便公開就把它移到一個新類當中去、在新類上設為公用;當下負擔不起分解職責的風險,才退而把存取權限從私有改為受保護,再子類化取得存取權(p.115–117) 10.2「有益的」語言特性(p.118–121) 參數的型別來自你控制不了的類庫,沒有公用建構函式,又被 sealed/final 封死,無法建立實例也無法派生 不能用 介面提取(285 頁)/實現提取(281 頁),只能用 參數適配(258 頁)(p.119);若那個封閉類有一個非封閉的基底類,可子類化基底類再把物件傳進去,「借助於依靠編譯器(251 頁)技術,我們的修改既安全又容易」(p.119);真的建不出實例,就用剝離並外覆 API(169 頁):提取一個介面 + 寫一個外覆類,測試時換上偽類(p.120) 10.3 無法探知的副作用(p.121–126) 方法什麼都不回傳,只是去戳別的物件,沒有合適的地方可以感知這段程式碼做了什麼(典型:GUI 事件處理器裡塞滿業務邏輯) 一連串方法提取(325 頁)+ 子類化並重寫方法,見下方五步(p.122–126) 10.3 的處理順序(p.122–126):
- 把整個方法體提取出來,就可以完全脫離對事件參數類的依賴(p.122)。
- 把要用到的協作物件設成該類的實例變數,再把使用它的程式碼提取成一組方法(p.123)。
- 命名時不要用顯示元件的名字:「可以在提取出的程式碼中使用顯示元件,但其方法名卻應當隱藏這一事實。」(p.123)並照命令/查詢分離(最先由 Bertrand Meyer 提出)把提取出的每塊做成命令式方法或查詢式方法——「一個方法要麼是一個命令,要麼是一個查詢,但不能兩者都是。」(p.123–124)
- 子類化並重寫這些新方法,測試就能看到原本看不到的副作用(p.125)。
- 最後才依「哪些方法只用到哪個成員變數」把它們看成互相獨立的職責、分到不同類去(p.126)。
書對這種粗糙產物的態度(p.126):
「如果目的是為了讓測試能夠安置到位的話,提取出具有糟糕名字或糟糕結構的方法是可以接受的。畢竟,安全才是第一位。在測試到位之後,就可以放心著手讓程式碼變得更清爽了。」
🧪 我實際套用的紀錄
- 2026-07-14:(待填)
⚠️ 注意
- 好的設計應當是可測試的(p.116):「好的設計應當是可測試的,不具可測試性的設計是糟糕的。」私有方法測不到,「大多數情況下便意味著我們的類做的事情太多了」。
- 先求「能測」的最小改動,別在沒安全網時做大改造。 要不要順手把職責分解掉,「取決於當前我們在整個產品發布週期中所處的階段,有無足夠時間以及所有相關的風險」(p.117);現有測試不多時「就不得不小心行事,先做一些其他工作,然後再開始分解」(p.116)。
- 削弱封裝是有意識付出的代價(p.118):把私有改成受保護,「坦白地說,我並不介意這麼做。對我來說,這樣做令我們能夠編寫測試,是一宗公平交易」,但「當我們在分析程式碼是如何工作的時候,就得把〔它的〕子類也能呼叫〔該方法〕這一事實也考慮進去」。
- 不要用反射作弊(p.118,方框):「我可不喜歡將存取私有變數的測試程式碼留在專案中。因為這種以『欺騙』手段存取私有變數的方式會蒙蔽團隊的眼睛,使他們看不到程式碼變得有多糟糕。」
- 子類化並重寫時別把要測的行為改掉了(p.95):「子類化並重寫方法在有些場合下是非常有用的解依賴手段,但在使用的時候我們得注意別把想要測試的行為給篡改了。」
- 選手法的順序:參數化建構函式「在解開建構函式中隱藏的依賴方面是一項易用的技術,而且它往往也是我第一個訴諸的技術」,但建構函式裡建立了大量物件或存取大量全域變數時會導致參數列表過長,這時才換路(p.98)。
- 全域依賴:把測試安置到位 ≠ 解決問題(p.106):引入靜態設置方法「即便是在面對廣泛的全域依賴的情況下,我們也可以運用該技術來將測試安置妥當。然而遺憾的是,它對全域依賴的狀況並無多大改善。」真要解決得靠 參數化方法(301 頁)與 參數化建構函式(297 頁),各有代價(前者類上多出很多額外方法影響理解、後者每個物件多一個成員變數,太多的話會影響應用的記憶體使用量)。
- 如果一個全域變數真的到處都在用(p.107):「這便意味著你的程式碼沒有進行任何層次化設計。」多數情況下全域變數只是全域可存取,並沒有被全域使用。
- 依賴不由自己控制的庫是自找的(p.121):「真正的錯誤卻出在我們自己身上,是我們自己選擇直接依賴於不由我們控制的庫的,這一舉動等於是在自尋煩惱。」「當我們需要使用標記為 sealed 或 final 的類時,最好將它們隔離在一層外覆類後面。」外覆的代價是產品程式碼得先遍歷原先的容器,把每個物件都打包進外覆物件——「這就是安全性所要付出的代價」(p.121)。
🔗 相關工具
🕸 安全網
🎯 什麼情境該想到我
當你要改一段沒有測試的舊碼、想先建立「改壞就會被抓到」的安全網時。
第 13 章開頭就先把測試的用途分清楚(p.153):
「自動化測試是一個非常有用的工具,但這並非對尋找 bug 而言,至少並沒有直接的關係。一般而言,自動化測試的任務是明確說明一個我們想要實現的目標,或者試圖保持程式碼中某些既有的行為。在自然的開發流程中,屬於前者的測試逐漸就會變成後者。當然,你會遇到 bug,但通常並非在某個測試第一次運行的時候,而是在不小心改變了不想改變的行為時,這時運行測試就會指出問題。」
所以對遺留程式碼的做法是(p.153):
「最好就是把我們想要修改的那一塊區域先用測試給罩起來,像安全網那樣。」
書也擋掉了「翻需求文件來寫測試」這條路。那個辦法「的確是個辦法,但並不算很好」(p.153),理由是(p.154):
「因為對於幾乎所有的遺留系統而言,更重要的不是『系統應該能夠做些什麼』,而是『系統當前能夠做些什麼』。所以如果我們基於從文件當中發掘出來的關於『系統應該能夠做些什麼』的假設來編寫測試的話,就又回到了尋找 bug 的老路上了。尋找 bug 的確很重要,但我們當前的目標是把測試安置到位,從而減少程式碼修改過程當中的不確定性。」
⚙️ 怎麼用
定義:它記錄的是現狀,不是應然(p.154)
「我把用於行為保持的測試稱為特徵測試(Characterization test)。特徵測試刻畫了一塊程式碼的實際行為。而不是『嗯……這塊程式碼應該具有這一行為』或者『我想它會那樣的吧』。特徵測試描述了系統當前的實際行為。」
書對名字只給到這裡:這類測試的工作就是「刻畫」實際行為,13.2 節的標題也直接叫「刻畫類」。書中沒有再進一步解釋命名由來。
五個步驟(p.154,逐字)
「(1) 在測試用具中使用目標程式碼塊。
(2) 編寫一個你知道會失敗的斷言。
(3) 從斷言的失敗中得知程式碼的行為。
(4) 修改你的測試,讓它預期目標程式碼的實際行為。
(5) 重複上述步驟。」書的示範(p.154–155):作者「相當確信」某個頁面產生器物件不會產生某個特定字串,於是寫下一個他知道會失敗的斷言;測試失敗的訊息告訴他那個新建物件實際回傳的是一個空串;把期望值改成空串,測試就通過了。
「現在測試通過了。而且,不僅是通過,它還起到了描述……一個最基本行為的作用」(p.155)
這樣做「不是在自欺欺人嗎?」——書自己提出並回答了這個質疑(p.155)
「比如,既然我們只是把程式碼產生的實際結果填入測試,那這些所謂的測試就沒任何意義了。要是程式碼本身就有 bug 的話,那我們填入測試的那些期望值豈不很可能都是錯的?
然而,如果我們換個角度的話,這個問題就消失了。我們不把它們看成軟體必須遵循的黃金準則,因為我們並不是為了尋找 bug,我們是想設置一個機制以便於以後尋找 bug。注意,這裡所說的 bug 是指後面可能出現的、與當前系統行為不一致的行為。」採用這個視角之後,「它們不再是一個個的準則,而是描述了系統各部分的實際行為」;知道實際行為之後,「結合之前對於系統『應該具有的行為』的認識,就可以明智地作出如何修改的決策」(p.155)。書強調(p.155):
「我們通常可以通過與其他人交流或者通過計算知道哪些行為是需要添加的,但若是少了(特徵)測試的話,便沒法知道系統當前的實際行為是什麼了」
現狀行為看起來像 bug 時怎麼辦——書的立場很明確
第一層(p.155,方框):不是丟掉那個測試,是標記為可疑。
「特徵測試描述了一塊程式碼的實際行為。在編寫特徵測試的時候如果發現某些結果與我們所期望的不一致,最好弄清它。因為我們遇到的可能是個 bug。但這並不是說我們就不能把該測試放進測試套裝中,而是說我們應該將它標記為可疑的,並搞清修正它會帶來哪些影響。」
第二層(p.157,「發現 bug 時……」方框):預設是盡早修正,只有已部署時才先調查。
「答案要視具體情況而定。倘若系統尚未被部署,那答案很簡單:修正 bug。否則就需要調查調查,看看是否已經有用戶依賴於系統的當前行為(甚至在你看來是 bug 的行為)了。對於後一種情況,通常得花上一點時間來分析如何才能在不導致連鎖反應的前提下修正一個 bug。
我比較傾向於一旦發現 bug 就盡早修正它們。如果一個行為很明顯就是錯的,那就該進行修正。如果你覺得某個行為有問題,則可以把相應的測試程式碼標成可疑的,並逐漸提升其優先級。盡早查明它是否為 bug 以及最好如何處置它。」同段也提醒(p.157):「只要是遺留程式碼就免不了有 bug,通常 bug 的量是跟它(遺留程式碼)被理解的程度成直接的比例關係的。」
該寫多少測試、什麼時候停(p.156)
對一個類到底可以寫多少特徵測試,「答案是,無窮多。花上十年八年也寫不完」。書給的判準:
「要想解答這個問題,關鍵的一點就是要意識到我們並不是在編寫黑盒測試。換句話說,我們在編寫特徵測試的時候是可以去查看所要刻畫的程式碼的。程式碼本身能夠告訴我們它們的行為,而如果看了程式碼還不能肯定的話,理想的辦法就是編寫測試去『詢問』它們了。」
兩步:
- 讓自己對目標程式碼的行為感到好奇——這是「編寫特徵測試的第一步」,「在這個階段我們不斷編寫測試直到感到已經理解了程式碼」。
- 設法弄清「我們的修改如果引入了 bug 的話測試能否『感應』得到」。「如果存在可能的漏網之魚,就要添加更多的測試,直到無遺漏為止。而如果我們沒有這麼大的信心,安全一點的辦法就是考慮換一種方式來修改程式碼。或許我們可以再返回到第一步看看。」
刻畫一個類:四個啟發式方法(13.2 節,p.156)
先「從較高的層面上來領會該類會做些什麼」,針對能想到的最簡單行為寫幾個測試,然後:
- 「尋找程式碼中邏輯複雜的部分。如果你不理解某塊程式碼,可以考慮引入感知變數(239 頁)來刻畫它。利用感知變數來確保程式碼中的某些特定的區域被執行到了。」
- 「隨著你不斷發現類或方法的一個個職責,不時停下來把你認為可能出錯的地方列一個單子。看看能不能編寫出能夠觸發這些問題的測試。」
- 「考慮你在測試中提供的輸入。如果故意把輸入的值變得極端化會出現什麼情況呢?」
- 「對於某個類的物件,有沒有某些條件在它的整個生命週期當中都是成立的?通常這被人們稱為不變式(invariant)。嘗試編寫測試去驗證它們。」——書補一句:「通常你可能需要重構才能夠發現這些不變式。但重構往往能夠給你帶來關於『程式碼應該怎樣』的新認識。」
書還提醒特徵測試是寫給人看的文件(p.156–157):換位思考,設想自己是「在面對一個以前從未見過的類」的讀者,想知道什麼、想以什麼順序知道;一開始放簡單的、說明目標類主要意圖的用例,然後放突出該類與眾不同之處的用例。
目標測試:確認測試真的蓋到你要改的那一段(13.3 節,p.157–161)
寫完理解性的測試之後,「接下來的工作就是看看我們的測試是否真的覆蓋了想要修改的地方」(p.157)。書的例子是把一個計算方法裡「頂層 if 語句提取到一個新的方法中,然後將這個新方法移到」另一個類別當中(p.158)。不會被改到的那個分支,「嚴格來說這個測試也不是必須的」;而「將會遭到修改」的那個 else 分支,才需要特地設計輸入去命中(p.158–159)。
兩個陷阱:
- 測試通過不代表分支被執行(p.159,方框):「在為程式碼分支編寫測試時,應該考慮除了那個分支被執行之外是否還存在其他能令測試通過的條件。如果不確定的話,可以使用一個感知變數或除錯器來確定你的測試是否恰好命中目標。」
- 型別轉換會把錯誤藏起來(p.159–160):把金額從整型改成雙精度型之後,真正的麻煩不是浮點誤差,「而是指除非我們小心選擇測試輸入資料,否則在提取方法的時候就可能會犯錯,而且永遠也不知道自己錯在哪」。書舉的錯法是提取出的方法「按整型而不是雙精度型來接受參數」——「在 Java 以及許多其他語言當中都允許從雙精度到整型的隱式轉換:運行時會把值截斷」,結果回傳值被截斷了測試卻依然通過,因為「如果我們僅僅是把整型賦給整型,那麼隱式轉換發生與否對結果是沒有影響的」(p.160)。「所以除非我們想辦法設計出能夠顯式導致這些錯誤的測試資料來,否則錯誤就被隱藏起來了。」(p.160)
四種對策(p.160–161):手動計算你期望得到的值,並在每一個存在型別轉換的地方留意是否可能截斷;用除錯器追蹤賦值運算,看某組輸入會導致什麼轉換;用感知變數確認特定路徑被覆蓋且目標轉換也被測到;或者乾脆刻畫一塊更小的程式碼(手頭有能安全提取方法的重構工具時,把大方法切開分別寫測試——但「並非所有的語言都有重構工具,而且有時候就算有也不一定會按照你設想的方式來提取方法」)。
「最有價值的特徵測試覆蓋某條特定的程式碼路徑並檢查這條路徑上的每個轉換。」(p.161,方框)
收尾:三條啟發式(13.4 節「編寫特徵測試的啟發式方法」,p.161,逐字)
「(1) 為準備修改的程式碼區域編寫測試,盡量編寫用例,直到覺得你已經理解了那塊程式碼的行為。
(2) 之後再開始考慮你所要進行的修改,並針對修改編寫測試。
(3) 如果想要提取或轉移某些功能,那就編寫測試來驗證這些行為的存在性和一致性,一種情況一種情況地編寫。確認你的測試覆蓋到了將被轉移的程式碼,確認這些程式碼被正確連接在系統中。最後別忘了測試型別轉換。」🧪 我實際套用的紀錄
- 2026-07-14:(待填)
⚠️ 注意
- 它鎖的是「現況」(可能含 bug),但書並不主張放著不管:作者「比較傾向於一旦發現 bug 就盡早修正它們」,明顯錯的就修,不確定的標成可疑並逐漸提升優先級(p.157)。只有在系統已被部署時才先調查是否已有用戶依賴當前行為,再分析怎麼在不導致連鎖反應的前提下修正(p.157)。
- 手動測試撐不住(p.153):「在我所接觸過的團隊中,幾乎每一個依賴於手動測試的團隊最終都遠遠落在了後面,結果團隊的信心大受打擊。」
- 不要把「找出並修正所有 bug」當目標(p.153):「對於大多數遺留程式碼來說,若是我們將尋找和修正所有 bug 當成目標的話,則永遠也不會有做完的一天。」書給的策略是「關心如何才能從一開始就避免讓 bug 進入程式碼」。
- 重構時要關心兩件事(p.160,方框):「一是目標行為在重構之後是否仍然存在,二是它是否正確『連接』在系統當中。許多特徵測試就像定心丸一樣。它們不去測試大量的特殊情況,而只是檢驗某些特定的行為是否存在。在一番移動或提取程式碼的重構之後,只要看到這些行為仍然存在,我們就可以放心地告訴自己:我們的重構保持了行為。」
- 使用方法的規則(p.156,方框):「當準備在遺留系統中使用一個方法之前,請查看一下是否已有針對它的測試。沒有的話就自己寫一個。始終保持這一習慣,你的測試就能起到資訊傳遞媒介的作用。」
🔗 相關工具
- 工具-遺留程式碼修改流程 —— 特徵測試是該流程第 4 步「編寫測試」要寫的東西
- 工具-接縫 —— 前一步,先找到能插進去的位置,才有辦法把現狀行為錄下來
- 工具-打破依賴以便測試 —— 步驟 (1)「在測試用具中使用目標程式碼塊」卡住時,要解的就是依賴問題
- 工具-影響分析 —— 13.3「目標測試」在問的正是「測試有沒有蓋到我要改的那一段」
- 工具-理解陌生程式碼 —— 同一件事的兩面:書說寫特徵測試的第一步是「讓自己對目標程式碼的行為感到好奇」,測試本身就是拿來「詢問」程式碼的(p.156)
- 工具-小步重構 —— 後一步,安全網架好之後才開始真正動結構
- 回到來源:修改程式碼的藝術
⏱ 沒時間時
🎯 什麼情境該想到我
當你今天就得在一段沒有測試的舊程式上加功能,而把它整個放進測試用具的代價你現在付不起時——用「寫全新的程式碼」繞過去,至少讓新加的那部分是被測試過的。
書裡的場景(p.51):
老闆走了進來,說「客戶們嚷著要這個特性,今天能完成嗎?」……你檢查了一下手頭的項目,有現成的測試嗎? 沒有。
書也提醒,正因為進度壓力才更要小心:「當處在期限壓力之下時,決定是否編寫測試就成了個難題,最困難的地方就在於你可能並不知道添加某個特性需要花多少時間。」(p.51)
⚙️ 怎麼用
先做一次判斷(p.51)
書給的順序是:
- 先試著在測試用具中實例化這個類。若不能,去讀書中談解依賴的那兩章。
- 「如果在了解這兩章所提到的方法之後還是覺得實在沒法承受現在就去解依賴並安置好測試的代價的話,那就仔細分析一下你所要進行的修改。可以通過編寫全新的代碼來完成它嗎? 很多時候這是可行的。」(p.51)
- 可行的話,才進入下面四種手法。
四種手法
手法 做什麼 什麼時候用 新生方法(Sprout Method,p.53) 把新功能寫成一個新方法,在原方法的修改點呼叫它 新功能可以寫成一塊獨立的程式碼、原方法還能進測試用具 新生類(Sprout Class,p.57) 建另一個類來容納這次改動,在原類中使用它 原類根本無法在合理期限內放進測試用具;或這次改動等於給原類加一個全新職責 外覆方法(Wrap Method,p.58) 把原方法改名,用原名建一個新方法,新方法呼叫改名後的舊方法+新行為 想為原方法的既有呼叫一次補上行為 外覆類(Wrap Class,p.61) 建一個新類持有原物件,在新類中做新行為,其餘委託給原物件 已存在一堆對該方法的呼叫;或原類已經大到不想再撐大 新生方法的步驟(p.53–54):確定修改點 → 在修改點插入一個對「還沒寫的新方法」的呼叫,先把這行註解掉 → 確定新方法需要原方法的哪些區域變數,當作實參傳進去 → 確定新方法要不要回傳值給原方法 → 用測試驅動開發寫出新方法 → 把註解掉的呼叫重新生效。
新生類的步驟(p.58)同構:改用一個類來完成、取個恰當的名字,在修改點插入建立物件與呼叫的程式碼並先註解掉,需要的區域變數改成傳給建構函數。
外覆方法第一形式的步驟(p.60–61):確定待修改的方法 → 把它重新命名,並用它原先的名字和簽名建立一個新方法(書特別提醒「記住要簽名保持」)→ 在新方法中呼叫改名後的原方法 → 為新特性寫一個方法(測試先行),在新方法中呼叫它。
第二形式(p.61):不必沿用舊名字——用 TDD 寫一個承載新特性的新方法,再建另一個函式去呼叫新舊兩個方法,讓使用者「可以在兩種支付方式之間自由選擇」(p.60)。外覆類的步驟(p.65):確定修改點 → 新建一個類,其建構函數接受被外覆的物件;「如果你無法在測試用具中創建外覆類的實例的話,你可能需要先對被覆類使用實現提取或接口提取技術」→ 用 TDD 寫承載新行為的方法,再寫一個方法去呼叫它和被覆類中的舊方法 → 在需要新行為的地方建立並使用外覆類的物件。
怎麼選(p.65)
- 新生方法 vs 外覆方法「區別是相當細微的」:
通常,如果現有方法中的代碼將一個清晰的算法傳達給了讀者,我就會使用新生方法。而如果我覺得欲添加的新特性的重要性跟已經存在的特性不相上下,就會轉而使用外覆方法。
- 外覆類的「使用閾值」要更高一些,兩種情況才用:(1)「欲添加的行為是完全獨立的,並且我們不希望讓低層或不相關的行為污染現有類」;(2)「原類已經夠大了,我實在不能想像把它撐得更大會如何」——這種情況下「使用外覆類只是相當於在地上插個木樁,為後面的修改設下一個標識」。
- 外覆類有兩種味道(p.62、p.64):與被外覆類具有相同接口的那種,書說「該技術在設計模式裡面被稱作裝飾模式」,好處是「可以透明地一次性將新的行為添加到一組……現存調用上」;若「只需要將新的行為添加到少數幾個地方」,則建一個非「裝飾性」的外覆類。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
共同代價一:測不到呼叫端。 這是全章最重的警告(p.51)——
當你用這些技術時,雖然是在系統中添加已被測試的代碼,然而除非你用測試覆蓋了所有調用這些代碼的代碼,否則還是沒有對這些代碼的使用進行測試。所以,小心使用。
共同代價二:等於暫時放棄原方法/原類。 新生方法的缺點(p.54):
效果上等於暫時放棄了原方法以及它所屬的類,也就是說你暫時不打算將它們置於測試之下和改善它們了……原方法可能包含了大量複雜的代碼以及一個新生方法。有時候事情並不明朗,為什麼偏偏就那點工作要放到其他地方去呢?
共同代價三:暫時讓設計變差。 新生類的主要缺點(p.58)是「可能會使系統中的概念複雜化」,「開始破壞系統中原有的抽象,並將大批工作放在其他類中進行……理想情況下應當呆在原有類中的代碼最終卻棲身在新生的類當中,實屬無奈之舉」。書自己也預演了讀者的白眼(p.56):「就為這點小事去創建一個新類也太荒唐了?」——然後回答「之所以這麼做,唯一的目的就是為了擺脫一個惡劣的依賴環境」。
外覆方法特有的兩個缺點(p.60、p.61):
- 「你添加的新特性無法跟舊特性的邏輯『交融』在一起。它們要麼在舊特性之前要麼在之後完成。」書說這其實並非壞事,建議儘量這麼做。
- 「可能會導致糟糕的命名」——你被迫替原方法裡的舊程式碼取一個新名字,而「許多時候我們之所以進行方法外覆正是因為缺少相應的測試,代碼脆弱且沒有上述工具(安全的方法提取重構工具)」。
外覆方法相對省事的地方(p.61):「新生方法和新生類都會將代碼添加到現有方法中,至少增加一行,而外覆方法則不會增加現有方法的體積。」
裝飾別套太多層(p.64):「裝飾是個不錯的模式,但還是保守使用比較好。在一個裝飾類套一個裝飾類的代碼中『行走』就好像是在一層一層地剝洋蔥皮一樣。」
新生方法卡住時的退路(p.54):若為了測試新生方法得先「偽造一大堆它的構造函數的實參」,替代方案是傳 Null 技術;再不行就「考慮將新生方法設為一個公用靜態方法」,把原類的實例當實參傳進去。書把靜態方法看成「臨時場地」,等它們數量累積、共用變數時再抽成新類。
別忘了這是欠款不是解法(p.66 小結):
之所以去創建一個新類唯一的原因就是我們想要編寫受測試的新代碼,而目前還沒有時間去將那個現有類納入測試。這是個很現實的情況。
書說後續會發生的事是:「一段時間之後,你對於總是避開舊類龐大的身軀感到厭煩了,於是開始試圖將它納入測試中」——而且因為你一直在查看那個巨大的未測試類來決定從哪裡抽生新類,「把它納入測試也就變得越來越不可怕了」。另外書也提醒同事關的社會成本(p.65):只為一個小特性就外覆一個類,若給不出有說服力的理由,可能會被同事「優待」一番。
🔗 相關工具
- 工具-遺留程式碼修改流程 —— 同書的完整流程;本卡是「來不及走完整套」時的捷徑
- 工具-打破依賴以便測試 —— 書要你先試這條路,真的付不起代價才退回本卡
- 工具-特徵測試 —— 等原類終於進得了測試用具時,用它把舊行為鎖住
- 回連 修改程式碼的藝術
🧰 這本書給我的工具
- 工具-遺留程式碼修改流程 — 接手一坨沒測試的程式碼、不知從何下手時(全書主演算法)
- 工具-接縫 — 想在難測的舊碼中找到下手點時
- 工具-特徵測試 — 想幫沒測試的舊碼建立安全網時
- 工具-打破依賴以便測試 — 某段程式因相依而無法測試時(第 9、10 章的症狀對策表)
- 工具-時間緊迫時的安全修改 — 今天就要上、但沒時間補測試時
- 工具-影響分析 — 判斷「改這行會波及哪裡、該測哪些方法」時
- 工具-理解陌生程式碼 — 接手系統完全看不懂、又沒文件沒人可問時
📖 完整型錄:24 種解依賴技術(詳解在 reference/)
參數與方法層:參數適配、參數化建構函式、參數化方法、樸素化參數、分解出方法對象、暴露靜態方法
提取與覆寫:提取並重寫呼叫、提取並重寫工廠方法、提取並重寫獲取方法、介面提取、實現提取、子類化並重寫方法
全域與靜態:封裝全域引用、以獲取方法替換全域引用、引入靜態設置方法、引入實例委託
語言特定:連接替換、定義補全(C/C++)、替換實例變數(C++ 不支援在建構函式中呼叫虛擬函式時的替代方案)、換函式為函式指標(C)、模板重定義(C++ 等泛型語言)、文本重定義(Ruby 等解釋型語言)
✨ 關鍵重點(Layer 1–2)
- 遺留程式碼的困境(p.14):「我們在修改程式碼時,應當有測試在周圍『護』著。而為了將這些測試安置妥當,我們往往又得先去修改程式碼。」——整本書就是在解這個循環。
- 兩種修改方式(p.8):「編輯並祈禱」vs「覆蓋並修改」。前者「幾乎可算是業界的標準做法」,後者的「覆蓋」意思是用測試覆蓋它。書強調安全性不取決於你有多細心。
- 解依賴有兩個理由(p.18):感知(否則無法探知行為)與分離(否則無法把程式碼放進測試用具)。
- 每個接縫都有一個激活點(p.31–32,書譯「激活點 enabling point」)——這是描述句,不是資格條件:在那個點上你可以決定使用哪種行為。書把接縫分成預處理期接縫、連接期接縫、對象接縫,但明說這些只是「主要的幾種」。
- 特徵測試不是驗對錯,是記錄現狀(p.154):「更重要的不是『系統應該能夠做些什麼』,而是『系統當前能夠做些什麼』。」
- 發現現狀行為像 bug 時(p.157):書的立場不是鎖住不動——「我比較傾向於一旦發現 bug 就盡早修正它們」;只有系統已部署時才需要先調查是否有使用者依賴了那個行為。
- 解依賴會讓程式碼暫時變醜(p.15):書要你「暫時將自己的審美感放在一旁」,並用疤痕作比喻。一旦測試就位,就能用測試支持的重構改善設計。
- 只提取你所了解的(p.241):手動提取方法時,一次 2–3 行、最多 5 行,並用「耦合數」衡量風險——0 耦合數最安全。
💬 金句原文(Layer 0)
- 「對我來說,遺留代碼就是那些沒有編寫相應測試的代碼。」(前言 p.2)
——注意作者刻意用第一人稱限定。同頁他先給了「嚴格定義」(從其他人那兒得來的代碼)與業內口語(「無法理解的、難以修改的代碼」),才提出自己這個不同的定義。 - 「沒有編寫測試的代碼是糟糕的代碼。不管我們有多細心地去編寫它們,不管它們有多漂亮、面向對象或封裝良好,只要沒有編寫測試,我們實際上就不知道修改後的代碼是變得更好了還是更糟了。反之,有了測試,我們就能夠迅速、可驗證地修改代碼的行為。」(前言 p.2)
- 「這並非是一本關於漂亮代碼的書。」(前言 p.2)
- 「依賴性是軟體開發中最為關鍵的問題之一……本書的大部分內容都圍繞著『解除依賴性以便使改動變得更容易』這一主題。」(p.14)