註解防不了改壞——防護需求要交給會發聲的機制
案例的形狀
一個批次操作:使用者先勾選多個項目,按確認後合而為一。它有兩個入口按鈕,差別在合併完成後要做什麼——一種是回到編輯流程繼續處理,另一種是直接進入下一階段。用途在按下入口按鈕的當下就決定了:
1enum ActionPurpose {
2 /// 合併後回到編輯流程繼續處理
3 continueEditing,
4
5 /// 合併後直接進入下一階段
6 finishImmediately,
7}持有它的欄位帶著這行 doc comment(宣告前方那段給外部閱讀者與 IDE 消費的註解)進了 review:
1/// 當次操作的用途,由進入該模式的入口設定
2final Rx<ActionPurpose> actionPurpose = ActionPurpose.continueEditing.obs;Rx<> 表示這個值可被畫面訂閱:它一變,顯示它的元件就重繪。
第一輪退件:查詢成本軸
第一輪的退件理由是「這個註解是什麼意思」。把自己放在讀者的位置實際走一遍——跳到這行、想從註解拿到資訊,逐字檢查它給了什麼。
「由進入該模式的入口設定」——「入口」指什麼?在程式裡搜 入口、搜 entry,找不到任何識別符。實際的進入點是兩個按鈕的 handler:
1// 按鈕一:合併後回到編輯流程
2onPressed: () => controller.enterBatchMode(ActionPurpose.continueEditing),
3
4// 按鈕二:合併後直接進入下一階段
5onPressed: () => controller.enterBatchMode(ActionPurpose.finishImmediately),對照這段程式碼就看得出落差:註解若寫「由 enterBatchMode 的呼叫端設定」,讀者搜這個識別符、一次就落在這兩行;寫「入口」,這個詞只存在於註解裡,讀者得自己猜它對應程式的哪個部分。用程式裡不存在的詞描述程式,註解就跟程式斷了線。
「由誰設定」這個資訊本身呢?搜 actionPurpose 的寫入點,同樣直接落在這兩行——不靠註解也是 grep 一下就有,註解沒有省下任何查詢。
剩下「當次操作的用途」——回頭看 ActionPurpose 的宣告,兩個值的 doc 已經把「合併後回到編輯流程」「合併後直接進入下一階段」寫完了,「用途」只是把型別名稱再唸一次。
走完這三步,一個檢查方式自然浮現:把型別名稱唸出來,doc 還剩下什麼資訊。 這裡唸完 ActionPurpose,剩下的字沒有一個是讀者拿得走的。
這一輪的問題都出在查詢成本:讀者跳到這行想拿到資訊,拿到的是零。第二輪退的則是防護力,兩條軸分開看才不會混淆。
第二輪退件:宣稱真實約束的註解
第二版改寫成型別說不出來的部分——這個值的生命週期:
1/// 當次操作的用途,見 [ActionPurpose]
2///
3/// 收尾動作在選取狀態重設之後才讀取,因此重設選取時不清掉這個值。這個約束是真的。程式的順序是:
1確認執行
2 → 呼叫 API
3 → 同步各層狀態
4 → resetSelection() ← 清掉所有選取狀態
5 → finishAction() ← 在這裡才讀 actionPurpose任何人想「順手整理」把 actionPurpose 加進 resetSelection 的清除清單,第二種用途就會靜默走成第一種——不報錯、不彈窗,只是使用者按了 B 卻得到 A 的結果。
第二版仍然被退,理由是:這個解釋停在程式面,沒有商業邏輯面的內容;沒有的話不該寫註解。
動機辨識:這行註解在防什麼
第二輪退件把評估往上推了一層。檢視一則註解時要評估的是:它有沒有解釋到這個行為、這個事件、或這個 flag 的商業邏輯。用這個標準看第二版——「收尾動作在選取狀態重設之後才讀取,因此重設選取時不清掉這個值」——整句都停在程式面:它描述讀寫順序、描述一個技術上的因果,沒有一個字回答「為什麼要有這條規則」。解釋不到商業邏輯的註解,就要重新檢討寫它的動機。
往動機追下去,這種註解的動機是防護:怕有人動壞那個順序。
而註解的作用只發生在有人剛好讀到它的時候。它不參與執行,改壞的當下沒有訊號產生。真正會在改壞當下發出訊號的機制是測試。
所以問題從「這段文字該怎麼寫」換成:這個約束有沒有被測試守著?
實測:故意破壞它
驗證方式是當場把它破壞掉。在重設選取狀態的地方加一行清除:
1void resetSelection() {
2 selectedIds.clear();
3 actionPurpose.value = ActionPurpose.continueEditing; // 故意破壞
4}跑那條流程的整合測試:
1+2 -1: 直接進入下一階段:不做編輯流程的收尾 [E]
2 Expected: Step:<Step.finished>
3 Actual: Step:<Step.editing>紅了,而且訊息直指症狀:該進到下一階段卻停在編輯階段。這條推論的前提是有一條會紅、而且會自動跑的測試——該專案的整合測試掛在 pre-commit 上,改壞的人在 commit 之前就會知道。
失敗訊息只給症狀,拿到紅燈的人還要逆推「為什麼這個順序是刻意的」。承擔這個「為什麼」的是測試名稱:「直接進入下一階段:不做編輯流程的收尾」把意圖寫在那裡了。
那段註解是一份沒有保護力的副本——它重複了測試已經在守的事,代價是佔用讀者最靠近程式碼的注意力。處置是還原破壞、刪掉整段 doc,欄位留白。
判準:把剩下的資訊寫成一句測試名稱
第一步:把型別名稱唸出來,doc 還剩什麼資訊?剩不下就刪——那是型別已經講完的話。
第二步:剩下的資訊,問有沒有一條會紅的斷言撐得住——標準是斷言存不存在,不是造不造得出句子。兩個出口:
- 寫得出來(本案例的「直接進入下一階段:不做編輯流程的收尾」)→ 那句話的家是測試名稱,不是 doc。去確認測試存在,並且名稱說得出這件事為什麼成立;名稱說不出來就先改測試名稱,改完仍說不出來才考慮留註解。
- 要寫出來必須引用 repo 之外的事實(法規、稽核要求、後端契約、當初的取捨)→ 測試名稱裝不下那個來源,這行才是 doc 該留的。
任一步判定為「程式碼自己講得出來」就整段刪除。
這一步把「剩一點點資訊怎麼辦」變成可執行的動作:不用估量資訊多寡,直接問有沒有一條會紅的斷言撐得住,而斷言存不存在是二元的。它把 程式碼註解撰寫方法論 的「移除註解後仍能理解程式邏輯」收窄了一次——把「讀者能不能理解」換成「有沒有機制接手」,而這兩者只在有測試的 repo 裡才會分岔。
落點的優先序
「這件事有沒有辦法讓程式自己講」——照這個順序試。
優先序之前的 gate:這個約束能不能不存在
這個案例裡,「必須活過重設」這個約束之所以存在,是因為用途被放在共享可變狀態裡——值存在一個多方都讀得到、任一方都改得動的地方。如果收尾函式在確認的當下就把用途當參數收下:
1final purpose = actionPurpose.value; // 確認當下捕捉
2...
3resetSelection();
4await finishAction(result, purpose: purpose); // 不再依賴共享狀態那個約束就不存在了,不需要測試守、也不需要註解講。
該案例最後選擇不重構,理由是收尾路徑的耦合已經有測試守著,重構的邊際效益不高。那個欄位本身仍得是可被畫面訂閱的狀態(確認鈕文案也要讀它來決定顯示哪一種),所以這個重構能消除的是耦合、不是那個欄位。但先問「這個約束能不能被消除」再問「該用什麼守它」:約束被消除之後,下面那張表都不必看。
約束留下來之後:訊號離改壞有多近
軸是「改壞的當下,訊號多快出現、能不能被忽略」:
| 手段 | 訊號出現的時機 | 裝得下的約束 |
|---|---|---|
| 型別 | 編譯當下,無法忽略 | 值的合法範圍、狀態機、不允許的欄位組合 |
| 測試 | 跑測試時(該專案在 pre-commit) | 讀寫順序、時序耦合、跨時間必須恆真的條件(不變式) |
| 命名 | 每個呼叫點都會被讀到 | 意圖、單位、所有權 |
| 註解 | 只有剛好被讀到時 | 以上都裝不下的 |
型別排在測試前面,因為編譯期的訊號更早、而且無法選擇忽略。但型別裝得下的範圍窄得多,而判法是問「違反這個約束的程式碼,寫得出來嗎」——「不該是負數」用一個非負型別就寫不出來,型別裝得下;「必須活過某一次重設」寫得出來、而且編譯得過,型別裝不下。這一問在設計當下就答得出,不必等落地。本案例的約束屬於後者,所以它的家是測試而不是型別。
這條分界跟 不變式的強制層次 的型別層 / 執行層判準同源。那章的五個位置沿強度排列,而強度其實混了兩件事——規則寫在被約束的產物裡還是外面、以及違反時何時發聲。測試寫在產物外面、在跑測試時發聲;跨函式的讀寫順序在產物內沒有位置寫得下,沿強度往上找會一格一格落空。
命名不會自動發聲,但它出現在每一個呼叫點、讀程式的人無法跳過。purposeSurvivingReset 這種名字守不住任何約束,卻讓「順手整理」的人在動手前多看一眼。註解連這一點都做不到,它是可以整段跳過的。
同一組手段換一條軸排,順序會不一樣。函式文件分層設計 把「範例與測試」放在最下層,因為它排的是閱讀成本——讀者最後才會跳去看測試。這裡把測試放最前,因為排的是改壞時會不會發聲。要用哪一條,取決於當下要解的是「讀者查得快不快」還是「改壞的人擋不擋得住」。
那什麼時候該寫註解
刪掉的是「程式自己講得出來」的那類。剩下這些仍然只有註解能講:
- 業務動機:為什麼有這條規則。例如「失敗的任務保留不清除」——因為稽核要看得到失敗紀錄。程式只看得到條件式,看不到這個理由。
- 來自系統外的約束:後端契約的已知行為、硬體限制、法規要求。這些不在 repo 裡,讀者無處查證。
- 刻意不做的事:某條路徑刻意不設守衛的理由。這一項要拆成兩半——「補了守衛會壞掉什麼」寫得成測試,交給測試守;「當初為什麼選擇不守」寫不成測試,那才是註解的內容。
共同點是:這些資訊是決策脈絡,不是程式當下的狀態。程式碼描述的是現在是什麼樣子,推導不出當初為什麼選這樣。凡是能從程式碼推導出來的,就交給程式碼講。
這三項是用「有沒有測試接手」篩過一遍的結果。型別取代 doc 的收益曲線 列的那份「型別表達不了的剩餘」有六項(業務動機、性能特性、對外部系統的副作用、時序契約、使用情境限制、跨方法不變式),其中時序契約與跨方法不變式在這一輪篩選裡移到了測試名下,剩下的才是註解的。
這條判準吃什麼前提
整條推論吃的是專案有一條 pre-commit 會跑的整合測試。同一個判準搬到 pubspec.yaml 或 build script 上就不成立,那裡沒有任何會紅的機制。給下游用的 package API 同理,下游 clone 不到專案內部的測試檔,doc 在那裡是契約介面而不是防護。
三條失效邊界(無測試的 surface、公開 API、沒有測試文化的 repo)整理在 #253。那張卡另外把查詢成本軸單獨列出來——它是另一條軸,有自己的判準,算進失效邊界會讓兩軸混在一起。
這條規則會逼出更好的結構
想寫註解的衝動是一個訊號。防護動機順著「能不能讓程式自己講」問下去,走到的是「這裡缺一個測試」跟「這個狀態不該共享」——兩者都比一行註解有價值。
把註解當成最後手段,等於強迫自己每次都先把結構、型別、測試、命名走過一遍。
下一步
- 原則層:本文的判準抽成 #253 寫註解的動機是怕被改壞時,要處理的是那個約束。那張卡處理動機辨識、消除 gate 與落點選擇的一般形態,並把失效邊界跟查詢成本軸分開。
- doc 內容往型別下移:型別取代 doc 的收益曲線 給的是「這條 doc 能不能變成型別」的三個 review 問題,接在本文第一步之後。
- 消除約束那一步的上游:
Rx<>那個欄位為什麼得是共享狀態、什麼時候該收成參數,見 Shared Mutable State——那張卡處理「可被觀察」與「可被寫入」為什麼是兩個獨立決定,本文只用到它的結論。 - 測試側的展開:從防護視角看測試的建立意義、變更時的作用、跟註解的分工與測試自身的文字,見 測試的價值發生在它變紅的那一刻——本文的案例在那裡是教學層的推導材料。
#documentation #comments #testing #code-review #dart #maintainability