本文永久链接 – https://tonybai.com/2026/10/28/google-rust-style-guide-large-scale-engineering

大家好,我是Tony Bai。

【导读】

Rust 社区有一条广为人知的原则:代码应该遵循社区约定,格式交给 rustfmt,API 设计则参考 Rust API Guidelines。

但如果你需要在一个拥有数百万行代码、多个语言共存、依赖关系复杂,并且必须长期维护的超大型代码库中使用 Rust,仅仅遵循这些规则就足够了吗?

Google 给出了自己的答案。

2026 年 10 月,Google 公开了 Rust Style Guide。这份指南的特别之处在于,它并不满足于规定缩进、命名和格式,而是将代码组织、所有权设计、API 演进、错误处理、宏使用和可维护性纳入统一的工程规范。

其中有些建议相当反直觉:优先拆分小 Crate、尽量避免 Rc 和 Arc、不要为了组织代码而引入 Trait、公共枚举优先考虑 #[non_exhaustive],甚至在 Chromium 中明确禁止使用 async/.await。

这些规定并非都适用于每个 Rust 项目。它们背后有着具体的工程环境和取舍。

指南发布后,Hacker News 上也迅速出现了争论。开发者围绕小 Crate 是否会导致过度拆分、#[non_exhaustive] 是否削弱编译器检查能力、类型导入方式是否影响可读性等问题展开讨论。

真正值得研究的,并不是我们应不应该照抄 Google 的规范,而是一个更深层的问题:当软件的主要成本从编写代码转向理解、修改和维护代码时,一家超大规模工程组织会如何重新定义好的代码?

【文章要点】

  • Google 的 Rust Style Guide 不只是格式规范,更是大规模工程治理规则。它强调代码可读性、长期可维护性和自动化重构能力,尤其关注跨大型代码库执行批量变更(LSC)的成本。
  • 优先拆分小 Crate,建立清晰的依赖边界。Chromium 倾向于使用细粒度 Crate,以改善依赖管理、并行编译和构建缓存;但这是一项有特定工程背景的建议,并不意味着所有 Rust 项目都应该无限拆分。
  • 优先使用借用和唯一所有权,避免过早引入 Rc、Arc 和深拷贝。共享所有权应该源于真实需求,而不是成为绕过所有权设计问题的默认手段。
  • 设计更灵活、更清晰的 API。优先使用 &str、&[T] 等通用借用类型;通过 enum 表达明确的业务状态,并合理区分 Option、Result 与 panic! 的适用场景。
  • 为公共 API 的未来演进预留空间。Google 建议对跨团队使用的公共枚举和特定公共结构体考虑使用 #[non_exhaustive],以降低新增变体或字段导致大规模兼容性问题的风险,同时也需要权衡穷尽匹配带来的编译期保障。
  • 克制使用 Trait、宏和复杂抽象。优先采用普通函数、类型和模块;只有在确实需要代码生成、泛型约束或运行时多态时,才引入相应机制。抽象应当降低理解成本,而不是增加额外的语言复杂度。
  • 部分规则是特定代码库的工程选择。例如,Chromium 明确限制 async/.await 的使用。这反映的是特定项目的运行时和基础设施约束,而不是 Rust 异步编程的普遍缺陷。
  • Hacker News 的争论揭示了工程设计中的真实权衡。小 Crate 与过度拆分、API 演进与穷尽匹配、类型导入与可读性,都没有脱离上下文的唯一答案。
  • 普通 Rust 项目应借鉴原则,而非照搬全部规则。可以优先采用格式检查、标准 Trait、清晰文档和合理借用等低成本实践;至于 Crate 粒度、公共 API 兼容策略和并发模型,则应结合自身项目规模与构建环境决定。

Google 为什么需要一份自己的 Rust 风格指南?

先看一个容易被忽略的事实:Google 并不是在重新发明 Rust 的编码规范。

官方指南明确要求,除非与 Google 的规则冲突,否则应遵循 Rust 社区公开的风格规范,包括官方 Rust Style Guide 和 Rust API Guidelines。

那么,既然社区已经有了规范,Google 为什么还要额外制定一套规则?

答案藏在它的工程规模中。

Google 的 Rust 代码分布在多个工程领域,包括 Google 内部代码库、Android、ChromeOS、Fuchsia,以及 Chromium 等项目。不同团队有各自的历史、工具链和代码组织方式。即使大家使用同一种语言,也可能对模块划分、API 设计、错误处理和依赖管理形成不同习惯。

在小型项目中,这种差异通常不会造成严重问题。但在超大型代码库中,局部的设计选择可能产生全局影响。

例如,一个看似方便的公共 API,可能在未来每次修改时都要求大量调用方同步调整;一个庞大的公共工具库,可能让只需要字符串处理的程序也间接依赖大量无关功能;一种需要人工理解的代码生成模式,则可能让跨目录、跨模块的自动化重构变得困难。

Google 的指南因此提出了几个明确目标:

  • 可读性与可维护性优先于编写时的便利。写代码时多做一点约束,如果能够降低未来的理解成本,就值得考虑。
  • 尽可能有利于自动化。统一的代码结构能够帮助工具执行大规模修改。
  • 尽量与 Rust 社区保持一致。只有能够产生实际收益时,才值得引入额外规则。
  • 每条规则都应该有价值。规范不应仅仅为了体现严格而增加开发者的负担。

这里最值得关注的是第二点:大规模变更,英文称为 Large-Scale Changes,简称 LSC。

在大型工程中,修改代码并不总是一个开发者编辑一个文件。可能需要统一更改公共 API、迁移依赖、调整类型定义,甚至对整个代码库执行机械化重构。

当这种工作成为日常活动时,代码风格就不再只是审美问题。

它会直接影响自动化工具能否准确理解代码、修改是否容易验证,以及一次看似局部的变更会影响多少下游组件。

可以把 Google 的思路概括为:

代码风格的一致性
        ↓
更明确的结构和边界
        ↓
更容易分析与自动化修改
        ↓
降低大规模变更的成本与风险
        ↓
提升长期维护效率

这也是理解整份指南的起点。

第一条重要分歧:为什么 Google 偏爱小 Crate?

如果要从整份指南中选出最容易引发争论的建议,Chromium 的小 Crate 策略无疑是其中之一。

Google 在 Chromium 的工程规范中建议优先使用小 Crate,理想情况下,一个 Crate 甚至可以只包含一个源文件,并且聚焦于单一抽象。

同时,Chromium 规定,同一个 rust_library 对应的 Rust 源文件应放在同一个目录下,不应通过创建子目录来组织这个 Crate 内部的文件。

这与很多 Rust 开发者熟悉的项目结构并不完全相同。

传统 Rust 项目的组织方式

使用 Cargo 开发应用时,我们通常会先创建一个 Crate,再通过模块(mod)划分代码。

例如:

my-app/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── config.rs
    ├── network.rs
    ├── storage.rs
    └── metrics.rs

当项目变大时,还可以进一步创建子模块:

src/
├── main.rs
├── network/
│   ├── mod.rs
│   ├── client.rs
│   └── retry.rs
└── storage/
    ├── mod.rs
    └── database.rs

这种结构很自然。模块可以表达逻辑边界,开发者不必为每个小组件创建独立的 Cargo 包。

但 Google 的工程环境中,Crate 的粒度还有另一层意义。

在大型代码库中,Crate 是构建边界

Rust 的 Crate 是编译单元。大型构建系统通常还会将其作为组织依赖、执行并行构建和管理构建缓存的重要边界。

假设一个大型项目拥有下面这样的依赖关系:

应用可能只需要网络模块,却依赖了整个 common Crate。

随着项目发展,射频模块的修改也可能波及两者共享的构建边界,依赖关系变得越来越难以控制。实际的重新编译范围还取决于构建系统、缓存和具体修改,不能简单地认为任何源文件变更都会导致整个 Crate 全量重编译,但粗粒度边界确实可能带来额外成本。

如果将不同职责拆成独立 Crate,依赖关系就可以变得更加明确:

这样,应用只需声明真正需要的依赖,射频模块也不再天然成为网络工具的组成部分。

小 Crate 还可以带来几个额外好处:

  • 限制跨组件的依赖关系。
  • 缩小公共 API 的范围。
  • 避免模块之间形成难以察觉的依赖循环。
  • 让构建系统拥有更细粒度的并行构建和缓存机会。
  • 让组件能够独立理解、测试和演进。

Hacker News 上有开发者指出,在一些大型构建系统中,创建一个独立库并不一定需要像普通 Cargo 项目那样承担完整的目录和清单管理成本。Crate 因而可以成为一种轻量级的架构边界,而不仅仅是代码打包方式。

另一些讨论则强调,模块依赖与 Crate 依赖并不完全相同。Rust 模块内部仍然可以组织复杂的数据结构和抽象,拆分 Crate 不等于要求所有函数都缩短到一两行。

这一区别非常重要。

Google 倡导的是更细粒度的库和依赖边界,而不是把所有抽象都切碎。

小 Crate 不等于无限拆分

Google 的规则也没有把小 Crate 绝对化。

指南明确指出,Trait coherence 等规则可能要求某些类型和 Trait 保持在同一个库中。基础库即使规模较大,也可能是合理的。

换句话说,拆分 Crate 不是一项脱离语言语义的机械任务。

Rust 的孤儿规则限制了我们能够为哪些类型实现哪些 Trait。如果把类型和相关 Trait 拆到不合适的 Crate 中,原本自然的实现可能变得不合法,或者迫使开发者设计出更复杂的架构。

因此,Google 给出的顺序是:先判断 API 是否真的应该属于同一个 Crate,再考虑是否需要通过模块组织实现细节。

这与一种常见的过度设计形成了鲜明对比:先构造复杂的模块树,再希望这些模块天然拥有良好的边界。

对于 Google 而言,真正重要的边界是构建和依赖边界,而不是目录树看起来是否足够漂亮。

需要强调的是,这些关于小 Crate 和扁平文件布局的规则带有明确的 [chrome] 标记,主要针对 Chromium 的工程环境,并非 Google 要求所有 Rust 项目都采用同样的文件布局。

对于普通 Cargo 项目,一个拥有清晰模块结构的中型 Crate 完全可能是更好的选择。

少用 Rc、Arc 和 clone():所有权设计应该从简单开始

Rust 最有特色的机制之一,就是通过所有权和借用检查,在编译阶段发现许多内存安全问题。

但 Rust 开发者也很容易形成一种习惯:遇到所有权问题,就尝试使用 Rc、Arc 或 clone() 让代码先编译通过。

Google 的指南对此给出了明确建议:优先考虑唯一所有权和借用,而不是过早引入共享所有权。

为什么 Arc 不应该成为默认选项?

看一个完整、可运行的例子。

下面的代码将一份只读配置传递给多个函数:

struct Config {
    service_name: String,
    timeout_ms: u64,
}

fn print_service(config: &Config) {
    println!("Service: {}", config.service_name);
}

fn print_timeout(config: &Config) {
    println!("Timeout: {} ms", config.timeout_ms);
}

fn main() {
    let config = Config {
        service_name: "payment".to_string(),
        timeout_ms: 3000,
    };

    print_service(&config);
    print_timeout(&config);
}

这里没有使用 Arc,也没有复制整个配置对象。

两个函数通过不可变引用读取同一个对象。只要调用关系和生命周期允许,Rust 就能够安全地管理这种访问。

现在,如果开发者习惯性地使用 Arc<Config>,代码可能变成这样:

use std::sync::Arc;

struct Config {
    service_name: String,
    timeout_ms: u64,
}

fn print_service(config: &Arc<Config>) {
    println!("Service: {}", config.service_name);
}

fn main() {
    let config = Arc::new(Config {
        service_name: "payment".to_string(),
        timeout_ms: 3000,
    });

    print_service(&config);
}

这段代码同样可以运行,但它并没有体现共享所有权的必要性。

Arc 的核心用途,是允许多个所有者共同持有一个对象,并通过原子引用计数管理其生命周期。它本身不意味着对象的内部数据可以任意并发修改,也不能自动解决所有线程安全问题。

如果程序只是在一个明确的调用链中读取配置,那么普通借用通常更简单。

Google 希望开发者先问一个问题:我们真的需要多个独立的所有者,还是只需要在适当的生命周期内访问同一个对象?

两者并不是一回事。

clone() 不是借用问题的通用解法

另一个常见问题,是靠复制数据绕开借用检查带来的困难。

例如:

fn print_name(name: &String) {
    println!("{name}");
}

fn main() {
    let name = String::from("Rust");

    print_name(&name.clone());
}

这段代码可以编译,但为了让函数读取字符串,我们没有必要复制整个 String。

更符合 Rust 惯例的方式是使用 &str:

fn print_name(name: &str) {
    println!("{name}");
}

fn main() {
    let name = String::from("Rust");

    print_name(&name);
    print_name("Google Rust");
}

这个版本更加灵活。调用者可以传入已有 String 的借用,也可以直接传入字符串字面量。

Google 的指南进一步建议,在函数接收不可变数据时,尽可能接受更通用的借用类型。

不太灵活的参数类型 更灵活的参数类型
&String &str
&Vec<T> &[T]
&Box<T> &T
&Option<T> Option<&T>

例如,函数需要读取一组整数时:

fn sum(values: &[i32]) -> i32 {
    values.iter().sum()
}

fn main() {
    let numbers = vec![1, 2, 3, 4];

    println!("{}", sum(&numbers));
    println!("{}", sum(&[10, 20, 30]));
}

这里的 sum 不关心调用者使用的是 Vec<i32>,还是其他能够提供切片的数据结构。

这不仅减少了不必要的类型约束,也避免了让函数接口与某种具体容器绑定。

当然,Google 并没有要求绝对禁止 clone()。

当确实需要独立拥有一份数据时,复制就是合理的选择。如果借用关系已经变得过度复杂,复制有时也可能是更清晰的工程决策。对于 Rc 和 Arc,指南同样保留了在其他所有权方案难以理解或维护时使用它们的空间。

关键不是把某个 API 列入黑名单,而是建立正确的默认顺序:

  1. 先考虑是否可以通过借用解决问题。
  2. 再判断是否确实需要独立所有权。
  3. 如果需要多个所有者,再选择适当的共享所有权机制。
  4. 最后根据实际需求决定是否复制数据。

Rust 的所有权系统不应该只是一个需要绕过去的编译器障碍,它本身就是帮助我们设计程序结构的工具。

Google 对 API 的态度:不要把未来的复杂性提前塞进今天的代码

Google 的指南并不只是关心函数实现,还特别强调 API 设计。

不过,它首先区分了两种代码:库代码与应用代码。

库代码通常需要面对多个使用者,设计者很难控制未来的调用方式,因此需要更加重视稳定性、通用性和演进空间。

应用代码则主要服务于特定业务。对于应用,指南明确建议避免过早泛化和抽象,在满足当前需求的前提下优先保持简单、直接。

这意味着 Google 并不是要求所有代码都设计成高度通用的框架。

相反,它试图避免两种方向相反的错误:公共库设计不足,导致未来难以演进;业务代码过度抽象,导致当前需求也难以理解。

为什么函数参数更喜欢 &str 和 &[T]?

前面已经展示了借用类型的优势。对于公共 API,这种设计还具有更深层的意义。

假设一个库只接受 &String:

fn normalize_name(name: &String) -> String {
    name.trim().to_lowercase()
}

虽然函数本身能够工作,但它把调用方限制在一个不必要的类型要求上。

改成下面这样:

fn normalize_name(name: &str) -> String {
    name.trim().to_lowercase()
}

fn main() {
    let owned = String::from("  Rust  ");

    println!("{}", normalize_name(&owned));
    println!("{}", normalize_name("  Google  "));
}

调用者可以使用不同的数据来源,而不需要先把所有输入统一转换成 String。

这种设计体现了 API 的一个基本原则:约束应该来自业务语义,而不是实现细节。

enum 优于 bool:让接口表达真正的意图

另一个很值得关注的建议,是在 API 中优先使用 enum,而不是含义模糊的 bool。

例如,假设一个日志系统需要决定是否记录详细信息。

一个简单的接口可能是:

fn configure_logging(verbose: bool) {
    if verbose {
        println!("Verbose logging enabled");
    } else {
        println!("Normal logging enabled");
    }
}

fn main() {
    configure_logging(true);
}

问题在于,调用者看到 true 时,必须知道它究竟代表什么。

当参数变多时,问题会更加明显:

configure_logging(true, false, true);

这三个布尔值究竟对应哪些选项?调用方很难一眼看明白。

如果使用枚举:

#[derive(Debug, Clone, Copy)]
enum LogLevel {
    Normal,
    Verbose,
    Debug,
}

fn configure_logging(level: LogLevel) {
    match level {
        LogLevel::Normal => println!("Normal logging enabled"),
        LogLevel::Verbose => println!("Verbose logging enabled"),
        LogLevel::Debug => println!("Debug logging enabled"),
    }
}

fn main() {
    configure_logging(LogLevel::Verbose);
}

现在,接口明确表达了业务语义。

调用者不必查阅文档,也不需要猜测布尔值的含义。

枚举还有一个重要优势:它可以将一组相关状态集中到类型定义中,让编译器帮助检查状态处理逻辑。

需要注意,这并不意味着所有布尔参数都应该改成枚举。如果一个布尔值本身具有清晰的语义,例如 is_empty 的返回值,那么继续使用 bool 完全合理。

Google 的真正意图,是让公共 API 的表达能力与其业务含义相匹配。

Option、Result 和 panic! 应该各司其职

Google 对错误处理也给出了清晰的分类。

  • Option<T>:操作可能失败,但调用者不需要知道具体原因。
  • Result<T, E>:操作可能失败,而且调用者需要知道错误原因,并有机会进行恢复。
  • panic!:程序遇到了违反不变量的情况,通常意味着存在应该修复的程序缺陷。

下面是一个完整示例:

use std::num::ParseIntError;

fn parse_port(input: &str) -> Result<u16, ParseIntError> {
    input.parse::<u16>()
}

fn find_port(config: &[(&str, &str)], key: &str) -> Option<u16> {
    config
        .iter()
        .find(|(name, _)| *name == key)
        .and_then(|(_, value)| value.parse::<u16>().ok())
}

fn main() {
    match parse_port("8080") {
        Ok(port) => println!("Port: {port}"),
        Err(error) => println!("Invalid port: {error}"),
    }

    let config = [("http", "8080"), ("admin", "9000")];

    println!("HTTP: {:?}", find_port(&config, "http"));
    println!("Missing: {:?}", find_port(&config, "missing"));
}

这里,parse_port 保留了错误信息;find_port 则将查找不到配置和解析失败统一视为没有可用结果。

这两种接口服务于不同的需求。

在真实系统中,如果端口配置来自用户输入、配置文件或网络请求,通常应该保留足够的错误信息,以便调用者诊断和恢复。相反,如果某个条件属于程序自身必须满足的不变量,那么通过 panic! 暴露缺陷可能比悄悄返回一个默认值更合适。

指南也承认,不同代码库可能对 panic 有更严格的限制。

因此,重点不是简单地选择一种错误处理风格,而是明确每个 API 对失败的承诺。

一个容易引发争议的规则:公共枚举为什么应该考虑 #[non_exhaustive]?

这是 Hacker News 讨论中最值得深入研究的主题之一。

Google 建议,对公开暴露的枚举使用 #[non_exhaustive]。

一些开发者认为,这会削弱 Rust 枚举最有价值的特性之一:当库增加一个新变体时,编译器能够提醒调用者更新所有相关的 match 分支。

这个担忧并非没有道理。

要理解分歧,首先需要区分两个不同的问题:编译器能否检查调用者是否处理了当前所有情况,以及库作者能否在不破坏下游代码的前提下增加新的情况。

这两个目标并不总是一致。

没有 #[non_exhaustive] 时会发生什么?

假设一个库定义了下面的枚举:

pub enum Status {
    Ready,
    Busy,
}

另一个项目可能这样使用它:

match status {
    Status::Ready => println!("ready"),
    Status::Busy => println!("busy"),
}

因为这个枚举已经被完整匹配,所以调用方可以依赖编译器检查所有变体。

但是,如果库作者后来增加一个变体:

pub enum Status {
    Ready,
    Busy,
    Offline,
}

下游代码可能需要修改才能重新编译。

从调用方的角度看,这是一项有价值的保护机制。新状态出现时,编译器迫使开发者重新审视原来的处理逻辑。

但从公共库作者的角度看,这也意味着增加一个变体可能成为破坏性变更。

#[non_exhaustive] 改变了什么?

如果库作者在公开枚举上添加这个属性:

#[non_exhaustive]
pub enum Status {
    Ready,
    Busy,
}

外部 Crate 在匹配这个枚举时,就必须考虑未来出现额外变体的可能性。

例如:

match status {
    Status::Ready => println!("ready"),
    Status::Busy => println!("busy"),
    _ => println!("other status"),
}

这使得库作者未来增加变体时,不必要求所有下游项目都显式列出新情况。

但代价也很明确:调用者可以使用通配分支,从而不再被迫针对每一个新变体编写独立处理逻辑。

如果新变体代表一种需要特别处理的安全状态、业务状态或错误类型,那么通配分支可能掩盖本应在编译时暴露的问题。

因此,HN 上的反对意见抓住了一个真实的权衡:API 的演进自由度,与调用者对每种状态进行显式处理的能力,存在张力。

那么,Google 为什么仍然倾向于这种设计?

原因在于公共库的演进方式。

如果一个枚举属于内部应用,调用方和定义方通常由同一个团队控制,那么强制穷尽匹配可能非常有价值。

但如果这是一个面向大量外部使用者的公共库,库作者无法保证每个调用方都能及时升级和修改代码。此时,预留未来增加变体的空间,可以降低库升级时的协调成本。

两种设计并没有绝对的胜负。

使用场景 更值得考虑的方向
内部应用,所有调用方都由同一团队维护 强穷尽匹配
公共库,预计未来会增加枚举变体 #[non_exhaustive]
每个变体都需要独立、安全的业务处理 谨慎使用通配分支
状态集合长期稳定,且新增状态应触发全局审查 保留显式穷尽匹配的价值

还需要补充一个细节:#[non_exhaustive] 不仅可以用于枚举,也可以用于公开结构体。对于带有公开字段的结构体,它同样能够为未来增加字段预留空间。

不过,这个属性并不意味着结构体或枚举可以随意修改。类型的可见性、字段访问方式、构造方式以及具体变更仍然会影响兼容性。

真正值得借鉴的,是 Google 将 API 演进成本纳入类型设计的思路。

Rust 的类型系统能够帮助我们尽早发现问题,但公共库作者也必须考虑,哪些问题应该在编译时交给调用者处理,哪些变化应该由库本身吸收。

少用 Trait、宏和复杂抽象:不是所有扩展能力都值得保留

Rust 拥有非常强大的抽象能力。

Trait 可以描述行为,泛型可以提供静态多态,dyn Trait 可以提供动态分发,宏则允许开发者在编译阶段生成代码。

这些能力是 Rust 的重要优势,但 Google 的指南提醒开发者:强大的语言能力不意味着应该尽可能多地使用它们。

不要仅仅为了组织代码而定义 Trait

假设我们有一个简单的结构体:

struct User {
    name: String,
}

impl User {
    fn greeting(&self) -> String {
        format!("Hello, {}!", self.name)
    }
}

fn main() {
    let user = User {
        name: "Rust".to_string(),
    };

    println!("{}", user.greeting());
}

这段代码不需要任何 Trait。

如果只是为了把 greeting 方法放到一个看起来更加抽象的接口中,我们可能会写出额外的 Trait 定义和实现。

但如果系统没有真正需要泛型约束、impl Trait、dyn Trait 或扩展 Trait,那么这种抽象可能只会增加理解成本。

Google 的规则是:当我们需要为一个类型实现功能时,默认优先考虑固有实现,也就是 impl Type;只有当 Trait 能够解决明确的问题时,才引入 Trait。

在库代码中,指南还倾向于优先使用泛型和 impl Trait,而不是无条件地选择 dyn Trait。这是因为泛型通常可以让编译器进行静态分发和更多优化,而动态分发会引入额外的间接调用。

当然,dyn Trait 仍然有它的适用场景。例如,需要在同一个集合中保存不同具体类型的对象,或者确实需要运行时多态时,动态分发可能更加合适。

因此,这条建议并不是拒绝抽象,而是要求抽象服务于明确的需求。

为什么 Google 对宏更加谨慎?

Google 对代码生成和元编程给出了相当明确的优先顺序:

  1. 普通函数、类型、Trait、对象和模块。
  2. macro_rules! 宏。
  3. 其他代码生成方案。
  4. 过程宏。

原因并不难理解。

普通 Rust 代码更容易被 IDE、代码搜索工具和重构工具识别。宏则可能隐藏实际生成的代码,让开发者必须理解额外的语法和展开规则。

下面是一个简单的例子:

macro_rules! make_message {
    ($name:expr) => {
        format!("Hello, {}!", $name)
    };
}

fn main() {
    let message = make_message!("Rust");
    println!("{message}");
}

这段代码完全合法,也没有任何实际问题。

但如果需求只是生成一条简单的字符串,普通函数通常已经足够:

fn make_message(name: &str) -> String {
    format!("Hello, {}!", name)
}

fn main() {
    let message = make_message("Rust");
    println!("{message}");
}

后者的调用关系和参数类型都更加直观。

这并不意味着应该禁止宏。宏能够解决普通函数无法解决的问题,例如生成重复的结构、扩展语法,或者在编译阶段完成某些特定任务。

Google 的重点是:当普通语言机制已经足够时,不要仅仅为了减少几行重复代码而创建一套新的小语言。

这个原则对于代码审查和自动化工具尤其重要。

在一个大型项目中,宏的使用方式可能形成独特的代码方言。开发者不仅需要理解 Rust,还需要理解项目内部的宏系统。跨模块重构时,工具也可能需要额外处理宏展开与源代码之间的关系。

对于规模较小的项目,这些成本可能无关紧要;对于长期维护的大型代码库,它们则可能逐渐累积。

迭代器链也不是越长越好

Google 对抽象的谨慎还体现在一个容易被忽略的地方:当直接循环更清晰时,不要强行使用迭代器组合链。

Rust 社区经常鼓励使用 map、filter、fold 等迭代器方法。这种风格非常适合表达简单的数据转换。

但当一个表达式包含复杂的条件判断、多个中间状态和嵌套闭包时,代码可能反而变得难以理解。

例如,下面的代码使用普通循环计算所有正整数的平方和:

fn sum_of_positive_squares(values: &[i32]) -> i64 {
    let mut total = 0_i64;

    for &value in values {
        if value > 0 {
            total += i64::from(value) * i64::from(value);
        }
    }

    total
}

fn main() {
    let values = [-2, -1, 0, 1, 2, 3];

    println!("{}", sum_of_positive_squares(&values));
}

这段代码的意图非常明确:遍历、判断、累加。

如果问题很简单,使用迭代器也完全合理:

fn sum_of_positive_squares(values: &[i32]) -> i64 {
    values
        .iter()
        .copied()
        .filter(|value| *value > 0)
        .map(|value| i64::from(value) * i64::from(value))
        .sum()
}

这两个版本都可以编译运行。哪个更好,取决于具体上下文。

Google 的建议不是要求大家放弃函数式编程,而是避免把某一种代码风格当成必须遵循的仪式。

好的抽象应该让代码更容易理解,而不是让代码看起来更抽象。

为什么类型名称、导入方式和文档也属于工程设计?

Google 的指南还包含许多乍看之下比较琐碎的规则。

例如,它建议默认导入类型并直接使用类型名,而不是在每次使用时都写完整模块路径。

use regex::Regex;

fn main() {
    let pattern = Regex::new(r"^[a-z]+$").unwrap();

    println!("{}", pattern.is_match("rust"));
}

而不是:

fn main() {
    let pattern = regex::Regex::new(r"^[a-z]+$").unwrap();

    println!("{}", pattern.is_match("rust"));
}

这里使用了第三方 regex 库,完整示例需要在 Cargo.toml 中声明依赖。

两种写法都合理。Google 倾向于第一种,是希望减少不必要的命名空间重复,同时让导入规则在团队内部保持一致。

但这条规则也存在例外。如果类型名称过于通用,或者不同模块中的类型发生冲突,保留模块路径可能更清晰,例如使用 std::io::Error。

同样,Google 不建议使用 glob import:

use some_module::*;

因为这种导入方式会隐藏名称的来源。当开发者看到某个类型或函数时,可能无法直接判断它来自哪个模块。

此外,指南还强调了标准 Trait 的使用。例如,如果某个类型的功能本来就符合 FromStr、Display 等标准 Trait 的语义,那么实现标准 Trait 通常比发明一个用途相同的自定义方法更合适。

对于可以通过 #[derive] 自动实现的 Trait,也不应该习惯性地编写手工实现。

这些建议看起来很细,但它们共同服务于一个目标:降低读者理解代码所需的上下文负担。

在一个拥有大量模块和依赖的项目中,每一次不必要的命名差异、每一种自定义约定,都可能增加理解成本。

文档注释不是普通注释的装饰版本

Rust 的文档注释可以被 rustdoc 等工具处理,并最终成为 API 文档的一部分。

因此,Google 建议优先使用 /// 和 //! 编写结构清晰的文档,而不是通过宏生成文档内容。

例如:

/// Returns the first positive number in the slice.
///
/// Returns `None` if no positive number exists.
fn first_positive(values: &[i32]) -> Option<i32> {
    values.iter().copied().find(|value| *value > 0)
}

fn main() {
    let values = [-3, 0, 4, 8];

    println!("{:?}", first_positive(&values));
}

这个例子可以直接运行。

函数的行为、返回值和边界情况都可以从文档中看出来,文档工具也能够提取这些信息。

对于公共库而言,这一点尤为重要。API 的使用者可能不需要阅读实现细节,但必须能够通过文档理解函数的契约。

这里体现的仍然是同一种工程思想:代码不应该只为当前作者服务,还应该为未来的调用者、维护者和自动化工具提供清晰的信息。

一个看似激进的规则:为什么 Chromium 禁止使用 async/.await?

Google 的指南中还有一些特别醒目的规则。

例如,Chromium 的规范明确要求不要使用 async/.await。在相关工程环境中,指南还建议优先使用既定的任务调度机制,而不是直接通过 Rust 标准库创建新线程。

对于熟悉 Rust 异步生态的开发者来说,这可能显得非常反直觉。

毕竟,Rust 的异步机制已经被广泛用于网络服务、异步 I/O 和高并发应用。

那么,这是否意味着 Google 认为 Rust 的异步模型设计不佳?

不能这样理解。

这两条规则都带有明确的 [chrome] 标记,说明它们是针对特定代码库的工程选择,而不是整个 Rust 生态的通用规范。

大型浏览器项目通常已经拥有成熟的任务调度、线程模型、事件循环和资源管理机制。引入另一套并发模型,可能增加调度、取消、生命周期管理以及跨模块协作的复杂性。

如果一个代码库已经规定了统一的任务执行方式,那么即使某种语言特性本身非常优秀,也不一定适合直接使用。

这与在一个普通 Rust 网络服务中使用 Tokio 等异步运行时,是完全不同的工程问题。

需要区分三个层面:

  • 语言能力。Rust 支持哪些特性?
  • 运行时能力。项目使用什么方式执行任务和管理并发?
  • 工程约束。代码库允许开发者使用哪些机制?

这三个层面不能混为一谈。

Google 的规则提醒我们:在大型系统中,技术选择不仅取决于语言特性本身,还取决于它能否与已有基础设施形成一致的运行模型。

同样的原则也适用于其他语言。

Java 项目可能限制某些线程池的使用方式,Go 项目可能规定统一的上下文传播和并发控制机制,C++ 项目也可能对内存分配和线程创建设置严格约束。

这并不意味着相关语言特性不好,而是系统需要统一的工程边界。

AI 编程时代,这些规则会不会更加重要?

HN 讨论中还出现了关于 AI 生成代码和未来软件开发方式的不同看法。

有人认为,随着大模型越来越擅长生成代码,人工编写代码的比例可能下降;也有人指出,代码长期维护所需要的理解能力并不会因此消失。

这些观点仍然属于讨论中的预测,不能视为已经确定的行业结论。

不过,它们提出了一个值得思考的问题:当代码越来越容易生成时,什么样的代码更容易被检查、修改和验证?

从 Google 的指南来看,至少有几项能力依然重要:

  • 明确的模块和依赖边界。
  • 能够被自动化工具可靠处理的代码结构。
  • 清晰的类型、API 和错误处理约定。
  • 尽量减少不必要的隐式行为。
  • 让公共接口能够安全演进。

这些特性对人类维护者有价值,对自动化代码分析、批量重构和 AI 编程代理也可能有帮助。

但需要注意,Google 的指南并不是专门为 AI 编程制定的。它主要针对大型工程的长期维护与自动化变更需求。AI 编程只是让这些原则有了新的应用场景,并不意味着这份指南已经证明了某种 AI 工程结论。

普通 Rust 项目应该如何借鉴 Google 的规范?

读完这份指南,很容易产生一个冲动:把所有规则都加入自己的项目规范。

但这样做未必是好事。

Google 的指南本身就明确区分了库代码和应用代码,并对 Chromium、Fuchsia 等特定环境使用单独标记。它并不是一套不考虑上下文的绝对规则。

对于普通 Rust 项目,我更建议从三个层面吸收它的经验。

第一层:直接采用低成本、高收益的规则

这些规则通常不需要改变项目架构,就能带来一致性收益:

  • 使用 rustfmt 统一代码格式。
  • 优先使用标准 Trait,而不是重复定义相同语义的自定义接口。
  • 尽可能使用 #[derive]。
  • 为公共 API 编写清晰的文档注释。
  • 避免无必要的 glob import。
  • 优先使用清晰的类型和错误处理方式。
  • 不要用 clone() 代替本来可行的借用。

这类规范适合写入项目的 CONTRIBUTING.md,并通过 CI 执行其中可以自动化的检查。

例如,创建项目后,可以先执行:

cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-targets

这三条命令分别用于检查格式、执行 Clippy 静态检查,以及运行测试。

它们不能替代代码审查,但可以把一部分机械性问题交给工具处理。

第二层:根据项目性质决定 API 规则

如果项目主要是内部应用,可以优先考虑:

  • 保持代码直接,避免过早抽象。
  • 只在确实需要时引入 Trait。
  • 对关键状态使用清晰的枚举类型。
  • 根据业务需求决定是否保留穷尽匹配。
  • 让错误类型和错误传播方式与实际恢复策略一致。

如果项目是公共库,则应该更加关注:

  • API 的稳定性与兼容性。
  • 是否需要通过 #[non_exhaustive] 预留演进空间。
  • 参数类型是否过度依赖具体容器。
  • 泛型、动态分发和返回类型的设计。
  • 文档是否充分说明行为和失败条件。

这两个方向并不冲突。它们只是服务于不同的使用者和演进方式。

第三层:不要盲目复制特定代码库的架构规则

小 Crate、扁平文件布局,以及 Chromium 对异步和线程创建的限制,都有具体的工程背景。

在自己的项目中采用这些规则之前,应该先回答几个问题:

  • 当前构建系统是否真的能从更细粒度的 Crate 中获益?
  • 拆分之后,依赖管理和发布流程是否会变得更复杂?
  • 模块结构是否已经足够清晰?
  • 项目是否拥有统一的并发运行时或任务调度机制?
  • 这些约束能否通过工具自动执行,还是只会增加人工审查成本?

如果没有明确的收益,仅仅因为 Google 使用某种结构就照搬,并不能保证自己的项目也会受益。

Google 这份指南的真正价值,不在于为每个 Rust 开发者提供一份必须照抄的答案,而在于展示了一家超大规模工程组织如何将维护经验转化为可以执行的规则。

小结:从代码风格到工程治理:Google 真正想解决的是什么?

回到文章开头的问题。

为什么一份 Rust Style Guide 会讨论小 Crate、所有权、API 演进、Trait、宏、错误处理,甚至特定项目的并发模型?

因为在大型工程中,代码风格并不是孤立的审美偏好。

一个项目的长期维护成本,往往来自许多看似微小的设计选择:某个类型是否拥有清晰的语义,某个模块是否引入不必要的依赖,某个公共 API 是否为未来变化预留空间,以及某种代码生成机制是否让工具难以理解程序结构。

这些选择单独看起来并不重要,但当它们在大量模块和团队之间反复出现时,就会成为整个工程系统的一部分。

Google 的指南试图通过统一规则,减少那些可以提前避免的歧义和摩擦。

它也揭示了一个容易被忽视的事实:好的代码不仅应该容易写出来,还应该容易读懂、容易修改、容易验证,并且能够在长期演进中保持可控。

对于 Rust 开发者而言,这份指南尤其值得参考,因为 Rust 本身就拥有强大的类型系统、所有权模型和抽象能力。

这些能力可以帮助我们构建可靠的软件,但它们并不会自动替我们完成架构设计。开发者仍然需要决定哪些边界应该明确、哪些抽象值得保留,以及哪些变化应该由类型系统强制暴露。

Google 给出的答案未必适用于所有团队。

但它提出的问题,值得每一个长期维护 Rust 项目的开发者认真思考。

当我们下一次准备引入一个 Arc、定义一个 Trait、拆分一个 Crate,或者为公共枚举添加一个变体时,不妨多问一句:

这个设计不仅能让今天的代码工作,还能让未来的维护者更容易理解和修改它吗?

这或许才是 Google Rust Style Guide 最值得借鉴的地方。

参考资料:


还在为写 Agent 框架频频死循环、上下文爆炸而束手无策?我的新专栏 《从0 开始构建 Agent Harness》 将带你:

  • 抛弃臃肿框架,回归“驾驭工程 (Harness Engineering)”的第一性原理
  • 用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等,复刻极简OpenClaw
  • 构建坚不可摧的 Safety Middleware 与飞书人工审批防线
  • 在底层实现 Token 成本审计、链路追踪与自动化跑分评估
  • 从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”

扫描下方二维码,开启从 0 开始构建Agent Harness 的实战之旅。


还在为“复制粘贴喂AI”而烦恼?我的新专栏 《AI原生开发工作流实战》 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。