资讯详情 Rust 官方教程学习笔记:所有权、借用检查与工程实践
📅 2026/10/28 4:10:22
1. 笔记的整体框架先想清楚“记给谁看”我整理这套 Rust 官方教程完整笔记的起点其实不是打开教程第一章而是先问自己一个问题这份笔记将来是给谁看的。答案很具体——给三个月后已经忘掉大半语法、只记得“当时好像有个借用报错”的自己看。这个定位一旦定下来笔记的写法就完全不同了。如果只是把教程里的段落复制粘贴一遍那不如直接收藏原文档真正有价值的是把“我当时卡在哪、编译器原话是什么、后来怎么绕过去”这三件事固定下来。Rust 这门语言的学习曲线有个很明显的特征语法层面并不吓人吓人的是编译器在你自以为写对的时候泼冷水。官方教程的章节安排已经足够循序渐进但它是一本“线性读物”而人的理解是网状的。你可能在学所有权的时候不懂生命周期在学 trait 的时候又回头补泛型。所以我这份笔记的组织原则是按概念建卡片按教程建索引两头都保留互相能跳转。内容上我把每个知识点拆成固定五栏一句话定义、最小可运行例子、故意写错的例子、编译器报错原文、我的白话解释。这五栏看起来笨但它逼着我把“看懂”变成“能复现”。很多时候你以为自己懂了一写反例就露馅。比如闭包的move关键字光看教程觉得理所当然真让你写一个“变量被移进线程后原作用域还能不能用”的小例子才发现理解是空的。适合谁看这份笔记我的判断是三档。第一档是刚接触系统编程、想找一门能长期用的语言的开发者重点看所有权和错误处理两块第二档是写过其他语言、想快速把 Rust 用起来的工程师重点看 Cargo、trait、智能指针的选型表第三档是已经能写但总被借用检查器拦住的人直接跳到编译错误排查那一节。三档人看同一份笔记但入口不同这也是我坚持给每节加编号和索引的原因。还有个容易被忽略的点笔记的载体。我试过纯文档、试过思维导图、也试过只在编辑器里写注释最后稳定下来的是Markdown 主笔记 一个独立 Cargo 示例仓库。Markdown 负责概念和结论示例仓库负责可编译的证据。两者用相对路径互相引用笔记里每段代码都注明它在仓库里的文件名。这样做的好处是半年后我看到“这里当时报了 E0502”可以直接cd进去跑一遍而不是对着一段无法执行的代码回忆。注意不要把示例代码全部塞进一个main.rs。Rust 的模块、可见性、测试都和文件结构绑定一个文件里堆十几个例子最后你自己都分不清哪个use对应哪段逻辑。2. 学习路径与工具链准备别让环境问题消耗热情2.1 用 rustup 管工具链用 override 锁版本Rust 的版本迭代节奏不慢教程里的写法在稳定版上基本都能跑但如果你机器上同时装了稳定版和 nightlycargo 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 longesta(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 的组合子要单独建卡片OptionT和ResultT, E的方法很多map、and_then、unwrap_or、ok_or、transpose这些如果只在教程里扫一遍用的时候还是想不起来。我的做法是给每个常用组合子建一张小卡片写清楚“输入什么、输出什么、什么时候用”。fn parse_port(s: str) - Resultu16, 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 largestT: 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 fT: Trait(x: T)无运行时开销可内联代码膨胀编译变慢动态分发fn f(x: dyn Trait)二进制小可存异构集合虚表跳转不能内联这张表是我从教程和实际踩坑里合并出来的。什么时候用dyn当你要在一个Vec里放不同具体类型、但它们共享同一组行为时。什么时候用泛型当你追求性能、并且类型在编译期已知时。5.3 Box、Rc、RefCell 的选型表智能指针这一章教程讲得比较散。我笔记里做了一张选型表按“所有权模型”和“可变性”两个维度来分。类型所有权可变性线程安全典型场景BoxT独占继承外层取决于 T递归类型、大对象装箱RcT共享不可变否单线程多所有者ArcT共享不可变是多线程共享只读数据RefCellT独占内部可变否运行时借用检查MutexT共享内部可变是多线程互斥访问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()); }这段代码演示了RcRefCellT的组合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::ResultString { 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转成自定义错误答案是你的自定义错误实现了Fromio::Error。如果不实现就得手动map_err。这个机制在教程里是分散讲的集中记一遍会清晰很多。另外main函数返回Result也是允许的fn main() - Result(), Boxdyn std::error::Error { let s std::fs::read_to_string(Cargo.toml)?; println!({}, s.lines().count()); Ok(()) }这种写法适合小工具和示例省去手写match。但笔记里我提醒库代码不要随便用Boxdyn 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 模式下会 panicrelease 模式下会回绕。这个差异我在笔记里单独标了出来因为它是“本地测试通过、上线行为不同”的经典来源。文档测试则是把文档注释里的代码块当测试跑/// 返回两个数中较大的那个。 /// /// # 示例 /// /// /// 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::takeexpected type found reference解引用层级不对T与T混用加*或用as_refno method named方法不在当前 trait 上缺少use导入引入对应 trait7.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 官方教程的完整笔记不该追求“全”而该追求“每一段都能跑、每一个坑都有记录”。语法细节可以随时查文档但“我当时为什么这么写、编译器原话是什么、后来怎么改的”这三件事只有你自己的笔记里有。把这三件事记扎实比抄十遍教程正文都管用。