不知從什麼時候開始,我們這個產業似乎認定:寫註解就代表你身為工程師失敗了,得要「git good」(別在終端機裡試,這不是 git 指令)。照這種邏輯,如果你的程式碼需要說明,那就代表它不夠好。
老實說,我其實很喜歡這種想法。我也希望每個程式碼庫都乾淨到:你打開檔案,讀一遍,然後心想「嗯,懂了。」一切都合乎邏輯,一切都一目了然。
但是……
我也希望道路設計得好到不需要路標。可就算是設計最精良的山路,也還是會有「前方急彎」的標誌,而且不是因為工程師失敗了,而是因為在你進入彎道之前,你根本看不到彎道。

所以,先來個現實檢查:不是每個函式都能解釋自己在做什麼。現在不能,明天不能,永遠都不一定能。總會有這類程式碼:
當初根本不是以結構化方式寫的,所以沒人看得懂。
寫得非常漂亮,但問題複雜到你還是得盯著同一個函式看上五個小時才能看懂。
簡單、乾淨、非常可讀,卻是基於某個原因而存在,而這個原因完全不會出現在程式碼裡。
這裡有一段非常乾淨的 C#:
public static partial class FlightNumbers
{
[GeneratedRegex(@"^([A-Z]{2}|[A-Z]\d|\d[A-Z])(\d{1,4})([A-Z]?)$")]
private static partial Regex FlightNumber();
public static bool IsValid(string input) =>
FlightNumber().IsMatch(input.Replace(" ", "").ToUpperInvariant());
}
名稱很好。現代化、由原始碼產生的 regex。沒有需要重構的地方,也沒有需要改名的地方。
現在來個小測驗。以下哪些是有效的?
FlightNumbers.IsValid("BA123");
FlightNumbers.IsValid("U21234");
FlightNumbers.IsValid("9W5A");
FlightNumbers.IsValid("99123");
除非你跟我一樣是航空迷,或者是能流利讀懂 regex 的人(如果是,那太厲害了,真的佩服),不然你根本不知道這個方法到底在檢查什麼。
當然,你可以去查。當然,你可以看文件。也當然,應該會有單元測試清楚描述哪些是合法、哪些是不合法。對吧?!
嗯,理論上是;不過「理論上」跟「實際上」,有時候就像廣告裡的漢堡和盒子裡的漢堡一樣。

有時候根本沒有文件。有時候根本沒有單元測試。有時候就只有你、regex,以及越來越濃的絕望感。
但即使文件和測試都存在,你為什麼要把自己的思緒打斷,開三個分頁,然後像尋寶一樣找答案?明明只要一段註解就能直接放在程式碼上方,等著你看見它。
我示範給你看。然後你告訴我,這樣是不是好得多:
// IATA 航班號,例如 "BA123"、"U21234"、"9W5A"。
// 航空公司程式碼長度為 2:兩個字母,或字母加數字的組合(U2 = easyJet)。
// 接著是 1 到 4 位數的航班號,以及一個可選的營運後綴字母。
// 只接受大寫且不含空格:比對前先將輸入正規化。
[GeneratedRegex(@"^([A-Z]{2}|[A-Z]\d|\d[A-Z])(\d{1,4})([A-Z]?)$")]
private static partial Regex FlightNumber();
而且,你還能學到一些程式碼永遠不會直接告訴你的事情。
那個 regex 解決的是翻譯問題:程式碼本身沒錯,只是它說的是大多數人看不懂的語言。但還有第二種註解,而且價值甚至更高,因為它處理的是程式碼無論用哪種語言都表達不出來的東西。
private const int MaxConcurrentRequests = 47;
乾淨。命名清楚。常數,不是藏在迴圈裡的 magic number。教科書範例。
但看到它的每位開發者都會有同一個疑問:為什麼是 47?不是整數,不是 2 的次方,看起來像某人的幸運數字、打錯字,或是某個漫長夜晚的產物。供應商文件寫的是上限 50,所以這一定只是錯誤吧。
於是有人把它改成 50。幾週後,在真實流量下,供應商開始隨機拒絕請求,而且沒人能重現問題。
缺少的是這段:
// 47,不是 50。供應商文件寫的是每秒 50 個請求,但它的
// 限流器是以 1.2 秒窗口計算突發流量,所以重試到 50 會觸發限制。
// 若他們公布新的限制,請重新確認。
private const int MaxConcurrentRequests = 47;
這就是只存在於作者腦中的那一部分程式。程式碼記錄了決策;註解記錄了決策背後的理由。少了理由,這個決策看起來就像 bug。
所以在實務上,一個好的註解通常只做四種事情之一:
像 regex、巧妙的技巧或數學公式這類密集程式碼,即使程式碼本身完全正確,「是什麼」也不直觀。
說明某個看起來錯誤、武斷或多餘的選擇,背後的原因。
如果你改了這裡會壞什麼,以及會壞多嚴重。
一個有期限的決策:曾經嘗試過什麼、哪些沒用,以及什麼時候值得再回來檢查。
如果註解沒有做到這四件事之一,那它大概就不該存在。但如果有,因為追求「乾淨程式碼」而刪掉它,那不是在整理。
順帶一提,這些都是非常簡單的例子;在實務上,你會遇到更複雜得多的情況。
這時候,後排會有人舉手:理由應該放在 git 歷史裡。寫好 commit 訊息,讓原始碼保持乾淨。這才是版本控制的用途。
同樣地,理論上聽起來很有紀律。但實務上,去對任何一個超過一年的檔案跑一下 git log 吧。我等你。
...
a91f3c2 apply editorconfig
7d2e8b1 fix
3c4f9a0 fix again
e81b7d4 PR feedback
b02c6f5 final fix
f5a1e93 final fix (actually)
...
在那堆紀錄裡某個地方,藏著 MaxConcurrentRequests 為什麼是 47 的理由。祝你好運。找到再告訴我。
那如果找不到呢?你就只能坐在那裡怪天怪地怪所有人嗎?還是更務實一點,寫一段簡短註解,幫未來的其他開發者和未來的自己解決問題?也許如果過去的你沒那麼教條,現在就會快樂好幾倍?你有想過嗎?
而且,就算你們團隊真的都寫了很漂亮的 commit 訊息,這個方法還是會因為幾個很無聊但很實際的原因而失效。
git blame這就是關鍵。當你不知道有問題時,你不會主動去找原因。47 這種數字看起來像打錯字,而不是值得調查的謎團。註解會在你犯錯之前先打斷你;git 歷史只回答你已經想到要問的問題。
一次重新格式化、一次重新命名、一次拆檔、一次 squash merge,這行就會指向某個叫做「apply editorconfig」的 commit。原始理由還在,但被埋在多年不相關的變更底下了。這時候你不再是在查詢,而是在考古。考古需要時間和精力。
有時候真正的文件根本不是 commit 歷史,而是 Bill。Bill 知道為什麼是 47。Bill 兩年前就去別家公司了,而且不會接你的電話,因為他知道你又要拿這些問題去煩他。
commit 訊息很擅長解釋一次變更:這個 commit 改了什麼、為什麼改。但它們很不擅長解釋「現在的狀態」,因為現在的狀態是數十次變更加總的結果,沒有人會照順序讀一遍就把它重建出來。
註解是唯一能直接在你眼前,描述「程式現在長什麼樣子」的地方。
我們換個問題。假設你寫了一段註解。最糟會怎樣?
我最常聽到的反對意見是這個:
「如果你改了程式碼,就得連註解一起改。」
那又怎樣?
真的。這有多難?註解就在那裡。不在 wiki,不在 2021 年之後就沒人打開過的 Confluence 頁面,不在另一個獨立的 repository 裡。它就在你正在編輯的程式碼上方一行。如果你可以把 47 改成 50,你也可以把那句話一起改掉。你就是在看它。
這個說法預設有一條規則:變更只應該碰程式碼,其他周邊都不能動。並沒有這種規則。當你改了某個方法的行為,你會更新它的測試。當你改了參數名稱,你會更新呼叫端。更新那段描述你剛改過東西的註解,本質上也是同一件事。這不是額外負擔,這就是工作。
這其實反映的是更大的問題:把一條好的準則,遵守得過頭,最後反而傷到它原本要幫助的程式碼。
拿 DRY 來說吧。這個主題留給這系列的下一集,不過你可以老實問自己:嚴格遵守 DRY,真的總是能讓你的程式更好嗎?
不一定。
有時候兩段程式看起來很像,於是有人抽出一個共用函式來避免「重複」。接著兩個使用情境開始慢慢分歧,函式就多了一個參數。然後再多一個。再來幾個旗標。六個月後,你就看到這種東西:
ProcessOrder(order, true, false, null, customer, true, 3, "legacy", false, skipValidation: true);
恭喜,程式碼很 DRY。唯一的缺點是,沒人知道它在做什麼。順帶一提,連未來的你也不知道。
別跟我說你沒看過,或者更好,沒自己寫過這種函式/方法。我有,而且是在我還是初階/中階工程師的時候。那段經驗讓我痛苦不堪。
有時候,在兩個地方重複幾行程式完全沒問題,因為各自保持可讀性,比消除每一處重複模式更重要。重複不一定是壞事,抽象化也不一定是好事。一切都是取捨,而知道該偏向哪一邊,正是好工程師跟照表操課的人之間的差別。
註解也是一樣。「永遠不要寫註解」和「每一行都要寫註解」一樣,都是照表操課。正確答案是:當註解能承載程式碼無法表達的內容時就寫;而且像更新其他東西一樣去更新它,因為它就在那裡。
現在,在有人衝去開始每一行都加註解之前,先說清楚:這不代表你可以開始逐行解說你的程式。爛註解真的存在,而這也是註解在第一時間就背上壞名聲的重要原因。以下是註解界的恥辱榜。
// 增加重試次數
retryCount++;
/// <summary>
/// 取得使用者
/// </summary>
/// <param name="id">辨識碼。</param>
/// <returns>使用者</returns>
public User GetUser(int id)
六行儀式感,零資訊。每個 .NET 程式碼庫裡都有成千上萬這種註解,通常只是為了把警告消掉而由工具產生。如果你要寫文件註解,請告訴我一些方法簽章看不出來的東西。
// 最多重試 3 次
private const int MaxRetries = 5;
// TODO: 暫時性 workaround,之後移除
之後是什麼時候?要怎麼移除?在什麼條件下?這段註解寫於 2019 年,而且它會比我們所有人都活得久。如果真的只是暫時的,請寫清楚它在等什麼:工單、版本、日期。
// var result = await _legacyService.CalculateAsync(order);
// if (result.IsValid) { ... }
// var result2 = await _newService.CalculateAsync(order);
被註解掉的程式碼只會告訴讀者一件事:某個人捨不得刪。刪掉它。真的有需要時,你再寫回來就好。
一個三十行的方法,上方再配一段三段式註解,詳細解釋它怎麼運作。有時候這是必要的。很多時候,這代表程式碼應該重寫,而註解只是在補洞。先試著重構。然後再把剩下的複雜性寫成註解。
乾淨的程式碼是個很棒的目標。好的命名、小而清楚的方法、明確的結構:這些都請繼續保持。但乾淨程式碼只能回答一個問題:這是在做什麼?而真實的程式碼庫會一直問更多問題。為什麼會長這樣?如果我改這裡會怎樣?這個奇怪的東西是 bug,還是歷史傷疤?
這些答案不在語法裡。它們存在寫下程式的那個人的腦中,而腦袋不是很好的儲存媒介。人會換工作、會休假,也會在禮拜二就忘記事情。
所以這是我實際在遵守的規則,而且只要一句話就夠:
如果我在寫下一行之前不得不停下來想一下,我就把當時想到的東西寫下來。
如果那一行很明顯,我就讓它保持原樣。不要重複,不要儀式感,不要龍。如果我曾經有一瞬間心想「嗯,這裡要小心」,那一刻就該進註解,因為下一個讀到的人也會有同樣的「嗯」,只是沒有答案。
乾淨程式碼告訴讀者你做了什麼;好的註解告訴他們你知道什麼。兩者都需要。
喜歡這篇文章嗎?一起保持聯繫吧!
我會在以下平台分享更多軟體工程觀點、專案與實驗: