寫註解的動機是怕被改壞時,要處理的是那個約束、不是那行文字
論述基礎與限制
這則檢討處理的是一類 code review 爭議:一行註解該不該存在,而爭論雙方都只在討論它的文字。這類爭議的出口不在文字層,判斷要往上一層問——這行想守的是什麼約束、那個約束需不需要存在、需要的話誰來守。
論述基礎是一次實際的兩輪退件。一個 Dart 專案的 controller 欄位持有「本次批次操作的用途」,收尾流程在重設選取狀態之後才讀它,因此那個值必須活過那一次重設。作者為這個約束寫了一行 doc comment,兩個版本都被退:第一版重述型別名稱、第二版寫的是真實存在的生命週期約束。退件理由停在「太程式面」,作者當下無法反駁也無法照做。
轉折點是追問寫它的動機——動機是「怕有人動壞它」。這個動機一旦說出口,判斷的對象就從「這段文字寫得好不好」換成「這個防護需求該送到哪裡」。實測方式是當場把約束破壞掉(在重設函式裡加一行清除),跑整合測試,測試紅了、訊息指出流程停在錯誤的階段。那個防護需求已經有人接手,註解是一份沒有保護力的副本。完整過程見 註解防不了改壞;測試側的教學層展開(建立測試該問什麼、與註解的分工、名稱承載意圖)見 測試的價值發生在它變紅的那一刻。
限制有四項。單一案例:一個語言、一個約束形態(跨函式的讀寫順序),對其他形態的推廣是機制類比。反事實那一支沒有跑:該案例的測試本來就存在,「測試不存在時那段註解值多少」沒有被實測,而那正是「註解沒有防護力」需要的另一支。宣稱因此收在機制層,不延伸成「註解沒有價值」。破壞實測的成本前提:它需要一條跑得動、且失敗訊息可讀的測試流程,沒有這個前提時實測本身就做不了。步驟順序沒有對照:本卡沒有取得順序對照的證據,所以下面主張的是「消除那一步不可跳過」,不是「順序顛倒會出錯」。前三項可回到案例查證,這一項是本卡自己的推論。
核心原則
散文型註解不參與執行,因此改壞的當下不產生任何訊號。 它的作用發生在有人剛好讀到它的時候,而註解寫在宣告處、改壞的動作常常發生在別的函式裡——「順手整理」的人根本不會經過那一行。防護意圖寫成註解,等於把需求送到一個沒有執行權的窗口。
這句話的限定在「散文型」與「不執行註解的工具鏈」。有些生態刻意讓註解參與執行:Rust 的 doc example 由 cargo test 編譯執行、Python 的 doctest 同型,API 改壞時那段註解會編不過。這些是反向的佐證——要讓註解發聲,做法就是讓它被執行。
落點清單有兩條軸,而它們被壓成一條
既有的分層清單通常排成一條強度刻度:註解與命名、介面簽名與型別、建構子與方法內的檢查、資料庫 schema 約束、CI 檢查,越往後違反的代價越難繞過。這條刻度好用,但它把兩件獨立的事壓在一起:
- 規則寫在哪:寫進被約束的產物裡(註解、型別、建構子檢查、schema)還是寫在產物外面(lint 設定、CI 規則、測試檔)。
- 違反時何時發聲:永不(註解、命名)、編譯當下(型別)、寫入當下(建構子檢查、schema 約束)、合併之前(CI)、上線之後(對帳與監控)。
壓成一條的代價是產物外那一側只被看到一格。清單裡代表它的是 CI 檢查,而那一格通常被描述成「把慣例類規則升級成合併前擋下」——裡面列的 lint 與 architecture test 都是讀程式文本的:它們掃的是原始碼長什麼樣,不是程式跑起來會怎樣。
空的是另一格:觀測執行行為的那一種。跨函式的讀寫順序、某個值必須活過某次操作,這類約束在多數主流型別系統裡沒有任何產物內的位置寫得下(Rust 的 lifetime、typestate 這類生態是例外,那裡它們回到產物內),而讀文本的觀測者要守它只能寫成綁死該約束的語法禁令。沿著強度刻度往上找的人會發現每一格都塞不進去,於是被送回刻度的起點——寫一行註解。這正是案例裡實際發生的事,也是這個分類唯一買到、卻很關鍵的東西:它是刻度的終止條件。剩下的那格裡放的是一條普通的行為測試。
處置的兩步
需求被辨識成防護之後,處置分兩步。消除那一步不可跳過(順序顛倒不一定出錯——來源案例正是顛倒著跑完的,結論仍然正確;不可跳過的理由是跳過它會去守一條本來可以不存在的規則)。
第一步問這個約束能不能不存在。 約束通常是某個結構選擇的產物。案例中的「必須活過重設」之所以存在,是因為那個值放在共享可變狀態裡;收尾函式若在確認的當下把值當參數收下,約束就消失了。答「不能消除」時要指得出約束的來源結構(值放在哪、誰改得動)以及消除它要動什麼——這兩項是第三人可查的事實,不是意見。指不出來源結構,代表這一步沒有做過。
第二步把約束交給一個會發聲的機制。 產物內寫得下就寫在產物內——型別在編譯當下發聲、建構子檢查在寫入當下發聲,訊號更早而且無法選擇忽略。產物內每一格都塞不進去的,才交給觀測執行行為的測試。命名是例外項:它出現在每個呼叫點、讀程式的人無法跳過,這讓它是有價值的輔助,但它永不發聲,因此不是防護需求的合法終點。挑了命名就宣稱處置完畢,等於合規地什麼都沒做——而這個結論在 review 裡特別容易被接受:改名是 diff 上看得見的動作、reviewer 有東西可以核可,補一條測試則要多花一輪。兩邊都覺得處置完畢。
判準:這段資訊有沒有對應的斷言
「該不該寫」需要一個可檢查的出口,否則判斷會停在「這段資訊算多還是算少」。三步,每一步的產出都是第三人看得到的物件、不是一個結論:
- 把型別名稱唸出來,說出這段文字還剩哪幾個字。 產出是那個具體字串。型別定義是雙方共同可見的,所以這一步的答案當場可以被反駁;作者列不出剩哪幾個字,該刪的結論就自己成立了。
- 問剩下的資訊有沒有對應的斷言。 二元性掛在「存不存在一個會紅的斷言」,不掛在造句能力——
test('這條路徑刻意不設守衛,因為設了會擋住舊匯入')文法上寫得出來卻沒有斷言,那不算。產出是那句測試名稱的實際字串,而它要通過同一個唸型別測試(只描述輸入輸出、說不出為什麼這個行為成立的名稱,先改名稱)。 - 問這段資訊的來源在不在 repo 裡。 產出是那個來源的具體指名——法條編號、契約條款、決策記錄的位置。指不出來,就代表來源其實不在 repo 外。
第二步與第三步可以同時成立:test('留存不得少於七年') 既有斷言、來源又在 repo 外。此時測試守行為、註解留出處,兩邊各寫各的那一半,不是二選一。
留下來的那類有一個共同點:它們是決策脈絡,不是程式當下的狀態。 程式碼描述現在是什麼樣子,推導不出當初為什麼選這樣。「這條路徑刻意不設守衛」要拆成兩半——「補了守衛會壞掉什麼」有斷言,「當初為什麼選擇不守」沒有。
收斂條件:當場破壞
前面每一步都可以被敷衍地走完——動機答「說明用的」、消除答「不能」、斷言答「有」,三句話就繞過整套判準,而這三個答案都不留痕跡。防護軸因此有一個收斂條件:在違反約束的位置加一行改動,跑測試,把輸出貼出來。
紅了,代表防護已經有人接手,那行註解是副本;沒紅,代表防護確實缺席,而缺的是那條測試不是那行文字。認真做過會有一段實際的失敗輸出,沒做只有一句宣稱,兩者在 review 裡長得不一樣。成本是一次改動加一次測試執行,之後還原。
這一步同時擋住一個更隱蔽的失誤:「有測試」與「那條測試會對這個違反發聲」是兩件事。grep 到一個名字相關的測試就宣稱有守,正是 #221 講的「規則存在」被當成「規則涵蓋」。
刪註解那次變更還有一個 diff 層的痕跡:註解消失而測試名稱一字未動,代表資訊沒有搬家、是蒸發了。
沒這樣做的麻煩
假防護感會讓 review 放行。 一行寫著「重設時不清掉這個值」的註解,讓 reviewer 以為這個約束有人管,而沒有任何機制在管;宣稱本身消滅了發現缺口的機會。
走到這裡出事會長這樣:那個欄位帶著註解通過 review。半年後另一個人在重設函式裡補上清除,那次 diff 不包含註解所在的那一行——reviewer 讀到的是一段合理的清理程式碼,而註解在另一個檔案的另一個位置。缺陷靠使用者回報才浮現,因為沒有任何機制在那條路徑上發聲,而當初的 reviewer 以為有。這與 #222 約束要讓違反路徑走不通 講的「註解宣稱的約束比沒有約束更糟」是同一個機制,差別在 #222 的修法是把約束上移到型別層或執行層,本卡多一個上游步驟與多一類落點。
在文字層打轉的循環沒有終點。 兩輪退件都在討論「這段文字該怎麼寫」,作者照著改,第二版寫的是真約束、仍然被退。問題不在文字裡,所以修得再準確也走不到出口。
沒有被消除的約束會長出後續成本。 約束留著就得有人守,守它的機制要建置、要維護、還要在重構時記得它存在。案例中最後選擇不消除,理由是耦合已經有測試守著、重構的邊際效益不高(那個欄位本身仍得是可被畫面訂閱的狀態,能消除的只有耦合)。那是一次有依據的取捨,不是漏掉這一步。
跟其他抽象層原則的關係
- #222 約束要讓違反路徑走不通:本卡是它的上游加一類落點。#222 處理「意圖已經確定要強制,該落在哪一層」,本卡處理更早的兩個問題——這個意圖是不是防護需求、以及它需不需要被強制。#222 的三層(註解、介面簽名、建構子檢查)都寫在被約束的產物裡,沿「違反時發生什麼」排成一條刻度;本卡指出那條刻度把「規則寫在哪」與「何時發聲」壓成了一條,於是產物外那一側只剩 CI 一格、而那格裡只有讀程式文本的檢查。不變式的強制層次 把同一組拆軸寫進教學層。
- #100 False sense of security 是資安寫作的主要失敗模式:同一失敗模式的另一個 surface。#100 的教學讓讀者以為做了 X 就安全,本卡的註解讓 reviewer 以為約束有人守。共同結構是「宣稱消滅了查證動機」,而消滅查證的代價是靜默累積。
- #67 寫作便利度跟意圖對齊反相關:寫一行註解是所有選項裡最便宜的,而它是唯一連被讀到都不保證的那個。動機是防護時,便利度排序與有效性排序剛好相反——這解釋了為什麼誤送會系統性發生,不需要作者疏忽。
- #221 檢查規則的作用域要顯式列舉:同構於「規則存在」與「規則涵蓋」的分離。本卡的分離是「約束被寫下來」與「約束被守著」,而破壞實測正是把後者從假設變成可觀測的動作。
- #256 多份文件必然漂移:同步期待要嘛有機制承接、要嘛明示降級:本卡的文件層同構。本卡處理單行註解(不參與執行、改壞不發聲、防護需求送錯窗口),#256 把同一個判準抬到整條文件鏈——不被機制守著的文件、同步需求同樣送錯窗口;修法同構:給機制(比對腳本、CI)或降級(scaffold、append-only 記錄),「行為的權威載體是測試」直接沿用本卡的落點選擇。
- #249 對當下段落沒有收益的標註不會自發發生:反向的姊妹。#249 處理該標而沒標(收益落在日後的人身上,於是不發生),本卡處理不該寫卻寫了(收益是作者當下的安心感,於是特別容易發生)。兩者都指向同一個判準來源:標註該不該存在由它的收益結構決定,不由作者的仔細程度決定。
- #277 通過關卡不等於通過的是同一個程式:補一個限制。本卡的判準是「存不存在一條會紅的斷言」,#277 補上「那條斷言涵蓋哪些維度」——斷言存在而維度之間沒有交叉時,保護是名義上的。
判讀徵兆
| 徵兆 | 該做的行動 |
|---|---|
| 說得出「寫這行是怕有人改壞」 | 走防護分支:先問約束能不能消除、不能消除時交給哪個會發聲的機制 |
| 動機說不出來、或答案聽起來是防衛性的 | 不必追動機,直接跑破壞實測——它不需要知道任何人的動機 |
| 動機是混合的(既想說明也怕被改壞) | 兩個分支不互斥。先把防護那半搬走,剩下的說明部分回到第一步重判 |
| 註解描述的是跨函式的讀寫順序、生命週期、某狀態必須活過某次操作 | 這類約束產物內多半寫不下,去找對應的行為測試;測試不存在就補測試而不是補註解 |
| 同一段註解改了兩版仍被退、每次退件理由都在文字層 | 判斷還停在文字層,換成問這行想守什麼約束 |
| 處置結論是「改個好名字」 | 命名不會自動發聲,不是防護需求的終點 |
| 「有測試守著」這個說法沒有附上失敗輸出 | 那是宣稱不是證據,跑一次破壞實測 |
前四列作者在寫的當下自查就取得到,後三列的觀察位置在 review。第五列還要累積兩輪退件才成立,而第一列在第一輪就問得出同一個判斷——走到第五列,代表第一列沒有被問。
第一項成本最低,也是唯一不需要讀程式就能執行的:在 review 裡問一句「這行是為了說明,還是為了防止有人改壞」。動機是便宜的入口啟發式,問不出來就走破壞實測那條路。
適用範圍與邊界
- 適用:有測試覆蓋的應用程式碼、跨函式或跨時間的約束、code review 中關於「該不該寫這行註解」的爭議。
- 邊界:
- 沒有任何會紅的機制的 surface:設定檔、schema 定義檔、build script、DSL。這些位置註解是唯一的落點,本卡的判準不適用。這一條看得出來(檔案類型是客觀事實),不需要舉證。
- 給下游看的公開 API:下游讀不到專案內部的測試檔,doc 是唯一的契約介面。此時 doc 的職責是契約而非防護,判準回到契約完整性。這一條同樣看得出來。
- 沒有測試文化的 repo:整條推論預設「有一條會紅的測試」。前提不成立時刪掉註解是淨損失——先讓測試存在,再刪註解。判斷自己在不在這一條裡,用的是同一個動作:對這個約束跑一次破壞實測。改壞之後沒有指令可跑、跑得動但這條路徑沒有覆蓋、或失敗訊息讀不出停在哪一步,三種都算。這一條是暫時狀態而非豁免,而記錄那個暫時狀態的地方不該又是一行註解——那正是本卡在講的形態。把它放到會發聲的位置:一條 skip 掉的測試(測試報告每次都會列出它)、或模組待辦清單的一列(可被 grep 盤點)。
- 不是邊界、是另一個問題:註解在 IDE hover 時把定義帶到呼叫點、省一次跳轉。這條價值與防護無關,它有自己的判準(唸完型別名稱後還剩哪幾個字),案例中第一版正是在這一軸被退的。把它當成防護判準的豁免會讓兩軸混在一起,退件理由因此講不清楚。