Working Effectively with Legacy Code 讀後心得: 基礎知識篇

fcamel
fcamel的程式開發心得
7 min readApr 1, 2020
https://www.tenlong.com.tw/products/9789864344000

( 若你已相信可測性和 unit test 的重要性,可看比較具體的下篇筆記。 )

雖然本書於 2004 出版,觀念至今仍相當實用。在 Amazon 評價相當地好:

https://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052#customerReviews

若有看過 Martin Fowler 的《重構─改善既有程式的設計》,會知道程式難以修改時,最佳實踐是先停下原本要作的事,改成進行重構。直到方便修改後,才回頭作原本的事。重構的定義是在不改變外部觀察功能的前提下,改善內部結構。因此,要有足夠的測試輔助,才能放手重構。

等等,若是沒有測試,又急著要加新功能或修緊急的 bug,該怎麼辦?

這正是本書的主題: 如何在有限的時間內,剛好補上足夠的測試,提升信心到敢完成目標。這也是軟體工程師絕大部份在作的事。比起設計模式、語言進階功能來說,這種技能更為實用。只是過於抽象瑣碎,不易傳達。

其實核心概念很簡單:提升程式碼的可測性,因此能寫出堪用的 unit test,然後修改產品碼。目標是找出時限內最小風險的作法,長遠來說,逐步往理想的架構邁進。

在設計時優先考慮可測性和我近年來的心得相似。我是在設計時先避免問題;本書是從已有的 legacy code 切入,實務上更常遇到的狀況。

Unit Test 的逆襲

https://www.youtube.com/watch?v=mizZc61rFUk

Just Say No to More End-to-End Tests (寫於 2015) 提到:

As a good first guess, Google often suggests a 70/20/10 split: 70% unit tests, 20% integration tests, and 10% end-to-end tests. The exact mix will be different for each team, but in general, it should retain that pyramid shape.

軟體維護的重點在於解開相依性,相依性愈低愈好測試和除錯。因此,能夠用 unit test 處理的情況,盡可能用 unit test 處理。況且這樣實作測試碼的成本也低,才能在時限內完成更多測試。

無奈的是,現實中充滿著漿糊碼,要怎麼加上 unit test?

相依性和接縫 (seam)

軟體由多個 packages 組成,每個 packages 由多個 classes 組成。它們之間透過定好的 API 溝通,這個溝通的介面也是所謂的接縫 (seam)。如果測試碼能透過適當的接縫抽換物件而不需修改產品碼,就能簡化寫測試碼的成本。

舉例來說,使用 openssl 作 TLS 連線,可以在連結時期抽換函式庫,換用不同的實作,避免作費時的連線。由底層到上層,共有以下的方式可以抽換呼叫的物件:

  • 抽換動態函式庫,例如 Linux 用 LD_LIBRARY_PATH 換用自己寫的、symbol 一樣的函式庫。
  • 用巨集抽換程式碼,例如 C/C++ 用 #ifdef 在有定義 TEST 時,改用不同版本的實作。
  • 用物件或函式指標決定呼叫的方法或函式,達到抽換執行的實作。

愈高階的作法愈好維護,不同語言提供不同輔助。

  • Go 的 implict interface 最小化放入接縫的成本,不需改相依的套件,就能放入接縫。我們的實作是相依於 interface,測試時替換別套實作即可。
  • Java 也有提供 interface,但是需要修改相依的套件,讓它實作我們訂的 interface。或是使用 adapter 隔離相依的套件
  • C++ 有 abstract class,但用起來沒 interface 那麼方便。

透過接縫,可以作到以下的事:

  • 測式時替換掉複雜的實作。例如資料庫。可簡化測試碼、加速測試時間、避免測試不確定性等。
  • 感測 (sensing) 內部狀態。例如替換成假的資料庫,透過假資料庫得知目標程式在計算過程中,查詢和寫入了那些資料。

解相依性的關鍵是找到適當的插入點,作最小風險 (並非最少行數) 的修改,讓我們能插入接縫。接著,就可以加上 unit test,提升對產品碼的信心,然後完成原本要作的事。

尋找插入點

第一步是理解目標程式碼。

軟體工程師大部份時間是讀碼而不是寫碼,因此,閱讀程式碼其實比寫程式碼重要。遺慽的是,和與漿糊碼相處一樣,讀碼的技能不只容易被忽略,也不易言傳。推薦閱讀 Thinker 的《閱讀程式碼的心理層面》 (原連結已失效,我是附別人的備份)。我在《在 Linux 理解大型 C/C++ 專案的輔助工具》有提到一些工具,是以前在讀 Chromium + Puffin Web Browser 原始碼時,覺得還不錯用的方法。

本書有提到一些作法。這裡提幾個例子。

12.1 攔截點

畫出多個物件 methods 之間的互動,如下圖所示:

圖 12–5

此圖可給我們寫測試的線索:

  • 如果只需修改 Invoice.getValue(),測 Invoice.getValue() 比較適當。
  • 如果需修改 Invoice.getValue() 和 Item.shippingCarrier(),測試 BillingStatement.makeStatement() 可涵蓋到兩者。時間有限時,或許只測 BillingStatement.makeStatement() 較適當。

20.1 職責識別

目標類別過於龐大時,可以先畫出 methods 碰到的 member fields,由此看出是否可拆分成不同小類別。下圖是書上說明 class Reservation 的例子:

圖 20–7

我將 member fields 編上了紅色星號,比較好識別。沒有紅色星號的是 methods。箭頭表示該 method 有用到該 member field。

可看出 Reservation 或許能拆成兩個不同類別,右下角那群是計算費用。只要 getTotalFee() 接收一個參數表示 principal fee,就能斷開兩群的相依性。拆開的操作很單純,改變的風險很低。並且拆開後也降低修改計算全部費用的風險,因為可以更方便準備不同的測試資料,而不用準備 duration … 等四個變數來湊出不同的 principal fee。

16.3 草稿式重構

試著重構看看,重構過程中遇到的問題,會幫助我們理解之前沒察覺的細節。反正最後不要 commit 就好了。有時候動手作一下,會比不斷地閱讀來得順暢。

遇到難以靠靜態分析理解行為時,我滿常會加 log 或印出 stack trace。執行一下,確認我想觀察的行為執行了哪些方法。不然看個老半天,最後那些程式碼沒有被執行到,會損失不少時間和精力。像 C/C++ Go 各有一些技巧可印出檔名、行數、函式名等資訊,比較好收斂要讀的範圍。

小結

  • Unit test 是你應付 legacy code 的好朋友。
  • 難以寫 unit test 時,需要適當地修改產品碼,以便放入接縫。
  • 放入接縫前,需要理解程式碼找出可能的插入點。
  • 然後見招拆招。

下篇會描述一些解相依性的最佳實踐。

--

--