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、在需要跨越 await 持有守卫时使用异步感知锁、对于真正阻塞的工作(如 CPU 密集型计算或同步第三方客户端)使用 spawn_blocking。
同样的规则适用于跨越 await 持有的 std::sync::Mutex 守卫。虽然没有标志会指出它在阻塞,但它会在任务被调度时持有一个锁,因此竞争锁的任务会阻塞其自己的工作线程,导致失败表现为死锁而非锁争用。
在当前线程运行时上嵌套 block_on 不会降级——它会直接死锁。这是诚实的失败方式;多线程情况只是通过线程数隐藏了同样的错误,直到负载增加使隐藏失效。
除非函数需要所有权,否则签名中使用引用 [R:borrow-in-signatures]
优先使用 &str 而非 String,&[T] 而非 Vec<T>,&Path 而非 PathBuf。签名是关于函数需要什么的声明,而所有权参数表示“我将保留它”——因此当函数体只读时,签名是不真实的,并且每个调用者都要为此付出分配的代价。
只在函数存储该参数时使用所有权。在这种情况下,impl Into<String> 是礼貌的形式:持有 String 的调用者可以零成本地移动它进去,而持有 &str 的调用者会有意识地分配一次。在返回时,返回 &str 而非 &String——额外的间接层对调用者没有任何好处,并且会将字段的表示方式固定到公共 API 中。
成本很少是单独的一次分配。而是所有权的参数会传播:调用者克隆以满足它,它的调用者再克隆以满足那个,一个未经思考的签名会变成一列克隆,每个克隆看起来局部上是必要的。R:justify-every-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 连接到 {addr} 失败")]
Tcp { addr: SocketAddr, #[source] source: io::Error },
#[error("在 {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 拥有其 provenance,生命周期超过了 &Library 借用
注释是使规则可审查的关键。未注释的 clone() 彼此无法区分,因此审查者必须为每一个重新推导所有权论证,实际上却很少这么做。注释过的 clone() 陈述了一个可检查的声明,并且在设计发生变化时可以找到并移除它。
理由必须涉及所有权或生命周期。“为了让它编译”只是错误类的复述,而非正当理由。代码认为是一个值的两个副本是正确性上的 bug,只等有人修改错误的那一个。
大多数在审查后存活的 clone() 是由签名而非调用点创建的:R:borrow-in-signatures 是压力来源,修复参数通常会删除 clone() 而非注释它。
模块是封装边界;pub 是有意为之的导出 [R:module-visibility-is-deliberate]
自由使用 pub(crate) 和 pub(super);仅对 crate 有意导出的内容保留裸 pub。可见性不是文书工作。它是关于哪些内容仍然可以自由修改的声明,并且是编译器可以检查的唯一此类声明。
默认值很重要,因为 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 且顺序只能由人类记忆。Newtype 通常是零开销的:它们编译成与原始类型相同的机器码,因此唯一增加的就是你想要的编译错误——但代价在此处是承载性质的,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]
解析成一个足够宽广以表示超出范围值的类型,然后进行范围检查
……
[因翻译而截断]