第 16 章 修改現有的程式碼
第 1 章介紹了軟體開發的迭代性和增量性。大型軟體系統透過一系列階段逐步演進,每個階段都會增加新功能、修改現有模組,因此系統的設計也在不斷變化。我們不可能一開始就構思出正確的設計;與最初的構想相比,演進過程中所作的變更對成熟系統的設計影響更大。前面的章節討論了如何在初始設計和實作時減少複雜性,本章則討論如何防止複雜性隨著系統演進而逐漸積累。
本章概覽修改現有程式碼時,怎樣不讓複雜性積累?
16.1 保持戰略式的思考
第 3 章介紹了戰術式程式設計與戰略式程式設計的區別:前者主要追求儘快讓程式碼工作,即使會增加複雜性;後者則把出色的系統設計放在首位。戰術式方法很快就會使系統設計陷入混亂。要讓系統易於維護和擴充,僅僅“能工作”還不夠,必須優先考慮設計,從戰略角度思考。修改現有程式碼時也不例外。
不幸的是,開發人員修改現有程式碼(例如修復缺陷或新增功能)時,通常不會從戰略角度思考。典型的心態是:“要達到這次修改的目的,最少需要改動哪些程式碼?”有時,他們覺得這樣做合理,是因為對要修改的程式碼沒有把握,擔心改動越大,風險越大,會引入新的缺陷。但這就變成了戰術式程式設計:每次最小改動都增加一些特殊情況、依賴關係或其他形式的複雜性,讓設計變差一點。系統每演進一步,問題就多積累一些。
要保持系統設計整潔,修改現有程式碼時就必須採用戰略式方法。理想情況下,每次修改完成後,系統結構都應如同一開始設計時就考慮了這次變更。 為此,必須剋制匆忙修補的衝動,重新考慮:面對這次變更,現有設計是否仍然最佳?如果不是,就重構系統,最終獲得最佳設計。這樣,每次修改都能改善系統設計。
這也體現了第 3.2 節介紹的投資思維:多花一點時間重構、改進設計,就能得到更整潔的系統,後續開發也會更快,從而收回重構投入的精力。即使當前變更不需要重構,也應留意程式碼中可以修復的設計缺陷。每次修改程式碼,都儘量找機會改進一點設計。如果沒有讓設計變好,你就很可能正在讓它變壞。
如第 3 章所述,投資思維有時會與商業軟體開發的現實發生衝突。如果按“正確的方式”重構需要三個月,而快速但不夠整潔的修補只需兩個小時,你可能不得不選擇後者,尤其是在期限緊迫時。又或者,重構會帶來不相容的變更,影響許多其他人員和團隊,那麼重構也可能不切實際。
儘管如此,仍應儘量避免這些妥協。問問自己:“在當前限制下,為了讓系統設計保持整潔,我是否已經做到了最好?”也許有其他方案,只需幾天就能完成,設計卻幾乎與花 3 個月重構一樣整潔。又或者,現在沒條件大規模重構,可以請負責人安排時間,在當前任務的截止日期之後再做。每個開發組織都應留出一小部分工作時間用於清理和重構;從長遠看,這些投入一定值得。
16.2 維護註解:將註解保留在程式碼附近
修改現有程式碼時,一些原有註解很可能隨之失效。開發人員又容易忘記同步更新,導致註解不再準確。過時的註解讓讀者失望;數量一多,讀者甚至會開始懷疑所有註解。好在只要養成習慣、遵循一些規則,不必投入太多精力,也能讓註解保持最新。本節及後續幾節介紹一些具體技巧。
確保註解及時更新的最佳方法,是把它放在所描述的程式碼附近,讓開發人員修改程式碼時能夠看到。註解離相關程式碼越遠,被正確更新的可能性就越小。例如,方法的介面註解最好放在程式碼檔案中,緊靠方法體。修改方法時必然會接觸這部分程式碼,因此開發人員很可能看到介面註解,並在需要時更新。
對於 C 和 C++ 這類程式碼檔案與標頭檔案分開的語言,也可以把介面註解放在 .h 檔案中的方法宣告旁邊。但這樣離實作程式碼較遠,開發人員修改方法體時看不到註解,還要另行開啟檔案,找到註解再更新,增加了額外工作。有人會認為,介面註解應該放在標頭檔案中,讓使用者不看實作程式碼就能瞭解如何使用這個抽象。然而,使用者應該既不必讀實作程式碼,也不必讀標頭檔案,而是查閱 Doxygen 或 Javadoc 等工具生成的文件。此外,許多 IDE 會自動提取並展示文件,例如輸入方法名稱時顯示相應說明。既然有這些工具,文件就應放在最方便開發人員修改程式碼時維護的位置。
編寫實作註解時,不要把整個方法的註解都堆在開頭,而應拆開,把每段註解儘可能下移到能涵蓋其所述全部程式碼的最小範圍內。例如,方法有三個主要階段時,不要在開頭用一大段註解詳述所有階段,而應為每個階段單獨寫註解,放在該階段第一行程式碼的正上方。不過,在方法實作的開頭概述整體策略也可能有幫助,例如:
// We proceed in three phases:
// Phase 1: Find feasible candidates
// Phase 2: Assign each candidate a score
// Phase 3: Choose the best, and remove it
更多的細節可以在各個階段程式碼的正上方記錄。
通常,註解離所描述的程式碼越遠,就應該越抽象,這樣可以降低註解因程式碼變更而失效的可能性。
譯者圖解註解應覆蓋多大範圍?
16.3 註解屬於程式碼,而不是提交日誌
修改程式碼時,一個常見錯誤是只在原始碼儲存庫的提交訊息中記錄變更詳情,卻不在程式碼中說明。雖然以後可以翻閱儲存庫日誌查看提交訊息,但需要這些資訊的開發人員未必知道要去查日誌。即使想到了,找到相關記錄也可能很費事。
編寫提交訊息時,問問自己:未來的開發人員是否還需要這些資訊?如果需要,就應該在程式碼中記錄。例如,某次變更是為了解決一個微妙的問題,而原因只寫在提交訊息中,後來的開發人員就可能撤銷這次變更,卻沒有意識到這會重新引入缺陷。在提交訊息中也保留一份說明當然可以,但最重要的是把它寫進程式碼。這體現了一個原則:文件應放在開發人員最可能看到的地方,提交日誌很少是這樣的地方。
16.4 維護註解:避免重複
讓註解保持最新的第二個方法是避免重複。文件一旦重複,開發人員就很難找到並更新所有副本。因此,每個設計決策應儘量只記錄一次。如果多個位置受同一個決策影響,不要到處重複註解,而要把說明放在最容易找到的位置。例如,某個變數有難以理解的行為,影響多個使用它的位置,就可以在變數宣告旁加以說明。開發人員讀不懂使用該變數的程式碼時,自然會到宣告處查看。
如果沒有一個開發人員自然會去查詢的位置,可以建立一個 designNotes 檔案,如第 13.7 節所述;或者從現有位置中選出最合適的一處放置文件。其他地方只需用簡短註解指向那裡,例如:“查看 xyz 中的註解以理解下面的程式碼。”如果主要註解被移動或刪除,引用失效就很容易被發現,因為開發人員在指定位置找不到它。他們可以查閱版本控制歷史,弄清註解的去向,再更新引用。反過來,如果到處都是重複文件,而部分副本沒有更新,開發人員可能根本不知道自己看到的是過時資訊。
不要在另一個模組中重複記錄某個模組的設計決策。例如,不要在方法呼叫前添加註解,以解釋被呼叫方法中發生的事情。如果讀者想知道,他們應該查看該方法的介面註解。好的開發工具通常會自動提供此資訊,例如,如果你選擇了方法的名稱或將滑鼠懸停在該方法的名稱上,則將顯示該方法的介面註解。儘量讓開發人員容易找到合適的文件,但是不要透過重複文件來做到這一點。
如果資訊已經記錄在程式之外,就不要在程式內部重複記錄,只需引用外部文件。 例如,編寫實作 HTTP 協議的類時,無需在程式碼中再次描述 HTTP 協議。網上已有許多相關文件,只需新增簡短註解,附上其中一份文件的 URL 即可。使用者手冊中已經說明的特性也是如此。假設程式實作了一組命令,每個命令由一個方法負責實作。如果使用者手冊已經描述了這些命令,就不必在程式碼中重複,只需在相應方法的介面註解中加上這樣的簡短說明:
// Implements the Foo command; see the user manual for details.
讓讀者能輕鬆找到理解程式碼所需的所有文件是很重要的,但這並不意味著你必須編寫所有這些文件。
譯者圖解說明複製與引用,失效方式不同
16.5 維護註解:檢查待提交的變更
還有一個讓文件保持最新的好方法:提交到版本控制系統之前,花幾分鐘檢查本次提交的所有變更,確認文件準確反映了每項變更。這樣的提交前檢查還可能發現其他問題,例如遺留的除錯程式碼,或尚未完成的 TODO 專案。
16.6 更高層級的註解更易於維護
關於文件維護,最後還有一點:比程式碼更高層級、更抽象的註解,更容易維護。它們不描述程式碼細節,因此細微的程式碼變更不會影響它們,只有整體行為變化時才需要更新。當然,正如第 13 章所討論的,有些註解確實需要詳細而精確。但總的來說,最有用、並非簡單重複程式碼的註解,也最容易維護。