引言

从 Go / C# / TypeScript 到 Rust 的系统工程师转型

A book for the 14-year veteran who wants to actually master Rust — including the unsafe parts.


关于这本书

市面上的 Rust 教材分两类:

  1. 新手向(The Rust Book、Rust by Example):假设你刚学编程,从变量、循环讲起。对老手来说慢且啰嗦。
  2. 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-7Struct & Enum / 错误处理 / Trait / 泛型进阶
III 内存与资源Ch 8-10智能指针 / 内部可变性 / Iterator & 闭包
IV 并发与异步Ch 11-13线程 / async/await / Tokio
V 工程实践Ch 14-16Cargo / Axum + sqlx / 测试 & bench
VI 深水区Ch 17-20宏 / unsafe / FFI / Mini-Tokio

总字数预计:18-22 万字,约 600-700 页 A5。


配套学习路线

本书配 12 周精通计划,把每章学习节奏拆成 84 天可执行的日任务。一边读书一边做计划里的练习,效果远胜单纯读。


给读者的承诺

  1. 不复读官方文档。The Rust Book 已经写得很好,这本书做的是它没做的事:从你已知世界翻译。
  2. 每章配实战练习。不是 toy example,是真实工程场景。
  3. 不藏复杂度。Pin、unsafe、async 内部 —— 该讲深的地方讲深,不掩盖权衡。
  4. 不假装精通。我自己卡过的地方会标记 "⚠️ 这里我也曾困惑",并说明怎么走出来。

开始吧。


Drafted 2026-05 · Working title, subject to change.

Rust 12 周精通计划

节奏:1.5h/天 × 6 天/周 × 12 周 ≈ 108h 目标:全面掌握 Rust,含 unsafe / 异步内部 / 宏 / FFI 起点:Go/TS/C# 多年经验,Rust 零基础


总体设计原则

  1. 代码量 > 阅读量:每天至少敲 50 行 Rust,不能只看书。
  2. 借用检查器是老师不是敌人:卡住时不要 .clone() 绕过,先理解为什么编译器拒绝。
  3. 每周一篇笔记:用自己的话总结一周关键概念,公开发布更好(GitHub repo 或博客)。
  4. 错题本:记录被借用检查器卡住的每个具体场景,周末复盘 mental model。
  5. 真实项目是练兵场:Week 7 起拿生产代码库当试炼场,Week 12 提一个有质量的 PR 作毕业作品。
  6. 不囤教程:选定 The Rust Book + Rustlings + Programming Rust + Rust for Rustaceans 这四本,不看其他十几个教程。
  7. 不立刻问 AI:遇到不会的先自己读编译器报错,5 分钟后再求助。借用检查器的 mental model 必须内化,绕过去等于没学。

阶段一 · 基础与所有权(Week 1-2)

目标:通过编译器的所有权检查不再是主要障碍。

Week 1 · 语法与心智模型迁移

日内容时长
1装 toolchain(rustup / rust-analyzer / clippy / rustfmt),配编辑器,Hello World;读 The Rust Book Ch 1-31.5h
2The Rust Book Ch 4(Ownership)——分水岭,慢读;同步 Rustlings move_semantics 系列1.5h
3The Rust Book Ch 5-6(Struct/Enum/Pattern Matching);Rustlings structs enums1.5h
4The Rust Book Ch 7-8(Modules/Collections);Rustlings modules vecs hashmaps1.5h
5The Rust Book Ch 9(Error Handling);重点理解 ? 与 From trait 的关系1.5h
6复盘 + 小项目:CLI 工具读 CSV 输出统计2h
7休息—

Week 2 · 类型系统基石

日内容时长
8The Rust Book Ch 10(Generics/Traits/Lifetimes)——这章读 2 遍1.5h
9Ch 10 第二遍 + Rustlings generics traits lifetimes1.5h
10Ch 11(Testing);给 Week 1 的 CLI 加单元测试 + 集成测试1.5h
11Ch 12(CLI Project);跟着写 minigrep1.5h
12Rust by Example 中所有权 + trait 的全部例子精读1.5h
13minigrep 扩展:支持正则 + 并行2h
14休息—

阶段二 · 类型系统深入(Week 3-4)

目标:能用 trait 抽象设计出符合 Rust 习惯的 API。

Week 3 · Trait 与函数式

日内容时长
15Ch 13(Closures/Iterators);三种 Fn trait(Fn / FnMut / FnOnce)的语义差异1.5h
16Iterator adapter 全家桶熟练:map / filter / fold / collect / zip / chain / flat_map / scan;写 100 行链式代码1.5h
17Trait 进阶:associated types、default methods、supertraits1.5h
18dyn Trait vs impl Trait,object safety 规则1.5h
19错误处理工程化:thiserror(库) + anyhow(应用)的分工;重写 Week 1 项目1.5h
20复盘 + 写一篇笔记1.5h
21休息—

Week 4 · 智能指针与内部可变性

⚠️ 第一个分水岭。这周卡过去,后面 Tokio 内部才能真正读懂。

日内容时长
22Ch 15(Smart Pointers);Box / Rc / RefCell 各自的使用场景1.5h
23内部可变性:Cell vs RefCell vs Mutex;运行时借用检查的代价1.5h
24Drop / Deref / DerefMut trait;RAII 模式1.5h
25实战:实现一个简单的 Arena allocator1.5h
26实战:实现一个 LRU cache(Rc<RefCell<Node>> 双向链表)1.5h
27阅读 std::collections 中 Vec 或 HashMap 源码片段1.5h
28休息—

阶段三 · 并发编程(Week 5-6)

目标:能写正确且符合 Rust 哲学的并发代码,理解 Tokio 的工作方式。

Week 5 · 线程并发

日内容时长
29Ch 16(Threads);thread::spawn / JoinHandle / move closure1.5h
30Send + Sync marker trait——透彻理解,这是 Rust 并发安全的根基1.5h
31std::sync::mpsc;Arc<Mutex<T>> 共享状态模式1.5h
32crossbeam 文档通读;理解为什么 std 之外还需要它(scoped threads / channel 性能)1.5h
33实战:并发 web crawler(work-stealing 模式)2h
34复盘 + 写笔记1.5h
35休息—

Week 6 · async/await 入门

日内容时长
36Ch 17(Async);Future trait 概念;async fn 编译成状态机1.5h
37Tokio 入门:#[tokio::main] / tokio::spawn / JoinHandle1.5h
38Tokio 异步原语:tokio::sync::{mpsc, oneshot, broadcast, Mutex, RwLock}1.5h
39select! 宏 / timeout / abort / cancellation 模式1.5h
40实战:用 Tokio 重写 Day 33 的爬虫,对比性能2h
41阅读你目标项目的真实代码,试图看懂 50%1.5h
42休息—

阶段四 · 生产级 Rust(Week 7-8)

目标:第一次给真实项目提交可合并的代码。

Week 7 · Web 服务实战

⚠️ 第二个分水岭。开始动真实代码库——被 review 一次值看一周书。

日内容时长
43Axum 入门:Router / handler / extractor / response1.5h
44Axum 中间件:tower::Service / tower::Layer 的设计哲学1.5h
45tracing 与结构化日志;span 与 event 模型1.5h
46sqlx 加深:query! / query_as! 宏的编译期检查1.5h
47选目标项目一个 small issue 开始实现2h
48继续实现 + 自我 review2h
49休息—

Week 8 · 工程化

日内容时长
50Cargo workspace + monorepo1.5h
51Feature flags / conditional compilation / cfg!1.5h
52Benchmarking:criterion crate;微基准的常见陷阱1.5h
53Profiling:cargo flamegraph / perf / tokio-console1.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 specifier1.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 业务代码"差距很大,这周不要跳。

日内容时长
64The Rustonomicon 序言 + raw pointer1.5h
65Rustonomicon:aliasing 规则、lifetime variance(协变/逆变/不变)1.5h
66UnsafeCell 与内部可变性的真相——为什么 &T 不一定是不可变1.5h
67FFI 入门:调 C 库(libc / bindgen)1.5h
68FFI 出口:把 Rust 暴露给其他语言(cbindgen / PyO3 简介)1.5h
69重读 std 中 Box 和 Vec 的源码,这次能看懂 unsafe 块为什么这么写2h
70休息—

阶段六 · 大师级(Week 11-12)

目标:能设计抽象、能读 runtime 源码、能写出地道的 idiomatic Rust。

Week 11 · Async 内部

日内容时长
71Pin 与 self-referential structs 的真正问题(为什么需要 Pin)1.5h
72手撸最简 executor(参考 mini-tokio 或 Phil Opp 的 async 教程)2h
73Waker / Context / Poll 链路打通;唤醒机制的实现1.5h
74Stream trait;futures::Stream 与 tokio_stream1.5h
75阅读 Tokio runtime 源码:driver 与 scheduler2h
76复盘 + 写笔记1.5h
77休息—

Week 12 · 收官

日内容时长
78类型系统魔法:GATs(Generic Associated Types)1.5h
79HRTB(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庆祝 🎉—

关键资源(精选不囤货)

主线:

进阶:

  • 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,看一遍胜过读十篇博客

社区:


进度追踪表

每周末填一行,公开 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。但请注意以下三件事:

  1. 编译器强制了所有并发安全约束。每一个跨 task 共享的值都被编译器验证满足 Send + Sync 约束。运行时不会有 data race——这不是承诺,这是定理。
  2. 没有"忘了 await"这种错误。Rust 的 Future 是 lazy 的,你必须 await 才能让它执行。如果你忘了,编译器会警告"unused future, must be polled"。
  3. 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++GoRust
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 + DropRust 是编译期决定,无 GC 开销
Go: defer 资源清理Drop trait + RAIIRust 自动调用,不需要写 defer
C#: using / IDisposableDrop trait同上,Rust 不需要 using 关键字
Go: pointer *T&T 借用 / Box<T> 拥有Rust 区分"借用"和"拥有",Go 不区分
Go: nil pointerOption<T> = NoneOption 是 enum,必须显式 match
C#: ref / out&mut TRust 的 &mut 受借用规则约束
C#: nullable referenceOption<T>Rust 没有 null,所有"可能为空"都是 Option
TS: T | undefinedOption<T>同上,但 Rust 编译器强制处理 None 分支

错误处理

你已经懂的Rust 对应关键差异
Go: if err != nilResult<T, E> + ?? 自动 propagate,不用写 if
C#: try / catchResult<T, E> + ?Rust 没有 exception,错误是值
Go: panicpanic!行为相似,但 Rust 不鼓励用 panic 处理业务错误
C#: exception inheritanceBox<dyn Error> / 自定义 enumRust 没有继承,用 enum 或 trait object

类型系统

你已经懂的Rust 对应关键差异
Go: interfacetraitRust trait 多了 associated types、blanket impl、static dispatch
C#: interfacetraitRust trait 没有继承,用 supertrait 表达约束
TS: interfacetraitTS 是 structural typing,Rust 是 nominal + 显式 impl
TS: discriminated unionenum几乎一样的语义,Rust 更严格
C#: pattern matchingmatchRust 强制穷尽性
Go: switch v.(type)match on enumRust 的更强,因为类型本身就是 enum
C#: generic where T : ...where T: ...语法几乎一样
Go: 泛型(1.18+)完整泛型系统Rust 远比 Go 强大
TS: conditional types(没直接对应)Rust 用 trait + impl block 实现类似效果

并发与异步

你已经懂的Rust 对应关键差异
Go: goroutinetokio::spawn 或 std::thread::spawnTokio task 是 stackless,thread 是 OS 线程
Go: channelmpsc::channel 或 tokio::sync::mpscRust channel 区分 owned vs cloned sender
Go: sync.Mutexstd::sync::Mutex 或 tokio::sync::MutexRust 的 Mutex 把数据包在里面,只能通过锁访问
Go: sync.WaitGroupJoinHandle::join() 或 JoinSetRust 用所有权表达"等待这个 task"
C#: Task<T>Future<Output = T>Rust Future 是 lazy,Task 是 hot
C#: async/awaitasync/await语法一样,语义不一样(lazy vs eager)
C#: IDisposable asyncDrop in async (有坑)Rust 在 async 里 drop 资源有微妙问题
JS: PromiseFuture同 Task,Promise 是 hot

工程实践

你已经懂的Rust 对应关键差异
Go: go.modCargo.tomlCargo 功能更多(workspace、feature、build script)
pnpm workspaceCargo workspace概念几乎一样
Go: go testcargo testRust 测试可以写在源文件里(#[cfg(test)] mod tests)
Go: go vetcargo clippyClippy 更严格,有 600+ lints
C#: NuGetcrates.io几乎一样的发布模式
C#: source generatorproc 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 immutable
  • X does not live long enough
  • cannot move out of borrowed content
  • the 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 版本少一个数量级。

关键建议

  1. 不要在 Week 2-3 用 AI 帮你绕过借用错误。每次借用错误都自己读编译器报错,自己想为什么。AI 帮你解决了表面问题,但你的 mental model 没建立起来。
  2. 每次想 .clone() 之前,问自己:这个值我是真的需要复制?还是我没想清楚所有权?
  3. 不要怕慢。Week 1-3 慢是正常的。Week 4 后你的速度会追上来,Week 7 后你会比 Go 写得还快(因为 bug 少)。
  4. 找一个真实项目。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-analyzer
  • tamasfe.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 的核心差异是多了"所有权"这个维度。这一章我们正式打开这个维度。

读完这章,你应该能回答以下问题(不是背答案,是从工程动机出发推导出答案):

  1. 为什么 Rust 要求你区分 stack 和 heap?Go 和 C# 是怎么把这件事藏起来的?
  2. 为什么 let s2 = s1 在 String 类型上会让 s1 失效,在 i32 上不会?
  3. 为什么 Rust 不需要 defer/using/finally 来释放资源?
  4. 函数参数应该写 T 还是 &T 还是 &mut T?判断依据是什么?
  5. 什么时候 .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)

这个布局有几个工程含义,值得记住:

  1. String 本身在 stack 上,只占 24 字节(64 位机器)。它是个"瘦"的描述符。
  2. 真正的字符数据在 heap 上,长度运行时才知。
  3. 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 原文:

  1. Each value in Rust has an owner.
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, the value will be dropped.

中文版:

  1. 每个值都有一个"所有者"。
  2. 同一时刻只能有一个所有者。
  3. 所有者离开作用域时,值会被丢弃(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 同一时刻只能有一个 ownerdouble-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++ 的发明)
Javafinally block 或 try-with-resources程序员(不写就漏)
Godefer程序员(不写就漏)
C#using / IDisposable程序员(不写就漏)
Pythonwith / __exit__程序员(不写就漏)
RustDrop 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
函数只需要读&Tfn print_user(u: &User)
函数需要修改&mut Tfn 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;
}

责任分布:

  • 内存:程序员
  • 并发安全:程序员
  • 资源清理:程序员

容易出错的地方(数一下):

  1. malloc 没检查返回值
  2. strdup 失败也没检查
  3. 调用方忘了 free → 内存泄漏
  4. 调用方 free 了一次又 free 了一次 → double-free,崩溃
  5. 调用方在 free 之后又访问 → use-after-free
  6. 函数文档里没说清楚 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()
}
}

三语言对比表

维度CGoRust
代码长度长短短
内存安全程序员负责GC 负责编译器负责
调用方需要看文档才知道是否要释放✅❌❌(类型签名告诉你)
改一处 API 改动会引发的 cascade大(文档、调用方、内存协议)小中(类型签名变化,编译器列出所有需要改的地方)
运行时开销无GC无
重构信心低中高

最后一行是隐性收益:Rust 重构信心高。你改了一个函数签名,编译器会列出所有影响的地方,你逐个改完代码就能编译,编译过了就大概率能跑。Go 改了 interface 不会立刻报错(只在调用点才报),C 更糟糕(可能完全不报)。

这一节的关键 takeaway

Rust 的代价(学习曲线陡)和收益(编译期保证、零运行时开销、重构信心)是一对耦合的事情。你接受了显式所有权这个负担,换来一组别的语言要么放弃要么靠纪律维持的保证。


2.7 .clone() 的工程判断

新手常用 .clone() 绕过借用检查。Ch 1 我们说这是 Week 2-3 的"危险时刻"。这一节给你一个具体的判断流程。

.clone() 在做什么

Clone::clone() 是显式的、可能开销大的复制。对 String 来说,clone 会:

  1. 在 heap 上分配新的内存
  2. 把原 String 的字符数据复制过去
  3. 返回一个新的 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 可以接受

经验法则

  1. 小项目早期可以 clone 多一点——Rust 的 clone 性能也比你想的快,过早优化是大坑。
  2. 代码 review 时,看到 clone 多问一句"为什么"——大部分情况下能消除。
  3. profile 时如果发现热点是 clone,认真重构所有权流动——这是真问题,不是风格偏好。
  4. 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 outclone 开销✅独立默认折中,优先选这个
D: Arc sharedArc clone(便宜)✅(共享)共享多处长期持有

这告诉你什么

同一个任务有 4 种合理实现,每种都对应一组不同的工程假设。Rust 强迫你显式做这个选择,Go 和 C# 帮你藏起来了(Go 全是 reference,C# 全是 reference,你不需要选)。

这是 Rust 学习曲线陡的根本原因,也是它工程价值的来源。多了一个维度的设计自由,代价是多了一个维度的设计负担。

工程经验:先写方案 C(borrow in / owned out)。等 profile 出问题了再改成 A 或 D。方案 B 适合内部辅助函数,不太适合 API 边界。


2.9 章末小结与习题

本章核心概念回顾

  1. Stack vs Heap:Rust 让你显式区分,因为没有 GC 帮你藏。String/Vec/Box 在 stack 有描述符,heap 有数据。
  2. Ownership 三规则:每个值有 owner、同时只有一个 owner、owner 离开作用域时 drop。这三条是无 GC 内存安全的最小约束。
  3. Move vs Copy:取决于类型有没有 heap/外部资源。Copy 是 marker trait,String 不实现是因为 bitwise copy 会 double-free。
  4. Drop 是默认 RAII:不用 defer / using,资源清理是免费的副产品。
  5. 函数签名的三种形式:T / &T / &mut T 各自对应"接管 / 只读 / 可写"的契约。设计 API 时优先用更通用的类型(&str 而不是 &String)。
  6. .clone() 的判断流程:每次 clone 之前问 4 个问题——真需要两份?能不能 borrow?是不是要共享(Arc)?是不是为了过编译?
  7. 同一任务的多种所有权策略:四方案对比,优先用 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 三规则的扩展形式:

  • 共享借用 &T vs 独占借用 &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 的概念。

读完这章,你应该能:

  1. 说清楚 &T 和 &mut T 为什么必须互斥
  2. 看到 'a 标注时不再恐慌——你知道它在告诉编译器什么
  3. 读懂 Rust 编译器最常见的 5 种借用报错
  4. 给一个函数签名,判断它的生命周期标注是否合理
  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(生命周期省略规则)在帮你。规则有三条:

  1. 每个引用参数有它自己的 lifetime
  2. 如果只有一个输入 lifetime,所有输出 lifetime 都跟它一致
  3. 如果有多个输入 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: <补充说明>

读报错的顺序:

  1. 先看 help: 那行——通常直接告诉你怎么改
  2. 看下划线位置,理解编译器认为问题在哪
  3. 看 note:,补充信息
  4. 最后才看错误码 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);
    }
}

解读

这个例子用到本章每一个概念:

  1. struct 含引用字段:WordIter<'a> 必须有 lifetime 参数,因为 remaining: &'a str 是借用
  2. new(s: &'a str) -> Self:输入借用的 lifetime 跟 struct 的 lifetime 一致
  3. Iterator::Item = &'a str:每次 next 返回的也是 'a 生命周期的引用
  4. 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 章末小结与习题

本章核心概念回顾

  1. 借用 &T / &mut T:让函数暂时使用值而不接管所有权
  2. 借用规则(Aliasing XOR Mutability):任意时刻要么多个共享借用,要么一个独占借用
  3. NLL:借用作用域在最后一次使用后立刻结束,不必等到 }
  4. Lifetime 'a:不是手动管理生命周期,是给编译器看的引用关系标注
  5. Lifetime elision:大多数情况编译器自动推导,你不用写
  6. 'static 的两个含义:&'static T 是引用指向永久数据;T: 'static 是类型不持有非 'static 借用
  7. 5 种典型借用错误:认得出模式,debug 就快
  8. 零成本迭代器:用 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 我们换一个视角:类型系统作为领域建模工具。

读完这章你应该能:

  1. 用 enum 替代 Go 的 interface{} + type switch 模式
  2. 理解为什么 Rust 的 pattern matching 是穷尽性检查的——这等于编译器替你写测试
  3. 设计 newtype 来避免"全是 String 和 u64"的类型贫血代码
  4. 把一个状态机从"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 章末小结与习题

本章核心概念回顾

  1. Struct 三种形态:命名字段、tuple struct、unit struct,各有用途
  2. Newtype pattern:用 struct UserId(u64) 这类便宜手法换类型安全,运行时零开销
  3. Enum 是 sum type:每个 variant 可以携带不同形态的数据,跟 C 的 enum 是两个东西
  4. Pattern matching 穷尽性检查:编译器替你写测试,不可能漏 case
  5. if let / while let / let-else:轻量 match,代码更简洁
  6. 状态机用 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) -> usize
  • fn max_depth(t: &Tree) -> usize
  • fn 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 的选择:错误是值,但语法不啰嗦。

读完这章你应该能:

  1. 解释 Rust 为什么没有 exception,以及这选择的工程含义
  2. 用 ? 操作符写出比 Go 短得多的错误处理代码
  3. 给一个项目设计错误层次:库用 thiserror、应用用 anyhow,知道为什么
  4. 决定什么时候该 panic、什么时候该返回 Result
  5. 把一个模块的错误层次做一次重设计

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 exceptionGo errorRust 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),
}

它做的事情:

  1. 自动 impl Display for SessionError(用 #[error("...")] 的字符串格式化)
  2. 自动 impl std::error::Error for SessionError
  3. #[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 是开箱即用。

分工原则

场景用什么为什么
库 / 公共 APIthiserror调用方需要 match 具体错误类型来决定行为
应用 / CLI / handleranyhow调用方只关心成功失败,不细分错误类型
大型应用的服务层thiserror(细分)上层 handler 需要根据错误类型决定 HTTP 响应
Quick prototypeanyhow 或 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。错误的可见性是安全话题——细节给运维,模糊化的版本给用户。

这个例子综合了什么

  1. thiserror 给 service 层细粒度错误类型
  2. #[from] 让 ? 自动转换底层错误
  3. IntoResponse 把业务错误转成 HTTP 响应,实现 service↔transport 解耦
  4. 敏感错误(数据库)模糊化、记日志,不向 client 泄露
  5. 整个错误流是显式的、类型化的、可重构的

5.7 章末小结与习题

本章核心概念回顾

  1. Rust 没有 exception:错误是值,函数签名声明可能失败
  2. ? 操作符:简洁的错误传播,内部调用 From::from 做类型转换
  3. thiserror for libraries:细粒度错误 enum,调用方可以 match
  4. anyhow for applications:粗粒度错误 + context,人读得懂的错误链
  5. panic 是逻辑错误信号:不变量破坏 / 测试 / unreachable,不是常规错误处理
  6. expect > unwrap:必须 unwrap 时也加上 context
  7. 错误的可观察性:Display 给用户、Debug 给开发者、source 链 + backtrace + tracing 给运维
  8. 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 的核心,也是这本书技术密度最高的章节之一。读完你应该能:

  1. 解释 Rust trait 跟 Go interface、C# interface、Haskell typeclass 的根本差异
  2. 选择正确的 trait 设计:associated type vs generic parameter
  3. 判断一个 trait 能不能做 trait object,以及对象安全规则的工程含义
  4. 选择 dyn Trait vs impl Trait vs T: Trait
  5. 给一个领域模型设计一组合理的 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 包装)。

跟其他语言一句话对比

语言接口风格显式/隐式给已有类型加方法
Gointerface(structural)隐式❌
C# / Javainterface(nominal)显式❌(需要 extension methods,有限)
TSinterface(structural)隐式❌(类型层面可,运行时不可)
Haskelltypeclass显式✅(trait 的精神祖先)
Rusttrait显式✅

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 不能处理的位置。

具体规则(简化):

  1. 方法不能返回 Self(原因如上)
  2. 方法不能有泛型参数(fn foo<T>(&self, x: T) 不行——vtable 装不下无穷多版本)
  3. 方法的第一个参数必须是 receiver(self / &self / &mut self / Box<Self> 等)
  4. 没有关联常量
  5. ...

详细规则见 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 章末小结与习题

本章核心概念回顾

  1. Trait 是 nominal:必须显式 impl Trait for Type,跟 Go structural typing 不同
  2. Default methods + supertraits:trait 可以有默认实现,可以依赖其他 trait
  3. Associated types vs generic parameters:类型层面"唯一"的关系用 associated type,"多对多"用 generic parameter
  4. Blanket impl:为"所有满足条件的类型"实现 trait,Rust 杀手锏
  5. 三种 trait 消费方式:T: Trait(静态、最常用)、impl Trait(类型擦除)、dyn Trait(动态、异构集合)
  6. Object safety:不是所有 trait 能 dyn,设计时考虑"是否需要保留 dyn 选项"
  7. Marker traits:Send / Sync / Copy / Sized 没方法,但是编译器关键决策依据
  8. 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% 的"看不懂"瞬间变成"原来如此"。

读完你应该能:

  1. 解释 monomorphization 的代价,知道什么时候该切到 dyn Trait
  2. 用 where 写出比 T: Trait 复杂得多的约束
  3. 看懂"'a is invariant over T"这种文档,并知道 PhantomData 是干嘛的
  4. 在签名里写 for<'a> Fn(&'a T),并解释为什么必须这样写
  5. 区分 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 codevirtual 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 编译慢的两大主因。

工程对策:

  1. 隔离泛型:让 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",编译开销极小;真正的逻辑只编一份。

  2. 必要时切到 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 object
  • B: 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 fn in 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。先简单再优化。


习题

  1. 给 Ch 6 你设计的 Storage trait 加一个 GAT 版本的"按 key 借出 entry"方法,签名是什么?
  2. Cow<'a, str> 在 'a 上是协变、逆变、还是不变?为什么?
  3. 写一个函数 fn apply<F: ?>(f: F),它的 F 是"对任意类型 T 都能调用的闭包"。能写吗?为什么 Rust 不支持类型层面的 HRTB?
  4. 用 cargo expand 看 serde_json::from_str::<MyStruct> 编译后展开成什么。
  5. 设计:一个 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",所有权概念隐形。

读完你应该能:

  1. 看到 Box<T> 不再问"为什么不直接放栈上"
  2. 在 Rc 和 Arc 之间果断选择
  3. 解释 Cow 的"零拷贝优化"在什么场景值得
  4. 看懂 Deref 的自动解引用规则
  5. 给你的领域问题选对智能指针

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# 类比

场景GoC#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 替代

习题

  1. 把一个 C++ 用 shared_ptr 实现的 LRU cache 翻成 Rust。要求线程安全。
  2. 写一个 fn trim_lower(s: &str) -> Cow<'_, str>,只在需要时分配。
  3. 故意构造一个 Rc 循环引用,用 Weak 修好。
  4. 读 std::sync::Arc 源码,找到 clone 里那条 fetch_add 用的是什么 ordering(Relaxed?),并解释为什么这个 ordering 够。
  5. 设计一个 plugin trait,使用方用 Vec<Box<dyn Plugin>> 持有。讨论:为什么不用 enum?

本章一句话总结

智能指针不是"更智能的指针",是"封装了不同所有权语义的容器"。选对它,代码自然简洁;选错它,你会从一种 bug 跳进另一种。


📝 学习记录

项内容
起始日期
完成日期
卡点
关键收获
配套代码仓库链接

Ch 9 · 内部可变性

Cell / RefCell / Mutex —— 在不可变接口下偷偷修改

核心问题:Rust 借用规则说"要么多个 &T,要么一个 &mut T",但有时候我们就是需要在 &T 下修改内部。怎么办?

借用规则是 Rust 的核心安全保证,但它有时太严格。observer pattern、cache、共享 mutex —— 这些场景需要"在外部看是不可变,内部能修改"。Rust 的答案是内部可变性:一组把借用检查从编译期推到运行时的类型。

读完你应该能:

  1. 解释为什么需要内部可变性(给出两个真实场景)
  2. 区分 Cell / RefCell / Mutex 各自的使用场景
  3. 用 RefCell 实现一个简单 cache,知道 panic 风险点在哪
  4. 理解 UnsafeCell 是所有内部可变性的根
  5. 看懂 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,不是"更好用"。

工程纪律

  1. 优先用 &mut self,不行才考虑 RefCell
  2. RefCell 的作用域尽量小——拿到 borrow_mut 后,做完事情立刻 drop,不要拿在手上调用别的方法
  3. 避免在 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> + 受限 API
  • RefCell<T> = UnsafeCell<T> + borrow counter
  • Mutex<T> = UnsafeCell<T> + OS mutex
  • Atomic* = 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 改写"。十有八九能,而且更快。


习题

  1. 实现一个有 cache 的 WordCounter:fn count(&self, word: &str) -> usize,内部缓存 word 出现的次数。
  2. 把 9.6 的双向链表改成索引版,对比代码量和性能(criterion)。
  3. 用 RefCell 写一个 observer pattern,故意在回调里再次 borrow,看 panic 现场。改写成"先 clone callback list"的安全版本。
  4. 用 parking_lot::Mutex 替换 std::sync::Mutex,对比性能(短临界区差别明显)。
  5. 设计一个并发 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 循环——抽象层数任意多,运行时为零。

读完你应该能:

  1. 看到 iter.filter(...).map(...).collect() 不再担心性能
  2. 解释 Fn / FnMut / FnOnce 各自的捕获和调用语义
  3. 知道什么时候必须 move closure
  4. 写出比 Go 同等代码更短同时更快的数据处理逻辑
  5. 看懂为什么 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 是 reference type)
Rust一个 for 循环,无中间分配

这就是 Rust 写出现代风格代码同时不输 C 性能的底气。


10.4 Fn / FnMut / FnOnce:三种 closure trait

闭包不是一种类型,而是一组类型(每个闭包是匿名 struct)。这三个 trait 描述"能调用几次、能不能修改捕获、能不能消耗捕获"。

trait调用签名含义
Fn&self调用任意次,不修改捕获
FnMut&mut self调用任意次,会修改捕获
FnOnceself只能调一次(消耗捕获)

包含关系: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 关键字强制闭包获取捕获变量的所有权。常见场景:

  1. 把闭包送到另一个线程(必须 move,因为线程之间所有权要清晰)

    #![allow(unused)]
    fn main() {
    use std::thread;
    let v = vec![1, 2, 3];
    thread::spawn(move || println!("{:?}", v));   // 必须 move
    }
  2. 闭包逃出局部作用域(返回 closure / 存到 struct)

    fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
        move |y| x + y   // 必须 move,否则 x 出函数就 drop
    }
  3. 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 循环不丢人。


习题

  1. 把你手头一段 Go 的 "filter + transform + reduce" 代码翻成 Rust iterator chain。对比可读性。
  2. 实现 struct Fib,impl Iterator,产生 Fibonacci 数列。用 take(10) 取前 10 个。
  3. 写一个函数 fn group_by<I, K, F>(iter: I, f: F) -> HashMap<K, Vec<I::Item>>,签名应该怎么写?
  4. 用 flat_map 把 Vec<Vec<i32>> 摊平,跟 into_iter().flatten() 对比。
  5. 写一个 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(数据竞争)——在编译期就被堵死,根本写不出来。

读完你应该能:

  1. 用 std::thread::spawn 写出能编过的多线程代码,并解释为什么必须 move
  2. 看到 Send / Sync 报错时能瞬间想到原因
  3. 在"共享内存"和"message passing"之间做工程选择
  4. 用 Arc<Mutex>、mpsc、crossbeam::scope 三种风格分别解决同一问题
  5. 知道死锁/活锁还在,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 → 多个线程可以同时拿到 &T
  • T: Send → 一个线程可以把 T 整体送给另一个线程

Send 与 Sync 的组合

类型SendSync说明
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-consumer
  • tx.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 / dataflowmpsc
共享配置 / 共享状态Arc
共享计数器Arc
共享大 mapArc<Mutex> 或 DashMap

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 内部用的是:

  1. 全局 / per-thread 任务队列(crossbeam::deque)
  2. 每个 worker 从自己队列 pop,空了就从别人队列 steal
  3. tasks 不可分割时就直接执行;可分割就 split + push

这是 Ch 20 mini-tokio 的核心结构。


习题

  1. 不用 rayon,自己用 thread::scope + mpsc 写一个并行 map:fn par_map<T, U>(items: Vec<T>, f: impl Fn(T) -> U + Sync) -> Vec<U>。
  2. 写一个程序故意构造死锁(两线程 + 两锁,反向加锁顺序)。再用 parking_lot::deadlock 检测器报告。
  3. 把 Rc<RefCell<Node>> 的双向链表改成 Arc<Mutex<Node>> 版本,在多线程并发 push 测试。讨论锁粒度。
  4. 解释:为什么 MutexGuard 不是 Send,但是 Sync?
  5. 设计一个 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。

读完你应该能:

  1. 写出 Future trait 的签名,解释 Poll::Ready 和 Poll::Pending 各自含义
  2. 描述编译器把 async fn 翻译成什么(状态机)
  3. 解释 Rust Future 为什么是 lazy 的,以及它跟 C# Task 的核心差异
  4. 看到 Pin 不再发慌,知道它解决的是什么真问题
  5. 写一个 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# TaskRust Future
是否立即开始是(hot:创建即调度)否(lazy:await 才推进)
谁推进TaskScheduler(默认全局 ThreadPool)用户选择的 executor
谁分配runtime(堆上 Task object)编译器(栈上 / 调用方分配状态机)
取消CancellationToken,协作式Drop Future 即取消(更彻底)
引用语义reference type,可多次 awaitvalue 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 时:

  1. 看自己在哪个状态
  2. 推进:调对应子 Future 的 poll
  3. 子 Future Pending → 自己也 Pending,记 Waker
  4. 子 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 是因为:

  1. 零运行时:不需要"创建即调度",所以不需要全局调度器。你完全可以写没有 async runtime 的 Rust async 代码(虽然没法 await,但可以 build)
  2. 取消即 drop:不需要 cancellation token,你直接把 Future 丢掉就停了
  3. 组合性: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:

  1. 第一次 poll:计算 deadline,把 (deadline, waker.clone()) 注册到 timer wheel,返回 Pending
  2. timer 到点:timer 线程取出 waker,wake()
  3. 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。

设计动机:

  1. 嵌入式:无 std、无 alloc 的环境也要能写 async(WASM 部分场景)
  2. 多种 runtime 共存:Tokio 适合服务器,smol 适合 CLI,自定义 executor 适合特殊场景
  3. 零成本一致性:运行时是"你需要时才付费"

代价是生态分裂:不同 runtime 的 IO 类型不兼容(tokio::TcpStream ≠ async_std::TcpStream)。社区在用 async-io / Trait 抽象慢慢解决,但目前最实用的选择就是 all-in Tokio。


习题

  1. 用 cargo expand 看 async fn ten() -> i32 { 10 } 编译后是什么。
  2. 实现一个 Yield Future:第一次 poll 返回 Pending 并唤醒自己,第二次返回 Ready(())。理解为什么这样做相当于"主动让出一次 CPU"。
  3. 改造 12.7 的 block_on 加一个简单的 spawn:把任务推到一个 Vec<Pin<Box<dyn Future>>>,主循环 poll 所有任务。
  4. 写一个 Sleep Future:用 std::thread 起一个 timer 线程,到点 wake。在 12.7 的 executor 上验证。
  5. 给一个写错的 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——而是教你生产里会踩的坑。

读完你应该能:

  1. 画出 Tokio runtime 的组件图(scheduler / IO driver / timer / blocking pool)
  2. 区分 spawn 和 spawn_blocking,知道把 CPU-bound 任务扔进 spawn 会怎样
  3. 用 select! 写 cancellation,知道什么叫 cancel safety
  4. 解释 async 上下文里 std::thread::sleep 是灾难
  5. 用 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一次性 reply1
broadcast广播,所有订阅者收到有界
watch最新值,迟到者只看到最新1(覆盖)
Notify信号量,无数据-
Mutexasync-aware 互斥锁-
RwLockasync-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。调用前看文档。

APIcancel 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 住" / "某个请求慢" 时一秒钟定位。生产先验把它接入。


习题

  1. 写一个 HTTP 服务,在 handler 里 std::thread::sleep(2s),wrk 压测。再换成 tokio::time::sleep,对比 QPS。
  2. 用 select! + signal::ctrl_c() 实现优雅关闭:收到 Ctrl-C 后停止接收新连接,等已有连接处理完。
  3. 给一个 cancel-unsafe 的 demo:用 select! 包 TcpStream::read,观察被取消时数据丢失。改成 cancel-safe 版本。
  4. 跑通 tokio-console,人为制造一个"忘了 yield"的 CPU 密集 task,看 console 里它的状态。
  5. 用 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。

读完你应该能:

  1. 设计一个多 crate workspace
  2. 用 feature flag 做"编译期可配置"而不踩 mutual-exclusive 的坑
  3. 区分 [dependencies] / [dev-dependencies] / [build-dependencies]
  4. 把编译时间从 10 分钟降到 2 分钟
  5. 发一个 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 change
  • license = "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 tagRust 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 的流程

  1. 第一次准备

    cargo login <token>   # 从 crates.io 拿 token
    
  2. Cargo.toml 必填字段

    • name / version / description / license / repository / readme
    • categories(从 crates.io 列表选)/ keywords
  3. 发布前 dry-run

    cargo publish --dry-run
    cargo package --list   # 看会上传哪些文件
    
  4. 正式发布

    cargo publish
    
  5. Workspace 多 crate 发布

    按依赖拓扑顺序逐个 publish,版本要先 bump:

    (cd crates/core && cargo publish)
    sleep 10   # 等 crates.io index 更新
    (cd crates/api  && cargo publish)
    
  6. 撤回

    cargo yank --version 0.3.0 把版本标记 yanked(不删除,但新项目不会拉)。删除需要给 crates.io 工作人员发邮件,很少批准。

维护 changelog

CHANGELOG.md 推荐 Keep a Changelog 格式。社区 ergonomic crates 都遵循。


习题

  1. 把你某个个人项目重构成 workspace 结构,把 "core 逻辑" / "HTTP" / "CLI" 拆三个 crate。
  2. 给一个 crate 加 pg / mysql 两个互斥的 feature(故意写错),用 cargo build --features pg,mysql 看会发生什么。改造成 additive。
  3. 用 cargo bloat 找出你二进制里占用最大的 crate,讨论能否裁掉它的 feature。
  4. 配 mold + sccache,对比 cargo clean && cargo build 的时间。
  5. 用 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——那是文档活儿——而是讲生产里把这三个串起来的细节。

读完你应该能:

  1. 解释 axum 的 Handler / Extractor / IntoResponse 三元组怎么协作
  2. 用 tower::Layer 写中间件,知道 layer 的执行顺序
  3. 用 sqlx 的 query! 宏在编译期校验 SQL,知道它的限制
  4. 用 tracing 输出结构化 JSON 日志并接入 OpenTelemetry
  5. 实现 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" 都是 Service。axum 的 router、handler、middleware 全部是 Service。

跟 Go / C# 类比

Go (chi/gin)C# (ASP.NET Core)axum
handler 形态func(w, r)controller class method普通 async fn
middlewareMiddleware(next) http.HandlerIMiddleware 或 delegatetower::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! 是过程宏,编译时连真实数据库校验:

  1. SQL 语法正确
  2. 表 / 字段存在
  3. 类型匹配(id: i64 对应 SQL bigint)
  4. 返回结构匹配 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 对比

sqlxdieselsea-ormGo (sqlx / gorm)
风格SQL-first,asyncDSL,sync(async beta)ORM,asyncsqlx: 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 后:

  1. 停止 accept 新连接
  2. 等已有连接的请求处理完
  3. 超过最长等待时间则强杀(自己加 timeout)

K8s rolling update / SIGTERM handling 必备。


习题

  1. 写一个 REST 服务:用户增删改查 + JWT 鉴权 + sqlx + tracing + graceful shutdown。
  2. 用 wrk 压测 axum hello world,跟 Go gin 对比 QPS。
  3. 故意写一个慢 handler(tokio::time::sleep(60s)),触发 shutdown,看连接是否优雅关闭。
  4. 接 OpenTelemetry 到 Jaeger,在 Jaeger UI 里看一个跨服务调用 trace。
  5. 测试 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 → 内存分析。

读完你应该能:

  1. 给一个 crate 写齐三种形态的测试
  2. 用 proptest 找到边界 case bug
  3. 用 insta 做 snapshot 测试,理解什么时候它合适
  4. 用 criterion 做严肃 benchmark(防止编译器优化掉)
  5. 用 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(单独编译)
  • 只能用 pub API(模拟外部用户)
  • 共用代码放 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

quickcheckproptest
历史更老更新
自定义 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 慢但隔离最干净

习题

  1. 给一个简单的 parser 加上 proptest 测 "parse → print → parse 不变"。
  2. 给一个 JSON 配置序列化加上 insta::assert_json_snapshot!,review 一次。
  3. 写 criterion benchmark 对比 Vec<i32>::sort 跟自己实现的 quicksort,故意忘记 black_box,看结果失真。
  4. 用 cargo flamegraph profile 一个有 hot loop 的程序,找到热点。
  5. 用 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 最难学也最容易被滥用的特性。

读完你应该能:

  1. 写一个简单的 macro_rules!,理解 fragment specifier
  2. 区分 declarative / proc-macro / derive / attribute 四种
  3. 用 syn + quote 写一个 derive 宏
  4. 用 cargo expand 看宏展开
  5. 给"什么时候用宏 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))
tttoken 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_builder crate 会识别 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 工程师水平的反比指标 = 项目里宏的数量。这话夸张但有道理:克制是美德。


习题

  1. 写一个 min! 宏接受任意个表达式,返回最小值。
  2. 写一个 #[derive(Getters)],为每个字段生成 fn field_name(&self) -> &FieldType。
  3. 用 cargo expand 看 #[tokio::main] async fn main() 展开后什么样,解释每一段做什么。
  4. 找一个 serde::Serialize 实现的展开结果(用 cargo expand),挑一个 struct 看它生成的代码量。
  5. 写一篇 200 字"我们项目要不要引入 proc-macro" 的内部 RFC,论证支持 / 反对的判断。

本章一句话总结

宏是 Rust 的元编程武器,但它是最容易被滥用的特性。读得懂 + 必要时能写 + 大部分时间不写——这是合格 Rust 工程师的判断力。


📝 学习记录

项内容
起始日期
完成日期
卡点
关键收获
配套代码仓库链接

Ch 18 · Unsafe Rust —— Rustonomicon 速成

"向编译器承诺以下不变量"

核心问题:unsafe 到底关掉了什么?程序员需要承诺什么?

unsafe 是 Rust 最被误解的关键字。常见误解:"unsafe 关掉了所有安全检查"。真相是:它只关掉一小部分检查,但要求你手动承诺一组不变量。绝大多数 Rust 工程师不应该写 unsafe;但每个 Rust 工程师都应该读懂 unsafe,因为标准库、Tokio、SmallVec、Bytes 等基础库都在用它。

读完你应该能:

  1. 说出 unsafe 能做的五件事
  2. 解释 Stacked Borrows 模型简化版
  3. 列出 10 种 UB,并知道为什么"看起来没事"也可能是 UB
  4. 用 Miri 验证 unsafe 代码
  5. 写一个简单的 unsafe 抽象(比如 ring buffer),并把 unsafety 包裹在安全 API 内

18.1 unsafe 能做的五件事

unsafe 在 Rust 里只多给你这五个能力:

  1. 解引用裸指针 *const T / *mut T
  2. 调用 unsafe 函数(包括 FFI)
  3. 访问 / 修改可变 static 变量
  4. 实现 unsafe trait(如 Send, Sync 手写)
  5. 访问 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):

  1. 悬空指针解引用 —— 指向已 drop / 已 free 的位置
  2. 未对齐访问 —— 解引用 misaligned pointer
  3. 野指针 / null 解引用(裸指针)
  4. 违反 aliasing 规则(见上)
  5. 数据竞争 —— 两线程同时无同步访问同一可变位置
  6. 读取未初始化内存
  7. 整数溢出(unchecked_add 等;safe 默认是 wrap 或 panic)
  8. 错误的转换 —— &u32 cast 到 &u64 然后读取
  9. 不变量违反 —— bool 里塞 2、enum 里塞非法 discriminant、char 塞代理对
  10. panic 跨 FFI 边界
  11. 错误的生命周期 —— 用 transmute 延长生命周期
  12. 不为 Send 的类型跨线程(手写 unsafe impl Send)
  13. 错误地 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 代码的工程纪律

  1. 把 unsafe 关在最小作用域里

    • 不要把整个函数 unsafe fn;unsafe 块越短越好
  2. 文档化 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: 注释,说明你为什么相信不变量成立

  3. 暴露 safe API,unsafe 锁在内部

    • 库用户用 Vec::push 不该见 unsafe;push 内部一堆 unsafe,但 push 本身是 safe fn,因为内部确保了不变量
  4. 测试用 Miri

    rustup install nightly
    rustup +nightly component add miri
    cargo +nightly miri test
    

    Miri 是 Rust 解释器,会检查 UB(stacked borrows、未初始化、悬空指针等)。unsafe 代码的 CI 必跑 Miri

  5. 多人 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_slice
  • Box::new(为什么是 unsafe 内部)
  • Rc 的 clone / drop(strong / weak count 的原子顺序)
  • Mutex(OS mutex 的封装)

习题

  1. 不用 Box / Vec 等,只用 alloc API + 裸指针,实现一个固定大小的 RingBuffer<T>。要求暴露 safe API。用 Miri 跑测试。
  2. 故意构造一个 Stacked Borrows 违规(同时存在两个 raw mut ptr 指向同一位置),用 Miri 测出来。
  3. 给某个支持自定义 hasher 的 HashMap-like 类型手写 unsafe impl Send,写出 SAFETY 注释。让其他人 review。
  4. 读 std::cell::Cell 的源码,理解为什么它是 safe 抽象但内部全是 unsafe。
  5. 写一篇 200 字"为什么 unsafe 不该 disable warnings"的内部文档,论证立场。

本章一句话总结

unsafe 不是"关掉安全检查",是"我向编译器承诺以下不变量"。读懂这个差别,unsafe 才能写得正确——大部分人不需要写它,但每个人都应该看得懂它。


📝 学习记录

项内容
起始日期
完成日期
卡点
关键收获
配套代码仓库链接

Ch 19 · FFI 与跨语言边界

Rust 怎么跟 C / Python / JS / WASM 互操作

核心问题:Rust 和别的语言怎么互操作?每种边界各有什么坑?

Rust 的现实世界主战场之一就是给别的语言提供库:Python(AI / 数据)、Node.js(前端工具)、C/C++(系统集成)、Web 浏览器(WASM)。这章是工程师向导,讲每种边界的工具、坑、生产实践。

读完你应该能:

  1. 解释 C ABI / #[repr(C)] / #[no_mangle] 各自做什么
  2. 用 bindgen 调一个 C 库,用 cbindgen 把 Rust 暴露给 C
  3. 用 PyO3 给 Python 写一个 Rust 模块
  4. 用 napi-rs 给 Node.js 写一个 Rust 模块
  5. 用 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 可能重排字段优化布局)

类型对应

RustC
i8/i16/i32/i64int8_t/int16_t/int32_t/int64_t
u8/u16/u32/u64uint8_t/...
f32/f64float/double
boolbool(stdbool.h)
*const T / *mut Tconst 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 回收
}

三条边界规则:

  1. 谁分配谁释放:不能 Rust 用 malloc C 用 free(allocator 不同)。必须暴露 free_string 给 C 调用。
  2. NULL / NUL 终止符的安全责任:CStr::from_ptr(name) 要求 name 非 NULL 且指向 NUL 结尾的 C 字符串——违反就是 UB。所以入口先做 NULL 检查,文档里写明 NUL 终止的契约。
  3. 错误用返回值表达,不要 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 边界
ruffPython linter(替代 flake8 / pylint)纯 Rust,带 Python wrapper
uvPython package manager纯 Rust binary,通过 CLI 跟 Python 交互
polarsDataFrameRust core + PyO3 + napi
denoTypeScript runtimeRust + v8 binding
swcJS 编译器Rust + napi-rs
turbopackbundlerRust + napi-rs
firefox浏览器C++ + Rust(CSS engine 等子系统)
discord部分服务后端Elixir/Erlang + Rust(NIF)

生态趋势:FFI 边界写好的 Rust 库 > 用 Rust 写整个产品。所以这章是高 ROI 内容。


习题

  1. 用 PyO3 把一个 Rust 函数(比如 fibonacci)暴露给 Python,在 Python 里压测 vs 纯 Python 实现。
  2. 用 bindgen 给 sqlite3 写 binding,实现一个 open + execute 的最小 Rust 包装。
  3. 故意在 extern "C" fn 里写一个 panic,看会发生什么(Linux 下可能 abort)。再用 catch_unwind 包好。
  4. 用 napi-rs 给 Node.js 暴露一个 Rust 函数,在 Node 里 require 调用。
  5. 把一个 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 倍。

读完你应该能:

  1. 写一个支持 block_on 的最简 executor
  2. 支持 spawn 多个并发任务
  3. 实现 Sleep Future + 简化的 timer wheel
  4. 实现 mpsc channel
  5. 升级到多线程 work-stealing scheduler
  6. 解释你的 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 可能在多线程被 poll
  • Option 因为 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-stealingLIFO slot 优化、global injection queue、负载感知
TimerBinaryHeap O(log n)hierarchical timer wheel O(1)
Task slabBoxSlab + 自定义 allocator
Drop / cancellation简单abort token + 各种 join handle 协议
Pin / 自引用手动 boxtokio::pin! / 自适应 pin
Yield 公平性无协作式 yield budget(防止 task 永不让出)
监控无tokio-console / Tracing 集成
性能教学单机数百万 task / 数十万 QPS

差距很大,但机理是一样的。读完真 Tokio 源码不再像第一次那么吓人——你知道每一个组件大概干什么。


习题(毕业作品)

  1. 跑通整个 mini-tokio(单线程 → spawn → sleep → channel → 多线程)。
  2. 基准对比:用 criterion 测 spawn 10000 个空 task 的吞吐,跟 tokio 对比,差距多少?
  3. 加 select! 宏:声明宏实现简化版 select!。
  4. 加 abort 支持:spawn 返回 JoinHandle,可以 abort()。
  5. 提一个 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。


习题 / 实战练习

  1. 故意写 10 段触发上述错误的代码,把编译器输出贴在每一项下面,对比模板。
  2. 找最近一次你卡过的 Rust 编译错误,按这个模板写一段"日志"——下次更快定位。
  3. 培养一个习惯:先把整段编译器输出读完再 google——80% 时间编译器自己已经给了 fix suggestion。

本章一句话总结

Rust 编译器的报错信息是它给你最大的礼物。学会读它,胜过看十本教程——其他语言的 runtime 错误,Rust 让你在 cargo check 里就看见。


📝 学习记录

项内容
起始日期
完成日期
卡点
关键收获
配套代码仓库链接

附录 B · Go → Rust 概念翻译大全

200+ 条对应关系

核心问题:Go 中的每个概念在 Rust 中对应什么?

这附录给你一个"翻译字典":Go 概念左 → Rust 概念右。注意:很多翻译不是 1:1,Rust 常常没有完全等价物,但你能找到"做相似事情的工具"。


B.1 基础类型与字面量

GoRust备注
inti32 / i64 / isizeGo int 平台相关,Rust 倾向显式
int8 / int16 / int32 / int64i8 / i16 / i32 / i64一致
uint8 (byte)u8一致
float32 / float64f32 / f64一致
boolbool一致
string (UTF-8)String / &strRust 区分 owned / borrowed
[]byteVec<u8> / &[u8]同上
runechar (4 字节 Unicode scalar)一致
nilOption::NoneRust 没有 null
true / falsetrue / false一致
0x1f, 1_000_0000x1f, 1_000_000字面量分隔符一致
const Pi = 3.14const PI: f64 = 3.14;Rust 要求类型

B.2 控制流

GoRust
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 / continueloop { ... } / 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 函数与方法

GoRust
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 ...intargs: &[i32] 或 slice / Vec
closure func(x int) int { return x+1 }|x: i32| x + 1(或 move |...| ...)
函数作参数 f func(int) intf: impl Fn(i32) -> i32 或 Box<dyn Fn...>
命名返回值没有(用 tuple 解构)

B.4 struct 与 interface

GoRust
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 structtuple 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 错误处理

GoRust
if err != nil { return err }? 运算符 / Result::map_err
error interfaceResult<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 / recoverpanic! / catch_unwind(不推荐做 error handling)

B.6 并发

GoRust
go f()std::thread::spawn(|| f()) 或 tokio::spawn(async { f().await })
chan Tstd::sync::mpsc::channel() / tokio::sync::mpsc / crossbeam::channel
select { case ... }tokio::select! / crossbeam::select!
sync.Mutexstd::sync::Mutex<T> / tokio::sync::Mutex<T> / parking_lot::Mutex
sync.RWMutexstd::sync::RwLock<T> / parking_lot::RwLock
sync.WaitGrouptokio::task::JoinSet / Arc<AtomicUsize> + Notify
sync.Oncestd::sync::OnceLock<T>
context.Contexttokio_util::sync::CancellationToken + propagate via async fn
context.WithTimeouttokio::time::timeout(dur, fut)
atomic.AddInt32AtomicI32::fetch_add(1, Ordering::SeqCst)
GCownership / Drop / Rc / Arc

B.7 包管理

GoRust
go.modCargo.toml
go.sumCargo.lock
module example.com/foo[package] name = "foo"
import "fmt"use std::fmt;
package foomod foo; / 一个 crate 一个名字
internal/ 包pub(crate) 可见性
GOPATH / GOPROXYcrates.io / [source.crates-io]
vendoringcargo vendor
go mod tidycargo update + cargo udeps
build tagsfeature flags

B.8 测试

GoRust
func TestX(t *testing.T)#[test] fn x() {}
t.Errorf / t.Fatalfassert! / assert_eq! / panic!
Benchmark func BenchmarkX(b *testing.B)criterion c.bench_function("x", ...)
Exampledoc-test ```rust
go test ./...cargo test
race detector -raceMiri / RUSTFLAGS="-Z sanitizer=thread"
coverage go test -covercargo llvm-cov / tarpaulin
testing/quickproptest / quickcheck

B.9 常用标准库

GoRust
fmt.Printlnprintln!
fmt.Sprintfformat!
strings.Splits.split(',').collect::<Vec<_>>()
strings.BuilderString::new() + push_str
bytes.BufferVec<u8> 或 bytes::BytesMut
encoding/jsonserde_json
net/http serveraxum / actix-web
net/http clientreqwest
database/sqlsqlx / diesel
os.Openstd::fs::File::open
time.Now()std::time::Instant::now() / chrono::Utc::now()
time.Sleepstd::thread::sleep / tokio::time::sleep
loglog + env_logger 或 tracing
flag packageclap crate
os.Getenvstd::env::var
regexpregex crate
crypto/...ring / rustls / RustCrypto 套件
io.Reader / io.Writerstd::io::Read / Write

B.10 生态对应

用途GoRust
Web 框架gin / chi / echoaxum / actix-web
RPCgrpc-gotonic
数据库 ORMgorm / sqlxsqlx / sea-orm / diesel
日志 / 观测zap / zerologtracing
配置viperconfig / figment
CLIcobra / urfaveclap
序列化encoding/json / protoserde + serde_json / prost
任务调度robfig/crontokio-cron-scheduler
缓存 / KVredigo / go-redisredis-rs / fred
队列NATS / Kafka clientnats / rdkafka
测试 mockgomockmockall
性能分析pprofpprof-rs / cargo flamegraph
文档站点hugo / docusaurusmdBook(本书在用)

几条战略级差异

  1. Go 没有 borrow checker;Rust 没有 GC —— 编码风格的根本差异
  2. Go 的 zero-value 设计 vs Rust 的"必须显式初始化" —— Rust 让你少踩"看似为空,其实未定义"的坑
  3. Go 拥抱"代码量换显式";Rust 拥抱"类型表达力" —— Go 经典 if err != nil;Rust ? 一个字符
  4. Go interface{} 万能;Rust 鄙视 dyn Any —— Rust 偏 generic / trait bound,运行时反射几乎不用
  5. 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 鄙视动态类型
objectBox<dyn Any>受限
sealed class默认就是(Rust 没有继承)
abstract class用 trait + default methods
partial class没有(模块拆分代替)

C.2 内存管理

C#Rust
GC,自动回收ownership + Drop,确定性回收
IDisposable + usingDrop 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 taskfuture.await
Task.Run(() => ...)tokio::spawn(async { ... })
Task.WhenAll(tasks)futures::future::join_all 或 JoinSet
Task.WhenAny(tasks)tokio::select! / FuturesUnordered::next
CancellationTokendrop Future / tokio_util::sync::CancellationToken
Task.Delay(...)tokio::time::sleep(...).await
ConfigureAwait(false)没有(Rust async 默认无 SyncContext)
IAsyncEnumerable<T>futures::Stream<Item = T>
await foreachwhile let Some(x) = stream.next().await
SemaphoreSlimtokio::sync::Semaphore
Channel<T>tokio::sync::mpsc::channel

C.4 LINQ → Iterator chain

C# LINQRust 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 SQLsqlx + 编译期校验 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/Invokeextern "C" + #[link] / bindgen
[Conditional("DEBUG")]#[cfg(debug_assertions)]
[Obsolete("...")]#[deprecated(note = "...")]
source generatorproc-macro

C.6 Nullable

C#Rust
T?(reference / value nullable)Option<T>
x?.Foo()x.as_ref().map(|v| v.foo())
x ?? defaultx.unwrap_or(default)
x ??= defaultx.get_or_insert(default)(对 Option)
!(null-forgiving).unwrap()(更安全:崩在显式 unwrap 处)
nullability annotation #nullable enableOption 强制了

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 exceptionDrop 自动处理(无 try/finally)
Exception.InnerExceptionsource() (std::error::Error::source)
custom exception classenum variant + thiserror
Ex.StackTraceRUST_BACKTRACE=1 + std::backtrace::Backtrace
Task.ExceptionResult<T, E>

经验:Rust 90% 错误用 Result;只在"真正异常" / 不变量违反时 panic。


C.8 Generic constraints → trait bounds

C#Rust
where T : class没有完全等价(Rust 没有 reference/value 区分)
where T : structT: Copy(对 value type 模拟)
where T : new()T: Default 或 T: From<...>
where T : IFooT: Foo(trait 名一样)
where T : IFoo, IBarT: 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 Foocargo add foo
Foo.csprojCargo.toml
obj/ / bin/target/
nuget.orgcrates.io
dotnet pack / nuget pushcargo publish
dotnet restorecargo 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 = "...")]
Moqmockall
BenchmarkDotNetcriterion
Coverlet 覆盖率cargo llvm-cov
dotnet-trace / dotnet-counterstokio-console / pprof-rs / flamegraph
IDisposable cleanupDrop
IAsyncLifetime测试函数手动 setup/cleanup

几条战略级差异(给 C# 工程师)

  1. 没有 GC,但你不用 manual free —— ownership 自动管,但你要学会"思考所有权"
  2. 没有 exception —— Result<T, E> 把"可能失败"写进类型,所有调用方必须显式处理
  3. 没有 null reference —— Option<T> 强制你解构,bug 大幅减少
  4. 没有继承 —— trait + 组合代替,设计上更平
  5. 没有 reflection —— 类型擦除、运行时类型查询都很受限。这是优点:静态决定一切,优化空间大
  6. async 模型不同 —— Rust Future 是 lazy + value type,没有 Task pool / SynchronizationContext
  7. 没有 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_conventionto_x 应该 &self,into_x 应该 self
clippy::eq_opx == x 永远 true
clippy::erasing_opx * 0 之类

clippy::style

Lint含义
clippy::needless_returnreturn x; 在末尾(应该 x)
clippy::collapsible_ifif a { if b { ... } } 可合并
clippy::redundant_field_namesFoo { x: x } 可写 Foo { x }
clippy::let_and_returnlet x = ...; x 可直接 ...
clippy::single_matchmatch x { A => ..., _ => () } 用 if let

clippy::complexity

Lint含义
clippy::needless_borrow&&x 多余
clippy::unnecessary_unwrapif x.is_some() { x.unwrap() } 用 if let
clippy::useless_conversionString::from(s.to_string()) 多余
clippy::map_unwrap_or.map(...).unwrap_or(...) 用 .map_or(...)

clippy::perf

Lint含义
clippy::redundant_cloneclone 多余
clippy::large_enum_variantenum 一个变体远大于其他(浪费内存)
clippy::format_in_format_argsformat!("{}", 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 Matsakisrustc 主席,语言设计博客
Jon Gjengset教学视频 (Crust of Rust) + 书
Mara Bos库团队主席,《Atomics and Locks》作者
Tyler Mandryasync 工作组
Yoshua Wuytsasync / executor 思考者
boatsFuture / Pin 原始设计者
matklad / Alex Kladovrust-analyzer / IDE / 工程实践
Amos / fasterthanli.me长文博主
Steve KlabnikThe Book 作者
dtolnayserde / syn / quote / anyhow / thiserror 作者(生态半边天)
BurntSushiripgrep / regex / serde-json 等基础库
sgrifdiesel 作者,trait magic 鬼才
张汉东 (rust-zh)中文社区组织者,翻译者

学完这本书后的"持续学习清单"

按"投入产出"排序:

  1. 每周 30 min 看 This Week in Rust —— 保持生态感
  2. 每个月读一篇 fasterthanli.me 长文 —— 深度
  3. 每月写一段非平凡 Rust 代码 —— 任何小工具 / 副业 / 重写
  4. 每年看 2 本 Rust 书 —— Rust for Rustaceans / Atomics and Locks 先
  5. 每年贡献一次开源 PR —— 哪怕 typo 修复,走流程的价值大

一句送别

"C 让你直面机器,Java 让你直面对象,Haskell 让你直面类型。Rust 让你直面所有权——一个你十年来下意识在思考但从未系统化的概念。从 GC 语言来,你不是学一个新语言,你是把脑子里一直存在的'谁负责这块内存 / 谁负责这条连接'的直觉,搬到类型系统里,变成可以编译期验证的事。"


📝 学习记录

项内容
起始日期
完成日期
卡点
关键收获
配套代码仓库链接