Rust 开发 - Day 1
一个实用的基础指南,助你从第一个提交起就写出优秀的 Rust 应用。这不是一本教科书。它专注于 Rust 与其他语言的差异、决定后续一切的第一天决策,以及覆盖大多数真实应用的小型 crate 集合。
何时使用
- 启动一个新的 Rust 项目(CLI、服务、库)
- 从 Python、JavaScript、Go、Java/C# 或 C++ 转向 Rust
- 在拥有/借用类型、智能指针、trait 对象与泛型之间做出选择
- 选择错误处理策略(
anyhow与thiserror) - 决定使用哪些 crate
- 配置一个最小但意见明确的
Cargo.toml、clippy 和 rustfmt
Day-1 设置
# 1. 安装工具链(rustup 是工具链管理器)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 2. 确认组件(rustfmt 和 clippy 随稳定版一起提供,rust-src 启用 IDE 功能)
rustup component add rustfmt clippy rust-src
# 3. 创建项目
cargo new my-app # 二进制(src/main.rs)
cargo new --lib my-lib # 库(src/lib.rs)
# 4. 开发循环(记住这四个)
cargo check # 快速类型检查,不生成代码
cargo run # 构建并运行(二进制)
cargo test # 构建并运行测试(包括文档测试)
cargo clippy --all-targets # 代码检查,包括测试和基准(在推送前运行)
cargo fmt # 格式化
# 5. 无需手动编辑 Cargo.toml 即可管理依赖
cargo add tokio --features full
cargo remove tokio
cargo update # 在现有 semver 范围内重新计算 Cargo.lock
cargo update 仅在 Cargo.toml 中已有的版本范围内移动。跨越主要版本(1.x 到 2.0)需要编辑 Cargo.toml 或使用 cargo add <crate>@2。
rust-analyzer 是必须的。 它是每个编辑器使用的语言服务器(VS Code、Zed、Neovim、Helix,RustRover 使用自己的引擎但功能类似)。在 VS Code 中,安装 rust-analyzer 扩展并将 rust-analyzer.check.command 设置为 "clippy",这样可以在保存时获得 lint 反馈。
以后想要一个文件监视器? cargo install --locked bacon,然后在你的项目中运行 bacon。第一天不需要。
Rust 思维模型的 5 个核心思想
Rust 用编译时保证(关于内存、数据竞争和错误处理)换取了你在大多数语言中认为理所当然的两样东西(垃圾回收器和异常)。语言的结构源于这种交换。
1. 所有权:每个值只有一个所有者
把值想象成物理对象。一本书、一个文件、一个网络连接。在任何时刻,一个变量拥有它。你可以:
- 移动它:
let b = a;将所有权交给b。a消失。 - 不可变地借用它:
&a允许其他人查看它。允许多个读取者。 - 可变地借用它:
&mut a允许一个人修改它。独占访问。 - 克隆它:
a.clone()创建一个深拷贝。两者都拥有自己的副本。
当所有者离开作用域时,值被丢弃(内存释放、文件关闭、锁释放)。没有垃圾回收,没有手动的 free。这就是 RAII,由编译器强制实施。
2. 别名与可变性互斥
在任何时刻,一份数据要么:
- 一个可变引用(
&mut T),要么 - 任意数量的不可变引用(
&T),
两者不能同时存在。这一条规则消除了数据竞争和大多数 use-after-free 错误。借用检查器强制实施它。当它报错时,它是在告诉你你的数据所有权故事不清楚,而不是语言本身在找麻烦。
3. 错误是值,而不是异常
没有 try/catch。可能失败的函数返回 Result<T, E>。可能返回无意义内容的函数返回 Option<T>。编译器强制你处理它们。? 操作符用一个字符将错误向上传播到调用栈:
fn read_config() -> Result<Config, anyhow::Error> {
let text = std::fs::read_to_string("config.toml")?; // ? = 遇到 Err 时提前返回
let config = toml::from_str(&text)?;
Ok(config)
}
没有 null。Option<T> 是 None 或 Some(value)。编译器不会让你忘记 None 的情况。
4. Trait 不是 Java 接口
一个 trait 定义行为。类型 impl 这些 trait。到目前为止还比较熟悉。不同之处:
- 默认是静态分发。 当你编写
fn f<T: Display>(x: T)时,编译器为调用它的每个具体T生成一个单独的f副本(单态化,类似于 C++ 模板)。零运行时开销。 - 动态分发是可选的,通过
dyn Trait(通常是Box<dyn Trait>或&dyn Trait>)。每次调用进行一次 vtable 查找。 - 没有继承。 Trait 组合。如果你发现自己想用
Deref来“扩展”一个类型,停下来,使用组合或枚举。 - 孤儿规则:你可以为别人的类型实现自己的 trait,或者为你的类型实现别人的 trait,但不能两者都是外部的。这保持了依赖解析的合理性。
5. 借用检查器是一个设计预言师
新手最常见的错误是将编译器错误视为需要沉默的障碍。它们不是。几乎每个借用检查错误都揭示了一个关于谁拥有什么的真正问题。当你卡住时,问题很少是“如何让它编译”,而几乎总是“我实际想要的所有权关系是什么?”阅读错误信息。编译器异常信息丰富。
每个函数签名的 3 个问题
在编写函数之前,问:它需要拥有、读取还是修改输入?
fn consume(s: String) // 拥有: 函数接管责任,调用者失去它
fn read(s: &str) // 读取: 函数查看它,调用者保留它
fn modify(s: &mut String) // 修改: 函数就地更改它
在 90% 的情况下有效的默认值:
- 函数参数:优先使用
&str而不是String,&[T]而不是Vec<T>(这些是切片,接受拥有者和借用者)。 - 函数返回值:返回拥有的类型(
String、Vec<T>)。返回引用意味着生命周期;在需要之前避免使用。 - 结构体字段:优先使用拥有的类型(
String、Vec<T>)。在结构体中存储&str是最常见的新手陷阱,它会导致生命周期注释级联到所有包含你的结构体的类型中。
Day-1 决策表
对首先出现的选择给出的一行答案。
| 决策 | 默认值 | 何时选择另一个 |
|---|---|---|
String 与 &str(结构体字段) | String | 几乎永远不是 &str,除非你有真正的理由并理解生命周期 |
String 与 &str(函数参数) | &str | 仅当必须在函数内部拥有/存储它时使用 String |
Vec<T> 与 &[T](参数) | &[T] | 仅当必须拥有时使用 Vec<T> |
Box<T> 与 Rc<T> 与 Arc<T> | Box<T>(单个所有者,堆) | Arc<T> 用于跨线程共享所有权。避免默认使用 Rc<T>;使用 Arc<T> 这样当你转向异步时就不需要重构 |
RefCell<T> 与 Mutex<T> | Mutex<T>(或 RwLock<T>) | 同样原因:在 async/线程中工作,而 RefCell 不行 |
Option<T> 与 Result<T, E> | Option<T> 用于“没有值”,Result<T, E> 用于“因某个原因失败” | 如果缺失携带了调用者应处理的意义,使用 Result |
dyn Trait 与 impl Trait / <T: Trait> | 泛型(<T: Trait> 或 impl Trait)- 静态分发 | 当你需要一个异构集合时使用 Box<dyn Trait>(Vec<Box<dyn Animal>>) |
| 应用代码中的错误 | 所有地方都使用 anyhow::Result<T> | - |
| 库代码中的错误 | thiserror 派生的枚举 | 永远不要在公共库 API 中使用 Box<dyn Error> - 强制调用者向下转换 |
&self 与 &mut self 与 self | &self 用于 getter,&mut self 用于 setter,self 用于构建器/消费操作 | - |
| 模块布局 | 内联模块直到文件变长,然后拆分 | 一个模块 = 一个文件是 Java/C# 的本能,不是 Rust 的 |
应尽早内化的惯用写法
这些出现在几乎每个 Rust 程序中。在第一周学习它们。
? 用于错误传播。 用单个字符替换了九行 match。
let body = reqwest::get(url).await?.text().await?;
迭代器链优于手动循环。 编译后与手写循环的机器码相同(LLVM 内联闭包)。惯用的 Rust 在风格上是函数式的。
let active_emails: Vec<String> = users
.iter()
.filter(|u| u.active)
.map(|u| u.email.clone())
.collect();
match 穷尽性。 为枚举添加一个新变体,每个未处理该变体的 match 都会变成编译错误。利用这一点。这是任何语言中最强大的重构工具之一。
让编译器驱动重构。 同样的技巧也适用于结构体:添加一个没有 Default 的必选字段,每个现有的构造函数都会变成编译错误——一个免费、彻底的待办列表,列出所有需要更新的位置。
if let 和 let else 用于常见的单臂匹配。
if let Some(name) = user.name { println!("hi {name}"); }
let Some(name) = user.name else { return Err(anyhow!("no name")); };
// `name` 从此处起在作用域内,无需嵌套
From / Into 用于类型转换。 实现 From,免费获得 Into。? 使用 From 自动转换错误类型。
Option / Result 上的组合子。 在拿起 match 之前先考虑 .map、.and_then、.unwrap_or、.unwrap_or_else、.ok_or。
派生宏。 #[derive(Debug, Clone, PartialEq)] 免费为你提供 80% 的样板代码。添加 #[derive(Serialize, Deserialize)] 用于 JSON。
最近的语法糖。 值得了解的已稳定功能,最新者优先:bool::ok_or 和 ok_or_else(1.98)将条件直接转换为 Result(ready.ok_or(Error::NotReady)?)。cfg_select! 是 cfg 谓词上的编译时 match,取代了 cfg-if crate 的大多数用途,而 if let 守卫可以在 match 分支上工作(match x { Some(v) if let Ok(n) = v.parse::<i32>() => ... })——两者都是 1.95 版本。assert_matches! 和 debug_assert_matches!(1.96)在模式上断言而不是相等,这是枚举的自然断言。有两个更旧的功能你会经常看到,不要误以为它们很特殊:let-chains(if let Some(x) = a && x > 3 { ... }),自 1.88 版本起在 edition 2024 中稳定,以及异步闭包(async || { ... },带有 AsyncFn 系列约束),自 1.85 版本起稳定。
来自 X 语言,这里是你可能遇到的问题
来自 Python 或 JavaScript:
let b = a;对于堆上的值(如String、Vec)是移动它。a不再可用。使用&a借用或a.clone()复制。- 没有 null。
Option<T>是强制的。 - 没有异常。
Result<T, E>和?。编译器不会让你忽略错误。 - 没有继承。组合 + trait + 枚举。
- 变量默认是不可变的。添加
mut来改变。引用同理:&与&mut。 - 重新声明一个名称是允许且惯用的:
let input = input.trim();用新绑定隐藏旧绑定,可能类型不同。这不是修改,这是 Rust 代码就地转换值而不发明input2的方式。 - 整数类型是显式的,索引需要
usize。它们的溢出行为也因构建而异:调试“包括导致程序在运行时_panic_ 的整数溢出检查”,而--release取消它们并执行“二进制补码环绕”。一个通过的测试套件在生产中可能仍然会溢出,因此在算术可能达到边界的地方使用checked_*/saturating_*/wrapping_*,而不是依赖调试 panic 来发现它。strict_add及其变体(自 1.91 稳定)在所有构建中 panic 溢出,而[profile.release]下的overflow-checks = true会在生产中保留调试行为。并且as在整数类型之间进行静默截断(300_u32 as u8是44)——在值可能不匹配的地方使用u8::try_from(x)?。 String不可索引。s[0]无法编译——查看references/ownership-and-types.md了解应使用什么。
来自 Go:
- 错误即值——同样的直觉,但使用
?而不是if err != nil。 - 没有
nil。Option<T>。 - 没有垃圾回收和 goroutine:所有权 + 借用,使用
tokio的 async/await。异步模型是协作式的(await是显式的让步点),而不是抢占式的。 interface{}变成 trait。默认使用泛型进行静态分发;Box<dyn Trait>仅在需要时使用。- 默认是静态链接。二进制文件更大但自包含。
panic!应保留给应用代码中不可恢复的错误;不要将其用作 Go 风格的“记录日志并继续”。
来自 Java 或 C#:
- Trait 不是默认具有虚分发的接口。
<T: Trait>是单态化的。dyn Trait是可选的动态版本。 - 没有 null 引用。
Option<T>。 - 没有异常。
Result<T, E>和?。 - 没有类继承。对和类型使用枚举,对共享行为使用 trait。
- 没有垃圾回收:所有权和借用决定生命周期。
Arc<T>是最接近 Java 引用的东西。 - 泛型是单态化的,而不是类型擦除的。
来自 C++:
- 类似于 RAII,但借用检查器在编译时强制实施。
- 没有复制/移动构造函数。
Clone是显式的,Copy是用于廉价逐位复制的标记 trait。 - 安全代码中没有未定义行为(理论上)。
&是编译时检查的借用,不是原始指针。原始指针存在(*const T、*mut T),但取消引用需要unsafe。- 智能指针是
Box<T>(unique_ptr)、Rc<T>(shared_ptr,单线程)、Arc<T>(shared_ptr,线程安全)。 - 宏是卫生的。过程宏(派生、属性、类函数)是
serde、tokio::main等的工作方式。
Crate 推荐列表
这些覆盖了大多数真实应用。根据需要添加;它们并非全部必需。
| Crate | 它提供什么 |
|---|---|
serde + serde_json | 序列化。#[derive(Serialize, Deserialize)] 然后完成 |
tokio | 异步运行时。#[tokio::main]、tokio::spawn、异步 I/O |
anyhow | 应用错误类型。anyhow::Result<T>、bail!、context() |
thiserror | 库错误枚举。#[derive(thiserror::Error)] |
clap | CLI 参数解析。#[derive(Parser)],自动生成 --help |
axum | Web 框架(路由器、中间件、提取器)。与 tokio 和 tower 生态配合 |
reqwest | HTTP 客户端(同步和异步)。发出 HTTP 请求 |
sqlx | 异步 SQL。编译时检查的查询,支持 PostgreSQL/MySQL/SQLite,无需 ORM |
tokio-rs/console | 异步诊断。tokio-console 显示每个任务的 CPU/等待时间和状态 |
tracing | 结构化日志和跨度。tracing-subscriber 用于输出格式化 |
chrono | 日期和时间。NaiveDateTime、Utc、时区 |
uuid | UUID。Uuid::new_v4()、Uuid::parse_str() |
何时避开流行 crate
lazy_static:使用std::sync::LazyLock(自 1.80 稳定)或once_cell::sync::Lazy代替。cfg-if:使用cfg_select!(1.95 稳定)代替。anyhow用于库:thiserror是你的朋友。保持公共 API 中的错误具体。actix-web用于新的 Rust 开发者:axum是更标准的异步生态的一部分,减少了认知开销。
最小且意见明确的 Cargo.toml
[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
rust-version = "1.85"
[dependencies]
[dev-dependencies]
[profile.release]
# 默认情况下,发布版本去掉 panic 栈展开(更小的二