案例的形狀

一個批次操作:使用者先勾選多個項目,按確認後合而為一。它有兩個入口按鈕,差別在合併完成後要做什麼——一種是回到編輯流程繼續處理,另一種是直接進入下一階段。用途在按下入口按鈕的當下就決定了:

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——那張卡處理「可被觀察」與「可被寫入」為什麼是兩個獨立決定,本文只用到它的結論。
  • 測試側的展開:從防護視角看測試的建立意義、變更時的作用、跟註解的分工與測試自身的文字,見 測試的價值發生在它變紅的那一刻——本文的案例在那裡是教學層的推導材料。