谷歌(Google)的 Rust 风格指南说明在谷歌内部编写 Rust 代码时采用的规范,它补充而非替代公开的 Rust 风格规则。背景部分先明确适用范围:这份指南适用于谷歌的 Rust 代码,包括 Chromium 中的代码;在指南未作规定之处,应遵循公开的 Rust 风格规则,例如 rustfmt 与 Rust API Guidelines。它也列出指南目标,即为代码提供一致的风格、提升可读性与可维护性,并避免常见错误。
正文
本文档是 Google 内部使用 Rust 编程语言的风格指南。这里的“风格”不仅指格式,还包括 API 和实现设计方面的指导。
Rust 被用于许多地方,包括 google3、Android、ChromeOS、Fuchsia 和 Chrome。我们希望 Google 内部的 Rust 用法在各代码库内部和之间保持一致。除了带来一致性,风格指导也有助于确立质量标准。
背景
范围
当前本指南中规则的适用范围是:在 Android、Chromium 以及其他代码库中,本风格指南是与其他代码库协商后制定的,它们打算开始采用。这些代码库可能有自己的规则来规定执行的严格程度。随着这些代码库采用本指南,此处会有更多信息。
风格指南的目标
大体上,我们编写本文档的原因与编写 C++ 风格指南的原因相同。
大多数风格规则都来自下面列出的一个或多个目标。
风格规则应优先保证可读性和可维护性,而不是易写性。我们期望代码长期存在,因此如果风格规则能提升可读性,那么在编写代码时多花一点精力从长远看是值得的。
风格规则应有利于更好的自动化。Google 经常进行大规模变更(LSC),一致性有助于更顺利地执行 LSC。
风格规则应尽可能与更广泛的 Rust 生态保持一致。更广泛的 Rust 生态在风格上已经有相当好的一致性,我们应尽可能依赖它,以减少意外并降低学习 Google Rust 风格的成本。
风格规则应当物有所值。Google 的每位 Rust 开发者和评审者都必须记住这些风格规则,因此我们应谨慎,只纳入有实际收益的规则。
风格规则有助于避免细微的风格分歧。许多情况下,理性的人可能对风格有不同意见,而且这种分歧预计会经常出现在 CL 上。有一条风格规则就能避免每次都重新讨论。这类没有其他收益的规则应尽量少。
遵循公开的 Rust 风格规则
在不与本风格指南冲突的前提下,遵循官方公开的 Rust 风格规则。
相关内容包括 Rust 风格指南和 Rust API 指南。大多数代码格式约定由 rustfmt 自动强制执行。
“冲突”指的是本风格指南推荐的写法被 Rust 风格指南反对,或与其主要推荐写法相反的情况。
本指南为何偏离常规 Rust 风格规则?大多数风格指南有三个目标:一致性、可读性,以及鼓励良好的 API 设计。无论具体如何选择,提前定好这些选择有助于减少无谓争论。本风格指南也有这些目标,但它还有一个不太常见的目标:为大规模变更(LSC)铺平道路。Google 的许多大型代码库偶尔需要运行由工具辅助、横跨整个代码库的重构:为此,代码应尽量让工具容易处理。一致性有帮助,但采用有意的、便于重构的模式也有帮助。
在上下文中应用 API 设计规则
在应用与 API 设计相关的规则时,要区分两类代码:
库代码:供其他项目(应用程序或其他库)使用;解决相对通用的问题。
应用程序代码:在单个应用程序中使用;解决更具体的问题。
对于库代码,涉及 API 设计的风格规则是必须遵守的。
对于应用程序代码,API 设计规则是强烈建议。应用时要注意,由于应用程序代码是为解决特定问题而编写的,它通常应偏向简单直接,而不是广泛的可复用性;避免过早泛化和抽象,专注于满足当前需求。
代码组织
术语:crate 是编译单元,始终包含一个无名 Rust 模块,即“根模块”。Rust 模块是 crate 内的代码组织单元,用于控制名称的可见性;模块可以嵌套,也可用于将代码拆分到多个文件和目录中。
下面几节说明,在 Chromium 中,最好不要利用将模块拆分到多个文件的所有可能性。
优先使用小型 crate [chrome]
在 Chromium 中优先使用小型 crate;理想情况下(但不强制)是单个文件,专注于一个抽象。如果代码可以拆分成小型 crate,就拆分。
一个 crate 对应一个 rust_static_library(chrome)规则,可包含一个或多个 .rs 文件。
其他考虑:
根模块允许从叶子子模块 re-export 公开 API,使子模块成为 crate 的实现细节(用户不需要知道这些子模块)。
关于小型 crate 的规则同样适用于有子模块和没有子模块的 crate。首先评估这些 API 是否需要放在同一个 crate 中,然后再考虑子模块是否有助于组织 API。
如果你决定使用子模块,请遵循预期的文件结构。
当 trait 实现的连贯性规则迫使多个类型和 trait 必须放在同一个库中,或者你在构建大型基础库时,允许使用更大的 crate。
理由:由于 Chromium 代码库规模巨大、在构建图中添加依赖会产生非局部影响,并且为了提高并行编译和构建缓存的性能,建议采用细粒度的库目标。
对 rust_library 源文件使用扁平文件布局 [chrome]
在 Chromium 中,属于同一个 rust_library(一个 Rust crate)的 .rs 源文件必须位于同一个目录中。
不要用子目录来组织单个 crate 的文件。如果觉得有必要把一个 crate 的文件放到子目录中,这强烈表明该 crate 已经变得过大,应当拆分。
这种 crate 布局有意不同于开源仓库通常使用的传统 Cargo 目录结构。
例外://third_party 中同时也用 Cargo 构建的 Rust 代码,应遵循传统的 Cargo 目录结构。
理由:Chromium 的做法由以下因素驱动:基于对小型 crate 的偏好,大多数 rust_library 目标只包含一两个源文件;因此为每个 rust_library 目标创建目录会导致大多数目录只含一个源文件;多文件 rust_library 目标也不应为子模块创建专用目录,以确保方法一致。
源文件和叶子模块以主要的用户定义项命名
当叶子模块有一个主要的用户定义项(例如 Foo)时,模块应以该项命名(例如 mod foo 和 foo.rs)。
并非主要围绕某个特定抽象的叶子模块可以随意命名。
理由:这样一眼就能看出源文件或叶子模块的内容和用途。
不要在叶子模块以外的模块中实现 API
非叶子模块中只应包含模块声明(不是定义)和 re-exports。
注意,叶子模块的定义允许以下子模块:mod test 和跨语言绑定模块,例如 #[cxx::bridge]。
例外:在非叶子模块中保留少量“把东西串起来”的核心实现代码是可以的;目标是这些模块中不要有太多代码。
理由:这样更容易找到内容,也避免把结构和内容混在一起。
不要使用 glob 导入
不要使用 glob 导入,除非在以下情况下你可以自行决定使用:
内联模块中使用 use super::*;在枚举的方法体以及主要处理该枚举的函数中使用 use enum::*;非叶子模块中使用 pub use submodule::*;C++ 互操作:由于 C++ 中的同一个命名空间在 Rust 中可能是不同 crate 里的子模块,你可以用 glob 导入把这些命名空间合并到一个私有副本中。例如:mod absl { pub use status::absl::*; pub use strings::absl::*; }。在这个假设例子中,你可以从 Rust 中像在 C++ 中一样使用 absl::Status 和 absl::StrCat,而不必写成 status::absl::Status 和 strings::absl::StrCat。
这只能用于合并 C++ 命名空间,且合并后的模块不能是公开的。
如果使用鼓励 prelude 的第三方 crate,是否允许由评审者决定。这也意味着应避免定义 prelude。
理由:glob 导入会增加执行 LSC 的代码维护者的负担,并且在没有交叉引用的情况下更难判断某个类型来自哪里。
一条 use 语句应从一个模块导入项
这条规则对应 rustfmt 设置 imports_granularity = Module。基本上,use 语句的 {..} 块内不应出现 ::。例如,use foo::Foo; 和 use foo::bar::{Bar, Baz}; 是合适的;但不应写成 use foo::{Foo, bar::Bar};,因为花括号内出现了 bar::Bar。
优先使用局部作用域的 trait 导入
在函数体内导入 trait,除非该 trait 的方法在整个文件中被大量使用。
例如,在 fn foo(x: u8) 函数体内写 use std::str::FromStr;,然后调用 x.parse() 得到 String,而不是在模块作用域导入该 trait。
调用顶层函数时用模块名限定
要使用其他模块或 crate 中定义的顶层函数,应导入其模块,并以 modulename::functionname() 的形式调用。例如,应写 use std::mem; 然后 let x = mem::transmute(y);,而不是 use std::mem::transmute; 后写成 let x = transmute(y);。
例外:导入名称已充分消歧的函数时,例如来自 C++ 的函数(通常直接调用顶层自由函数);在 Chromium 中,从小型、单一用途的第一方 crate 导入函数时;模块名本身容易冲突时,例如模块名是 C++ 命名空间名,且多个 crate 定义了相同子模块。
理由:许多 Rust 代码库保持这样一种风格:不带限定符的小写名称被假定为局部的,因此 foo() 可假定来自同一模块。这条规则主要为了避免评审者分歧,收益主要是一致性,以及在无交叉引用时阅读代码的一点小优势。
类型名应导入并不加限定地使用
默认情况下,代码中引用类型和 trait 时不带模块路径,例如使用 Regex 而不是 regex::Regex,并在文件顶部(或相关函数内)写 use regex::Regex。
例外:存在歧义时,可以直接引用 io::Error 这类常见名称,也可重命名(如 use std::io::Error as IoError);如果你有一大“组”类型,例如 rustc 的 hir 模块包含大量 HIR 类型;在宏和生成代码中,要求写 use 会适得其反;在依赖很多的代码中,导入所有东西会导致导入块很大却没什么好处。
理由:这条规则主要为了避免评审者分歧。
使用合适的导入机制 [chrome]
Chrome 使用 chromium::import!()。
代码格式与美感
使用合适的格式化工具
使用合适的格式化工具(例如 Chromium 中的 git cl format)。
配置(供风格指南作者参考):如果代码库关心在 stable 上构建,而某个 rustfmt 选项在他们的 stable Rust 上不可用,则可以选择不应用以下选项:每行最多 100 列,即 max_width = 100;十六进制字面量使用小写字母(0xab123cdef 而不是 0xAB123CDEF),即 hex_literal_case = Lower;宏匹配器格式化,即 format_macro_matchers = true;对字面量中的不可见字符使用转义,例如若文本包含零宽连接符,就写 \u{200D}。
选择退出自动格式化
可以使用 #[rustfmt::skip] 退出自动格式化,但仅限以下情况:
手动对齐:你有特定理由让某段代码以某种方式对齐以增强可读性,例如多行相似代码希望保持对齐,或构造矩阵的函数。
生成代码(理想情况下应改用 genrule)。
重要:不能因为个人风格偏好而跳过格式化。
所有权与借用
避免使用 Rc 和 Arc
优先使用唯一所有权和借用,而不是用 Rc 和 Arc 管理内存。
例外:当替代的所有权/借用结构写起来、理解起来或维护起来会不合理地复杂时,可以使用 Rc 和 Arc。这是代码评审时讨论的判断问题。
理由:通常 Rust 允许在不使用共享所有权智能指针的情况下表示对象图。这些指针会增加运行时开销和自身独有的复杂性。从其他语言转过来的工程师可能过快使用 Rc 和 Arc。
优先借用而不是深拷贝
只有确实需要对象的深拷贝时才使用 .clone()。
不要把 .clone() 当作借用的替代品。设计代码时要自然地遵循 Rust 的所有权和借用规则。不过,如果借用关系变得过于复杂,.clone() 可以是务实的选择。
在选择克隆还是借用时,要考虑代码的性能需求。
注意:本建议不适用于克隆 Rc 或 Arc 指针。克隆引用计数指针不会产生深拷贝,这是预期的使用方法。
理由:深拷贝由于内存分配和数据复制,代价可能很高。
函数输入与输出
对不可变数据接受灵活的引用
当函数接受借用的不可变数据时,应使用提供最大灵活性的引用类型。传递数据本身的引用,而不是智能指针、容器或包装器的引用。
| 使用 | 而不是 | 为什么? |
| --- | --- | --- |
| &T | &Box<T> | 借用 Box 里的数据 |
| &[T] | &Vec<T> | 接受任意切片,而不是特定容器 |
| &str | &String | 接受字符串切片,而不是对 owned 字符串的引用 |
| Option<&T> | &Option<T> | 借用 Option 里的数据 |
例外:期待非线性字符数据时,例如 Rope/Cord。
相关背景见 Clippy lint borrowed_box 和 ptr_arg。
理由:接受不提及具体智能指针、容器或包装器的引用类型,能让函数更通用、更高性能:调用者可以用多种方式传递持有的数据,而无需先复制。
返回类型中迭代器与 owned 集合的取舍
尽可能优先返回迭代器(具体类型或 impl Iterator),而不是返回 Vec<T>。
注意:在应用程序代码中,如果返回 Vec<T> 能简化代码,且并不明显需要迭代器的灵活性和性能优势,那么返回 Vec<T> 是可以的。
例外:大多数调用者需要构造一个 owned vector;函数实现 naturally 构造了一个 vector;返回值很可能需要随机索引;算法用 for 循环更好,且强制分配无关紧要;函数的预期目的就是创建一个新的 owned 集合;函数返回一个已存在的 vector。
理由:高度可复用的库代码通常应努力提供更灵活、更高性能的 API。返回迭代器让调用者选择如何消费数据:收集成 vector、在循环中处理,或链式调用更多迭代器适配器。其中一些方式能让用户避免不必要的分配。在应用程序代码中,函数的使用模式通常更容易预测;如果明确性能不是问题,或者数据无论如何都会被收集成 vector,返回 vector 能形成更简单的 API。
函数参数中迭代器与切片的取舍
高度可复用的库代码如果需要遍历输入,应接受迭代器或 impl IntoIterator。
注意:在应用程序代码中,如果接受切片 &[T] 能简化代码,且并不明显需要迭代器的灵活性和性能优势,那么接受 &[T] 是可以的。
理由:高度可复用的库代码通常应努力提供更灵活、更高性能的 API。接受迭代器的函数为调用者提供:更大的灵活性(数据可来自任何集合);更高效率(数据无需复制到连续内存中);更好的可组合性(函数能更好地配合迭代器链工作)。在应用程序代码中,函数的使用模式通常更容易预测;如果明确性能不是问题,或者调用者总是已有 vector,接受切片能形成更简单的 API。
接受 Into
在设计接受 T 的库 API 时,如果用户很可能拥有各种类型,并且调用点的人机工程学至关重要,可以考虑接受 impl Into<T> 而不是 T。
提示:这应与代码膨胀问题相平衡;并不要求代码中大量使用 Into<T>。
注意:应用程序代码几乎不应使用 impl Into<T> 参数。
理由:使用 impl Into<T> 允许函数直接接受各种兼容类型的参数。例如,接受 impl Into<String> 的函数可以直接用 String 或 &str 字面量调用。这里的权衡是:在调用点避免 .into() 的便利性与泛型代价(更复杂的 API 签名、更大的代码体积,有时甚至类型推断困难)之间的平衡。
在 API 中优先用 enum 而不是 bool
当参数用途不明显时,使用 enum 函数参数而不是 bool。
如果你确实要用 bool,考虑用内联注释在参数列表中说明该 bool 的用途。
报错
选择适合你 API 契约的报错机制:Option、Result 或 panic!。
用 Option 表示简单失败:原因很明显,调用者不会从更多细节中受益。这适用于标准、可预期的结果,例如缺少值或解析不匹配。例如 checked_add(a: u32, b: u32) -> Option<u32>,失败原因(整数溢出)很明显,无法合理提供更多细节。
用 Result 表示可恢复失败,且调用者需要上下文来处理错误。这些通常是“不可避免”的错误,由外部因素(如 I/O 或网络)导致,即使实现正确的代码也无法提前检查来防止。不过调用者可以合理预期错误的种类(文件相关函数可能因 I/O 错误失败)。错误细节让调用者区分失败模式并适当恢复。例如 open_file(path: &Path) -> Result<File, io::Error>,调用者需要知道为何失败(NotFound 还是 PermissionDenied)。
用 panic! 报告 bug 和违反的不变量。这些错误是可避免的:程序缺少检查,并在未满足前置条件的情况下调用操作。这些缺陷必须在代码中修复,而不是在运行时处理,尽管可能用 catch_unwind 做粗粒度恢复。例如 MyVec<T> 的 remove(&mut self, index: usize) -> T 在 index >= self.len() 时 panic!("index out of bounds"),表示调用者逻辑有 bug。
注意:有些代码库可能选择完全避免 panic,要求即使是不变量被破坏也通过 Result 报告。
例外:面向开发者的二进制文件可以用 panic 处理非致命错误;测试可以用 panic 做断言。
类型
Newtype 与类型别名
在以下情况使用 newtype 而不是类型别名:
你定义的类型有显著的语义差异;这些类型值得拥有自己的 API;如果你希望以后能灵活更改底层类型,尤其是你无法控制该类型的所有用法时;为了类型安全,如果类型容易混淆。例如 Date 类型可以用 newtype 区分内部计算中的 Day、Month 和 Year 数字。
优先使用命名字段,而不是元组和非 newtype 元组结构体
优先使用带命名字段的类型,而不是元组,因为元组索引(.0、.1 等)很快会让人困惑。
例外:newtype 元组结构体(只有一个字段的元组结构体);在极局部、几乎不可能混淆的上下文中。
Trait
在必要时添加 trait 实现
一般规则是:底层库代码应主动预判用户需求,而应用程序代码应在需要时添加 trait 实现。
实现已知的标准库 trait,而不是自定义方法
如果某个方法符合标准库 trait 的目的和行为,就实现该 trait。例如,不要在 Foo 上写 fn from_str(&str) -> Self,而应 impl FromStr for Foo。
出于可发现性原因,同时保留方法和 trait 实现也是可以接受的。
相关背景见 Clippy lint should_implement_trait。
尽可能使用 #[derive()]
如果手动实现可以表达为 #[derive()],就应避免手动实现。
泛型与 dyn Trait
在库中,函数参数优先接受泛型(T: Trait 或 impl Trait),而不是 dyn Trait。
例外:代码体积很重要的场景。
理由:效率方面,创建 dyn Trait 需要建立胖指针,每次调用 trait 上的函数都需要 vtable 查找;如果函数接受 dyn Trait,编译器无法执行某些泛型时可用的优化,例如内联 trait 函数调用。另外,有些 trait 并非 dyn compatible,例如具有泛型函数、没有 self 参数的关联函数,或返回 Self 的函数。
何时定义 trait,何时用固有实现
仅在以下目的定义 trait:
启用泛型编程(在泛型函数和类型的约束中使用 T: Trait);接受或返回 impl Trait 或 dyn Trait;定义扩展 trait(但也要注意扩展 trait 一节中的注意事项)。
在所有其他想为类型实现功能的情况中,使用固有实现。
不要仅仅为了组织代码而使用 trait。
扩展 trait
扩展 trait 是给来自其他 crate 的类型添加方法的 trait。
扩展 trait 与其他 trait 的区别在于,它的唯一目的就是给类型添加方法;它不打算用于泛型(T: Trait、impl Trait)或动态分发(dyn Trait)。为表明这一点,扩展 trait 的名字应带 ~Ext 后缀(例如 ForeignTypeExt)。
通常,拥有原始类型的团队也应拥有该扩展 trait。
例外:预期会变得广为人知并提供重大价值的扩展方法,例如在基础库/框架中。
注意:这条规则专门针对扩展 trait。相对常见的是定义一个 trait 作为输入 trait(例如 DateLike),并在一堆外部类型上实现它。
例子:trait ForeignTypeExt { fn additional_method(&self); },然后为 ForeignType 实现它;也可以为每个实现了某个外部 trait 的类型定义扩展 trait,等价于给外部 trait 添加默认方法:trait ForeignTraitExt { fn additional_method(&self); },然后 impl<T: ForeignTrait> ForeignTraitExt for T { ... }。
理由:当同一团队拥有扩展 trait 和被扩展的类型时,有助于避免基类型与扩展 trait 之间、或两个扩展 trait 之间出现方法名冲突。
重要 trait 的具体指导
带公开字段的类型:考虑实现所有子字段都实现的 trait。
错误类型:尽可能实现 Copy trait。Copy 类型更易于管理和传播,因为可以按值传递而不移动所有权。如果错误必须包含非 Copy 字段(如 String),则无法这样做。
Copy:只要 reasonably 明确该类型未来仍会是 Copy,就应实现。
Clone:大多数类型都应实现 Clone,除非它们表示某种外部资源。
Default:如果类型实现了 new() 或另有默认状态,就应实现 Default。相关背景见 Clippy lint new_without_default。
PartialEq / Eq:如果希望用户使用 == 运算符,优先实现 PartialEq 而不是 Eq。实现 Eq 时要注意额外要求的自反性。
PartialOrd / Ord:如果希望用户使用 < 或 > 运算符,优先实现 PartialOrd 而不是 Ord。实现 Ord 时要注意它要求全序。
Map 键:如果你认为某个类型很可能被用作 map 键或被缓存,就实现 Hash 和 Eq。此外,如果该类型有明确的全序,还应实现 Ord(以及它所需的 trait)。
为重要的 From 实现配上显式构造函数
如果某个类型有一个重要的 From 实现,且它预期是该类型的主要构造方式之一,就应添加固有的 ::new()、::new_from_foo() 或 ::from_foo() 方法。
选项结构体应实现 Default
所有字段都公开、且主要用作函数“选项包”的结构体,应实现 Default。
理由:这样可以方便地使用结构体更新语法,例如 Struct { a: 0, ..Default::default() }。
并发
处理 poison
无论用例看起来多诱人,都不要进入被 poison 的 mutex 内部去获取其内容。
不要使用 async / .await [chrome]
在 Chromium 中不要使用 async / .await。
不要通过 Rust 标准库启动新线程 [chrome]
在 Chromium 中,优先使用 sequence 而不是线程。
使用 fuchsia_sync::Mutex [Fuchsia]
使用 Fuchsia 的 mutex 类型,而不是 std::sync 或 parking_lot 中的类型。
宏
考虑如何表达 API 时,优先顺序如下:
常规 Rust 函数、类型、trait、对象和模块(尽可能避免宏、代码生成和元编程);macro_rules! 风格的宏;其他代码生成策略(例如 genrule);proc_macro 宏。
当你决定使用宏或代码生成时,请权衡收益与成本:
IDE、Code Search 等开发者工具在处理使用宏的代码时效果会变差;宏会创造出语言方言,使工程师更难跨代码库工作;大规模变更更难、更贵地实施。
理由:优先非宏设施,因为宏会创造出语言方言,读者看到宏用法时对该代码的假设比不用宏时更少,而且宏比常规 Rust 代码更难写对。用 macro_rules! 做简单代码生成:由于 Rust 宏作用于 AST,它们比文本代码生成更不易碎。
命名
方法命名
遵循 Rust 标准库的方法命名风格。也可参考 Comprehensive Rust 课程中的命名约定指导。
具体考虑:使用 as_...()、to_...() 或 into_...() 等前缀,并搭配合适的 self 约定。相关见 Rust API 指南中的这一节以及 Clippy lint wrong_self_convention。
生命周期命名
生命周期应有描述性名称,尤其是类型生命周期。
例外:像 Cow 这种极其泛化的抽象;在能工作的地方使用匿名生命周期 '_ 是可以的;当单字符生命周期仅仅表达方法输入与输出之间的关系,或是局部高阶生命周期约束(for<'a>)所需要时,可以使用单字符。最多两个这样的局部生命周期是可以的;如果需要更多,强烈建议使用描述性名称。
泛型参数命名
为泛型参数使用与其代表事物种类相关的短名称(单字符或多字符)。单字母泛型可以,但所选字母应能指示对象类型,最好取自最重要的 trait 约束。
当泛型处于相对无约束的上下文时,T 或 U 是可以的;也就是该 API 预期用于大多数类型,例如 Vec<T>。
扩展 trait 命名
给扩展 trait 命名时,加上 ~Ext 后缀(例如 ForeignTypeExt)。
初始化函数命名
构造函数:使用 new(),如果有多个则用 new_with_foo()。如果构造函数返回 Result,使用 try_new()。
值到值的转换:使用 from_foo(),并配上关联的 From 实现。关于何时可视为值到值转换的细节,见相应文档。
避免缩写
不要使用项目外的人不太可能知道的缩写(尤其是首字母缩略词和首字母缩略语)。不要通过删减单词内的字母来缩写。
另见 Google C++ 风格指南。
例外:维基百科中常见的缩写;项目外的人不太可能混淆的缩写,例如 IpAddr(你和评审者应做此判断;总体而言仍应倾向于不缩写)。
何时以及如何编写文档
为公开 API 添加文档注释,可考虑用 missing_doc lint 辅助。
文档注释最佳实践:
考虑包含示例:不必每个函数都有示例,但应有足够功能在某处被示例覆盖,让人了解 crate 如何工作。
总是为可能因畸形输入而 panic 的函数记录 panic 情况。
清楚地记录不安全函数和 trait 的不变量。
使用大写和标点。
不要解释编程语言的基础。例如不要写“MockFileSystem 是 FileSystem trait 的 mock 实现,旨在单元测试中使用”这种显而易见的内容。
例外:注释应说出代码没说出的东西。如果没有什么有见地的可说,宁可省略注释,也不要用琐碎的方式重复函数名(这种情况下 allow 该 lint 是可以的)。通常可以省略单个 enum 变体的文档,尤其是名称自明时。
如何格式化文档注释
遵循这些准则编写文档注释:
用 /// 注释来记录注释后面的项,例如函数、结构体、枚举、trait 和类型别名。
用 //! 注释来记录模块或库本身,这些注释应放在文件顶部。
遵循官方 Rust 风格指南中关于 doc comments 的规则。
使用 Markdown 语法。
用普通注释语法(//)写非文档注释,例如实现注释、用户不关心的 TODO、被注释掉的源代码等。
优先用 /// 和 //! 直接记录,而不是从宏生成文档。这使文档在查看或修改源代码时直接可读。
混合使用文档注释(/// 和 //!)和普通注释(//)是可接受且受鼓励的。例如:
/// Finds the firstFooItemwith the givenname, orNoneif not present.
// TODO: crbug.com/12345 -nameshould be &str, not String.
pub fn get_named(items: &[FooItem], name: String) -> Option<&FooItem> {...}
理由:许多(但非全部)语言对文档注释和实现注释有不同的注释约定(例如额外的 * 字符)。Rust 和 Python notably 不仅有不同的注释约定,而且有完全不同的语法。这给 Rust 生态带来具体好处:文档注释能被工具消费,包括 rustdoc、IDE 功能和 Kythe;文档注释有标准结构和格式规则(Markdown),从而支持富文本;错误应用文档注释语法(例如应用到表达式而非可记录项)会被 lint 检查,尽早发现文档错误;如果没有 lint 错误,文档注释保证对文档工具可见,消除了文档过程中的不稳定和不可预测;文档注释是 AST 的一部分,因此可由宏(包括 macro_rules! 宏和过程宏)生成和消费。
其他 Rust 特性
不安全代码
不要写不安全代码,除非无法用安全代码实现相同功能。
如果必须写不安全代码:
将不安全代码封装在安全抽象中,并尽可能与业务逻辑隔离。理想情况下,不安全代码应放在一个对标准库之外依赖很少的独立 crate 中。
添加以 SAFETY: 开头的安全注释,解释为什么代码是正确的。
为不安全代码添加技术论证。根据上下文,这可以是对单个 unsafe 块的论证,或覆盖整个文件/构建目标的更通用论证。论证应回答:为什么这段代不能用等效的安全方式编写?如果原因是性能,是否有基准测试?
不使用不稳定特性
不要使用不稳定特性。
例外:Android 嵌入式编程可能需要某些不稳定特性。虽然启用它们需要评审(以帮助寻找替代方案),但正确构建产物所绝对必需的特性通常会获批准。Chrome 风格仲裁者可能会为整个代码库整体批准一小批特性;通常只有有明确稳定路径的特性才会获批。Fuchsia 见其指导文档。
Getter 和 Setter
Getter 和 Setter 的编写与命名应遵循:
foo():getter,按值返回 T。
foo_mut():getter,返回 &mut T。
take_foo():getter,“清空”该值(设为 None 或其他默认状态)并返回旧值。
set_foo():setter,按值接受 T。
with_foo() / with_mut_foo():通过闭包操作的访问器(详情见下文),调用形式为 object.with_foo(|foo| frob(foo))。
replace_foo():setter,返回旧值。
例子:impl Stuff 中可提供 fn foo(&self) -> &Foo、fn foo_mut(&mut self) -> &mut Foo、fn take_baz(&mut self) -> Option<Baz>(将 "baz" 属性设为 None 并返回旧值)、fn set_foo(&mut self, foo: Foo)、fn with_bar<R>(&self, f: impl FnOnce(&Bar) -> R) -> R(用 "bar" 属性的借用调用 f,返回 f 的返回值)、fn with_mut_bar<R>(&self, f: impl FnOnce(&mut i32) -> R) -> R,以及 fn replace_foo(&mut self, foo: Foo) -> Foo(将 "foo" 属性设为 foo 并返回旧值)。
关于接受闭包的访问器
接受闭包的访问器(.with_foo())通常调用为 object.with_foo(|foo| do_something_with(foo))。它们在以下情况是不错的选择:
属性包含在内部可变性包装器中(例如 Mutex 或 RefCell)。这些机制需要将对属性的访问限制在某个作用域内,闭包提供了这个作用域。这通常比直接返回实现该作用域的 RAII 对象(例如 MutexGuard<T>)更可取,因为后者会泄露实现细节。
属性更改后需要后续工作或验证,且无法使用 set_foo() 访问器时(例如属性的类型克隆代价很高或根本无法克隆)。
with_foo() 访问器通常允许闭包返回值,并通过从 with_foo() 返回该值来转发它。
如果需要提供两个接受闭包的访问器,一个只读访问、一个可变访问,通常命名为 with_foo() 和 with_mut_foo()。否则,with_foo() 也常用于可变访问器。
一起实现预期的 API 集合
如果你实现了 len(),也要实现 is_empty()。相关见 Clippy lint len_without_is_empty。
不要访问你不拥有的、标记为 #[doc(hidden)] 的项
把标记为 #[doc(hidden)] 的项视为私有。除非你拥有该代码,否则不要访问它们。
负责任地使用 const 函数
只有当期望某个公开函数未来仍保持 const(或能在其变化时承诺帮助调用方迁移)时,才把它标记为 const。
理由:把 const 函数变成普通函数是一种破坏性变更。
避免为非数学目的重载
运算符重载应保留其原始数学/逻辑用途,而不只是为了简洁而当作“语法糖”。
对公开暴露的 enum 应用 #[non_exhaustive]
如果某个 enum 是供其他团队使用的公开 API 的一部分,就将其标记为 #[non_exhaustive]。
例外:该 enum 永远不会改变。例如描述现有本体的 enum,如 enum Color { Red, Green, Blue },或只有两种状态、本可以是 bool 的 enum,如 enum Visibility { Visible, Hidden }。
该 enum 未来可能会变,但你承诺通过 LSC 修复由此产生的破坏。
相关背景见 Clippy lint exhaustive_enums。
理由:将 enum 标记为 #[non_exhaustive] 会强制用户在 match 语句中包含通配符分支(_ => ...)。这确保以后给 enum 添加新变体时代码仍能编译。这条规则主要对跨团队边界使用的 enum 重要,因为破坏范围可能很大。只使用很少次的 enum 不会造成大范围破坏,因此可忽略此规则。
对带公开字段的公开结构体应用 #[non_exhaustive]
带公开字段、且属于供其他团队使用的公开 API 的结构体,应标记为 #[non_exhaustive]。
例外:结构体字段不太可能改变,例如因为它们描述现有本体(如 Rectangle 类型),或评审者能确信它们不会改变。
结构体字段未来可能会变,但你承诺通过 LSC 修复由此产生的破坏。
你希望用户能使用函数式记录更新(FRU)结构体初始化语法(Person { name: "foo", ..Default::default() })。
理由:给一个所有字段都公开的结构体添加新字段(即使是私有字段)是破坏性变更,因为调用者无法再在结构体字面量中列出所有字段。标记为 #[non_exhaustive] 的结构体不能通过结构体字面量语法构造,同一 crate 中的代码除外。这允许添加新的私有字段而不破坏用户。这条规则主要对跨团队边界使用的结构体重要;使用很少次的结构体不会造成大范围破坏,因此可忽略。
函数上的 #[must_use]
在以下情况对函数使用 #[must_use]:
如果调用者忽略返回值就无法正确使该函数。这包括纯函数(无副作用的函数),例如 HashMap::get()。
任何返回值表示错误或关于函数行为的关键信息的函数。如果返回类型已标注 #[must_use](例如 Result 就是如此),该函数本身不需要再标注 #[must_use]。
类型上的 #[must_use]
对那些意图被函数返回、且按上述规则本应标注 #[must_use] 的类型使用 #[must_use]。
例子:该类型是错误类型(类似 Result 或 StatusOr);该类型表示惰性求值计算,即函数调用本身不做任何事,必须访问返回值才能产生期望的副作用。
不要在 main() 之前运行代码
不要导致代码在 main() 之前运行,例如使用 ctor crate 之类的技术。
实现关注点
不要过度使用类型推断
添加足够的显式类型,让熟悉代码库的人能推断出被推断的类型是什么。
优先把类型加在变量声明上,而不是写成 turbofish。如果编译器强迫你使用 turbofish,通常加一个 _ 推断标记就可以了。
可能应该加类型的具体情况包括:调用具有泛型返回类型的方法时,例如 .into() 或 .parse();复杂迭代器链的结果。
优先用模式匹配,而不是“先检查再 unwrap”
当输入数据保证不会 panic 时,避免可能 panic 的操作。例如,不要对已知有效的值使用 Option::unwrap,而应使用模式匹配向编译器证明正确性。
好的写法是 if let Some(value) = option { use_value(value); },匹配分支向编译器证明 value 是 Some,显然不可能 panic。不好的写法是 if option.is_some() { use_value(option.unwrap()); },因为 unwrap 的正确性依赖运行时检查 option 是 Some。
优先用 expect 而不是 unwrap
如果因为某个不变量无法在类型系统中表达而必须 panic,优先用 expect 而不是 unwrap,以记录原因。
匹配 non_exhaustive enum 时不要省略模式
在 match 中匹配 non_exhaustive enum 时,优先列出所有 enum 变体。
如果你不关心特定变体,把它们放进通配符分支。如果你只关心特定变体,使用 if let 或 let … else {}。
例如,对于 #[non_exhaustive] pub enum Shape { Circle, Square, Triangle },好的写法是 match shape { Shape::Circle => println!("It's a circle"), Shape::Square => println!("It's a square"), Shape::Triangle | _ => {} };如果只关心 Circle,可用 if let Shape::Circle = shape { println!("It's a circle") }。
例外:你只关心特定变体,并且 match 比 if let 更清晰。这由内置 lint non_exhaustive_omitted_patterns 覆盖,你可以在代码上启用它。也可写成 match shape { Shape::Circle => println!("It's a circle"), _ => {} },但需注意上述 lint。
用上下文字符串澄清断言
虽然有些断言不言自明,但很多断言受益于额外上下文,以澄清为什么断言应该成立,或是什么导致它被违反。使用 assert! 的可选格式化字符串参数来传达这种上下文。
你应始终考虑添加上下文,尤其是在以下情况:
如果你想给断言加注释,就改用格式化字符串:好处是它会出现在日志中。
如果你希望失败的断言被用户看到:此时人类可读的解释比 Rust 表达式更有帮助。
如果记录某个值有助于理解断言为何失败。
为了澄清失败的断言是指:客户端误用了 API(即他们应改变 API 用法),还是实现包含 bug(即客户端应提交 bug 报告)。
例子:解释断言为何应成立,可写 assert!(values.is_sorted(), "values来自PriorityQueue,因此期望它是已排序的")。记录值以澄清失败,可写 assert!(refund > 0, "Refund must be positive, got {refund:?}")。标记 API 误用,可在 Database::execute_query 中写 assert!(self.stream.is_some(), "Call connect() before executing queries")。不要用上下文字符串仅仅重复条件,例如不要写 assert!(!values.is_sorted(), "values should always be sorted")。
当直接迭代更清晰时,不要用迭代器组合子链
避免以损害可读性的方式使用组合子链。具体来说:
避免长而复杂的组合子链。
不要在 for 循环的头部构造组合子链;应把组合子链赋值给一个变量,然后在 for 循环中消费该变量。
避免使用 Iterator::for_each,除非你确信有充分理由。for 循环通常更易读。
对于较短的链,以及组合子本身提供了大量否则必须在对应 for 循环中写出逻辑的情况,组合子更可取。
例如,原本用 items.into_iter().map(transformation_that_may_fail).map(|result| { if result.is_err() { error_count += 1; } result }).filter_map(Result::ok).collect() 这样难以阅读的链,对应 for 循环更易读:let mut error_count = 0; let mut processed_items = Vec::with_capacity(items.len()); for item in items { match transformation_that_may_fail(item) { Ok(val) => processed_items.push(val), Err(_) => error_count += 1, } }。
又比如,避免在 for 循环头部构造:for bug in bug_descriptions.iter().filter_map(|description| parse_bug_description(description).ok()).filter(|bug| bug.priority <= Priority::P1) { assign_bug(bug); };应改为先赋值 let urgent_bugs = bug_descriptions.iter().filter_map(...).filter(...);,再 for bug in urgent_bugs { assign_bug(bug); },这样变量名也记录了组合子链的作用。
适合用组合子链的例子:let threshold_exceeded = values.iter().any(|&v| v > threshold);。对应的 for 循环更难读,且重新发明了 any() 的逻辑。
不要调用 panic::catch_unwind
在第一方 Rust 代码中禁止使用 panic::catch_unwind。
例外:只要绝不依赖它来保证任何属性,作为深度防御机制是可以接受的。已经试图避免 panic 的代码库可以用 catch_unwind 恢复机制包裹关键入口点,以防万一有 panic 漏过。代码作者必须让评审者信服该代码库已尽最大努力避免 panic。此例外仅适用于可能以 panic=unwind 运行的代码(因此不适用于 Chrome、Android、Fuchsia)。
理由:Rust 代码可能用于许多环境,而 panic::catch_unwind 只在 panic=unwind 的环境中能正确工作。虽然生产二进制文件的最终 panic 处置尚未确定,但 Chrome、Android 和 Fuchsia 都要求 panic=abort。
拼接和替换时优先用 format! / write!
在以下情况优先使用 format!() 和 write!():
拼接两个以上字符串时,例如用 format!("{a}{b}{c}") 而不是 a + b + c。
对字面量字符串执行替换时(而不是用 str::replace)。
理由:format! / write! 比用 + 拼接字符串需要的分配更少;优先使用标准格式化字符串,而不是自创模板语法。
使用适合各代码库的测试框架
大多数代码库使用自己特定的测试框架,而不是 #[test]。
Chrome 使用 rust_gtest_interop,使 Rust 测试能集成到 C++ 测试二进制中。
Fuchsia 使用 #[fuchsia::test],详见其文档。
评论
讨论气氛是认可与挑刺并存:不少读者肯定这份 Rust 风格指南把生态常见实践集中整理,也有不少人针对具体规则提出反对。分歧主轴集中在几条“是否为了工程规模而牺牲 Rust 的表达力或编译期保障”的规则上,同时很多人半开玩笑地说,如今风格指南更多是喂给模型和 agent 的指令,而不是给人读的。
认为指南现在主要是喂给 LLM 和 agent 的指令,而不是给人读的
- 在 AI 编程代理普及后,人工逐条阅读风格指南的动力下降;指南更像是复制进 AGENTS.md 或严格约束模型的配置,甚至有人自称已经是“指挥 agent 大军”的将军,只在自己写着玩时才手动写代码。(较多人) > 现在还会关心风格指南,唯一理由就是把它喂给模型,让模型按它干活(@Thanemate)
肯定指南把常见 Rust 实践集中整理、便于统一风格
- 很多规则在 Rust 生态里并不新鲜,但能在一处查齐,对统一项目风格很有帮助;统一的自动格式化也让人更欣赏类似 Gofmt 的做法。(较多人) > 很多内容在 Rust 生态里很常见,但集中在一处很有帮助(@i2talics)
认可指南区分库代码与应用代码,认为应用代码没那么泛型复杂
- 指南区分库代码与应用代码,缓解了“Rust 泛型太复杂”的刻板印象,实际业务代码往往写不到那么复杂或泛型。(少数人) > Rust 因复杂泛型背了太多骂名,但多数应用场景根本写不到那么复杂或泛型(@tcallred)
认为设置方法应使用具体名词,读取方法才加 get 前缀
- 方法名应体现具体任务而不是泛化的“返回”;在 Rust 里读取方法无法与字段同名,因此应让设置方法占用 text、color、fill 这类名词,读取方法退而加 get 前缀。(少数人) > 方法是为了完成具体任务,泛化的读取不该占用无前缀名字,Rust 里同名冲突时应给 getter 加前缀而不是反过来(@incrediblers)
认为存活在调用栈外的引用应直接用 Arc/Rc,别用生命周期硬扛
- 对于需要跨调用栈存活的引用,直接上引用计数智能指针更简单可靠;为了“说服编译器”而把生命周期写复杂是没必要的折腾,这一点在 C++ 里同理。(少数人) > 对于存活在调用栈之外的引用,应该直接用 Rc/Arc,而不是用复杂生命周期说服编译器(@slaymaker1907)
不认同类型名必须不限定导入,主张保留完整路径以保留上下文
- 除非是 HashMap 这类公认类型,否则完整限定名能让读者立刻知道类型来源与语义,避免只看导入甚至依赖语言服务器;同时还能减少不必要的合并冲突。(少数人) > 除非像 HashMap 那样广为人知,否则我更愿意保留完整限定名,这样读代码时就有上下文,不必回头查导入(@epage)
质疑 Chromium 禁止嵌套模块/源文件、推崇极小 crate 的规则
- 把模块和源文件拆得极小,像早年“函数越短越好”一样,会制造一层层没必要的抽象,反而让人看不懂整体逻辑;即便想给 Chromium 善意推定,也觉得这条很怪。(少数人) > 这像早年“函数越小越好”的风气,会让人陷进一层层没必要的抽象里,反而看不懂代码(@monocasa)
反对对公开 enum 使用 non_exhaustive,认为会失去编译时穷尽检查
- enum 的一大价值就是新增分支时编译器会提醒所有未处理的匹配;加 nonexhaustive 后 catch-all 会把漏改从编译错误推迟成运行时问题,省下的开发时间不值。(少数人) > 给公开枚举加 nonexhaustive 会把新增分支漏改从编译错误变成运行时问题,省下的开发时间不值(@japgolly)
认为 Google 已不再拥有超出同侪的权威,其建议不必高看一眼
- Google 的品牌光环在 2010 年代后减弱,如今其推荐和微软、IBM 等大公司差不多,仍然聪明人多、体量大,但并没有高于平均水平的特殊分量。(少数人) > 如今我对他们建议的重视程度,已经和微软、IBM 差不多,只是大公司、聪明人多,并不比同行高出一截(@preommr)
觉得风格指南里出现项目专属变体很讽刺
- 风格指南本应提炼通用实践,里面却带着 Chromium 等项目的专属变体,这种自我矛盾让人觉得讽刺。(少数人) > 风格指南里带项目专属变体,本身就挺讽刺(@wocram)
有价值的补充
- 十六进制字面量用小写更易读:数字和字母混排时,小写字母的字高变化更明显,能降低把 0O、I1、8B、5S 这类字形看错的风险,在印刷不清、光线差或看序列号时尤其重要。(@mmooss)
- 即便是一个人写项目,用 enum 取代布尔值也值得保留;有人靠它在证书校验器里避免过一次标志位反转的错误。(@oddCopper65)
- 关于扩展 trait,有个冷知识:在 trait 上标注 rust-analyzer 的 preferunderscoreimport 属性(且使用 rust-analyzer 而非 RustRover),自动补全和导入辅助会默认把 trait 导入为下划线形式,对扩展 trait 很方便。(@afdbcreid)
- Chromium 直接禁用 async/.await,据说 Rust for Linux 圈子也有类似的非正式共识。(@hitekker)