1. 笔记的整体框架:先想清楚“记给谁看”
我整理这套 Rust 官方教程完整笔记的起点,其实不是打开教程第一章,而是先问自己一个问题:这份笔记将来是给谁看的。答案很具体——给三个月后已经忘掉大半语法、只记得“当时好像有个借用报错”的自己看。这个定位一旦定下来,笔记的写法就完全不同了。如果只是把教程里的段落复制粘贴一遍,那不如直接收藏原文档;真正有价值的是把“我当时卡在哪、编译器原话是什么、后来怎么绕过去”这三件事固定下来。
Rust 这门语言的学习曲线有个很明显的特征:语法层面并不吓人,吓人的是编译器在你自以为写对的时候泼冷水。官方教程的章节安排已经足够循序渐进,但它是一本“线性读物”,而人的理解是网状的。你可能在学所有权的时候不懂生命周期,在学 trait 的时候又回头补泛型。所以我这份笔记的组织原则是按概念建卡片,按教程建索引,两头都保留,互相能跳转。
内容上,我把每个知识点拆成固定五栏:一句话定义、最小可运行例子、故意写错的例子、编译器报错原文、我的白话解释。这五栏看起来笨,但它逼着我把“看懂”变成“能复现”。很多时候你以为自己懂了,一写反例就露馅。比如闭包的move关键字,光看教程觉得理所当然,真让你写一个“变量被移进线程后原作用域还能不能用”的小例子,才发现理解是空的。
适合谁看这份笔记?我的判断是三档。第一档是刚接触系统编程、想找一门能长期用的语言的开发者,重点看所有权和错误处理两块;第二档是写过其他语言、想快速把 Rust 用起来的工程师,重点看 Cargo、trait、智能指针的选型表;第三档是已经能写但总被借用检查器拦住的人,直接跳到编译错误排查那一节。三档人看同一份笔记,但入口不同,这也是我坚持给每节加编号和索引的原因。
还有个容易被忽略的点:笔记的载体。我试过纯文档、试过思维导图、也试过只在编辑器里写注释,最后稳定下来的是Markdown 主笔记 + 一个独立 Cargo 示例仓库。Markdown 负责概念和结论,示例仓库负责可编译的证据。两者用相对路径互相引用,笔记里每段代码都注明它在仓库里的文件名。这样做的好处是,半年后我看到“这里当时报了 E0502”,可以直接cd进去跑一遍,而不是对着一段无法执行的代码回忆。
注意:不要把示例代码全部塞进一个
main.rs。Rust 的模块、可见性、测试都和文件结构绑定,一个文件里堆十几个例子,最后你自己都分不清哪个use对应哪段逻辑。
2. 学习路径与工具链准备:别让环境问题消耗热情
2.1 用 rustup 管工具链,用 override 锁版本
Rust 的版本迭代节奏不慢,教程里的写法在稳定版上基本都能跑,但如果你机器上同时装了稳定版和 nightly,cargo build的行为可能和你笔记里记录的不一致。我的做法是用 rustup 统一管理,并且在笔记仓库根目录做一次工具链覆盖,保证半年后回来复现时环境是确定的。
rustup toolchain install stable rustup component add rustfmt clippy cd notes_demo rustup override set stable rustc --version cargo --versionrustup override set会在当前目录生成一个记录文件,作用域只限这个项目,不会污染全局。这个细节看起来小,但我吃过亏:有一次在笔记里记录某段代码“编译通过”,三个月后换机器复现却失败,查了半天发现是默认工具链变了。从那以后,凡是笔记里标注“已验证”的代码,我都会同时记下rustc --version的输出。
工具链装好之后,建议把rustfmt和clippy当成笔记的校对员。前者保证代码块风格统一,后者能指出很多教程里没讲的惯用法问题。
cargo fmt -- --check cargo clippy --all-targets -- -D warnings-D warnings把警告升级为错误,逼着你处理掉。刚开始会觉得烦,尤其clippy对needless_return、redundant_clone这类问题的提示很密。但正是这些提示,帮我改掉了从其他语言带过来的坏习惯,比如到处.clone()图省事。
2.2 Cargo 项目结构与笔记仓库的联动
我的笔记仓库大致长这样:
rust-notes/ ├── ch04-ownership/ │ ├── Cargo.toml │ └── src/ │ ├── main.rs │ └── bin/ │ ├── move_demo.rs │ └── borrow_demo.rs ├── ch06-enums/ ├── ch10-generics/ └── README.md每个教程大章一个独立 crate,章内每个可独立运行的小例子放src/bin/下。这样做的好处是cargo run --bin move_demo就能单独跑一个例子,互不干扰,而且每个 bin 都是独立的fn main,不会出现“上面例子的变量影响下面例子”的串味问题。
Cargo.toml 我尽量保持极简,只放必要元信息。笔记仓库不是产品,不需要花哨的依赖。
[package] name = "ch04-ownership" version = "0.1.0" edition = "2021" [dependencies]edition这一项值得单独说。教程不同版本对应的 edition 不同,2021和2018在闭包捕获、数组IntoIterator等细节上有差异。我在笔记开头固定写清楚“本笔记基于 2021 edition”,避免读者按图索骥时对不上。
2.3 用 rust-analyzer 和 cargo doc 补齐教程之外的信息
官方教程讲原理很清楚,但具体 API 的签名它不可能全列。我的习惯是编辑器装 rust-analyzer,悬停看类型、跳转看定义;遇到第三方 crate 或者标准库模块,直接cargo doc --open在本地生成文档。这两件事配合起来,能解决笔记里大量“这个方法的返回类型到底是什么”的疑问。
还有一个小技巧:rustc --explain E0382这类命令会打印出编译器错误码的详细解释。我在笔记里每记录一个典型错误,都会把错误码和--explain的要点摘进去。这样下次再遇到,不用重新搜索,直接翻自己的笔记。
提示:错误码是稳定的,但解释文案会随版本微调。笔记里记错误码比记整段报错原文更耐久,报错原文只作为“当时的现场记录”保留。
3. 所有权与借用:笔记里最值得反复看的部分
3.1 把三条规则写成可验证的卡片
所有权这块,教程给了三条规则:每个值有且只有一个所有者;同一时间只能有一个所有者;所有者离开作用域,值被丢弃。这三句话背下来只要一分钟,但真正用起来,难点全在“什么时候所有权发生了转移”。
我的笔记里给每条规则配了一个最小例子和一个反例。
fn main() { let s1 = String::from("hello"); let s2 = s1; // println!("{}", s1); // error[E0382]: borrow of moved value: `s1` println!("{}", s2); }反例就是把注释那行放开。编译器会告诉你s1的值已经被 move 走了。这里我特意在笔记里写了一句白话:String在栈上是一个“指针 + 长度 + 容量”的三元组,赋值时复制的是这个三元组,堆上的字节没有复制,所以 Rust 选择让原变量失效,避免两个变量同时以为自己能释放同一块内存。
对于实现了Copy的类型,行为又不一样:
fn main() { let x = 5; let y = x; println!("{} {}", x, y); // 正常,i32 实现了 Copy }这两段代码放在一起看,才能理解“move 还是 copy”取决于类型是否实现了Copy,而不是取决于你写没写clone。我在笔记里把这条单独标红:不要用“赋值就是复制”的直觉去理解 Rust,要先问这个类型是不是 Copy。
3.2 借用规则用“银行取号”来类比
可变借用和不可变借用的规则,教程的表述是:同一时间,要么有多个不可变引用,要么只有一个可变引用。我笔记里用的类比是银行柜台取号:你可以同时有很多人在大厅里看公告(不可变借用),但只要有人在窗口办业务(可变借用),其他人就不能同时改同一份材料。
fn main() { let mut s = String::from("hello"); let r1 = &s; let r2 = &s; println!("{} {}", r1, r2); let r3 = &mut s; r3.push_str(" world"); println!("{}", r3); }这段能编译,是因为r1、r2在最后一次使用之后就不再被借用了,这就是常说的 NLL(非词法生命周期)。很多初学者在这里困惑:明明r1的作用域还没结束,为什么能借可变的?答案是要看“最后一次使用”,不是看花括号。我在笔记里专门画了一个时间轴示意,把每个引用的“活跃区间”标出来,比纯文字好懂。
反例则更值得记:
fn main() { let mut s = String::from("hello"); let r1 = &s; let r2 = &mut s; // error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable println!("{}", r1); let _ = r2; }这里r1在后面还被用到,所以不可变借用仍然活跃,冲突成立。我笔记里把这个错误的读法总结成三步:先找报错行涉及哪几个引用,再看每个引用最后一次使用在哪,最后判断活跃区间是否重叠。
3.3 生命周期标注:先会读,再会写
生命周期是很多人卡住的地方。我的建议是先把“省略规则”吃透,因为日常代码里绝大多数生命周期都是省略的,你不需要写标注也能编译。教程里给了三条省略规则,我笔记里用自己的话重述:
| 场景 | 省略规则 | 例子 |
|---|---|---|
| 每个输入引用 | 各自获得一个独立生命周期 | fn f(x: &str, y: &str)等价于两个不同参数 |
| 只有一个输入引用 | 输出引用的生命周期等于它 | fn f(x: &str) -> &str |
有&self或&mut self | 输出引用生命周期等于self | 方法返回值默认绑定到 self |
真正需要手写标注的场景,往往是“多个输入、有输出引用”。比如:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } }这里的'a不是“让变量活得更久”,而是“告诉编译器,返回值的有效期不超过两个输入中较短的那个”。这句话我在笔记里反复写了两遍,因为太多人把生命周期理解成“延长寿命”,实际它只是描述关系,不改变任何运行时行为。
注意:不要一上来就给所有函数加生命周期标注。绝大多数函数不需要,加了反而降低可读性。先让编译器报错,再根据报错补标注,这是更实际的路径。
4. 结构体、枚举与模式匹配:把“数据形状”记成模板
4.1 结构体的字段初始化与更新语法
结构体本身不难,难的是和所有权结合之后的细节。比如字段是String时,结构体整体 move 之后部分字段还能不能用;比如..更新语法会 move 掉未实现Copy的字段。这些在教程里分散在各处,我在笔记里集中成一个小节。
#[derive(Debug, Clone)] struct User { name: String, age: u8, active: bool, } impl User { fn new(name: &str, age: u8) -> Self { Self { name: name.to_string(), age, active: true, } } } fn main() { let u1 = User::new("A", 20); let u2 = User { age: 21, ..u1.clone() }; println!("{:?} {:?}", u1, u2); }我在笔记里特意用了u1.clone()而不是..u1,原因是后者会把u1的name字段 move 走,导致u1部分失效,后面再打印u1就会报错。这个细节教程提过,但很容易看漏。笔记里我把它标成“结构体更新语法的隐藏 move”。
4.2 枚举加 match:穷尽性检查是免费的安全网
Rust 的match要求穷尽所有分支,这个设计在写业务代码时特别省心。我的笔记里用形状面积计算作为固定例子,因为它同时覆盖了元组变体和结构体变体。
enum Shape { Circle(f64), Rect { w: f64, h: f64 }, } fn area(s: &Shape) -> f64 { match s { Shape::Circle(r) => std::f64::consts::PI * r * r, Shape::Rect { w, h } => w * h, } } fn main() { let shapes = vec![Shape::Circle(1.0), Shape::Rect { w: 2.0, h: 3.0 }]; for s in &shapes { println!("{:.3}", area(s)); } }笔记里我强调两点。第一,match的分支顺序有意义,宽泛的模式要放在具体模式后面,否则会被遮蔽。第二,_虽然能兜底,但会让穷尽性检查失效,新增变体时编译器不再提醒你,所以除非确实不关心,否则尽量写全。
if let和while let是match的简化形式,我在笔记里给了一张对照表:
| 写法 | 适用场景 | 是否会丢分支 |
|---|---|---|
match | 需要处理多个变体 | 不会,强制穷尽 |
if let | 只关心一个变体 | 会,其余被忽略 |
while let | 循环消费直到模式不匹配 | 会,但语义明确 |
4.3 Option 和 Result 的组合子要单独建卡片
Option<T>和Result<T, E>的方法很多,map、and_then、unwrap_or、ok_or、transpose这些如果只在教程里扫一遍,用的时候还是想不起来。我的做法是给每个常用组合子建一张小卡片,写清楚“输入什么、输出什么、什么时候用”。
fn parse_port(s: &str) -> Result<u16, String> { s.parse::<u16>() .map_err(|e| format!("端口解析失败: {}", e)) .and_then(|p| { if p == 0 { Err("端口不能为 0".to_string()) } else { Ok(p) } }) } fn main() { println!("{:?}", parse_port("8080")); println!("{:?}", parse_port("abc")); println!("{:?}", parse_port("0")); }这段代码把map_err和and_then放在一起,正好说明“转换错误类型”和“链式继续校验”的区别。笔记里我写了一句总结:map改值,map_err改错,and_then换一条可能失败的路。这三句话比背方法名有用。
5. trait、泛型与智能指针:选型比语法更重要
5.1 trait 是行为契约,默认方法要慎用
trait 的核心是“定义一组行为,由类型来实现”。教程里的Summary例子很经典,但我在笔记里加了一个默认方法的坑:默认方法里调用其他方法时,要注意self的借用。
trait Summary { fn summarize(&self) -> String; fn preview(&self) -> String { let s = self.summarize(); if s.chars().count() > 20 { format!("{}...", s.chars().take(20).collect::<String>()) } else { s } } } struct Article { title: String, } impl Summary for Article { fn summarize(&self) -> String { format!("文章: {}", self.title) } } fn main() { let a = Article { title: "笔记示例".to_string() }; println!("{}", a.preview()); }这里用chars().count()而不是len(),是因为len()返回字节数,中文标题会算错。这个细节教程没重点讲,但实际写中文内容时必踩。笔记里我把它归到“字符边界”专题,和字符串切片一起记。
5.2 泛型约束与单态化:编译期展开的代价
泛型函数的写法本身不复杂,关键是理解它和 trait bound 的关系,以及单态化带来的编译时间成本。
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T { let mut max = list[0]; for &item in list.iter() { if item > max { max = item; } } max } fn main() { let nums = vec![3, 7, 2, 9]; println!("{}", largest(&nums)); }笔记里我记了两点。第一,PartialOrd而不是Ord,因为浮点数只实现了偏序。第二,泛型在编译期会被单态化成具体类型,也就是说largest::<i32>和largest::<f64>会生成两份机器码。这解释了为什么泛型用多了编译会变慢、二进制会变大。
| 分发方式 | 写法 | 优点 | 代价 |
|---|---|---|---|
| 静态分发 | fn f<T: Trait>(x: T) | 无运行时开销,可内联 | 代码膨胀,编译变慢 |
| 动态分发 | fn f(x: &dyn Trait) | 二进制小,可存异构集合 | 虚表跳转,不能内联 |
这张表是我从教程和实际踩坑里合并出来的。什么时候用dyn?当你要在一个Vec里放不同具体类型、但它们共享同一组行为时。什么时候用泛型?当你追求性能、并且类型在编译期已知时。
5.3 Box、Rc、RefCell 的选型表
智能指针这一章,教程讲得比较散。我笔记里做了一张选型表,按“所有权模型”和“可变性”两个维度来分。
| 类型 | 所有权 | 可变性 | 线程安全 | 典型场景 |
|---|---|---|---|---|
Box<T> | 独占 | 继承外层 | 取决于 T | 递归类型、大对象装箱 |
Rc<T> | 共享 | 不可变 | 否 | 单线程多所有者 |
Arc<T> | 共享 | 不可变 | 是 | 多线程共享只读数据 |
RefCell<T> | 独占 | 内部可变 | 否 | 运行时借用检查 |
Mutex<T> | 共享 | 内部可变 | 是 | 多线程互斥访问 |
use std::rc::Rc; use std::cell::RefCell; fn main() { let shared = Rc::new(RefCell::new(vec![1, 2, 3])); let a = Rc::clone(&shared); a.borrow_mut().push(4); println!("{:?}", shared.borrow()); }这段代码演示了Rc<RefCell<T>>的组合:Rc解决多所有者,RefCell解决不可变引用下的可变访问。笔记里我加了一句警告:RefCell把借用检查从编译期挪到运行期,borrow_mut冲突会直接 panic,所以它适合“逻辑上确定不会冲突、但编译器证明不了”的场景,不适合当万能钥匙。
6. 错误处理、测试与并发:把“能跑”变成“可验证”
6.1?运算符与错误类型转换
?的本质是“遇到 Err 就提前返回,遇到 Ok 就取出值”。它背后依赖Fromtrait 做错误类型转换,这一点很多人用了很久都没意识到。
use std::fs::File; use std::io::{self, Read}; fn read_head(path: &str, n: usize) -> io::Result<String> { let mut f = File::open(path)?; let mut buf = String::new(); f.read_to_string(&mut buf)?; Ok(buf.chars().take(n).collect()) } fn main() { match read_head("Cargo.toml", 20) { Ok(s) => println!("{}", s), Err(e) => eprintln!("读取失败: {}", e), } }笔记里我记了一个常见困惑:为什么?有时候能自动把io::Error转成自定义错误?答案是你的自定义错误实现了From<io::Error>。如果不实现,就得手动map_err。这个机制在教程里是分散讲的,集中记一遍会清晰很多。
另外,main函数返回Result也是允许的:
fn main() -> Result<(), Box<dyn std::error::Error>> { let s = std::fs::read_to_string("Cargo.toml")?; println!("{}", s.lines().count()); Ok(()) }这种写法适合小工具和示例,省去手写match。但笔记里我提醒:库代码不要随便用Box<dyn Error>,它对调用方不友好,调用方没法按具体错误类型做分支处理。
6.2 单元测试与文档测试的双保险
Rust 的测试是语言内置的,这一点比很多语言省心。我的笔记里固定用“被测函数 + 测试模块”的结构。
pub fn add(a: i32, b: i32) -> i32 { a + b } #[cfg(test)] mod tests { use super::*; #[test] fn add_works() { assert_eq!(add(2, 3), 5); } #[test] #[should_panic(expected = "overflow")] fn overflow_panics() { let _ = add(i32::MAX, 1); } }注意i32::MAX + 1在 debug 模式下会 panic,release 模式下会回绕。这个差异我在笔记里单独标了出来,因为它是“本地测试通过、上线行为不同”的经典来源。
文档测试则是把文档注释里的代码块当测试跑:
/// 返回两个数中较大的那个。 /// /// # 示例 /// /// ``` /// let m = my_crate::max(3, 5); /// assert_eq!(m, 5); /// ``` pub fn max(a: i32, b: i32) -> i32 { if a > b { a } else { b } }cargo test会把这段文档注释里的代码编译并执行。好处是文档永远不会过时,坏处是文档里的示例必须能独立编译,写起来要稍微注意use路径。
6.3 线程与通道:先理解 move 再理解 Send
并发这一章,我认为最关键的铺垫是闭包的move和Send/Sync两个标记 trait。教程的例子是通道,我在笔记里把它扩成了“多生产者单消费者”的最小版本。
use std::sync::mpsc; use std::thread; fn main() { let (tx, rx) = mpsc::channel(); for i in 0..3 { let tx = tx.clone(); thread::spawn(move || { tx.send(i * 10).unwrap(); }); } drop(tx); for v in rx { println!("收到: {}", v); } }笔记里我特别标注了drop(tx)这一行。如果不 drop 原始的发送端,接收端的for循环会一直等,因为通道认为还有生产者活着。这个坑很隐蔽,表现为程序“卡住不退出”,而不是报错。
另一个值得记的点是move闭包会夺走捕获变量的所有权,所以循环里每次都要clone一份发送端,否则第二次迭代就用不了。这种“编译器逼着你写对”的体验,正是 Rust 并发的价值所在。
7. 常见编译错误排查实录:把报错当成老师
7.1 高频错误速查表
下面这张表是我从笔记里整理出来的高频错误,按出现频率排序。每条都对应一个真实踩过的场景,读者可以按报错关键词直接查。
| 报错关键词 | 典型场景 | 根因 | 处理方式 |
|---|---|---|---|
borrow of moved value | 变量赋值后继续用原变量 | 所有权已转移 | 用clone或改传引用 |
cannot borrow as mutable | 同时存在可变与不可变借用 | 借用规则冲突 | 缩短不可变引用的活跃区间 |
does not live long enough | 返回局部变量的引用 | 生命周期不足 | 返回拥有所有权的值 |
the trait bound is not satisfied | 泛型缺少约束 | 类型未实现所需 trait | 补T: Trait或为类型实现 |
cannot move out of | 试图从借用中移出字段 | 借用期间不能 move | 用clone或mem::take |
expected type found reference | 解引用层级不对 | &T与T混用 | 加*或用as_ref |
no method named | 方法不在当前 trait 上 | 缺少use导入 | 引入对应 trait |
7.2 借用检查报错的三步定位法
遇到借用类报错,我的笔记里固定三步。第一步,看报错行涉及哪几个变量,把它们的声明位置标出来。第二步,找每个引用的“最后一次使用”,而不是看作用域结束的花括号。第三步,判断活跃区间是否重叠,重叠就调整顺序或缩短区间。
举个实际例子:你想在遍历Vec的同时修改它,直觉写法是这样:
fn main() { let mut v = vec![1, 2, 3]; for x in &v { if *x == 2 { v.push(4); // error[E0499]: cannot borrow `v` as mutable more than once } } }编译器不允许,因为for循环持有不可变借用,push需要可变借用。处理方式有几种:收集要改的下标后再改、用retain、或者先clone一份遍历。我在笔记里把三种方案的性能和可读性对比都写了,最后推荐“先收集下标再操作”,因为它最直观且不额外分配大对象。
7.3 生命周期报错的读法
生命周期报错看起来最长最吓人,但读法有规律。编译器通常会画出两条线:一条是“数据实际活到哪”,一条是“引用被要求活到哪”。你要做的是让后者不超过前者。
fn bad() -> &str { let s = String::from("temp"); &s // error[E0106]: missing lifetime specifier }这个例子的修法不是加生命周期标注,而是返回String。因为标注只能描述关系,不能凭空延长局部变量的寿命。笔记里我把这类问题归为“所有权问题伪装成生命周期问题”,提醒自己先问“这个值该由谁拥有”。
提示:遇到看不懂的报错,先跑
rustc --explain 错误码。解释里通常有一段最小复现,比搜索引擎里的二手答案可靠。
8. 笔记的维护与长期复用:让笔记反过来推动学习
8.1 每周回放一次可运行示例
笔记写完不是结束,真正的价值在回放。我的习惯是每周挑一个章节,把该章所有src/bin里的例子重新跑一遍,顺便跑cargo clippy。这个过程经常能发现“当时以为对、其实有警告”的地方。比如早期笔记里大量使用unwrap(),后来被 clippy 提醒后才改成expect加说明,可读性明显提升。
回放还有一个好处:随着版本更新,某些写法会过时。比如数组的into_iter()在不同 edition 下行为不同,回放时编译器会直接告诉你。笔记里我会在过时的地方加删除线而不是直接删掉,保留“为什么改”的记录,这比干净的最终版更有参考价值。
8.2 用 git 管笔记,提交信息写“学会了什么”
笔记仓库我按章节提交,提交信息不写“update”,而是写具体收获,比如“搞清楚 Rc 和 Arc 的线程边界”“修正 match 守卫的借用说明”。半年后翻提交历史,相当于一条学习曲线。这个习惯是从写代码迁移过来的,用在笔记上意外地有效,因为它逼你在提交前把“这周到底学了什么”用一句话说清楚。
示例代码和主笔记分开提交也有讲究。代码改动往往伴随概念修正,两者一起提交,回溯时上下文完整。
8.3 输出倒逼输入:把笔记讲给别人听
最后分享一个我坚持了很久的做法:每整理完一个章节,试着用五分钟把它讲给一个没写过 Rust 的人听。讲不下去的地方,就是笔记里还有空洞的地方。所有权可以类比成“一本书只能借给一个人”,生命周期可以类比成“借条上的归还日期不能超过书本身的存在时间”。这些类比不是学术定义,但它们能帮你在卡壳时找回直觉。
我个人在实际操作中的体会是,Rust 官方教程的完整笔记不该追求“全”,而该追求“每一段都能跑、每一个坑都有记录”。语法细节可以随时查文档,但“我当时为什么这么写、编译器原话是什么、后来怎么改的”这三件事,只有你自己的笔记里有。把这三件事记扎实,比抄十遍教程正文都管用。