引述說明
本卡引述全部出自第 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)。
🔗 相關工具
- 工具-打破依賴以便測試 —— 接縫是概念層,那邊是實際把依賴拆開的手法
- 工具-特徵測試 —— 接縫換上測試替身之後要寫什麼測試:先把現有行為原樣釘住,當改動的安全網
- 工具-遺留程式碼修改流程 —— 接縫是「解依賴」這一步的核心概念
- 回到來源:修改程式碼的藝術