domain: rust 規則
非同步必須全面一致 [R:async-all-the-way]
部分由鉤子
no-block-on-in-async強制執行;其他在非同步程式碼中阻塞執行執行緒的方式——如同步檔案與網路 I/O、跨await持有的std::sync鎖、未交給spawn_blocking的長時間 CPU 區段——以及src/樹之外的任何 Rust 檔案,皆由此指令單獨約束。
非同步程式碼中禁止使用 block_on。非同步執行緒將許多任務複用至少數執行緒上,因此阻塞呼叫不僅延遲一個任務——它會從執行緒池中移除一個工作執行緒,並延遲所有原本應在那裡執行的任務。受影響的任務與導致阻塞的程式碼無關,這就是為何症狀會表現為完全不相關的尾端延遲,並且事後幾乎無法歸因。
block_on 只在同步程式碼進入非同步的邊界處是正確的:例如 main、測試、或來自 C 庫的回呼。一旦進入,就應留在非同步領域中——使用非同步 I/O、在 guard 必須存活於 await 的地方使用非同步感知鎖、並將真正會阻塞的工作(如 CPU 密集計算或同步第三方客戶端)交給 spawn_blocking。
同樣的原則適用於跨 await 持有的 std::sync::Mutex guard。雖然沒有任何標記表示它會阻塞,但它會在任務被取消排程期間持有鎖,導致競爭任務阻塞自己的工作執行緒,而失敗表現為死鎖而非鎖爭用。
在當前執行緒執行緒上巢狀呼叫 block_on 不會導致效能下降——它會直接死鎖。這是誠實的失敗;多執行緒情況只是用執行緒數量掩蓋了相同錯誤,直到負載移除這個隱藏點。
除非函數需要擁有所有權,否則在簽名中使用借用 [R:borrow-in-signatures]
優先使用 &str 而非 String、&[T] 而非 Vec<T>、&Path 而非 PathBuf。簽名是對函數所需內容的聲明,而擁有所有權的參數意味著「我將保留此值」——因此當函數體僅進行讀取時,簽名便不真實,且每個呼叫者都需為此付出分配的代價。
僅在函數儲存該參數的地方擁有所有權。在那種情況下,impl Into<String> 是禮貌的形式:持有 String 的呼叫者無需成本即可移入,而持有 &str 的呼叫者則有意識地進行一次分配。在回傳值方面,優先回傳 &str 而非 &String——額外的間接層對呼叫者毫無益處,並會將欄位的表示形式固定到公開 API 中。
代價很少是單一分配。而是擁有所有權的參數會傳播:呼叫者為了滿足它而 clone,它的呼叫者再為了滿足那個而 clone,一個未經深思選擇的簽名就會變成一連串 clone,每個 clone 在局部看來都是必要的。R:justify-every-clone 會揭露這些 clone;而此規則是關於如何從一開始就避免產生它們。
先透過撰寫型別來設計,再撰寫邏輯 [R:design-types-first]
自撰寫以來已出現 2 次;最近一次為 2026-11-01。
先勾勒型別直到設計浮現,再撰寫邏輯。型別層次的勾勒是測試優先的紅燈階段所追求的「可執行規格」,而且是更強大的規格:測試僅採樣行為空間的點,而型別則約束整個空間,且編譯器能在編譯時、只要有程式碼存在,就證明其正確性。當型別正確時,大部分實作會自行編寫,許多錯誤的實作則無法編譯。在 Rust 中,此順序優先於任何測試優先的預設——測試不會被移除,而是降級到它們作為正確工具的層級:屬性測試用於型別無法編碼的行為法則,單元測試則作為過去錯誤的迴歸固定。將每個保證應用到能維持它的最強層級,並在審查時,首先問的不是「它通過了嗎?」而是「這些保證中,有哪些可以移到更高一層?」
錯誤列舉應攜帶能識別失敗的值 [R:errors-name-what-failed]
部分由鉤子
no-string-error強制執行;不帶欄位的變體、省略識別值的變體,以及名為Unknown(String)的包羅萬象變體(一行程式碼無法將其與攜帶未識別值的變體區分開來),皆由此指令單獨約束。
每個不同的失敗都是 thiserror 列舉的一個變體,且每個變體都攜帶能識別該實例的值:
#[derive(Debug, thiserror::Error)]
pub enum ConnectError {
#[error("TCP connect to {addr} failed")]
Tcp { addr: SocketAddr, #[source] source: io::Error },
#[error("timed out after {elapsed:?}")]
Timeout { elapsed: Duration },
}
字串無法提供的兩個優點由此產生。呼叫者可以進行 match——重試 Timeout、呈現 Tcp——而不是解析下次有人編輯訊息時就會改變的文字。而且訊息會指名實際的位址,因此一條日誌就足以採取行動,而不是展開調查。
將原因放在 #[source] 中,而不是將其內插到文字中。然後,錯誤鏈會在邊界處一次性列印,無需每一層都重複下一層的內容。
每個呼叫者可能以不同方式處理的條件都應作為一個變體。將四個原因合併為 Other(String) 會在列舉內部重新建立字串錯誤:它看似型別,實則表現如文字,而這正是本規則旨在捕捉的型態。
每個 clone() 都必須攜帶其理由,否則設計有誤 [R:justify-every-clone]
部分由鉤子
no-clone-without-comment強制執行;註解是否陳述了真實理由而非重複呼叫本身,以及src/樹之外的任何 Rust 檔案,皆由此指令單獨約束。
借用檢查器的錯誤是關於所有權的問題。clone() 並未回答問題;它只是付費來避免回答。應先嘗試回答:重新組織結構以使一個擁有者顯而易見、使用具名生命週期的借用、將借用拆分到較小的欄位、或在使用 Arc/Rc(當值確實是共享而非複製時)分享。
對於那些在審查後仍存活下來的 clone,請在旁邊寫下理由,使用此程式碼庫中通用的格式:
let sources: Vec<RuleTag> = rules.iter().map(|r| r.tag().clone()).collect(); // allow:clone: OutputFile 擁有其來源,其生命週期超出 &Library 借用
這個註解使規則可供審查。未標註的 clone 彼此無法區分,因此審查者必須為每個 clone 重新推導所有權論證,而實際上,對於任何一個都不會重新推導。標註過的 clone 陳述了一個可被檢查的主張,並且在圍繞它的設計發生變化時可以被找到並移除。
理由必須是關於所有權或生命週期。「為了讓它編譯通過」只是錯誤類別的重述,而非正當理由。程式碼認為是同一個值的兩個副本,是一個正確性錯誤,只等著誰修改了錯誤的那個。
存活過審查的大多數 clone 是由簽名而非呼叫點產生的:R:borrow-in-signatures 是壓力的來源,而修正參數通常會刪除 clone,而不是為其加上註解。
模組是封裝邊界;pub 是審慎的匯出 [R:module-visibility-is-deliberate]
自由使用 pub(crate) 和 pub(super);將裸 pub 保留給 crate 審慎匯出的內容。可見性不是文書工作。它是關於哪些內容仍可自由更改的聲明,並且是編譯器唯一能檢查的此類聲明。
預設值很重要,因為 pub 易於添加卻難於移除。一旦某個項目變為公開,外部呼叫者可能依賴於它,因此模組無法再重組、欄位無法重新命名、為單一呼叫點編寫的輔助函數現在成為了支援的 API。這些決定從未被做出過;它們只是被預設值所決定。
模組如同型別一樣,維持著一個不變量。一個只能在檢查之後才能呼叫的輔助函數,應與該檢查一起放在模組的私有區域中,這樣「只能在之後」就能因為其他地方沒有人能呼叫它而得到強制執行。將其設為公開,則會將這個保證轉化為文件註解。
應在 crate 根目錄使用 pub use 明確地重新匯出預期的表面 API,並讓其背後的所有內容盡可能保持私有。公開 API 於是成為了一份由某人撰寫的清單,而不是程式碼恰好所在位置的殘留物。
R:private-fields-only 是同樣的論點,只是低了一個層級:作用於欄位而非項目。兩者是可以分開的——公開型別上的私有欄位會洩漏結構,而 pub(crate) 型別上的公開欄位則不會——因此滿足其中一個並不能說明另一個。
在重要的回傳值上標記 #[must_use] [R:must-use-on-consequential-returns]
在所有回傳 Result 的函數上,以及在所有回傳值為呼叫該函數之目的的函數上,添加 #[must_use]。如此一來,編譯器會在每個呼叫點(現在與未來)標記被丟棄的結果,而不是留給審查者在一百行程式中注意到一個孤立的語句。
優先將屬性放在型別上而非函數上。#[must_use] struct Receipt; 會傳播到每個回傳 Receipt 的函數,包括那些後來由從未讀過此規則的人編寫的函數;而函數層級的屬性則需要在每次使用時記住。當型別始終是重要的時,應將其附加到型別上;僅在相同型別有時可以被丟棄這種較窄的情況下,才退而使用函數層級的屬性。
將產生的警告升級為 CI 中的錯誤。一個違反 #[must_use] 只會印出沒人看的筆記,這不過是帶有額外語法的註解,而其聲稱的保證則無處可尋。
廣泛使用 newtype:不同的概念使用不同的型別 [R:newtype-liberally]
為每個領域概念賦予自己的型別,即使底層表示相同。Miles(f64) 與 Kilometers(f64)、Host(String) 與 Port(u16)——永遠不要使用兩個裸 f64 或一個 String 與一個 u16 且順序只有人類記得。Newtypes 通常是零成本的:它們編譯成與基本型別相同的機器碼,因此它們唯一增加的是你想要的編譯錯誤——但當這種成本是關鍵時,R:verify-the-abstraction-compiled-away 要求檢查這個主張而不是重複它。無法被寫出的單位混淆錯誤比在審查中捕獲的錯誤成本更低,而遠低於那些未被捕獲的錯誤。R:parse-dont-validate 是 newtype 的來源——邊界將其鑄造為見證者;此規則是關於之後將其隨處攜帶,而不是拆解回基本型別。
庫中禁止使用 anyhow 作為回傳型別 [R:no-anyhow-in-libraries]
同時由鉤子
no-anyhow-in-lib強制執行。
庫 crate 應回傳型別化的錯誤列舉(使用 thiserror),這樣 Result 就能準確說出可能出錯的內容,且呼叫者可以對其進行 match。anyhow 僅屬於最外層的二進位邊界。此規則已成熟:no-anyhow-in-lib 鉤子現在能在撰寫時確定性地強制執行它。
不要使用 let _ = 來忽略未使用的區域變數;使用它、回報它、或刪除它 [R:no-let-underscore-on-a-local]
部分由鉤子
no-let-underscore-local強制執行;底線前綴的繫結由此指令單獨約束。
編譯器的未使用繫結警告是一個讀者。它指出你花費功夫計算出的值最終毫無用處。let _ = x; 的存在就是為了關閉這個讀者,而對於區域變數,它幾乎總是在不該關閉的時候關閉了它。
它有兩種形式,兩者在 -D warnings 下都能乾淨編譯,但對審查者來說卻讀起來像是某人做出的決定:
- 一個被計算出又被丟棄的區別。 一個旗標被解析、一個模式被解碼、一個設定值被讀取——然後被丟棄,因此本應被區分的案例被默默地當作一個案例處理。
- 一個被捕獲又被丟棄的失敗。
if let Err(e) = op() { let _ = e; continue; }不會留下任何事件;失敗發生了,而系統中沒有任何東西之後能說明此事。
當警告觸發時,請執行以下三件事之一。使用該值——一個未被使用的區別通常表示缺少一個分支,而警告正在指向它。回報或傳遞它——一個真正安全到可以忽略的錯誤仍應獲得一個命名事件,或一個註解說明為什麼下游沒有人需要知道。或者刪除計算出它的程式碼。 當一個繫結在設計上就是死的(例如迴圈變數的最後一次賦值),應改寫迴圈以避免產生這個死賦值,而不是讓警告保持靜默。
在呼叫上使用 let _ = f(); 是一個不同的情況:它丟棄了回傳值,這由 R:must-use-on-consequential-returns 管轄。此規則關乎於你已經持有的區域變數。R:guarantee-needs-a-reader 依賴編譯器來捕捉未讀取的繫結;let _ = 是它無法看到的一種寫法。
失敗模式檢查:每當你即將寫下 let _ = name; 時,問問自己:這個值被計算出來是為了決定什麼,而那個決定現在發生在哪裡? 如果答案是「無處可尋」,那麼警告就是對的。
生產程式碼中禁止使用 unwrap() [R:no-unwrap-in-production]
同時由鉤子
no-unwrap-in-src與no-expect-empty-msg強制執行。
生產程式碼中禁止使用 unwrap()。僅在使用有意義的 panic 訊息(能指明資源與不變量)時使用 expect(),或者使用 ? 回傳錯誤,讓呼叫者決定如何呈現它。此規則已成熟:no-unwrap-in-src 鉤子現在能在撰寫時確定性地強制執行它,因此指令層級已無需再處理。
解析,而非驗證 [R:parse-dont-validate]
部分由鉤子
no-free-validator強制執行;在內部呼叫點重新檢查已解析的資料由此指令單獨約束。
在最外層邊界將原始輸入轉換為豐富的領域型別,產生一個見證 newtype,其存在證明了檢查已發生。內部程式碼使用見證且從不重新檢查——一個邊界、一次檢查、之後由編譯器在所有地方強制執行。R:parse-wide-then-range-check 進一步強化了這一點:邊界必須能夠看到非法值,以便為其命名。
先廣義解析,再進行範圍檢查 [R:parse-wide-then-range-check]
解析為一個足夠寬廣以能表示超出範圍值的型別,然後再進行範圍檢查……
[已截斷以利翻譯]