引言
从 Go / C# / TypeScript 到 Rust 的系统工程师转型
A book for the 14-year veteran who wants to actually master Rust — including the unsafe parts.
关于这本书
市面上的 Rust 教材分两类:
- 新手向(The Rust Book、Rust by Example):假设你刚学编程,从变量、循环讲起。对老手来说慢且啰嗦。
- C++ 老兵向(Programming Rust、Rust for Rustaceans):假设你熟悉手动内存管理、模板元编程、RAII。对 GC 语言出身的工程师来说有断层。
这本书的位置:写给已经有 10+ 年工程经验、用 GC 语言到生产级、懂 async / concurrency / generic 概念,但没系统学过手动内存管理的工程师。每个 Rust 概念都从你已知的世界出发,然后讲清楚 Rust 为什么不这么做。
读完之后你能:
- 给 Tokio / Axum / Hyper 这一级别的项目提交有质量的 PR
- 写
unsafe代码而不引入 undefined behavior - 给团队解释 Rust 的设计权衡,而不是只会照抄
- 看懂 Rust 编译器报错背后的真正原因,而不是猜
读完之后你不会:
- 成为 Rust 编译器(rustc)贡献者 —— 那是另一本书
- 精通嵌入式 Rust(
no_std、bare metal) —— 本书 2 章简介,不展开
全书结构
| Part | 章节 | 主题 |
|---|---|---|
| I 心智模型重置 | Ch 1-3 | 为什么 Rust 不一样 / Ownership / Borrowing |
| II 类型系统 | Ch 4-7 | Struct & Enum / 错误处理 / Trait / 泛型进阶 |
| III 内存与资源 | Ch 8-10 | 智能指针 / 内部可变性 / Iterator & 闭包 |
| IV 并发与异步 | Ch 11-13 | 线程 / async/await / Tokio |
| V 工程实践 | Ch 14-16 | Cargo / Axum + sqlx / 测试 & bench |
| VI 深水区 | Ch 17-20 | 宏 / unsafe / FFI / Mini-Tokio |
总字数预计:18-22 万字,约 600-700 页 A5。
配套学习路线
本书配 12 周精通计划,把每章学习节奏拆成 84 天可执行的日任务。一边读书一边做计划里的练习,效果远胜单纯读。
给读者的承诺
- 不复读官方文档。The Rust Book 已经写得很好,这本书做的是它没做的事:从你已知世界翻译。
- 每章配实战练习。不是 toy example,是真实工程场景。
- 不藏复杂度。Pin、unsafe、async 内部 —— 该讲深的地方讲深,不掩盖权衡。
- 不假装精通。我自己卡过的地方会标记 "⚠️ 这里我也曾困惑",并说明怎么走出来。
开始吧。
Drafted 2026-05 · Working title, subject to change.
Rust 12 周精通计划
节奏:1.5h/天 × 6 天/周 × 12 周 ≈ 108h 目标:全面掌握 Rust,含 unsafe / 异步内部 / 宏 / FFI 起点:Go/TS/C# 多年经验,Rust 零基础
总体设计原则
- 代码量 > 阅读量:每天至少敲 50 行 Rust,不能只看书。
- 借用检查器是老师不是敌人:卡住时不要
.clone()绕过,先理解为什么编译器拒绝。 - 每周一篇笔记:用自己的话总结一周关键概念,公开发布更好(GitHub repo 或博客)。
- 错题本:记录被借用检查器卡住的每个具体场景,周末复盘 mental model。
- 真实项目是练兵场:Week 7 起拿生产代码库当试炼场,Week 12 提一个有质量的 PR 作毕业作品。
- 不囤教程:选定 The Rust Book + Rustlings + Programming Rust + Rust for Rustaceans 这四本,不看其他十几个教程。
- 不立刻问 AI:遇到不会的先自己读编译器报错,5 分钟后再求助。借用检查器的 mental model 必须内化,绕过去等于没学。
阶段一 · 基础与所有权(Week 1-2)
目标:通过编译器的所有权检查不再是主要障碍。
Week 1 · 语法与心智模型迁移
| 日 | 内容 | 时长 |
|---|---|---|
| 1 | 装 toolchain(rustup / rust-analyzer / clippy / rustfmt),配编辑器,Hello World;读 The Rust Book Ch 1-3 | 1.5h |
| 2 | The Rust Book Ch 4(Ownership)——分水岭,慢读;同步 Rustlings move_semantics 系列 | 1.5h |
| 3 | The Rust Book Ch 5-6(Struct/Enum/Pattern Matching);Rustlings structs enums | 1.5h |
| 4 | The Rust Book Ch 7-8(Modules/Collections);Rustlings modules vecs hashmaps | 1.5h |
| 5 | The Rust Book Ch 9(Error Handling);重点理解 ? 与 From trait 的关系 | 1.5h |
| 6 | 复盘 + 小项目:CLI 工具读 CSV 输出统计 | 2h |
| 7 | 休息 | — |
Week 2 · 类型系统基石
| 日 | 内容 | 时长 |
|---|---|---|
| 8 | The Rust Book Ch 10(Generics/Traits/Lifetimes)——这章读 2 遍 | 1.5h |
| 9 | Ch 10 第二遍 + Rustlings generics traits lifetimes | 1.5h |
| 10 | Ch 11(Testing);给 Week 1 的 CLI 加单元测试 + 集成测试 | 1.5h |
| 11 | Ch 12(CLI Project);跟着写 minigrep | 1.5h |
| 12 | Rust by Example 中所有权 + trait 的全部例子精读 | 1.5h |
| 13 | minigrep 扩展:支持正则 + 并行 | 2h |
| 14 | 休息 | — |
阶段二 · 类型系统深入(Week 3-4)
目标:能用 trait 抽象设计出符合 Rust 习惯的 API。
Week 3 · Trait 与函数式
| 日 | 内容 | 时长 |
|---|---|---|
| 15 | Ch 13(Closures/Iterators);三种 Fn trait(Fn / FnMut / FnOnce)的语义差异 | 1.5h |
| 16 | Iterator adapter 全家桶熟练:map / filter / fold / collect / zip / chain / flat_map / scan;写 100 行链式代码 | 1.5h |
| 17 | Trait 进阶:associated types、default methods、supertraits | 1.5h |
| 18 | dyn Trait vs impl Trait,object safety 规则 | 1.5h |
| 19 | 错误处理工程化:thiserror(库) + anyhow(应用)的分工;重写 Week 1 项目 | 1.5h |
| 20 | 复盘 + 写一篇笔记 | 1.5h |
| 21 | 休息 | — |
Week 4 · 智能指针与内部可变性
⚠️ 第一个分水岭。这周卡过去,后面 Tokio 内部才能真正读懂。
| 日 | 内容 | 时长 |
|---|---|---|
| 22 | Ch 15(Smart Pointers);Box / Rc / RefCell 各自的使用场景 | 1.5h |
| 23 | 内部可变性:Cell vs RefCell vs Mutex;运行时借用检查的代价 | 1.5h |
| 24 | Drop / Deref / DerefMut trait;RAII 模式 | 1.5h |
| 25 | 实战:实现一个简单的 Arena allocator | 1.5h |
| 26 | 实战:实现一个 LRU cache(Rc<RefCell<Node>> 双向链表) | 1.5h |
| 27 | 阅读 std::collections 中 Vec 或 HashMap 源码片段 | 1.5h |
| 28 | 休息 | — |
阶段三 · 并发编程(Week 5-6)
目标:能写正确且符合 Rust 哲学的并发代码,理解 Tokio 的工作方式。
Week 5 · 线程并发
| 日 | 内容 | 时长 |
|---|---|---|
| 29 | Ch 16(Threads);thread::spawn / JoinHandle / move closure | 1.5h |
| 30 | Send + Sync marker trait——透彻理解,这是 Rust 并发安全的根基 | 1.5h |
| 31 | std::sync::mpsc;Arc<Mutex<T>> 共享状态模式 | 1.5h |
| 32 | crossbeam 文档通读;理解为什么 std 之外还需要它(scoped threads / channel 性能) | 1.5h |
| 33 | 实战:并发 web crawler(work-stealing 模式) | 2h |
| 34 | 复盘 + 写笔记 | 1.5h |
| 35 | 休息 | — |
Week 6 · async/await 入门
| 日 | 内容 | 时长 |
|---|---|---|
| 36 | Ch 17(Async);Future trait 概念;async fn 编译成状态机 | 1.5h |
| 37 | Tokio 入门:#[tokio::main] / tokio::spawn / JoinHandle | 1.5h |
| 38 | Tokio 异步原语:tokio::sync::{mpsc, oneshot, broadcast, Mutex, RwLock} | 1.5h |
| 39 | select! 宏 / timeout / abort / cancellation 模式 | 1.5h |
| 40 | 实战:用 Tokio 重写 Day 33 的爬虫,对比性能 | 2h |
| 41 | 阅读你目标项目的真实代码,试图看懂 50% | 1.5h |
| 42 | 休息 | — |
阶段四 · 生产级 Rust(Week 7-8)
目标:第一次给真实项目提交可合并的代码。
Week 7 · Web 服务实战
⚠️ 第二个分水岭。开始动真实代码库——被 review 一次值看一周书。
| 日 | 内容 | 时长 |
|---|---|---|
| 43 | Axum 入门:Router / handler / extractor / response | 1.5h |
| 44 | Axum 中间件:tower::Service / tower::Layer 的设计哲学 | 1.5h |
| 45 | tracing 与结构化日志;span 与 event 模型 | 1.5h |
| 46 | sqlx 加深:query! / query_as! 宏的编译期检查 | 1.5h |
| 47 | 选目标项目一个 small issue 开始实现 | 2h |
| 48 | 继续实现 + 自我 review | 2h |
| 49 | 休息 | — |
Week 8 · 工程化
| 日 | 内容 | 时长 |
|---|---|---|
| 50 | Cargo workspace + monorepo | 1.5h |
| 51 | Feature flags / conditional compilation / cfg! | 1.5h |
| 52 | Benchmarking:criterion crate;微基准的常见陷阱 | 1.5h |
| 53 | Profiling:cargo flamegraph / perf / tokio-console | 1.5h |
| 54 | 测试进阶:proptest(property testing) / insta(snapshot testing) | 1.5h |
| 55 | 阅读高质量 crate:ripgrep 的 walker 模块 或 hyper 的 service 模块 | 1.5h |
| 56 | 休息 | — |
阶段五 · 高级特性(Week 9-10)
目标:能读懂任意 Rust 代码,包括宏和 unsafe。
Week 9 · Macros
| 日 | 内容 | 时长 |
|---|---|---|
| 57 | 声明宏 macro_rules! 基础:$expr / $ident / $ty 等 fragment specifier | 1.5h |
| 58 | 声明宏进阶:tt munching、重复模式、卫生性 | 1.5h |
| 59 | 过程宏三种类型:derive / attribute / function-like;syn + quote 工具链 | 1.5h |
| 60 | 写一个简单的 derive macro(例如自动生成 Builder) | 2h |
| 61 | 阅读 thiserror 或 serde_derive 源码 | 1.5h |
| 62 | 复盘 + 写笔记 | 1.5h |
| 63 | 休息 | — |
Week 10 · Unsafe 与 FFI
⚠️ 第三个分水岭。"全面掌握含 unsafe"和"会写 Rust 业务代码"差距很大,这周不要跳。
| 日 | 内容 | 时长 |
|---|---|---|
| 64 | The Rustonomicon 序言 + raw pointer | 1.5h |
| 65 | Rustonomicon:aliasing 规则、lifetime variance(协变/逆变/不变) | 1.5h |
| 66 | UnsafeCell 与内部可变性的真相——为什么 &T 不一定是不可变 | 1.5h |
| 67 | FFI 入门:调 C 库(libc / bindgen) | 1.5h |
| 68 | FFI 出口:把 Rust 暴露给其他语言(cbindgen / PyO3 简介) | 1.5h |
| 69 | 重读 std 中 Box 和 Vec 的源码,这次能看懂 unsafe 块为什么这么写 | 2h |
| 70 | 休息 | — |
阶段六 · 大师级(Week 11-12)
目标:能设计抽象、能读 runtime 源码、能写出地道的 idiomatic Rust。
Week 11 · Async 内部
| 日 | 内容 | 时长 |
|---|---|---|
| 71 | Pin 与 self-referential structs 的真正问题(为什么需要 Pin) | 1.5h |
| 72 | 手撸最简 executor(参考 mini-tokio 或 Phil Opp 的 async 教程) | 2h |
| 73 | Waker / Context / Poll 链路打通;唤醒机制的实现 | 1.5h |
| 74 | Stream trait;futures::Stream 与 tokio_stream | 1.5h |
| 75 | 阅读 Tokio runtime 源码:driver 与 scheduler | 2h |
| 76 | 复盘 + 写笔记 | 1.5h |
| 77 | 休息 | — |
Week 12 · 收官
| 日 | 内容 | 时长 |
|---|---|---|
| 78 | 类型系统魔法:GATs(Generic Associated Types) | 1.5h |
| 79 | HRTB(Higher-Ranked Trait Bounds):for<'a> Fn(&'a T) 到底什么意思 | 1.5h |
| 80 | 性能优化:内存布局、#[repr(C)] / #[repr(transparent)]、零拷贝设计 | 1.5h |
| 81 | 设计一个完整的小型框架(mini-axum 或 mini-tokio,二选一) | 2h |
| 82 | 给目标项目提交一个有质量的 PR——毕业作品 | 2h |
| 83 | 写一篇博客:《我是怎么用 12 周从 Go 转到 Rust 的》 | 2h |
| 84 | 庆祝 🎉 | — |
关键资源(精选不囤货)
主线:
- The Rust Book(官方免费)
- Rustlings(交互练习)
- Rust by Example(字典查阅)
进阶:
- Programming Rust(Blandy/Orendorff,O'Reilly,系统编程视角)
- Rust for Rustaceans(Jon Gjengset,中高级话题,最适合有经验的工程师)
- The Rustonomicon(unsafe 圣经)
- The Async Book(异步官方书)
视频:
- Jon Gjengset 的 YouTube(Crust of Rust 系列):live coding 实现 channel / Mutex / Pin,看一遍胜过读十篇博客
社区:
- This Week in Rust(周报,周末扫一眼)
- Rust 中文社区
- r/rust(英文)
进度追踪表
每周末填一行,公开 commit 到 GitHub 增加正反馈。
| 周 | 完成度 | 卡点(借用检查器卡了几次/具体场景) | 关键收获 | 下周调整 |
|---|---|---|---|---|
| 1 | ||||
| 2 | ||||
| 3 | ||||
| 4 | ||||
| 5 | ||||
| 6 | ||||
| 7 | ||||
| 8 | ||||
| 9 | ||||
| 10 | ||||
| 11 | ||||
| 12 |
何时偏离计划
- 超前:跳过 Rustlings 中明显简单的练习,但不要跳 The Rust Book 章节。
- 滞后:不要积压超过 3 天。如果某一阶段彻底卡住,把当前周延长一周,后面阶段顺延——计划是地图不是法律。
- 生病/项目压力:跳过一周用周日补,但不要连续跳两周——节奏一旦断超过 10 天,你会需要 1.5 倍时间才能回到状态。
Plan crafted 2026-05 · Adjust as you go.
Ch 1 · 为什么 Rust 不一样
"If you're going to learn Rust, you need to first unlearn some things you know about programming." — 一位匿名 Rust 老手
这一章不教语法。这一章只做一件事:让你对 Rust 这门语言形成正确的预期。
如果你跳过这一章直奔后面的 ownership、trait、async,你会在某个时刻撞墙。撞墙的原因不是技术,是心智模型——你带着 Go 或 C# 的世界观走进 Rust,会发现编译器一直在拒绝你"明显正确"的代码。这章帮你提前调整世界观。
1.1 这本书是写给谁的
直接对话——你已经写了多年代码。你懂 GC、懂 channel、懂 interface、懂 async/await、懂 generic constraint、懂 monorepo、懂分布式系统的取舍。市面上的 Rust 书要么把你当成 C++ 老兵(假设你天天手写 new/delete),要么把你当成新手(从 let x = 1 讲起),都不合适。
这本书假设你已经会的事情:
- 至少一门 GC 语言到生产级(Go / Java / C# / Python / TS 任意一种)
- 并发的基本概念:thread、mutex、race condition、deadlock
- 异步的基本概念:event loop、Promise/Task、callback hell
- 泛型与接口:Go interface、C# generic、TS type parameter
- 工程基础:版本控制、CI/CD、依赖管理、单元测试
这本书不会教你的事情:
- 什么是变量
- 什么是函数
- 什么是循环
- 什么是面向对象
这本书不是:
- Rust 语法手册(那是 The Rust Reference)
- Rust 食谱(那是 Rust by Example)
- 一本万人通用的教科书
这本书是:
- 一份从你已知世界出发,到 Rust 的迁移指南
- 一本对你已有的工程直觉做"必要拆除和重建"的书
- 一本写到 unsafe 与 async runtime 内部的全栈视图
1.2 五分钟内看到 Rust 的差异
理论说再多不如一段代码。下面这个任务用三种语言实现:并发抓取一组 URL,把响应体收集起来。
Go 版本
func fetchAll(urls []string) []string {
results := make([]string, len(urls))
var wg sync.WaitGroup
for i, url := range urls {
wg.Add(1)
go func(i int, url string) {
defer wg.Done()
resp, err := http.Get(url)
if err != nil {
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
results[i] = string(body)
}(i, url)
}
wg.Wait()
return results
}
写得很自然。但请回答几个问题:
results[i] = string(body)是在多个 goroutine 里并发写同一个 slice。这为什么是安全的?- 答:因为不同 goroutine 写不同的 index,所以底层数组的不同 cache line 不会冲突。这是程序员的承诺,编译器不验证。
- 如果某天你重构,把
results[i] = ...改成results = append(results, ...),会发生什么? - 答:运行时数据竞争,
go test -race可能能抓到,生产环境可能不能。线上挂掉。
C# 版本
async Task<List<string>> FetchAll(List<string> urls)
{
using var client = new HttpClient();
var tasks = urls.Select(u => client.GetStringAsync(u));
return (await Task.WhenAll(tasks)).ToList();
}
更简洁。但请注意:
client是被 closure 捕获的。多个 task 并发使用同一个HttpClient——这为什么安全?- 答:因为
HttpClient的设计者承诺它线程安全。这是库的承诺,编译器不验证。 - 如果你换成自己的 stateful object,async 之间的状态污染需要你自己想清楚。
- 如果你忘了
await(var tasks = ...; return tasks.ToList();),编译器会给你警告但不会报错。Production crash 风险。
Rust 版本(Tokio)
use reqwest::Client;
use futures::future::join_all;
async fn fetch_all(urls: Vec<String>) -> Vec<String> {
let client = Client::new();
let futures = urls.into_iter().map(|u| {
let client = client.clone();
async move {
let resp = client.get(&u).send().await?;
let body = resp.text().await?;
Ok::<_, reqwest::Error>(body)
}
});
join_all(futures)
.await
.into_iter()
.filter_map(|r| r.ok())
.collect()
}
表面看 Rust 版本啰嗦一些,有几个 clone() 和 await。但请注意以下三件事:
- 编译器强制了所有并发安全约束。每一个跨 task 共享的值都被编译器验证满足
Send + Sync约束。运行时不会有 data race——这不是承诺,这是定理。 - 没有"忘了 await"这种错误。Rust 的 Future 是 lazy 的,你必须 await 才能让它执行。如果你忘了,编译器会警告"unused future, must be polled"。
client.clone()不是性能问题。reqwest::Client内部是Arc,clone 只是引用计数 +1。Rust 让你显式看到这个 clone,而 C# 把它隐藏在引用类型的语义里。
这是 Rust 的差异化价值:
它把工程纪律编译期化了。
你在 Go 和 C# 里靠"小心"和"测试"维护的不变量,在 Rust 里被编译器固化下来。代价是学习曲线陡峭。收益是一旦代码编译通过,一大类 bug 就被排除了。
1.3 Rust 的三个核心承诺
Rust 反复宣传三件事。每一件背后都有具体含义和具体代价。
承诺 1:Memory Safety Without GC
| 维度 | C / C++ | Go / Java / C# | Rust |
|---|---|---|---|
| 内存安全 | ❌ 程序员负责 | ✅ GC 负责 | ✅ 编译器负责 |
| 运行时开销 | 无 | 有 GC pause、heap overhead | 无 |
| 学习成本 | 中(但 bug 多) | 低 | 高 |
| 决定时刻 | 运行时(crash) | 运行时(GC) | 编译期 |
Rust 的差异:把别的语言放在运行时的安全检查搬到编译期。代价是你要学一套新的概念(ownership、borrowing、lifetime),收益是没有 GC pause、没有 use-after-free、没有 double-free、没有 buffer overflow。
承诺 2:Fearless Concurrency
| 维度 | C / C++ | Go | Rust |
|---|---|---|---|
| Data race | ❌ 易发 | ❌ 运行时检测 | ✅ 编译期阻止 |
| Deadlock | ❌ 易发 | ❌ 易发 | ❌ 易发(没解决这个) |
| Send/Sync 检查 | 无 | 无(靠纪律) | ✅ 编译期 marker trait |
| Channel | 库提供 | 语言原生 | 库提供(std + crossbeam + tokio) |
Rust 没解决死锁,但解决了数据竞争。这是通过 Send 和 Sync 两个 marker trait 在编译期完成的——任何不满足这两个约束的代码,在跨线程时编译就过不了。
承诺 3:Zero-Cost Abstractions
"What you don't use, you don't pay for. What you do use, you couldn't hand-code any better." — Bjarne Stroustrup
Rust 借了 C++ 这个理念,做得比 C++ 还彻底。具体表现:
- 泛型用 monomorphization(单态化),编译期为每个具体类型生成代码,运行时无 dispatch 开销
- Iterator chain
vec.iter().map(...).filter(...).sum()编译后等同于手写 for 循环 - async 函数被编译成状态机,没有线程、没有协程栈分配,内存占用极小
- 不用就不付:你不写
Box::new就没有 heap 分配,你不写Mutex就没有锁
代价是编译时间。Rust 编译比 Go 慢 5-10 倍。这是已经被反复讨论的工程权衡。
1.4 概念翻译表(本书最重要的一页)
把这页打印出来贴在显示器旁边。下面所有章节都会用这张表。
内存与所有权
| 你已经懂的 (Go / C# / TS) | Rust 对应 | 关键差异 |
|---|---|---|
| Go: GC 自动回收 | Ownership + Drop | Rust 是编译期决定,无 GC 开销 |
Go: defer 资源清理 | Drop trait + RAII | Rust 自动调用,不需要写 defer |
C#: using / IDisposable | Drop trait | 同上,Rust 不需要 using 关键字 |
Go: pointer *T | &T 借用 / Box<T> 拥有 | Rust 区分"借用"和"拥有",Go 不区分 |
| Go: nil pointer | Option<T> = None | Option 是 enum,必须显式 match |
C#: ref / out | &mut T | Rust 的 &mut 受借用规则约束 |
| C#: nullable reference | Option<T> | Rust 没有 null,所有"可能为空"都是 Option |
TS: T | undefined | Option<T> | 同上,但 Rust 编译器强制处理 None 分支 |
错误处理
| 你已经懂的 | Rust 对应 | 关键差异 |
|---|---|---|
Go: if err != nil | Result<T, E> + ? | ? 自动 propagate,不用写 if |
C#: try / catch | Result<T, E> + ? | Rust 没有 exception,错误是值 |
Go: panic | panic! | 行为相似,但 Rust 不鼓励用 panic 处理业务错误 |
| C#: exception inheritance | Box<dyn Error> / 自定义 enum | Rust 没有继承,用 enum 或 trait object |
类型系统
| 你已经懂的 | Rust 对应 | 关键差异 |
|---|---|---|
Go: interface | trait | Rust trait 多了 associated types、blanket impl、static dispatch |
C#: interface | trait | Rust trait 没有继承,用 supertrait 表达约束 |
TS: interface | trait | TS 是 structural typing,Rust 是 nominal + 显式 impl |
| TS: discriminated union | enum | 几乎一样的语义,Rust 更严格 |
| C#: pattern matching | match | Rust 强制穷尽性 |
Go: switch v.(type) | match on enum | Rust 的更强,因为类型本身就是 enum |
C#: generic where T : ... | where T: ... | 语法几乎一样 |
| Go: 泛型(1.18+) | 完整泛型系统 | Rust 远比 Go 强大 |
| TS: conditional types | (没直接对应) | Rust 用 trait + impl block 实现类似效果 |
并发与异步
| 你已经懂的 | Rust 对应 | 关键差异 |
|---|---|---|
| Go: goroutine | tokio::spawn 或 std::thread::spawn | Tokio task 是 stackless,thread 是 OS 线程 |
| Go: channel | mpsc::channel 或 tokio::sync::mpsc | Rust channel 区分 owned vs cloned sender |
Go: sync.Mutex | std::sync::Mutex 或 tokio::sync::Mutex | Rust 的 Mutex 把数据包在里面,只能通过锁访问 |
Go: sync.WaitGroup | JoinHandle::join() 或 JoinSet | Rust 用所有权表达"等待这个 task" |
C#: Task<T> | Future<Output = T> | Rust Future 是 lazy,Task 是 hot |
C#: async/await | async/await | 语法一样,语义不一样(lazy vs eager) |
C#: IDisposable async | Drop in async (有坑) | Rust 在 async 里 drop 资源有微妙问题 |
| JS: Promise | Future | 同 Task,Promise 是 hot |
工程实践
| 你已经懂的 | Rust 对应 | 关键差异 |
|---|---|---|
Go: go.mod | Cargo.toml | Cargo 功能更多(workspace、feature、build script) |
| pnpm workspace | Cargo workspace | 概念几乎一样 |
Go: go test | cargo test | Rust 测试可以写在源文件里(#[cfg(test)] mod tests) |
Go: go vet | cargo clippy | Clippy 更严格,有 600+ lints |
| C#: NuGet | crates.io | 几乎一样的发布模式 |
| C#: source generator | proc macro | 概念相似,Rust 更强大 |
1.5 Rust 难学的真相
误区与真相
误区 1:Rust 难是因为语法多。
真相:Rust 语法不复杂。它的 keyword 比 C++ 少,比 Java 多一点。语法不是难点。
误区 2:Rust 慢是因为代码量大。
真相:Rust 代码量和 Go 相当,有时候更短(thanks to iterator chain 和 ? 操作符)。
误区 3:Rust 难是因为有指针。
真相:Rust 没有 C 那种"裸指针到处飞"的指针。&T / &mut T 是引用,有严格规则;Box<T> / Rc<T> / Arc<T> 是智能指针,自动管理。真要用裸指针得进 unsafe 块。
真正的难点:
Rust 要求你显式表达内存所有权——这是别的 GC 语言帮你隐藏掉的一个维度。
GC 语言里,你脑子里只有一个问题:"这个变量的值是什么?"。Rust 里你必须同时回答两个问题:"这个变量的值是什么?" 和 "这个变量拥有这个值,还是只是借用?"
多了一个维度。所有难点都从这里派生。
学习曲线的三个阶段
能力
↑
│ ┌──── 流畅期
│ ╱
│ ╱
│ ╱
│ .clone() 期 ──────────────╱ ← 危险区!
│ ╱
│ ╱
│ ╱
│ ╱
│ 挫败期 ────╱
│ ╱
│ ╱
│ ╱
│ ╱
└────────────────────────────────────────→ 时间
Week 1 Week 2-3 Week 4-6 Week 7+
Week 1:挫败期
你每天被编译器拒绝 30 次。最常见的报错:
cannot borrow X as mutable because it is also borrowed as immutableX does not live long enoughcannot move out of borrowed contentthe trait Send is not implemented for ...
正常反应:气馁、怀疑自己、怀疑 Rust 是不是太极端了。
Week 2-3:.clone() 期(危险!)
你开始猜出哪些写法会被拒绝。但你的应对方式可能是错的——很多人在这个阶段开始用 .clone() 绕过所有借用检查。
// 这就是 .clone() 期的典型代码
fn process(data: &Vec<String>) -> Vec<String> {
let copy = data.clone(); // 不必要的 clone
let filtered: Vec<String> = copy.into_iter()
.filter(|s| s.len() > 0)
.map(|s| s.clone()) // 又一个不必要的 clone
.collect();
filtered
}
这是危险时刻。如果你停在这里,你就永远没真正学会 Rust。你写的是"看起来像 Rust 的 Go 代码"。性能没优势,工程纪律没提升,只是把 Go 的代码硬翻译过来。
正确的反应:每次借用错误是一次"你没想清楚所有权"的提醒。停下来想:这个值真的需要被两个地方同时拥有吗?如果是,是不是该用 Arc?如果不是,是不是该用 & 借用?如果是,生命周期怎么排?
如果你在 Week 2-3 反复练这个思考过程,Week 4 后你就真的会 Rust 了。
Week 4-6:开窍期
某天你会突然发现:
- 你看到一段代码就能猜出借用检查器会不会接受
- 你设计 API 时会考虑"这个参数应该是 owned 还是 borrowed"
- 你看 stdlib 源码不再处处看到 unsafe 就害怕——你能读懂为什么这里需要 unsafe
- 你会主动减少
.clone(),用&str/Cow/Arc等更精细的工具
Week 7+:流畅期
Rust 不再是阻力。你能像写 Go 一样自然地写 Rust。差别在于:你的代码编译过了之后,bug 比 Go 版本少一个数量级。
关键建议
- 不要在 Week 2-3 用 AI 帮你绕过借用错误。每次借用错误都自己读编译器报错,自己想为什么。AI 帮你解决了表面问题,但你的 mental model 没建立起来。
- 每次想
.clone()之前,问自己:这个值我是真的需要复制?还是我没想清楚所有权? - 不要怕慢。Week 1-3 慢是正常的。Week 4 后你的速度会追上来,Week 7 后你会比 Go 写得还快(因为 bug 少)。
- 找一个真实项目。toy project 不够。给开源 crate 提 PR,或者把你某个生产模块用 Rust 重写。
1.6 这本书的承诺与不承诺
承诺:
- 读完你能给 Tokio / Axum / Hyper 这一级别的项目提交可合并的 PR
- 读完你能写
unsafe代码而不引入 undefined behavior - 读完你能给同事讲清楚"为什么 Rust 这样设计",而不是只会照抄
- 读完你看 Rust 编译器报错时,能立刻识别错误模式
不承诺:
- 不会让你成为 rustc(Rust 编译器)贡献者——那是另一本书
- 不会精讲 Rust 嵌入式开发(
no_std、bare metal)——只在 Ch 19 简介 - 不会教你具体某个领域的 best practice(Web 后端讲 Axum,但不会教你怎么设计一个支付系统)
阅读建议:
- Part I(Ch 1-3)必须按顺序读,不能跳
- Part II 之后可以按需跳读
- 每章末尾的练习不要跳。Rust 是练出来的,不是看出来的
- 配套代码仓库的 cargo workspace 跑起来,每章在对应子目录写代码
1.7 准备工作
安装 toolchain
# 安装 rustup(Rust 的版本管理器,类似 nvm)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 验证安装
rustc --version # 应该看到 rustc 1.x.x
cargo --version # 应该看到 cargo 1.x.x
# 安装常用组件
rustup component add clippy rustfmt rust-analyzer rust-src
配置编辑器
强烈推荐 VS Code + rust-analyzer 插件(就算你平时用 JetBrains)。rust-analyzer 是目前最好的 Rust LSP,JetBrains 的 RustRover 也基于它,但 VS Code 的 setup 更轻。
VS Code 必装插件:
rust-lang.rust-analyzertamasfe.even-better-toml(Cargo.toml 高亮)vadimcn.vscode-lldb(调试)serayuzgur.crates(依赖版本提示)
Hello World
cargo new hello-rust
cd hello-rust
cargo run
输出:
Compiling hello-rust v0.1.0 (...)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
Running `target/debug/hello-rust`
Hello, world!
打开 src/main.rs:
fn main() { println!("Hello, world!"); }
注意 println! 后面的 !——这是宏调用,不是函数调用。Rust 区分得很严格。Ch 17 详讲。
第一个有意义的程序
下面这段代码故意写得啰嗦,演示几个本书后面会讲的核心特性。先运行起来,不需要全懂。
use std::io::{self, BufRead, Write}; fn main() { let stdin = io::stdin(); let stdout = io::stdout(); let mut out = stdout.lock(); writeln!(out, "Type a string (Ctrl+D to exit):").unwrap(); for line in stdin.lock().lines() { match line { Ok(s) => { let reversed: String = s.chars().rev().collect(); writeln!(out, "Reversed: {}", reversed).unwrap(); } Err(e) => { eprintln!("Error: {}", e); break; } } } }
运行 cargo run,输入几行字符串,看到反转后的输出,Ctrl+D 退出。
观察这段代码,你已经看到了本书后面会展开的几个概念:
let mut out = stdout.lock()——mut是显式可变性,默认不可变match line { Ok(s) => ..., Err(e) => ... }——Result用 match 处理s.chars().rev().collect()—— iterator chain,Ch 10 详讲.unwrap()—— 出错就 panic,这是练习代码,生产里不应该这样&和mut在参数里出现 —— 借用,Ch 3 详讲
验收标准
进入下一章前,确认以下事情你都做到了:
-
cargo --version能跑出版本号 -
VS Code 打开 hello-rust 项目,鼠标悬停在
String上能看到类型提示 - 上面那段反转字符串的程序能编译运行
-
你能在不查资料的情况下解释
let和let mut的区别 -
你能猜出
String::new()和let s: String = ...的差别(都行,凭直觉答)
本章习题
习题 1.1(简单)
把"反转字符串"程序改成"统计每行单词数"。提示:split_whitespace() 和 count()。
习题 1.2(中等)
写一个程序,从命令行参数读取一个数字 N,打印从 1 到 N 的所有 Fibonacci 数。提示:std::env::args()、parse::<u64>()、用 for 循环或 iterator。
习题 1.3(思考题,不用写代码)
回到 1.2 节那个 Go 版本的 fetchAll。如果用 Rust 实现等价代码,你预计哪些地方会被借用检查器拒绝?哪些地方需要 Arc 或 clone?把你的猜测写下来,本书读到 Ch 13 时回头看自己的猜测。
习题 1.4(开放题)
回顾你最近 3 个月写的一段代码(任何语言)。如果用 Rust 重写,哪些 bug 会被编译器在编译期拦下?哪些"运行时小心"的约束会变成"编译期保证"?这个练习帮你建立对 Rust 价值的具体感受。
下一章预告
Ch 2 我们正式进入 ownership。我们会用一个具体的场景反复打磨:把一个 Vec<String> 按某个规则分组。你会看到至少 4 种不同的实现方式,每种背后是不同的所有权决策。读完那一章你会理解为什么 Rust 要求你做这些选择,以及怎么做这些选择。
本章一句话总结
Rust 不是"更难的 Go",也不是"更安全的 C++"。它是一门多了一个维度(所有权)的语言。理解这个维度,你能写出任何其他语言都达不到的安全和性能;理解不到,你会一直跟编译器吵架。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点(借用检查器拒绝的具体场景) | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 2 · Ownership 是工程纪律
"The borrow checker is not your enemy. It is your colleague who reads every line of your code before you commit."
Ch 1 我们建立了一个判断:Rust 的核心差异是多了"所有权"这个维度。这一章我们正式打开这个维度。
读完这章,你应该能回答以下问题(不是背答案,是从工程动机出发推导出答案):
- 为什么 Rust 要求你区分 stack 和 heap?Go 和 C# 是怎么把这件事藏起来的?
- 为什么
let s2 = s1在String类型上会让s1失效,在i32上不会? - 为什么 Rust 不需要
defer/using/finally来释放资源? - 函数参数应该写
T还是&T还是&mut T?判断依据是什么? - 什么时候
.clone()是对的,什么时候是"我没想清楚所有权"的代码味?
如果读完你还回答不上来,回头再读一遍。这章是后面所有内容的地基。
2.1 Stack 与 Heap:为什么 Rust 让你显式看见
GC 语言把这件事藏在哪里
写 Go 代码的时候,你大概率不会问自己"这个变量在 stack 还是 heap"。
// Go
func makeUser() *User {
u := User{Name: "Marvin"}
return &u // 返回栈上变量的指针?Go 编译器会自动把它改成 heap allocation
}
Go 有一个叫 escape analysis(逃逸分析)的机制——编译器在你看不见的地方分析:这个变量的引用有没有"逃出"当前函数?如果逃出去了,自动放到 heap;否则放在 stack。
C# 的规则更简单粗暴:class 永远在 heap,struct 永远在 stack(除非作为 class 的字段被装箱)。但具体的内存布局对你不可见。
TS / JavaScript 更进一步:你完全不知道 V8 把你的对象放在哪里。引擎想优化就优化,你说了不算。
这些语言为什么能藏起来?
因为它们都有 GC。GC 接管了"内存什么时候释放"这个问题,所以"内存放在哪里"也变得无关紧要——反正不归你管。
Rust 为什么不能藏
Rust 没有 GC。"什么时候释放"这件事必须有人决定。Rust 的选择是:让你显式表达,编译器在编译期决定。
为了让你能表达,Rust 需要你区分:
| 内存位置 | 特点 | Rust 中的体现 |
|---|---|---|
| Stack | 大小编译期已知,LIFO,函数返回自动清理 | let x: i32 = 42,let arr: [u8; 16] = ... |
| Heap | 大小运行时才知,显式分配/释放 | Box<T>,Vec<T>,String |
举例:
fn main() { // Stack: 大小编译期确定,8 字节 let n: i64 = 42; // Stack: 大小编译期确定,16 字节(4 个 u32) let arr: [u32; 4] = [1, 2, 3, 4]; // Heap: 大小运行时才知,String 在 stack 上有个 fat pointer (24 字节), // 实际字符数据在 heap 上 let s: String = String::from("hello"); // Heap: Vec 同理,stack 上是 fat pointer,数据在 heap 上 let v: Vec<i32> = vec![1, 2, 3]; }
String 在内存里实际长这样:
Stack 上的 String 结构体 Heap 上的真实字符数据
┌───────────────────┐ ┌──────────────────────┐
│ ptr: *mut u8 ─────┼──────────► │ 'h','e','l','l','o' │
│ len: usize = 5 │ └──────────────────────┘
│ cap: usize = 5 │
└───────────────────┘
(24 bytes on 64-bit)
这个布局有几个工程含义,值得记住:
String本身在 stack 上,只占 24 字节(64 位机器)。它是个"瘦"的描述符。- 真正的字符数据在 heap 上,长度运行时才知。
String离开作用域时,Rust 必须释放 heap 上那块内存。这就是 ownership 解决的问题。
这一节的关键 takeaway
Go 用 escape analysis 自动决定 stack/heap;C# 用 class/struct 二分;Rust 让你通过类型系统显式表达——基本类型(i32 / bool / [T; N])在 stack 上,带堆分配的类型(Box<T> / Vec<T> / String)在 stack 上有描述符、heap 上有数据。
这个区分不是为了让你折腾,是为了下面要讲的 ownership 规则有意义。
2.2 Ownership 三条规则
Rust 的 ownership 有且只有三条规则,The Rust Book 原文:
- Each value in Rust has an owner.
- There can only be one owner at a time.
- When the owner goes out of scope, the value will be dropped.
中文版:
- 每个值都有一个"所有者"。
- 同一时刻只能有一个所有者。
- 所有者离开作用域时,值会被丢弃(drop)。
这三条规则非常短,但它们一起解决了一组真实的工程问题。我们用反例展示。
这三条规则在拦截什么 bug?
Bug 1:Use-after-free
C 里典型的 use-after-free:
char* get_greeting() {
char buf[20];
strcpy(buf, "hello");
return buf; // 返回栈上变量的指针,函数返回后这块内存被释放
}
int main() {
char* p = get_greeting();
printf("%s\n", p); // 未定义行为,可能打印乱码,可能段错误,可能正确
}
GC 语言不会发生这个问题——GC 会保留被引用的内存。Rust 没有 GC,但通过 规则 3(owner 离开作用域 = drop),加上后面要讲的借用规则,在编译期就能拦下:
fn get_greeting() -> &str { // 编译错误!
let s = String::from("hello");
&s // s 即将被 drop,你怎么能返回它的引用?
}
编译器报:
error[E0106]: missing lifetime specifier
|
3 | &s
| ^^ this returns a reference to data owned by the current function
Bug 2:Double-free
C++ 里的经典:
class Buffer {
char* data;
public:
Buffer(int size) { data = new char[size]; }
~Buffer() { delete[] data; }
};
int main() {
Buffer a(100);
Buffer b = a; // 默认 copy constructor 只复制了指针!
// a 和 b 都拥有同一块 heap 内存
} // a 和 b 都析构,都 delete[] data,double-free,程序崩溃
Rust 通过规则 2(同一时刻只能有一个所有者)拦截:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // 所有权从 s1 转移到 s2
println!("{}", s1); // 编译错误!s1 已经不再拥有这个 String
}
编译器报:
error[E0382]: borrow of moved value: `s1`
|
3 | let s2 = s1;
| -- value moved here
4 | println!("{}", s1);
| ^^ value borrowed here after move
Bug 3:资源泄漏
Java/C# 里的经典——文件、socket 之类的资源忘了关:
public void ProcessFile(string path) {
var file = File.OpenRead(path);
// 忘了 Dispose,如果方法抛异常,文件不会被关
}
C# 用 using 解决,但要程序员主动写:
public void ProcessFile(string path) {
using var file = File.OpenRead(path);
// 离开作用域自动 Dispose
}
Rust 通过规则 3 + Drop trait,直接默认行为就是这样:
#![allow(unused)] fn main() { fn process_file(path: &str) -> std::io::Result<()> { let file = std::fs::File::open(path)?; // 用 file 做事 Ok(()) // file 离开作用域,自动调用 Drop,文件自动关闭 // 不需要 using,不需要 defer,不需要 finally } }
三条规则的工程动机总结
| 规则 | 拦截的 bug | 类比 |
|---|---|---|
| #1 每个值有一个 owner | 野指针 | 每条狗有一个主人 |
| #2 同一时刻只能有一个 owner | double-free | 一条狗不能同时被两个主人完全拥有 |
| #3 owner 出作用域时 drop | 资源泄漏 | 主人离开时,狗也走了 |
这三条规则不是 Rust 设计者拍脑袋定的。它们是为了不用 GC 也能保证内存安全所需要的最小约束。少一条就有 bug。
2.3 Move 与 Copy:let s2 = s1 到底发生了什么
这是新手最迷惑的地方。同样一行代码 let b = a,在不同类型上行为不同:
// 案例 1:i32
let a: i32 = 42;
let b = a;
println!("{}", a); // ✅ 正常打印 42
// 案例 2:String
let a: String = String::from("hello");
let b = a;
println!("{}", a); // ❌ 编译错误:value borrowed here after move
为什么?
Move 的本质
回顾 2.1 节的 String 内存布局:String 在 stack 上是 24 字节的描述符(ptr/len/cap),真实数据在 heap。
当你写 let b = a,Rust 默认做的是 bitwise copy of stack content——把 stack 上那 24 字节复制一份给 b。结果:
执行 let b = a 之前:
a: [ptr ─→ heap data, len=5, cap=5]
执行 let b = a 之后(如果允许同时使用):
a: [ptr ─→ heap data, len=5, cap=5]
b: [ptr ─→ same heap data, len=5, cap=5]
注意:两个 stack 描述符都指向同一块 heap 内存。如果允许 a 和 b 同时使用,a 离开作用域 drop 一次,b 离开作用域又 drop 一次——double-free。
Rust 的解决方案不是"深拷贝 heap 数据"(那是 clone() 做的事),而是让 a 失效。这就叫 move:
执行 let b = a 之后(Rust 实际行为):
a: [被标记为已 move,不能再使用]
b: [ptr ─→ heap data, len=5, cap=5]
stack 上的 24 字节确实复制了一份,但编译器禁止你再用 a。这是个编译期标记,运行时没有任何额外开销——move 在汇编层面就是一次普通的内存复制,没有任何特殊处理。
Copy 的本质
那为什么 i32 的 let b = a 之后 a 还能用?
因为 i32 实现了 Copy trait。Copy 是一个marker trait(空 trait,没有方法),它告诉编译器:"这个类型 bitwise copy 之后,原来的值依然有效,不需要 move 语义"。
判断标准:类型的所有数据都在 stack 上,不持有任何 heap 资源或外部资源。
| 类型 | 是 Copy 吗? | 为什么 |
|---|---|---|
i32 / i64 / u8 / f64 / bool / char | ✅ | 完全在 stack,无外部资源 |
(i32, i32) / [u8; 16] | ✅ | tuple/array 全是 Copy 类型,自身也是 Copy |
&T(共享引用) | ✅ | 引用本身只是个指针,复制指针没问题 |
String | ❌ | 持有 heap 内存,bitwise copy 会导致 double-free |
Vec<T> | ❌ | 同上 |
Box<T> | ❌ | 同上,持有独占的 heap 分配 |
&mut T(可变引用) | ❌ | 可变引用必须独占,不能复制 |
| 你自己的 struct(默认) | ❌ | 默认不实现 Copy,需要显式 #[derive(Copy, Clone)] |
实操:让自定义 struct 变成 Copy
// 默认行为:不是 Copy
#[derive(Debug)]
struct Point {
x: f64,
y: f64,
}
fn main() {
let p1 = Point { x: 1.0, y: 2.0 };
let p2 = p1; // move
println!("{:?}", p1); // ❌ 编译错误
}
// 显式标注 Copy #[derive(Debug, Copy, Clone)] struct Point { x: f64, y: f64, } fn main() { let p1 = Point { x: 1.0, y: 2.0 }; let p2 = p1; // copy println!("{:?}", p1); // ✅ 正常工作 }
注意 Copy 必须和 Clone 一起 derive,因为 Copy 在 trait 层面要求 Clone(Copy 是 Clone 的一个特例:bitwise clone 即可)。
工程建议:不要无脑给所有 struct 加 Copy。如果你的 struct 将来可能加一个 String 字段(比如加个 name),那 Copy 就得拿掉,所有用过 move-after-use 模式的代码都得改。Copy 是对外承诺,加了就难撤回。
Move 在函数调用边界的体现
Move 不只发生在 let b = a,函数调用也是 move 时刻:
fn take_string(s: String) {
println!("{}", s);
} // s 在这里被 drop
fn main() {
let s = String::from("hello");
take_string(s); // s 被 move 进函数
println!("{}", s); // ❌ 编译错误:s 已经被 move
}
这就是为什么 Rust 的函数签名要小心写——fn take_string(s: String) 是"我要拿走这个 String",fn take_string(s: &String) 是"我借一下,看完还你"。后面 2.5 节详谈。
这一节的关键 takeaway
let b = a 的行为取决于 a 的类型:
- 如果是
Copy类型(i32,bool,&T, 全 Copy 字段的 struct):复制,a仍可用 - 如果是非
Copy类型(String,Vec,Box):move,a失效
判断依据:有没有 heap/外部资源。有就 move,没有就 copy。
2.4 Drop trait:RAII 是默认行为
C++ 老兵看到 RAII 不会陌生。GC 语言出身的工程师可能要重新校准——Rust 的 RAII 比 C++ 还彻底,因为它是语言默认行为而不是程序员的纪律。
什么是 Drop
每个非 Copy 类型在离开作用域时,都会自动调用 Drop::drop() 方法。stdlib 里所有持有外部资源的类型都实现了 Drop:
| 类型 | drop 时做什么 |
|---|---|
String / Vec<T> / Box<T> | 释放 heap 内存 |
File | 关闭文件句柄 |
TcpStream / UdpSocket | 关闭 socket |
MutexGuard<'_, T> | 释放锁 |
Rc<T> / Arc<T> | 引用计数减 1,到 0 时 drop 内部值 |
跟其他语言对比
| 语言 | 资源清理机制 | 谁负责调用 |
|---|---|---|
| C | 手动 free / close | 程序员(经常忘) |
| C++ | RAII(析构函数) | 编译器(C++ 的发明) |
| Java | finally block 或 try-with-resources | 程序员(不写就漏) |
| Go | defer | 程序员(不写就漏) |
| C# | using / IDisposable | 程序员(不写就漏) |
| Python | with / __exit__ | 程序员(不写就漏) |
| Rust | Drop trait | 编译器(默认行为) |
注意 Rust 这一行:默认行为。你不需要写 defer,不需要写 using,不需要写 with。资源清理是免费的副产品。
实操对比
Go 版本:
func processFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close() // 必须写,不写就漏
// ... 处理文件 ...
return nil
}
Rust 版本:
#![allow(unused)] fn main() { fn process_file(path: &str) -> std::io::Result<()> { let file = std::fs::File::open(path)?; // ... 处理文件 ... Ok(()) // file 在这里离开作用域,Drop::drop 被自动调用,文件被关闭 } }
Rust 版本短一行,但更重要的是你不可能忘。
自定义 Drop
你也可以给自己的类型实现 Drop。常见场景:打印调试信息、释放外部资源、解除注册。
struct Connection { id: u32, } impl Drop for Connection { fn drop(&mut self) { println!("Connection {} closed", self.id); // 真实代码里可能做:发送 disconnect 包、从连接池移除、记日志、etc. } } fn main() { let c1 = Connection { id: 1 }; { let c2 = Connection { id: 2 }; // c2 在这个内层作用域结束时 drop } println!("After inner scope"); // c1 在 main 结束时 drop }
输出:
Connection 2 closed
After inner scope
Connection 1 closed
Drop 的几个工程细节
1. Drop 顺序是 LIFO(后进先出)
fn main() {
let a = Connection { id: 1 };
let b = Connection { id: 2 };
let c = Connection { id: 3 };
}
// 输出:
// Connection 3 closed
// Connection 2 closed
// Connection 1 closed
这跟 C++ 析构、Go defer 的顺序一致。
2. struct 的字段 drop 顺序是定义顺序
struct Outer {
a: Connection, // 字段 a 先 drop
b: Connection, // 字段 b 后 drop?不对,看下面
}
实际上 struct 字段是按定义顺序(top-down)drop 的,不是反向。这跟 LIFO 不一致,容易踩坑。如果两个字段有 drop 顺序依赖,要小心。
3. 你不能手动调用 drop() 方法
let c = Connection { id: 1 };
c.drop(); // ❌ 编译错误:explicit use of destructor method
但你可以用 std::mem::drop 函数提前 drop:
let c = Connection { id: 1 };
drop(c); // ✅ c 在这里被 drop
println!("After drop");
drop 函数的源码很简单——它就是空函数,把参数 move 进来,函数返回时参数离开作用域,触发 Drop:
#![allow(unused)] fn main() { pub fn drop<T>(_x: T) {} }
4. panic 时 Drop 仍然被调用(默认配置下)
Rust 默认是 "unwinding panic"——panic 时栈展开,所有局部变量的 Drop 被调用。这跟 C++ 的异常机制类似。
如果你配置 panic = "abort"(常见于嵌入式或追求最小二进制大小的场景),那 panic 直接终止程序,Drop 不被调用。
这一节的关键 takeaway
RAII 在 Rust 里不是模式,是语言默认。你不需要写 defer、using、finally。每次你看到资源类型(File、MutexGuard、TcpStream),你应该相信"它会被自动清理",并把注意力放在它的所有权应该流到哪里。
2.5 函数边界:by value、by ref、by mut ref
到目前为止我们讨论了一个值在一个函数内的所有权。函数调用是所有权流动的关键时刻——你的 API 设计很大程度上是在回答"谁拥有这个值"。
Rust 函数参数有三种主要形式:
fn take_ownership(s: String) { /* ... */ } // by value: 接管所有权
fn borrow(s: &String) { /* ... */ } // by ref: 共享借用
fn borrow_mut(s: &mut String) { /* ... */ } // by mut ref: 独占借用
借用细节我们 Ch 3 详讲,这里先建立"什么时候用哪种"的工程直觉。
场景化对照表
| 场景 | 选哪种 | 例子 |
|---|---|---|
| 函数需要"消费"或转换这个值 | T (by value) | fn build_thing(config: Config) -> Thing |
| 函数只需要读 | &T | fn print_user(u: &User) |
| 函数需要修改 | &mut T | fn add_item(list: &mut Vec<Item>, item: Item) |
| 函数只需要看一段字符串 | &str(不是 &String) | fn count_chars(s: &str) -> usize |
| 函数只需要看一段切片 | &[T](不是 &Vec<T>) | fn sum(v: &[i32]) -> i32 |
| 函数需要存这个值到长期容器 | T 或 Arc<T> | fn register(handler: Arc<Handler>) |
最后两条很重要——下一节展开。
反模式:不要写 &String / &Vec<T>
新手经常写:
#![allow(unused)] fn main() { fn count_chars(s: &String) -> usize { s.chars().count() } fn sum(v: &Vec<i32>) -> i32 { v.iter().sum() } }
这是反模式。正确写法:
#![allow(unused)] fn main() { fn count_chars(s: &str) -> usize { s.chars().count() } fn sum(v: &[i32]) -> i32 { v.iter().sum() } }
为什么?因为 &str 比 &String 更通用——String 可以自动转 &str(deref coercion),但反过来不行。同理 &[T] 比 &Vec<T> 更通用。
举例,有了 fn sum(v: &[i32]),这些都能传:
let v: Vec<i32> = vec![1, 2, 3];
sum(&v); // Vec 自动转 &[i32]
let arr: [i32; 3] = [1, 2, 3];
sum(&arr); // 数组也能转
let slice: &[i32] = &[1, 2, 3];
sum(slice); // 直接 slice
// 如果签名是 fn sum(v: &Vec<i32>),后两种都不能传
工程经验:写函数签名时,选择能接受最广输入的类型。&str > &String,&[T] > &Vec<T>,impl AsRef<Path> > &PathBuf。
实操对比:同一段逻辑的三种签名
任务:把一个用户列表中的某个用户的 email 改成大写。
版本 A:by value(接管所有权)
struct User { name: String, email: String, } fn uppercase_email(mut user: User) -> User { user.email = user.email.to_uppercase(); user } fn main() { let u = User { name: "Marvin".into(), email: "m@hp.com".into() }; let u = uppercase_email(u); println!("{}", u.email); // M@HP.COM }
适用场景:函数式风格、不可变更新、链式调用。代价:每次调用都 move,如果 caller 还要原值就得 clone。
版本 B:by mut ref(原地修改)
struct User { name: String, email: String, } fn uppercase_email(user: &mut User) { user.email = user.email.to_uppercase(); } fn main() { let mut u = User { name: "Marvin".into(), email: "m@hp.com".into() }; uppercase_email(&mut u); println!("{}", u.email); // M@HP.COM }
适用场景:命令式风格、原地修改、不需要返回新值。这是大多数 Rust 工程代码的选择。
版本 C:by ref(只读 + 返回新值)
struct User { name: String, email: String, } fn uppercase_email(user: &User) -> String { user.email.to_uppercase() } fn main() { let u = User { name: "Marvin".into(), email: "m@hp.com".into() }; let upper = uppercase_email(&u); println!("{}", upper); // M@HP.COM println!("{}", u.email); // m@hp.com,原值未改 }
适用场景:只需要派生信息,不修改原值。
选择指南
我需要这个值做什么?
│
├─ 我要接管它,以后不还(比如存进容器、转换成别的类型)
│ └─ 用 T (by value)
│
├─ 我要修改它,改完还给 caller
│ └─ 用 &mut T
│
└─ 我只是看一眼
├─ 是字符串? → &str
├─ 是数组? → &[T]
├─ 是 Path? → impl AsRef<Path>
└─ 其他 → &T
这一节的关键 takeaway
函数签名是 Rust 工程师的主要设计工具。选 T / &T / &mut T 等于在回答"调用方在这次调用之后还需要这个值吗?需要修改吗?"。这个习惯一旦养成,你写出的 API 会有清晰的契约。
2.6 反例对比:同一段逻辑在 Go / C / Rust
讲了这么多概念,我们用一个具体场景三语言对照,体会"责任放在哪里"的差异。
任务:写一个函数,接受一个字符串列表,过滤出长度大于 3 的,返回一个新列表。
Go 版本
func filterLong(strs []string) []string {
result := []string{}
for _, s := range strs {
if len(s) > 3 {
result = append(result, s)
}
}
return result
}
func main() {
input := []string{"hi", "hello", "hey", "world"}
output := filterLong(input)
fmt.Println(output)
fmt.Println(input) // input 仍可用
}
责任分布:
- 内存:GC 全包
- 并发安全:程序员自觉
- 资源清理:GC
容易出错的地方:几乎不会出错,因为 GC 兜底。
C 版本
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char** filter_long(const char** strs, int n, int* out_n) {
char** result = malloc(sizeof(char*) * n);
int count = 0;
for (int i = 0; i < n; i++) {
if (strlen(strs[i]) > 3) {
// 这里要不要 strdup?调用方期望什么?
result[count++] = strdup(strs[i]);
}
}
*out_n = count;
return result;
}
int main() {
const char* input[] = {"hi", "hello", "hey", "world"};
int out_n;
char** output = filter_long(input, 4, &out_n);
for (int i = 0; i < out_n; i++) {
printf("%s\n", output[i]);
free(output[i]); // 必须 free 每个 strdup 的字符串
}
free(output); // 还要 free 数组本身
return 0;
}
责任分布:
- 内存:程序员
- 并发安全:程序员
- 资源清理:程序员
容易出错的地方(数一下):
malloc没检查返回值strdup失败也没检查- 调用方忘了 free → 内存泄漏
- 调用方 free 了一次又 free 了一次 → double-free,崩溃
- 调用方在 free 之后又访问 → use-after-free
- 函数文档里没说清楚 caller 是不是要 free,容易传错
实际工程里,C 项目大量精力在管这些。Linux kernel、SQLite、PostgreSQL 都有详细的内存管理约定文档。
Rust 版本
fn filter_long(strs: Vec<String>) -> Vec<String> {
strs.into_iter()
.filter(|s| s.len() > 3)
.collect()
}
fn main() {
let input = vec![
String::from("hi"),
String::from("hello"),
String::from("hey"),
String::from("world"),
];
let output = filter_long(input);
println!("{:?}", output);
// println!("{:?}", input); // ❌ 编译错误:input 已被 move
}
责任分布:
- 内存:编译器
- 并发安全:编译器(下章详讲)
- 资源清理:编译器(via Drop)
容易出错的地方:几乎没有。如果你写错了——比如想要在调用 filter_long 之后还用 input——编译器会立刻告诉你,你有几个明确的修复选项:
#![allow(unused)] fn main() { // 选项 1:接受 borrow,只读不消费 fn filter_long(strs: &[String]) -> Vec<String> { strs.iter() .filter(|s| s.len() > 3) .cloned() // 因为返回 Vec<String> 而不是 Vec<&String>,需要 clone .collect() } // 选项 2:返回引用,零拷贝 fn filter_long_ref<'a>(strs: &'a [String]) -> Vec<&'a String> { strs.iter() .filter(|s| s.len() > 3) .collect() } }
三语言对比表
| 维度 | C | Go | Rust |
|---|---|---|---|
| 代码长度 | 长 | 短 | 短 |
| 内存安全 | 程序员负责 | GC 负责 | 编译器负责 |
| 调用方需要看文档才知道是否要释放 | ✅ | ❌ | ❌(类型签名告诉你) |
| 改一处 API 改动会引发的 cascade | 大(文档、调用方、内存协议) | 小 | 中(类型签名变化,编译器列出所有需要改的地方) |
| 运行时开销 | 无 | GC | 无 |
| 重构信心 | 低 | 中 | 高 |
最后一行是隐性收益:Rust 重构信心高。你改了一个函数签名,编译器会列出所有影响的地方,你逐个改完代码就能编译,编译过了就大概率能跑。Go 改了 interface 不会立刻报错(只在调用点才报),C 更糟糕(可能完全不报)。
这一节的关键 takeaway
Rust 的代价(学习曲线陡)和收益(编译期保证、零运行时开销、重构信心)是一对耦合的事情。你接受了显式所有权这个负担,换来一组别的语言要么放弃要么靠纪律维持的保证。
2.7 .clone() 的工程判断
新手常用 .clone() 绕过借用检查。Ch 1 我们说这是 Week 2-3 的"危险时刻"。这一节给你一个具体的判断流程。
.clone() 在做什么
Clone::clone() 是显式的、可能开销大的复制。对 String 来说,clone 会:
- 在 heap 上分配新的内存
- 把原 String 的字符数据复制过去
- 返回一个新的 String,持有这块新内存
#![allow(unused)] fn main() { let s1 = String::from("hello"); let s2 = s1.clone(); // 分配新 heap 内存,复制 5 个字节 // 现在 s1 和 s2 各自拥有自己的 heap 内存 }
成本:一次 heap 分配 + 一次 memcpy。对小 String 来说是几十纳秒,对大 String 或 Vec<HashMap<...>> 这种深层结构,可能是毫秒级。
何时 clone 是对的
✅ 当你确实需要两份独立的数据
fn save_to_two_places(data: String) {
db1.save(data.clone()); // db1 拿一份
db2.save(data); // db2 拿原始
}
两个数据库各持一份,这是真实需要,clone 合理。
✅ 当 clone 成本可忽略
#![allow(unused)] fn main() { let id: u64 = 42; let id2 = id.clone(); // 无所谓,u64 是 Copy 类型,clone == copy }
(对 Copy 类型,你也可以不写 clone,编译器自动 copy。但写了不影响,可读性而已。)
✅ 当你 clone 的是 Arc<T> / Rc<T> 这种引用计数类型
let shared = Arc::new(big_data);
let s1 = shared.clone(); // 只是引用计数 +1,极其便宜
let s2 = shared.clone();
Arc::clone 不复制 heap 数据,只把原子引用计数 +1。这是 Rust 共享所有权的标准模式。
何时 clone 是逃避
❌ 借用检查器报错你直接 clone
fn process(data: &Vec<String>) -> String {
let copy = data.clone(); // 不需要!
copy.iter().filter(|s| !s.is_empty()).next().cloned().unwrap_or_default()
// 应该直接用 data:
// data.iter().filter(|s| !s.is_empty()).next().cloned().unwrap_or_default()
}
❌ 函数返回引用太麻烦,改成 owned + clone
#![allow(unused)] fn main() { // 如果你写成这样 fn first_word(s: &String) -> String { s.split_whitespace().next().unwrap_or("").to_string() // 这里 to_string() 是不必要的 clone } // 应该写成 fn first_word_better(s: &str) -> &str { s.split_whitespace().next().unwrap_or("") } }
❌ 存到 struct 字段时无脑 clone
struct Service {
config: Config,
}
impl Service {
fn new(config: &Config) -> Self {
Service { config: config.clone() } // 真的需要复制?
// 也许应该是 Service { config: Arc<Config> }
}
}
决策流程图
我想 clone 这个值,先停一下,问自己:
┌─────────────────────────────────────────────────────────┐
│ 1. 我真的需要两份独立的数据吗? │
│ (一份会被修改,另一份不能被影响) │
└─────────┬───────────────────────────────┬───────────────┘
│ 是 │ 否
▼ ▼
clone 合理 继续问问题 ↓
┌─────────────────────────────────────────────────────────┐
│ 2. 我能不能用 borrow (&T) 替代? │
└─────────┬───────────────────────────────┬───────────────┘
│ 可以 │ 不行
▼ ▼
用 &T 继续问问题 ↓
┌─────────────────────────────────────────────────────────┐
│ 3. 我是不是想"多个地方共享所有权"? │
└─────────┬───────────────────────────────┬───────────────┘
│ 是 │ 否
▼ ▼
用 Arc<T> / Rc<T> 继续问问题 ↓
┌─────────────────────────────────────────────────────────┐
│ 4. 这个 clone 是不是只是为了过编译? │
└─────────┬───────────────────────────────┬───────────────┘
│ 是(警告!) │ 否
▼ ▼
回去重新设计所有权流动 clone 可以接受
经验法则
- 小项目早期可以 clone 多一点——Rust 的 clone 性能也比你想的快,过早优化是大坑。
- 代码 review 时,看到 clone 多问一句"为什么"——大部分情况下能消除。
- profile 时如果发现热点是 clone,认真重构所有权流动——这是真问题,不是风格偏好。
Arc不是clone的替代品——它是"我真的需要共享所有权"的明确表达。看到Arc<T>满天飞,要审视架构。
这一节的关键 takeaway
.clone() 不是错的,但每次写之前停 5 秒,走一遍上面的决策流程。这个习惯养成后,你会自然写出更地道的 Rust 代码,你的 mental model 会真正建立起来。
2.8 实战:四种方式实现 group-by-length
这一节我们用一个具体任务,展示同一个问题的四种不同所有权策略,让你看到工程权衡。
任务:把一个 Vec<String> 按字符串长度分组,返回 HashMap<usize, Vec<???>>。
??? 处的类型选择,本身就是一次所有权设计。
方案 A:消费 owned,返回 owned
use std::collections::HashMap; fn group_by_length_a(strs: Vec<String>) -> HashMap<usize, Vec<String>> { let mut result: HashMap<usize, Vec<String>> = HashMap::new(); for s in strs { // for 循环消费 strs let len = s.len(); result.entry(len).or_insert_with(Vec::new).push(s); } result } fn main() { let input = vec![ String::from("a"), String::from("bb"), String::from("cc"), String::from("ddd"), ]; let groups = group_by_length_a(input); // input 在这里已经不能用了 println!("{:?}", groups); }
特性:
- 函数接管
strs,调用方失去所有权 - 返回的
HashMap完全拥有所有 String - 零 clone——String 从输入容器移动到输出容器
- 适合"调用方不再需要 input"的场景
方案 B:借用 input,返回 borrow(零拷贝)
use std::collections::HashMap; fn group_by_length_b<'a>(strs: &'a [String]) -> HashMap<usize, Vec<&'a String>> { let mut result: HashMap<usize, Vec<&'a String>> = HashMap::new(); for s in strs { result.entry(s.len()).or_insert_with(Vec::new).push(s); } result } fn main() { let input = vec![ String::from("a"), String::from("bb"), String::from("cc"), ]; let groups = group_by_length_b(&input); println!("{:?}", groups); println!("{:?}", input); // input 还能用 }
特性:
- 函数借用
strs,不消费 - 返回的 HashMap 持有 input 中 String 的引用
- 零拷贝——既不消费也不复制
- 代价:返回值的生命周期受限于 input,不能跨函数边界长期持有
方案 C:借用 input,clone 出 owned
#![allow(unused)] fn main() { use std::collections::HashMap; fn group_by_length_c(strs: &[String]) -> HashMap<usize, Vec<String>> { let mut result: HashMap<usize, Vec<String>> = HashMap::new(); for s in strs { result.entry(s.len()).or_insert_with(Vec::new).push(s.clone()); } result } }
特性:
- 函数借用 input,但返回值持有自己的副本
- 有 clone 开销,但返回值生命周期独立
- 调用方两边都拿得住
- 这是"懒得想"的折中方案,工程里最常见
方案 D:用 Arc 共享所有权
use std::collections::HashMap; use std::sync::Arc; fn group_by_length_d(strs: Vec<Arc<String>>) -> HashMap<usize, Vec<Arc<String>>> { let mut result: HashMap<usize, Vec<Arc<String>>> = HashMap::new(); for s in strs { let len = s.len(); result.entry(len).or_insert_with(Vec::new).push(s); } result } fn main() { let input: Vec<Arc<String>> = vec![ Arc::new(String::from("a")), Arc::new(String::from("bb")), Arc::new(String::from("cc")), ]; let groups = group_by_length_d(input.clone()); // clone 是 Arc clone,只是 +1 引用计数 println!("{:?}", groups); println!("{:?}", input); // 还能用,因为 Arc clone }
特性:
- 用 Arc 表达"多处共享所有权"
input.clone()是 Arc clone,极便宜(原子加 1)- 适合多线程或需要 input 在多个地方长期持有的场景
- 代价:每次访问要解引用一层,失去一些零成本性
四方案对比
| 方案 | 内存开销 | 输入是否还能用 | 输出生命周期 | 工程场景 |
|---|---|---|---|---|
| A: owned in/owned out | 零 clone(move) | ❌ | 独立 | 调用方不再需要 input |
| B: borrow in/borrow out | 零拷贝 | ✅ | 受限于 input | 临时分组,不跨边界 |
| C: borrow in/owned out | clone 开销 | ✅ | 独立 | 默认折中,优先选这个 |
| D: Arc shared | Arc clone(便宜) | ✅(共享) | 共享 | 多处长期持有 |
这告诉你什么
同一个任务有 4 种合理实现,每种都对应一组不同的工程假设。Rust 强迫你显式做这个选择,Go 和 C# 帮你藏起来了(Go 全是 reference,C# 全是 reference,你不需要选)。
这是 Rust 学习曲线陡的根本原因,也是它工程价值的来源。多了一个维度的设计自由,代价是多了一个维度的设计负担。
工程经验:先写方案 C(borrow in / owned out)。等 profile 出问题了再改成 A 或 D。方案 B 适合内部辅助函数,不太适合 API 边界。
2.9 章末小结与习题
本章核心概念回顾
- Stack vs Heap:Rust 让你显式区分,因为没有 GC 帮你藏。
String/Vec/Box在 stack 有描述符,heap 有数据。 - Ownership 三规则:每个值有 owner、同时只有一个 owner、owner 离开作用域时 drop。这三条是无 GC 内存安全的最小约束。
- Move vs Copy:取决于类型有没有 heap/外部资源。
Copy是 marker trait,String不实现是因为 bitwise copy 会 double-free。 - Drop 是默认 RAII:不用
defer/using,资源清理是免费的副产品。 - 函数签名的三种形式:
T/&T/&mut T各自对应"接管 / 只读 / 可写"的契约。设计 API 时优先用更通用的类型(&str而不是&String)。 .clone()的判断流程:每次 clone 之前问 4 个问题——真需要两份?能不能 borrow?是不是要共享(Arc)?是不是为了过编译?- 同一任务的多种所有权策略:四方案对比,优先用 borrow-in/owned-out,profile 后再优化。
习题
习题 2.1(简单)
下面这段代码会报错,请只改一行让它编译:
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{} {}", s1, s2);
}
思考:你有两种修复方案,各自对应什么所有权策略?
习题 2.2(中等)
实现一个函数 concat_all,签名如下,无 .clone(),无 unsafe:
fn concat_all(strs: ???) -> String {
// 把所有 String 拼接成一个,返回新 String
}
??? 由你决定,要求:
- 调用方在调用之后不能再使用 input(强制移交所有权)
- 函数内部不调用
clone()
习题 2.3(中等)
实现一个函数 longest_string,接受一个 String 列表,返回最长的那个。要求:
- 调用方在调用之后仍然能使用 input
- 函数返回的不是 owned String 而是借用
- 函数签名你自己决定
提示:这个函数的签名会用到 lifetime annotation。Ch 3 详讲,这里你可以先靠编译器报错猜签名。
习题 2.4(困难,工程)
设想以下场景:
一个 Session 持有大量配置(
SessionConfig)。这个配置被多个组件读取:scheduler 用来决定调度、reporter 用来输出日志、metrics collector 用来打 tag。
请用本章知识设计三种方案,并写出权衡:
- 方案 A:每个组件持有
SessionConfig的 owned copy - 方案 B:每个组件持有
&SessionConfig - 方案 C:每个组件持有
Arc<SessionConfig>
在什么场景下你会选哪个?写下你的判断,在你后面真的接触这块代码时回头看自己的判断准不准。
习题 2.5(开放)
回顾你最近三个月写的某段 Go 或 TS 代码。试着把它"翻译"成 Rust——只翻译数据流和所有权,不要纠结语法。问自己:
- 这段代码在 Go 里靠 GC 隐藏了什么?
- 用 Rust 写需要做哪些显式选择?
- 这些选择是负担还是清晰?
这个练习不需要敲代码,只需要思考。思考本身比代码更重要。
下一章预告
Ch 3 我们进入 borrowing 与 lifetime。你会看到 ownership 三规则的扩展形式:
- 共享借用
&Tvs 独占借用&mut T为什么互斥? - Lifetime
'a标注到底在标注什么? - 编译器是怎么检查"引用不能比被引用的对象活得久"这件事的?
'static是什么,什么时候真的需要它?
读完那一章,你就能看懂 Rust 编译器最常见的报错——而看懂报错是 Rust 流畅期的开始。
本章一句话总结
Ownership 不是 Rust 强加给你的负担,是 Rust 把"内存安全的承诺"从程序员的脑子里搬到了编译器的检查里。学会用编译器思考,而不是跟它对抗。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 3 · Borrowing 与 Lifetime —— 编译器是结对程序员
"Lifetimes are the most distinctive feature of Rust. They're also the most misunderstood."
Ch 2 我们说 ownership 是"每个值有一个 owner,owner 出作用域时 drop"。这一章我们处理一个直接派生的问题:如果某个函数想暂时使用一个值,但不想接管所有权,怎么办?
答案是 borrowing(借用)。但借用不是免费的——为了保证安全,Rust 引入了一组借用规则,和一个叫 lifetime 的概念。
读完这章,你应该能:
- 说清楚
&T和&mut T为什么必须互斥 - 看到
'a标注时不再恐慌——你知道它在告诉编译器什么 - 读懂 Rust 编译器最常见的 5 种借用报错
- 给一个函数签名,判断它的生命周期标注是否合理
- 知道
'static的真实含义,不再误用
3.1 为什么需要借用?
先看一个看似无害的需求。我们有一个大 Vec<String>,想写个函数计算其中字符串的总长度:
fn total_length(strs: Vec<String>) -> usize { strs.iter().map(|s| s.len()).sum() } fn main() { let v = vec![ String::from("hello"), String::from("world"), ]; let n = total_length(v); println!("{}", n); // println!("{:?}", v); // ❌ v 已经被 move 进 total_length }
这个签名 fn total_length(strs: Vec<String>) 有问题——它接管了所有权。调用一次就消费一次,调用方再也用不了 v 了。
但语义上,total_length 只是想读一下,根本不需要拥有这个 Vec。这正是借用要解决的事情:
fn total_length(strs: &Vec<String>) -> usize { strs.iter().map(|s| s.len()).sum() } fn main() { let v = vec![ String::from("hello"), String::from("world"), ]; let n = total_length(&v); println!("{}", n); println!("{:?}", v); // ✅ v 还能用 }
&Vec<String> 是一个共享借用——函数借一下看看,不影响所有权。
(回顾 Ch 2:这里更地道的写法是 &[String] 而不是 &Vec<String>。但本章为了讲清借用本身,我们暂时用后者(&Vec<String>)。)
借用 ≠ 指针
重要心智迁移:
如果你来自 Go 或 C,可能想把 &T 理解成"指针"。表面看是,但 Rust 的引用比 C 指针多一组编译期约束:
| 维度 | C 指针 T* | Rust 引用 &T / &mut T |
|---|---|---|
| 可以为 NULL | ✅ | ❌(永远指向有效值) |
| 可以悬垂(指向已释放内存) | ✅ | ❌(编译期保证不悬垂) |
| 可以同时存在多个写指针 | ✅ | ❌(&mut T 必须独占) |
| 可以从越界数组偏移 | ✅(undefined behavior) | ❌ |
| 编译期类型检查 | 弱 | 强 |
Rust 的引用是"加了护栏的指针"。在汇编层面,&T 就是一个指针;在源码层面,它带着一组编译期保证。
这一节的关键 takeaway
借用是 ownership 的补丁——让函数可以"暂时使用"一个值而不接管所有权。&T 是共享借用,&mut T 是独占借用。下面我们详细说这两个为什么必须互斥。
3.2 借用规则:Aliasing XOR Mutability
Rust 的借用规则,标准表述是:
在任意时刻,一个值要么:
- 有任意多个共享借用
&T(可以读,不能写)- 或者有且仅有一个独占借用
&mut T(可以读写)二者不能并存。
学术界把这条规则叫 "Aliasing XOR Mutability"——别名(多个引用指向同一个值)和可变性,二者只能取其一。
为什么这条规则存在?
不是 Rust 设计者的洁癖。这条规则解决三个真实的工程问题。
问题 1:数据竞争(data race)
如果一个值可以被多个线程同时读写,就有 data race。data race 是 undefined behavior,可能产生任何错误结果。
// Go 里的 data race(运行时才能检测)
var counter int = 0
go func() { counter++ }()
go func() { counter++ }()
// 两个 goroutine 同时写 counter,结果未定义
Rust 的借用规则在编译期就拦住:你不可能同时持有两个 &mut counter。要跨线程共享可变状态,必须用 Mutex 或 Atomic,把同步显式化。
问题 2:迭代失效(iterator invalidation)
C++ 经典 bug:
std::vector<int> v = {1, 2, 3};
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it == 2) {
v.push_back(4); // 可能触发 vector 扩容,it 失效
}
// 下一次循环用 it,可能崩溃
}
Rust 的借用规则直接拦掉这种代码:
let mut v = vec![1, 2, 3];
for x in v.iter() { // 这里有一个 &v 借用
if *x == 2 {
v.push(4); // ❌ 错误:cannot borrow v as mutable because it is also borrowed as immutable
}
}
v.iter() 持有 &v,在 for 循环内一直存活;v.push(4) 需要 &mut v,违反规则,编译期拦下。
问题 3:别名优化(aliasing-based optimization)
现代 CPU 的优化假设"如果两个指针类型不同,它们指向的内存不重叠"。C 编译器有 restrict 关键字让你声明"这个指针没有别名,放心优化"。但程序员经常用错,产生 undefined behavior。
Rust 的借用规则等于天然的 restrict:&mut T 保证整个值在那段时间只通过这一个引用访问,编译器可以激进优化。
借用规则的实际形态
多个共享借用 OK
#![allow(unused)] fn main() { let s = String::from("hello"); let r1 = &s; let r2 = &s; let r3 = &s; println!("{} {} {}", r1, r2, r3); // ✅ 都是只读 }
一个独占借用 OK
#![allow(unused)] fn main() { let mut s = String::from("hello"); let r = &mut s; r.push_str(" world"); println!("{}", r); // ✅ }
共享 + 共享 OK,共享 + 独占 NO
let mut s = String::from("hello");
let r1 = &s; // 共享借用
let r2 = &mut s; // ❌ 错误:cannot borrow as mutable because it is also borrowed as immutable
println!("{} {}", r1, r2);
独占 + 独占 NO
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // ❌ 错误:cannot borrow as mutable more than once
println!("{} {}", r1, r2);
NLL:借用提前结束
早期 Rust 的借用作用域是"词法作用域"——借用一直活到所在的 {} 结束。这导致很多本应合法的代码被拒绝。Rust 2018 引入 NLL(Non-Lexical Lifetimes),借用的实际作用域是"最后一次使用之后立刻结束"。
#![allow(unused)] fn main() { let mut s = String::from("hello"); let r1 = &s; let r2 = &s; println!("{} {}", r1, r2); // r1, r2 在此最后使用 // NLL 后:r1, r2 的借用在此结束 let r3 = &mut s; // ✅ 现在可以独占借用 r3.push_str(" world"); println!("{}", r3); }
如果没有 NLL,这段代码会被拒绝(r1/r2 的借用被认为活到 } 结束)。NLL 之后这是合法代码。
工程含义:Rust 编译器比你想的聪明。很多"借用冲突"其实可以靠重排代码顺序解决,而不是放弃 borrow 改成 clone。
这一节的关键 takeaway
借用规则不是约束,是定理——它让一大类并发 bug、迭代失效、别名问题在编译期就消失。你接受了这条规则,获得的是 fearless concurrency 和零成本优化。
3.3 Lifetime:'a 在标注什么?
到现在为止,我们写的借用都是 &T / &mut T,没有 'a。但 Rust 标准库里到处是这样的签名:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { /* ... */ }
这个 'a 是什么?
Lifetime 是关于"引用何时失效"的标注
简单事实:任何引用都有一个有效区间——从引用创建,到引用最后一次使用。这个区间就叫这个引用的 lifetime。
编译器的工作是检查:引用使用时,被引用的值是否还活着?
考虑这段代码:
fn main() {
let r;
{
let x = 5;
r = &x;
} // x 在这里 drop
println!("{}", r); // ❌ r 引用了一个已被 drop 的值
}
编译器报:
error[E0597]: `x` does not live long enough
|
4 | r = &x;
| ^^ borrowed value does not live long enough
5 | }
| - `x` dropped here while still borrowed
6 | println!("{}", r);
| - borrow later used here
编译器的推理:r 的使用要求 x 还活着,但 x 在更内层的作用域结束就 drop 了。冲突。
单个函数内,编译器自动推导
上面的例子,编译器自己就能推导出生命周期。你不用写 'a。
那 'a 什么时候必须写?
'a 在跨函数边界时显现
考虑这个函数:
#![allow(unused)] fn main() { fn first_word(s: &str) -> &str { let bytes = s.as_bytes(); for (i, &b) in bytes.iter().enumerate() { if b == b' ' { return &s[..i]; } } s } }
返回值 &str 是从参数 s 派生出来的——它指向 s 内部的一段。返回的引用必须活得不久于 s,否则就是悬垂引用。
但编译器看到这个签名时不知道这个事实。它怎么知道返回的 &str 应该跟 s 关联,而不是凭空冒出来的?
答案是:生命周期标注。完整签名应该是:
fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ }
读法:"对于任意生命周期 'a,这个函数接受一个生命期为 'a 的 &str,返回一个生命期同为 'a 的 &str。"
这个签名告诉调用方:返回的引用不能比 s 活得久。
Lifetime elision:大多数情况你不用写
但你刚才看到 first_word 的版本里,我们写的是 fn first_word(s: &str) -> &str,没写 'a,也能编译。
这是 Rust 的 lifetime elision rules(生命周期省略规则)在帮你。规则有三条:
- 每个引用参数有它自己的 lifetime
- 如果只有一个输入 lifetime,所有输出 lifetime 都跟它一致
- 如果有多个输入 lifetime,但其中一个是
&self或&mut self,所有输出 lifetime 跟self一致
first_word(s: &str) -> &str 满足规则 2,自动推导成 <'a>(s: &'a str) -> &'a str。
什么时候规则覆盖不到、必须手动写?
必须手动写 'a 的场景
多个引用参数,返回值跟其中一个绑定
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
编译器拒绝:返回值应该跟 x 还是 y 的 lifetime 绑定? 编译器不知道。
显式标注:
#![allow(unused)] fn main() { fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } }
'a 是 x 和 y 中较短的那个 lifetime——编译器取交集。返回值不会比 x 或 y 中任何一个活得久。
struct 含引用字段
#![allow(unused)] fn main() { struct Excerpt<'a> { part: &'a str, } }
含引用的 struct 必须标注 lifetime。意思是"这个 Excerpt 不能比 part 引用的字符串活得久"。
返回值需要不同于输入的 lifetime
少见,但存在:
#![allow(unused)] fn main() { fn longest_with_announcement<'a, 'b>( x: &'a str, y: &'a str, ann: &'b str, ) -> &'a str { println!("Announcement! {}", ann); if x.len() > y.len() { x } else { y } } }
这里 ann 的生命周期跟 x/y 无关,所以用了不同的 'b。
实操:看一个 lifetime 错误并修复
fn longest_owned(x: &str, y: &str) -> &str { // 缺 lifetime
if x.len() > y.len() { x } else { y }
}
编译器报:
error[E0106]: missing lifetime specifier
|
1 | fn longest_owned(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
= help: this function's return type contains a borrowed value, but the signature does not
say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
1 | fn longest_owned<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
Rust 的报错带着修复建议,直接照着改就行。这是 Rust 编译器的一个温柔——错误消息是设计过的,不是抛出去就完事。
这一节的关键 takeaway
'a 不是"我手动管理生命周期",是"我告诉编译器引用之间的关系"。绝大多数情况编译器自己推导。需要手动写 'a 的场景就那么几种,见多了就熟了。
3.4 'static Lifetime:不是"永远活着"
'static 是个特殊 lifetime,意思字面看是"静态的、永远活着"。但工程上有两个含义,经常混淆。
含义 1:&'static T —— 引用本身指向永久数据
#![allow(unused)] fn main() { let s: &'static str = "hello world"; // 字符串字面量是 'static }
字符串字面量被嵌入二进制,程序运行期间一直存在,所以它们的引用是 'static。
#![allow(unused)] fn main() { static GREETING: &str = "hello"; // 也是 'static }
static 关键字定义的全局变量,引用类型也是 'static。
含义 2:T: 'static —— 类型不持有任何短于 'static 的引用
这个更微妙。T: 'static 是一个 trait bound(其实是 lifetime bound),表示"类型 T 不持有任何借用,或者持有的所有借用都是 'static 的"。
哪些类型满足 T: 'static?
| 类型 | 是 'static 吗? | 为什么 |
|---|---|---|
i32 / bool | ✅ | 没有引用 |
String | ✅ | 持有 owned heap 数据,没有借用 |
Vec<i32> | ✅ | 同上 |
&'static str | ✅ | 持有的引用本身是 'static |
&'a str(其中 'a 不是 'static) | ❌ | 持有非 'static 引用 |
Box<dyn Trait> | ❌(可能) | 取决于 trait object 的生命周期 |
Box<dyn Trait + 'static> | ✅ | 显式标注 |
为什么这个区别重要?因为线程边界要求 T: 'static:
fn spawn_task<T: Send + 'static>(t: T) { /* ... */ }
std::thread::spawn 和 tokio::spawn 都要求传入的 closure 是 'static——因为线程可能比当前函数活得久,closure 内部不能引用任何短期的栈变量。
fn main() { let s = String::from("hello"); std::thread::spawn(move || { println!("{}", s); // ✅ s 被 move 进 closure,closure 是 'static }); let s2 = String::from("world"); let r = &s2; // std::thread::spawn(|| { // println!("{}", r); // ❌ closure 持有 &s2,不是 'static // }); }
误用警告
新手看到编译器要求 'static,经常的反应是"那我把所有东西都标 'static 不就行了"。
错。
'static 是限制,不是好东西。代码里 'static 越多,你的程序就越没法做"作用域共享"——所有跨边界的数据都得是 owned 或全局的。
正确做法:默认不写 'static,只在必须的地方写(主要是 thread/task spawn)。
这一节的关键 takeaway
'static 字面是"永远活着",工程上是"不持有任何短期借用"。它经常出现在线程边界,但不要滥用——能用更短的 lifetime 就别用 'static。
3.5 Rust 借用错误的 5 种典型模式
借用检查器报错看起来神秘,实际上 90% 的报错可以归为 5 类。看清模式后,debug 就快了。
模式 1:"cannot borrow X as mutable because it is also borrowed as immutable"
let mut v = vec![1, 2, 3];
let first = &v[0]; // 共享借用
v.push(4); // ❌ 要 &mut v,冲突
println!("{}", first);
根本原因:你在持有共享借用时尝试可变操作。
修复方向:
- 重排顺序:在
first最后一次使用之后再 push - clone:
let first = v[0].clone()(对 Copy 类型直接let first = v[0]) - 限制 first 作用域:
{ let first = &v[0]; println!("{}", first); }然后 push
模式 2:"X does not live long enough"
let r;
{
let x = 5;
r = &x;
}
println!("{}", r);
根本原因:你引用了一个比引用本身活得短的值。
修复方向:
- 把被引用的值移到外层作用域
- 改成 owned(把
&x改成x.clone(),如果 x 不是 Copy) - 重新审视设计——可能你压根不需要长生命周期的引用
模式 3:"cannot move out of borrowed content"
fn take_first(v: &Vec<String>) -> String {
v[0] // ❌ v 是借用,不能从中 move 出来
}
根本原因:你借了别人的东西,但想拿走其中一部分。借的就是借的,不能拆。
修复方向:
- clone:
v[0].clone() - 返回引用而非 owned:
&v[0],签名改成fn take_first<'a>(v: &'a Vec<String>) -> &'a String - 改成接管所有权:
fn take_first(mut v: Vec<String>) -> String
模式 4:"borrowed value does not live long enough"(返回引用类)
fn first_char_ref() -> &str {
let s = String::from("hello");
&s // ❌ s 即将被 drop
}
根本原因:你想返回一个指向函数内局部变量的引用。函数返回时局部变量 drop,引用会悬垂。
修复方向:
- 返回 owned:
fn first_word_owned() -> String然后s.chars().next().unwrap().to_string() - 接受输入并返回输入的子借用:
fn first_char(s: &str) -> &str
模式 5:"closure may outlive borrowed value"
fn main() {
let s = String::from("hello");
let f = || println!("{}", s); // closure 借用了 s
drop(s); // ❌ 不能 drop,closure 还要用
f();
}
更常见在 thread 场景:
fn main() {
let s = String::from("hello");
std::thread::spawn(|| { // ❌ closure 借用 s,但 thread 可能比 main 活得久
println!("{}", s);
});
}
修复方向:
- 用
move关键字:std::thread::spawn(move || { ... }),closure 接管所有权 - 用
Arc<T>跨线程共享所有权
编译器报错的"读法"
Rust 的报错信息有固定格式:
error[E0XXX]: <短描述>
--> file.rs:line:col
|
N | <错误代码行>
| <下划线指出问题位置>
N+1| <相关上下文>
= help: <修复建议>
= note: <补充说明>
读报错的顺序:
- 先看
help:那行——通常直接告诉你怎么改 - 看下划线位置,理解编译器认为问题在哪
- 看
note:,补充信息 - 最后才看错误码
E0XXX(如果还需要,可以rustc --explain E0XXX查详细解释)
工程经验:Rust 报错是设计资产,不是噪音。花时间读懂它们,几周后你大部分错误一眼就能定位。
3.6 实战:实现一个不 clone 的字符串切分迭代器
把这章的概念串起来,我们做个练习:实现一个返回字符串切片的 word iterator。
需求
let s = String::from("hello world rust");
let mut iter = WordIter::new(&s);
assert_eq!(iter.next(), Some("hello"));
assert_eq!(iter.next(), Some("world"));
assert_eq!(iter.next(), Some("rust"));
assert_eq!(iter.next(), None);
要求:不 clone,不 copy 字符串。每个 next() 返回的是 &str,指向原 s 的某段。
实现
struct WordIter<'a> { remaining: &'a str, } impl<'a> WordIter<'a> { fn new(s: &'a str) -> Self { WordIter { remaining: s.trim_start() } } } impl<'a> Iterator for WordIter<'a> { type Item = &'a str; fn next(&mut self) -> Option<&'a str> { if self.remaining.is_empty() { return None; } match self.remaining.find(char::is_whitespace) { Some(i) => { let word = &self.remaining[..i]; self.remaining = self.remaining[i..].trim_start(); Some(word) } None => { let word = self.remaining; self.remaining = ""; Some(word) } } } } fn main() { let s = String::from("hello world rust"); let mut iter = WordIter::new(&s); while let Some(word) = iter.next() { println!("{}", word); } }
解读
这个例子用到本章每一个概念:
- struct 含引用字段:
WordIter<'a>必须有 lifetime 参数,因为remaining: &'a str是借用 new(s: &'a str)->Self:输入借用的 lifetime 跟 struct 的 lifetime 一致Iterator::Item = &'a str:每次 next 返回的也是'a生命周期的引用fn next(&mut self) -> Option<&'a str>:返回的引用跟 self 内部的remaining来自同一个'a,即原始字符串
整个迭代器没有任何 clone 或分配,纯指针运算。这就是 Rust 零成本抽象的真容——一个看似复杂的迭代器,编译后跟手写的字符串扫描循环性能一致。
对比:如果用 owned
如果你不会 lifetime,可能会写成:
#![allow(unused)] fn main() { struct WordIterOwned { words: Vec<String>, index: usize, } impl WordIterOwned { fn new(s: &str) -> Self { let words = s.split_whitespace().map(|w| w.to_string()).collect(); WordIterOwned { words, index: 0 } } } impl Iterator for WordIterOwned { type Item = String; fn next(&mut self) -> Option<String> { if self.index >= self.words.len() { return None; } let w = self.words[self.index].clone(); self.index += 1; Some(w) } } }
能跑,但每个单词 clone 两次(构造时 to_string,next 时 clone)。100 个单词就是 200 次 heap 分配。借用版本是 0 次。
这就是为什么"会用 lifetime"是工程进阶——它让你能写出真正零成本的代码。
3.7 章末小结与习题
本章核心概念回顾
- 借用
&T/&mut T:让函数暂时使用值而不接管所有权 - 借用规则(Aliasing XOR Mutability):任意时刻要么多个共享借用,要么一个独占借用
- NLL:借用作用域在最后一次使用后立刻结束,不必等到
} - Lifetime
'a:不是手动管理生命周期,是给编译器看的引用关系标注 - Lifetime elision:大多数情况编译器自动推导,你不用写
'static的两个含义:&'static T是引用指向永久数据;T: 'static是类型不持有非 'static 借用- 5 种典型借用错误:认得出模式,debug 就快
- 零成本迭代器:用 lifetime 设计返回引用的 Iterator,无 clone 无分配
习题
习题 3.1(简单)
下面代码有借用错误,用三种不同方式修复:
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
println!("{}", first);
}
提示:三种方向是 (1) 重排顺序、(2) 改成 Copy、(3) 限制借用作用域
习题 3.2(中等)
写一个函数,签名:
fn find_pair<'a>(v: &'a [i32], target: i32) -> Option<(&'a i32, &'a i32)>;
返回 v 中两个元素的引用,使它们的和等于 target。如果找不到,返回 None。要求不 clone、不 copy。
习题 3.3(中等)
下面代码有问题,只看签名能不能判断出问题:
fn dangle<'a>() -> &'a str {
let s = String::from("hello");
&s
}
为什么编译器拒绝?如果我们硬要返回一个 &str,有什么合法的方法?
习题 3.4(困难)
实现 LineIter,类似 3.6 节的 WordIter,但按换行符切分。要求:
let s = "line1\nline2\nline3";
let mut iter = LineIter::new(s);
assert_eq!(iter.next(), Some("line1"));
assert_eq!(iter.next(), Some("line2"));
assert_eq!(iter.next(), Some("line3"));
assert_eq!(iter.next(), None);
注意空行、末尾换行符等边界情况。
习题 3.5(开放)
找一段你写的或读过的 Rust 代码,标出每个 & 引用的 lifetime 来源:它从哪里借来,什么时候失效?
如果某个引用让你看不懂,把它改写成 owned + clone 看看,问自己:这个 clone 是必要的吗?
下一章预告
Ch 4 我们离开"内存"这个维度,进入"类型设计"。Rust 的 enum 不是 C 那种 enum,是真正的 sum type。配合 pattern matching,你能写出比 Go 简洁得多的领域模型代码。
我们会用一个具体例子贯穿:重新设计一个 Session 状态机,从你熟悉的"struct + status 字段"风格,变成 Rust 风格的"enum 即状态机"。
本章一句话总结
借用检查器不是路障,是结对程序员。它在你提交代码之前,把一类并发 bug、迭代失效、悬垂引用的问题都筛掉了。学会读它的报错,是学会 Rust 的关键转折。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 4 · Struct、Enum、Pattern Matching —— 用类型表达领域
"Make illegal states unrepresentable." — Yaron Minsky
Part I 我们处理了内存维度——所有权、借用、生命周期。Part II 我们换一个视角:类型系统作为领域建模工具。
读完这章你应该能:
- 用 enum 替代 Go 的
interface{}+ type switch 模式 - 理解为什么 Rust 的 pattern matching 是穷尽性检查的——这等于编译器替你写测试
- 设计 newtype 来避免"全是 String 和 u64"的类型贫血代码
- 把一个状态机从"struct + status enum"风格,重构成"enum 即状态"风格
4.1 Struct 三种形式
Rust 的 struct 有三种形式,各有用途。
命名字段 struct(最常见)
struct User { name: String, email: String, active: bool, } fn main() { let u = User { name: String::from("Marvin"), email: String::from("m@hp.com"), active: true, }; }
跟 Go 的 struct、C# 的 record 用法相似。
Tuple struct
struct Point(f64, f64); fn main() { let p = Point(1.0, 2.0); println!("{} {}", p.0, p.1); }
字段没有名字,通过 .0 .1 访问。看起来鸡肋,但有一个关键用途——newtype pattern(下一节展开)。
Unit struct
struct Marker; fn main() { let m = Marker; }
完全没有字段,只有类型本身。用于 trait 标记、phantom type、空容器。看起来更鸡肋,但在 trait-heavy 代码里很有用。
字段更新语法
跟 TypeScript spread operator 类似:
struct User { name: String, email: String, active: bool, } fn main() { let u1 = User { name: String::from("Marvin"), email: String::from("m@hp.com"), active: true, }; let u2 = User { email: String::from("new@hp.com"), ..u1 // 其余字段从 u1 取 }; // 注意:u1 在这里被部分 move 了 // 因为 name 是 String(非 Copy),被 move 进 u2 // active 是 bool(Copy),u1.active 还能用 let _ = u2; }
字段更新语法跟 ownership 交互的细节经常坑人。如果一个非 Copy 字段被 move 走,整个原 struct 不能再当作整体使用。
工程小贴士
- 公开字段 vs 私有字段:Rust 的字段默认私有,需要
pub才能外部访问。这跟 Go 的"首字母大写"、Java 的public是同一回事。 - 不要给所有字段都加
pub:能私有就私有,通过方法暴露行为,这是封装。 - 构造方法用
Self::new()或 builder:不要让外部直接构造 struct,以便将来加校验逻辑。
4.2 Newtype Pattern:类型安全的代价是几个字符
Go/TS/Python 代码里,你经常看到这样的签名:
func TransferMoney(from string, to string, amount int) error
from 和 to 都是 string——你能不能不小心传反?能。amount 是 int——你能不能传成时间戳?能。
类型系统帮不了你。
Rust 让你很便宜地修复这个问题:
struct UserId(u64);
struct AccountId(u64);
struct Cents(u64);
fn transfer_money(from: AccountId, to: AccountId, amount: Cents) -> Result<(), Error> { /* ... */ }
现在你不能把 UserId 传给要 AccountId 的位置。你不能把 Cents 当 Seconds 用。编译器在编译期拦下一类业务 bug。
代价:每个 newtype 多写 1 行。
为什么这值得
回想你过去的 bug:
- API 调用传错了参数顺序
- 单位混淆(秒 vs 毫秒,字节 vs 比特)
- 用户 ID 传成了 session ID
GC 语言里这类 bug 靠 code review、命名规范、文档。Rust 让类型系统帮你拦,且基本零成本——newtype 在运行时就是 inner type 本身,monomorphization 后的汇编一致。
实战:ID 类型设计
假设你的系统有这些 ID:
#![allow(unused)] fn main() { pub struct SessionId(pub u64); pub struct NodeId(pub u64); pub struct TaskId(pub u64); pub struct AgentId(pub u64); }
签名层面:
fn dispatch_task(session: SessionId, task: TaskId, target: NodeId) { /* ... */ }
参数顺序写错,编译器立刻报错。比对照文档省心多了。
加点料:让 newtype 更好用
光是 struct SessionId(u64) 用起来啰嗦——构造、访问、Display 都麻烦。常见做法:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SessionId(pub u64); impl SessionId { pub fn new(id: u64) -> Self { SessionId(id) } pub fn as_u64(&self) -> u64 { self.0 } } impl std::fmt::Display for SessionId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "session-{}", self.0) } } }
#[derive(...)] 是 Rust 的"自动实现 trait"语法。Ch 6 详讲。
4.3 Enum:不是 C 的 enum,是 sum type
C 里的 enum 是 named constant 的集合:
enum Color { RED, GREEN, BLUE };
Rust 的 enum 远超过这个。它是 sum type——可以是 A、可以是 B、可以是 C,每个 variant 还可以携带不同类型的数据。
#![allow(unused)] fn main() { enum Message { Quit, // 无数据 Move { x: i32, y: i32 }, // 命名字段 Write(String), // tuple 字段 ChangeColor(i32, i32, i32), // tuple 字段 } }
每个 variant 形态可以不同——这是 C/Java enum 完全做不到的。
Enum 的 sizeof
Message 在内存里多大?
答案:所有 variant 中最大的那个 + 1 字节(tag)(实际有对齐)。Rust 的 enum 是 tagged union——一个 tag 表示当前是哪个 variant,后面是实际数据。
举例,如果 Move { x: i32, y: i32 } 是 8 字节,String 是 24 字节,(i32, i32, i32) 是 12 字节,那么 Message 大概是 24 + 几字节 tag + 对齐。
Pattern Matching:enum 的另一半
光定义 enum 没用,关键在于消费它:
#![allow(unused)] fn main() { enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(i32, i32, i32), } fn process(msg: Message) { match msg { Message::Quit => println!("quit"), Message::Move { x, y } => println!("move to {}, {}", x, y), Message::Write(s) => println!("write: {}", s), Message::ChangeColor(r, g, b) => println!("color: {} {} {}", r, g, b), } } }
match 必须穷尽所有 variant。如果你少写一个,编译器报错:
error[E0004]: non-exhaustive patterns: `ChangeColor(_, _, _)` not covered
这是 Rust 类型系统的核心承诺之一——不可能漏处理 case。Go 用 interface{} + type switch 时,你忘加一个 case,编译器不报错;Java 用 sealed class + switch,有些情况能检查,但语法笨。Rust 这事儿是默认行为。
工程价值:让非法状态不可表达
Yaron Minsky 那句名言:make illegal states unrepresentable。Rust enum 是这句话的工具。
考虑一个网络请求的状态:
// Go 风格:struct + 一堆字段
type Request struct {
Status string // "pending", "loading", "success", "error"
Data []byte // 只在 success 时有效
Error error // 只在 error 时有效
}
非法状态:Status = "success" 但 Data == nil?Status = "error" 但 Error == nil?Status = "pending" 但 Data != nil?这些都"可以表示",运行时靠你小心维护不变量。
#![allow(unused)] fn main() { // Rust 风格:enum 强制每个状态只携带它需要的数据 enum Request { Pending, Loading, Success(Vec<u8>), Error(String), } }
非法状态在类型层面不存在。Request::Success 必须带数据,不能没有;Request::Pending 不能带数据。代码读起来也清楚——每个 variant 名字告诉你状态、它的数据告诉你这个状态需要什么。
Option 和 Result 都是 enum
Rust 没有 null,没有 exception——它们都被 enum 替代:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
你已经在用它们了。重要的是认识到:它们没有特殊性。stdlib 里这俩 enum 跟你自己定义的 enum 完全等价。这是 Rust 一个重要的设计哲学——核心 abstractions 不是语言关键字,而是用语言基本构造块自己定义出来的。
4.4 Match 进阶:模式的全部形态
match 不只是"枚举各 variant"。模式语法很丰富,值得熟练。
字面量与范围
fn main() { let x = 5; match x { 1 => println!("one"), 2 | 3 | 5 | 7 => println!("prime"), 1..=10 => println!("1 to 10"), _ => println!("other"), } }
解构 struct
struct Point { x: i32, y: i32 } fn main() { let p = Point { x: 0, y: 7 }; match p { Point { x: 0, y: 0 } => println!("origin"), Point { x: 0, y } => println!("on y-axis at {}", y), Point { x, y: 0 } => println!("on x-axis at {}", x), Point { x, y } => println!("at ({}, {})", x, y), } }
解构 enum 嵌套
enum Shape {
Circle(Point, f64),
Rectangle(Point, Point),
}
match shape {
Shape::Circle(Point { x: 0, y: 0 }, r) => println!("circle at origin, r={}", r),
Shape::Circle(_, r) => println!("circle, r={}", r),
Shape::Rectangle(p1, p2) => println!("rect {:?} to {:?}", p1, p2),
}
守卫(guard)
fn main() { let n = 5; match n { x if x < 0 => println!("negative"), 0 => println!("zero"), x if x % 2 == 0 => println!("positive even"), _ => println!("positive odd"), } }
绑定(@ pattern)
fn main() { let x = 5; match x { n @ 1..=5 => println!("got {} in range", n), n @ 6..=10 => println!("got {} in upper range", n), _ => println!("out of range"), } }
if let / while let:轻量 match
如果你只关心一个 variant,full match 显得啰嗦:
fn main() { let config: Option<&str> = Some("hello"); // 啰嗦版本 match config { Some(c) => println!("{}", c), None => (), } // 轻量版本 if let Some(c) = config { println!("{}", c); } }
while let 同理:
fn main() { let mut stack = vec![1, 2, 3]; while let Some(x) = stack.pop() { println!("{}", x); } }
let-else:错误分支提前 return
Rust 1.65 引入,极好用:
#![allow(unused)] fn main() { fn process(input: Option<String>) { let Some(s) = input else { println!("no input"); return; }; // 这里 s 是 String println!("got: {}", s); } }
跟 Swift 的 guard let、Go 早期的"tons of if x == nil"相比,Rust 这个语法既明确又简洁。
4.5 实战:重设计 Session 状态机
把 4.1-4.4 串起来,我们做一个具体的设计练习。
起点:Go 风格的状态机
一个 Session 可能有这样的 Go 风格设计:
type SessionStatus string
const (
StatusCreated SessionStatus = "created"
StatusRunning SessionStatus = "running"
StatusFailed SessionStatus = "failed"
StatusFinished SessionStatus = "finished"
)
type Session struct {
ID SessionId
Status SessionStatus
StartedAt *time.Time // 只在 Running/Finished 时有
Result *Result // 只在 Finished 时有
Error error // 只在 Failed 时有
}
这套设计的问题:
StartedAt = nil但Status = Running是非法状态,但能表示Result != nil但Status = Created是非法状态,但能表示- 维护这些不变量靠程序员小心,且要在每个分支检查
Rust 风格:enum 即状态
use std::time::Instant;
pub struct SessionId(pub u64);
pub enum SessionState {
Created {
config: SessionConfig,
},
Running {
config: SessionConfig,
started_at: Instant,
},
Finished {
config: SessionConfig,
started_at: Instant,
finished_at: Instant,
result: SessionResult,
},
Failed {
config: SessionConfig,
started_at: Option<Instant>, // 可能在 Created 阶段就失败
error: SessionError,
},
}
pub struct Session {
pub id: SessionId,
pub state: SessionState,
}
每个 variant 携带且只携带它需要的字段。Running 必须有 started_at,Finished 必须有 result——非法状态在类型层面消失。
状态转换函数
状态转换写成 method:
impl Session {
pub fn start(self) -> Result<Self, Self> {
match self.state {
SessionState::Created { config } => Ok(Session {
id: self.id,
state: SessionState::Running {
config,
started_at: Instant::now(),
},
}),
other => Err(Session { id: self.id, state: other }),
// 不在 Created 状态,无法 start,返回原样
}
}
pub fn finish(self, result: SessionResult) -> Result<Self, Self> {
match self.state {
SessionState::Running { config, started_at } => Ok(Session {
id: self.id,
state: SessionState::Finished {
config,
started_at,
finished_at: Instant::now(),
result,
},
}),
other => Err(Session { id: self.id, state: other }),
}
}
pub fn fail(self, error: SessionError) -> Self {
let started_at = match &self.state {
SessionState::Running { started_at, .. } => Some(*started_at),
_ => None,
};
let config = match self.state {
SessionState::Created { config }
| SessionState::Running { config, .. } => config,
SessionState::Finished { config, .. }
| SessionState::Failed { config, .. } => config,
};
Session {
id: self.id,
state: SessionState::Failed {
config,
started_at,
error,
},
}
}
}
注意 fn start(self) -> Result<Self, Self> 这个签名:
- 接收
self(by value,接管所有权) - 返回
Result<Self, Self>——成功就是新状态的 Session,失败就是原 Session
这是 Rust 的"状态机 by ownership"风格——旧状态被消费,新状态被产生。你不可能"忘了更新 status",因为旧的 Session 被 move 走了。
进一步:用 typestate pattern 让状态错误编译期消失
更激进的设计:每个状态是不同的类型,转换函数只在特定类型上存在。
pub struct CreatedSession {
pub id: SessionId,
pub config: SessionConfig,
}
pub struct RunningSession {
pub id: SessionId,
pub config: SessionConfig,
pub started_at: Instant,
}
pub struct FinishedSession {
pub id: SessionId,
pub config: SessionConfig,
pub started_at: Instant,
pub finished_at: Instant,
pub result: SessionResult,
}
impl CreatedSession {
pub fn start(self) -> RunningSession {
RunningSession {
id: self.id,
config: self.config,
started_at: Instant::now(),
}
}
}
impl RunningSession {
pub fn finish(self, result: SessionResult) -> FinishedSession {
FinishedSession {
id: self.id,
config: self.config,
started_at: self.started_at,
finished_at: Instant::now(),
result,
}
}
}
现在你根本不能对一个 FinishedSession 调用 start()——那个方法不存在于这个类型上。整个状态机的合法转换在类型层面写死。
两种风格的权衡
| 维度 | enum 状态机 | Typestate(类型即状态) |
|---|---|---|
| 表达力 | 高 | 极高 |
容器友好(放进 Vec<_>) | ✅ 一个类型搞定 | ❌ 需要 enum 包装或 trait object |
| 状态转换错误处理 | 运行时 Result | 编译期(根本不存在) |
| 适用场景 | 多状态可流动 | 强约束工作流 |
工程经验:业务状态机用 enum 风格(灵活、好序列化、好放容器);关键安全约束用 typestate(编译期保证不可越权)。普通的 Session 用 enum 合适,因为你需要把它存到数据库、通过 API 暴露、放到 Vec<Session> 里。
4.6 章末小结与习题
本章核心概念回顾
- Struct 三种形态:命名字段、tuple struct、unit struct,各有用途
- Newtype pattern:用
struct UserId(u64)这类便宜手法换类型安全,运行时零开销 - Enum 是 sum type:每个 variant 可以携带不同形态的数据,跟 C 的 enum 是两个东西
- Pattern matching 穷尽性检查:编译器替你写测试,不可能漏 case
if let/while let/let-else:轻量 match,代码更简洁- 状态机用 enum 表达:让非法状态在类型层面消失;关键约束用 typestate 进一步固化
习题
习题 4.1(简单)
把下面 Go 风格的 struct 重写成 Rust enum,让非法状态不可表达:
type ApiResponse struct {
Success bool
Data []byte // 只在 Success=true 时有
Error string // 只在 Success=false 时有
Code int // 只在 Success=false 时有
}
习题 4.2(中等)
实现一个 Mailbox enum,代表邮箱可能的状态:
- 空
- 有 N 封未读邮件(N > 0)
- 满了(超过容量,带容量数字)
写一个函数 summary(mb: &Mailbox) -> String,返回一句描述。要求 match 必须穷尽。
习题 4.3(中等)
给一个二叉树定义:
#![allow(unused)] fn main() { enum Tree { Leaf, Node(i32, Box<Tree>, Box<Tree>), } }
(注意 Box<Tree> 是必要的——否则 Tree 大小无法确定,Ch 8 详讲。)
实现:
fn count_leaves(t: &Tree) -> usizefn max_depth(t: &Tree) -> usizefn contains(t: &Tree, target: i32) -> bool
全部用 match,不用 if-else 链。
习题 4.4(困难,工程)
回到 4.5 节的 Session 状态机。如果加一个新状态 Paused(只能从 Running 转入,可以恢复到 Running),你的 enum 怎么改?转换函数怎么改?
更进一步:如果用 typestate 风格,Paused 是不是成了第三个独立 type?多了哪些转换 method?
写下你的设计,跟同事 review 时讨论。
习题 4.5(开放)
回顾你过去三个月写过的 Go/TS 代码。找一个 "struct + status enum" 风格的状态机。试着重新设计成 Rust enum 风格——每个 variant 只带它需要的数据。
问自己:重新设计之后,有多少处运行时 nil-check 可以删掉?
下一章预告
Ch 5 我们处理一个直接相关的话题:错误处理。
Result<T, E> 也是 enum,你已经见过了。但工程上的错误处理远不止"用 Result 包一下就完了"——thiserror vs anyhow 的分工、错误层次设计、? 操作符的解糖、何时 panic、错误的可观察性,这些一起决定你的 Rust 代码在生产环境的可维护性。
本章一句话总结
Rust 的 struct + enum + pattern matching 不只是语法糖——它是 Yaron Minsky 那句 "make illegal states unrepresentable" 的工程实现。学会用类型表达领域,你的 bug 在编译期就死掉一大半。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 5 · 错误处理工程化 —— Result、?、thiserror、anyhow
"Errors are values." — Rob Pike(关于 Go,但 Rust 走得更远)
Go 的 if err != nil 仪式感强但啰嗦。C# 的 try/catch 隐藏控制流。Java 的 checked exception 让人想哭。Rust 的选择:错误是值,但语法不啰嗦。
读完这章你应该能:
- 解释 Rust 为什么没有 exception,以及这选择的工程含义
- 用
?操作符写出比 Go 短得多的错误处理代码 - 给一个项目设计错误层次:库用
thiserror、应用用anyhow,知道为什么 - 决定什么时候该 panic、什么时候该返回 Result
- 把一个模块的错误层次做一次重设计
5.1 Rust 为什么没有 exception?
C++、Java、C#、Python、JS 都有 exception。Rust 没有。这是有意识的设计选择,不是疏忽。
Exception 的隐藏成本
考虑 C# 代码:
public User LoadUser(int id) {
var conn = OpenConnection();
var data = conn.Query($"SELECT * FROM users WHERE id={id}");
return ParseUser(data);
}
这函数能抛多少种异常?
OpenConnection:SqlException/TimeoutException/IOException/ ...Query:同上,加SqlException的子类一堆ParseUser:FormatException/NullReferenceException/ ...
调用方不看实现就不知道。文档可能写,可能没写,可能过期。每个异常都是一条隐藏的控制流路径——你的代码可能在任何 .Method() 调用处突然跳到上层 catch。
Rust 的选择:错误显式化
Rust 强制你用类型表达错误的可能性。一个可能失败的函数,签名上必须写出来:
fn load_user(id: UserId) -> Result<User, LoadUserError> { /* ... */ }
调用方一看签名就知道这函数会失败,且只可能用 LoadUserError 这一种方式失败。没有隐藏控制流,没有未声明的异常。
Result<T, E> 是普通 enum
回顾 Ch 4:
enum Result<T, E> {
Ok(T),
Err(E),
}
没有特殊性。它就是个 enum。你可以 match 它、可以 map 它、可以放进 Vec、可以序列化——它是值,不是控制流装置。
工程含义对比
| 维度 | C# / Java exception | Go error | Rust Result |
|---|---|---|---|
| 函数签名是否声明可能错误 | ❌(checked exception 除外) | ✅ | ✅ |
| 调用方是否被强制处理 | ❌(可以不 catch) | ❌(可以忽略 err) | ✅(必须处理 Result,unwrap 也算) |
| 错误是值还是控制流 | 控制流 | 值 | 值 |
| 性能:成功路径 | 快 | 快 | 快(零成本) |
| 性能:失败路径 | 慢(stack unwinding) | 快 | 快(就是返回值) |
| 错误传播语法 | 自动(冒泡) | 啰嗦 | ? 简洁 |
Rust 拿到了 Go 的"显式"+ C# 的"语法不啰嗦"——? 操作符是关键。
5.2 ? 操作符:Rust 错误处理的核心语法
Go 错误处理的标志性代码:
data, err := fetch()
if err != nil {
return nil, err
}
parsed, err := parse(data)
if err != nil {
return nil, err
}
result, err := process(parsed)
if err != nil {
return nil, err
}
return result, nil
Rust 等价代码:
fn pipeline() -> Result<Output, MyError> {
let data = fetch()?;
let parsed = parse(data)?;
let result = process(parsed)?;
Ok(result)
}
? 在做的事情:
let data = fetch()?;
// 等价于
let data = match fetch() {
Ok(v) => v,
Err(e) => return Err(From::from(e)),
};
如果 fetch() 返回 Ok(v),? 取出 v 继续;如果返回 Err(e),? 把 e 转换成函数的错误类型,提前 return。
关键细节:? 调用了 From::from(e)——所以函数的错误类型 E 不需要跟 fetch 返回的错误类型完全一致,只要存在 From<FetchError> for E 的实现即可。这个细节是构造错误层次的基石。
? 也能用在 Option
#![allow(unused)] fn main() { fn first_two_chars(s: &str) -> Option<(char, char)> { let mut chars = s.chars(); let a = chars.next()?; // Option 是 None 就提前返回 None let b = chars.next()?; Some((a, b)) } }
? 在 Option 上的语义跟在 Result 上一样——None 提前返回 None。
? 不能跨边界
? 要求函数返回类型跟使用 ? 的表达式兼容。在返回 () 的函数(包括 main)里直接用 ? 编译不过。
修复方式:main 函数也可以返回 Result:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let data = std::fs::read_to_string("config.toml")?;
println!("{}", data);
Ok(())
}
这是写 CLI 工具时的标准 main 签名。
5.3 错误层次设计:thiserror vs anyhow
实际项目里你不会只用一种错误类型。你需要为不同层次设计不同错误类型。
一个典型的错误层次
考虑你的某个 Control Plane 模块:
HTTP handler 层:把错误转成 HTTP response
▲
│ uses
│
service 层:业务逻辑,可能调用多个 repository
▲
│ uses
│
repository 层:跟数据库交互
▲
│ uses
│
sqlx 库:返回 sqlx::Error
每一层的错误类型应该不同——repository 关心"数据库连接超时",service 关心"用户不存在",handler 关心"返回什么 HTTP status code"。
thiserror:为库定义错误类型
thiserror 是个 derive macro,帮你简洁地定义错误 enum:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum SessionError {
#[error("session {0} not found")]
NotFound(SessionId),
#[error("session {0} is in invalid state: {1}")]
InvalidState(SessionId, String),
#[error("database error")]
Database(#[from] sqlx::Error),
#[error("config parse error")]
Config(#[from] toml::de::Error),
}
它做的事情:
- 自动
impl Display for SessionError(用#[error("...")]的字符串格式化) - 自动
impl std::error::Error for SessionError #[from]标记自动生成impl From<sqlx::Error> for SessionError
#[from] 那行让 ? 操作符自动工作——你的代码里 pool.fetch_one(...).await? 直接把 sqlx::Error 转成 SessionError::Database。
anyhow:为应用做粗粒度错误
应用层(尤其是 main 函数、handler、CLI)经常不需要细分错误——你只关心"出错了,把错误打印或返回"。这种场景下 anyhow 更合适:
use anyhow::{Result, Context};
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path))?;
let config: Config = toml::from_str(&content)
.context("failed to parse config")?;
Ok(config)
}
fn main() -> Result<()> {
let config = load_config("config.toml")?;
println!("{:?}", config);
Ok(())
}
anyhow::Result<T> 是 Result<T, anyhow::Error> 的别名。anyhow::Error 是个万能错误类型,可以从任何实现了 std::error::Error 的类型构造。
with_context / context 是 anyhow 的妙处——给错误链添加上下文:
Error: failed to read config.toml
Caused by:
0: No such file or directory (os error 2)
这种链式错误信息,用 thiserror 也能做但麻烦,anyhow 是开箱即用。
分工原则
| 场景 | 用什么 | 为什么 |
|---|---|---|
| 库 / 公共 API | thiserror | 调用方需要 match 具体错误类型来决定行为 |
| 应用 / CLI / handler | anyhow | 调用方只关心成功失败,不细分错误类型 |
| 大型应用的服务层 | thiserror(细分) | 上层 handler 需要根据错误类型决定 HTTP 响应 |
| Quick prototype | anyhow 或 Box<dyn Error> | 还没想清楚错误层次,先跑起来 |
反模式:Box 一把梭
fn whatever() -> Result<(), Box<dyn std::error::Error>> { /* ... */ }
Box<dyn Error> 能装下任何错误,但你失去了所有类型信息——调用方没法 match 来区别处理。
临时代码可以,生产 API 不要。要么用 thiserror 细分,要么用 anyhow 也比 Box<dyn Error> 好(anyhow 至少有 context、有 backtrace)。
5.4 panic vs Result:边界在哪里
什么时候用 Result 让调用方处理,什么时候直接 panic!() 让程序崩?
Panic 的语义
panic!() 不是错误——它是程序逻辑错误的信号。意思是:"出现了不应该出现的状态,我没法继续,程序应该停止"。
默认行为是 unwinding(栈展开,所有 Drop 被调用,然后线程退出)。可以配置 panic = "abort"(直接终止)。
应该 panic 的场景
✅ 不变量被破坏:进入了"理论上不可能"的代码路径
fn process(state: &State) {
match state.kind {
Kind::A => { /* ... */ }
Kind::B => { /* ... */ }
// 假如 state.kind 只能是 A 或 B,且这是不变量
_ => unreachable!("invalid state kind: {:?}", state.kind),
}
}
unreachable!() 是带语义的 panic,告诉读者"这里逻辑上不可能到达"。
✅ 测试代码、prototype:unwrap()、expect() 用得理所当然
#[test]
fn test_parse() {
let result = parse("hello").unwrap(); // 测试里 unwrap 没问题
assert_eq!(result, "hello");
}
✅ 早期开发,先跑起来:后期再细化错误处理
不应该 panic 的场景
❌ 任何 IO、网络、用户输入:这些"会失败"是常态,必须用 Result
// 烂
let data = std::fs::read_to_string("config.toml").unwrap(); // 文件不存在?直接挂?
// 好
let data = std::fs::read_to_string("config.toml")?;
❌ 任何要发布到生产的库代码:库不应该 panic,让调用方决定
❌ 服务器主循环:一个请求处理出错,不应该让整个进程挂
unwrap() vs expect()
let x = some_option.unwrap(); // panic 时只说 "called Option::unwrap on None"
let x = some_option.expect("config must be loaded by now"); // panic 时说你给的信息
工程经验:生产代码里如果一定要 unwrap,一律用 expect 加 context。这样 panic 信息至少能定位问题。clippy 有专门的 lint unwrap_used 帮你强制这个。
5.5 错误的可观察性
错误信息只在生产环境出错时有用。这一节讲怎么让错误信息有用。
Display vs Debug
每个 Error 类型应该实现:
Display:给用户看的(短、清晰)Debug:给开发者看的(详细、含上下文)
thiserror 的 #[error("...")] 自动实现 Display。Debug 可以 #[derive(Debug)]。
#[derive(Error, Debug)]
pub enum LoadError {
#[error("file not found: {0}")]
NotFound(String),
}
let e = LoadError::NotFound("config.toml".into());
println!("{}", e); // "file not found: config.toml"
println!("{:?}", e); // "NotFound(\"config.toml\")"
错误链:source()
std::error::Error::source() 返回错误的"原因"——这是错误链的实现:
let err: Box<dyn Error> = /* ... */;
let mut current = err.as_ref() as &dyn Error;
while let Some(source) = current.source() {
eprintln!("caused by: {}", source);
current = source;
}
anyhow 自动维护这个链,{:#} 格式化时会打印整条链:
println!("{:#}", anyhow_err);
// failed to load session: file not found: caused by: No such file or directory
Backtrace
Rust 1.65+ 标准库支持 backtrace。anyhow 1.x 也支持。
RUST_BACKTRACE=1 cargo run
设了这个环境变量,panic 或 anyhow error 会带完整调用栈。生产环境一般这个变量是开的。
错误日志规范
工程实践:用 tracing crate(Ch 15 详讲)记录错误,而不是 eprintln!:
match do_something() {
Ok(v) => v,
Err(e) => {
tracing::error!(error = ?e, "failed to do something");
return Err(e);
}
}
error = ?e 用 Debug 格式化,把整个错误结构(包括 source 链)写进日志。结构化日志在生产环境的可观察性远超 plain text。
5.6 实战:重设计一个模块的错误层次
回到工程。假设你有这么一个模块:
// session_manager.rs
pub struct SessionManager {
pool: PgPool,
}
impl SessionManager {
pub async fn start_session(&self, id: SessionId) -> Result<Session, ???> {
let row = sqlx::query!("SELECT * FROM sessions WHERE id = $1", id.0)
.fetch_one(&self.pool)
.await?; // sqlx::Error
let config: SessionConfig = serde_json::from_str(&row.config)?; // serde_json::Error
let session = Session::create(id, config);
let session = session.start().map_err(|_| ???)?; // 状态机错误
sqlx::query!("UPDATE sessions SET status = 'running' WHERE id = $1", id.0)
.execute(&self.pool)
.await?; // sqlx::Error
Ok(session)
}
}
??? 处的设计就是错误层次决策。
设计错误 enum
use thiserror::Error;
#[derive(Error, Debug)]
pub enum SessionManagerError {
#[error("session {0} not found")]
NotFound(SessionId),
#[error("session {0} is not in a state that can be started")]
InvalidState(SessionId),
#[error("database error")]
Database(#[from] sqlx::Error),
#[error("invalid session config")]
InvalidConfig(#[from] serde_json::Error),
}
改写后的代码
impl SessionManager {
pub async fn start_session(
&self,
id: SessionId,
) -> Result<Session, SessionManagerError> {
let row = sqlx::query!("SELECT * FROM sessions WHERE id = $1", id.0)
.fetch_optional(&self.pool)
.await?
.ok_or(SessionManagerError::NotFound(id))?;
let config: SessionConfig = serde_json::from_str(&row.config)?;
let session = Session::create(id, config);
let session = session
.start()
.map_err(|_| SessionManagerError::InvalidState(id))?;
sqlx::query!(
"UPDATE sessions SET status = 'running' WHERE id = $1",
id.0
)
.execute(&self.pool)
.await?;
Ok(session)
}
}
Handler 层把错误转 HTTP
Service 层返回了 SessionManagerError,但 HTTP handler 需要决定返回什么 status code:
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
impl IntoResponse for SessionManagerError {
fn into_response(self) -> Response {
let status = match &self {
SessionManagerError::NotFound(_) => StatusCode::NOT_FOUND,
SessionManagerError::InvalidState(_) => StatusCode::CONFLICT,
SessionManagerError::InvalidConfig(_) => StatusCode::UNPROCESSABLE_ENTITY,
SessionManagerError::Database(_) => {
tracing::error!(error = ?self, "database error");
StatusCode::INTERNAL_SERVER_ERROR
}
};
(status, self.to_string()).into_response()
}
}
注意 Database 分支:数据库错误不应该把 sqlx::Error 的细节暴露给 client(可能含敏感信息),只返回 500 + 内部记 error log。错误的可见性是安全话题——细节给运维,模糊化的版本给用户。
这个例子综合了什么
thiserror给 service 层细粒度错误类型#[from]让?自动转换底层错误IntoResponse把业务错误转成 HTTP 响应,实现 service↔transport 解耦- 敏感错误(数据库)模糊化、记日志,不向 client 泄露
- 整个错误流是显式的、类型化的、可重构的
5.7 章末小结与习题
本章核心概念回顾
- Rust 没有 exception:错误是值,函数签名声明可能失败
?操作符:简洁的错误传播,内部调用From::from做类型转换thiserrorfor libraries:细粒度错误 enum,调用方可以 matchanyhowfor applications:粗粒度错误 + context,人读得懂的错误链- panic 是逻辑错误信号:不变量破坏 / 测试 / unreachable,不是常规错误处理
expect>unwrap:必须 unwrap 时也加上 context- 错误的可观察性:Display 给用户、Debug 给开发者、source 链 + backtrace + tracing 给运维
- service 层错误 ≠ HTTP 错误:用
IntoResponse在边界做转换
习题
习题 5.1(简单)
把下面 Go 代码翻译成 Rust,使用 ? 操作符:
func loadAndParse(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, err
}
config, err := parseConfig(data)
if err != nil {
return Config{}, err
}
return config, nil
}
习题 5.2(中等)
设计一个 BlogPostError enum,用 thiserror,涵盖以下错误:
- 文章不存在(带 post id)
- 用户没有权限(带 user id 和 post id)
- 标题太长(带当前长度和最大长度)
- 数据库错误(包装 sqlx::Error)
习题 5.3(中等)
下面代码用了 unwrap,改成正确的 Result + ?:
fn read_user_age(id: u64) -> u32 {
let conn = open_db().unwrap();
let row = conn.query("SELECT age FROM users WHERE id = ?", id).unwrap();
row.get::<u32>("age").unwrap()
}
习题 5.4(困难,工程)
回到你的项目。找你模块里所有 Result<T, E> 返回的函数,分类:
- 哪些 E 应该是
thiserror细分? - 哪些可以是
anyhow::Error? - 哪些其实应该是
panic?(不变量错误)
这个分类不需要 100% 精确,目的是建立"什么场景用什么错误"的判断习惯。
习题 5.5(开放)
回顾你写过的 Go/TS/C# 代码。找一段错误处理特别啰嗦或特别松散的——比如 catch-all 的 try/catch、或一堆 if err != nil { return err }。
如果用 Rust 重写,错误层次怎么设计?是不是有些原本被吞掉的错误会被显式化?是不是有些重复的错误代码可以消失?
下一章预告
Ch 6 我们进入 Rust 类型系统的核心:trait。
trait 不是 Go interface 的复刻,也不是 C# interface 的翻版。它有自己的设计——associated types、blanket impl、object safety、static vs dynamic dispatch——这些一起让 Rust 的抽象表达力比 Go 高一个量级。
本章一句话总结
错误处理不是负担,是契约。
Result让函数告诉调用方"我可能失败",?让传播零成本,thiserror+anyhow让你按层次设计错误而不被语法淹没。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 6 · Trait —— 比 interface 多了什么
"Traits are the closest thing Rust has to inheritance, and they're nothing like inheritance."
Go 有 interface,C# 有 interface,TS 有 interface。三种 interface 各不一样,但都是"一组方法签名的集合"。Rust 的 trait 远超过这个——它有 associated types、有 blanket impl、有 marker trait、有 object safety 规则、有静态/动态分发的明确区分。
这章是 Part II 的核心,也是这本书技术密度最高的章节之一。读完你应该能:
- 解释 Rust trait 跟 Go interface、C# interface、Haskell typeclass 的根本差异
- 选择正确的 trait 设计:associated type vs generic parameter
- 判断一个 trait 能不能做 trait object,以及对象安全规则的工程含义
- 选择
dyn Traitvsimpl TraitvsT: Trait - 给一个领域模型设计一组合理的 trait
6.1 从 Go interface 到 Rust trait
Go interface
type Reader interface {
Read(p []byte) (n int, err error)
}
type FileReader struct { ... }
func (f *FileReader) Read(p []byte) (n int, err error) { ... }
// FileReader 自动实现了 Reader,因为它有匹配的方法签名
// 这是 structural typing —— "看起来像鸭子"
Go interface 是 structural——任何带匹配方法的类型自动满足 interface,不需要显式声明。
Rust trait
trait Reader {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, Error>;
}
struct FileReader { /* ... */ }
impl Reader for FileReader {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, Error> { /* ... */ }
}
Rust 是 nominal——必须显式 impl Trait for Type 才算实现。光是有匹配方法的类型不算实现 trait。
这个差异为什么重要?
Go structural typing 的好处:解耦灵活,你可以为已有类型隐式满足新 interface。
代价:意图不清晰。FileReader 实现 Reader 是巧合还是设计?Go 不区分。
Rust 的选择:实现是设计决定,不是巧合。impl Reader for FileReader 这一行明确表达"我就是要让 FileReader 当 Reader 用"。代价是写多一行,收益是意图明确。
更深一点:Rust 允许你为已有类型实现你定义的 trait,这是 Go interface 做不到的:
// 你定义的 trait trait MyDescribe { fn describe(&self) -> String; } // 实现给 std 的 String impl MyDescribe for String { fn describe(&self) -> String { format!("a string of length {}", self.len()) } } fn main() { // 现在 String 有 describe 方法了 let s = String::from("hello"); println!("{}", s.describe()); }
这就是著名的 "orphan rule" 的灵活面——你可以扩展任何类型(在你自己的 crate 里)。但有限制:你不能既给已有类型又实现已有 trait(那需要 newtype 包装)。
跟其他语言一句话对比
| 语言 | 接口风格 | 显式/隐式 | 给已有类型加方法 |
|---|---|---|---|
| Go | interface(structural) | 隐式 | ❌ |
| C# / Java | interface(nominal) | 显式 | ❌(需要 extension methods,有限) |
| TS | interface(structural) | 隐式 | ❌(类型层面可,运行时不可) |
| Haskell | typeclass | 显式 | ✅(trait 的精神祖先) |
| Rust | trait | 显式 | ✅ |
Rust trait 的近亲是 Haskell typeclass,不是 Java interface。这是理解 trait 的关键。
6.2 Trait 的几个核心特性
Default methods
trait 可以提供方法的默认实现:
#![allow(unused)] fn main() { trait Greet { fn name(&self) -> &str; fn hello(&self) -> String { format!("Hello, {}!", self.name()) } } struct User { name: String } impl Greet for User { fn name(&self) -> &str { &self.name } // hello 不需要实现,用默认的 } }
User 只实现 name,自动获得 hello。这跟 C# interface default method、Java default method 类似。
Supertraits:trait 之间的依赖
#![allow(unused)] fn main() { trait Animal { fn name(&self) -> &str; } trait Pet: Animal { // Pet 必须先是 Animal fn owner_name(&self) -> &str; } }
任何实现 Pet 的类型必须先实现 Animal。这是组合,不是继承——Rust 没有继承,但 supertrait 表达"这个 trait 假设那个 trait 已经有了"。
Associated types
这是 Rust 区别于 Go interface 的关键特性。
考虑 Iterator trait(Rust 标准库):
trait Iterator {
type Item; // 关联类型
fn next(&mut self) -> Option<Self::Item>;
}
Item 是个类型成员,跟方法是平级的。每个实现者指定自己的 Item:
#![allow(unused)] fn main() { struct Counter { count: u32 } impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<u32> { self.count += 1; if self.count < 6 { Some(self.count) } else { None } } } }
Counter 把 Item 设成 u32。
Associated type vs Generic parameter:为什么不用泛型?
为什么不写成:
trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}
理论上等价,工程上有差异。
关键差异:一个类型可以实现Iterator<T> 多次(对不同 T),但只能实现 Iterator(关联类型版本)一次。
// 如果 Iterator 是泛型版本
impl Iterator<u32> for Counter { /* ... */ }
impl Iterator<i32> for Counter { /* ... */ } // 也合法
impl Iterator<String> for Counter { /* ... */ } // 也合法
let c: Counter = /* ... */;
let x = c.next(); // x 是什么类型?编译器不知道,需要你指定
调用方每次得 disambiguate。
关联类型版本:
impl Iterator for Counter {
type Item = u32; // 一次定下来
/* ... */
}
let c: Counter = /* ... */;
let x = c.next(); // x 是 Option<u32>,编译器自动推导
调用方不需要指定。这是为什么 stdlib 用关联类型——大部分 trait 概念上"对一个类型只有一种意义"(一个 Counter 迭代出来就是 u32,没有歧义)。
判断准则:
| 场景 | 用关联类型 | 用泛型参数 |
|---|---|---|
| 类型实现 trait 时,关联的类型是唯一的 | ✅ | ❌ |
| 类型可能对多种"参数"实现 trait | ❌ | ✅ |
| 调用方需要选择"用哪个版本" | ❌ | ✅ |
| 调用方不应该关心"有哪些版本" | ✅ | ❌ |
例子:Iterator::Item 用关联类型(一个迭代器只产生一种类型);From<T> 用泛型参数(一个类型可以 From 多种来源)。
6.3 Blanket impl:Rust 的杀手锏
// stdlib 里的代码(简化)
impl<T: Display> ToString for T {
fn to_string(&self) -> String { /* ... */ }
}
这是 blanket impl——为"所有满足某条件的类型"实现 trait。
含义:任何实现了 Display 的类型,自动获得 to_string() 方法。
实操你已经在用:
fn main() { let n = 42; let s = n.to_string(); // i32 实现了 Display,所以自动有 to_string() println!("{}", s); }
i32 没有显式 impl ToString,它是通过 blanket impl 获得的。
Blanket impl 的工程力量
举例,你想让"所有实现 Serialize 的类型自动获得一个 to_json() 方法":
trait ToJson {
fn to_json(&self) -> Result<String, serde_json::Error>;
}
impl<T: serde::Serialize> ToJson for T {
fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
}
现在所有 Serialize 类型自动有 to_json()。一行代码扩展整个生态。
这种力量 Go interface 完全做不到——Go 的方法实现必须在类型定义所在的 package。Rust 的 orphan rule 限制(你只能给"你的类型"实现"任何 trait",或给"任何类型"实现"你的 trait")在保留封装的同时打开了这种扩展空间。
6.4 静态分发 vs 动态分发:impl Trait / T: Trait / dyn Trait
到目前为止,trait 是用来"约束类型必须有某些方法"。现在我们看怎么消费这种约束——三种语法,语义不同。
写法 1:T: Trait(泛型 + trait bound)
fn print_hello<T: Greet>(g: &T) {
println!("{}", g.hello());
}
或等价的 where 子句:
fn print_hello<T>(g: &T) where T: Greet {
println!("{}", g.hello());
}
或简写(自 Rust 1.26):
fn print_hello(g: &impl Greet) {
println!("{}", g.hello());
}
这是静态分发——编译器为每个具体的 T 生成独立的函数实例(monomorphization)。print_hello(&user) 和 print_hello(&dog) 是两个不同的函数版本。
成本:编译期代码膨胀(每个用法生成一份)。 收益:零运行时开销,内联优化都能进。
写法 2:impl Trait 作返回类型
fn make_greeter() -> impl Greet {
User { name: "Marvin".into() }
}
意思是"我返回某个实现 Greet 的类型,具体是什么不告诉调用方"。编译器知道是 User,但调用方代码里把它当 impl Greet 用。
工程意义:隐藏实现类型,暴露 trait 契约。如果你将来改返回 Dog 而不是 User,调用方代码不用改(只要他们只用 Greet 的方法)。
限制:函数所有 return path 必须返回同一个具体类型。下面这段不行:
fn make_greeter(use_dog: bool) -> impl Greet {
if use_dog {
Dog { /* ... */ } // ❌ 不同 return path 不能返回不同类型
} else {
User { /* ... */ }
}
}
要解决这个,需要 dyn Trait。
写法 3:dyn Trait(trait object,动态分发)
fn print_hello(g: &dyn Greet) {
println!("{}", g.hello());
}
&dyn Greet 是个 fat pointer——两个指针:一个指向数据,一个指向 vtable(虚函数表)。调用 g.hello() 时通过 vtable 查找。
&dyn Greet 的内存布局:
┌──────────────┬──────────────┐
│ data ptr │ vtable ptr │
└──────┬───────┴──────┬───────┘
│ │
▼ ▼
┌──────┐ ┌──────────────────┐
│ User │ │ vtable │
│ data │ │ - hello: fn ptr │
└──────┘ │ - drop: fn ptr │
└──────────────────┘
成本:每次 method 调用一次 vtable 查找(无法内联)。
收益:可以异构存储——Vec<Box<dyn Greet>> 可以同时装 User 和 Dog。
let greeters: Vec<Box<dyn Greet>> = vec![
Box::new(User { /* ... */ }),
Box::new(Dog { /* ... */ }),
];
for g in &greeters {
println!("{}", g.hello());
}
Vec<Box<dyn Greet>> 跟 Java 的 List<Greet> 语义最接近——都是异构集合,都用虚函数表。
三种写法对比
| 写法 | 分发 | 成本 | 适用 |
|---|---|---|---|
T: Trait 或 impl Trait(参数位置) | 静态 | 编译期膨胀 | 高性能、可内联 |
impl Trait(返回位置) | 静态 | 同上 + 类型擦除 | 隐藏实现类型 |
dyn Trait | 动态 | 一次 vtable 查找/调用 | 异构集合、动态多态 |
工程经验:默认写 impl Trait,需要异构集合时用 dyn Trait。
6.5 Object Safety:为什么有些 trait 不能 dyn
不是所有 trait 都能做 trait object。下面这段不能编译:
trait Cloneable {
fn clone_me(&self) -> Self; // 返回 Self
}
let x: Box<dyn Cloneable> = /* ... */; // ❌ trait Cloneable is not object-safe
原因:fn clone_me(&self) -> Self——返回类型是 Self。但 dyn Cloneable 已经擦除了具体类型,编译器不知道返回什么大小的对象。
Rust 的 object safety rules(对象安全规则)一句话:
trait 可以做 trait object,当且仅当它的方法都不"使用 Self"在 vtable 不能处理的位置。
具体规则(简化):
- 方法不能返回
Self(原因如上) - 方法不能有泛型参数(
fn foo<T>(&self, x: T)不行——vtable 装不下无穷多版本) - 方法的第一个参数必须是 receiver(
self/&self/&mut self/Box<Self>等) - 没有关联常量
- ...
详细规则见 The Rust Reference,但直觉是:vtable 必须能装下所有方法的具体地址,任何"依赖具体类型"的东西都不行。
工程含义
设计 trait 时如果想保留 dyn 选项,避免:
- 返回 Self(改成
Box<dyn TraitName>,例如下面例子里的Box<dyn Animal>,或某个具体类型) - 泛型方法(改成 trait 自身的关联类型,或拆成两个 trait)
例子:
// ❌ 不能 dyn 的设计
trait Animal {
fn duplicate(&self) -> Self;
fn play_with<T: Toy>(&self, toy: T);
}
// ✅ 能 dyn 的设计
trait Animal {
fn duplicate(&self) -> Box<dyn Animal>;
fn play_with(&self, toy: &dyn Toy);
}
如果你设计的 trait 不需要 dyn(比如它就是给泛型 bound 用的),不用管 object safety,该用 Self 就用 Self。Iterator 就有 fn collect<B: FromIterator<Self::Item>>,这就不能 dyn(stdlib 提供 &mut dyn Iterator<Item = T> 的子集是 object-safe 的——这是经过精心设计的)。
6.6 Marker Traits:不带方法的 trait
有些 trait 没有方法,只用作"类型标记":
pub trait Send { }
pub trait Sync { }
pub trait Copy: Clone { }
pub trait Sized { }
这些 trait 不实现任何行为,但编译器靠它们做关键判断。
Send / Sync:并发安全的根基
T: Send:T 的所有权可以转移到另一个线程T: Sync:&T可以被多个线程共享
大部分类型自动实现这两个(编译器自动推导)。手动 unsafe impl Send for MyType 是危险操作,Ch 11 详讲。
工程意义:线程 spawn 函数有 T: Send + 'static 约束,违反就编译期拦下。
Copy:bitwise 复制后原值仍可用
Ch 2 讲过。Copy 是 marker trait,告诉编译器"这个类型 move 时按 copy 处理"。
Sized:编译期已知大小
默认所有泛型参数都隐含 T: Sized。这是为什么写 dyn Trait 时通常要 Box<dyn Trait>——dyn Trait 是 unsized 的(!Sized),不能直接放在 stack。
如果你想接受 unsized 类型,显式 opt out:
#![allow(unused)] fn main() { use std::fmt::Debug; fn print<T: ?Sized + Debug>(x: &T) { // ?Sized 表示不要求 Sized println!("{:?}", x); } }
6.7 实战:为领域模型设计 trait
回到工程。假设你有一个 artifact 生命周期管理器,artifact 有多种类型(spec / code / test / doc),每种有不同的处理方式。
起点:大 enum
可能的初版设计:
pub enum Artifact {
Spec(SpecArtifact),
Code(CodeArtifact),
Test(TestArtifact),
Doc(DocArtifact),
}
impl Artifact {
pub fn id(&self) -> ArtifactId {
match self {
Artifact::Spec(a) => a.id,
Artifact::Code(a) => a.id,
Artifact::Test(a) => a.id,
Artifact::Doc(a) => a.id,
}
}
pub fn validate(&self) -> Result<(), ValidationError> {
match self {
Artifact::Spec(a) => a.validate(),
Artifact::Code(a) => a.validate(),
Artifact::Test(a) => a.validate(),
Artifact::Doc(a) => a.validate(),
}
}
// ... 每个方法都要 match 一次
}
每加一个方法,要写一次 match。每加一个 variant,要改所有方法。这是 Rust 里"用 enum 替代 dynamic dispatch"的代价。
重设计:trait
pub trait Artifact {
fn id(&self) -> ArtifactId;
fn kind(&self) -> ArtifactKind;
fn validate(&self) -> Result<(), ValidationError>;
fn dependencies(&self) -> &[ArtifactId];
}
pub struct SpecArtifact { /* ... */ }
impl Artifact for SpecArtifact {
fn id(&self) -> ArtifactId { self.id }
fn kind(&self) -> ArtifactKind { ArtifactKind::Spec }
fn validate(&self) -> Result<(), ValidationError> { /* spec validation */ }
fn dependencies(&self) -> &[ArtifactId] { &self.deps }
}
// CodeArtifact, TestArtifact, DocArtifact 类似
加新 artifact 类型:impl Artifact for ProseArtifact,不需要改任何已有代码。
enum 还是 trait:工程权衡
| 场景 | 选 enum | 选 trait |
|---|---|---|
| variant 集合稳定,行为多变 | ✅ | |
| variant 集合多变,行为稳定 | ✅ | |
| 需要序列化(serde) | ✅(enum 直接 derive) | ❌(trait object 序列化复杂) |
| 需要异构集合 | ✅(Vec<Artifact>) | ✅(Vec<Box<dyn Artifact>>) |
| 需要外部扩展(用户自己加新 variant) | ❌ | ✅ |
| 性能极敏感(避免 vtable 查找) | ✅ | △(用静态分发可以,但失去异构能力) |
Artifact 这个例子:variant 集合可能扩展(将来加新 artifact 类型),但行为相对固定(每个 artifact 都要 id/validate/deps)。trait 更合适。
进一步:trait 组合
不只关心 Artifact 通用接口。还有些专门的 trait:
use std::path::Path;
pub trait Validatable {
fn validate(&self) -> Result<(), ValidationError>;
}
pub trait Renderable {
fn render(&self) -> String;
}
pub trait Persistable {
fn save(&self, path: &Path) -> std::io::Result<()>;
fn load(path: &Path) -> std::io::Result<Self> where Self: Sized;
}
pub trait Artifact: Validatable + Persistable {
fn id(&self) -> ArtifactId;
fn kind(&self) -> ArtifactKind;
}
Artifact 是组合 trait——任何实现 Artifact 的类型都自动是 Validatable + Persistable。但反过来,只是 Renderable 的类型不自动是 Artifact。
这是 Rust 的 trait 组合优于继承——你可以拼装多个小 trait,让类型按需获得能力。比 Java 的"单继承 + 多 interface"灵活。
6.8 章末小结与习题
本章核心概念回顾
- Trait 是 nominal:必须显式
impl Trait for Type,跟 Go structural typing 不同 - Default methods + supertraits:trait 可以有默认实现,可以依赖其他 trait
- Associated types vs generic parameters:类型层面"唯一"的关系用 associated type,"多对多"用 generic parameter
- Blanket impl:为"所有满足条件的类型"实现 trait,Rust 杀手锏
- 三种 trait 消费方式:
T: Trait(静态、最常用)、impl Trait(类型擦除)、dyn Trait(动态、异构集合) - Object safety:不是所有 trait 能 dyn,设计时考虑"是否需要保留 dyn 选项"
- Marker traits:
Send/Sync/Copy/Sized没方法,但是编译器关键决策依据 - enum vs trait:variant 稳定+行为多变用 enum,variant 扩展+行为稳定用 trait
习题
习题 6.1(简单)
为下面 trait 写一个实现:
trait Describable {
fn describe(&self) -> String;
}
struct Movie {
title: String,
year: u32,
}
// 给 Movie 实现 Describable,describe 返回 "Movie: {title} ({year})"
习题 6.2(中等)
设计一个 Storage trait,可以保存和读取键值:
trait Storage {
type Key;
type Value;
fn get(&self, key: &Self::Key) -> Option<&Self::Value>;
fn set(&mut self, key: Self::Key, value: Self::Value);
}
为 HashMap<String, String> 和 Vec<(u32, User)> 各实现一次 Storage,注意 Self::Key 不同。
习题 6.3(中等)
判断下面 trait 哪些是 object-safe(能 dyn),哪些不是,为什么:
trait A {
fn foo(&self);
}
trait B {
fn foo(&self) -> Self;
}
trait C {
fn foo<T>(&self, x: T);
}
trait D {
fn foo(&self, x: &dyn std::fmt::Debug);
}
trait E {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
习题 6.4(困难,工程)
设计一组 trait 来表达 artifact 生命周期 hooks:
- 每个 artifact 有"创建"、"修改"、"删除"三个生命周期事件
- 不同类型的 artifact 对每个事件有不同处理(spec 触发 lint,code 触发 compile,doc 触发 render)
- 系统应该可以注册多个 hooks,顺序触发
要求:
- 用 trait + 静态分发 vs 用 trait object,对比两种设计
- 哪种适合实际场景?为什么?
习题 6.5(开放)
回顾你写的 Go interface 或 C# interface。挑一个,试着用 Rust trait 重新设计:
- 哪些 method 该是 default method?
- 是否应该用 supertrait 拆分关注点?
- 该用 associated type 还是 generic parameter?
- 是否需要保留 object safety?
不需要敲代码。设计本身的练习更有价值。
下一章预告
Ch 7 处理 trait 的双胞胎:泛型与生命周期的进阶。
我们会讲 monomorphization 的实际成本、where 子句的复杂用法、variance(协变/逆变)、HRTB(higher-ranked trait bounds)、GAT(generic associated types)。这些是 Rust 类型系统最深的部分,读懂之后你能看懂 stdlib 里几乎任何 trait 设计。
本章一句话总结
Trait 不是 interface 的复刻。它是 Haskell typeclass 的近亲,带着 nominal typing、associated types、blanket impl、object safety 等一组特性,共同构成 Rust 抽象表达力的基础。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 7 · 泛型与生命周期进阶
Rust 类型系统的深水区
核心问题:为什么 Rust 类型系统能表达比 Go / C# 复杂得多的约束?为什么这些约束在工程上值得?
Part II 前三章你已经能写"够用的" Rust:struct、enum、trait、Result、错误处理。这一章是把"够用"推到"地道"——一旦看懂 monomorphization、HRTB、variance、GAT,你会发现自己读标准库、读 tokio、读 axum 时,90% 的"看不懂"瞬间变成"原来如此"。
读完你应该能:
- 解释 monomorphization 的代价,知道什么时候该切到
dyn Trait - 用
where写出比T: Trait复杂得多的约束 - 看懂"
'ais invariant overT"这种文档,并知道 PhantomData 是干嘛的 - 在签名里写
for<'a> Fn(&'a T),并解释为什么必须这样写 - 区分 GAT 跟 associated type,知道 GAT 解决了哪类设计
7.1 Monomorphization:零成本抽象的真实代价
什么是 monomorphization
C++ template、Rust 泛型、C# unconstrained generic 的实现方式有一个共同名字:单态化。编译器把每个具体类型实例化一份代码。
fn largest<T: PartialOrd>(list: &[T]) -> &T { let mut largest = &list[0]; for item in list { if item > largest { largest = item; } } largest } fn main() { let v_i32 = vec![1, 2, 3]; let v_f64 = vec![1.0, 2.0, 3.0]; largest(&v_i32); largest(&v_f64); }
编译后,等价于:
fn largest_i32(list: &[i32]) -> &i32 { /* 内联 PartialOrd::gt for i32 */ }
fn largest_f64(list: &[f64]) -> &f64 { /* 内联 PartialOrd::gt for f64 */ }
调用是直接函数调用,没有虚表查找。这就是 Rust 所说的 "zero-cost":运行时和手写一个类型的版本一样快。
跟其他语言对比
| 语言 | 实现方式 | 运行时代价 | 编译时代价 |
|---|---|---|---|
| Go(generics, 1.18+) | GC shape stenciling | 接近 interface(轻微 boxing) | 中等 |
| Java / C#(reference type) | type erasure / shared code | virtual dispatch | 低 |
| C#(value type) | reified, per-type code | 零(同 Rust) | 中 |
| C++ template | 单态化 | 零 | 高 |
| Rust | 单态化 | 零 | 高 |
Rust 和 C++ 在同一阵营。编译时间和二进制体积换运行时性能。
代价 1:二进制膨胀
// 你以为这一个函数
fn parse_json<T: DeserializeOwned>(s: &str) -> Result<T> { ... }
// 实际编译出 10 份(每种被调用类型一份):
// parse_json::<User>
// parse_json::<Order>
// parse_json::<Settings>
// ...
一个被 serde 调用上百次的库,二进制体积涨几十 MB 是常态。这就是为什么 ripgrep release build 比 grep 大几倍。
代价 2:编译时间
每个实例化的版本要单独编译、内联、优化。serde + tokio 是 Rust 编译慢的两大主因。
工程对策:
-
隔离泛型:让 generic 函数只做"分发",真正的工作交给非 generic 的内层函数。
pub fn load_config<P: AsRef<Path>>(path: P) -> Result<Config> { load_config_impl(path.as_ref()) // 内层是 &Path,非泛型 } fn load_config_impl(path: &Path) -> Result<Config> { ... }外层只是"把 P 转成 &Path",编译开销极小;真正的逻辑只编一份。
-
必要时切到
dyn:当类型种类多、调用次数少,用 trait object 反而更划算。
代价 3:看不见的代码
你写 T: Display + Debug + Clone + Send + Sync,编译器要为每个具体 T 生成 4 个 trait 的实现链接。错误信息也会变冗长。
什么时候切到 dyn Trait
| 信号 | 偏好 |
|---|---|
| 类型集合在编译期是封闭的(已知 3-5 种) | enum 或 generic |
| 类型由运行时数据决定(plugin / config-driven) | Box<dyn Trait> |
| 调用次数极高(每秒百万级) | generic |
| 关心二进制体积或编译时间 | dyn |
经验:生产代码默认 generic,二进制体积报警了再切 dyn。
7.2 where 子句:比 T: Trait 表达力强一倍
基础
fn print_all<T>(items: &[T]) where T: Display {
for item in items { println!("{}", item); }
}
跟 <T: Display> 等价。但 where 的真本事在复杂约束。
多重约束
fn process<T, U>(t: T, u: U) -> String
where
T: Display + Clone + Send + 'static,
U: AsRef<str> + Debug,
{
format!("{:?} -> {}", u, t.clone())
}
行内 <T: A + B + C, U: D + E> 写起来挤,where 一行一约束,可读性碾压。
关联类型上的约束
fn collect_to_vec<I>(iter: I) -> Vec<I::Item>
where
I: Iterator,
I::Item: Clone + Send, // 给关联类型加约束 —— 行内语法做不到
{
iter.collect()
}
行内 <I: Iterator<Item: Clone>> 写不出来(Item: Clone 在行内是错误语法)。where 是唯一选择。
引用类型上的约束(HRTB 预告)
fn apply<F>(f: F)
where
F: for<'a> Fn(&'a str) -> &'a str, // 见 7.4
{ /* ... */ }
条件实现(blanket impl with constraint)
impl<T> Display for Wrapper<T>
where
T: Display + Sized,
{
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "Wrapper({})", self.0)
}
}
Wrapper<T> 只有在 T 实现 Display 时才有 Display。这是标准库到处在用的模式(Option<T>: Display where T: Display)。
实战:看懂 std::iter::Iterator::collect
fn collect<B: FromIterator<Self::Item>>(self) -> B
where
Self: Sized,
读法:
Self: Sized—— self 不能是 trait objectB: FromIterator<Self::Item>—— 目标容器要能从 self 的 Item 流构造- 返回值的类型由调用方通过
::<B>决定 —— 这就是let v: Vec<_> = iter.collect()那个 Vec 注解的来源
Rust 把"如何收集"完全外包给 FromIterator trait。Go 里你得为 Vec、HashSet、HashMap 各写一套 Collect。
7.3 Variance:协变 / 逆变 / 不变
直觉:子类型替换
Rust 没有继承,但有"子类型"概念——lifetime 之间存在子类型关系:'static <: 'a('static 是任何 'a 的子类型,因为 'static 活得更长,什么场合都能用)。
Variance 回答的问题:F<'a> 与 F<'b> 之间的子类型关系怎么传递?
| 名字 | 含义 | 例子 |
|---|---|---|
| 协变 covariant | 'a <: 'b 推出 F<'a> <: F<'b> | &'a T over 'a |
| 逆变 contravariant | 'a <: 'b 推出 F<'b> <: F<'a> | fn(&'a T) over 'a |
| 不变 invariant | 两个方向都不传递 | &'a mut T over 'a |
为什么 &'a T 是协变的
fn print(s: &'short str) { println!("{}", s); }
let long: &'static str = "hi"; // 'static <: 'short
print(long); // OK
&'static str 可以当 &'short str 用,因为它"活得更久"。这就是协变。
为什么 &'a mut T 是不变的
fn swap<'a>(a: &'a mut &'a str, b: &'a mut &'a str) { ... }
let mut long: &'static str = "hello";
let short = String::from("world");
let mut short_ref: &str = &short;
// 如果 &'a mut 是协变的:
swap(&mut long, &mut short_ref);
// long 现在指向 short,但 short 在外层 scope 结束后会 drop
// 之后访问 long ⇒ use-after-free
&mut 既被读又被写,既要协变又要逆变,结果只能不变。这是 Rust 唯一的"不变" reference。
PhantomData:告诉编译器我"逻辑上"持有 T
#![allow(unused)] fn main() { use std::marker::PhantomData; struct MyBox<T> { ptr: *const T, _marker: PhantomData<T>, } }
裸指针 *const T 不参与 drop check 和 variance 推断。PhantomData<T> 是给编译器看的:"这个结构体在所有权语义上拥有一个 T",让 variance / drop check / Send / Sync 推断正确工作。
PhantomData 的几种"味道":
| 写法 | 语义 |
|---|---|
PhantomData<T> | 拥有 T,对 T 协变 |
PhantomData<&'a T> | 借用 T,对 'a/T 协变 |
PhantomData<*mut T> | 不变,且不是 Send/Sync |
PhantomData<fn() -> T> | 协变,且 Send + Sync |
写 unsafe 容器时,选错 PhantomData 是经典 unsoundness 来源(参见 18 章)。
7.4 HRTB:Higher-Ranked Trait Bounds
动机
写一个高阶函数,它的参数是一个能处理"任意 lifetime 的 &str"的闭包:
fn apply<F>(f: F)
where
F: for<'a> Fn(&'a str) -> &'a str,
{
let s1 = String::from("hello");
println!("{}", f(&s1));
let s2 = String::from("world");
println!("{}", f(&s2));
}
for<'a> 读作 "for any lifetime 'a"——F 必须对所有可能的 'a 都成立,而不是某个具体的 'a。
跟普通 lifetime 的差别
// 错的写法:'a 是函数签名里的 lifetime 参数
fn apply<'a, F>(f: F)
where F: Fn(&'a str) -> &'a str,
{ /* ... */ }
// 这里 'a 在调用 apply 时就被确定了,不能用不同 lifetime 的两个 String 调 f
// 对的写法:'a 由 HRTB 量化,调用 f 时每次新定
fn apply<F>(f: F)
where F: for<'a> Fn(&'a str) -> &'a str,
{ /* ... */ }
为什么大部分时候不用写
编译器对 Fn(&T) -> &T 这种 trait bound 默认就按 HRTB 推断。只有当你想显式写、或编译器推不出来时,才手写 for<'a>。常见场景:trait object Box<dyn for<'a> Fn(&'a str)>,或 serde 自定义 deserializer。
7.5 GATs:Generic Associated Types
普通 associated type 的局限
#![allow(unused)] fn main() { trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } }
Item 是固定类型——Iterator for Vec<String> 的 Item 永远是 String(或 &String,看实现)。但有一类 iterator 想做不到:返回的元素借用了 self。
经典反例 —— "lending iterator":
trait LendingIterator {
type Item<'a> where Self: 'a; // ← GAT
fn next<'a>(&'a mut self) -> Option<Self::Item<'a>>;
}
每次调用 next,Item 的 lifetime 跟当次的 &mut self 绑定。这就是 GAT 解决的问题——关联类型本身带参数。
一个具体例子:WindowsMut
要做一个 iterator,每次给出一个对内部 buffer 的可变窗口:
struct WindowsMut<'b, T> { buf: &'b mut [T], size: usize, pos: usize }
impl<'b, T> LendingIterator for WindowsMut<'b, T> {
type Item<'a> = &'a mut [T] where Self: 'a;
fn next<'a>(&'a mut self) -> Option<Self::Item<'a>> {
if self.pos + self.size > self.buf.len() { return None; }
let window = &mut self.buf[self.pos..self.pos + self.size];
self.pos += 1;
Some(window)
}
}
普通 Iterator 做不到——因为 type Item 没法引用 'a(self 的 lifetime)。
工程意义
- async trait(
async fnin trait)在底层就靠 GAT 把"返回的 Future"参数化到 self 的 lifetime。 - 任何"借用 self 的迭代"——database cursor、parser 状态机、stream chunking——GAT 都能让 API 自然。
注意
GAT 在 1.65 稳定,但表达力仍在演进。复杂签名容易触发"未实现"或推断失败。用,但不要炫——库作者要懂,业务代码 90% 用不到。
7.6 实战:设计一个借用 self 的 Iterator
业务:解析日志文件,每次返回一个 &str 切片(指向内部 buffer,避免分配)。
朴素 Iterator(行不通)
struct LogParser<R: BufRead> { reader: R, buf: String }
impl<R: BufRead> Iterator for LogParser<R> {
type Item = &str; // ❌ 没法写 lifetime
fn next(&mut self) -> Option<&str> { /* ... */ }
}
Iterator::Item 没法跟 &mut self 共享 lifetime,编译器拒绝。
用 LendingIterator(GAT)
trait LendingIterator {
type Item<'a> where Self: 'a;
fn next(&mut self) -> Option<Self::Item<'_>>;
}
struct LogParser<R: BufRead> { reader: R, buf: String }
impl<R: BufRead> LendingIterator for LogParser<R> {
type Item<'a> = &'a str where Self: 'a;
fn next(&mut self) -> Option<&'_ str> {
self.buf.clear();
match self.reader.read_line(&mut self.buf) {
Ok(0) | Err(_) => None,
Ok(_) => Some(self.buf.trim_end()),
}
}
}
代价:不能用 for x in parser 语法糖,也不能直接 .collect()。LendingIterator 是更弱的接口,换来 zero-copy。
替代方案:返回 owned String
impl<R: BufRead> Iterator for LogParser<R> {
type Item = String;
fn next(&mut self) -> Option<String> { /* 每行分配一个 String */ }
}
简单、能 collect、支持 for 循环;代价是每行一次分配。
工程判断:90% 业务场景用 owned String,剩下 10% 性能敏感才上 GAT。先简单再优化。
习题
- 给 Ch 6 你设计的
Storagetrait 加一个 GAT 版本的"按 key 借出 entry"方法,签名是什么? Cow<'a, str>在'a上是协变、逆变、还是不变?为什么?- 写一个函数
fn apply<F: ?>(f: F),它的 F 是"对任意类型 T 都能调用的闭包"。能写吗?为什么 Rust 不支持类型层面的 HRTB? - 用
cargo expand看serde_json::from_str::<MyStruct>编译后展开成什么。 - 设计:一个 plugin 系统,运行时从
.so加载实现。该用 generic 还是dyn?给出选择和论据。
本章一句话总结
类型系统的高级特性不是炫技,是表达力的延伸——你越能用类型表达约束,运行时 bug 越少。但每用一项,都要问一句"业务真的需要这个表达力吗?"
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 8 · 智能指针
Box / Rc / Arc / Cow —— 一组语义各异的容器
核心问题:每种智能指针解决了什么问题?它们的运行时代价和编译期约束分别是什么?
C++ 程序员第一次看到 Box、Rc、Arc 会觉得熟悉:跟 unique_ptr、shared_ptr 是一对一对应。但 Rust 比 C++ 多了几样:Cow、Deref coercion、跟 GC 语言不一样的"清晰所有权语义"。Go 工程师没有对应物——Go 的指针是"裸引用 + GC",所有权概念隐形。
读完你应该能:
- 看到
Box<T>不再问"为什么不直接放栈上" - 在
Rc和Arc之间果断选择 - 解释 Cow 的"零拷贝优化"在什么场景值得
- 看懂
Deref的自动解引用规则 - 给你的领域问题选对智能指针
8.1 Box<T>:把数据放到堆上
Box<T> 是最简单的智能指针:一个堆指针,大小固定为 usize,drop 时 free 内存。
三种典型用途
用途一:把大对象从栈搬到堆。
struct BigStruct { data: [u8; 1_000_000], // 1 MB } fn main() { let big = Box::new(BigStruct { data: [0; 1_000_000] }); // big 在栈上只占 8 字节(指针),真正的 1MB 在堆上 }
栈空间有限(通常 1-8 MB),大对象放栈上轻则慢、重则 stack overflow。
用途二:递归类型。
#![allow(unused)] fn main() { enum List { Cons(i32, Box<List>), // 不能写 Box<List> 改成 List —— 类型大小未知 Nil, } }
编译器算 List 大小时:Cons(i32, List) 的大小 = 4 + sizeof(List) —— 递归。Box<List> 是固定大小指针,递归终止。
用途三:trait object 容器。
trait Animal { fn speak(&self); }
let zoo: Vec<Box<dyn Animal>> = vec![
Box::new(Cat),
Box::new(Dog),
];
不同实现类型大小不同,放进同一 Vec 只能通过 trait object,而 trait object 大小不确定,必须装在 Box(或 &、Arc 等)里。
跟 Go / C# 类比
| 场景 | Go | C# | Rust |
|---|---|---|---|
| 堆分配 | 编译器逃逸分析(隐式) | new / 引用类型(隐式) | Box::new(显式) |
| 递归类型 | 用 pointer(*Node) | 用 reference 类型(class Node) | Box<Node> |
| 接口容器 | []SomeInterface(自动 box) | List<ISomething> | Vec<Box<dyn Trait>> |
Rust 的"显式 Box"不是麻烦——它让"这里有一次堆分配"在代码里可见。Go 把堆分配藏在编译器里,排查内存问题时反而头疼。
代价
- 一次
malloc+free - 一次额外的间接寻址
- 总体跟 C++
unique_ptr完全等价
8.2 Rc<T>:单线程共享所有权
Rc<T>(Reference Counted)= 共享所有权 + 引用计数 + 不允许跨线程。
典型用法
#![allow(unused)] fn main() { use std::rc::Rc; let a = Rc::new(String::from("hello")); let b = Rc::clone(&a); // 计数 +1,不是 deep clone let c = Rc::clone(&a); // 计数 +1 println!("count = {}", Rc::strong_count(&a)); // 3 // 全部 drop 后,String 才被释放 }
Rc::clone 是 cheap 操作,只 +1 计数器。约定上不写 a.clone() 而是 Rc::clone(&a),让"这只是共享一份引用"这个意图更明显。
为什么不能跨线程
Rc 内部计数器是普通 usize,没有原子操作。两个线程同时 +1,可能丢失更新,导致内存提前释放(use-after-free)或永不释放(leak)。
Rust 编译器知道这点:Rc<T> 不实现 Send。你尝试把 Rc move 到另一个线程:
let a = Rc::new(0);
std::thread::spawn(move || {
println!("{}", a); // 编译错误:`Rc<i32>` cannot be sent between threads safely
});
编译期堵死。这就是 Rust 类型系统的胜利——同一个 bug 在 C++ 是 segfault,Go 没有 Rc 这么直接的设计但有 channel 之类的等价坑。
循环引用问题
Rc + Rc 的 graph 会有循环引用:
struct Node {
next: Option<Rc<RefCell<Node>>>,
}
let a = Rc::new(RefCell::new(Node { next: None }));
let b = Rc::new(RefCell::new(Node { next: Some(a.clone()) }));
a.borrow_mut().next = Some(b.clone());
// a -> b -> a -> b -> ... 计数器永远 > 0,内存泄漏
Rc 不防循环,它只在计数归零时才释放。
Weak<T>:打破循环
#![allow(unused)] fn main() { use std::rc::{Rc, Weak}; use std::cell::RefCell; struct Parent { children: RefCell<Vec<Rc<Child>>> } struct Child { parent: RefCell<Weak<Parent>> } }
约定:owner 持有 Rc,被引用者持有 Weak。父持有子 = Rc<Child>,子反向引用父 = Weak<Parent>。
Weak 不增加 strong count;访问要 upgrade() 拿到 Option<Rc<T>>(可能 None,表示对方已被释放)。
8.3 Arc<T>:多线程共享所有权
Arc<T>(Atomic Reference Counted)= Rc<T> 的多线程版,计数器用原子操作。
#![allow(unused)] fn main() { use std::sync::Arc; use std::thread; let data = Arc::new(vec![1, 2, 3]); let mut handles = vec![]; for _ in 0..4 { let data = Arc::clone(&data); handles.push(thread::spawn(move || { println!("{:?}", data); })); } for h in handles { h.join().unwrap(); } }
代价:为什么 Arc 比 Rc 慢
每次 clone / drop:
Rc:一条inc/dec普通指令(约 1 ns)Arc:一条lock xadd原子指令(10-30 ns,跨 CPU cache 时可达 100 ns)
差距 10x-100x。对于热路径上每秒数百万次 clone 的场景(比如频繁的事件分发),Rc 和 Arc 的差距体感明显。
经验:单线程用 Rc,跨线程必须 Arc。不要"为了将来扩展性"默认用 Arc —— 在还没有跨线程需求时,这只是无谓的开销。
Arc + Mutex / RwLock
Arc<T> 共享的是不可变引用。要共享并修改:
#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; let counter = Arc::new(Mutex::new(0)); let c = Arc::clone(&counter); std::thread::spawn(move || { *c.lock().unwrap() += 1; }); }
Arc<Mutex<T>> 是 Rust 多线程共享可变状态的事实标准。看到这个组合不要诧异。
8.4 Cow<'a, T>:Clone on Write
动机
API 设计常见两难:
- 接收
&str性能好,但调用方有String时要写&s - 接收
String接口对调用方友好,但要 clone
Cow(Clone on Write)= 可能借用、可能拥有的两态枚举。
pub enum Cow<'a, T: ?Sized + ToOwned> {
Borrowed(&'a T),
Owned(<T as ToOwned>::Owned),
}
经典场景:可能修改的字符串
use std::borrow::Cow; fn normalize<'a>(s: &'a str) -> Cow<'a, str> { if s.contains(' ') { Cow::Owned(s.replace(' ', "_")) // 需要修改,分配新 String } else { Cow::Borrowed(s) // 不需要修改,零拷贝 } } fn main() { let a = normalize("hello"); // 借用,零分配 let b = normalize("hi there"); // 拥有,一次分配 println!("{} / {}", a, b); }
调用方完全感知不到差别——Cow<str> 自动 Deref 成 &str,跟 String 一样能用 .len()、.chars()。
在 API 边界返回 Cow
pub fn config_value(&self, key: &str) -> Cow<'_, str>
返回 Cow 让你保留"零拷贝"的可能,同时也允许"需要的时候返回临时构造的字符串"。serde 大量使用这个模式做 zero-copy 反序列化。
适用场景
- 字符串清洗 / 规范化(大概率不修改,小概率修改)
- 反序列化(JSON 字段大部分能借,带转义的需要拥有)
- HTTP header 解析
不适用:总是需要修改(直接返回 String)、总是只读(直接返回 &str)。Cow 是"动态选择"的工具。
8.5 Deref / DerefMut:智能指针的核心 trait
pub trait Deref {
type Target: ?Sized;
fn deref(&self) -> &Self::Target;
}
实现 Deref 后,编译器会自动在你的类型和它的 Target 之间做转换:
#![allow(unused)] fn main() { let b = Box::new(String::from("hello")); let len: usize = b.len(); // 实际是 (*b).len(),即 String::len }
Box<String> 通过 Deref 链 Box<String> -> String -> str,自动可以调 str 的方法。
自定义类型的 Deref
use std::ops::Deref; struct UserId(u64); impl Deref for UserId { type Target = u64; fn deref(&self) -> &u64 { &self.0 } } fn main() { let id = UserId(42); println!("{}", *id + 1); // 43 }
Deref coercion 链
#![allow(unused)] fn main() { fn takes_str(s: &str) { println!("{}", s); } let owned: String = String::from("hi"); takes_str(&owned); // String → str let boxed: Box<String> = Box::new(owned); takes_str(&boxed); // Box<String> → String → str let arc: std::sync::Arc<Box<String>> = std::sync::Arc::new(boxed); takes_str(&arc); // Arc → Box → String → str }
四种类型自动转。这就是为什么 Rust 函数普遍接受 &str 而不是 &String —— 接 &str 调用方任何 string-like 类型都能传。
Deref 的工程纪律
不要为业务类型滥用 Deref —— 它是给"指针类型"用的(Box / Rc / Arc / 自定义 smart pointer)。把 User Deref 到 UserId,看起来方便,但破坏了"我看到 User 调 .id() 就是访问 id 字段"的可读性。
经验:newtype + 显式 .0 访问 > 滥用 Deref。
8.6 何时选哪个:决策流程
需要堆分配?
├─ 不需要(数据小且作用域清晰) → 直接放栈
└─ 需要 → 进入下一步
需要多个所有者?
├─ 单一所有者 → Box<T>
└─ 多个所有者 → 进入下一步
需要跨线程共享?
├─ 单线程 → Rc<T>(共享只读)
│ Rc<RefCell<T>>(共享可变)
└─ 多线程 → Arc<T>(共享只读)
Arc<Mutex<T>>(共享可变)
Arc<RwLock<T>>(读多写少)
API 边界,返回值可能借可能拥有? → Cow<'a, T>
反模式提示
Arc<Mutex<HashMap<K, V>>>性能瓶颈 → 考虑DashMap或Arc<RwLock>Rc<RefCell<T>>双向数据结构 → 优先用 index(Vec+usize)替代指针Box<dyn Trait>满天飞 → 检查是否能用 enum 替代
习题
- 把一个 C++ 用
shared_ptr实现的 LRU cache 翻成 Rust。要求线程安全。 - 写一个
fn trim_lower(s: &str) -> Cow<'_, str>,只在需要时分配。 - 故意构造一个
Rc循环引用,用Weak修好。 - 读
std::sync::Arc源码,找到clone里那条fetch_add用的是什么 ordering(Relaxed?),并解释为什么这个 ordering 够。 - 设计一个 plugin trait,使用方用
Vec<Box<dyn Plugin>>持有。讨论:为什么不用 enum?
本章一句话总结
智能指针不是"更智能的指针",是"封装了不同所有权语义的容器"。选对它,代码自然简洁;选错它,你会从一种 bug 跳进另一种。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 9 · 内部可变性
Cell / RefCell / Mutex —— 在不可变接口下偷偷修改
核心问题:Rust 借用规则说"要么多个 &T,要么一个 &mut T",但有时候我们就是需要在 &T 下修改内部。怎么办?
借用规则是 Rust 的核心安全保证,但它有时太严格。observer pattern、cache、共享 mutex —— 这些场景需要"在外部看是不可变,内部能修改"。Rust 的答案是内部可变性:一组把借用检查从编译期推到运行时的类型。
读完你应该能:
- 解释为什么需要内部可变性(给出两个真实场景)
- 区分
Cell/RefCell/Mutex各自的使用场景 - 用
RefCell实现一个简单 cache,知道 panic 风险点在哪 - 理解
UnsafeCell是所有内部可变性的根 - 看懂
Rc<RefCell<T>>这种"祖传组合"出现时是什么意思
9.1 为什么需要内部可变性
场景一:逻辑上不可变的对象需要内部缓存
struct Repo {
data: Vec<u8>,
// 想要:第一次访问 line_count 时计算并缓存,后续直接返回
line_count_cache: Option<usize>,
}
impl Repo {
fn line_count(&self) -> usize { // 接口是 &self
if let Some(n) = self.line_count_cache { return n; }
let n = self.data.iter().filter(|&&b| b == b'\n').count();
self.line_count_cache = Some(n); // ❌ 不能在 &self 下修改
n
}
}
从语义讲,Repo 的状态(行数)没变,只是 cache 有变。强迫接口变 &mut self 会污染整个调用链。
场景二:trait 实现要求 &self 但你需要可变状态
trait Logger {
fn log(&self, msg: &str); // trait 定义 &self
}
struct CountingLogger {
count: usize,
}
impl Logger for CountingLogger {
fn log(&self, msg: &str) {
self.count += 1; // ❌ 不能改
println!("[{}] {}", self.count, msg);
}
}
不能改 trait,也不能让 log 变 &mut self —— 内部可变性是唯一解。
场景三:共享 mutable state(并发)
经典 Arc<Mutex<T>> 组合 —— 多个线程都有 Arc<Mutex<T>> 的 & 引用,要修改时通过 lock() 拿到 &mut T。如果没有内部可变性,这事根本做不到。
9.2 Cell<T>:Copy 类型的简单内部可变性
Cell<T> 是最简单的内部可变性:只能 get / set 整体,没有借用检查开销。
use std::cell::Cell; struct Counter { value: Cell<u32> } impl Counter { fn new() -> Self { Counter { value: Cell::new(0) } } fn incr(&self) { self.value.set(self.value.get() + 1); } fn get(&self) -> u32 { self.value.get() } } fn main() { let c = Counter::new(); c.incr(); // 通过 &self 修改内部 c.incr(); println!("{}", c.get()); // 2 }
特点
get(&self) -> T—— 复制出来一份(要求T: Copy)set(&self, value: T)—— 整体替换- 没有运行时检查开销(因为它根本不暴露
&T给你,所以不会有"同时多个 borrow"的问题) - 限制:不能对内部"取引用"修改字段
适用类型
- 数字、bool、枚举(任何 Copy 的)
- 不适合大对象(每次 get 复制成本高)
- 不能用于
String、Vec等非 Copy 类型
9.3 RefCell<T>:运行时借用检查
RefCell<T> 适合非 Copy 类型 + 需要 borrow / borrow_mut。
#![allow(unused)] fn main() { use std::cell::RefCell; let cell = RefCell::new(vec![1, 2, 3]); { let r1 = cell.borrow(); let r2 = cell.borrow(); println!("{:?} {:?}", r1, r2); // OK,多个 immutable borrow } { let mut m = cell.borrow_mut(); m.push(4); // OK,独占 mutable borrow } // 错误用法:同时持有 immut 和 mut let _r = cell.borrow(); let _m = cell.borrow_mut(); // panic at runtime! }
关键点
borrow()返回Ref<T>,borrow_mut()返回RefMut<T>—— 它们是 RAII 守卫,drop 时归还借用- 违反借用规则:运行时 panic(不是编译错误!)
- 内部用一个计数器:借出
Ref+1,借出RefMut设特殊值
跟编译期借用检查的差异
| 维度 | 编译期 (& / &mut) | 运行时 (RefCell) |
|---|---|---|
| 检查时机 | 编译时 | 运行时 |
| 违反后果 | 编译错 | panic |
| 检查粒度 | 整体 | 整体 |
| 性能开销 | 0 | 每次 borrow 一个计数器更新(约 1 ns) |
用 RefCell 等于自愿放弃了"编译期保证"。 这是 trade-off,不是"更好用"。
工程纪律
- 优先用
&mut self,不行才考虑 RefCell - RefCell 的作用域尽量小——拿到
borrow_mut后,做完事情立刻 drop,不要拿在手上调用别的方法 - 避免在
borrow_mut还活着时调用回调 / 触发递归
经典反例:RefCell 在回调里炸
let observers: RefCell<Vec<Box<dyn Fn()>>> = ...;
observers.borrow().iter().for_each(|cb| cb());
// 如果某个 cb 内部又调了 observers.borrow_mut() —— panic
写 observer / event bus 时,先 clone 出 callback 列表,drop borrow,再触发。
9.4 Mutex<T> 与 RwLock<T>:线程安全的内部可变性
RefCell 不是 Send + Sync,跨线程要用 Mutex / RwLock。
Mutex<T>:互斥锁
#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; use std::thread; let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let c = Arc::clone(&counter); handles.push(thread::spawn(move || { let mut n = c.lock().unwrap(); // 拿锁,得到 &mut 0 *n += 1; })); } for h in handles { h.join().unwrap(); } println!("{}", *counter.lock().unwrap()); // 10 }
lock()返回Result<MutexGuard<T>, PoisonError>—— Guard drop 时自动 unlock- 持锁线程 panic 时 mutex 被 poisoned,下次 lock 返回 Err。可
into_inner强制取出
RwLock<T>:读写锁
use std::sync::RwLock;
let cache = RwLock::new(HashMap::new());
// 多个读
{ let r = cache.read().unwrap(); /* ... */ }
// 单个写
{ let mut w = cache.write().unwrap(); w.insert("k", "v"); }
读多写少的场景比 Mutex 性能好;读写比接近的场景 Mutex 反而更快(RwLock 协议开销大)。
跟 RefCell 的关系
单线程 → 跨线程
Cell → AtomicXxx
RefCell → Mutex / RwLock
API 几乎一一对应,只是检查模型不一样:RefCell 运行时 panic,Mutex / RwLock 阻塞等。
9.5 UnsafeCell:内部可变性的最底层
pub struct UnsafeCell<T: ?Sized> { value: T }
impl<T: ?Sized> UnsafeCell<T> {
pub const fn get(&self) -> *mut T { /* 返回内部数据的裸指针 */ }
}
UnsafeCell 是 Rust 类型系统里唯一能从 &UnsafeCell<T> 拿到 *mut T 的类型——它绕过的不是借用检查,而是编译器的"&T 不可变"假设。
所有内部可变性都建立在 UnsafeCell 之上:
Cell<T>=UnsafeCell<T>+ 受限 APIRefCell<T>=UnsafeCell<T>+ borrow counterMutex<T>=UnsafeCell<T>+ OS mutexAtomic*=UnsafeCell<T>+ 原子指令
业务代码永远不要直接用 UnsafeCell——它要求 unsafe,且没有任何安全保证。它是构建内部可变性抽象时才会用到的"原始材料"。
9.6 实战:基于 Rc<RefCell> 的双向链表
#![allow(unused)] fn main() { use std::rc::{Rc, Weak}; use std::cell::RefCell; type Link<T> = Option<Rc<RefCell<Node<T>>>>; type Back<T> = Option<Weak<RefCell<Node<T>>>>; struct Node<T> { value: T, next: Link<T>, prev: Back<T> } struct DList<T> { head: Link<T>, tail: Back<T> } impl<T> DList<T> { fn new() -> Self { DList { head: None, tail: None } } fn push_front(&mut self, value: T) { let node = Rc::new(RefCell::new(Node { value, next: self.head.take(), prev: None })); if let Some(next) = &node.borrow().next { next.borrow_mut().prev = Some(Rc::downgrade(&node)); } else { self.tail = Some(Rc::downgrade(&node)); } self.head = Some(node); } } }
代价分析
- 每个 node 一次堆分配 + 引用计数
- 每次访问都要
borrow()/borrow_mut(),一次计数器更新 - 比 C++
std::list慢 2-3 倍 - 写 / 调试都比预期复杂(
borrow作用域穿插)
替代方案:基于索引的链表
struct Node<T> { value: T, next: Option<usize>, prev: Option<usize> }
struct DList<T> { nodes: Vec<Node<T>>, head: Option<usize>, tail: Option<usize> }
- 没有
Rc/RefCell,没有引用计数 - "指针"是
usize索引,缓存友好 - 借用检查靠
&mut self,自然 - 是 Rust 社区写图 / 树 / 链表的事实标准
经验:看到 Rc<RefCell<T>> 的链表 / 图,先问"能不能用 Vec + index 改写"。十有八九能,而且更快。
习题
- 实现一个有 cache 的
WordCounter:fn count(&self, word: &str) -> usize,内部缓存 word 出现的次数。 - 把 9.6 的双向链表改成索引版,对比代码量和性能(criterion)。
- 用
RefCell写一个 observer pattern,故意在回调里再次 borrow,看 panic 现场。改写成"先 clone callback list"的安全版本。 - 用
parking_lot::Mutex替换std::sync::Mutex,对比性能(短临界区差别明显)。 - 设计一个并发 LRU cache 的 lock 策略:整体
Mutex<LRU>vs 分桶Vec<Mutex<Bucket>>。讨论 trade-off。
本章一句话总结
内部可变性把"借用检查"从编译期推到运行时,代价是部分编译期保证消失。慎用,但必要时不要回避——
Arc<Mutex<T>>是 Rust 并发代码的日常,不是"代码味道"。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 10 · Iterator 与闭包
Rust 函数式编程的两大支柱
核心问题:为什么 Rust 的 iterator 是真正的零成本抽象?三种 Fn trait 到底有什么差别?
Go 没有 iterator(range 是语法糖),也没有真正的闭包(func 字面量是 closure 但所有权语义模糊)。C# 的 LINQ 漂亮但有装箱 / 委托调用开销。Rust 的 iterator 链是编译器把链路展开成等价的 for 循环——抽象层数任意多,运行时为零。
读完你应该能:
- 看到
iter.filter(...).map(...).collect()不再担心性能 - 解释
Fn/FnMut/FnOnce各自的捕获和调用语义 - 知道什么时候必须
move closure - 写出比 Go 同等代码更短同时更快的数据处理逻辑
- 看懂为什么
Vec::iter()、Vec::into_iter()、Vec::iter_mut()是三个不同的方法
10.1 Iterator trait 的真实定义
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// ... 70+ 个 default method
}
整个 Iterator 体系只有 next() 一个必须实现的方法。其他都是基于 next 的 default impl。
手写一个 Iterator
struct Counter { count: u32 } impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<u32> { if self.count < 5 { self.count += 1; Some(self.count) } else { None } } } fn main() { let sum: u32 = Counter { count: 0 } .map(|x| x * 2) .filter(|x| x % 3 != 0) .sum(); println!("{}", sum); // 2 + 4 + 8 + 10 = 24 }
写一遍 next,自动得到 map / filter / sum 等所有方法。
三种 iter 方法
let v = vec![1, 2, 3];
for x in v.iter() { /* x: &i32 —— 借用,v 还在 */ }
for x in v.iter_mut() { /* x: &mut i32 —— 可变借用 */ }
for x in v.into_iter() { /* x: i32 —— 拿走所有权,v 消失 */ }
这是 Go / C# 不直接对应的设计。Go for _, x := range v 永远是 copy + index,没法表达"我要 move 走"或"我要可变借用"。Rust 用类型区分三种意图。
懒求值的工程意义
#![allow(unused)] fn main() { let v: Vec<i32> = (1..1_000_000) .map(|x| x * 2) .filter(|&x| x % 3 == 0) .take(5) .collect(); }
不要被 1..1_000_000 吓到——iterator 是懒的,take(5) 一遇上 5 个就停。Go 等价代码要写一个带 break 的 for 循环;Rust 的链式调用既短又一样快。
10.2 Iterator adapter 全家桶
最常用的 ~20 个:
#![allow(unused)] fn main() { let nums = vec![1, 2, 3, 4, 5]; // 变换 let doubled: Vec<_> = nums.iter().map(|x| x * 2).collect(); // 过滤 let evens: Vec<_> = nums.iter().filter(|&&x| x % 2 == 0).collect(); // 折叠 let sum: i32 = nums.iter().sum(); let product: i32 = nums.iter().product(); let max = nums.iter().max(); let fold = nums.iter().fold(0, |acc, &x| acc + x * x); // 配对 let with_index: Vec<(usize, i32)> = nums.iter().copied().enumerate().collect(); let zipped: Vec<(i32, char)> = nums.iter().copied().zip("abcde".chars()).collect(); // 切片 let chunks: Vec<Vec<i32>> = nums.chunks(2).map(|c| c.to_vec()).collect(); let windows: Vec<Vec<i32>> = nums.windows(2).map(|w| w.to_vec()).collect(); // 串接 let concat: Vec<i32> = vec![1,2].into_iter().chain(vec![3,4]).collect(); let flat: Vec<i32> = vec![vec![1,2], vec![3]].into_iter().flatten().collect(); let mapped_flat: Vec<i32> = vec![1,2,3].iter().flat_map(|&x| vec![x, x * 10]).collect(); // 短路 let any_neg = nums.iter().any(|&x| x < 0); // bool let all_pos = nums.iter().all(|&x| x > 0); // bool let first_big = nums.iter().find(|&&x| x > 3); // Option<&i32> // 状态机 let running_sum: Vec<i32> = nums.iter() .scan(0, |acc, &x| { *acc += x; Some(*acc) }) .collect(); // [1, 3, 6, 10, 15] }
collect() 的多形态
collect() 是泛型,目标容器靠 turbofish 或类型注解决定:
let v: Vec<i32> = iter.collect();
let s: HashSet<i32> = iter.collect();
let m: HashMap<String, i32> = iter.collect(); // 需要 iter 元素是 (K, V) tuple
let s: String = chars_iter.collect();
let r: Result<Vec<i32>, Error> = iter_of_results.collect(); // 神奇的 Result collect
最后一个最妙:Iterator<Item = Result<T, E>> 可以 collect 成 Result<Vec<T>, E>,遇到第一个 Err 就停止。
10.3 Iterator 是零成本抽象的证据
#![allow(unused)] fn main() { pub fn sum_squares(v: &[i32]) -> i32 { v.iter().map(|x| x * x).sum() } }
cargo asm sum_squares --rust 或 godbolt 看汇编:出来是 ~10 条循环指令,跟手写 for 循环完全一样。没有 Box<dyn Fn>,没有虚表查找,没有 Vec 临时分配。
为什么?
每个 adapter(map, filter)是一个泛型 struct,它的 next() 是 inline 的。链 N 层 adapter,编译器看到的是一个 N 层嵌套的结构体,inline 完了等价于一个 for 循环。
Go / C# 类比
| 语言 | "filter + map + reduce" 链 |
|---|---|
| Go | 三个独立 for 循环,中间产生中间 slice |
| C# (LINQ) | 委托调用 + 装箱(IEnumerable |
| Rust | 一个 for 循环,无中间分配 |
这就是 Rust 写出现代风格代码同时不输 C 性能的底气。
10.4 Fn / FnMut / FnOnce:三种 closure trait
闭包不是一种类型,而是一组类型(每个闭包是匿名 struct)。这三个 trait 描述"能调用几次、能不能修改捕获、能不能消耗捕获"。
| trait | 调用签名 | 含义 |
|---|---|---|
Fn | &self | 调用任意次,不修改捕获 |
FnMut | &mut self | 调用任意次,会修改捕获 |
FnOnce | self | 只能调一次(消耗捕获) |
包含关系:Fn: FnMut: FnOnce(实现 Fn 自动实现 FnMut 和 FnOnce)。
实例
fn main() { let s = String::from("hello"); // Fn:只读引用 let print = || println!("{}", s); print(); print(); // 可以反复调 // FnMut:可变引用 let mut v = vec![1, 2, 3]; let mut push = |x| v.push(x); push(4); push(5); // FnOnce:拿走所有权 let take = move || { let _owned = s; }; // s 被 move 进闭包 take(); // take(); // ❌ 第二次调编译错,s 已经被消耗 }
编译器怎么决定
编译器看闭包对捕获变量的使用方式,选择最弱(最宽松)的 trait:
- 只读访问 → 实现
Fn+FnMut+FnOnce - 修改 → 实现
FnMut+FnOnce - move 走 → 实现
FnOnce
收 closure 的函数签名
fn run_once<F: FnOnce()>(f: F) { f(); }
fn run_repeated<F: FnMut()>(mut f: F) { f(); f(); }
fn run_concurrent<F: Fn() + Sync>(f: F) { /* 多线程调 */ }
API 设计:接受最宽松的 trait。如果只调一次,写 FnOnce,这样调用方可以传 move closure;如果要调多次,写 FnMut;只有需要多线程同时调时才写 Fn + Sync。
10.5 move closure 与所有权
#![allow(unused)] fn main() { let s = String::from("hello"); let f = move || println!("{}", s); // s 已经 move 进闭包,这里不能再用 s f(); }
move 关键字强制闭包获取捕获变量的所有权。常见场景:
-
把闭包送到另一个线程(必须 move,因为线程之间所有权要清晰)
#![allow(unused)] fn main() { use std::thread; let v = vec![1, 2, 3]; thread::spawn(move || println!("{:?}", v)); // 必须 move } -
闭包逃出局部作用域(返回 closure / 存到 struct)
fn make_adder(x: i32) -> impl Fn(i32) -> i32 { move |y| x + y // 必须 move,否则 x 出函数就 drop } -
async block 进 spawn
let data = expensive_computation(); tokio::spawn(async move { process(data).await; // 必须 move,async block 要拥有 data });
move 不改变闭包实现哪个 trait,只改变捕获方式(by ref → by value)。
10.6 实战:用 iterator 链替代命令式循环
Go 风格
var result []User
for _, u := range users {
if u.Active && u.Age >= 18 {
result = append(result, User{
ID: u.ID,
Name: strings.ToUpper(u.Name),
})
}
}
Rust 直译
let mut result: Vec<User> = Vec::new();
for u in &users {
if u.active && u.age >= 18 {
result.push(User { id: u.id, name: u.name.to_uppercase() });
}
}
Rust 地道写法
let result: Vec<User> = users.iter()
.filter(|u| u.active && u.age >= 18)
.map(|u| User { id: u.id, name: u.name.to_uppercase() })
.collect();
短一半,意图更清晰,性能完全一样(可能因为 collect 能预分配反而更快)。
但不要强迫症地链式
复杂逻辑、需要中间变量、需要日志 —— for 循环可读性更好。iterator chain 的优势是简单变换,复杂逻辑 for 循环不丢人。
习题
- 把你手头一段 Go 的 "filter + transform + reduce" 代码翻成 Rust iterator chain。对比可读性。
- 实现
struct Fib,impl Iterator,产生 Fibonacci 数列。用take(10)取前 10 个。 - 写一个函数
fn group_by<I, K, F>(iter: I, f: F) -> HashMap<K, Vec<I::Item>>,签名应该怎么写? - 用
flat_map把Vec<Vec<i32>>摊平,跟into_iter().flatten()对比。 - 写一个 closure adapter:
fn retry<F, T, E>(f: F) -> Result<T, E> where F: FnMut() -> Result<T, E>,重试 3 次。讨论为什么用FnMut而不是Fn或FnOnce。
本章一句话总结
Iterator 是 Rust 把函数式抽象做成零成本的关键设计。掌握它,你能写出比 Go 更简洁同时同样快的代码——这是 Rust 给"GC 语言出身工程师"最直接的奖励。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 11 · 线程与 Send/Sync
Fearless concurrency 的真实含义
核心问题:Send 和 Sync 这两个 marker trait 是如何在编译期阻止 data race 的?
"Fearless concurrency" 是 Rust 最响亮的口号之一。它不是说"Rust 没有并发 bug"——你照样可能死锁、活锁、忘记 join。它说的是一类特定 bug:data race(数据竞争)——在编译期就被堵死,根本写不出来。
读完你应该能:
- 用
std::thread::spawn写出能编过的多线程代码,并解释为什么必须move - 看到
Send/Sync报错时能瞬间想到原因 - 在"共享内存"和"message passing"之间做工程选择
- 用
Arc<Mutex>、mpsc、crossbeam::scope三种风格分别解决同一问题 - 知道死锁/活锁还在,Rust 没免疫它们
11.1 std::thread::spawn 与 JoinHandle
use std::thread; fn main() { let handle = thread::spawn(|| { println!("hello from spawned"); }); handle.join().unwrap(); // 等待结束 }
spawn 接受 F: FnOnce() + Send + 'static,返回 JoinHandle<T>。
为什么必须 move
let v = vec![1, 2, 3];
thread::spawn(|| println!("{:?}", v)); // ❌
报错:closure may outlive the current function, but it borrows v。
v 在 main 的栈帧里,子线程可能比 main 活得更久(虽然这里不会,但编译器假定最坏)。解法:
#![allow(unused)] fn main() { let v = vec![1, 2, 3]; std::thread::spawn(move || println!("{:?}", v)); // ✅ }
move 把 v 的所有权转给子线程,生命周期问题消失。
跟 Go goroutine 对比
v := []int{1, 2, 3}
go func() { fmt.Println(v) }() // 隐式 capture,编译能过
Go 让你"看起来很方便",代价是:你共享了一个 slice header,主程 / goroutine 都可能修改,这是 data race 的温床。Rust 强制你显式 move,本质是让"所有权转移"显示出来。
多个线程读同一份不可变数据
#![allow(unused)] fn main() { use std::sync::Arc; use std::thread; let data = Arc::new(vec![1, 2, 3]); let mut handles = vec![]; for _ in 0..4 { let d = Arc::clone(&data); handles.push(thread::spawn(move || { println!("{:?}", d); })); } for h in handles { h.join().unwrap(); } }
Arc 是"多个所有者"的标准方案(见 Ch 8)。
11.2 Send:类型可以被转移到另一个线程
Send 是 marker trait(没有方法,只是标签),表示"这个类型的值能跨线程移动"。
绝大多数 Rust 类型自动实现 Send:i32、String、Vec<T>(只要 T: Send)等。
几种不是 Send 的类型
| 类型 | 为什么不是 Send |
|---|---|
Rc<T> | 引用计数不是原子的,跨线程修改是 data race |
*const T / *mut T | 裸指针,Rust 不知道你怎么用 |
RefCell<T> | 借用计数不是原子的(单线程才安全) |
Cell<T> | 同上 |
MutexGuard<'_, T> | 锁的 unlock 必须在 lock 的线程,跨线程释放是 UB |
报错示例
use std::rc::Rc;
use std::thread;
let a = Rc::new(1);
thread::spawn(move || println!("{}", a));
// error: `Rc<i32>` cannot be sent between threads safely
编译器直接堵在编译期。这是 Rust 跟 Go / C++ 的根本差别:那种 bug 在 Go 是 runtime data race,在 C++ 是 UB,在 Rust 是 type error。
11.3 Sync:类型的 &T 可以被多个线程共享
Sync 表示"&T 可以跨线程"。形式定义:T: Sync 当且仅当 &T: Send。
T: Sync→ 多个线程可以同时拿到&TT: Send→ 一个线程可以把T整体送给另一个线程
Send 与 Sync 的组合
| 类型 | Send | Sync | 说明 |
|---|---|---|---|
i32 | ✅ | ✅ | 平凡 |
&T (T: Sync) | ✅ | ✅ | 引用类型 |
Rc<T> | ❌ | ❌ | 单线程共享 |
Arc<T> | ✅ | ✅ | 多线程共享 |
RefCell<T> | ✅ | ❌ | 单线程能 send,但不能多线程同时借 |
Mutex<T> (T: Send) | ✅ | ✅ | 多线程共享可变 |
MutexGuard<'_, T> | ❌ | ✅(T: Sync) | guard 必须在原线程 drop |
*mut T | ❌ | ❌ | 裸指针默认 |
auto trait
Send / Sync 是 auto trait:编译器自动为"所有字段都 Send 的 struct" 推 Send,自动为"所有字段都 Sync 的 struct" 推 Sync。你几乎不用手写 impl Send for ...(手写要 unsafe,见 18 章)。
一个常见困惑
// 这能跨线程吗?
let s: String = "hi".into();
thread::spawn(move || println!("{}", s)); // ✅ String: Send
String 是 Send。Send 不要求"内部没有堆指针",只要求"整体所有权转移是安全的"。String 的堆指针随 String 一起 move 给新线程,旧线程没法访问,没有 race。
11.4 std::sync::mpsc 与 Arc<Mutex>
两种最常用的线程间通信模式。
message passing:mpsc
#![allow(unused)] fn main() { use std::sync::mpsc; use std::thread; let (tx, rx) = mpsc::channel(); for i in 0..3 { let tx = tx.clone(); thread::spawn(move || tx.send(i).unwrap()); } drop(tx); // 关闭原始 sender for msg in rx { println!("{}", msg); } }
mpsc= multi-producer, single-consumertx.clone()创建额外的 sender,共享同一 channel- 所有 sender drop 后,for 循环结束(
recv返回 Err)
shared state:Arc<Mutex>
#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; use std::thread; let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let c = Arc::clone(&counter); handles.push(thread::spawn(move || { let mut n = c.lock().unwrap(); *n += 1; })); } for h in handles { h.join().unwrap(); } println!("{}", *counter.lock().unwrap()); // 10 }
选择标准
| 场景 | 偏好 |
|---|---|
| 生产者-消费者 / 工作分发 | mpsc |
| pipeline / dataflow | mpsc |
| 共享配置 / 共享状态 | Arc |
| 共享计数器 | Arc |
| 共享大 map | Arc<Mutex |
11.5 crossbeam 的 scoped threads
std::thread::spawn 要求 'static,所以你不能借栈数据。crossbeam(以及 1.63 后的 std::thread::scope)解决了这个:
#![allow(unused)] fn main() { use std::thread; let data = vec![1, 2, 3, 4, 5]; thread::scope(|s| { for chunk in data.chunks(2) { s.spawn(move || { let sum: i32 = chunk.iter().sum(); println!("chunk sum = {}", sum); }); } }); // scope 结束前所有 spawn 出去的都被 join }
scope保证所有子线程在 scope 退出前 join- 因此可以借用栈上数据(编译器知道借用不会逃出 scope)
- 完美适合"切片并行处理"这类 fork-join 模式
11.6 共享内存 vs message passing
Go 名言:"Don't communicate by sharing memory; share memory by communicating."(用通信共享内存,不要用共享内存通信。)
Rust 哲学:两种都给你,自己选。区别是:
| 维度 | shared memory (Arc<Mutex>) | message passing (mpsc) |
|---|---|---|
| 学习曲线 | 直观,但小心 deadlock | 模型清晰,但需要重排代码 |
| 性能 | 锁的临界区小则快 | 通道有内存分配,适中 |
| 调试 | 跨线程状态难看清 | 消息流可被序列化打印 |
| 适用 | 共享配置、计数器、大缓存 | 流水线、任务分发、actor 风格 |
我的经验:默认 message passing,只有"明显是共享 state(配置、计数、缓存)"的时候才上 Arc<Mutex>。
11.7 实战:并发目录扫描器
任务:递归扫描一个目录,统计所有 .rs 文件的总行数。
朴素串行
fn count_lines(path: &Path) -> usize {
let mut total = 0;
for entry in std::fs::read_dir(path).unwrap().flatten() {
let p = entry.path();
if p.is_dir() { total += count_lines(&p); }
else if p.extension() == Some("rs".as_ref()) {
total += std::fs::read_to_string(&p).unwrap().lines().count();
}
}
total
}
rayon 一键并行
use rayon::prelude::*;
use walkdir::WalkDir;
fn count_lines(root: &Path) -> usize {
WalkDir::new(root).into_iter().filter_map(Result::ok)
.filter(|e| e.path().extension() == Some("rs".as_ref()))
.par_bridge() // ← rayon 把串行 iterator 变并行
.map(|e| std::fs::read_to_string(e.path()).unwrap().lines().count())
.sum()
}
rayon 自动 work-stealing,4 核机器上几乎是 4x 加速。生产里 90% 的"并行计算"任务都该先试 rayon——把 .iter() 换 .par_iter() 就完事。
自己写 work-stealing
如果你想理解原理,Tokio / rayon 内部用的是:
- 全局 / per-thread 任务队列(
crossbeam::deque) - 每个 worker 从自己队列 pop,空了就从别人队列 steal
- tasks 不可分割时就直接执行;可分割就 split + push
这是 Ch 20 mini-tokio 的核心结构。
习题
- 不用 rayon,自己用
thread::scope+mpsc写一个并行map:fn par_map<T, U>(items: Vec<T>, f: impl Fn(T) -> U + Sync) -> Vec<U>。 - 写一个程序故意构造死锁(两线程 + 两锁,反向加锁顺序)。再用
parking_lot::deadlock检测器报告。 - 把
Rc<RefCell<Node>>的双向链表改成Arc<Mutex<Node>>版本,在多线程并发 push 测试。讨论锁粒度。 - 解释:为什么
MutexGuard不是 Send,但是 Sync? - 设计一个 worker pool:N 个 worker 从同一个 task 队列拿任务执行,主线程提交任务并等待全部完成。给出实现。
本章一句话总结
Send + Sync 是 Rust 敢叫 'fearless concurrency' 的根本原因——data race 这一整类 bug 在编译期就被堵死了。死锁还在,逻辑还要写对——但你至少不会用错共享。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 12 · async/await 基础:Future、Pin、Waker
Rust 异步模型的内部机制
核心问题:Rust 的 Future 跟 C# 的 Task 哪里不一样?为什么需要 Pin?
C# 工程师写 async/await 写了十年,几乎从不考虑 Task 内部如何调度。这套抽象在 C# 工作得好,代价是有一个 .NET runtime。Rust 的设计哲学是"零成本 + 无强制 runtime",所以它的 async 比 C# 复杂得多——但理解之后,你能解释 Tokio、能写自定义 executor、能调试看似魔法的 async bug。
读完你应该能:
- 写出
Futuretrait 的签名,解释Poll::Ready和Poll::Pending各自含义 - 描述编译器把
async fn翻译成什么(状态机) - 解释 Rust Future 为什么是 lazy 的,以及它跟 C# Task 的核心差异
- 看到 Pin 不再发慌,知道它解决的是什么真问题
- 写一个 50 行的
block_on跑通自己的 Future
12.1 Future trait 定义
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> { Ready(T), Pending }
契约
poll 是 Future 唯一的方法。它的含义是:"试着推进一下,看看完成了没"。
- 返回
Ready(value):计算完成,这是结果 - 返回
Pending:还没完,但Future 已经记录了 Waker,等它能继续推进时会通知 executor
executor 不会忙等地反复 poll —— 它只在 Future 自己说"现在可以了"(通过 Waker.wake())时才再次 poll。这是整个 async 模型的核心。
跟 C# Task 对比
| 维度 | C# Task | Rust Future |
|---|---|---|
| 是否立即开始 | 是(hot:创建即调度) | 否(lazy:await 才推进) |
| 谁推进 | TaskScheduler(默认全局 ThreadPool) | 用户选择的 executor |
| 谁分配 | runtime(堆上 Task object) | 编译器(栈上 / 调用方分配状态机) |
| 取消 | CancellationToken,协作式 | Drop Future 即取消(更彻底) |
| 引用语义 | reference type,可多次 await | value type,move-only |
最大差别:Rust Future 是 lazy value,不创建运行时副作用。
12.2 async fn 是编译器生成的状态机
你写:
async fn fetch_two(url1: &str, url2: &str) -> (String, String) {
let a = fetch(url1).await;
let b = fetch(url2).await;
(a, b)
}
编译器翻译成等价(简化)的状态机:
enum FetchTwo<'a> {
Start { url1: &'a str, url2: &'a str },
AwaitingA { f: FetchFuture, url2: &'a str },
AwaitingB { a: String, f: FetchFuture },
Done,
}
impl Future for FetchTwo<'_> {
type Output = (String, String);
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
loop {
match *self {
Self::Start { url1, url2 } => {
let f = fetch(url1);
*self = Self::AwaitingA { f, url2 };
}
Self::AwaitingA { ref mut f, url2 } => match Pin::new(f).poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(a) => {
let f2 = fetch(url2);
*self = Self::AwaitingB { a, f: f2 };
}
},
Self::AwaitingB { ref a, ref mut f } => match Pin::new(f).poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(b) => {
let out = (std::mem::take(a), b);
*self = Self::Done;
return Poll::Ready(out);
}
},
Self::Done => panic!("polled after completion"),
}
}
}
}
每个 .await 是状态机的一个状态。poll 时:
- 看自己在哪个状态
- 推进:调对应子 Future 的
poll - 子 Future Pending → 自己也 Pending,记 Waker
- 子 Future Ready → 转下一个状态,继续 loop
用 cargo expand 真看一眼
cargo install cargo-expand
cargo expand --bin myapp | less
会看到编译器生成的状态机原貌。第一次看会被吓到,但你只需要看一次就能理解 "async fn 不是魔法,是 syntactic sugar"。
12.3 为什么 Rust Future 是 lazy 的
async fn do_work() {
println!("starting");
// ...
}
fn main() {
let f = do_work(); // ← 这里什么都没发生!不会打印 "starting"
// f 是 Future 值,放在那
}
Rust 选择 lazy 是因为:
- 零运行时:不需要"创建即调度",所以不需要全局调度器。你完全可以写没有 async runtime 的 Rust async 代码(虽然没法 await,但可以 build)
- 取消即 drop:不需要 cancellation token,你直接把 Future 丢掉就停了
- 组合性:lazy Future 可以被
select!/join!/timeout等组合,而不会"已经开始执行"
代价是初学者的迷思:"我的 async fn 没运行!" —— 因为你没 await,也没 spawn。
C# 风格的 hot Task
如果你想要 C# 那种"创建即开始",写:
let handle = tokio::spawn(do_work()); // ← spawn 之后立刻开始
// handle 是 JoinHandle,可以 await 等结果
spawn 把 Future 交给 runtime,runtime 立刻开始 poll。JoinHandle 自己也是 Future,await 它等任务完成。
12.4 .await 的解糖
let x = some_future.await;
约等于(伪代码):
let mut f = some_future;
loop {
match Future::poll(Pin::new(&mut f), &mut cx) {
Poll::Ready(v) => break v,
Poll::Pending => yield, // 把控制权让回 executor,等被唤醒再继续
}
}
yield 不是真关键字——编译器把这个 loop 编入它生成的状态机,实际上是 return Poll::Pending。
Waker 在哪儿注册
poll(cx) 的 cx: &mut Context<'_> 里携带 &Waker。Future 内部需要"等某件事发生"时,把 Waker 存起来,事件发生时调 waker.wake()。
例如 tokio::time::sleep:
- 第一次 poll:计算 deadline,把 (deadline, waker.clone()) 注册到 timer wheel,返回 Pending
- timer 到点:timer 线程取出 waker,
wake() - executor 重新 poll 该 Future:
Ready(())
12.5 Pin 解决的真问题
self-referential struct
考虑编译器生成的状态机:
enum Stateful {
Start,
Reading { buf: [u8; 1024], ref_into_buf: *const u8 }, // 内部自引用!
Done,
}
ref_into_buf 指向同一个结构体内的 buf。如果你 move 这个 struct,buf 在新地址,ref_into_buf 还指向旧地址 → dangling pointer。
这种 self-referential struct 在 async fn 里普遍出现——你 await 一个借用栈上 buffer 的子 Future,编译器生成的状态机就会自引用。
Pin 的设计
Pin<P>(P 是某种指针)的契约:被 Pin 起来后,值的内存地址不再变。
Pin<&mut T>给你&mut T,但你不能用它来mem::swap/mem::replace(会移动)- 一旦 Pin,直到 drop,值都待在原地
只有"地址不变"才让 self-referential struct 内部的指针保持有效。Future::poll 的签名是 self: Pin<&mut Self> 而不是 &mut self,就是因为状态机可能自引用。
Unpin:大部分类型不需要被 Pin
Unpin 是 auto trait:大部分类型实现它,意思是"我没有 self-reference,move 我没事"。u32 / String / 普通 struct 都是 Unpin。
只有"编译器生成的 async 状态机"以及一些手写 unsafe 类型(PhantomPinned 标记)不是 Unpin,需要小心处理。
业务代码里你看到的 Pin
fn do_thing<F: Future + Unpin>(mut f: F) {
let pin = Pin::new(&mut f); // 因为 F: Unpin,可以这样
}
// 大部分时候你这样
async fn outer() {
let f = inner();
f.await; // 编译器自动给你 pin,你完全不用想
}
90% 的应用代码不需要直接接触 Pin。只有写库、写 executor、写自定义 stream 时才需要。
12.6 Waker 与 Context
pub struct Context<'a> { /* ... */ }
impl<'a> Context<'a> { pub fn waker(&self) -> &'a Waker { /* ... */ } }
pub struct Waker { /* ... */ }
impl Waker {
pub fn wake(self); // 消耗 self
pub fn wake_by_ref(&self);
pub fn clone(&self) -> Waker; // 实现 Clone
}
Waker 是什么
Waker 内部是 RawWaker —— 一个 vtable + data 的组合,把"通知 executor 重新 poll" 的实现交给 executor。Tokio 的 Waker 会把当前任务标记 ready,放回任务队列。
谁负责调 wake
等异步事件的那一方——比如:
- timer:到点后唤醒
- IO source:fd ready 时唤醒
- channel:有消息时唤醒发送侧 Waker
如果你写自己的 Future,要在收到事件时主动调 waker.wake()。忘记 wake = Future 永远 pending = 任务僵死。这是写底层 async 库最常见的 bug。
12.7 手撸最简 executor
50 行跑通 block_on(单 Future,无 spawn):
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Wake, Waker};
use std::thread;
struct Park(Mutex<bool>, std::sync::Condvar);
impl Wake for Park {
fn wake(self: Arc<Self>) {
*self.0.lock().unwrap() = true;
self.1.notify_one();
}
}
fn block_on<F: Future>(future: F) -> F::Output {
let park = Arc::new(Park(Mutex::new(false), std::sync::Condvar::new()));
let waker = Waker::from(park.clone());
let mut cx = Context::from_waker(&waker);
// 用 Box::pin 在堆上 pin Future
let mut future = Box::pin(future);
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(v) => return v,
Poll::Pending => {
let mut woken = park.0.lock().unwrap();
while !*woken {
woken = park.1.wait(woken).unwrap();
}
*woken = false;
}
}
}
}
fn main() {
let result = block_on(async {
// 任何不依赖 IO 的 async 代码都能跑
1 + 1
});
println!("{}", result);
}
支持 spawn / timer / IO 的 executor 还要几百行,Ch 20 一步步搭。
12.8 为什么 Rust 选择"无运行时 by default"
C# / Go / Node.js 都内置 async runtime——你写 async 代码就有一个调度器在跑。Rust 没有,你必须显式选 Tokio / async-std / smol。
设计动机:
- 嵌入式:无 std、无 alloc 的环境也要能写 async(WASM 部分场景)
- 多种 runtime 共存:Tokio 适合服务器,smol 适合 CLI,自定义 executor 适合特殊场景
- 零成本一致性:运行时是"你需要时才付费"
代价是生态分裂:不同 runtime 的 IO 类型不兼容(tokio::TcpStream ≠ async_std::TcpStream)。社区在用 async-io / Trait 抽象慢慢解决,但目前最实用的选择就是 all-in Tokio。
习题
- 用
cargo expand看async fn ten() -> i32 { 10 }编译后是什么。 - 实现一个
YieldFuture:第一次 poll 返回 Pending 并唤醒自己,第二次返回Ready(())。理解为什么这样做相当于"主动让出一次 CPU"。 - 改造 12.7 的
block_on加一个简单的spawn:把任务推到一个Vec<Pin<Box<dyn Future>>>,主循环 poll 所有任务。 - 写一个
SleepFuture:用std::thread起一个 timer 线程,到点 wake。在 12.7 的 executor 上验证。 - 给一个写错的 Future 例子(漏调
waker.wake_by_ref),解释为什么任务僵死。
本章一句话总结
Rust async 不是 magic——它是编译器把 async fn 翻译成状态机,加上一组接口契约(Future / Waker / Context / Pin)。理解这个,Tokio 内部就不再神秘。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 13 · Tokio 生产实战
从能跑到能上生产
核心问题:Tokio 的 scheduler 和 driver 是怎么工作的?生产中的常见陷阱有哪些?
Tokio 是 Rust 异步生态的事实标准。axum、tower、reqwest、sqlx、tonic 全跑在它上面。这一章不教你"hello tokio"——那只是 tokio::main + await——而是教你生产里会踩的坑。
读完你应该能:
- 画出 Tokio runtime 的组件图(scheduler / IO driver / timer / blocking pool)
- 区分
spawn和spawn_blocking,知道把 CPU-bound 任务扔进spawn会怎样 - 用
select!写 cancellation,知道什么叫 cancel safety - 解释 async 上下文里
std::thread::sleep是灾难 - 用 tokio-console 调一个 hang 住的服务
13.1 Tokio runtime 架构
Tokio runtime 由三块组成:
┌────────────────────────────────────────┐
│ Tokio Runtime │
│ ┌────────────┐ ┌──────────────────┐ │
│ │ Scheduler │ │ IO / Timer Driver│ │
│ │ (N workers)│ │ (mio/epoll/kq) │ │
│ └────────────┘ └──────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ Task Queue(work-stealing) │ │
│ └──────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────┐ │
│ │ Blocking Thread Pool (max 512) │ │
│ └────────────────────────────────────┘ │
└────────────────────────────────────────┘
- Scheduler:N 个 worker thread(默认 CPU 核数),work-stealing 调度
- IO driver:基于 mio(对 Linux epoll、macOS kqueue、Windows IOCP 的抽象),处理 socket / pipe 等异步事件
- Timer driver:hierarchical timer wheel,处理
sleep/timeout - Blocking pool:专门给
spawn_blocking用的线程池(默认上限 512)
单线程 vs 多线程 runtime
#[tokio::main] // 多线程,N=CPU 核数
#[tokio::main(flavor = "current_thread")] // 单线程
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
- 单线程:吞吐低但所有 await 之间状态不需要 Send,适合本地工具 / CLI
- 多线程:吞吐高,async fn 状态必须 Send
13.2 tokio::spawn vs spawn_blocking
灾难性反例
#[tokio::main]
async fn main() {
tokio::spawn(async {
// CPU 密集任务,不 await 任何东西
for _ in 0..1_000_000_000 { /* busy compute */ }
});
// 多线程 runtime:占住一个 worker
// 单线程 runtime:整个 runtime 卡死
}
tokio::spawn 假设你写的是"短期跑、频繁 await"的任务。CPU 密集会饿死别的任务。
正确做法
let result = tokio::task::spawn_blocking(|| {
// 这里跑在 blocking pool,不影响 worker
expensive_computation()
}).await?;
经验法则:任何 await 之间会跑超过 ~100 µs 的 CPU 工作都该用 spawn_blocking(或同步等价的 rayon 并发)。
实战中的坑
调用看似纯 CPU 但其实有 IO 的 sync 库(比如某些 ORM、加密库)——一律 spawn_blocking。
13.3 tokio::sync 全家桶
| 类型 | 用途 | 容量 |
|---|---|---|
mpsc | 多生产单消费 | 有界(背压) / 无界(危险) |
oneshot | 一次性 reply | 1 |
broadcast | 广播,所有订阅者收到 | 有界 |
watch | 最新值,迟到者只看到最新 | 1(覆盖) |
Notify | 信号量,无数据 | - |
Mutex | async-aware 互斥锁 | - |
RwLock | async-aware 读写锁 | - |
Semaphore | 限流 / 并发上限 | N |
关键差异:tokio::sync::Mutex vs std::sync::Mutex
短临界区永远用 std::sync::Mutex:
// 推荐:短锁,std 即可
let v = state.lock().unwrap();
do_something_quick(&v);
drop(v);
// 必须用 tokio::sync::Mutex 的场景:锁要 await 时还在持有
let mut guard = state.lock().await;
guard.fetch_remote().await; // ← 持锁 await
std::sync::Mutex 是操作系统级互斥锁,持有时 await 等于 OS 线程被 park,worker 浪费。tokio::sync::Mutex 的 guard 是 async-aware 的,跨 await 不阻塞 worker。
经验:默认 std,跨 await 时换 tokio。
oneshot 用法
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let result = compute().await;
let _ = tx.send(result);
});
match rx.await {
Ok(v) => println!("{}", v),
Err(_) => println!("sender dropped"),
}
13.4 select! 宏与 cancellation
use tokio::time::{sleep, Duration};
tokio::select! {
res = fetch_data() => println!("got {:?}", res),
_ = sleep(Duration::from_secs(3)) => println!("timeout"),
}
select!同时 poll 多个 Future,第一个 ready 的胜出- 其他 Future 被 drop(取消)
Cancel safety
let mut chunks = Vec::new();
loop {
tokio::select! {
c = stream.next() => chunks.push(c?),
_ = signal::ctrl_c() => break,
}
}
stream.next() 在没拿到东西时被取消会怎样?如果 stream 实现里"已经从 buffer 拿了字节但还没返回",这个数据丢失——这就是 cancel-unsafe。
社区做法:在文档里标注每个 async API 是否 cancel-safe。调用前看文档。
| API | cancel safe |
|---|---|
mpsc::Receiver::recv | ✅ |
TcpStream::read | ❌(可能丢数据) |
Mutex::lock | ✅ |
oneshot::Receiver::await | ✅ |
time::sleep | ✅ |
不确定时,用 select! 包裹"原子查询" + biased 模式 + 显式 buffer。
13.5 Drop 在 async 里的陷阱
struct DbConn { /* ... */ }
impl Drop for DbConn {
fn drop(&mut self) {
// 想在这里发起 async cleanup —— 做不到!Drop 是 sync 函数
// self.send_close_message().await; // ❌
}
}
Drop 是 sync 函数,不能 await。这意味着:
- 异步连接关闭、异步资源释放,只能用显式
close()方法 - 或者:在 Drop 里
tokio::spawn一个 task(代价:可能在 runtime shutdown 时 spawn 失败)
业内事实标准:显式 cleanup API + tracing 警告 "did you forget to close?"。
13.6 tokio::time::sleep vs std::thread::sleep
async fn bad() {
std::thread::sleep(Duration::from_secs(1)); // ❌ 灾难
}
std::thread::sleep 阻塞当前 OS 线程——也就是阻塞一个 worker。10 个并发任务 + 8 worker 多线程 runtime → 整个 runtime 几乎卡死 1 秒。
async fn good() {
tokio::time::sleep(Duration::from_secs(1)).await; // ✅
}
所有 std 阻塞 API 在 async 上下文都是禁区:std::thread::sleep / std::sync::Mutex::lock(长时间)/ std::net::TcpStream::read / std::fs::read(大文件)/ std::io::stdin().read_line 等等。
经验:async 函数里只用 tokio::* 或 async_* 系列 API。要用同步库,套 spawn_blocking。
13.7 Structured concurrency
tokio::spawn 起来的任务在你不 await JoinHandle 时孤儿运行——你失去对它们的把控。
JoinSet:批量管理
let mut set = tokio::task::JoinSet::new();
for url in urls {
set.spawn(fetch(url));
}
while let Some(res) = set.join_next().await {
println!("{:?}", res);
}
// set drop 时未完成的任务自动被 abort
TaskTracker:跨模块管控
tokio_util::task::TaskTracker 提供"spawn 时登记,shutdown 时等齐"的模式。优雅关闭(graceful shutdown)的标准做法。
经验
避免 tokio::spawn 出去之后不管——除非你真的不在乎它什么时候完成。常态是用 JoinHandle::await 或 JoinSet 收尾。
13.8 性能调优
worker thread 数量
- 默认 = CPU 核数
- IO 密集且阻塞操作多:可适当增加(
worker_threads(N*2)) - CPU 密集为主:别多开,会增加 context switch
task 粒度
- 太细:调度开销 > 工作量(每个 task 也得做状态机切换)
- 太粗:个别 task 卡住,其他无法插入
- 经验:每个 task 大约对应一个"独立可取消的工作单元"(一个连接 / 一个请求 / 一个 job)
主动让出
长循环里 await 一下,给 scheduler 机会:
for chunk in big_data.chunks(1024) {
process(chunk);
tokio::task::yield_now().await;
}
CPU pinning / NUMA
生产高吞吐场景考虑 tokio::runtime::Builder 的 on_thread_start 做 CPU affinity。多 socket NUMA 机器尤其重要。
13.9 tokio-console 实战调试
[dependencies]
tokio = { version = "1", features = ["full", "tracing"] }
console-subscriber = "0.4"
#[tokio::main]
async fn main() {
console_subscriber::init();
// ...
}
然后跑:
RUSTFLAGS="--cfg tokio_unstable" cargo run
# 另起终端
tokio-console
看到的内容:
- 每个 task 的状态(running / idle / waiting on...)
- 持续 idle 太久的 task(可能僵死)
- 长期 busy 的 task(可能 CPU 密集没 yield)
- 锁 / channel 的等待时间分布
调"服务突然 hang 住" / "某个请求慢" 时一秒钟定位。生产先验把它接入。
习题
- 写一个 HTTP 服务,在 handler 里
std::thread::sleep(2s),wrk 压测。再换成tokio::time::sleep,对比 QPS。 - 用
select!+signal::ctrl_c()实现优雅关闭:收到 Ctrl-C 后停止接收新连接,等已有连接处理完。 - 给一个 cancel-unsafe 的 demo:用
select!包TcpStream::read,观察被取消时数据丢失。改成 cancel-safe 版本。 - 跑通 tokio-console,人为制造一个"忘了 yield"的 CPU 密集 task,看 console 里它的状态。
- 用
Semaphore给一个并发 HTTP 客户端加 "最多同时 100 个 in-flight" 的限流。
本章一句话总结
Tokio 是 Rust 生产 async 的事实标准。掌握它的内部不是炫技,是上生产之前的必修课——大部分 async 事故都来自"不知道 Tokio 内部怎么工作"。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 14 · Cargo、Workspace、依赖管理
大型 Rust 项目的工程化
核心问题:大型 Rust 项目怎么组织?跟 Go module / pnpm workspace 有什么异同?
Cargo 在所有"语言官方包管理器"里被公认最好用——比 npm、go mod、pip / poetry 都设计得更严肃。这章不是 cargo 命令罗列,是讲生产级项目怎么用 Cargo。
读完你应该能:
- 设计一个多 crate workspace
- 用 feature flag 做"编译期可配置"而不踩 mutual-exclusive 的坑
- 区分
[dependencies]/[dev-dependencies]/[build-dependencies] - 把编译时间从 10 分钟降到 2 分钟
- 发一个 crate 到 crates.io
14.1 Cargo.toml 全字段解读
[package]
name = "my-service"
version = "0.3.1"
edition = "2021" # 2015 / 2018 / 2021 / 2024
rust-version = "1.78" # MSRV(最低支持版本)
authors = ["Alice <a@x.com>"]
description = "An example service"
license = "MIT OR Apache-2.0" # SPDX
readme = "README.md"
repository = "https://github.com/x/y"
keywords = ["http", "service"] # 最多 5 个,crates.io 用
categories = ["web-programming"]
[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
my-shared = { path = "../shared" } # 同 workspace
private-lib = { git = "ssh://git@host/x.git", branch = "main" }
[dev-dependencies]
tokio-test = "0.4"
insta = "1"
[build-dependencies]
cc = "1"
[features]
default = ["pg"]
pg = ["dep:sqlx-postgres"]
mysql = ["dep:sqlx-mysql"]
[profile.release]
lto = "thin" # link-time optimization
codegen-units = 1 # 全局优化(慢但快)
strip = "symbols"
panic = "abort"
[profile.dev]
opt-level = 1 # dev build 加速
[[bin]]
name = "server"
path = "src/bin/server.rs"
[[bench]]
name = "throughput"
harness = false
几个关键字段
edition:三年一版,改变语言行为(2021启用了let_else, disjoint capture in closures 等)。新项目直接2024(2024 末稳定后)rust-version(MSRV):申明你保证编译过的最低 rustc。库作者要谨慎,改动 MSRV 是 breaking changelicense = "MIT OR Apache-2.0":Rust 生态约定双协议;不写 license crates.io 不让发布
14.2 Workspace 与 monorepo
# 仓库根 Cargo.toml
[workspace]
members = [
"crates/core",
"crates/api",
"crates/cli",
]
resolver = "2"
[workspace.package]
version = "0.3.1"
edition = "2021"
license = "MIT"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
# crates/api/Cargo.toml
[package]
name = "api"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
tokio.workspace = true # 继承 workspace 里定义的版本
serde.workspace = true
core = { path = "../core" }
优势
- 多个 crate 共享一个
Cargo.lock(版本一致性强) - 编译缓存可复用
cargo build在根目录一次构建全部 crate
常见分层
my-service/
├── crates/
│ ├── core/ # 业务模型,纯逻辑,no_std?可以
│ ├── persistence/ # DB / cache 适配器
│ ├── api/ # HTTP / gRPC 入口
│ ├── cli/ # 管理命令
│ └── testkit/ # 测试公用,dev-only
└── Cargo.toml
跟 Go monorepo 类比:每个 crate ≈ 一个 Go package。跟 pnpm workspace 类比:每个 crate ≈ 一个 npm package,但依赖解析更严格(版本统一、特性合并)。
14.3 Feature flags 设计
[features]
default = ["http", "pg"]
http = ["dep:axum", "dep:tower"]
grpc = ["dep:tonic"]
pg = ["dep:sqlx-postgres"]
mysql = ["dep:sqlx-mysql"]
Additive 原则
Feature 必须是叠加式的:开 feature 只新增功能,不删除、不替换。原因:Cargo 会合并所有上层 crate 用到的 feature(union),你不能假定某个 feature 是关的。
反例:
[features]
small-int = [] # 想要的语义:类型变 i32
large-int = [] # 想要的语义:类型变 i64
# 如果 crate A 开 small-int,crate B 开 large-int,合并后两个都开,语义崩了
修正:用 trait / 类型抽象代替,或者拆成两个 crate。
跟 Go build tag 对比
| Go build tag | Rust feature | |
|---|---|---|
| 控制粒度 | 文件级 | 代码块级(#[cfg(feature)]) |
| 互斥/叠加 | 不限 | 强烈要求叠加 |
| 默认行为 | 显式标 | default feature |
何时该用 feature
- 给同一 crate 选择不同的可选依赖(pg / mysql / sqlite)
- 给可选功能做"按需编译"(
http/grpc) - 调试 / 性能 / 工具用途(
tracing,metrics)
何时不该用 feature(用 trait / 枚举更好):
- 运行时配置(env / config 决定)
- A/B 测试(不需要编译时分支)
14.4 dev-dependencies / build-dependencies 的边界
| 类别 | 何时编译 | 何时进入二进制 |
|---|---|---|
[dependencies] | 总是 | 是 |
[dev-dependencies] | 仅测试 / 示例 / 基准 | 否 |
[build-dependencies] | 仅 build.rs 时 | 否(但产物可能影响代码生成) |
典型 dev-deps
[dev-dependencies]
tokio-test = "0.4"
insta = "1"
proptest = "1"
criterion = "0.5"
mockall = "0.13"
典型 build-deps + build.rs
[build-dependencies]
cc = "1"
tonic-build = "0.12"
// build.rs
fn main() {
println!("cargo:rerun-if-changed=proto/foo.proto");
tonic_build::compile_protos("proto/foo.proto").unwrap();
}
陷阱:把测试用的 helper 放 [dependencies],导致生产二进制带上不该有的代码。审计时盯这个。
14.5 cargo 命令全家桶
# 编译 & 检查
cargo check # 不生成二进制,只过类型检查(快 3x)
cargo build # debug 编译
cargo build --release # release 编译
cargo build --profile=ci # 自定义 profile
# 运行
cargo run -p api -- --port 8080 # 跑 workspace 里的 api crate
# 测试
cargo test # 全部测试
cargo test --doc # 只 doc tests
cargo test -- --nocapture # 测试里 println! 真的打印
cargo test --release # 用 release build 跑(慢编译,快运行)
cargo nextest run # 第三方,并行更猛
# Lint & 格式
cargo fmt # rustfmt
cargo fmt --check # CI 用
cargo clippy # lint
cargo clippy -- -D warnings # 警告变错误
# 文档
cargo doc --open
cargo doc --no-deps # 只生成本 crate 的
# 基准
cargo bench
# 依赖
cargo update # 更新 Cargo.lock
cargo update -p tokio # 只更新一个
cargo tree # 看依赖树
cargo tree -d # 看重复依赖(版本冲突)
cargo outdated # 查可升级(第三方插件)
cargo udeps # 查未使用的依赖(第三方插件)
# 安装第三方
cargo install ripgrep
cargo install --git ... --branch ...
三个最该装的子命令
cargo install cargo-edit # cargo add / cargo rm / cargo upgrade
cargo install cargo-nextest # 更快的 test runner
cargo install cargo-expand # 看宏 / async 展开后什么样
14.6 依赖审计
cargo audit
cargo install cargo-audit
cargo audit
查 RustSec 数据库里的已知漏洞。CI 必跑。
cargo deny
# deny.toml
[bans]
multiple-versions = "warn"
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC"]
deny = ["GPL-3.0"]
[advisories]
vulnerability = "deny"
unmaintained = "warn"
cargo install cargo-deny
cargo deny check
cargo deny 比 audit 强:它管 license、版本冲突、unmaintained crate。任何对外发布的 Rust 产品都该接入。
supply chain
近两年供应链攻击逐步增多。生产配置:
- pin 主版本号,review 升级
- 关键依赖订 GitHub release notification
cargo vet标记 "我已 review 过这个版本"(Google / Mozilla 在用)
14.7 编译时间优化
Rust 编译慢是众所周知的痛。常用对策:
链接器换 lld 或 mold
# .cargo/config.toml
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
mold 把"链接"从 10s 压到 1s,debug build 体感巨大。
sccache:跨项目缓存
cargo install sccache
export RUSTC_WRAPPER=sccache
第一次编译完后,换 branch / 切项目都能复用编译产物。
cargo-chef:Docker 构建加速
FROM rust as planner
RUN cargo chef prepare --recipe-path recipe.json
FROM rust as cacher
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
# ...
把依赖编译跟源码编译分离,Docker 缓存命中率从 0% 到 95%。
split debuginfo
[profile.dev]
split-debuginfo = "unpacked" # macOS 默认,其他平台显式
debug = "line-tables-only" # 比完整 debuginfo 小,够调用栈用
隔离泛型(见 Ch 7)
让 generic 函数转发到非 generic 内部函数。每个 generic 函数实例化都增加编译时间。
14.8 发布到 crates.io 的流程
-
第一次准备
cargo login <token> # 从 crates.io 拿 token -
Cargo.toml 必填字段
name/version/description/license/repository/readmecategories(从 crates.io 列表选)/keywords
-
发布前 dry-run
cargo publish --dry-run cargo package --list # 看会上传哪些文件 -
正式发布
cargo publish -
Workspace 多 crate 发布
按依赖拓扑顺序逐个 publish,版本要先 bump:
(cd crates/core && cargo publish) sleep 10 # 等 crates.io index 更新 (cd crates/api && cargo publish) -
撤回
cargo yank --version 0.3.0把版本标记 yanked(不删除,但新项目不会拉)。删除需要给 crates.io 工作人员发邮件,很少批准。
维护 changelog
CHANGELOG.md 推荐 Keep a Changelog 格式。社区 ergonomic crates 都遵循。
习题
- 把你某个个人项目重构成 workspace 结构,把 "core 逻辑" / "HTTP" / "CLI" 拆三个 crate。
- 给一个 crate 加
pg/mysql两个互斥的 feature(故意写错),用cargo build --features pg,mysql看会发生什么。改造成 additive。 - 用
cargo bloat找出你二进制里占用最大的 crate,讨论能否裁掉它的 feature。 - 配
mold+sccache,对比cargo clean && cargo build的时间。 - 用
cargo deny加进 CI,故意引入一个 GPL 依赖,看 CI 怎么拦。
本章一句话总结
Cargo 是 Rust 工程化的基石。会用 Cargo 跟会写 Rust 同等重要——它是你跟整个生态、跟 CI、跟生产之间的胶水。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 15 · Web 服务:Axum + sqlx + tracing
现代 Rust 生产级 HTTP 服务栈
核心问题:怎么用 Rust 写一个媲美 Go gin / C# ASP.NET Core 的生产级 HTTP 服务?
2026 年的 Rust web 三件套:axum(框架,Tokio 团队出品)+ sqlx(数据库,编译期 SQL 检查)+ tracing(结构化日志 / 分布式追踪)。这章不教 hello world——那是文档活儿——而是讲生产里把这三个串起来的细节。
读完你应该能:
- 解释 axum 的
Handler/Extractor/IntoResponse三元组怎么协作 - 用
tower::Layer写中间件,知道 layer 的执行顺序 - 用 sqlx 的
query!宏在编译期校验 SQL,知道它的限制 - 用 tracing 输出结构化 JSON 日志并接入 OpenTelemetry
- 实现 graceful shutdown(等已有请求完成才退出)
15.1 Axum 设计哲学
axum 是 tower 生态的一部分。底层 abstraction 是 tower::Service:
pub trait Service<Request> {
type Response;
type Error;
type Future: Future<Output = Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;
fn call(&mut self, req: Request) -> Self::Future;
}
任何 "Request → Future
跟 Go / C# 类比
| Go (chi/gin) | C# (ASP.NET Core) | axum | |
|---|---|---|---|
| handler 形态 | func(w, r) | controller class method | 普通 async fn |
| middleware | Middleware(next) http.Handler | IMiddleware 或 delegate | tower::Layer<S> |
| DI | 手动或第三方 | 内置(constructor injection) | State<T> extractor |
| 类型安全 | 弱(string-based routes) | 强(model binding) | 强(参数类型 ↔ extractor) |
15.2 Handler / Extractor / IntoResponse 契约
Handler 是普通 async fn
async fn hello() -> &'static str { "hi" }
async fn user(Path(id): Path<u64>) -> Json<User> { /* ... */ }
async fn login(Json(body): Json<LoginReq>) -> Result<Json<Token>, AppError> { /* ... */ }
axum 通过泛型 magic 把"参数是 extractor"、"返回值是 IntoResponse"的 async fn 自动适配成 tower::Service。
Extractor:从请求里取东西
| Extractor | 取什么 |
|---|---|
Path<T> | URL path 参数 |
Query<T> | query string |
Json<T> | request body (JSON) |
Form<T> | request body (form-urlencoded) |
Bytes / String | 原始 body |
Headers | 请求头 |
State<T> | 共享应用状态 |
Extension<T> | 中间件注入的值 |
TypedHeader<...> | 强类型 header |
一个 handler 可以有多个 extractor
async fn handler(
Path(user_id): Path<u64>,
Query(q): Query<ListParams>,
State(state): State<AppState>,
Json(body): Json<UpdateReq>,
) -> Result<Json<User>, AppError> { /* ... */ }
最后一个非泛型 extractor 必须是 body(Json/Form/Bytes 等),因为 body 只能消耗一次。
IntoResponse:返回值如何变成 HTTP response
// 内置实现
impl IntoResponse for &'static str { /* 200 text/plain */ }
impl IntoResponse for String { ... }
impl<T: Serialize> IntoResponse for Json<T> { /* 200 application/json */ }
impl IntoResponse for StatusCode { ... }
impl IntoResponse for (StatusCode, String) { ... }
impl<T, E> IntoResponse for Result<T, E> where T: IntoResponse, E: IntoResponse { ... }
自定义错误类型:
enum AppError {
NotFound,
Db(sqlx::Error),
Validation(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, msg) = match self {
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("db: {e}")),
AppError::Validation(m) => (StatusCode::BAD_REQUEST, m),
};
(status, Json(json!({ "error": msg }))).into_response()
}
}
Result<T, AppError> 的 handler 就能 ? 错误自动转 HTTP response。
15.3 tower::Layer / tower::Service 中间件
use axum::{Router, middleware};
use tower::ServiceBuilder;
use tower_http::{trace::TraceLayer, cors::CorsLayer, timeout::TimeoutLayer};
let app = Router::new()
.route("/users/:id", get(get_user))
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::new(Duration::from_secs(10)))
.layer(CorsLayer::permissive()),
);
层的执行顺序
ServiceBuilder 的 layer 从上到下应用,但请求从外到内:
请求 → TraceLayer → TimeoutLayer → CorsLayer → handler
响应 ← TraceLayer ← TimeoutLayer ← CorsLayer ← handler
理解这个顺序很关键——TraceLayer 在最外层,它能看到完整的请求 / 响应时间,包括 Timeout 和 CORS 的处理。
自定义中间件
简单写法:
use axum::{middleware::Next, response::Response, http::Request};
async fn auth(req: Request, next: Next) -> Result<Response, StatusCode> {
let token = req.headers().get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
if !verify(token) { return Err(StatusCode::UNAUTHORIZED); }
Ok(next.run(req).await)
}
let app = Router::new().route("/x", get(handler))
.route_layer(middleware::from_fn(auth));
复杂的(需要状态、自己实现 Service trait)以后再写。
15.4 状态共享:State<Arc>
#[derive(Clone)]
struct AppState {
db: PgPool,
redis: redis::Client,
config: Arc<Config>,
}
#[tokio::main]
async fn main() {
let state = AppState {
db: PgPool::connect(&db_url).await.unwrap(),
redis: redis::Client::open(redis_url).unwrap(),
config: Arc::new(load_config()),
};
let app = Router::new()
.route("/users/:id", get(get_user))
.with_state(state);
}
async fn get_user(
Path(id): Path<i64>,
State(s): State<AppState>,
) -> Result<Json<User>, AppError> {
let u = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_one(&s.db).await?;
Ok(Json(u))
}
AppState 必须 Clone(PgPool 内部是 Arc,clone 廉价)。如果有不可 clone 的字段,包一层 Arc。
Extension vs State
State<T>是新版推荐,编译期检查类型存在Extension<T>是旧版,运行时查找,缺失 → 500。不推荐
15.5 sqlx 编译期检查
let user = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE id = $1",
user_id,
).fetch_one(&pool).await?;
query_as! 是过程宏,编译时连真实数据库校验:
- SQL 语法正确
- 表 / 字段存在
- 类型匹配(
id: i64对应 SQLbigint) - 返回结构匹配
User
配置
# .env(开发)
DATABASE_URL=postgres://localhost/myapp_dev
# 或离线模式(CI 用)
cargo sqlx prepare # 生成 .sqlx/ 缓存
git add .sqlx
# 之后构建无需 live DB,sqlx 用缓存
限制
- 动态 SQL(
SELECT * FROM table_decided_at_runtime)不行 → 改用sqlx::query(非宏,无编译期检查) - 复杂 join 返回结构可能推断失败 → 用
query_as!显式指定结构 - 涉及函数(
COALESCE(x, 0))的列,sqlx 不确定 nullable → 用x as "x!: i64"强制
跟 ORM 对比
| sqlx | diesel | sea-orm | Go (sqlx / gorm) | |
|---|---|---|---|---|
| 风格 | SQL-first,async | DSL,sync(async beta) | ORM,async | sqlx: SQL-first / gorm: ORM |
| 编译期检查 | ✅ | ✅(DSL层面) | ❌ | ❌ |
| 学习曲线 | 平 | 陡(DSL) | 中 | 平 / 平 |
经验:业务复杂、SQL 多变 → sqlx;模型固定、要 migration 自动生成 → diesel / sea-orm。
15.6 连接池配置
use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(5)
.acquire_timeout(Duration::from_secs(5))
.idle_timeout(Some(Duration::from_secs(300)))
.max_lifetime(Some(Duration::from_secs(3600)))
.connect(&db_url).await?;
池大小的工程经验
- 不要把 max_connections 调到 100+ —— Postgres 的连接是重资源(每个连接对应一个 server-side 进程,~10MB 内存)
- 公式经验:
max_connections ≈ (核数 × 2) + 磁盘数(参考 HikariCP 文档) - N 个服务实例 → 总连接数 = N × max_connections,DB 端
max_connections要够
Statement timeout 服务端配
ALTER ROLE myapp SET statement_timeout = '5s';
防止慢查询占住连接拖垮整个池。
15.7 tracing 结构化日志
use tracing::{info, error, instrument};
#[instrument(skip(pool), fields(user_id = %id))]
async fn get_user(pool: &PgPool, id: i64) -> Result<User, AppError> {
info!("fetching user");
let u = sqlx::query_as!(User, "...", id).fetch_one(pool).await?;
info!(name = %u.name, "got user");
Ok(u)
}
三个核心概念
- event:一次日志事件(
info!,warn!,error!) - span:有起止的逻辑作用域(一次 HTTP request、一次 DB query)
- subscriber:消费 event / span,决定怎么输出
设置 subscriber
use tracing_subscriber::{fmt, EnvFilter};
fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_target(true)
.with_thread_ids(true)
.json() // JSON 格式
.with_current_span(true)
.init();
RUST_LOG=info,sqlx=warn,my_service=debug cargo run
在 axum 里自动给每个请求一个 span
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/...", get(...))
.layer(TraceLayer::new_for_http());
每个请求会自动建一个 span,内部所有 log 自动带 request_id、method、path、status、duration。
15.8 OpenTelemetry 集成
opentelemetry = { version = "0.27", features = ["trace"] }
opentelemetry_sdk = { version = "0.27", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.27", features = ["tonic"] }
tracing-opentelemetry = "0.28"
use opentelemetry::trace::TracerProvider as _;
use tracing_subscriber::prelude::*;
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint("http://otel-collector:4317")
.build()?;
let provider = opentelemetry_sdk::trace::TracerProvider::builder()
.with_batch_exporter(exporter, opentelemetry_sdk::runtime::Tokio)
.build();
let tracer = provider.tracer("my-service");
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(tracing_opentelemetry::layer().with_tracer(tracer))
.with(EnvFilter::from_default_env())
.init();
之后所有 tracing span 自动导出到 OpenTelemetry collector,可以串到 Jaeger / Tempo / Datadog。
15.9 错误处理:从 Result 到 HTTP response
thiserror 定义错误类型 + 自己实现 IntoResponse(见 15.2)= 业务代码完全 ?,框架自动转 HTTP。
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("not found")]
NotFound,
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("validation: {0}")]
Validation(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
// 重点:日志 + 状态码
let status = match &self {
Self::NotFound => StatusCode::NOT_FOUND,
Self::Db(_) => {
tracing::error!(error = ?self, "db error");
StatusCode::INTERNAL_SERVER_ERROR
}
Self::Validation(_) => StatusCode::BAD_REQUEST,
};
(status, Json(json!({ "error": self.to_string() }))).into_response()
}
}
关键:5xx 错误记日志,4xx 不记(否则攻击者一通 bad request 把你日志刷满)。
15.10 优雅关闭(Graceful shutdown)
use tokio::signal;
async fn shutdown_signal() {
let ctrl_c = async { signal::ctrl_c().await.unwrap(); };
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.unwrap().recv().await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
tracing::info!("shutdown signal received");
}
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
行为:收到 SIGTERM / SIGINT 后:
- 停止 accept 新连接
- 等已有连接的请求处理完
- 超过最长等待时间则强杀(自己加 timeout)
K8s rolling update / SIGTERM handling 必备。
习题
- 写一个 REST 服务:用户增删改查 + JWT 鉴权 + sqlx + tracing + graceful shutdown。
- 用
wrk压测 axum hello world,跟 Go gin 对比 QPS。 - 故意写一个慢 handler(
tokio::time::sleep(60s)),触发 shutdown,看连接是否优雅关闭。 - 接 OpenTelemetry 到 Jaeger,在 Jaeger UI 里看一个跨服务调用 trace。
- 测试 sqlx 的离线模式:用
cargo sqlx prepare生成缓存,在没有 DB 的环境构建,看是否成功。
本章一句话总结
Axum + sqlx + tracing 是 2026 年写 Rust web 服务的事实组合。掌握这三个,你能上生产;写得地道,你能比 Go 同等服务 QPS 高 1.5-3 倍同时内存少一半。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 16 · 测试、基准、可观测性
生产级 Rust 的质量保证
核心问题:怎么给 Rust 项目建立完整的质量保障矩阵?
Rust 自带 cargo test,但生产工程里"测试"远不止跑一遍 unit test。这章讲完整矩阵:单元测试 → 集成测试 → property-based → snapshot → mock → benchmark → flamegraph → tokio-console → 内存分析。
读完你应该能:
- 给一个 crate 写齐三种形态的测试
- 用 proptest 找到边界 case bug
- 用 insta 做 snapshot 测试,理解什么时候它合适
- 用 criterion 做严肃 benchmark(防止编译器优化掉)
- 用 flamegraph + pprof 定位 CPU 热点
16.1 三种测试形态
单元测试:跟模块同文件
pub fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds() { assert_eq!(add(2, 3), 5); }
#[test]
#[should_panic(expected = "overflow")]
fn overflows() {
let _ = add(i32::MAX, 1);
}
}
#[cfg(test)]只在cargo test时编译- 可以访问 crate 内部
pub(crate)项 #[should_panic]测断言失败
集成测试:tests/ 目录
my-crate/
├── src/
│ └── lib.rs
└── tests/
├── basic.rs
├── api.rs
└── common/
└── mod.rs ← 测试间共用代码
// tests/api.rs
use my_crate::Client;
#[test]
fn create_then_list() {
let c = Client::new();
c.create("alpha").unwrap();
assert!(c.list().contains(&"alpha".to_string()));
}
- 每个文件是独立 crate(单独编译)
- 只能用
pubAPI(模拟外部用户) - 共用代码放
tests/common/mod.rs(注意:不是common.rs,否则 cargo 把它当独立测试 crate)
Doc-test:文档即测试
/// Returns twice the input.
///
/// # Examples
/// ```
/// assert_eq!(my_crate::double(2), 4);
/// ```
pub fn double(x: i32) -> i32 { x * 2 }
cargo test 默认连 doc-test 一起跑。文档总跟实现同步——Rust 生态的好习惯。
跑特定类型的测试:
cargo test --lib # 只跑 src/ 里的 #[test]
cargo test --tests # 只跑 tests/ 集成测试
cargo test --doc # 只跑 doc-test
16.2 Property-based testing
普通测试给一组 input 看 output;property test 描述"对任意输入,应满足的性质",框架自动生成大量随机输入找反例。
use proptest::prelude::*;
proptest! {
#[test]
fn sort_idempotent(mut v: Vec<i32>) {
let a = { let mut x = v.clone(); x.sort(); x };
let b = { let mut x = a.clone(); x.sort(); x };
prop_assert_eq!(a, b); // 排两次跟排一次相同
}
#[test]
fn round_trip(s in "[a-z]+") {
let encoded = encode(&s);
let decoded = decode(&encoded);
prop_assert_eq!(s, decoded);
}
}
真实价值
proptest 在十秒内试 1000+ 输入,常找到人工想不到的 case:""(空)、"\0"、超长字符串、UTF-8 边界、负数边界。
经验:任何"自反"性质(encode→decode、parse→print、压缩→解压)都该写 prop test。
quickcheck 还是 proptest
| quickcheck | proptest | |
|---|---|---|
| 历史 | 更老 | 更新 |
| 自定义 generator | 麻烦 | 优雅(prop_compose!) |
| shrinker(自动缩小反例) | 一般 | 强 |
| 社区使用 | 老项目 | 新项目首选 |
新项目用 proptest。
16.3 Snapshot testing(insta)
use insta::assert_yaml_snapshot;
#[test]
fn snapshot_user() {
let u = make_user();
assert_yaml_snapshot!(u); // 第一次跑生成 .snap 文件,后续跑对比
}
第一次跑:生成 tests/snapshots/foo__snapshot_user.snap。后续跑对比;不一致 → 测试失败。
修订快照:
cargo install cargo-insta
cargo insta review # 交互式 review 改动
cargo insta accept # 全部接受
适用场景
- 序列化输出(JSON / YAML / 配置文件生成)
- 编译器 / linter 的诊断信息(rust-analyzer 自己就用 insta)
- 模板渲染
- CLI help 输出
不适合:逻辑判断(用普通断言更清晰)。
16.4 Mock 框架(mockall)
use mockall::*;
#[automock]
trait UserRepo {
fn get(&self, id: u64) -> Option<User>;
fn save(&mut self, u: &User);
}
fn service(repo: &dyn UserRepo, id: u64) -> Option<String> {
repo.get(id).map(|u| u.name)
}
#[test]
fn test_service() {
let mut mock = MockUserRepo::new();
mock.expect_get()
.with(predicate::eq(42))
.returning(|_| Some(User { id: 42, name: "Bob".into() }));
assert_eq!(service(&mock, 42), Some("Bob".into()));
}
工程经验
Rust 社区对 mock 的态度比 Java / C# 保守:优先用真实实现 + 测试 stub。
- 用 trait 抽象边界(DB / HTTP / message queue)
- 测试时实现一个内存版而不是 mock
- 只有交互复杂(call order / call count assertion)时才上 mockall
为什么:trait 抽象边界后,内存版实现写一遍能给所有测试共用,可读性比一堆 .expect_xxx().returning(...) 好得多。
16.5 criterion 微基准
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "my_bench"
harness = false
// benches/my_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn fib(n: u64) -> u64 {
if n < 2 { n } else { fib(n - 1) + fib(n - 2) }
}
fn bench_fib(c: &mut Criterion) {
c.bench_function("fib 20", |b| b.iter(|| fib(black_box(20))));
}
criterion_group!(benches, bench_fib);
criterion_main!(benches);
cargo bench
为什么必须 black_box
b.iter(|| fib(20)); // 编译器可能算出常量 6765,优化掉整个调用!
b.iter(|| fib(black_box(20))); // 阻止编译器看穿参数
black_box 是优化屏障——告诉编译器"假装这个值你不知道"。所有 input 必须用它包,否则跑出来的可能是空循环。
criterion 的优势
- 统计分析(置信区间、回归检测)
- 自动跟历史对比(
target/criterion/缓存) - HTML 报告
16.6 cargo flamegraph
cargo install flamegraph
cargo flamegraph --bin my-app -- --some-args
生成 flamegraph.svg,浏览器打开。纵轴是调用栈,横轴是 CPU 时间。看哪个函数最宽 → 那就是热点。
依赖:Linux 上需要 perf,macOS 上需要 dtrace(且 SIP 关闭)。生产容器排查时,Linux 直接装 perf 跑。
解读
- 平坦宽的栈 → CPU 真在那里转
- 窄但很深 → 深递归 / 长调用链,可能 inline 没起作用
- 大段
unknown/[unknown]→ 缺 debug info,Cargo.toml 加debug = "line-tables-only"for release
16.7 tokio-console(见 13.9)
console-subscriber = "0.4"
console_subscriber::init();
RUSTFLAGS="--cfg tokio_unstable" cargo run
tokio-console
查"async 任务为什么卡"。前提:跑的是 Tokio runtime。
16.8 pprof-rs:容器友好的 CPU profiling
[dependencies]
pprof = { version = "0.14", features = ["flamegraph", "criterion"] }
use pprof::ProfilerGuard;
let guard = ProfilerGuard::new(100).unwrap(); // 100Hz sampling
// ... 跑你的代码 ...
if let Ok(report) = guard.report().build() {
let file = std::fs::File::create("flamegraph.svg").unwrap();
report.flamegraph(file).unwrap();
}
优势:不需要 perf 权限,容器里也能跑。生产服务可暴露一个 /debug/pprof endpoint,运行时按需拿 profile。
16.9 内存分析
heaptrack(Linux)
heaptrack ./my-app
heaptrack --analyze heaptrack.my-app.12345.gz
记录每次 malloc / free,GUI 分析:总分配、生命周期长的分配、leak。
dhat-rs(跨平台,Cargo 集成)
[dependencies]
dhat = "0.3"
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
fn main() {
let _profiler = dhat::Profiler::new_heap();
// ...
}
跑完生成 dhat-heap.json,丢 https://nnethercote.github.io/dh_view/dh_view.html 看。
bytehound(中文社区有人用)
更现代的 heap profiler,UI 友好。Linux 上推荐。
16.10 测试隔离
文件系统
tempfile crate 给临时目录,drop 时自动清理:
use tempfile::tempdir;
let dir = tempdir().unwrap();
let path = dir.path().join("foo.txt");
// ... 用完
// dir drop 时整个临时目录递归删除
数据库
事实标准:每个测试函数一个事务,测完 rollback。
async fn test_db(pool: &PgPool) {
let mut tx = pool.begin().await.unwrap();
// ... 操作
tx.rollback().await.unwrap(); // 不 commit
}
更高级:sqlx::test macro 自动给每个测试函数一个独立 schema,完全隔离。
外部服务
- wiremock / httpmock:启个本地 HTTP server,fixture 化外部 API
- testcontainers-rs:测试启动 docker 容器(真 Postgres、真 Redis)。CI 慢但隔离最干净
习题
- 给一个简单的 parser 加上
proptest测 "parse → print → parse 不变"。 - 给一个 JSON 配置序列化加上
insta::assert_json_snapshot!,review 一次。 - 写 criterion benchmark 对比
Vec<i32>::sort跟自己实现的 quicksort,故意忘记black_box,看结果失真。 - 用
cargo flamegraphprofile 一个有 hot loop 的程序,找到热点。 - 用
mockall写一个 HTTP client 的测试。再用"trait + 内存版实现"重写,对比可读性。
本章一句话总结
测试和可观测性不是上线后才考虑的事。Rust 工具链让这些事情比 Go / C# 更系统化——但你得主动把工具一个个装起来,默认啥也没有。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 17 · 宏:声明宏与过程宏
Rust 元编程的两套机制
核心问题:macro_rules! 和 proc-macro 分别能做什么?何时该用宏,何时该用泛型?
C# 有 source generator,Go 有 go generate,TS 有 decorator —— 都是元编程,各有局限。Rust 给你两套:声明宏(pattern-based syntax expansion)和过程宏(rustc 调你写的代码生成代码)。能力强,代价是这是 Rust 最难学也最容易被滥用的特性。
读完你应该能:
- 写一个简单的
macro_rules!,理解 fragment specifier - 区分 declarative / proc-macro / derive / attribute 四种
- 用
syn + quote写一个 derive 宏 - 用
cargo expand看宏展开 - 给"什么时候用宏 vs trait vs 普通函数"建立判断力
17.1 macro_rules! 基础
macro_rules! say_hi { () => { println!("hi"); }; } fn main() { say_hi!(); }
Fragment specifiers
宏匹配靠 fragment specifier —— 告诉编译器"我期望这个位置是哪类语法节点":
| Specifier | 匹配 |
|---|---|
expr | 表达式 |
ty | 类型 |
ident | 标识符 |
path | 路径 (std::collections::HashMap) |
pat | 模式 |
stmt | 语句 |
block | { ... } |
item | 项(fn / struct / impl ...) |
meta | 属性 (derive(Debug)) |
tt | token tree(最通用) |
literal | 字面量 |
lifetime | 'a |
vis | 可见性 (pub, pub(crate)) |
macro_rules! make_fn {
($name:ident, $ty:ty, $val:expr) => {
fn $name() -> $ty { $val }
};
}
make_fn!(forty_two, i32, 42);
make_fn!(greeting, &'static str, "hello");
17.2 重复模式
$( ... )sep rep 匹配重复:
*—— 零次或多次+—— 一次或多次?—— 零次或一次sep—— 分隔符(可选)
macro_rules! my_vec { ( $( $x:expr ),* ) => { { let mut v = Vec::new(); $( v.push($x); )* v } }; } fn main() { let v: Vec<i32> = my_vec![1, 2, 3, 4]; println!("{:?}", v); }
读法:
- 匹配
1, 2, 3, 4:$($x:expr),*把每个分别绑定到$x - 展开:
$( v.push($x); )*对每次匹配生成一个 push 语句
真正的 std::vec! 大致也是这样写的
只是更复杂(预分配 + 几种重载形式)。
17.3 卫生性(hygiene)
C 的宏臭名昭著——#define SQUARE(x) x*x,SQUARE(1+2) 展开成 1+2*1+2 = 5。Rust 宏是卫生的:
- token 不会污染:宏内部的
let tmp = ...跟外面同名的tmp不冲突 - 作用域正确:宏展开后,变量解析仍按"宏定义处 + 调用处"的可见性,不会把内部名字暴露给外面
macro_rules! using_tmp {
() => {
let tmp = 100;
println!("{}", tmp);
};
}
fn main() {
let tmp = 1;
using_tmp!();
println!("{}", tmp); // 1,宏内部的 tmp 跟这里不冲突
}
(注意:macro_rules! 的卫生性主要对 ident 起作用,对 lifetime / type 较弱。复杂场景仍要小心命名。)
17.4 TT munching 模式
写复杂宏时常用的"递归 + 逐步消耗 token"技巧:
macro_rules! count {
() => (0_usize);
($x:tt $($rest:tt)*) => (1_usize + count!($($rest)*));
}
fn main() {
assert_eq!(count!(a b c d), 4);
}
每次匹配吃掉一个 token,递归调自己处理剩下的。
实战中你写这种宏的机会不多,但读社区库(tokio::select!、serde_json::json!)时会看到。
17.5 过程宏的三种形态
声明宏的限制:只能做 token-level 的模式替换。要解析 Rust AST、查类型、生成完整 impl —— 你需要过程宏。
| 形态 | 用法 | 例子 |
|---|---|---|
#[derive(MyTrait)] | 给 struct/enum 自动 impl | #[derive(Debug, Clone, Serialize)] |
#[my_attr(args)] | 任意 attribute | #[tokio::main], #[instrument] |
my_macro!(...) | 函数式 | sqlx::query!(...), html!(...) |
过程宏是独立 crate,cargo 编译它先于调用它的 crate:
# Cargo.toml of the macro crate
[lib]
proc-macro = true
[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"
17.6 syn + quote 工具链
syn 把 TokenStream 解析成 Rust AST;quote 把 AST / 代码片段拼回 TokenStream。
最小 derive 宏骨架
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(HelloWorld)]
pub fn hello_world(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let expanded = quote! {
impl HelloWorld for #name {
fn hello() {
println!("Hello from {}!", stringify!(#name));
}
}
};
expanded.into()
}
使用:
#[derive(HelloWorld)]
struct Pancakes;
fn main() { Pancakes::hello(); }
17.7 实战:写一个 derive Builder
目标:
#[derive(Builder)]
struct Request {
url: String,
method: String,
timeout_ms: u64,
}
let r = Request::builder()
.url("https://api.example.com".into())
.method("POST".into())
.timeout_ms(3000)
.build()?;
注意:这个简化版对所有字段都生成
Option<#ty>槽位,build 时 ok_or 报错。这意味着原本就是Option<T>的字段会被二次包成Option<Option<T>>——setter 也得传Option<T>,使用体验差。真derive_buildercrate 会识别Option<T>字段做特殊处理(本章习题之一)。为了示例自洽,目标 struct 不要混Option字段。
实现
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{parse_macro_input, spanned::Spanned, Data, DeriveInput, Fields};
#[proc_macro_derive(Builder)]
pub fn derive_builder(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let builder_name = format_ident!("{}Builder", name);
// 用 syn::Error::to_compile_error 替代 panic!——错误带 span,定位到 token,
// 用户看到的是 "expected named struct" 而不是 "proc-macro derive panicked"。
let fields = match &input.data {
Data::Struct(s) => match &s.fields {
Fields::Named(f) => &f.named,
other => return syn::Error::new(other.span(), "Builder requires named fields")
.to_compile_error().into(),
},
_ => return syn::Error::new_spanned(&input.ident, "Builder can only derive on structs")
.to_compile_error().into(),
};
let field_names: Vec<_> = fields.iter().map(|f| &f.ident).collect();
let field_types: Vec<_> = fields.iter().map(|f| &f.ty).collect();
let optional_field_decl = field_types.iter().map(|ty| {
quote!(Option<#ty>)
});
let setters = fields.iter().map(|f| {
let n = &f.ident;
let ty = &f.ty;
quote!(pub fn #n(mut self, value: #ty) -> Self {
self.#n = Some(value);
self
})
});
let build_fields = field_names.iter().map(|n| {
quote!(#n: self.#n.ok_or(concat!("missing field: ", stringify!(#n)))?)
});
let expanded = quote! {
#[derive(Default)]
pub struct #builder_name {
( #field_names: #optional_field_decl, )*
}
impl #name {
pub fn builder() -> #builder_name { #builder_name::default() }
}
impl #builder_name {
( #setters )*
pub fn build(self) -> Result<#name, &'static str> {
Ok(#name { #( #build_fields, )* })
}
}
};
expanded.into()
}
这是个简化版,真实的 derive_builder 还支持 #[builder(...)] attribute 控制行为(default value、setter rename、setter prefix、识别 Option<T> 字段不二次包装等)。
17.8 cargo expand
cargo install cargo-expand
cargo expand --bin myapp
cargo expand --lib path::to::module
调试宏的最重要工具。把所有宏(println!, vec!, tokio::main, derive ...)展开后给你看,你能精确知道编译器看到的代码。
第一次跑 cargo expand on 一个有 #[tokio::main] 的程序,会看到几十行的 runtime 构建代码——再也不觉得 async magical 了。
17.9 宏使用纪律
优先级:函数 > 泛型 > trait > 宏
| 想做的事 | 用什么 |
|---|---|
| 计算 | 函数 |
| 多类型支持 | 泛型 + trait bound |
| 不同行为切换 | trait + dyn / impl |
| 模式化的代码生成(boilerplate) | 宏 |
| 不规则语法 | 宏 |
| 编译时算 / 查 | 宏(或 const fn) |
何时用宏是合理的
- 真正的不规则语法(
println!("x = {}", x)这种) - 大量样板代码(derive
Debug/Clone/Serialize) - 编译时校验(
sqlx::query!) - DSL(
html!,quote!)
何时不该用宏
- 能用普通函数 / generic 表达
- 你"觉得" boilerplate 多但其实只有 2-3 处
- 写来"显得有意思"
社区有个段子:Rust 工程师水平的反比指标 = 项目里宏的数量。这话夸张但有道理:克制是美德。
习题
- 写一个
min!宏接受任意个表达式,返回最小值。 - 写一个
#[derive(Getters)],为每个字段生成fn field_name(&self) -> &FieldType。 - 用
cargo expand看#[tokio::main] async fn main()展开后什么样,解释每一段做什么。 - 找一个
serde::Serialize实现的展开结果(用cargo expand),挑一个 struct 看它生成的代码量。 - 写一篇 200 字"我们项目要不要引入 proc-macro" 的内部 RFC,论证支持 / 反对的判断。
本章一句话总结
宏是 Rust 的元编程武器,但它是最容易被滥用的特性。读得懂 + 必要时能写 + 大部分时间不写——这是合格 Rust 工程师的判断力。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 18 · Unsafe Rust —— Rustonomicon 速成
"向编译器承诺以下不变量"
核心问题:unsafe 到底关掉了什么?程序员需要承诺什么?
unsafe 是 Rust 最被误解的关键字。常见误解:"unsafe 关掉了所有安全检查"。真相是:它只关掉一小部分检查,但要求你手动承诺一组不变量。绝大多数 Rust 工程师不应该写 unsafe;但每个 Rust 工程师都应该读懂 unsafe,因为标准库、Tokio、SmallVec、Bytes 等基础库都在用它。
读完你应该能:
- 说出 unsafe 能做的五件事
- 解释 Stacked Borrows 模型简化版
- 列出 10 种 UB,并知道为什么"看起来没事"也可能是 UB
- 用 Miri 验证 unsafe 代码
- 写一个简单的 unsafe 抽象(比如 ring buffer),并把 unsafety 包裹在安全 API 内
18.1 unsafe 能做的五件事
unsafe 在 Rust 里只多给你这五个能力:
- 解引用裸指针
*const T/*mut T - 调用 unsafe 函数(包括 FFI)
- 访问 / 修改可变 static 变量
- 实现 unsafe trait(如
Send,Sync手写) - 访问 union 字段
注意它不给你:
- 关掉借用检查 ——
&和&mut规则仍然成立 - 关掉类型检查 —— 类型仍要匹配
- 跳过 trait bound
unsafe { } 块比初学者想的小得多。
18.2 Aliasing 规则:Stacked Borrows / Tree Borrows
Rust 编译器假设:任一时刻,要么有任意多个 &T,要么有恰好一个 &mut T。这条 alias 规则即便在 unsafe 里也得守。违反 → UB。
Stacked Borrows 模型(简化)
每个内存位置内部有一个"borrow stack"。每次创建引用,push 一个 tag 上栈;访问该位置时,该位置上的 tag 必须在栈中。访问破坏栈结构 → UB。
实战要点:
- 写
&mut x之后,所有先前由 x 派生的&都失效(包括裸指针!) - 裸指针
as *const _/as *mut _时,只有"最近一次创建"的那条链上的指针有效
反例
unsafe {
let mut x = 0u8;
let p = &mut x as *mut u8; // 创建 mutable raw ptr
let q = &mut x as *mut u8; // 再创建一个 —— p 失效
*p = 1; // ❌ UB,虽然编译过
*q = 2;
}
代码看着像无害,但 Stacked Borrows 视角下是 UB,优化器可能据此把 *p = 1 整段删掉。
Tree Borrows
Rust 团队在研发更宽松的模型 Tree Borrows,允许更多看起来合理但 Stacked Borrows 拒绝的模式。实践中目前以 Stacked Borrows 为准(Miri 默认是它)。
18.3 UB(未定义行为)清单
unsafe 代码里你必须避免的事(部分,完整看 Rustonomicon):
- 悬空指针解引用 —— 指向已 drop / 已 free 的位置
- 未对齐访问 —— 解引用 misaligned pointer
- 野指针 / null 解引用(裸指针)
- 违反 aliasing 规则(见上)
- 数据竞争 —— 两线程同时无同步访问同一可变位置
- 读取未初始化内存
- 整数溢出(
unchecked_add等;safe 默认是 wrap 或 panic) - 错误的转换 ——
&u32cast 到&u64然后读取 - 不变量违反 ——
bool里塞 2、enum里塞非法 discriminant、char塞代理对 - panic 跨 FFI 边界
- 错误的生命周期 —— 用
transmute延长生命周期 - 不为 Send 的类型跨线程(手写
unsafe impl Send) - 错误地 mem::transmute —— size/align/语义不对
UB 不是"会崩溃"的同义词——很多 UB 看起来运行良好,直到优化器 / 不同硬件 / 不同 rustc 版本下崩溃。别相信"我跑过没事"。
18.4 UnsafeCell 的真实含义
struct UnsafeCell<T> { value: T }
为什么必须有这个类型?因为 Rust 编译器假定 &T 是 immutable,可以做积极优化(缓存到寄存器、常量折叠)。
UnsafeCell<T> 告诉编译器"这个位置可能被并发修改,不能假设它不变"。唯一能 legally 从 &T 取 *mut T 的类型就是 UnsafeCell<T>。
所有内部可变性(Cell, RefCell, Mutex, AtomicXxx)都基于 UnsafeCell。
18.5 Lifetime variance(回顾 Ch 7)
unsafe 代码里 variance 错误是常见 unsoundness 来源:
struct WrongPtr<'a, T> {
ptr: *mut T,
_marker: PhantomData<&'a T>, // ❌:Should it be &'a mut T?
}
如果你的 WrongPtr<'a, T> 实际上拿"独占可变"语义,但用 PhantomData<&'a T>(协变),编译器允许"短 lifetime 当长 lifetime 用",安全 API 就 unsound。
经验:写带生命周期参数的 unsafe 类型,先停下来画 variance 表。
18.6 写正确的 unsafe 代码的工程纪律
-
把 unsafe 关在最小作用域里
- 不要把整个函数
unsafe fn;unsafe块越短越好
- 不要把整个函数
-
文档化 SAFETY 注释
// SAFETY: ptr 来自 Box::into_raw,刚刚分配,未被 free; // 且 buffer 长度满足 self.cap,所以 offset cap-1 仍在有效范围内。 unsafe { ptr.add(self.cap - 1).write(value) }社区惯例:每个 unsafe 块 / unsafe fn 都要有
SAFETY:注释,说明你为什么相信不变量成立 -
暴露 safe API,unsafe 锁在内部
- 库用户用
Vec::push不该见 unsafe;push内部一堆 unsafe,但push本身是 safe fn,因为内部确保了不变量
- 库用户用
-
测试用 Miri
rustup install nightly rustup +nightly component add miri cargo +nightly miri testMiri 是 Rust 解释器,会检查 UB(stacked borrows、未初始化、悬空指针等)。unsafe 代码的 CI 必跑 Miri
-
多人 review
- 内部规范:unsafe 修改需要至少 2 人 review
18.7 手动实现 Send / Sync
Send / Sync 是 auto trait —— 大部分时候不用你手写。但有些场景需要:
struct MyPool {
inner: *mut PoolImpl, // 裸指针,默认不 Send
}
// SAFETY: PoolImpl 内部用 Mutex 保护所有可变状态,可跨线程安全访问。
unsafe impl Send for MyPool {}
unsafe impl Sync for MyPool {}
危险:你向编译器承诺这个类型是 thread-safe。错了就是 data race,运行时崩。
90% 的情况你不需要手写 Send/Sync——如果你不知道为什么需要,就先别加。
18.8 Miri:Rust 的 UB 检测器
cargo +nightly miri test
Miri 会:
- 检测越界访问
- 检测未初始化读
- 检测 stacked borrows 违规
- 检测 misaligned access
- 检测数据竞争(实验性,加
-Zmiri-num-cpus=N)
代价:Miri 比正常执行慢 ~100x。只跑小规模 unit test,不能上集成测试。
CI 集成示例
- name: Miri
run: |
rustup toolchain install nightly --component miri
cargo +nightly miri test --lib
18.9 案例分析:Vec::push 简化版
pub fn push(&mut self, value: T) {
if self.len == self.cap {
self.grow(); // 重分配,扩大 cap
}
unsafe {
// SAFETY: self.len < self.cap(刚检查过或扩容后必满足);
// self.ptr.add(self.len) 在有效 allocation 范围内;
// 该位置目前是未初始化(超出 len),可以 write 而不 drop 旧值
std::ptr::write(self.ptr.add(self.len), value);
}
self.len += 1;
}
仔细看 SAFETY 注释 —— 它精确描述了三个不变量。任何一条破了都 UB。这是工业级 unsafe 代码的写法。
推荐继续阅读源码
Vec的push/pop/extend_from_sliceBox::new(为什么是 unsafe 内部)Rc的clone/drop(strong / weak count 的原子顺序)Mutex(OS mutex 的封装)
习题
- 不用 Box / Vec 等,只用
allocAPI + 裸指针,实现一个固定大小的RingBuffer<T>。要求暴露 safe API。用 Miri 跑测试。 - 故意构造一个 Stacked Borrows 违规(同时存在两个 raw mut ptr 指向同一位置),用 Miri 测出来。
- 给某个支持自定义 hasher 的 HashMap-like 类型手写
unsafe impl Send,写出 SAFETY 注释。让其他人 review。 - 读
std::cell::Cell的源码,理解为什么它是 safe 抽象但内部全是 unsafe。 - 写一篇 200 字"为什么 unsafe 不该 disable warnings"的内部文档,论证立场。
本章一句话总结
unsafe 不是"关掉安全检查",是"我向编译器承诺以下不变量"。读懂这个差别,unsafe 才能写得正确——大部分人不需要写它,但每个人都应该看得懂它。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 19 · FFI 与跨语言边界
Rust 怎么跟 C / Python / JS / WASM 互操作
核心问题:Rust 和别的语言怎么互操作?每种边界各有什么坑?
Rust 的现实世界主战场之一就是给别的语言提供库:Python(AI / 数据)、Node.js(前端工具)、C/C++(系统集成)、Web 浏览器(WASM)。这章是工程师向导,讲每种边界的工具、坑、生产实践。
读完你应该能:
- 解释 C ABI /
#[repr(C)]/#[no_mangle]各自做什么 - 用 bindgen 调一个 C 库,用 cbindgen 把 Rust 暴露给 C
- 用 PyO3 给 Python 写一个 Rust 模块
- 用 napi-rs 给 Node.js 写一个 Rust 模块
- 用 wasm-bindgen 把 Rust 编到浏览器
19.1 C ABI 基础
Rust 默认 ABI 不稳定——版本之间结构布局可能改。要跨语言,必须用 C ABI(几十年来稳定的事实标准)。
三个关键 attribute
#[no_mangle] // 不混淆函数名,符号叫 add_one
pub extern "C" fn add_one(x: i32) -> i32 { x + 1 }
#[repr(C)] // C 兼容布局
pub struct Point { pub x: f64, pub y: f64 }
extern "C"—— 用 C ABI(参数寄存器、栈布局)#[no_mangle]—— 编译后符号保留原名(否则 Rust 加 hash 后缀,C 找不到)#[repr(C)]—— struct 字段顺序、对齐按 C 规则(否则 Rust 可能重排字段优化布局)
类型对应
| Rust | C |
|---|---|
i8/i16/i32/i64 | int8_t/int16_t/int32_t/int64_t |
u8/u16/u32/u64 | uint8_t/... |
f32/f64 | float/double |
bool | bool(stdbool.h) |
*const T / *mut T | const T* / T* |
Option<&T>(T:Sized) | T*(可为 NULL,niche 优化) |
&[T] | 没有直接对应(必须拆成 ptr + len) |
String | 没有直接对应(只能 *const c_char + len) |
字符串边界
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::panic::catch_unwind;
use std::ptr;
#[no_mangle]
pub extern "C" fn greet(name: *const c_char) -> *mut c_char {
// FFI 边界:任何 panic 跨过都是 UB,所以整体包 catch_unwind。
let result = catch_unwind(|| {
if name.is_null() { return ptr::null_mut(); }
// SAFETY: 调用方保证 name 指向以 NUL 结尾的有效 C 字符串。
let cstr = unsafe { CStr::from_ptr(name) };
let s = match cstr.to_str() {
Ok(s) => s, // UTF-8 非法 → 返回 Err,不是 UB
Err(_) => return ptr::null_mut(),
};
let owned = format!("Hello, {}!", s);
match CString::new(owned) {
Ok(c) => c.into_raw(), // 把所有权交给 C
Err(_) => ptr::null_mut(), // 内部含 NUL → 返回 NULL
}
});
result.unwrap_or(ptr::null_mut())
}
#[no_mangle]
pub extern "C" fn free_string(s: *mut c_char) {
if s.is_null() { return; }
// SAFETY: s 必须是之前由 greet 返回的指针,且未被 free 过。
unsafe { let _ = CString::from_raw(s); } // Rust 回收
}
三条边界规则:
- 谁分配谁释放:不能 Rust 用
mallocC 用free(allocator 不同)。必须暴露free_string给 C 调用。 - NULL / NUL 终止符的安全责任:
CStr::from_ptr(name)要求name非 NULL 且指向 NUL 结尾的 C 字符串——违反就是 UB。所以入口先做 NULL 检查,文档里写明 NUL 终止的契约。 - 错误用返回值表达,不要
unwrap:to_str失败(非 UTF-8)和CString::new失败(中间含 NUL 字节)都返回Err,这不是 UB,但你.unwrap()一旦 panic 跨过 FFI 边界就成 UB 了。所有extern "C" fn都该catch_unwind兜底。
19.2 调 C 库:bindgen 自动生成 binding
[build-dependencies]
bindgen = "0.70"
// build.rs
fn main() {
println!("cargo:rustc-link-lib=zstd");
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("Unable to generate bindings");
let out_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
bindings.write_to_file(out_path.join("bindings.rs")).unwrap();
}
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
fn main() {
let v = unsafe { ZSTD_versionNumber() };
println!("zstd version: {}", v);
}
bindgen 解析 wrapper.h(#include <zstd.h> 等),生成 Rust 的 extern "C" 声明 + #[repr(C)] struct。你直接 unsafe 调即可。
工程包装
bindgen 生成的是 -sys crate(惯例:libfoo-sys)。在上面再封装一个安全 crate libfoo:
my-zstd/
├── libzstd-sys/ # bindgen 输出,raw unsafe
└── libzstd/ # 在上面包安全 API:Compressor / Decompressor / Stream
Rust 生态约定:sys crate = 1:1 binding,普通 crate = 安全封装。
19.3 暴露 Rust 给 C/C++:cbindgen
[build-dependencies]
cbindgen = "0.27"
// build.rs
fn main() {
cbindgen::Builder::new()
.with_crate(env!("CARGO_MANIFEST_DIR"))
.with_language(cbindgen::Language::C)
.generate()
.unwrap()
.write_to_file("include/my_lib.h");
}
cargo build 自动生成 my_lib.h:
typedef struct Point { double x; double y; } Point;
double point_distance(const Point *a, const Point *b);
C 工程 #include "my_lib.h" 后链接 libmy_lib.a / .so 即可。
Cargo.toml 编出 staticlib / cdylib
[lib]
crate-type = ["staticlib", "cdylib"] # 选你需要的
staticlib→.a(静态库,链进 C 程序)cdylib→.so/.dylib/.dll(动态库)rlib(默认) → Rust-only,不能给 C 用
19.4 PyO3:暴露 Rust 给 Python
[lib]
name = "my_rust_module"
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.22", features = ["extension-module"] }
use pyo3::prelude::*;
#[pyfunction]
fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
Ok((a + b).to_string())
}
#[pymodule]
fn my_rust_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(sum_as_string, m)?)?;
Ok(())
}
构建 + 安装(用 maturin):
pip install maturin
maturin develop # 编译并 pip install 到当前 venv
import my_rust_module
print(my_rust_module.sum_as_string(1, 2)) # "3"
AI 工程价值
Python 数据科学栈 + Rust 高性能 = 现代标配:
- pydantic-core:Pydantic v2 内核(Rust 写,Pydantic 速度提升 5-50x)
- polars:DataFrame 库(pandas 替代,纯 Rust)
- ruff:Python linter,比 flake8 快 10-100x
- uv:Python package manager,比 pip / pipenv / poetry 都快得多
- tokenizers / safetensors:Hugging Face 生态
写 Python 的人都在间接用 Rust。
GIL 与并发
#[pyfunction]
fn heavy(py: Python<'_>) -> PyResult<i64> {
py.allow_threads(|| { // 释放 GIL,Python 其他线程能跑
(0..1_000_000_000_i64).sum()
})
}
allow_threads 让你在 Rust 里跑 CPU 密集任务而不阻塞 Python GIL——这是 PyO3 性能的关键。
19.5 napi-rs:暴露 Rust 给 Node.js
[lib]
crate-type = ["cdylib"]
[dependencies]
napi = { version = "2", features = ["napi9"] }
napi-derive = "2"
[build-dependencies]
napi-build = "2"
use napi_derive::napi;
#[napi]
fn sum(a: i32, b: i32) -> i32 { a + b }
npm install -g @napi-rs/cli
napi build --platform --release
生成 .node 文件,Node.js 里 require() 就用。
实战案例
- SWC:Rust 写的 JS / TS 编译器(替代 Babel / TSC,快 20-70x)
- Turbopack(Vercel):Rust 写的 webpack 替代,Next.js 用
- Rolldown / Lightning CSS:同上,前端工具链的"用 Rust 重写"潮
19.6 WASM:Rust 编译到浏览器
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
cargo install wasm-pack
wasm-pack build --target web
生成 pkg/ 目录,里面是 .wasm + JS glue。前端:
import init, { greet } from './pkg/my_lib.js';
await init();
console.log(greet('world'));
适用场景
- 计算密集型:图像处理 / 音频处理 / 加密 / parser
- 复用业务核心代码(同一份 Rust 跑前端 + 后端)
- 在线 IDE / playground(Rust playground 自己就是 WASM)
代价:
- 二进制比 JS 大(常见 100KB-2MB)
- 跟 JS 互调有边界开销
- Rust → JS DOM 操作没有直接 API,要靠 web-sys 全套
19.7 FFI 安全检查清单
跨边界时,安全规则全靠你(编译器无法越过 FFI 检查):
1. lifetime 不穿越边界
不要把 &'a T 直接暴露给外部语言。如果非要,文档严格写明"返回的指针只在 X 之前有效"。
2. panic 不能跨 FFI 边界
#[no_mangle]
pub extern "C" fn risky() {
std::panic::catch_unwind(|| {
// 你的代码,可能 panic
}).unwrap_or_else(|_| {
// 处理 panic(转 error code 等)
});
}
panic 跨 FFI = UB。所有 extern "C" fn 入口都应该 catch_unwind 或保证不 panic。
3. 用错误码,不用异常
Python / JS / Java 期待异常,C/Go/Rust 期待返回值。Rust 的 Result 转什么由调用方决定。常见做法:返回 i32(0 = OK,负数 = 错误码)。
4. UTF-8 vs 别的编码
区分两类失败:
- 指针无效 / 不是以 NUL 结尾 →
CStr::from_ptr直接 UB(编译器无法检测,你必须保证) - 字节合法但不是 UTF-8 →
to_str()返回Err(Utf8Error),不是 UB;转Result处理即可
所以从 C 接来 *const c_char 时,先 NULL 检查,再 CStr::from_ptr(...)(凭契约 unsafe),最后 to_str()? 拿到 &str——前两步是安全责任,第三步是普通错误处理。
5. allocator 不能跨边界
C 的 malloc 跟 Rust 的 alloc 是两个 allocator,互相不能 free。设计 API 时:谁分配谁释放,或暴露明确的 free_xxx 函数。
6. opaque pointer 比 struct 安全
把 Rust 类型作为 opaque void* 暴露给 C,比暴露 #[repr(C)] struct 安全得多——你保留了改动内部布局的自由。
19.8 真实案例
| 项目 | 干什么 | FFI 边界 |
|---|---|---|
| ruff | Python linter(替代 flake8 / pylint) | 纯 Rust,带 Python wrapper |
| uv | Python package manager | 纯 Rust binary,通过 CLI 跟 Python 交互 |
| polars | DataFrame | Rust core + PyO3 + napi |
| deno | TypeScript runtime | Rust + v8 binding |
| swc | JS 编译器 | Rust + napi-rs |
| turbopack | bundler | Rust + napi-rs |
| firefox | 浏览器 | C++ + Rust(CSS engine 等子系统) |
| discord | 部分服务后端 | Elixir/Erlang + Rust(NIF) |
生态趋势:FFI 边界写好的 Rust 库 > 用 Rust 写整个产品。所以这章是高 ROI 内容。
习题
- 用 PyO3 把一个 Rust 函数(比如 fibonacci)暴露给 Python,在 Python 里压测 vs 纯 Python 实现。
- 用 bindgen 给 sqlite3 写 binding,实现一个
open + execute的最小 Rust 包装。 - 故意在
extern "C" fn里写一个 panic,看会发生什么(Linux 下可能 abort)。再用catch_unwind包好。 - 用 napi-rs 给 Node.js 暴露一个 Rust 函数,在 Node 里
require调用。 - 把一个 Rust 计算函数编到 WASM,写个 HTML 调它,benchmark vs 纯 JS。
本章一句话总结
FFI 是 Rust 渗透到所有语言生态的能力。写得好的 Rust 库往往不是用 Rust 调用,是被 Python / Node / Web 调用——掌握这块,你的"投资回报"最高。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
Ch 20 · Capstone:从零实现 Mini-Tokio
把全书概念串起来,写一个能跑的异步 runtime
核心问题:看完这本书,你能不能给 Tokio 提 PR?可以,从 issue tracker 找 good first issue 开始。但在那之前,你应该亲手写一遍 mini-tokio——所有概念在自己实现里活一遍后,印象深 10 倍。
读完你应该能:
- 写一个支持
block_on的最简 executor - 支持
spawn多个并发任务 - 实现
SleepFuture + 简化的 timer wheel - 实现
mpscchannel - 升级到多线程 work-stealing scheduler
- 解释你的 mini-tokio 跟真 Tokio 的差距在哪
20.1 设计目标
我们要做的 mini-tokio:
- ✅
block_on(future):阻塞当前线程直到 Future 完成 - ✅
spawn(future):把 Future 加入调度(本章教学版本只负责提交,不返回JoinHandle;习题里会让你补) - ✅
Sleep::new(duration):返回一个 Future,睡 duration 后 ready - ✅ 一个简化的单生产者 channel(
Sender不 Clone),教学到 waker 注册 + race 修复就够;真 mpsc 留作习题 - ✅ 多线程 work-stealing
不做:
- ❌ 真正的 IO(epoll / kqueue / IOCP)——简化课程范围
- ❌ Pin 投影宏 / 自适应缓冲
- ❌ 性能调优
- ❌
JoinHandle/ 多生产者 channel(故意省,留作毕业题)
代码总量 ~500 行,3 个文件搞定。
20.2 核心数据结构
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Wake, Waker};
struct Task {
future: Mutex<Option<Pin<Box<dyn Future<Output = ()> + Send>>>>,
queue: Arc<TaskQueue>,
}
struct TaskQueue {
ready: Mutex<VecDeque<Arc<Task>>>,
/* 唤醒主线程的机制(condvar 或 parking_lot::Park) */
}
每个 task 是一个 trait object Future(dyn Future<Output = ()>),装在 Mutex<Option<...>> 里:
Mutex因为 task 可能在多线程被 pollOption因为 poll 完(Poll::Ready)后我们把 future drop 掉
20.3 Step 1:单线程 executor + block_on
use std::sync::Condvar;
struct Inner {
ready: Mutex<VecDeque<Arc<Task>>>,
cvar: Condvar,
}
struct Mini { inner: Arc<Inner> }
impl Mini {
pub fn new() -> Self {
Mini { inner: Arc::new(Inner {
ready: Mutex::new(VecDeque::new()),
cvar: Condvar::new(),
}) }
}
pub fn block_on<F: Future>(&self, future: F) -> F::Output {
// 单 future 简化版:不走 task queue,直接 park 自己
let (sender, receiver) = std::sync::mpsc::channel();
let main = MainTask { sender: Mutex::new(Some(sender)) };
let waker = Waker::from(Arc::new(main));
let mut cx = Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(v) => return v,
Poll::Pending => { let _ = receiver.recv(); }
}
}
}
}
struct MainTask { sender: Mutex<Option<std::sync::mpsc::Sender<()>>> }
impl Wake for MainTask {
fn wake(self: Arc<Self>) { self.wake_by_ref(); }
fn wake_by_ref(self: &Arc<Self>) {
if let Some(s) = &*self.sender.lock().unwrap() { let _ = s.send(()); }
}
}
试一下:
fn main() {
let mini = Mini::new();
let result = mini.block_on(async { 1 + 1 });
println!("{}", result); // 2
}
✅ 检查点:能跑通最简 async block。
20.4 Step 2:支持 spawn 与并发 task
加入任务队列:
use std::sync::atomic::{AtomicUsize, Ordering};
struct Inner {
ready: Mutex<VecDeque<Arc<Task>>>,
cvar: Condvar,
alive: AtomicUsize, // 还没跑完的 task 数,归零时让 run_until_idle 返回
}
struct Task {
future: Mutex<Option<Pin<Box<dyn Future<Output = ()> + Send>>>>,
queue: Arc<Inner>,
}
impl Wake for Task {
fn wake(self: Arc<Self>) { self.wake_by_ref(); }
fn wake_by_ref(self: &Arc<Self>) {
let mut q = self.queue.ready.lock().unwrap();
q.push_back(self.clone());
self.queue.cvar.notify_one();
}
}
impl Mini {
pub fn spawn<F>(&self, future: F)
where F: Future<Output = ()> + Send + 'static,
{
let task = Arc::new(Task {
future: Mutex::new(Some(Box::pin(future))),
queue: self.inner.clone(),
});
self.inner.alive.fetch_add(1, Ordering::SeqCst);
self.inner.ready.lock().unwrap().push_back(task.clone());
self.inner.cvar.notify_one();
}
/// 跑到所有 spawn 的 task 都完成为止再返回。
/// 真 Tokio 的 runtime 一直运行,我们这里做教学版,**有限运行**才好让示例程序退出。
pub fn run_until_idle(&self) {
loop {
if self.inner.alive.load(Ordering::SeqCst) == 0 { return; }
let task = {
let mut q = self.inner.ready.lock().unwrap();
while q.is_empty() {
if self.inner.alive.load(Ordering::SeqCst) == 0 { return; }
q = self.inner.cvar.wait(q).unwrap();
}
q.pop_front().unwrap()
};
let waker = Waker::from(task.clone());
let mut cx = Context::from_waker(&waker);
let mut fut_slot = task.future.lock().unwrap();
if let Some(mut fut) = fut_slot.take() {
match fut.as_mut().poll(&mut cx) {
Poll::Pending => *fut_slot = Some(fut),
Poll::Ready(()) => {
// task 完成,future 已 drop;减计数,可能允许 run 退出
self.inner.alive.fetch_sub(1, Ordering::SeqCst);
}
}
}
}
}
}
注意:用 run_until_idle() 而不是无限 run()——示例程序结束时能正常退出。真 Tokio 的 runtime 是无限的(它要等 IO 事件),教学版本里不退出会让读者迷惑"程序为什么卡住"。
测试:
let mini = Mini::new();
for i in 0..5 {
mini.spawn(async move {
println!("task {}", i);
});
}
mini.run_until_idle();
✅ 检查点:能并发跑多个任务。
20.5 Step 3:实现 Sleep Future + timer wheel
use std::time::{Duration, Instant};
use std::collections::BinaryHeap;
use std::cmp::Reverse;
struct TimerEntry { deadline: Instant, slot: Arc<Mutex<Option<Waker>>> }
impl Ord for TimerEntry { fn cmp(&self, o: &Self) -> std::cmp::Ordering { self.deadline.cmp(&o.deadline) } }
impl PartialOrd for TimerEntry { fn partial_cmp(&self, o: &Self) -> Option<std::cmp::Ordering> { Some(self.cmp(o)) } }
impl Eq for TimerEntry {}
impl PartialEq for TimerEntry { fn eq(&self, o: &Self) -> bool { self.deadline == o.deadline } }
struct Timers { heap: Mutex<BinaryHeap<Reverse<TimerEntry>>> }
pub struct Sleep {
deadline: Instant,
slot: Arc<Mutex<Option<Waker>>>, // 共享 waker 槽,timer 线程从这里取最新 waker
registered: bool,
timers: Arc<Timers>,
}
impl Sleep {
pub fn new(timers: Arc<Timers>, dur: Duration) -> Self {
Sleep {
deadline: Instant::now() + dur,
slot: Arc::new(Mutex::new(None)),
registered: false,
timers,
}
}
}
impl Future for Sleep {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if Instant::now() >= self.deadline { return Poll::Ready(()); }
// 关键:每次 poll 都用 will_wake 判断是否需要刷新 waker。
// Future 契约允许调度器在不同 poll 用不同 waker(任务在 worker 间迁移会发生),
// 如果只在首次 poll 固定 waker,后续可能唤醒错了 waker → 任务挂死。
{
let mut slot = self.slot.lock().unwrap();
match &*slot {
Some(old) if old.will_wake(cx.waker()) => {} // 相同,免 clone
_ => *slot = Some(cx.waker().clone()),
}
}
if !self.registered {
self.timers.heap.lock().unwrap().push(Reverse(TimerEntry {
deadline: self.deadline,
slot: self.slot.clone(), // timer 持槽,到点取最新 waker
}));
self.registered = true;
}
Poll::Pending
}
}
TimerEntry 也得同步改成持 slot 而不是直接持 waker:
struct TimerEntry { deadline: Instant, slot: Arc<Mutex<Option<Waker>>> }
timer 线程到点时 if let Some(w) = entry.slot.lock().unwrap().take() { w.wake(); }——这样总能拿到最近一次 poll 注册的 waker。生产级 runtime 通常用 futures::task::AtomicWaker 做这个槽,免一次锁。
executor 加 timer pump 线程:
fn timer_thread(timers: Arc<Timers>) {
loop {
let mut heap = timers.heap.lock().unwrap();
let now = Instant::now();
while let Some(Reverse(top)) = heap.peek() {
if top.deadline <= now {
let Reverse(e) = heap.pop().unwrap();
if let Some(w) = e.slot.lock().unwrap().take() { w.wake(); }
} else { break; }
}
drop(heap);
std::thread::sleep(Duration::from_millis(1));
}
}
✅ 检查点:spawn(async { Sleep::new(_, 1s).await; println!("done") }) 跑 1 秒后打印。
真 Tokio 用 hierarchical timer wheel 优化定时器 O(1) 插入,我们这版 O(log n) 用 BinaryHeap 足够教学。
20.6 Step 4:实现 channel
教学版本是单生产者 / 单消费者 (SPSC):Sender<T> 不 Clone,一旦 sender drop 就关闭 channel。把它升级到真 mpsc 是本章习题之一——核心区别是要用 sender 计数(Arc::strong_count 或独立 AtomicUsize),最后一个 sender drop 才关闭。
use std::collections::VecDeque;
struct Inner<T> {
queue: Mutex<VecDeque<T>>,
receiver_waker: Mutex<Option<Waker>>,
closed: Mutex<bool>,
}
pub struct Sender<T>(Arc<Inner<T>>);
pub struct Receiver<T>(Arc<Inner<T>>);
pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
let inner = Arc::new(Inner {
queue: Mutex::new(VecDeque::new()),
receiver_waker: Mutex::new(None),
closed: Mutex::new(false),
});
(Sender(inner.clone()), Receiver(inner))
}
impl<T> Sender<T> {
pub fn send(&self, v: T) {
self.0.queue.lock().unwrap().push_back(v);
if let Some(w) = self.0.receiver_waker.lock().unwrap().take() {
w.wake();
}
}
}
impl<T> Drop for Sender<T> {
fn drop(&mut self) {
*self.0.closed.lock().unwrap() = true;
if let Some(w) = self.0.receiver_waker.lock().unwrap().take() {
w.wake();
}
}
}
impl<T> Receiver<T> {
pub fn recv(&mut self) -> Recv<'_, T> { Recv { rx: self } }
}
pub struct Recv<'a, T> { rx: &'a Receiver<T> }
impl<'a, T> Future for Recv<'a, T> {
type Output = Option<T>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<T>> {
if let Some(v) = self.rx.0.queue.lock().unwrap().pop_front() {
return Poll::Ready(Some(v));
}
if *self.rx.0.closed.lock().unwrap() {
return Poll::Ready(None); // sender 都 drop 了,channel 关闭
}
*self.rx.0.receiver_waker.lock().unwrap() = Some(cx.waker().clone());
// 再 check 一次防止 race
if let Some(v) = self.rx.0.queue.lock().unwrap().pop_front() {
return Poll::Ready(Some(v));
}
Poll::Pending
}
}
✅ 检查点:两个 task 之间发送 / 接收消息。
注意 receiver poll 末尾的"二次检查"——经典 race 修复模式:先存 waker 再检查 queue,防止"发完了再存 waker"的丢失唤醒。
20.7 Step 5:多线程 work-stealing scheduler
把单一 task queue 拆成 per-worker 局部队列 + 全局 fallback 队列:
struct Worker {
local: Mutex<VecDeque<Arc<Task>>>,
}
struct Shared {
workers: Vec<Worker>,
global: Mutex<VecDeque<Arc<Task>>>,
cvar: Condvar,
}
fn worker_loop(shared: Arc<Shared>, idx: usize) {
loop {
// 1. 从自己的 local queue 拿
if let Some(t) = shared.workers[idx].local.lock().unwrap().pop_front() {
poll_task(t);
continue;
}
// 2. 从全局拿
if let Some(t) = shared.global.lock().unwrap().pop_front() {
poll_task(t);
continue;
}
// 3. 从别人那 steal(从队尾偷,降低冲突)
for i in 0..shared.workers.len() {
if i == idx { continue; }
if let Some(t) = shared.workers[i].local.lock().unwrap().pop_back() {
poll_task(t);
break;
}
}
// 4. 都空了,park 自己
}
}
真 Tokio 用 crossbeam::deque::Worker / Stealer 实现无锁双端队列,本地 push/pop 不需要锁;我们用 Mutex<VecDeque> 简化教学。
✅ 检查点:N 个 worker 并行 poll 多个 task,CPU 利用率上去。
20.8 与真 Tokio 对比
| 维度 | mini-tokio | 真 Tokio |
|---|---|---|
| IO | 无 | mio 全套(epoll / kqueue / IOCP) |
| 调度 | 朴素 work-stealing | LIFO slot 优化、global injection queue、负载感知 |
| Timer | BinaryHeap O(log n) | hierarchical timer wheel O(1) |
| Task slab | Box | Slab + 自定义 allocator |
| Drop / cancellation | 简单 | abort token + 各种 join handle 协议 |
| Pin / 自引用 | 手动 box | tokio::pin! / 自适应 pin |
| Yield 公平性 | 无 | 协作式 yield budget(防止 task 永不让出) |
| 监控 | 无 | tokio-console / Tracing 集成 |
| 性能 | 教学 | 单机数百万 task / 数十万 QPS |
差距很大,但机理是一样的。读完真 Tokio 源码不再像第一次那么吓人——你知道每一个组件大概干什么。
习题(毕业作品)
- 跑通整个 mini-tokio(单线程 → spawn → sleep → channel → 多线程)。
- 基准对比:用 criterion 测 spawn 10000 个空 task 的吞吐,跟 tokio 对比,差距多少?
- 加 select! 宏:声明宏实现简化版
select!。 - 加 abort 支持:
spawn返回JoinHandle,可以abort()。 - 提一个 Tokio 文档 PR(typo 也行 —— 走完贡献流程是真正的毕业)。
本章一句话总结
走完这章,你就走完了从 GC 语言到 Rust 内部的全程。剩下的是日复一日的工程实践——但你已经站在能看得清整张图的高度了。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
附录 A · Rust 编译器报错读法手册
常见 50 种报错的解读模板
核心问题:如何高效读懂 Rust 编译器的报错?
Rust 编译器的错误信息是它给你最大的礼物之一——它常常直接告诉你怎么修。但前提是你能看懂格式。这附录是常见报错速查,每条都用同一个模板:
错误码 / 关键词 | 触发场景 | 典型修法
按主题分组,遇到对照查。
A.1 借用检查错误(borrow checker)
E0382 — use of moved value
let s = String::from("hi");
let _t = s;
println!("{}", s); // ❌ moved
修法:.clone()、改成 &s 借用、或者重构所有权流。
E0502 — cannot borrow as mutable because also borrowed as immutable
let mut v = vec![1, 2, 3];
let r = &v[0];
v.push(4);
println!("{}", r); // ❌ &v 还活着
修法:缩小 r 的作用域,或先 push 再借用。
E0499 — cannot borrow as mutable more than once
let mut v = vec![1, 2];
let a = &mut v;
let b = &mut v; // ❌
修法:同一时刻只能有一个 &mut。让前一个 drop 后再借。
E0596 — cannot borrow as mutable
let v = vec![1, 2];
v.push(3); // ❌ v 不是 mut
修法:let mut v = ...
E0507 — cannot move out of borrowed content
fn take_first(v: &Vec<String>) -> String { v[0] } // ❌
修法:v[0].clone() 或改返回 &String。
E0716 — temporary value dropped while borrowed
let r = &String::from("hi");
println!("{}", r); // ❌ 临时 String 已 drop
修法:把 String::from("hi") 绑到一个 let 变量上。
E0505 — cannot move out of value because it is borrowed
let v = vec![1, 2];
let r = &v;
drop(v); // ❌ r 还借着
"the trait Sized is not implemented for X"
签名里用了 T 但 T 可能是 ?Sized 类型。修法:加 T: Sized 或 ?Sized 显式。
A.2 生命周期错误
E0106 — missing lifetime specifier
fn first(s: &str, t: &str) -> &str { s } // ❌
修法:fn first<'a>(s: &'a str, t: &str) -> &'a str { s }
E0623 — lifetime mismatch
返回值生命周期跟参数对不上。逐个看每个 'a 推断的是哪个,缺哪条信息就显式加哪条。
E0309 — the parameter type T may not live long enough
struct Holder<T> { val: &'static T } // ❌ T 默认有 'static bound
修法:struct Holder<T: 'static> { ... }
E0759 — argument requires that 'a must outlive 'static
通常因为想把借用的对象塞进 'static 容器(Box<dyn Trait> 默认是 + 'static)。修法:换成 owned 类型,或显式加生命周期。
"lifetime may not live long enough" 多见于闭包
闭包捕获了带短生命周期的引用,但被存进长寿命的容器。修法:move + clone 或重构。
异步函数里的 borrowed value does not live long enough
async fn bad(s: String) -> usize {
let r = &s;
tokio::time::sleep(_).await;
r.len() // 经常报 lifetime 问题
}
实际报错是 Future 跨 await 的"自引用"问题。修法:把借用放进 await 之前完成,或者放进 let 绑定别再跨 await 用。
A.3 Trait 错误
E0277 — the trait bound X: Y is not satisfied
最常见报错。它会告诉你:T 类型 / 这个表达式需要 Y trait,但 X 没实现。
fn f<T: Display>(x: T) {}
struct S;
f(S); // ❌ S: Display 没满足
修法:给 S impl Display,或换成实现了 Display 的类型,或在 generic 上放宽约束。
E0599 — method not found
let v: Vec<i32> = vec![];
v.contains_key(&1); // ❌ Vec 没有 contains_key
90% 是用错方法,10% 是漏 import(use std::io::Read 等)——这种情况编译器一般会主动建议 use ...。
E0119 — conflicting implementations
两个 impl 适用于同一类型 → 违反孤儿规则 / coherence。修法:换 newtype 包装。
E0277 + cannot be sent between threads safely
Send 报错。检查链上哪个类型不是 Send(Rc<T> / RefCell<T> / 裸指针)。
dyn Trait 报错 "is not object safe"
trait 有 Self: Sized 方法、fn foo<T>() generic 方法、或返回 Self。修法:把方法标 where Self: Sized(它就不进 vtable),或重设计 trait。
A.4 泛型错误
E0107 — wrong number of generic arguments
let v: Vec<i32, String> = vec![]; // ❌ Vec 只接受一个 T
E0282 — type annotations needed
let v = vec![]; // ❌ vec 是什么类型?
修法:let v: Vec<i32> = vec![] 或 vec![1, 2]。
E0283 — type annotations needed 但带 trait
let n = "1".parse()?; // ❌ parse 成什么?
修法:turbofish "1".parse::<i32>()? 或 let n: i32 = "1".parse()?。
"expected X, found Y"
类型不匹配。仔细对比签名 vs 实际值,常见就是漏了 &、*、.clone()、.to_owned()、.as_str()。
"cannot infer type for type parameter T"
类型推断卡住。修法:在调用处加 turbofish 或注解。
A.5 宏错误
macro_rules 里 no rules expected this token
模式匹配失败。检查 fragment specifier 和分隔符。
recursion limit reached while expanding
宏 / serde 嵌套太深。修法:#![recursion_limit = "256"] 加到 crate root。
proc-macro derive panicked
derive macro 内部 panic 了。看 panic 信息(通常是 syn 解析错误),或用 cargo expand 看展开。
cannot find macro X! in this scope
漏 import。use foo::bar; 不导出 macro,要用 #[macro_use] extern crate foo; 或 use foo::baz!;(Rust 2018+)。
error: format argument must be a string literal
let s = "hi {}";
println!(s, 1); // ❌
println! 的第一个参数必须是字面量。修法:用 print!("{}", format!(s, 1)) 或者改 string literal。
unstable feature used 在 stable rustc 上
某些宏或 API 需要 nightly。修法:换 stable 版的等价、或者切 nightly。
the trait Display is not implemented 在 println!("{}", x)
实现 Display,或在调试场景用 {:?} 配 Debug。
习题 / 实战练习
- 故意写 10 段触发上述错误的代码,把编译器输出贴在每一项下面,对比模板。
- 找最近一次你卡过的 Rust 编译错误,按这个模板写一段"日志"——下次更快定位。
- 培养一个习惯:先把整段编译器输出读完再 google——80% 时间编译器自己已经给了 fix suggestion。
本章一句话总结
Rust 编译器的报错信息是它给你最大的礼物。学会读它,胜过看十本教程——其他语言的 runtime 错误,Rust 让你在 cargo check 里就看见。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
附录 B · Go → Rust 概念翻译大全
200+ 条对应关系
核心问题:Go 中的每个概念在 Rust 中对应什么?
这附录给你一个"翻译字典":Go 概念左 → Rust 概念右。注意:很多翻译不是 1:1,Rust 常常没有完全等价物,但你能找到"做相似事情的工具"。
B.1 基础类型与字面量
| Go | Rust | 备注 |
|---|---|---|
int | i32 / i64 / isize | Go int 平台相关,Rust 倾向显式 |
int8 / int16 / int32 / int64 | i8 / i16 / i32 / i64 | 一致 |
uint8 (byte) | u8 | 一致 |
float32 / float64 | f32 / f64 | 一致 |
bool | bool | 一致 |
string (UTF-8) | String / &str | Rust 区分 owned / borrowed |
[]byte | Vec<u8> / &[u8] | 同上 |
rune | char (4 字节 Unicode scalar) | 一致 |
nil | Option::None | Rust 没有 null |
true / false | true / false | 一致 |
0x1f, 1_000_000 | 0x1f, 1_000_000 | 字面量分隔符一致 |
const Pi = 3.14 | const PI: f64 = 3.14; | Rust 要求类型 |
B.2 控制流
| Go | Rust |
|---|---|
if x > 0 { ... } else { ... } | if x > 0 { ... } else { ... } |
for i := 0; i < n; i++ {} | for i in 0..n {} |
for _, v := range items {} | for v in &items {} |
for { ... } / break / continue | loop { ... } / break / continue |
switch x { case 1: ... } | match x { 1 => ..., _ => ... } |
switch { case x>0: ... } | match x { _ if x > 0 => ..., _ => ... } |
defer f() | Drop trait / RAII / scopeguard crate |
panic("msg") | panic!("msg") |
recover() | std::panic::catch_unwind |
goto | 没有 |
B.3 函数与方法
| Go | Rust |
|---|---|
func add(a, b int) int { return a+b } | fn add(a: i32, b: i32) -> i32 { a + b } |
func (s Type) M() {}(value receiver) | impl Type { fn m(self) {} } |
func (s *Type) M() {}(pointer receiver) | impl Type { fn m(&mut self) {} } |
多返回值 (a, b, err) | (a, b, Result) 或 tuple |
variadic args ...int | args: &[i32] 或 slice / Vec |
closure func(x int) int { return x+1 } | |x: i32| x + 1(或 move |...| ...) |
函数作参数 f func(int) int | f: impl Fn(i32) -> i32 或 Box<dyn Fn...> |
| 命名返回值 | 没有(用 tuple 解构) |
B.4 struct 与 interface
| Go | Rust |
|---|---|
type Point struct { X, Y float64 } | struct Point { x: f64, y: f64 } |
Point{X:1, Y:2} | Point { x: 1.0, y: 2.0 } |
| anonymous struct | tuple struct struct Wrap(i32) 或 struct A { x: i32 } |
| embedded struct(组合) | 显式字段 + 实现 Deref(谨慎) |
interface { Read(p []byte) (int, error) } | trait Read { fn read(&mut self, buf: &mut [u8]) -> Result<usize, Error>; } |
interface{} (empty) | Box<dyn Any> |
| structural typing(隐式实现) | nominal,显式 impl Trait for Type |
type assertion v.(*T) | (x as &dyn Any).downcast_ref::<T>() |
标签 \json:"name"`` | #[serde(rename = "name")] |
B.5 错误处理
| Go | Rust |
|---|---|
if err != nil { return err } | ? 运算符 / Result::map_err |
error interface | Result<T, E> + std::error::Error trait |
errors.New("msg") | anyhow!("msg") / 自定义 enum |
fmt.Errorf("ctx: %w", err) | err.context("ctx")(anyhow) |
errors.Is(err, ErrFoo) | matches!(err, AppError::Foo) |
errors.As(err, &target) | 用 enum 匹配 |
| panic / recover | panic! / catch_unwind(不推荐做 error handling) |
B.6 并发
| Go | Rust |
|---|---|
go f() | std::thread::spawn(|| f()) 或 tokio::spawn(async { f().await }) |
chan T | std::sync::mpsc::channel() / tokio::sync::mpsc / crossbeam::channel |
select { case ... } | tokio::select! / crossbeam::select! |
sync.Mutex | std::sync::Mutex<T> / tokio::sync::Mutex<T> / parking_lot::Mutex |
sync.RWMutex | std::sync::RwLock<T> / parking_lot::RwLock |
sync.WaitGroup | tokio::task::JoinSet / Arc<AtomicUsize> + Notify |
sync.Once | std::sync::OnceLock<T> |
context.Context | tokio_util::sync::CancellationToken + propagate via async fn |
context.WithTimeout | tokio::time::timeout(dur, fut) |
atomic.AddInt32 | AtomicI32::fetch_add(1, Ordering::SeqCst) |
| GC | ownership / Drop / Rc / Arc |
B.7 包管理
| Go | Rust |
|---|---|
go.mod | Cargo.toml |
go.sum | Cargo.lock |
module example.com/foo | [package] name = "foo" |
import "fmt" | use std::fmt; |
package foo | mod foo; / 一个 crate 一个名字 |
internal/ 包 | pub(crate) 可见性 |
| GOPATH / GOPROXY | crates.io / [source.crates-io] |
| vendoring | cargo vendor |
go mod tidy | cargo update + cargo udeps |
| build tags | feature flags |
B.8 测试
| Go | Rust |
|---|---|
func TestX(t *testing.T) | #[test] fn x() {} |
t.Errorf / t.Fatalf | assert! / assert_eq! / panic! |
Benchmark func BenchmarkX(b *testing.B) | criterion c.bench_function("x", ...) |
| Example | doc-test ```rust |
go test ./... | cargo test |
race detector -race | Miri / RUSTFLAGS="-Z sanitizer=thread" |
coverage go test -cover | cargo llvm-cov / tarpaulin |
| testing/quick | proptest / quickcheck |
B.9 常用标准库
| Go | Rust |
|---|---|
fmt.Println | println! |
fmt.Sprintf | format! |
strings.Split | s.split(',').collect::<Vec<_>>() |
strings.Builder | String::new() + push_str |
bytes.Buffer | Vec<u8> 或 bytes::BytesMut |
encoding/json | serde_json |
net/http server | axum / actix-web |
net/http client | reqwest |
database/sql | sqlx / diesel |
os.Open | std::fs::File::open |
time.Now() | std::time::Instant::now() / chrono::Utc::now() |
time.Sleep | std::thread::sleep / tokio::time::sleep |
log | log + env_logger 或 tracing |
flag package | clap crate |
os.Getenv | std::env::var |
regexp | regex crate |
crypto/... | ring / rustls / RustCrypto 套件 |
io.Reader / io.Writer | std::io::Read / Write |
B.10 生态对应
| 用途 | Go | Rust |
|---|---|---|
| Web 框架 | gin / chi / echo | axum / actix-web |
| RPC | grpc-go | tonic |
| 数据库 ORM | gorm / sqlx | sqlx / sea-orm / diesel |
| 日志 / 观测 | zap / zerolog | tracing |
| 配置 | viper | config / figment |
| CLI | cobra / urfave | clap |
| 序列化 | encoding/json / proto | serde + serde_json / prost |
| 任务调度 | robfig/cron | tokio-cron-scheduler |
| 缓存 / KV | redigo / go-redis | redis-rs / fred |
| 队列 | NATS / Kafka client | nats / rdkafka |
| 测试 mock | gomock | mockall |
| 性能分析 | pprof | pprof-rs / cargo flamegraph |
| 文档站点 | hugo / docusaurus | mdBook(本书在用) |
几条战略级差异
- Go 没有 borrow checker;Rust 没有 GC —— 编码风格的根本差异
- Go 的 zero-value 设计 vs Rust 的"必须显式初始化" —— Rust 让你少踩"看似为空,其实未定义"的坑
- Go 拥抱"代码量换显式";Rust 拥抱"类型表达力" —— Go 经典
if err != nil;Rust?一个字符 - Go
interface{}万能;Rust 鄙视dyn Any—— Rust 偏 generic / trait bound,运行时反射几乎不用 - Go 是 productivity-first;Rust 是 correctness-first —— 两个语言适合的项目类型不同
本附录用法:遇到不熟悉的 Rust 概念时,搜表里的 Go 对应物,先有大致印象,再读正文章节。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
附录 C · C# → Rust 概念翻译大全
面向 .NET 工程师的迁移指南
核心问题:C# 中的每个概念在 Rust 中对应什么?
C# 和 Rust 都属于"类型系统强、面向系统的语言",但底层假设不同:C# 假定有 GC + 运行时 + 反射,Rust 假定零成本 + 静态决定一切。这附录给你常用 C# 概念到 Rust 的对照。
C.1 类型系统
| C# | Rust | 备注 |
|---|---|---|
class Foo { ... }(reference type) | struct Foo { ... } + Arc<Foo> 共享 | Rust 默认 value,共享要显式 Arc |
struct Foo { ... }(value type) | struct Foo { ... } | 一致(但 Rust 不区分 class / struct) |
record Foo(int X, int Y) | #[derive(Clone, PartialEq, Eq, Hash)] struct Foo(i32, i32) | record 等价于"自动生成 Equality 的 struct" |
interface IFoo { void Bar(); } | trait Foo { fn bar(&self); } | nominal,显式实现 |
enum Color { Red, Green } | enum Color { Red, Green } | Rust enum 是 sum type,比 C# 强得多 |
enum Color : byte | #[repr(u8)] enum Color { ... } | |
class Tagged<T> | struct Tagged<T> | |
T?(nullable reference) | Option<T> | 显式 |
Tuple<A, B> / (A, B) | (A, B) | |
dynamic | 没有等价(Box<dyn Any> 受限) | Rust 鄙视动态类型 |
object | Box<dyn Any> | 受限 |
| sealed class | 默认就是(Rust 没有继承) | |
| abstract class | 用 trait + default methods | |
| partial class | 没有(模块拆分代替) |
C.2 内存管理
| C# | Rust |
|---|---|
| GC,自动回收 | ownership + Drop,确定性回收 |
IDisposable + using | Drop trait,作用域结束自动释放 |
IAsyncDisposable + await using | 没有 async drop(用 explicit close()) |
WeakReference<T> | Weak<T>(Rc::downgrade / Arc::downgrade) |
GC.Collect() | 没有(对象在所有者 drop 时立刻释放) |
| ref struct(stack-only) | 普通 struct(默认就在栈) |
stackalloc | 普通数组 / SmallVec |
pinned object(fixed) | Box::leak / Pin |
Span<T> / Memory<T> | &[T] / &mut [T](更自然) |
C.3 async/await
| C# | Rust |
|---|---|
Task / Task<T>(hot) | impl Future<Output = T>(lazy) |
ValueTask<T> | 没有专门类型,Rust Future 默认就在栈 |
await task | future.await |
Task.Run(() => ...) | tokio::spawn(async { ... }) |
Task.WhenAll(tasks) | futures::future::join_all 或 JoinSet |
Task.WhenAny(tasks) | tokio::select! / FuturesUnordered::next |
CancellationToken | drop Future / tokio_util::sync::CancellationToken |
Task.Delay(...) | tokio::time::sleep(...).await |
ConfigureAwait(false) | 没有(Rust async 默认无 SyncContext) |
IAsyncEnumerable<T> | futures::Stream<Item = T> |
await foreach | while let Some(x) = stream.next().await |
SemaphoreSlim | tokio::sync::Semaphore |
Channel<T> | tokio::sync::mpsc::channel |
C.4 LINQ → Iterator chain
| C# LINQ | Rust Iterator |
|---|---|
.Where(x => p) | .filter(|x| p) |
.Select(x => x.Name) | .map(|x| x.name) |
.SelectMany(...) | .flat_map(...) |
.OrderBy(x => k) | .sorted_by_key(|x| k)(via itertools)或 Vec::sort_by_key |
.GroupBy(...) | .fold 或 itertools .group_by |
.Distinct() | .collect::<HashSet<_>>() |
.First() / .FirstOrDefault() | .next() |
.Single() | 自己写 |
.Count() | .count() |
.Sum() / .Max() / .Min() / .Average() | .sum() / .max() / .min() / fold for average |
.Any(p) / .All(p) | .any(|x| p) / .all(|x| p) |
.Take(n) / .Skip(n) | .take(n) / .skip(n) |
.ToList() / .ToArray() | .collect::<Vec<_>>() |
.ToDictionary(k, v) | .collect::<HashMap<_, _>>() |
.Zip(other) | .zip(other) |
.Aggregate(seed, fn) | .fold(seed, fn) |
| 表达式树 LINQ to SQL | sqlx + 编译期校验 SQL |
性能差异:LINQ 有委托调用 / 装箱,Rust iterator 是零成本(见 Ch 10)。
C.5 Attributes → derive / proc-macro
| C# | Rust |
|---|---|
[Serializable] | #[derive(Serialize, Deserialize)] |
[JsonProperty("name")] | #[serde(rename = "name")] |
[Required] (DataAnnotations) | 用类型表达(Option<T> vs T) |
[AttributeUsage] 自定义 | 写 proc-macro |
reflection typeof / GetType() | std::any::TypeId / type_name,受限 |
[DllImport] / P/Invoke | extern "C" + #[link] / bindgen |
[Conditional("DEBUG")] | #[cfg(debug_assertions)] |
[Obsolete("...")] | #[deprecated(note = "...")] |
| source generator | proc-macro |
C.6 Nullable
| C# | Rust |
|---|---|
T?(reference / value nullable) | Option<T> |
x?.Foo() | x.as_ref().map(|v| v.foo()) |
x ?? default | x.unwrap_or(default) |
x ??= default | x.get_or_insert(default)(对 Option) |
!(null-forgiving) | .unwrap()(更安全:崩在显式 unwrap 处) |
nullability annotation #nullable enable | Option 强制了 |
Rust 没有"无意识的 null"——把"可能没有"显式编码在类型上,编译器逼你处理 None 分支。这是 Rust 比 C# 安全的核心一点之一。
C.7 异常 → Result
| C# | Rust |
|---|---|
try { } catch (Exception e) { } | match result { Ok(v) => ..., Err(e) => ... } |
try { } catch (FooEx e1) { } catch (BarEx e2) { } | enum + match 多分支 |
throw new FooException("msg") | return Err(FooError::new("msg")) |
throw;(rethrow) | return Err(e) |
using + dispose on exception | Drop 自动处理(无 try/finally) |
Exception.InnerException | source() (std::error::Error::source) |
| custom exception class | enum variant + thiserror |
Ex.StackTrace | RUST_BACKTRACE=1 + std::backtrace::Backtrace |
Task.Exception | Result<T, E> |
经验:Rust 90% 错误用 Result;只在"真正异常" / 不变量违反时 panic。
C.8 Generic constraints → trait bounds
| C# | Rust |
|---|---|
where T : class | 没有完全等价(Rust 没有 reference/value 区分) |
where T : struct | T: Copy(对 value type 模拟) |
where T : new() | T: Default 或 T: From<...> |
where T : IFoo | T: Foo(trait 名一样) |
where T : IFoo, IBar | T: Foo + Bar |
where T : U(类型参数关系) | T: Trait<U> 类似关联类型 |
variance(out T / in T) | variance 由编译器从字段推断(见 Ch 7) |
| reified generic(value type) | monomorphization(同效果) |
C.9 NuGet ↔ crates.io
| C# | Rust |
|---|---|
dotnet add package Foo | cargo add foo |
Foo.csproj | Cargo.toml |
obj/ / bin/ | target/ |
nuget.org | crates.io |
dotnet pack / nuget push | cargo publish |
dotnet restore | cargo build(自动) |
| .NET Standard 多版本兼容 | edition + feature + MSRV |
| private feed | [source] in .cargo/config.toml |
C.10 测试
| C# (xUnit / NUnit) | Rust |
|---|---|
[Fact] / [Test] | #[test] |
[Theory] + [InlineData] | #[test] 多个 / 用 proptest(更强) |
Assert.Equal(a, b) | assert_eq!(a, b) |
Assert.Throws<T>(...) | #[should_panic(expected = "...")] |
Moq | mockall |
| BenchmarkDotNet | criterion |
| Coverlet 覆盖率 | cargo llvm-cov |
| dotnet-trace / dotnet-counters | tokio-console / pprof-rs / flamegraph |
IDisposable cleanup | Drop |
IAsyncLifetime | 测试函数手动 setup/cleanup |
几条战略级差异(给 C# 工程师)
- 没有 GC,但你不用 manual free —— ownership 自动管,但你要学会"思考所有权"
- 没有 exception ——
Result<T, E>把"可能失败"写进类型,所有调用方必须显式处理 - 没有 null reference ——
Option<T>强制你解构,bug 大幅减少 - 没有继承 —— trait + 组合代替,设计上更平
- 没有 reflection —— 类型擦除、运行时类型查询都很受限。这是优点:静态决定一切,优化空间大
- async 模型不同 —— Rust Future 是 lazy + value type,没有 Task pool / SynchronizationContext
- 没有 LINQ provider —— sqlx 用编译期 SQL 校验代替 LINQ-to-SQL,体验不一样但更可控
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
附录 D · Cargo / Clippy 速查
命令与配置的快速参考
核心问题:Cargo 常用命令与 Clippy lint 一览。
放手边,需要时翻。
D.1 cargo 基础命令
# 项目
cargo new my-app # 新建 binary
cargo new --lib my-lib # 新建 library
cargo init # 在当前目录初始化
# 编译
cargo check # 只检查类型,不生成 binary(快)
cargo build # debug build
cargo build --release # release build,优化开启
cargo build --target x86_64-unknown-linux-musl # 交叉编译
# 运行
cargo run # 跑 default binary
cargo run --bin server # 跑指定 binary
cargo run --example foo # 跑 examples/foo.rs
cargo run --release # release 跑
cargo run -- --port 8080 # 把参数传给程序(-- 后)
# 测试
cargo test # 跑全部
cargo test some_test_name # 跑匹配名字的测试
cargo test --lib # 只 src/ 单元测试
cargo test --tests # 只 tests/ 集成测试
cargo test --doc # 只 doc-test
cargo test -- --nocapture # 显示 println!
cargo test -- --test-threads=1 # 串行(适合改全局状态的测试)
# 格式化 + lint
cargo fmt # 格式化
cargo fmt --check # 检查是否已格式化(CI 用)
cargo clippy # lint
cargo clippy --fix # 自动修复部分
cargo clippy -- -D warnings # 警告变错误
# 文档
cargo doc # 生成 target/doc/...
cargo doc --open # 打开浏览器
cargo doc --no-deps # 不生成依赖文档(快)
# 依赖
cargo update # 更新 Cargo.lock
cargo update -p tokio --precise 1.40.0 # 锁定具体版本
cargo tree # 看依赖树
cargo tree -d # 重复依赖检查
D.2 cargo 高级命令(常装的子命令)
# 安装
cargo install cargo-edit cargo-expand cargo-watch cargo-nextest
# cargo-edit
cargo add tokio --features full # 加依赖
cargo add tokio@1 # 指定版本
cargo rm serde # 移除
cargo upgrade # 升级所有依赖到 latest
# cargo-expand:看宏 / async 展开
cargo expand --bin myapp
cargo expand --lib path::module
cargo expand --tests test_name
# cargo-watch:文件改动自动跑命令
cargo watch -x check
cargo watch -x 'test some_test -- --nocapture'
# cargo-nextest:更快的 test runner
cargo nextest run
# 二进制体积分析
cargo install cargo-bloat
cargo bloat --release # 看哪些函数最大
cargo bloat --release --crates # 看哪个 crate 最大
# 未使用依赖
cargo install cargo-udeps
cargo +nightly udeps # 找出 [dependencies] 里没用上的
# 过期依赖
cargo install cargo-outdated
cargo outdated
# 安全 / license 审计
cargo install cargo-audit cargo-deny
cargo audit
cargo deny check
# 覆盖率
cargo install cargo-llvm-cov
cargo llvm-cov --html
# 模糊测试
cargo install cargo-fuzz
cargo fuzz init
cargo fuzz run target_name
D.3 常用 Cargo.toml 配置
dependency 写法
[dependencies]
tokio = "1" # 最新 1.x
tokio = { version = "1.40", features = ["full"] } # 指定特性
local-lib = { path = "../local-lib" } # 本地
git-lib = { git = "https://github.com/x/y.git", branch = "main" }
git-lib = { git = "https://...", tag = "v0.1" }
git-lib = { git = "https://...", rev = "abcdef" } # 锁 commit
foo = { version = "1", default-features = false, features = ["bar"] }
foo = { version = "1", optional = true } # feature 才启用
profile
[profile.release]
opt-level = 3 # 0-3 / "s" / "z" (size)
lto = "thin" # "fat" / "thin" / false
codegen-units = 1 # 1 最优,默认 16
strip = "symbols" # 减小二进制
debug = false # 不加 debug info
panic = "abort" # 不展开栈(更小)
overflow-checks = false
[profile.dev]
opt-level = 1 # dev build 也开一点优化
debug = "line-tables-only" # 调用栈够用,体积更小
incremental = true
[profile.bench]
debug = true # 让 flamegraph 看得见名字
[profile.release-with-debug] # 自定义 profile
inherits = "release"
debug = true
strip = "none"
feature 写法
[features]
default = ["pg", "tls"]
pg = ["dep:sqlx-postgres"]
mysql = ["dep:sqlx-mysql"]
tls = ["dep:rustls", "dep:rustls-pemfile"]
unstable = [] # 仅启用 #[cfg(feature = "unstable")] 代码块
[dependencies]
sqlx-postgres = { version = "0.8", optional = true }
sqlx-mysql = { version = "0.8", optional = true }
rustls = { version = "0.23", optional = true }
rustls-pemfile= { version = "2", optional = true }
镜像 / 私有源
# .cargo/config.toml
[source.crates-io]
replace-with = "ustc"
[source.ustc]
registry = "https://mirrors.ustc.edu.cn/crates.io-index"
# 私有源
[registries.my-corp]
index = "ssh://git@host/my-registry"
workspace
[workspace]
members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
[patch] 覆盖依赖
[patch.crates-io]
serde = { git = "https://github.com/serde-rs/serde", branch = "main" }
D.4 Clippy 常见 lint
按类别(clippy::category):
clippy::correctness(默认 deny)
| Lint | 含义 |
|---|---|
clippy::approx_constant | 用了近似常量(0.999... 而非 std::f64::consts::PI) |
clippy::wrong_self_convention | to_x 应该 &self,into_x 应该 self |
clippy::eq_op | x == x 永远 true |
clippy::erasing_op | x * 0 之类 |
clippy::style
| Lint | 含义 |
|---|---|
clippy::needless_return | return x; 在末尾(应该 x) |
clippy::collapsible_if | if a { if b { ... } } 可合并 |
clippy::redundant_field_names | Foo { x: x } 可写 Foo { x } |
clippy::let_and_return | let x = ...; x 可直接 ... |
clippy::single_match | match x { A => ..., _ => () } 用 if let |
clippy::complexity
| Lint | 含义 |
|---|---|
clippy::needless_borrow | &&x 多余 |
clippy::unnecessary_unwrap | if x.is_some() { x.unwrap() } 用 if let |
clippy::useless_conversion | String::from(s.to_string()) 多余 |
clippy::map_unwrap_or | .map(...).unwrap_or(...) 用 .map_or(...) |
clippy::perf
| Lint | 含义 |
|---|---|
clippy::redundant_clone | clone 多余 |
clippy::large_enum_variant | enum 一个变体远大于其他(浪费内存) |
clippy::format_in_format_args | format!("{}", format!(...)) 嵌套 |
clippy::useless_vec | &vec![1, 2, 3] 应该 &[1, 2, 3] |
clippy::pedantic(默认不开)
更严格,可选开。生产 crate 推荐:
#![warn(clippy::pedantic)]
#![allow(clippy::module_name_repetitions)] // 觉得太烦的关掉
整 crate 配置
// crate root
#![warn(clippy::all, clippy::pedantic)]
#![warn(missing_docs)]
#![deny(unsafe_code)] // 整个 crate 禁 unsafe
#![forbid(unsafe_code)] // 更严:连 #[allow] 也覆盖不了
D.5 rustfmt 配置(rustfmt.toml)
# 行宽
max_width = 100
# import 整理
imports_granularity = "Crate" # 把 use 按 crate 合并
group_imports = "StdExternalCrate"
reorder_imports = true
# 字符串 / 数组格式
use_field_init_shorthand = true
use_try_shorthand = true
# 调用链
chain_width = 80
fn_call_width = 80
# 注释
wrap_comments = true
comment_width = 100
normalize_comments = true
# 函数参数
fn_params_layout = "Tall"
# trailing comma
trailing_comma = "Vertical"
# 把 fn body 短的并到一行
fn_single_line = false
unstable_features = true 后能用更多选项,但需要 nightly rustfmt。
常用 CI 套路
- run: cargo fmt --check
- run: cargo clippy --all-targets --all-features -- -D warnings
- run: cargo test --all-features
- run: cargo audit
- run: cargo deny check
任何 PR 都过这五关 → 代码质量底线有了。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |
附录 E · 推荐阅读路线图
进阶资源、社区资源、值得 follow 的人
核心问题:走完这本书之后,下一步读什么?
这本书提供了"从 GC 语言到 Rust 内部"的全程框架。但 Rust 生态变化快、深水区永远学不完——这附录给你一份持续学习清单。
E.1 进阶书
必读
- The Rust Programming Language(俗称 "the book")—— Steve Klabnik & Carol Nichols。官方入门书,本书的对照参考。如果某一章你觉得讲得不够全,去查"the book"。
- Rust for Rustaceans —— Jon Gjengset。最推荐的第二本书。讲"用 Rust 写库"的工程实践,涵盖 API 设计、unsafe、async、FFI、性能。读完你能写出 idiomatic crate。
- Programming Rust (2nd ed.) —— Jim Blandy & Jason Orendorff。比 the book 厚,系统性强。机械工业出版社有中译。
深度
- Rust Atomics and Locks —— Mara Bos(rustc 主席之一)。写并发库的必读。从原子操作 → Mutex / RwLock / Condvar 内部 → memory ordering 一步步搭。
- The Rustonomicon —— 官方写的"unsafe rust 黑魔法"。在线免费:https://doc.rust-lang.org/nomicon/。
- Zero To Production In Rust —— Luca Palmieri。手把手用 axum / sqlx / 测试驱动写一个真实的邮件订阅服务。生产级实践教材。
领域专用
- Async Rust —— Maxwell Flitton, Caroline Morton。Tokio 等异步生态系统讲解。
- Hands-On Rust —— Herbert Wolverson。用 Rust 写游戏(roguelike)。看怎么把 ECS 跟 Rust 类型系统融合。
E.2 官方深度文档
The Rustonomicon https://doc.rust-lang.org/nomicon/
The Reference https://doc.rust-lang.org/reference/
Async Book https://rust-lang.github.io/async-book/
Edition Guide https://doc.rust-lang.org/edition-guide/
The Cargo Book https://doc.rust-lang.org/cargo/
The rustc dev guide https://rustc-dev-guide.rust-lang.org/ ← 编译器自身
The Unstable Book https://doc.rust-lang.org/unstable-book/ ← nightly 特性
The API Guidelines https://rust-lang.github.io/api-guidelines/
Rust by Example https://doc.rust-lang.org/rust-by-example/
凡是涉及"具体语法 / 模型语义"的疑问,先翻 Reference。最权威。
E.3 博客与文章
必订
- fasterthanli.me(Amos)—— 长文教学,深入到字节级别,写得非常清楚。系列 "Declarative macros" / "Pin and suffering" 是经典。
- Aleksey Kladov(matklad)(rust-analyzer 作者)—— 简洁、深刻、工程导向。
- without.boats(原 Rust async 主要设计者之一)—— 关于 Pin / async / Future 的设计原因。
- Yoshua Wuyts(
tide作者,async 思考者) - Niko Matsakis —— rustc 主席之一,语言设计文章。
- Smallcultfollowing(Niko 个人博客的别名)
中文社区
- rust-cn.com —— 官方中文站
- rust-lang.github.io/this-week-in-rust(英文,但每周一发,内容跟英文社区同步)
E.4 视频
- Crust of Rust —— Jon Gjengset 的 YouTube 系列。Rust 视频教学天花板。每集 1-2 小时,实时写一个 crate,讲他的思路。Iterator / Lifetime / Channels / Sorting / etc 系列必看。
- Decrusting —— Jon Gjengset 另一系列,解构 tokio / axum / serde 等核心 crate。
- Building a Custom Allocator / Tokio internals 等 RustConf talks
- Phil Opp 的 Blog OS —— 用 Rust 写操作系统的系列(博客 + 视频)。即使你不写 OS,看里面 no_std / unsafe / 内存管理也长见识。
- fasterthanli.me 也发 video(高质量)
E.5 开源项目(读源码)
第一梯队(必读)
- Tokio —— 学异步 runtime。从
tokio::time::sleep入手,顺藤到 driver、scheduler。 - Hyper —— HTTP 库。看 Service trait 是怎么搭建一个网络框架的。
- Axum —— Web 框架。看 trait magic 如何让 handler 是普通 async fn。
- sqlx —— 数据库。看 macro 怎么编译期检查 SQL。
- ripgrep —— CLI 范本。性能、并发、跨平台路径处理的活教材。
想看 unsafe / 性能
- bytes —— 高性能 buffer。SBO + 引用计数 + zero-copy slicing。
- dashmap —— 并发 HashMap。bucket-level locking。
- smallvec / arrayvec —— stack-based 容器。
- crossbeam —— lock-free 数据结构。
- rayon —— 并行计算。从 par_iter() 入手,看怎么搭 work-stealing。
想看 macro / DSL
- serde —— 序列化。derive macro 经典。
- clap —— CLI 解析。derive + builder。
- diesel —— 编译期 SQL DSL,极致用 trait 系统。
想看 FFI / 平台
- wasmtime —— WASM runtime。
- rusqlite —— SQLite 包装,bindgen + 安全 API 经典。
E.6 社区
- This Week in Rust —— 每周邮件列表,RFC / blog / crate / job。订。
- r/rust(Reddit)—— 讨论 / 提问 / 新工具发布。
- Rust 用户论坛 —— https://users.rust-lang.org/
- Rust 内部论坛 —— https://internals.rust-lang.org/(讨论语言设计)
- Discord / Zulip —— Rust 项目主要协作平台
- RustChinaConf / RustConf / EuroRust —— 年会,YouTube 有录像
E.7 值得 follow 的人
(2026 年视角,排名不分先后)
| 人 | 身份 / 输出 |
|---|---|
| Niko Matsakis | rustc 主席,语言设计博客 |
| Jon Gjengset | 教学视频 (Crust of Rust) + 书 |
| Mara Bos | 库团队主席,《Atomics and Locks》作者 |
| Tyler Mandry | async 工作组 |
| Yoshua Wuyts | async / executor 思考者 |
| boats | Future / Pin 原始设计者 |
| matklad / Alex Kladov | rust-analyzer / IDE / 工程实践 |
| Amos / fasterthanli.me | 长文博主 |
| Steve Klabnik | The Book 作者 |
| dtolnay | serde / syn / quote / anyhow / thiserror 作者(生态半边天) |
| BurntSushi | ripgrep / regex / serde-json 等基础库 |
| sgrif | diesel 作者,trait magic 鬼才 |
| 张汉东 (rust-zh) | 中文社区组织者,翻译者 |
学完这本书后的"持续学习清单"
按"投入产出"排序:
- 每周 30 min 看 This Week in Rust —— 保持生态感
- 每个月读一篇 fasterthanli.me 长文 —— 深度
- 每月写一段非平凡 Rust 代码 —— 任何小工具 / 副业 / 重写
- 每年看 2 本 Rust 书 —— Rust for Rustaceans / Atomics and Locks 先
- 每年贡献一次开源 PR —— 哪怕 typo 修复,走流程的价值大
一句送别
"C 让你直面机器,Java 让你直面对象,Haskell 让你直面类型。Rust 让你直面所有权——一个你十年来下意识在思考但从未系统化的概念。从 GC 语言来,你不是学一个新语言,你是把脑子里一直存在的'谁负责这块内存 / 谁负责这条连接'的直觉,搬到类型系统里,变成可以编译期验证的事。"
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |