代码质量约束准则
0. 总原则:代码首先是给人读的,其次才是给机器跑的
Rust 代码质量的核心目标是:
让非法状态难以表示,让错误路径显式可见,让所有权关系清晰,让模块边界稳定,让抽象只承担一个责任。
Rust 的整洁代码不应只是“写得漂亮”,而应同时满足:
| 维度 | Rust 版目标 |
|---|---|
| 可读性 | 名字表达意图,函数短小,模块边界清楚 |
| 正确性 | 用类型系统、所有权、生命周期、Result/Option 表达约束 |
| 可维护性 | 低耦合、少重复、小 API、可测试 |
| 可演进性 | 公共 API 少暴露实现细节,避免破坏性变更 |
| 可诊断性 | 错误、日志、Debug、测试信息足够定位问题 |
| Rust 惯用性 | 遵守 Rust 命名、格式化、trait、模块、错误处理习惯 |
Rust 官方书强调所有权是 Rust 的核心特性,它让 Rust 不依赖 GC 也能提供内存安全保证;因此 Rust 版整洁代码必须把“所有权是否清晰”当作一级质量指标。(Rust 文档)
0.1 报纸式阅读结构:先看标题,再读正文
源文件应像一份排版清楚的报纸:读者打开文件时,先看到标题和导语,马上知道这个文件解决什么问题;继续向下读时,看到一组按业务顺序排列的小节;最后才进入细节、边界处理和测试。
对应到 Rust 代码,推荐阅读层次 如下:
| 报纸层次 | Rust 源文件层次 | 读者应获得的信息 |
|---|---|---|
| 标题 | 模块说明、核心类型、整体功能入口 | 这个文件负责什么,主要能力从哪里开始读 |
| 导语 | 编排函数 | 完整流程有哪些步骤,步骤之间如何连接 |
| 正文 | 一个个命名清楚的步骤函数 | 每一步的业务规则、错误路径和状态变化 |
| 专栏 | 边界转换、辅助函数、底层细节 | 不影响主线阅读的局部实现 |
| 附录 | #[cfg(test)] mod tests | 行为约束和回归用例 |
一个可阅读的源文件应先给出整体功能函数,再让这个函数像目录一样列出内部步骤:
pub fn process_order(input: ProcessOrderInput) -> Result<OrderReceipt, OrderError> {
let order = validate_order(input)?;
let reservation = reserve_inventory(&order)?;
let payment = charge_payment(&order, &reservation)?;
let receipt = persist_receipt(order, reservation, payment)?;
notify_customer(&receipt)?;
Ok(receipt)
}
fn validate_order(input: ProcessOrderInput) -> Result<Order, OrderError> {
// ...
}
fn reserve_inventory(order: &Order) -> Result<Reservation, OrderError> {
// ...
}
fn charge_payment(
order: &Order,
reservation: &Reservation,
) -> Result<Payment, OrderError> {
// ...
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_order_when_inventory_is_insufficient() {
// ...
}
}
这类结构让读者可以先读 process_order 建立全局理解,再按需要跳到某一步。不要把底层 helper、临时转换函数或测试模块放在主入口之前;那会迫使读者先读细节,再反推整体功能。
1. 命名准则
1.1 名字必须表达业务意图,而不是实现细节
必须:
let active_users = repository.find_active_users()?;
let retry_policy = RetryPolicy::exponential_backoff();
禁止:
let data = repo.get()?;
let flag = true;
let x = RetryPolicy::new(1, 3, true);
变量、函数、类型、模块名称必须让读者知道“它代表什么业务概念”或“它要完成什么动作”。
1.2 遵守 Rust 命名惯例
Rust API Guidelines 规定,类型和 trait 通常使用 UpperCamelCase,函数、方法、变量、模块使用 snake_case,常量使用 SCREAMING_SNAKE_CASE。(Rust语言)
| 对象 | 规则 | 示例 |
|---|---|---|
| struct / enum / trait | UpperCamelCase | UserProfile, PaymentStatus, Repository |
| 函数 / 方法 | snake_case | find_user, validate_token |
| 模块 | snake_case | user_service, payment_gateway |
| 常量 | SCREAMING_SNAKE_CASE | MAX_RETRY_COUNT |
| 生命周期 | 短小但有意义 | 'a, 'de, 'src |
| 泛型 | 简洁 | T, E, S, R |
1.3 布尔参数必须替换为有意义的类型
禁止:
create_user(name, true, false);
必须改为:
create_user(
name,
EmailVerification::Required,
WelcomeEmail::Disabled,
);
Rust API Guidelines 明确建议:参数含义应由类型表达,而不是用 bool、裸 u8 或含义模糊的 Option 承载。(Rust语言)
2. 函数准则
2.1 函数只做一件事,并且函数名能概括这件事
一个函数如果需要用“并且”“然后”“同时”描述,通常应拆分。
禁止:
fn process_order(order: Order) -> Result<(), Error> {
validate_order(&order)?;
reserve_inventory(&order)?;
charge_payment(&order)?;
send_email(&order)?;
Ok(())
}
如果这是编排函数,可以保留;但每一步必须是清楚的独立函数。业务细节不能全部堆在一个函数中。
推荐:
fn process_order(order: Order) -> Result<(), OrderError> {
validate_order(&order)?;
let reservation = reserve_inventory(&order)?;
charge_payment(&order, &reservation)?;
notify_customer(&order)?;
Ok(())
}
2.2 整体功能函数应放在源文件开头
一个源文件如果承载一个主要功能,读者应该能在文件开头看到这个功能的入口。入口函数不一定必须是 pub,但它必须表达该文件的主线能力。
禁止:
fn normalize_price(value: i64) -> Money {
// ...
}
fn parse_currency(input: &str) -> Result<Currency, Error> {
// ...
}
#[cfg(test)]
mod tests {
// ...
}
pub fn checkout(input: CheckoutInput) -> Result<Receipt, CheckoutError> {
// main flow hidden after details
}
推荐:
pub fn checkout(input: CheckoutInput) -> Result<Receipt, CheckoutError> {
let request = validate_checkout_input(input)?;
let priced_cart = price_cart(&request)?;
let payment = charge_customer(&request, &priced_cart)?;
let receipt = create_receipt(request, priced_cart, payment)?;
Ok(receipt)
}
fn validate_checkout_input(input: CheckoutInput) -> Result<CheckoutRequest, CheckoutError> {
// ...
}
fn price_cart(request: &CheckoutRequest) -> Result<PricedCart, CheckoutError> {
// ...
}
如果文件包含多个同级入口,应先列出最重要、最常读的入口,再列出次要入口。不要让读者在 helper、常量、测试和底层适配之间寻找“这个文件到底从哪里开始”。
2.3 编排函数只表达流程目录,不夹杂底层细节
编排函数的职责是让读者看懂流程,而不是在一屏代码里完成所有工作。它应该像目录:每一行调用一个表达业务动作的函数,调用顺序就是阅读顺序。
禁止:
pub fn boot_system(fdt: *const u8) -> Result<(), BootError> {
if fdt.is_null() {
return Err(BootError::MissingFdt);
}
let header = unsafe { read_fdt_header(fdt) };
if header.magic != FDT_MAGIC {
return Err(BootError::InvalidFdt);
}
for node in unsafe { iter_fdt_nodes(fdt) } {
if node.name == "memory" {
// parse ranges, align pages, merge regions...
}
}
// initialize IRQ, timer, console, scheduler...
Ok(())
}
推荐:
pub fn boot_system(fdt: NonNull<u8>) -> Result<(), BootError> {
let firmware = read_firmware_tables(fdt)?;
let memory = build_memory_layout(&firmware)?;
let devices = discover_boot_devices(&firmware)?;
initialize_console(&devices)?;
initialize_interrupts(&devices)?;
initialize_timer(&devices)?;
start_scheduler(memory)?;
Ok(())
}
底层细节仍然存在,但被移动到步骤函数中。读者第一遍只需要理解“启动分几步”,第二遍才进入某一步的具体解析。
2.4 函数应按阅读顺序向下展开
同一个源文件内,函数排序应尽量遵循“从主线到细节”的方向:
- 公共入口或该文件的核心入口函数。
- 入口函数直接调用的主要编排步骤。
- 每个步骤内部使用的领域规则函数。
- 边界转换、格式化、错误映射、底层 helper。
#[cfg(test)] mod tests,并放在文件最后。
如果一个 helper 只服务于某个步骤,优先放在该步骤函数之后,而不是统一堆到文件顶部或底部。这样读者沿着入口向下读,就能像阅读目录和正文一样逐层展开。
例外情况需要有明确理由:
- 宏生成或 Rust item 顺序限制要求提前 定义。
- 常量、类型别名或 trait bound 会影响入口函数签名,需要放在入口前帮助理解。
- 多个入口共享同一组重要类型,应先定义这些类型,再给出入口函数。
2.5 函数参数不应过多
超过 3 个参数时,优先考虑:
- 引入配置 struct;
- 引入 builder;
- 引入领域对象;
- 拆分函数职责。
禁止:
fn connect(
host: String,
port: u16,
timeout_ms: u64,
use_tls: bool,
retry_count: u8,
) -> Result<Client, Error>
推荐:
let client = ClientConfig::new(host, port)
.timeout(Duration::from_secs(3))
.tls(TlsMode::Required)
.retries(3)
.connect()?;
Rust API Guidelines 也建议复杂对象使用 builder,尤其是参数多、可选项多、有副作用或有多种构造方式的类型。(Rust语言)
2.6 不使用输出参数
禁止:
fn parse_user(input: &str, output: &mut User) -> Result<(), ParseError>
推荐:
fn parse_user(input: &str) -> Result<User, ParseError>
Rust 中返回值、元组、struct、Result<T, E> 已足够表达结果。输出参数会降低可读性,也让所有权更难判断。
2.7 优先让调用方控制分配
公共 API 不应强迫调用方接受不必要的 String、Vec、clone 或堆分配。
优先:
fn find_user(id: UserId) -> Result<User, Error>;
fn parse(input: &str) -> Result<Command, ParseError>;
fn write_report<W: Write>(writer: W, report: &Report) -> io::Result<()>;
谨慎:
fn parse(input: String) -> Result<Command, ParseError>;
除非函数确实需要取得所有权,否则优先接收借用。
3. 注释与文档准则
3.1 注释解释“为什么”,不是复述“做了什么”
禁止:
// increment i by 1
i += 1;
推荐:
// The upstream API uses 1-based page numbers.
let external_page = internal_page + 1;
代码能表达“做了什么”;注释应解释背景、约束、边界条件、权衡和历史原因。
3.2 公共 API 必须有 rustdoc
公共的 struct、enum、trait、函数、模块必须说明:
- 它解决什么问题;
- 如何使用;
- 什么时候返回错误;
- 什么时候 panic;
- 如果有
unsafe,调用方必须满足什么安全条件。
Rust API Guidelines 建议公共项提供 rustdoc 示例,错误条件放在 # Errors,panic 条件放在 # Panics,unsafe 函数放在 # Safety。(Rust语言)
示例:
/// Loads a user by id.
///
/// # Errors
///
/// Returns [`UserError::NotFound`] if the user does not exist.
/// Returns [`UserError::Storage`] if the repository cannot be accessed.
pub fn load_user(id: UserId) -> Result<User, UserError> {
// ...
}
3.3 文档示例不能滥用 unwrap
文档示例经常会被用户复制。Rust API Guidelines 建议示例使用 ?,而不是 unwrap。(Rust语言)
推荐:
/// ```rust
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let config = Config::from_file("app.toml")?;
/// # Ok(())
/// # }
/// ```
4. 格式化与风格准则
4.1 格式统一交给 rustfmt
所有项目必须使用:
cargo fmt --all --check
Rust Style Guide 说明,统一格式能减少沟通成本和认知负担,rustfmt 使用 Rust Style Guide 作为默认风格参考。(Rust 文档)
推荐团队不要为缩进、换行、大括号风格争论;这些交给工具。
4.2 使用 Clippy 检查常见错误和复杂写法
5. 错误处理准则
5.1 可恢复错误必须用 Result
禁止:
fn load_config() -> Config {
std::fs::read_to_string("config.toml").unwrap();
// ...
}
推荐:
fn load_config(path: &Path) -> Result<Config, ConfigError> {
let content = std::fs::read_to_string(path)?;
Config::parse(&content)
}
Rust 官方书说明,unwrap 在 Err 时会 panic;生产代码中即使确实认为不会失败,也更推荐使用带上下文的 expect,以便假设被破坏时更容易诊断。(Rust 文档)
5.2 unwrap 只能出现在受控位置
允许:
#[test]
fn parses_valid_user_id() {
let id = UserId::parse("u_123").unwrap();
assert_eq!(id.as_str(), "u_123");
}
谨慎允许:
let regex = Regex::new(r"^\d+$").expect("hard-coded regex must be valid");
禁止:
let user = repository.find_user(id).unwrap();
生产路径中的失败必须显式传播、转换或处理。
5.3 库用具体错误类型,应用可用聚合错误
库 crate 的公共 API 应返回具体错误类型:
pub fn parse_user(input: &str) -> Result<User, UserParseError>
应用层可以使用聚合错误:
fn main() -> anyhow::Result<()> {
run()
}
公共错误类型应实现 std::error::Error、Display、Debug,并尽可能满足 Send + Sync + 'static。Rust API Guidelines 明确建议公共 Result<T, E> 的错误类型要有意义、可表现良好,并且不要使用 () 作为错误类型。(Rust语言)
库 crate、组件 crate、领域 crate 和硬件抽象 crate 的非平凡 public error enum,默认使用 workspace 里的 thiserror 派生 Error,把用户可读错误消息写在 #[error("...")] 上。只有极小、强依赖敏感或无法接受 proc-macro 依赖的 crate,才手写 Display 和 core::error::Error。
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
pub enum UserParseError {
#[error("user id is empty")]
EmptyId,
#[error("invalid user age")]
InvalidAge,
}
不要在可使用 thiserror 的 lib 类型 crate 中 重复手写机械的 Display match;这会让错误消息、文档和变体维护分散到多个位置。
5.4 panic! 只用于程序员错误或不可恢复不变量破坏
允许:
assert!(capacity > 0, "capacity must be positive");
禁止把业务失败写成 panic:
panic!("user not found");
应改为:
return Err(UserError::NotFound(id));
6. 类型系统准则
6.1 用类型表达约束,不靠注释和约定
禁止:
fn transfer(amount: i64, currency: String)
推荐:
fn transfer(amount: Money)
pub struct Money {
cents: NonZeroI64,
currency: Currency,
}
Rust API Guidelines 推荐用 newtype 区分同一底层类型的不同语义,例如英里和公里,避免把含义不同的值混用。(Rust语言)
6.2 非法状态应尽量无法构造
禁止:
pub struct User {
pub email: String,
pub age: i32,
}
推荐:
pub struct User {
email: Email,
age: Age,
}
impl User {
pub fn new(email: Email, age: Age) -> Self {
Self { email, age }
}
}
公共字段会把内部表示暴露给调用方,使类型无法维护不变量。Rust API Guidelines 建议除被动数据结构外,struct 字段应保持私有,通过构造函数和方法维护约束。(Rust语言)
6.3 使用 enum 表达封闭集合
当所有变体在当前领域内已知时,优先使用 enum,而不是 trait object。
enum PaymentMethod {
Card(CardPayment),
BankTransfer(BankTransfer),
Wallet(WalletPayment),
}
适合 enum 的情况:
- 状态集合固定;
- 调用方需要 exhaustive match;
- 每个变体数据不同;
- 不希望外部扩展新类型。
适合 trait 的情况:
- 实现类型开放;
- 插件式扩展;
- 调用方可定义自己的类型;
- 只依赖共同能力而非具体数据。
7. 模块与边界准则
7.1 模块不是文件夹,而是边界
Rust 模块应围绕领域边界组织,而不是机械地按技术层拆散。
推荐:
src/
user/
mod.rs
entity.rs
repository.rs
service.rs
error.rs
payment/
mod.rs
method.rs
gateway.rs
error.rs
模块内聚目标:
- 同一模块内的类型经常一起变化;
- 模块对外暴露少量稳定接口;
- 模块内部细节默认私有;
- 跨模块调用通过明确 API,而不是到处
pub。
Rust 的可见性机制支持 pub(crate)、pub(super)、pub(in path) 等范围限制,适合表达“只在 crate 内可见”或“只给父模块使用”的边界。(Rust 文档)
7.2 默认私有,按需公开
禁止:
pub struct UserService {
pub repository: UserRepository,
pub cache: Cache,
}
推荐:
pub struct UserService {
repository: UserRepository,
cache: Cache,
}
impl UserService {
pub fn new(repository: UserRepository, cache: Cache) -> Self {
Self { repository, cache }
}
}
7.3 第三方依赖必须隔离在边界层
不要让外部库类型污染整个业务层。
禁止:
pub fn create_user(row: sqlx::postgres::PgRow) -> Result<User, sqlx::Error>
推荐:
pub trait UserRepository {
fn find_by_id(&self, id: UserId) -> Result<Option<User>, UserRepositoryError>;
}
基础设施层负责把 sqlx::Error 转成领域错误。
7.4 单文件内也要有阅读顺序
模块边界解决“哪些代码应该放在一起”,单文件顺序解决“读者应该怎样进入这些代码”。同一个 Rust 源文件推荐按以下顺序组织:
| 顺序 | 内容 | 目的 |
|---|---|---|
| 1 | 模块级 rustdoc、必要use、核心类型和错误类型 | 建 立上下文 |
| 2 | 该文件最重要的入口函数或公共 API | 先看到整体功能 |
| 3 | 入口函数直接调用的步骤函数 | 按目录顺序展开主线 |
| 4 | 步骤内部 helper、转换函数、错误映射和底层细节 | 支撑局部实现 |
| 5 | #[cfg(test)] mod tests | 把验证作为附录放在文件最后 |
禁止:
fn parse_flags(raw: u64) -> Flags {
// low-level helper
}
fn align_down(addr: usize) -> usize {
// low-level helper
}
pub fn map_user_region(request: MapRequest) -> Result<Mapping, MapError> {
// main entry appears too late
}
推荐:
pub fn map_user_region(request: MapRequest) -> Result<Mapping, MapError> {
let request = validate_map_request(request)?;
let pages = allocate_user_pages(&request)?;
let mapping = install_page_table_entries(&request, pages)?;
Ok(mapping)
}
fn validate_map_request(request: MapRequest) -> Result<ValidatedMapRequest, MapError> {
// ...
}
fn allocate_user_pages(
request: &ValidatedMapRequest,
) -> Result<PageAllocation, MapError> {
// ...
}
底层 helper 不是不能存在,而是不应该抢在主线之前出现。读者应先知道“这个模块做什么”,再读“它如何做到”。
7.5 lib.rs、mod.rs 和领域模块的组织方式
lib.rs 和 mod.rs 是读者进入 crate 或领域模块的目录页,应避免变成无序的 re-export 仓库。
推荐规则:
lib.rs先写 crate 级说明,再声明内部模块,最后 re-export 稳定入口。mod.rs先说明该领域模块的职责,再列出子模块和对外 API。- 领域模块文件先给核心类型和入口函数,再展开领域步骤。
utils、helpers、common这类泛名模块只在确实跨领域共享且职责清楚时使用。- 测试模块不应插在生产代码中间;单元测试放源文件最后,集成测试放
tests/。
示例结构:
src/
lib.rs # crate 说明、模块声明、稳定 re-export
process/
mod.rs # process 领域入口和子模块目录
lifecycle.rs # 入口函数在前,状态转换步骤向下展开
signal.rs # signal 规则和边界
error.rs # process 领域错误
如果 mod.rs 已经长到需要滚动多屏才能看完入口,说明它承担了太多实现细节,应把具体逻辑下沉到领域文件中。
7.6 源文件过大必须拆分
Rust 源文件不是越集中越好。一个文件承担太多职责时,读者需要同时记住入口、状态、错误、边界适配、helper、测试和外部依赖,报纸式阅读结构会失效。
推荐把文件大小作为 code review 的触发线,而不是机械的 CI 硬限制:
| 信号 | 处理方式 |
|---|---|
| 约 400 行以上 | 审视是否混入多种变化原因,优先拆出错误、状态、适配或测试 |
| 约 800 行以上 | 必须拆分,或在评审中说明为什么当前文件仍是单一职责 |
| 一个文件同时有入口、状态机、外部 IO、错误转换、测试 helper | 按职责拆文件 |
mod.rs / lib.rs 承载大量业务实现 | 下沉到领域文件,入口页只保留模块声明和稳定导出 |
拆分时不要按“代码行数平均分”切文件,而要按变化原因切。Robert C. Martin 对 Single Responsibility Principle(SRP)的解释是:一个模块应只对一类 actor 或一类变化原因负责。落到 Rust 中,就是同因变化的类型和函数放在一起,异因变化的实现拆到不同文件。(Clean Coder)
常见拆分方向:
| 变化原因 |
|---|