<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Code-Review on Tarragon</title><link>https://tarrragon.github.io/blog/tags/code-review/</link><description>Recent content in Code-Review on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 07 Aug 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/code-review/index.xml" rel="self" type="application/rss+xml"/><item><title>註解防不了改壞——防護需求要交給會發聲的機制</title><link>https://tarrragon.github.io/blog/work-log/comment_cannot_guard_invariant/</link><pubDate>Fri, 07 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/comment_cannot_guard_invariant/</guid><description>&lt;h2 id="案例的形狀">案例的形狀&lt;/h2>
&lt;p>一個批次操作：使用者先勾選多個項目，按確認後合而為一。它有兩個入口按鈕，差別在&lt;strong>合併完成後要做什麼&lt;/strong>——一種是回到編輯流程繼續處理，另一種是直接進入下一階段。用途在按下入口按鈕的當下就決定了：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">enum&lt;/span> &lt;span class="n">ActionPurpose&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="c1">/// 合併後回到編輯流程繼續處理
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">continueEditing&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="c1">/// 合併後直接進入下一階段
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">finishImmediately&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>持有它的欄位帶著這行 doc comment（宣告前方那段給外部閱讀者與 IDE 消費的註解）進了 review：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">/// 當次操作的用途，由進入該模式的入口設定
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">Rx&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">ActionPurpose&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">actionPurpose&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ActionPurpose&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">continueEditing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Rx&amp;lt;&amp;gt;&lt;/code> 表示這個值可被畫面訂閱：它一變，顯示它的元件就重繪。&lt;/p>
&lt;h2 id="第一輪退件查詢成本軸">第一輪退件：查詢成本軸&lt;/h2>
&lt;p>第一輪的退件理由是「這個註解是什麼意思」。把自己放在讀者的位置實際走一遍——跳到這行、想從註解拿到資訊，逐字檢查它給了什麼。&lt;/p>
&lt;p>「由進入該模式的入口設定」——「入口」指什麼？在程式裡搜 &lt;code>入口&lt;/code>、搜 &lt;code>entry&lt;/code>，找不到任何識別符。實際的進入點是兩個按鈕的 handler：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 按鈕一：合併後回到編輯流程
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nl">onPressed:&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">controller&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">enterBatchMode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ActionPurpose&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">continueEditing&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 按鈕二：合併後直接進入下一階段
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nl">onPressed:&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">controller&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">enterBatchMode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ActionPurpose&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">finishImmediately&lt;/span>&lt;span class="p">),&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>對照這段程式碼就看得出落差：註解若寫「由 &lt;code>enterBatchMode&lt;/code> 的呼叫端設定」，讀者搜這個識別符、一次就落在這兩行；寫「入口」，這個詞只存在於註解裡，讀者得自己猜它對應程式的哪個部分。用程式裡不存在的詞描述程式，註解就跟程式斷了線。&lt;/p>
&lt;p>「由誰設定」這個資訊本身呢？搜 &lt;code>actionPurpose&lt;/code> 的寫入點，同樣直接落在這兩行——不靠註解也是 grep 一下就有，註解沒有省下任何查詢。&lt;/p>
&lt;p>剩下「當次操作的用途」——回頭看 &lt;code>ActionPurpose&lt;/code> 的宣告，兩個值的 doc 已經把「合併後回到編輯流程」「合併後直接進入下一階段」寫完了，「用途」只是把型別名稱再唸一次。&lt;/p>
&lt;p>走完這三步，一個檢查方式自然浮現：&lt;strong>把型別名稱唸出來，doc 還剩下什麼資訊。&lt;/strong> 這裡唸完 &lt;code>ActionPurpose&lt;/code>，剩下的字沒有一個是讀者拿得走的。&lt;/p>
&lt;p>這一輪的問題都出在&lt;strong>查詢成本&lt;/strong>：讀者跳到這行想拿到資訊，拿到的是零。第二輪退的則是防護力，兩條軸分開看才不會混淆。&lt;/p>
&lt;h2 id="第二輪退件宣稱真實約束的註解">第二輪退件：宣稱真實約束的註解&lt;/h2>
&lt;p>第二版改寫成型別說不出來的部分——這個值的生命週期：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">/// 當次操作的用途，見 [ActionPurpose]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">///
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="o">///&lt;/span> &lt;span class="err">收尾動作在選取狀態重設之後才讀取，因此重設選取時不清掉這個值。&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個約束是真的。程式的順序是：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">確認執行
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> → 呼叫 API
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> → 同步各層狀態
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> → resetSelection() ← 清掉所有選取狀態
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> → finishAction() ← 在這裡才讀 actionPurpose&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>任何人想「順手整理」把 &lt;code>actionPurpose&lt;/code> 加進 &lt;code>resetSelection&lt;/code> 的清除清單，第二種用途就會靜默走成第一種——不報錯、不彈窗，只是使用者按了 B 卻得到 A 的結果。&lt;/p>
&lt;p>第二版仍然被退，理由是：這個解釋停在程式面，沒有商業邏輯面的內容；沒有的話不該寫註解。&lt;/p>
&lt;h2 id="動機辨識這行註解在防什麼">動機辨識：這行註解在防什麼&lt;/h2>
&lt;p>第二輪退件把評估往上推了一層。檢視一則註解時要評估的是：&lt;strong>它有沒有解釋到這個行為、這個事件、或這個 flag 的商業邏輯&lt;/strong>。用這個標準看第二版——「收尾動作在選取狀態重設之後才讀取，因此重設選取時不清掉這個值」——整句都停在程式面：它描述讀寫順序、描述一個技術上的因果，沒有一個字回答「為什麼要有這條規則」。解釋不到商業邏輯的註解，就要重新檢討寫它的動機。&lt;/p>
&lt;p>往動機追下去，這種註解的動機是&lt;strong>防護&lt;/strong>：怕有人動壞那個順序。&lt;/p>
&lt;p>而註解的作用只發生在有人剛好讀到它的時候。它不參與執行，改壞的當下沒有訊號產生。真正會在改壞當下發出訊號的機制是測試。&lt;/p>
&lt;p>所以問題從「這段文字該怎麼寫」換成：這個約束有沒有被測試守著？&lt;/p>
&lt;h2 id="實測故意破壞它">實測：故意破壞它&lt;/h2>
&lt;p>驗證方式是當場把它破壞掉。在重設選取狀態的地方加一行清除：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="n">resetSelection&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="n">selectedIds&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">clear&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="n">actionPurpose&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ActionPurpose&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">continueEditing&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 故意破壞
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跑那條流程的整合測試：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">+2 -1: 直接進入下一階段：不做編輯流程的收尾 [E]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> Expected: Step:&amp;lt;Step.finished&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> Actual: Step:&amp;lt;Step.editing&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>紅了，而且訊息直指症狀：該進到下一階段卻停在編輯階段。這條推論的前提是有一條會紅、而且會自動跑的測試——該專案的整合測試掛在 pre-commit 上，改壞的人在 commit 之前就會知道。&lt;/p>
&lt;p>失敗訊息只給症狀，拿到紅燈的人還要逆推「為什麼這個順序是刻意的」。承擔這個「為什麼」的是&lt;strong>測試名稱&lt;/strong>：「直接進入下一階段：不做編輯流程的收尾」把意圖寫在那裡了。&lt;/p>
&lt;p>那段註解是一份&lt;strong>沒有保護力的副本&lt;/strong>——它重複了測試已經在守的事，代價是佔用讀者最靠近程式碼的注意力。處置是還原破壞、刪掉整段 doc，欄位留白。&lt;/p>
&lt;hr>
&lt;h2 id="判準把剩下的資訊寫成一句測試名稱">判準：把剩下的資訊寫成一句測試名稱&lt;/h2>
&lt;p>&lt;strong>第一步&lt;/strong>：把型別名稱唸出來，doc 還剩什麼資訊？剩不下就刪——那是型別已經講完的話。&lt;/p>
&lt;p>&lt;strong>第二步&lt;/strong>：剩下的資訊，問有沒有一條會紅的斷言撐得住——標準是斷言存不存在，不是造不造得出句子。兩個出口：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>寫得出來&lt;/strong>（本案例的「直接進入下一階段：不做編輯流程的收尾」）→ 那句話的家是測試名稱，不是 doc。去確認測試存在，並且名稱說得出這件事為什麼成立；名稱說不出來就先改測試名稱，改完仍說不出來才考慮留註解。&lt;/li>
&lt;li>&lt;strong>要寫出來必須引用 repo 之外的事實&lt;/strong>（法規、稽核要求、後端契約、當初的取捨）→ 測試名稱裝不下那個來源，這行才是 doc 該留的。&lt;/li>
&lt;/ul>
&lt;p>任一步判定為「程式碼自己講得出來」就整段刪除。&lt;/p>
&lt;p>這一步把「剩一點點資訊怎麼辦」變成可執行的動作：不用估量資訊多寡，直接問有沒有一條會紅的斷言撐得住，而斷言存不存在是二元的。它把 &lt;a href="https://tarrragon.github.io/blog/record/comment-writing-methodology/" data-link-title="註解保護需求，不解釋程式" data-link-desc="註解寫成函式名稱的重述、或改動一段程式時查不到當初的業務約束時，用來判斷哪些內容該進註解、哪些該進命名或文件">程式碼註解撰寫方法論&lt;/a> 的「移除註解後仍能理解程式邏輯」收窄了一次——把「讀者能不能理解」換成「有沒有機制接手」，而這兩者只在有測試的 repo 裡才會分岔。&lt;/p>
&lt;hr>
&lt;h2 id="落點的優先序">落點的優先序&lt;/h2>
&lt;p>「這件事有沒有辦法讓程式自己講」——照這個順序試。&lt;/p>
&lt;h3 id="優先序之前的-gate這個約束能不能不存在">優先序之前的 gate：這個約束能不能不存在&lt;/h3>
&lt;p>這個案例裡，「必須活過重設」這個約束之所以存在，是因為用途被放在&lt;strong>共享可變狀態&lt;/strong>裡——值存在一個多方都讀得到、任一方都改得動的地方。如果收尾函式在確認的當下就把用途當參數收下：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">purpose&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">actionPurpose&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 確認當下捕捉
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="n">resetSelection&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="kd">await&lt;/span> &lt;span class="n">finishAction&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">purpose:&lt;/span> &lt;span class="n">purpose&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="o">//&lt;/span> &lt;span class="err">不再依賴共享狀態&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>那個約束就&lt;strong>不存在了&lt;/strong>，不需要測試守、也不需要註解講。&lt;/p>
&lt;p>該案例最後選擇不重構，理由是收尾路徑的耦合已經有測試守著，重構的邊際效益不高。那個欄位本身仍得是可被畫面訂閱的狀態（確認鈕文案也要讀它來決定顯示哪一種），所以這個重構能消除的是耦合、不是那個欄位。但&lt;strong>先問「這個約束能不能被消除」再問「該用什麼守它」&lt;/strong>：約束被消除之後，下面那張表都不必看。&lt;/p>
&lt;h3 id="約束留下來之後訊號離改壞有多近">約束留下來之後：訊號離改壞有多近&lt;/h3>
&lt;p>軸是「改壞的當下，訊號多快出現、能不能被忽略」：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>手段&lt;/th>
 &lt;th>訊號出現的時機&lt;/th>
 &lt;th>裝得下的約束&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>型別&lt;/td>
 &lt;td>編譯當下，無法忽略&lt;/td>
 &lt;td>值的合法範圍、狀態機、不允許的欄位組合&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試&lt;/td>
 &lt;td>跑測試時（該專案在 pre-commit）&lt;/td>
 &lt;td>讀寫順序、時序耦合、跨時間必須恆真的條件（&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">不變式&lt;/a>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>命名&lt;/td>
 &lt;td>每個呼叫點都會被讀到&lt;/td>
 &lt;td>意圖、單位、所有權&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>註解&lt;/td>
 &lt;td>只有剛好被讀到時&lt;/td>
 &lt;td>以上都裝不下的&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>型別排在測試前面，因為編譯期的訊號更早、而且無法選擇忽略。但型別裝得下的範圍窄得多，而判法是問「違反這個約束的程式碼，寫得出來嗎」——「不該是負數」用一個非負型別就寫不出來，型別裝得下；「必須活過某一次重設」寫得出來、而且編譯得過，型別裝不下。這一問在設計當下就答得出，不必等落地。本案例的約束屬於後者，所以它的家是測試而不是型別。&lt;/p>
&lt;p>這條分界跟 &lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a> 的型別層 / 執行層判準同源。那章的五個位置沿強度排列，而強度其實混了兩件事——規則寫在被約束的產物裡還是外面、以及違反時何時發聲。測試寫在產物外面、在跑測試時發聲；跨函式的讀寫順序在產物內沒有位置寫得下，沿強度往上找會一格一格落空。&lt;/p>
&lt;p>命名不會自動發聲，但它出現在每一個呼叫點、讀程式的人無法跳過。&lt;code>purposeSurvivingReset&lt;/code> 這種名字守不住任何約束，卻讓「順手整理」的人在動手前多看一眼。註解連這一點都做不到，它是可以整段跳過的。&lt;/p>
&lt;p>同一組手段換一條軸排，順序會不一樣。&lt;a href="https://tarrragon.github.io/blog/record/function-doc-layered-design/" data-link-title="函式文件分層設計：型別、介面、實作各自該寫什麼" data-link-desc="函式文件分層設計：把資訊放在能表達它的最低層次（名稱 / 型別簽章 / 介面 doc / 實作 doc / 範例與測試）、上層留給「下層表達不了的剩餘」。整理各層該寫什麼、容易誤入的內容、配合反模式列表跟寫 doc 前 checklist 收斂出短而精準的文件。">函式文件分層設計&lt;/a> 把「範例與測試」放在最下層，因為它排的是&lt;strong>閱讀成本&lt;/strong>——讀者最後才會跳去看測試。這裡把測試放最前，因為排的是&lt;strong>改壞時會不會發聲&lt;/strong>。要用哪一條，取決於當下要解的是「讀者查得快不快」還是「改壞的人擋不擋得住」。&lt;/p>
&lt;hr>
&lt;h2 id="那什麼時候該寫註解">那什麼時候該寫註解&lt;/h2>
&lt;p>刪掉的是「程式自己講得出來」的那類。剩下這些仍然只有註解能講：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>業務動機&lt;/strong>：為什麼有這條規則。例如「失敗的任務保留不清除」——因為稽核要看得到失敗紀錄。程式只看得到條件式，看不到這個理由。&lt;/li>
&lt;li>&lt;strong>來自系統外的約束&lt;/strong>：後端契約的已知行為、硬體限制、法規要求。這些不在 repo 裡，讀者無處查證。&lt;/li>
&lt;li>&lt;strong>刻意不做的事&lt;/strong>：某條路徑刻意不設守衛的理由。這一項要拆成兩半——「補了守衛會壞掉什麼」寫得成測試，交給測試守；「當初為什麼選擇不守」寫不成測試，那才是註解的內容。&lt;/li>
&lt;/ul>
&lt;p>共同點是：&lt;strong>這些資訊是決策脈絡，不是程式當下的狀態&lt;/strong>。程式碼描述的是現在是什麼樣子，推導不出當初為什麼選這樣。凡是能從程式碼推導出來的，就交給程式碼講。&lt;/p></description><content:encoded><![CDATA[<h2 id="案例的形狀">案例的形狀</h2>
<p>一個批次操作：使用者先勾選多個項目，按確認後合而為一。它有兩個入口按鈕，差別在<strong>合併完成後要做什麼</strong>——一種是回到編輯流程繼續處理，另一種是直接進入下一階段。用途在按下入口按鈕的當下就決定了：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">enum</span> <span class="n">ActionPurpose</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">/// 合併後回到編輯流程繼續處理
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="n">continueEditing</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c1">/// 合併後直接進入下一階段
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="n">finishImmediately</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>持有它的欄位帶著這行 doc comment（宣告前方那段給外部閱讀者與 IDE 消費的註解）進了 review：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">/// 當次操作的用途，由進入該模式的入口設定
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">Rx</span><span class="o">&lt;</span><span class="n">ActionPurpose</span><span class="o">&gt;</span> <span class="n">actionPurpose</span> <span class="o">=</span> <span class="n">ActionPurpose</span><span class="p">.</span><span class="n">continueEditing</span><span class="p">.</span><span class="n">obs</span><span class="p">;</span></span></span></code></pre></div><p><code>Rx&lt;&gt;</code> 表示這個值可被畫面訂閱：它一變，顯示它的元件就重繪。</p>
<h2 id="第一輪退件查詢成本軸">第一輪退件：查詢成本軸</h2>
<p>第一輪的退件理由是「這個註解是什麼意思」。把自己放在讀者的位置實際走一遍——跳到這行、想從註解拿到資訊，逐字檢查它給了什麼。</p>
<p>「由進入該模式的入口設定」——「入口」指什麼？在程式裡搜 <code>入口</code>、搜 <code>entry</code>，找不到任何識別符。實際的進入點是兩個按鈕的 handler：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 按鈕一：合併後回到編輯流程
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nl">onPressed:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="n">controller</span><span class="p">.</span><span class="n">enterBatchMode</span><span class="p">(</span><span class="n">ActionPurpose</span><span class="p">.</span><span class="n">continueEditing</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 按鈕二：合併後直接進入下一階段
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="nl">onPressed:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="n">controller</span><span class="p">.</span><span class="n">enterBatchMode</span><span class="p">(</span><span class="n">ActionPurpose</span><span class="p">.</span><span class="n">finishImmediately</span><span class="p">),</span></span></span></code></pre></div><p>對照這段程式碼就看得出落差：註解若寫「由 <code>enterBatchMode</code> 的呼叫端設定」，讀者搜這個識別符、一次就落在這兩行；寫「入口」，這個詞只存在於註解裡，讀者得自己猜它對應程式的哪個部分。用程式裡不存在的詞描述程式，註解就跟程式斷了線。</p>
<p>「由誰設定」這個資訊本身呢？搜 <code>actionPurpose</code> 的寫入點，同樣直接落在這兩行——不靠註解也是 grep 一下就有，註解沒有省下任何查詢。</p>
<p>剩下「當次操作的用途」——回頭看 <code>ActionPurpose</code> 的宣告，兩個值的 doc 已經把「合併後回到編輯流程」「合併後直接進入下一階段」寫完了，「用途」只是把型別名稱再唸一次。</p>
<p>走完這三步，一個檢查方式自然浮現：<strong>把型別名稱唸出來，doc 還剩下什麼資訊。</strong> 這裡唸完 <code>ActionPurpose</code>，剩下的字沒有一個是讀者拿得走的。</p>
<p>這一輪的問題都出在<strong>查詢成本</strong>：讀者跳到這行想拿到資訊，拿到的是零。第二輪退的則是防護力，兩條軸分開看才不會混淆。</p>
<h2 id="第二輪退件宣稱真實約束的註解">第二輪退件：宣稱真實約束的註解</h2>
<p>第二版改寫成型別說不出來的部分——這個值的生命週期：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">/// 當次操作的用途，見 [ActionPurpose]
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">///
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="o">///</span> <span class="err">收尾動作在選取狀態重設之後才讀取，因此重設選取時不清掉這個值。</span></span></span></code></pre></div><p>這個約束是真的。程式的順序是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">確認執行
</span></span><span class="line"><span class="ln">2</span><span class="cl">  → 呼叫 API
</span></span><span class="line"><span class="ln">3</span><span class="cl">  → 同步各層狀態
</span></span><span class="line"><span class="ln">4</span><span class="cl">  → resetSelection()   ← 清掉所有選取狀態
</span></span><span class="line"><span class="ln">5</span><span class="cl">  → finishAction()     ← 在這裡才讀 actionPurpose</span></span></code></pre></div><p>任何人想「順手整理」把 <code>actionPurpose</code> 加進 <code>resetSelection</code> 的清除清單，第二種用途就會靜默走成第一種——不報錯、不彈窗，只是使用者按了 B 卻得到 A 的結果。</p>
<p>第二版仍然被退，理由是：這個解釋停在程式面，沒有商業邏輯面的內容；沒有的話不該寫註解。</p>
<h2 id="動機辨識這行註解在防什麼">動機辨識：這行註解在防什麼</h2>
<p>第二輪退件把評估往上推了一層。檢視一則註解時要評估的是：<strong>它有沒有解釋到這個行為、這個事件、或這個 flag 的商業邏輯</strong>。用這個標準看第二版——「收尾動作在選取狀態重設之後才讀取，因此重設選取時不清掉這個值」——整句都停在程式面：它描述讀寫順序、描述一個技術上的因果，沒有一個字回答「為什麼要有這條規則」。解釋不到商業邏輯的註解，就要重新檢討寫它的動機。</p>
<p>往動機追下去，這種註解的動機是<strong>防護</strong>：怕有人動壞那個順序。</p>
<p>而註解的作用只發生在有人剛好讀到它的時候。它不參與執行，改壞的當下沒有訊號產生。真正會在改壞當下發出訊號的機制是測試。</p>
<p>所以問題從「這段文字該怎麼寫」換成：這個約束有沒有被測試守著？</p>
<h2 id="實測故意破壞它">實測：故意破壞它</h2>
<p>驗證方式是當場把它破壞掉。在重設選取狀態的地方加一行清除：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kt">void</span> <span class="n">resetSelection</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="n">selectedIds</span><span class="p">.</span><span class="n">clear</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">actionPurpose</span><span class="p">.</span><span class="n">value</span> <span class="o">=</span> <span class="n">ActionPurpose</span><span class="p">.</span><span class="n">continueEditing</span><span class="p">;</span>  <span class="c1">// 故意破壞
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>跑那條流程的整合測試：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">+2 -1: 直接進入下一階段：不做編輯流程的收尾 [E]
</span></span><span class="line"><span class="ln">2</span><span class="cl">  Expected: Step:&lt;Step.finished&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">    Actual: Step:&lt;Step.editing&gt;</span></span></code></pre></div><p>紅了，而且訊息直指症狀：該進到下一階段卻停在編輯階段。這條推論的前提是有一條會紅、而且會自動跑的測試——該專案的整合測試掛在 pre-commit 上，改壞的人在 commit 之前就會知道。</p>
<p>失敗訊息只給症狀，拿到紅燈的人還要逆推「為什麼這個順序是刻意的」。承擔這個「為什麼」的是<strong>測試名稱</strong>：「直接進入下一階段：不做編輯流程的收尾」把意圖寫在那裡了。</p>
<p>那段註解是一份<strong>沒有保護力的副本</strong>——它重複了測試已經在守的事，代價是佔用讀者最靠近程式碼的注意力。處置是還原破壞、刪掉整段 doc，欄位留白。</p>
<hr>
<h2 id="判準把剩下的資訊寫成一句測試名稱">判準：把剩下的資訊寫成一句測試名稱</h2>
<p><strong>第一步</strong>：把型別名稱唸出來，doc 還剩什麼資訊？剩不下就刪——那是型別已經講完的話。</p>
<p><strong>第二步</strong>：剩下的資訊，問有沒有一條會紅的斷言撐得住——標準是斷言存不存在，不是造不造得出句子。兩個出口：</p>
<ul>
<li><strong>寫得出來</strong>（本案例的「直接進入下一階段：不做編輯流程的收尾」）→ 那句話的家是測試名稱，不是 doc。去確認測試存在，並且名稱說得出這件事為什麼成立；名稱說不出來就先改測試名稱，改完仍說不出來才考慮留註解。</li>
<li><strong>要寫出來必須引用 repo 之外的事實</strong>（法規、稽核要求、後端契約、當初的取捨）→ 測試名稱裝不下那個來源，這行才是 doc 該留的。</li>
</ul>
<p>任一步判定為「程式碼自己講得出來」就整段刪除。</p>
<p>這一步把「剩一點點資訊怎麼辦」變成可執行的動作：不用估量資訊多寡，直接問有沒有一條會紅的斷言撐得住，而斷言存不存在是二元的。它把 <a href="/blog/record/comment-writing-methodology/" data-link-title="註解保護需求，不解釋程式" data-link-desc="註解寫成函式名稱的重述、或改動一段程式時查不到當初的業務約束時，用來判斷哪些內容該進註解、哪些該進命名或文件">程式碼註解撰寫方法論</a> 的「移除註解後仍能理解程式邏輯」收窄了一次——把「讀者能不能理解」換成「有沒有機制接手」，而這兩者只在有測試的 repo 裡才會分岔。</p>
<hr>
<h2 id="落點的優先序">落點的優先序</h2>
<p>「這件事有沒有辦法讓程式自己講」——照這個順序試。</p>
<h3 id="優先序之前的-gate這個約束能不能不存在">優先序之前的 gate：這個約束能不能不存在</h3>
<p>這個案例裡，「必須活過重設」這個約束之所以存在，是因為用途被放在<strong>共享可變狀態</strong>裡——值存在一個多方都讀得到、任一方都改得動的地方。如果收尾函式在確認的當下就把用途當參數收下：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">purpose</span> <span class="o">=</span> <span class="n">actionPurpose</span><span class="p">.</span><span class="n">value</span><span class="p">;</span>   <span class="c1">// 確認當下捕捉
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="p">...</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">resetSelection</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="kd">await</span> <span class="n">finishAction</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="nl">purpose:</span> <span class="n">purpose</span><span class="p">);</span>   <span class="o">//</span> <span class="err">不再依賴共享狀態</span></span></span></code></pre></div><p>那個約束就<strong>不存在了</strong>，不需要測試守、也不需要註解講。</p>
<p>該案例最後選擇不重構，理由是收尾路徑的耦合已經有測試守著，重構的邊際效益不高。那個欄位本身仍得是可被畫面訂閱的狀態（確認鈕文案也要讀它來決定顯示哪一種），所以這個重構能消除的是耦合、不是那個欄位。但<strong>先問「這個約束能不能被消除」再問「該用什麼守它」</strong>：約束被消除之後，下面那張表都不必看。</p>
<h3 id="約束留下來之後訊號離改壞有多近">約束留下來之後：訊號離改壞有多近</h3>
<p>軸是「改壞的當下，訊號多快出現、能不能被忽略」：</p>
<table>
  <thead>
      <tr>
          <th>手段</th>
          <th>訊號出現的時機</th>
          <th>裝得下的約束</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>型別</td>
          <td>編譯當下，無法忽略</td>
          <td>值的合法範圍、狀態機、不允許的欄位組合</td>
      </tr>
      <tr>
          <td>測試</td>
          <td>跑測試時（該專案在 pre-commit）</td>
          <td>讀寫順序、時序耦合、跨時間必須恆真的條件（<a href="/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">不變式</a>）</td>
      </tr>
      <tr>
          <td>命名</td>
          <td>每個呼叫點都會被讀到</td>
          <td>意圖、單位、所有權</td>
      </tr>
      <tr>
          <td>註解</td>
          <td>只有剛好被讀到時</td>
          <td>以上都裝不下的</td>
      </tr>
  </tbody>
</table>
<p>型別排在測試前面，因為編譯期的訊號更早、而且無法選擇忽略。但型別裝得下的範圍窄得多，而判法是問「違反這個約束的程式碼，寫得出來嗎」——「不該是負數」用一個非負型別就寫不出來，型別裝得下；「必須活過某一次重設」寫得出來、而且編譯得過，型別裝不下。這一問在設計當下就答得出，不必等落地。本案例的約束屬於後者，所以它的家是測試而不是型別。</p>
<p>這條分界跟 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a> 的型別層 / 執行層判準同源。那章的五個位置沿強度排列，而強度其實混了兩件事——規則寫在被約束的產物裡還是外面、以及違反時何時發聲。測試寫在產物外面、在跑測試時發聲；跨函式的讀寫順序在產物內沒有位置寫得下，沿強度往上找會一格一格落空。</p>
<p>命名不會自動發聲，但它出現在每一個呼叫點、讀程式的人無法跳過。<code>purposeSurvivingReset</code> 這種名字守不住任何約束，卻讓「順手整理」的人在動手前多看一眼。註解連這一點都做不到，它是可以整段跳過的。</p>
<p>同一組手段換一條軸排，順序會不一樣。<a href="/blog/record/function-doc-layered-design/" data-link-title="函式文件分層設計：型別、介面、實作各自該寫什麼" data-link-desc="函式文件分層設計：把資訊放在能表達它的最低層次（名稱 / 型別簽章 / 介面 doc / 實作 doc / 範例與測試）、上層留給「下層表達不了的剩餘」。整理各層該寫什麼、容易誤入的內容、配合反模式列表跟寫 doc 前 checklist 收斂出短而精準的文件。">函式文件分層設計</a> 把「範例與測試」放在最下層，因為它排的是<strong>閱讀成本</strong>——讀者最後才會跳去看測試。這裡把測試放最前，因為排的是<strong>改壞時會不會發聲</strong>。要用哪一條，取決於當下要解的是「讀者查得快不快」還是「改壞的人擋不擋得住」。</p>
<hr>
<h2 id="那什麼時候該寫註解">那什麼時候該寫註解</h2>
<p>刪掉的是「程式自己講得出來」的那類。剩下這些仍然只有註解能講：</p>
<ul>
<li><strong>業務動機</strong>：為什麼有這條規則。例如「失敗的任務保留不清除」——因為稽核要看得到失敗紀錄。程式只看得到條件式，看不到這個理由。</li>
<li><strong>來自系統外的約束</strong>：後端契約的已知行為、硬體限制、法規要求。這些不在 repo 裡，讀者無處查證。</li>
<li><strong>刻意不做的事</strong>：某條路徑刻意不設守衛的理由。這一項要拆成兩半——「補了守衛會壞掉什麼」寫得成測試，交給測試守；「當初為什麼選擇不守」寫不成測試，那才是註解的內容。</li>
</ul>
<p>共同點是：<strong>這些資訊是決策脈絡，不是程式當下的狀態</strong>。程式碼描述的是現在是什麼樣子，推導不出當初為什麼選這樣。凡是能從程式碼推導出來的，就交給程式碼講。</p>
<p>這三項是用「有沒有測試接手」篩過一遍的結果。<a href="/blog/record/types-replacing-docs/" data-link-title="型別取代 doc 的收益曲線：強型別語言的 doc 該有多短" data-link-desc="型別系統強化等於 doc 表達力轉移——很多 doc 內容應該下移到型別。整理 null safety / enum / wrapper / Result / typestate 各能消除哪類 doc、型別表達不了的剩餘部分（業務動機、性能、副作用、時序）以及收益曲線的邊際遞減點。">型別取代 doc 的收益曲線</a> 列的那份「型別表達不了的剩餘」有六項（業務動機、性能特性、對外部系統的副作用、時序契約、使用情境限制、跨方法不變式），其中時序契約與跨方法不變式在這一輪篩選裡移到了測試名下，剩下的才是註解的。</p>
<hr>
<h2 id="這條判準吃什麼前提">這條判準吃什麼前提</h2>
<p>整條推論吃的是<strong>專案有一條 pre-commit 會跑的整合測試</strong>。同一個判準搬到 <code>pubspec.yaml</code> 或 build script 上就不成立，那裡沒有任何會紅的機制。給下游用的 package API 同理，下游 clone 不到專案內部的測試檔，doc 在那裡是契約介面而不是防護。</p>
<p>三條失效邊界（無測試的 surface、公開 API、沒有測試文化的 repo）整理在 <a href="/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253</a>。那張卡另外把查詢成本軸單獨列出來——它是另一條軸，有自己的判準，算進失效邊界會讓兩軸混在一起。</p>
<hr>
<h2 id="這條規則會逼出更好的結構">這條規則會逼出更好的結構</h2>
<p>想寫註解的衝動是一個訊號。防護動機順著「能不能讓程式自己講」問下去，走到的是「這裡缺一個測試」跟「這個狀態不該共享」——兩者都比一行註解有價值。</p>
<p>把註解當成最後手段，等於強迫自己每次都先把結構、型別、測試、命名走過一遍。</p>
<hr>
<h2 id="下一步">下一步</h2>
<ul>
<li><strong>原則層</strong>：本文的判準抽成 <a href="/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253 寫註解的動機是怕被改壞時，要處理的是那個約束</a>。那張卡處理動機辨識、消除 gate 與落點選擇的一般形態，並把失效邊界跟查詢成本軸分開。</li>
<li><strong>doc 內容往型別下移</strong>：<a href="/blog/record/types-replacing-docs/" data-link-title="型別取代 doc 的收益曲線：強型別語言的 doc 該有多短" data-link-desc="型別系統強化等於 doc 表達力轉移——很多 doc 內容應該下移到型別。整理 null safety / enum / wrapper / Result / typestate 各能消除哪類 doc、型別表達不了的剩餘部分（業務動機、性能、副作用、時序）以及收益曲線的邊際遞減點。">型別取代 doc 的收益曲線</a> 給的是「這條 doc 能不能變成型別」的三個 review 問題，接在本文第一步之後。</li>
<li><strong>消除約束那一步的上游</strong>：<code>Rx&lt;&gt;</code> 那個欄位為什麼得是共享狀態、什麼時候該收成參數，見 <a href="/blog/ddd/knowledge-cards/shared-mutable-state/" data-link-title="Shared Mutable State（共享可變狀態）" data-link-desc="判斷一個值該不該放在多方都讀得到、都改得動的位置時使用。共享可變狀態會長出跨函式的時序約束，而那類約束在型別與建構子檢查裡都沒有位置寫得下。">Shared Mutable State</a>——那張卡處理「可被觀察」與「可被寫入」為什麼是兩個獨立決定，本文只用到它的結論。</li>
<li><strong>測試側的展開</strong>：從防護視角看測試的建立意義、變更時的作用、跟註解的分工與測試自身的文字，見 <a href="/blog/testing/05-test-design-judgment/test-as-change-guard/" data-link-title="測試的價值發生在它變紅的那一刻——建立、變更與註解分工的防護視角" data-link-desc="為一段邏輯決定第一條測試該測什麼、變更或重構前評估既有測試會擋什麼、review 裡爭論某個約束該寫註解還是測試、或拿到紅燈要判讀它是刻意規則還是漏網 bug 時使用。這些問題共用同一個視角：測試的價值在未來的紅燈、不在當下的綠燈。">測試的價值發生在它變紅的那一刻</a>——本文的案例在那裡是教學層的推導材料。</li>
</ul>
]]></content:encoded></item></channel></rss>