Rust 语法学习文档
配套 22 个可运行示例 · 从 Cargo 到异步与项目实战 · 中文详解
文档说明#
适用对象
- 有编程基础、第一次系统学习 Rust 的开发者
- 学过其他语言,想理解 Rust 所有权、借用、生命周期等核心机制的人
- 需要一份可以边看边运行的 Rust 语法速查手册的人
配套代码
本文档配套项目中的 22 个课程文件,全部位于 src/bin/ 目录。每课都是一个独立的可执行程序,可以单独运行:
cargo run --bin 00_cargo
cargo run --bin 01_variables
cargo run --bin 05_ownership
cargo run --bin 13_lifetimes
cargo run --bin 16_testing
cargo run --bin 21_project -- Cargo.toml
环境
- Rust 1.99+,Edition 2024(安装:https://rustup.rs,用
rustc --version验证) - 仅使用标准库,无第三方依赖
- 文档版本:v3.0(2026-11)
阅读约定
- ✅ 表示推荐写法
- ❌ 表示会编译失败或运行时报错
- 代码块中的
// ❌注释表示“这行不能编译”,建议取消注释亲自观察错误 - 每课末尾都有“常见错误”和“练习”,建议动手完成
第一次学习,先读 自学手册:里面是学习闭环、卡住排查、复习节奏和提问模板。
说明:文档中的代码片段用于讲解语法,部分省略了上下文;完整可运行版本见
src/bin/对应文件。
一页速览#
Rust 的设计目标可以概括为三句话:
- 内存安全:没有 GC,也没有悬垂指针和数据竞争。
- 零成本抽象:泛型、迭代器、Trait 在运行时通常没有额外开销。
- 所有权驱动:用“谁拥有、谁借用、能活多久”替代手动 free 和 GC。
| 你遇到的问题 | Rust 的机制 | 对应课程 |
|---|---|---|
| 项目怎么建、怎么跑? | Cargo、package、target | 00 |
| 变量能不能改? | let / let mut |
01 |
| 数据放在哪里? | 栈、堆、Copy / move |
02、05 |
| 函数怎么传参? | 值、&T、&mut T |
03、05 |
| 如何表达“可能有值/可能失败”? | Option<T> / Result<T, E> |
07、10 |
| 如何复用代码? | 泛型 + Trait | 11、12 |
| 引用能活多久? | 生命周期 | 13 |
| 如何共享数据? | Rc / Arc / RefCell / Mutex |
15 |
| 如何保证正确性? | 测试 + 断言 | 16 |
| 如何并发? | 线程 / channel / Arc<Mutex> |
17 |
| 如何写宏? | macro_rules! |
18 |
| 如何做异步? | async / await + 执行器 |
19 |
| 如何调用 C / 写 unsafe? | unsafe / extern "C" |
20 |
| 如何把知识串起来? | 项目实战 | 21 |
学习顺序建议:先理解 05(所有权),再学 13(生命周期);这两课是 Rust 的分水岭。
学习路线#
Rust 的学习曲线主要集中在“所有权”和“生命周期”。建议分六个阶段:
| 阶段 | 课程 | 目标 | 建议时间 |
|---|---|---|---|
| 项目准备 | 00 | 会建项目、跑命令、看懂 Cargo.toml | 0.5 天 |
| 基础语法 | 01–04 | 能写变量、类型、函数、循环和条件判断 | 2–3 天 |
| 核心机制 | 05–07 | 理解所有权、借用、结构体、枚举和模式匹配 | 3–5 天 |
| 工程能力 | 08–13 | 掌握集合、模块、错误处理、泛型、Trait、生命周期 | 5–7 天 |
| 进阶主题 | 14–15 | 闭包、迭代器、智能指针、多线程共享 | 2–3 天 |
| 实战拓展 | 16–21 | 测试、并发、宏、异步、unsafe、项目实战 | 5–8 天 |
推荐顺序:00 → 21 顺序学习。00 是 Cargo 准备课,14–15 是进阶语法,16–21 是工程实践。
四种读法
| 目标 | 路线 |
|---|---|
| 快速上手 | 00 → 01 → 05 → 07 → 10,先能写小工具 |
| 系统学习 | 00 → 21 顺序阅读并完成练习 |
| 项目实战 | 00 → 16 → 17 → 19 → 21,先学测试、并发、异步,再做项目 |
| 复习 / 面试 | 一页速览 → 语法速查表 → 常见编译错误 → 编译器报错实战 → 十个常见坑 → 术语表 → FAQ |
学习原则
- 先运行,再阅读,再修改。不要只看不写。
- 遇到编译错误,先读错误码和编译器建议,再查文档。
- 每学完一课,尝试不看源码复写关键示例。
- 不要过早追求“零 clone”。先让程序正确,再优化所有权。
自学手册#
这份文档可以“读”,但更推荐“练”。自学最容易失败的地方不是看不懂语法,而是:卡住没人问、写完不知道对不对、学完没有复习。下面是一套最小闭环。
学习闭环#
每一课按这个顺序走:
- 先跑:
cargo run --bin <课程名>,看输出,不改代码。 - 再读:读本课正文,遇到不懂的回到源码对照。
- 改一改:改类型、改变量名、改分支,观察编译器和输出变化。
- 合上书:完成“本课目标”里的可验收动作,不看源码复写关键例子。
- 做验收:完成练习区的验收任务,跑断言;卡住 15 分钟再看参考实现。
- 做自测:概念、读代码、改错三问;答不出就回到对应小节。
- 复习:第 1、3、7、14 天各花 10 分钟回看小结和错题。
规则:先自己写,再看答案;答案不是“读过”,而是“能复现”。本课目标里的“理解/掌握”,以能否完成验收任务和自测为准,不以“看过”为准。
卡住时怎么办#
按顺序排查,不要一上来就问人或让 AI 重写:
- 读错误码和第一行:
error[E0382]比后面的几十行更重要。 - 看
help:/note::编译器经常直接给出改法。 - 跑
rustc --explain E0382:标准库官方解释,比搜索更快。 - 做最小复现:把代码删到 10 行以内,直到错误还出现。
- 加
dbg!/RUST_BACKTRACE=1:确认值、分支、panic 位置。 - 问三个问题:谁拥有这个值?谁在借用?借用持续到什么时候?
- 查文档/搜索:标准库文档 → Rust Book → 错误码 → 社区提问。
如果是生命周期错误,先画时间线:每个引用的有效区间从哪开始、到哪结束、返回值要活多久。
环境故障排查#
| 现象 | 常见原因 | 处理 |
|---|---|---|
cargo: command not found |
没装 rustup 或 PATH 未生效 | 重开终端,rustup update,检查 PATH |
linker 'cc' not found |
缺少 C 链接器 | Linux 安装 build-essential;Windows 安装 VS Build Tools |
edition2024 is unstable |
工具链太旧 | rustup update stable,确认 ≥ 1.85 |
no such file or directory |
不在项目根目录 | cd 到有 Cargo.toml 的目录 |
could not find system library |
系统库缺失 | 按报错安装对应的 -dev / -devel 包 |
cargo add 失败 |
网络或镜像问题 | 配置镜像,或手动编辑 Cargo.toml |
复习节奏#
不要“学完 22 课再复习”,会忘。用间隔复习:
| 时间 | 做什么 |
|---|---|
| 当天 | 合上书复写本课最小例子 |
| 第 1 天 | 重做本课自测,错题标红 |
| 第 3 天 | 只看小结,尝试口述关键概念 |
| 第 7 天 | 做一次跨课小练习(例如用 05 + 06 + 07 写一个库存程序) |
| 第 14 天 | 回看错题和“卡住记录”,再写一次 |
网页版的学习记录表可以记录“自测分”和“下次复习”,并支持导出/导入进度。
笔记模板#
每课只记三行,避免抄书:
本课解决:(一句话)
我卡住的点:(一句话)
能复述的一句话:(一句话)
错题单独记:
错误码/现象:
我原来的理解:
正确规则:
下次如何识别:
提问模板#
问人之前,先准备这四样,通常自己就解决了:
目标:我想让程序做什么
最小代码:删到能复现问题的最短版本
完整报错:从 error[...] 开始,不要只贴最后一行
已尝试:我查了什么、改了什么、结果如何
避免 tutorial hell#
- 不要连续看三课不写代码;每课至少改一处、跑一次。
- 不要收集 20 个教程;这套文档 + 标准库文档 + Rust Book 足够。
- 不要以“看完”为目标,以“能不看资料写出本课验收题”为目标。
- 不要过早追求零 clone、零 unsafe、完美生命周期;先正确,再优化。
- 如果一课卡超过 2 小时,先跳过,记录问题,学完下一课再回来。
教回去#
每课结束,合上书,用 60 秒向一个假想的人解释:
- 这个机制解决什么问题?
- 不用它会怎样?
- 什么时候该用/不该用?
如果解释不出来,说明你只是“看懂了”,还没有“学会”。
第 00 课:Cargo 与项目结构#
运行:cargo run --bin 00_cargo
源码:src/bin/00_cargo.rs
难度:★☆☆ | 前置:无 | 建议用时:15–20 分钟 | 定位:准备
本课目标
- 看懂一个最小
Cargo.toml的字段 - 区分 package、crate、binary target
- 会用
run / check / test / fmt / clippy / doc / build - 知道
src/、tests/、examples/的目录约定
0.1 Cargo.toml#
[package]
name = "rust"
version = "0.1.0"
edition = "2024"
rust-version = "1.99"
default-run = "rust"
[dependencies]
name是 package 名,也是 library crate 的默认名字edition决定语言版本;本项目使用 2024rust-version声明最低支持的 Rust 版本default-run让cargo run在多个 binary 中选默认入口- 没有第三方依赖时,
[dependencies]可以留空
0.2 package、crate 与 target#
| 概念 | 说明 |
|---|---|
| package | 一个 Cargo.toml 管理的项目 |
| library crate | src/lib.rs,最多一个,可被其他 crate 引用 |
| binary crate | src/main.rs 或 src/bin/*.rs,每个文件一个可执行程序 |
| target | 一次具体构建产物,例如 --bin 00_cargo |
一个 package 可以同时有 library 和多个 binary。本项目第 09 课就是这种情况:
src/lib.rs -> library crate(名字 rust)
src/main.rs -> 默认 binary
src/bin/00_cargo.rs -> binary target: 00_cargo
src/bin/01_variables.rs -> binary target: 01_variables
0.3 常用命令#
| 命令 | 作用 |
|---|---|
cargo new my_app |
新建 package |
cargo run --bin 00_cargo |
构建并运行指定 binary |
cargo check --all-targets |
只做类型检查,最快 |
cargo test |
运行单元测试、集成测试、文档测试 |
cargo fmt |
按 rustfmt 格式化 |
cargo clippy --all-targets |
运行 lint |
cargo doc --open |
生成并打开 API 文档 |
cargo build --release |
发布构建,开启优化 |
日常顺序通常是:check → run / test → fmt → clippy。
0.4 目录约定#
.
├── Cargo.toml
├── Cargo.lock
├── src
│ ├── main.rs
│ ├── lib.rs
│ └── bin
│ ├── 00_cargo.rs
│ └── 01_variables.rs
├── tests
│ └── integration_test.rs
├── examples
│ └── demo.rs
└── target
src/bin/*.rs会被自动识别为 binary,不需要在Cargo.toml里逐个声明tests/*.rs是集成测试,只能访问 library crate 的公共 APIexamples/*.rs用cargo run --example <name>运行target/是构建产物,通常不提交
0.5 依赖、profile 与 workspace#
添加依赖:
cargo add serde --features derive
等价于在 Cargo.toml 写:
[dependencies]
serde = { version = "1", features = ["derive"] }
发布优化:
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
多个 crate 共存时可以用 workspace:
[workspace]
members = ["app", "core", "cli"]
resolver = "2"
workspace 让多个 crate 共用一份 Cargo.lock 和 target/,适合中大型项目。
0.6 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
no bin target named ... |
--bin 名字写错 |
用 cargo run 查看列表 |
failed to parse manifest |
Cargo.toml 语法错误 |
检查表头、引号、逗号 |
edition2024 is unstable |
工具链太旧 | rustup update 到 1.85+ |
unresolved import |
忘记在 Cargo.toml 加依赖 |
cargo add 或检查 feature |
could not compile ... (bin ...) |
某个 binary 单独报错 | cargo check --bin <名字> |
0.7 练习#
- 用
cargo new建一个自己的项目,运行cargo check和cargo run。 - 把
src/main.rs改名成src/bin/hello.rs,观察cargo run --bin hello。 - 给项目加一个
tests/smoke.rs,写一个最简单的#[test]。 - 用
cargo doc --open打开文档,找到标准库的String页面。
运行输出(节选)
=== 1. 当前 package ===
name = rust
version = 0.1.0
edition = 2024(见 Cargo.toml)
rust-version= 1.99
manifest = /home/.../rust
=== 2. Cargo.toml(编译期嵌入) ===
[package]
name = "rust"
...
本课小结
Cargo.toml描述 package,src/目录描述 targetcargo check是最快的反馈,cargo test负责正确性src/bin/*.rs自动成为可执行程序,不需要手写注册表- 依赖、profile、workspace 都是
Cargo.toml里的配置
📖 延伸阅读:The Cargo Book、TRPL · Hello, Cargo!
🧪 本课练习与自测:练习提示 的“第 00 课”。
第 01 课:变量、可变性与常量#
运行:cargo run --bin 01_variables
源码:src/bin/01_variables.rs
难度:★☆☆ | 前置:00 | 建议用时:30–40 分钟 | 定位:核心
本课目标
- 理解
let默认不可变、mut的作用 - 掌握变量遮蔽与
const/static的区别 - 能用类型标注和后缀写出清晰的字面量
1.1 不可变绑定#
Rust 的变量默认不可变:
let x = 5;
println!("x = {x}");
// x = 6; // ❌ cannot assign twice to immutable variable
不可变是默认值,不是限制。它让编译器可以放心优化,也让读代码的人知道这个值不会悄悄变化。
1.2 可变绑定#
需要修改变量时,加 mut:
let mut y = 10;
y += 5;
y *= 2;
println!("y = {y}"); // 30
mut 只允许改变值,不允许改变类型:
let mut n = 1;
// n = "hello"; // ❌ 类型不匹配
1.3 变量遮蔽(shadowing)#
用同一个名字重新 let,会创建一个新变量,旧变量被遮蔽:
let spaces = " "; // &str
let spaces = spaces.len(); // usize,类型可以改变
println!("{spaces}"); // 3
遮蔽与 mut 的区别:
| 特性 | mut |
遮蔽 |
|---|---|---|
| 是否同一变量 | 是 | 否,是新变量 |
| 能否改变类型 | 不能 | 能 |
| 能否改变可变性 | 不能 | 能 |
| 典型用途 | 原地修改 | 转换后继续用同一个名字 |
内层作用域的遮蔽只在内层有效:
let value = 1;
{
let value = value * 10;
println!("内层:{value}"); // 10
}
println!("外层:{value}"); // 1
1.4 常量 const#
const MAX_POINTS: u32 = 100_000;
const SECONDS_PER_DAY: u32 = 24 * 60 * 60;
规则:
- 必须标注类型
- 值必须是编译期可计算的常量表达式
- 命名规范:
SCREAMING_SNAKE_CASE - 可以声明在函数内、模块内或全局
- 使用处会被内联,没有固定内存地址
1.5 静态变量 static#
static GREETING: &str = "hello";
static 有固定内存地址,生命周期是整个程序('static)。可变静态变量需要 unsafe:
// static mut COUNTER: u32 = 0;
// unsafe { COUNTER += 1; } // 不推荐
初学阶段尽量避免 static mut,需要共享可变状态时用 Mutex 或 Atomic*。
1.6 类型标注与命名规范#
let guess: u32 = "42".parse().expect("不是数字");
let ratio = 1.5_f64;
let big = 1_000_000_u64;
let _unused = 2.5; // 下划线开头,避免未使用警告
命名规范:
- 变量、函数、模块:
snake_case - 类型、Trait、枚举变体:
PascalCase - 常量、静态变量:
SCREAMING_SNAKE_CASE
1.7 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
cannot assign twice to immutable variable |
对 let 变量赋值 |
加 mut 或使用遮蔽 |
type annotations needed |
编译器无法推断类型 | 添加 : T 或使用带后缀的字面量 |
unused variable |
变量未使用 | 使用它,或命名为 _name |
1.8 练习#
- 声明一个
i32变量,用mut修改三次并打印。 - 用遮蔽把
&str转成usize,再转成String。 - 定义常量
PI和静态变量APP_NAME,分别在函数内外使用。
运行输出(节选)
=== 1. 不可变绑定(默认行为) ===
x = 5
=== 2. 用 mut 声明可变变量 ===
修改前:y = 10
修改后:y = 30
=== 3. 变量遮蔽(shadowing) ===
spaces = 3(类型已从 &str 变为 usize)
本课小结
- 变量默认不可变,
mut只改变可变性,不改变类型 - 遮蔽会创建新变量,可以改变类型
const编译期内联,static有固定地址且生命周期为'static
📖 延伸阅读:The Rust Book · 变量与可变性
🧪 本课练习与自测:练习提示 的“第 01 课”。
第 02 课:数据类型#
运行:cargo run --bin 02_data_types
源码:src/bin/02_data_types.rs
难度:★☆☆ | 前置:01 | 建议用时:40–50 分钟 | 定位:核心
本课目标
- 区分标量、元组、数组、切片
- 理解整数溢出与浮点误差
- 掌握
as、From、TryFrom的使用边界
2.1 标量类型#
| 类型 | 说明 | 示例 |
|---|---|---|
i8 / i16 / i32 / i64 / i128 |
有符号整数 | -42_i32 |
u8 / u16 / u32 / u64 / u128 |
无符号整数 | 255_u8 |
isize / usize |
与平台指针宽度相同 | 42_usize |
f32 / f64 |
浮点数 | 3.14_f64 |
bool |
布尔值 | true / false |
char |
Unicode 标量值,4 字节 | '汉' / '🦀' |
整数默认是 i32,浮点默认是 f64,索引用 usize。
2.2 整数字面量#
let dec = 98_222; // 十进制
let hex = 0xff; // 十六进制
let octal = 0o77; // 八进制
let binary = 0b1111_0000;// 二进制
let byte = b'A'; // u8 字节字面量
2.3 整数溢出#
let max = u8::MAX; // 255
max.wrapping_add(1); // 0,回绕
max.checked_add(1); // None
max.saturating_add(1); // 255,饱和
max.overflowing_add(1); // (0, true)
直接 max + 1 的行为:
- debug 构建:panic(arithmetic overflow)
- release 构建:默认回绕,可用
overflow-checks = true改为 panic
生产代码应使用 checked_* 或 saturating_* 明确表达意图。
2.4 浮点数#
let x: f64 = 2.0;
let y = 3.5_f32;
注意:
- 浮点数遵循 IEEE-754,
0.1 + 0.2 != 0.3 - 不要直接用
==比较浮点数,使用误差范围 f64::NAN != f64::NAN,判断 NaN 用is_nan()f64::INFINITY表示无穷大
let sum: f64 = 0.1 + 0.2;
println!("{}", (sum - 0.3).abs() < 1e-9); // true
f64::EPSILON 是 1.0 附近的最小间隔,不是通用容差;比较浮点数应使用与量级相关的相对误差,或使用 approx 这类库。
2.5 元组#
元组长度固定,元素类型可以不同:
let tup: (i32, f64, char) = (500, 6.4, 'x');
let (a, b, c) = tup; // 解构
println!("{}", tup.0); // 索引访问
单元类型 () 是空元组,表示“没有有意义的值”。
2.6 数组#
数组长度固定,元素类型相同:
let arr: [i32; 5] = [1, 2, 3, 4, 5];
let zeros = [0u8; 4]; // 4 个 0
println!("{}", arr[0]);
println!("{}", arr.len());
索引越界会在运行时 panic。需要动态长度时用 Vec<T>。
2.7 切片#
切片是对连续数据的借用视图,由指针和长度组成:
let slice: &[i32] = &arr[1..4]; // 左闭右开
let text = "hello 世界";
let hello = &text[..5];
let world = &text[6..];
字符串切片按字节索引,必须落在 UTF-8 字符边界上,否则 panic。
2.8 类型转换#
let big: i32 = 300;
println!("{}", big as u8); // 44,截断
println!("{}", -1_i32 as u8); // 255
println!("{}", 3.99_f64 as i32); // 3,向零截断
as 可能丢数据。无损转换优先用 From / Into:
let a: i64 = i64::from(42_i32);
let b: i64 = 42_i32.into();
可能失败的转换用 TryFrom / TryInto:
let small = u8::try_from(300_i32); // Err(TryFromIntError)
let ok = u8::try_from(42_i32); // Ok(42)
Edition 2021 起 TryFrom / TryInto 已在 prelude 中,不需要手动 use。
2.9 字符串类型#
| 类型 | 所有权 | 特点 |
|---|---|---|
&str |
借用 | 字符串切片,通常指向字面量或 String 的一部分 |
String |
拥有 | 堆分配、可增长、UTF-8 |
函数参数优先写 &str,因为 &String 可以自动解引用为 &str。
2.10 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
index out of bounds |
数组/Vec 越界 | 用 get() 返回 Option |
byte index is not a char boundary |
字符串切片切在字符中间 | 用 chars() 或按字符边界切 |
mismatched types |
类型不一致 | 用 as 或 From / Into 转换 |
2.11 练习#
- 打印
i32、u64、usize、char的size_of。 - 用四种进制定义整数并打印。
- 用
checked_add处理u8::MAX + 1。 - 遍历字符串的
chars()和bytes(),比较数量。
运行输出(节选)
=== 1. 整数类型 ===
a = -42
b = 170 (0b1010_1010)
c = 493 (0o755)
d = 65535 (0xFF_FF)
e = 1000000
f = 340282366920938463463374607431768211455 (u128::MAX)
g = -1, h = 42
default_int 的类型是 i32:42
本课小结
- 整数默认
i32、浮点默认f64,索引用usize as可能截断,失败转换用TryFrom- 浮点比较不要直接用
==,整数溢出优先用checked_*/saturating_*
📖 延伸阅读:The Rust Book · 数据类型
🧪 本课练习与自测:练习提示 的“第 02 课”。
第 03 课:函数#
运行:cargo run --bin 03_functions
源码:src/bin/03_functions.rs
难度:★★☆ | 前置:01 | 建议用时:40–50 分钟 | 定位:核心
本课目标
- 写出带参数和返回值的函数
- 分清表达式与语句、提前返回与发散函数
- 了解函数指针、递归、
const fn
3.1 基本语法#
fn add(a: i32, b: i32) -> i32 {
a + b
}
- 参数必须标注类型
- 返回类型写在
->后面 - 最后一个表达式就是返回值,不要加分号
- 没有返回值时返回
()
3.2 表达式与语句#
语句执行动作,不返回值;表达式求值并返回值。
let y = {
let x = 3;
x * x // 没有分号,块的值是 9
};
如果最后一行加了分号,块的值就变成 ():
let unit = {
let x = 3;
let _ = x * x; // 块仍然返回 ()
};
3.3 提前返回#
fn abs(n: i32) -> i32 {
if n < 0 {
return -n;
}
n
}
return 适合提前退出,最后一个表达式适合自然返回。
3.4 发散函数#
返回类型 ! 表示函数永远不会正常返回:
fn boom() -> ! {
panic!("boom")
}
panic!、std::process::exit、无限 loop 都属于发散表达式。
3.5 函数指针与高阶函数#
函数名可以当作函数指针:
fn double(x: i32) -> i32 { x * 2 }
let f: fn(i32) -> i32 = double;
println!("{}", f(21));
fn apply(f: fn(i32) -> i32, x: i32) -> i32 {
f(x)
}
函数指针适合简单回调;需要捕获环境时用闭包(第 14 课)。
3.6 递归#
fn factorial(n: u64) -> u64 {
if n <= 1 { 1 } else { n * factorial(n - 1) }
}
递归要有终止条件。朴素 fib 是指数级复杂度,真实项目应使用迭代或记忆化。
3.7 嵌套函数与 const fn#
fn outer() {
fn square(n: i32) -> i32 { n * n }
println!("{}", square(12));
}
const fn square_const(n: usize) -> usize { n * n }
const TABLE_SIZE: usize = square_const(8);
const fn 可以在编译期求值,限制比普通函数多。
3.8 参数传递#
fn by_value(mut n: i32) -> i32 { n += 1; n }
fn by_ref(n: &mut i32) { *n += 1; }
fn by_str(s: &str) -> usize { s.len() }
建议:
- 只读:
&T/&str - 需要修改:
&mut T - 需要拥有或返回新值:按值传入
3.9 Rust 没有函数重载和默认参数#
// fn greet(name: &str) {}
// fn greet(name: &str, times: u32) {} // ❌ 不允许
替代方案:
- 不同函数名:
greet/greet_many - 结构体 + 方法 + 构造器
Option参数或 builder 模式
3.10 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
expected (), found i32 |
函数声明返回 (),但最后表达式产生了值 |
去掉分号或显式 return |
mismatched types |
参数或返回值类型不一致 | 检查函数签名与调用处 |
cannot move out of borrowed content |
试图从借用中移出值 | 用 clone 或改用引用 |
unreachable expression |
在 return / panic! 后继续写代码 |
删除不可达代码 |
3.11 练习#
- 写一个返回
(min, max)的函数。 - 用函数指针数组实现加减乘除。
- 用
const fn计算编译期常量。 - 写一个递归函数计算斐波那契,再改成迭代版本。
运行输出(节选)
=== 1. 基本函数 ===
add(2, 3) = 5
print_number: 7
print_number 的返回值是 ()
=== 2. 表达式 vs 语句 ===
块表达式的值 = 9
加分号后 = ()
本课小结
- 函数最后一个表达式就是返回值,加分号会变成
() !表示发散,return适合提前退出- Rust 没有重载和默认参数,用不同函数名或 builder 替代
📖 延伸阅读:The Rust Book · 函数
🧪 本课练习与自测:练习提示 的“第 03 课”。
第 04 课:流程控制#
运行:cargo run --bin 04_control_flow
源码:src/bin/04_control_flow.rs
难度:★★☆ | 前置:01 | 建议用时:40–50 分钟 | 定位:核心
本课目标
- 掌握
if/loop/while/for - 会用循环标签和
break带值 - 能用
while let、let-else简化流程
4.1 if 表达式#
let number = 7;
if number % 2 == 0 {
println!("偶数");
} else {
println!("奇数");
}
let desc = if number > 5 { "大于 5" } else { "不大于 5" };
条件必须是 bool,Rust 不会把 0/非 0 当布尔值。所有分支必须返回相同类型。
4.2 loop、while、for#
let result = loop {
break 42;
};
while countdown > 0 {
countdown -= 1;
}
for i in 1..=5 {
println!("{i}");
}
区间:
1..5:左闭右开,1,2,3,41..=5:左闭右闭,1,2,3,4,5.rev():倒序.step_by(3):步长
4.3 循环标签#
'outer: for i in 1..=5 {
for j in 1..=5 {
if i * j == 12 {
break 'outer;
}
}
}
break 'label value 可以把值带出循环。带标签的块也可以使用 break 'label value。
4.4 while let 与 if let#
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("{top}");
}
if let Some(name) = maybe_name {
println!("{name}");
}
4.5 let-else#
for input in ["1", "不是数字"] {
let Ok(value) = input.parse::<i32>() else {
println!("解析失败");
continue;
};
println!("{value}");
}
else 分支必须发散:return、break、continue 或 panic!。
4.6 match 预览#
let label = match n {
0 => "零",
1..=9 => "个位数",
x if x < 0 => "负数",
_ => "大数",
};
match 是表达式,必须穷尽所有可能。第 07 课详细展开。
4.7 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
expected bool, found integer |
if / while 条件不是 bool |
使用比较表达式 |
unreachable pattern |
match 分支被前面的分支覆盖 |
调整分支顺序或删除重复分支 |
cannot break outside of a loop |
break 不在循环或带标签块内 |
检查标签与作用域 |
let-else 的 else 分支没有发散 |
else 必须 return / break / continue / panic! |
补上发散语句 |
4.8 练习#
- 用
loop计算 1 到 100 的和,用break返回值。 - 用带标签的循环找到第一个满足条件的
(i, j)。 - 用
while let清空一个Vec。 - 用
let-else解析命令行参数。
运行输出(节选)
=== 1. if 表达式 ===
7 是奇数
description = 大于 5
=== 2. else if ===
score = 86, grade = B
=== 3. loop:无限循环 + break 带值 ===
loop 的结果 = 50
本课小结
if、match、块都是表达式break 'label value可以带值跳出多层循环let-else的else必须发散
📖 延伸阅读:The Rust Book · 控制流
🧪 本课练习与自测:练习提示 的“第 04 课”。
第 05 课:所有权与借用#
运行:cargo run --bin 05_ownership
源码:src/bin/05_ownership.rs
难度:★★☆ | 前置:01–04 | 建议用时:60–90 分钟 | 定位:核心
本课目标
- 记住所有权三条规则
- 理解 move、clone、Copy 的差异
- 掌握借用规则、切片与 NLL
这是 Rust 最核心的一课。
5.1 三条所有权规则#
- 每个值都有一个所有者。
- 同一时刻只能有一个所有者。
- 所有者离开作用域时,值被自动
drop。
5.2 栈与堆#
- 栈:大小固定,分配释放快,存放
Copy类型和String的元数据。 - 堆:大小可变,通过指针访问,
String的字符数据在堆上。
String 在栈上存三部分:指针、长度、容量。
5.3 移动(move)#
let s1 = String::from("hello");
let s2 = s1; // s1 被移动
// println!("{s1}"); // ❌ borrow of moved value
赋值只复制栈上的三字段,然后把 s1 标记为已移动。不会深拷贝,也不会双重释放。
5.4 克隆(clone)#
let s3 = s2.clone();
clone 深拷贝堆数据,可能很昂贵。不要无脑 clone。
5.5 Copy 类型#
整数、浮点、bool、char、由 Copy 类型组成的元组/数组都实现了 Copy:
let x = 5;
let y = x; // 按位复制,x 仍可用
String、Vec、Box 拥有堆内存,不是 Copy。
5.6 所有权与函数#
fn take(s: String) { } // 取得所有权
fn borrow(s: &str) { } // 借用
fn change(s: &mut String) { } // 可变借用
let name = String::from("Ferris");
take(name);
// println!("{name}"); // ❌
let name2 = String::from("Ferris");
borrow(&name2);
println!("{name2}"); // ✅
5.7 借用规则#
任意时刻,要么有多个 &T,要么只有一个 &mut T。
let mut value = 10;
let r1 = &value;
let r2 = &value;
println!("{r1} {r2}");
// let r3 = &mut value; // ❌ 与 r1/r2 冲突
let r3 = &mut value; // ✅ r1/r2 最后一次使用之后
*r3 += 1;
NLL(Non-Lexical Lifetimes):引用的有效期到它最后一次使用为止,而不是到作用域结束。
5.8 切片也是借用#
let mut text = String::from("hello world");
let word = first_word(&text);
println!("{word}");
text.push_str("!"); // ✅ word 最后一次使用之后
如果 word 还在使用,text.push_str 会报错:不能同时存在不可变借用和可变借用。
5.9 drop 与作用域#
{
let temp = String::from("temp");
} // temp 在这里释放
let bye = String::from("bye");
drop(bye); // 提前释放
5.10 引用不能悬垂#
// let r;
// {
// let s = String::from("short");
// r = &s;
// }
// println!("{r}"); // ❌ s 已释放
返回引用的函数必须让编译器知道返回值和谁绑定,这就是生命周期标注(第 13 课)。
5.11 参数选择建议#
| 需求 | 参数类型 |
|---|---|
| 只读,不转移所有权 | &T / &str |
| 需要修改 | &mut T |
| 需要拥有或返回新值 | T |
5.12 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
borrow of moved value |
值已被移动 | 用 clone 或传引用 |
cannot borrow as mutable |
已有不可变借用 | 缩小借用范围或先结束借用 |
cannot borrow as immutable because it is also borrowed as mutable |
可变借用未结束 | 调整使用顺序 |
does not live long enough |
引用比数据活得久 | 调整作用域或返回拥有所有权的值 |
5.13 练习#
- 写一个函数,接收
String并返回它的长度,分别用传值和传引用实现。 - 解释为什么
let s2 = s1; println!("{s1}")会失败。 - 用 NLL 改写一段借用冲突的代码。
- 写一个
first_word函数,返回&str。
运行输出(节选)
=== 1. 栈与堆 ===
stack_num = 42(值直接在栈上)
heap_string = hello(栈上存指针/长度/容量,数据在堆上)
=== 2. 移动(move) ===
s2 = hello
移动后只有 s2 有效。
=== 3. 克隆(clone) ===
本课小结
- 同一时刻只有一个所有者,离开作用域自动释放
- 要么多个
&T,要么一个&mut T - NLL 让借用持续到最后一次使用,而不是整个作用域
📖 延伸阅读:The Rust Book · 什么是所有权
🧪 本课练习与自测:练习提示 的“第 05 课”。
第 06 课:结构体#
运行:cargo run --bin 06_structs
源码:src/bin/06_structs.rs
难度:★★☆ | 前置:05 | 建议用时:45–60 分钟 | 定位:核心
本课目标
- 定义三种结构体并创建实例
- 区分
&self、&mut self、self方法 - 会用
derive和Debug打印
6.1 三种结构体#
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
struct Color(i32, i32, i32); // 元组结构体
struct AlwaysEqual; // 单元结构体
6.2 创建实例#
let mut user = User {
active: true,
username: String::from("ferris"),
email: String::from("ferris@rust-lang.org"),
sign_in_count: 0,
};
user.sign_in_count += 1;
字段初始化简写:
let username = String::from("crab");
let user1 = User {
active: true,
username, // 等价于 username: username
email: String::from("crab@example.com"),
sign_in_count: 0,
};
结构体更新语法:
let user2 = User {
email: String::from("new@example.com"),
..user1
};
// user1 的 username / email 已被移入 user2,不能再整体使用。
// 若想保留 user1,可为 User 加上 #[derive(Clone)](derive 见 6.5),再写 ..user1.clone()。
6.3 方法与关联函数#
impl User {
fn new(username: &str, email: &str) -> Self {
Self {
active: true,
username: username.to_string(),
email: email.to_string(),
sign_in_count: 0,
}
}
fn display_name(&self) -> &str {
&self.username
}
fn deactivate(&mut self) {
self.active = false;
}
fn into_username(self) -> String {
self.username
}
}
| 第一个参数 | 含义 | 调用后原变量 |
|---|---|---|
&self |
不可变借用 | 仍可用 |
&mut self |
可变借用 | 仍可用 |
self |
取得所有权 | 不可用 |
关联函数没有 self,用 Type::function() 调用,常用于构造器。
6.4 多个 impl 块#
impl Rectangle {
fn area(&self) -> f64 { self.width * self.height }
}
impl Rectangle {
fn perimeter(&self) -> f64 { 2.0 * (self.width + self.height) }
}
6.5 derive#
#[derive(Debug, Clone, Copy, PartialEq)]
struct Point {
x: f64,
y: f64,
}
常用 derive:
Debug:{:?}打印Clone:.clone()Copy:赋值时按位复制PartialEq/Eq:==比较PartialOrd/Ord:排序Hash:作为 HashMap/HashSet 的键Default:Default::default()
6.6 Debug 打印#
println!("{point:?}"); // 单行
println!("{point:#?}"); // 多行美化
自定义输出格式需要手动实现 Display(第 12 课)。
6.7 结构体与所有权#
结构体拥有自己的字段。字段是 String 时,结构体也拥有这块堆内存。
如果结构体所有字段都是 Copy,结构体也可以 derive(Copy)。
6.8 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
missing field |
创建实例时漏了字段 | 补全字段或用 ..base |
no field named ... |
字段名拼写错误 | 检查结构体定义 |
cannot borrow ... as mutable |
方法需要 &mut self,但变量不是 mut |
声明 let mut |
use of moved value |
结构体更新语法移走了字段 | 用 clone 或调整字段顺序 |
6.9 练习#
- 定义
Rectangle,实现area、can_hold、square。 - 为
User添加email_domain方法。 - 用结构体更新语法复制一个用户并修改邮箱。
- 给结构体加
#[derive(Debug, PartialEq)],比较两个实例。
运行输出(节选)
=== 1. 定义与创建结构体 ===
user = User { active: true, username: "ferris", email: "ferris@rust-lang.org", sign_in_count: 0 }
登录次数:1
=== 2. 字段初始化简写 ===
user2 = User { active: true, username: "crab", email: "crab@example.com", sign_in_count: 0 }
=== 3. 结构体更新语法 ===
user3 = User { active: true, username: "crab", email: "new@example.com", sign_in_count: 0 }
本课小结
- 结构体拥有自己的字段,
String字段拥有堆数据 &self只读、&mut self可改、self消耗- 更新语法会移动未覆盖字段,保留原值需要
Clone
📖 延伸阅读:The Rust Book · 定义结构体 / 方法
🧪 本课练习与自测:练习提示 的“第 06 课”。
第 07 课:枚举与模式匹配#
运行:cargo run --bin 07_enums_and_matching
源码:src/bin/07_enums_and_matching.rs
难度:★★☆ | 前置:06 | 建议用时:60–80 分钟 | 定位:核心
本课目标
- 定义带数据的枚举
- 用
match做穷尽匹配和解构 - 熟练使用
Option、Result、if let
7.1 枚举基础#
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
枚举变体可以携带数据,形式有三种:
- 单元变体:
Quit - 元组变体:
Write(String) - 结构体变体:
Move { x: i32, y: i32 }
7.2 Option 与 Result#
标准库最重要的两个枚举:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
Option<T> 把“可能没有值”编码进类型系统,替代 null。
7.3 match 与穷尽性#
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("来自 {state:?}");
25
}
}
}
match 必须覆盖所有变体,否则编译失败。新增变体时,编译器会提醒所有需要更新的 match。
7.4 绑定、守卫、范围、或模式、@#
let n = 5;
let label = match n {
0 => "零",
1 | 2 | 3 => "小数字",
4..=9 => "中等数字",
x if x < 0 => "负数",
_ => "大数字",
};
println!("{label}");
let age = 20;
match age {
n @ 0..=12 => println!("儿童:{n}"),
n @ 13..=19 => println!("青少年:{n}"),
n => println!("成人:{n}"),
}
7.5 解构#
let (x, y) = (1, 2);
match point {
Point { x: 0, y: 0 } => println!("原点"),
Point { x, y: 0 } => println!("x 轴,x = {x}"),
Point { x: 0, y } => println!("y 轴,y = {y}"),
Point { x, y } => println!("({x}, {y})"),
}
let Point { x, .. } = point; // 只取 x
7.6 if let / let-else / while let / matches!#
if let Some((host, port)) = config {
println!("{host}:{port}");
}
// 下面这段假设位于循环体内
let Ok(id) = input.parse::<u32>() else {
continue;
};
while let Some(top) = stack.pop() {
println!("{top}");
}
println!("{}", matches!(coin, Coin::Quarter(_)));
7.7 Option 组合子#
let some = Some(10);
let none: Option<i32> = None;
some.map(|x| x * 2); // Some(20)
none.unwrap_or(0); // 0
none.unwrap_or_else(|| 42); // 42
some.filter(|x| *x > 5); // Some(10)
some.and_then(|x| Some(x + 1)); // Some(11)
none.ok_or("没有值"); // Err("没有值")
7.8 递归枚举#
enum List {
Cons(i32, Box<List>),
Nil,
}
Box<List> 提供固定大小的指针,打破无限递归。也可以使用 Rc 或 Arc。
7.9 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
non-exhaustive patterns |
match 没有覆盖所有变体 |
补全分支或加 _ |
mismatched types |
模式绑定的类型与值不一致 | 检查枚举变体定义 |
unreachable pattern |
某个分支永远匹配不到 | 调整顺序或删除 |
recursive type has infinite size |
递归枚举没有间接层 | 用 Box / Rc |
7.10 练习#
- 定义
Shape枚举,计算圆、矩形、三角形面积。 - 用
match实现简单的命令行解析。 - 用
Option组合子改写一段嵌套if let。 - 用
Box实现一个链表并求和。
运行输出(节选)
=== 1. 枚举基础 ===
IPv4: 127.0.0.1
IPv6: ::1
=== 2. 带数据的变体 ===
Quit
Move 到 (10, 20)
Write: hello
颜色改为 (255, 0, 0)
本课小结
- 枚举变体可以携带元组或结构体数据
match必须穷尽,编译器会检查新增变体Option/Result把“可能没有/可能失败”编码进类型
📖 延伸阅读:The Rust Book · 枚举与模式匹配
🧪 本课练习与自测:练习提示 的“第 07 课”。
第 08 课:常见集合#
运行:cargo run --bin 08_collections
源码:src/bin/08_collections.rs
难度:★★☆ | 前置:02、05 | 建议用时:50–70 分钟 | 定位:核心
本课目标
- 掌握
Vec、String、HashMap的常用操作 - 理解 UTF-8 与字符串切片的边界
- 知道
HashSet、BTreeMap、VecDeque的适用场景
8.1 Vec#
let mut v: Vec<i32> = Vec::new();
v.push(3);
v.push(1);
let v2 = vec![1, 2, 3];
let v3 = vec![0; 5];
println!("{}", v[0]); // 越界会 panic
println!("{:?}", v.get(10)); // None,更安全
遍历与修改:
for x in &v {
println!("{x}");
}
for x in &mut v {
*x *= 10;
}
常用操作:
v.insert(0, 9);
v.remove(0);
v.pop();
v.sort();
v.dedup();
v.retain(|x| x % 2 == 0);
v.reverse();
8.2 String#
let mut s = String::new();
s.push_str("hello");
s.push(' ');
s.push_str("world");
let s2 = format!("{s}!");
let s3 = String::from("Hello, ") + "Rust";
UTF-8 注意点:
let text = String::from("hello 世界");
println!("{}", text.len()); // 字节数
println!("{}", text.chars().count()); // 字符数
字符串切片按字节索引,必须落在字符边界:
let hello = &text[..5]; // ✅
// let bad = &text[..7]; // ❌ panic
8.3 HashMap#
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
let score = scores.get("Blue").copied().unwrap_or(0);
entry API:
let mut counts = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
entry 返回 Entry,or_insert 在键不存在时插入默认值并返回可变引用。
8.4 HashSet、BTreeMap、VecDeque#
use std::collections::{BTreeMap, HashSet, VecDeque};
let a: HashSet<i32> = [1, 2, 3, 4].into_iter().collect();
let b: HashSet<i32> = [3, 4, 5].into_iter().collect();
a.intersection(&b);
a.union(&b);
a.difference(&b);
let mut btree = BTreeMap::new();
btree.insert(3, "three");
btree.insert(1, "one"); // 按 key 有序
let mut deque = VecDeque::from([2, 3]);
deque.push_front(1);
deque.push_back(4);
8.5 集合选择#
| 需求 | 集合 |
|---|---|
| 长度固定 | 数组 [T; N] |
| 动态增长、按索引访问 | Vec<T> |
| 键值查找,无序 | HashMap<K, V> |
| 键值查找,按 key 排序 | BTreeMap<K, V> |
| 去重、集合运算 | HashSet<T> |
| 两端进出 | VecDeque<T> |
| 文本 | String / &str |
8.6 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
index out of bounds |
Vec / 数组索引越界 | 用 get() 或检查长度 |
byte index is not a char boundary |
字符串切片切在 UTF-8 字符中间 | 用 chars() 或合法边界 |
the trait Hash is not implemented |
HashMap 键类型没有 Hash / Eq |
换可哈希类型或手动实现 |
cannot borrow ... as mutable |
遍历时修改集合 | 先收集索引、克隆或使用 retain |
8.7 练习#
- 统计一段文本中每个单词出现的次数,按词频排序输出。
- 用
Vec实现一个简单的栈和队列。 - 用
HashSet求两个数组的交集、并集、差集。 - 比较
Vec和HashMap查找元素的时间复杂度。
运行输出(节选)
=== 1. Vec:创建与基本操作 ===
v = [3, 1, 4], len = 3, capacity = 4
v2 = [1, 2, 3]
v3 = [0, 0, 0, 0, 0]
=== 2. 索引 vs get ===
v[0] = 3
索引 10 越界,get 返回 None
v.get(2) = Some(4)
本课小结
Vec索越界会 panic,get返回Option- 字符串按字节索引,切片必须落在 UTF-8 边界
entry(...).or_insert(0)是计数惯用法
📖 延伸阅读:The Rust Book · 向量 / 字符串 / HashMap
🧪 本课练习与自测:练习提示 的“第 08 课”。
第 09 课:包、crate 与模块#
运行:cargo run --bin 09_packages_and_modules
源码:src/bin/09_packages_and_modules.rs
难度:★★☆ | 前置:00 | 建议用时:40–60 分钟 | 定位:核心
本课目标
- 分清 package、crate、module
- 掌握
pub、use、pub use - 会按文件组织模块并在 bin 中引用 lib
9.1 三个概念#
| 概念 | 说明 |
|---|---|
| package | 一个 Cargo.toml 管理的项目,可以包含多个 crate |
| crate | 一次编译的单元:library crate 或 binary crate |
| module | crate 内的命名空间,用 mod 声明 |
一个 package 可以有:
- 最多一个 library crate(
src/lib.rs) - 多个 binary crate(
src/main.rs、src/bin/*.rs)
9.2 内联模块#
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {
seat_at_table();
}
fn seat_at_table() { } // 私有
}
pub mod serving {
pub fn take_order() {
super::hosting::add_to_waitlist();
}
}
}
模块和项默认私有。pub 让它们对外可见。
9.3 可见性#
| 写法 | 可见范围 |
|---|---|
| 默认(无 pub) | 当前模块及其子模块 |
pub |
所有地方 |
pub(crate) |
当前 crate |
pub(super) |
父模块 |
pub(in path) |
指定路径 |
结构体字段可以单独设置可见性;枚举变体与枚举同可见性。
9.4 use#
use front_of_house::{hosting, serving};
use std::collections::HashMap as Map;
hosting::add_to_waitlist();
use 只是缩短路径,不改变可见性。可以写在函数内部,作用域限于该函数。
9.5 路径关键字#
crate:::从当前 crate 根开始self:::从当前模块开始super:::从父模块开始std::/ 外部 crate 名:绝对路径
9.6 pub use 重新导出#
mod api {
pub use crate::front_of_house::hosting;
}
api::hosting::add_to_waitlist();
门面模式:外部调用者只依赖稳定的公共路径,内部结构可以调整。
9.7 文件模块#
Rust 2018+ 的规则:
src/lib.rs或src/main.rs中mod foo;→src/foo.rs或src/foo/mod.rssrc/foo.rs中mod bar;→src/foo/bar.rs- 现代风格推荐
foo.rs+foo/目录
本项目的第 09 课使用:
src/lib.rs
src/module_demo.rs
src/module_demo/greetings.rs
src/module_demo/math.rs
src/module_demo/math/advanced.rs
在 binary crate 中通过 use rust::module_demo::... 引用 library crate。
9.8 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
unresolved import |
路径或模块名错误 | 检查 mod、use、crate 名 |
function is private |
调用了私有项 | 加 pub / pub(crate) |
file not found for module |
文件路径与 mod 不匹配 |
按模块文件规则放置文件 |
use of undeclared crate or module |
忘记声明 mod 或依赖 |
添加声明或依赖 |
9.9 练习#
- 创建
mod math { pub fn add... },在main中调用。 - 把内联模块拆成
math.rs和math/advanced.rs。 - 用
pub(crate)限制一个函数只在 crate 内可见。 - 用
pub use重新导出内部模块。
运行输出(节选)
=== 1. 概念:package / crate / module ===
package = Cargo.toml 描述的项目(可以含多个 crate)
crate = 一次编译的单元:lib crate 或 bin crate
module = crate 内的命名空间,用 mod 声明
本 package 包含:
- library crate `rust`(src/lib.rs)
- binary crate `09_packages_and_modules`(本文件)
- 以及 src/bin/ 下的其他课程 binary crate
本课小结
- package 管 Cargo.toml,crate 是编译单元,module 是命名空间
- 项默认私有,
pub控制可见性 - 文件模块遵循
foo.rs+foo/的现代布局
📖 延伸阅读:The Rust Book · 包、crate 与模块
🧪 本课练习与自测:练习提示 的“第 09 课”。
第 10 课:错误处理#
运行:cargo run --bin 10_error_handling
源码:src/bin/10_error_handling.rs
难度:★★☆ | 前置:07 | 建议用时:50–70 分钟 | 定位:核心
本课目标
- 区分
panic!与Result - 用
?和From传播错误 - 实现自定义错误与
Box<dyn Error>
10.1 两类错误#
| 类型 | 使用场景 | 工具 |
|---|---|---|
| 不可恢复 | bug、违反不变量 | panic! |
| 可恢复 | 文件不存在、解析失败、网络超时 | Result<T, E> |
Rust 没有 try/catch。Result 是普通枚举,必须显式处理。
10.2 panic!#
// panic!("something is wrong");
以下操作也会 panic:
- 数组/Vec 越界
unwrap()遇到None/Err- debug 构建下的整数溢出
assert!/assert_eq!失败
10.3 Result#
enum Result<T, E> {
Ok(T),
Err(E),
}
match divide(10.0, 2.0) {
Ok(value) => println!("{value}"),
Err(error) => println!("{error}"),
}
10.4 unwrap 与 expect#
let result: Result<i32, &str> = Ok(42);
let value = result.unwrap();
println!("{value}");
let result: Result<i32, &str> = Ok(42);
let value = result.expect("这里不应该失败");
println!("{value}");
成功时取值,失败时 panic。只适合确定成功或原型代码。生产代码应处理错误。
10.5 ? 运算符#
fn calc(x: f64) -> Result<f64, MathError> {
let a = divide(10.0, x)?;
let b = sqrt(a)?;
Ok(b)
}
? 的行为:
Ok(v)→ 取出v继续执行Err(e)→ 立即return Err(From::from(e))
? 也可以用于 Option。在返回 Option 的函数中,None 会直接提前返回:
fn first_char(s: &str) -> Option<char> {
let c = s.chars().next()?;
Some(c.to_ascii_uppercase())
}
10.6 自定义错误#
#[derive(Debug)]
enum AppError {
EmptyInput,
Parse(std::num::ParseIntError),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::EmptyInput => write!(f, "输入不能为空"),
AppError::Parse(e) => write!(f, "解析失败: {e}"),
}
}
}
impl std::error::Error for AppError {}
impl From<std::num::ParseIntError> for AppError {
fn from(e: std::num::ParseIntError) -> Self {
AppError::Parse(e)
}
}
有了 From,? 才能自动转换底层错误。
10.7 Box#
fn parse_len(input: &str) -> Result<usize, Box<dyn std::error::Error>> {
let n: i32 = input.parse()?;
Ok(n.unsigned_abs() as usize)
}
适合应用层快速汇总错误,代价是丢失具体类型。
10.8 Option 与 Result 互转#
let found = find_user(1).ok_or("用户不存在");
let missing = find_user(2).ok_or_else(|| format!("id 2 不存在"));
let parsed: Result<i32, _> = "123".parse();
let option = parsed.ok();
10.9 常用组合子#
let result: Result<i32, String> = Ok(21);
println!("{:?}", result.map(|x| x * 2)); // Ok(42)
let result: Result<i32, String> = Err("bad".into());
println!("{:?}", result.map_err(|e| format!("错误: {e}")));
let result: Result<i32, String> = Ok(21);
println!("{:?}", result.and_then(|x| Ok(x + 1))); // Ok(22)
let result: Result<i32, String> = Err("bad".into());
println!("{}", result.unwrap_or(0)); // 0
let result: Result<i32, String> = Err("bad".into());
println!("{}", result.unwrap_or_else(|e| e.len() as i32)); // 3
10.10 选择建议#
用 panic!:
- 程序内部 bug
- 违反不变量
- 示例/原型快速失败
用 Result:
- 用户输入
- 文件/网络/数据库
- 库函数把选择权交给调用者
真实项目常用 thiserror 定义错误,用 anyhow 处理应用层错误。本课程不引入第三方依赖,所以手动实现。
10.11 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
the ? operator can only be used in a function that returns Result or Option |
? 用在返回 () 的函数 |
改返回类型或使用 match |
called Option::unwrap() on a None value |
unwrap() 遇到 None |
用 match、unwrap_or 或 ? |
the trait From<...> is not implemented |
? 无法转换错误类型 |
实现 From 或使用 map_err |
自定义错误不能用于 Box<dyn Error> |
缺少 Display / Error 实现 |
实现 Display 和 Error |
10.12 练习#
- 写一个
parse_positive函数,负数返回自定义错误。 - 用
?串联三个可能失败的函数。 - 为自定义错误实现
Display和Error。 - 用
Box<dyn Error>改写一个错误类型复杂的函数。
运行输出(节选)
=== 1. panic!:不可恢复错误 ===
v.get(99).unwrap_or(&0) = 0(安全地处理越界)
=== 2. Result<T, E> ===
ok = Ok(42)
err = Err("出错了")
=== 3. 用 match 处理 Result ===
10 / 2 = 5
本课小结
panic!用于不可恢复错误,Result用于可恢复错误?自动调用From转换错误类型- 自定义错误实现
Display + Error才能更好用
📖 延伸阅读:The Rust Book · 错误处理
🧪 本课练习与自测:练习提示 的“第 10 课”。
第 11 课:泛型#
运行:cargo run --bin 11_generics
源码:src/bin/11_generics.rs
难度:★★★ | 前置:05、06 | 建议用时:50–70 分钟 | 定位:核心
本课目标
- 写泛型函数、结构体、枚举
- 使用 trait bound 与
where - 理解 const 泛型与单态化
11.1 泛型函数#
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list {
if item > largest {
largest = item;
}
}
largest
}
T 是类型参数。PartialOrd 保证可以比较,Copy 保证可以按值取出。
11.2 泛型结构体#
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl Point<f64> {
fn distance_from_origin(&self) -> f64 {
(self.x * self.x + self.y * self.y).sqrt()
}
}
可以为所有 T 实现方法,也可以只为特定具体类型实现方法。
11.3 多个类型参数#
struct Pair<T, U> {
first: T,
second: U,
}
fn make_pair<T, U>(a: T, b: U) -> (T, U) {
(a, b)
}
11.4 泛型枚举#
enum MyOption<T> {
Some(T),
None,
}
enum MyResult<T, E> {
Ok(T),
Err(E),
}
标准库的 Option 和 Result 就是泛型枚举。
11.5 trait bound 与 where#
fn print_all<T: Display>(items: &[T]) { }
fn notify<T>(item: &T)
where
T: Summary + Display,
{ }
多个 bound 用 + 连接。where 子句适合约束较多、签名较长的情况。
11.6 const 泛型#
struct ConstArray<T, const N: usize> {
items: [T; N],
}
fn sum_array<const N: usize>(arr: [i32; N]) -> i32 {
arr.iter().sum()
}
数组长度 [T; N] 就是 const 泛型的典型应用。
11.7 单态化#
编译器在编译期为每个实际使用的具体类型生成一份专用代码,称为单态化。
- 优点:运行时没有动态分发开销
- 代价:编译时间变长、二进制体积变大
11.8 impl Trait#
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y
}
impl Trait 隐藏具体类型,只承诺行为。
11.9 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
the trait bound ... is not satisfied |
泛型缺少所需 trait | 添加 bound 或为类型实现 trait |
type annotations needed |
泛型参数无法推断 | 显式标注 ::<T> 或变量类型 |
impl Trait 返回不同类型 |
impl Trait 只能返回一种具体类型 |
用 Box<dyn Trait> |
mismatched types(const 泛型) |
数组长度与 N 不一致 |
检查 [T; N] |
11.10 练习#
- 写一个泛型
min_max函数。 - 定义
Pair<T, U>,实现swap方法。 - 用
where改写一个 bound 较多的函数。 - 用 const 泛型实现固定长度数组求和。
运行输出(节选)
=== 1. 泛型函数 ===
largest(&numbers) = 100
largest(&chars) = y
largest(&floats) = 3.2
largest_ref(&numbers) = 100
=== 2. 泛型结构体 ===
integer = Point { x: 5, y: 10 }
float = Point { x: 1.0, y: 4.0 }
本课小结
- 泛型在编译期单态化,运行时通常没有额外开销
- trait bound 描述“类型必须能做什么”
- const 泛型让数组长度等常量参与类型
📖 延伸阅读:The Rust Book · 泛型
🧪 本课练习与自测:练习提示 的“第 11 课”。
第 12 课:Trait#
运行:cargo run --bin 12_traits
源码:src/bin/12_traits.rs
难度:★★★ | 前置:11 | 建议用时:60–90 分钟 | 定位:核心
本课目标
- 定义 trait、默认方法和 trait 对象
- 理解关联类型与运算符重载
- 掌握孤儿规则和 newtype
12.1 定义与实现#
trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(阅读更多来自 {} 的内容...)", self.summarize_author())
}
}
struct Tweet {
username: String,
content: String,
}
impl Summary for Tweet {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
Trait 定义共享行为,类似接口,但更强大。
12.2 默认方法#
Trait 可以提供默认实现,实现者可以重写:
trait Greet {
fn name(&self) -> String;
fn greet(&self) -> String {
format!("Hello, {}!", self.name())
}
}
12.3 trait 作为参数#
use std::fmt::Display;
fn notify(item: &impl Summary) {
println!("{}", item.summarize());
}
fn notify_generic<T: Summary>(item: &T) {
println!("{}", item.summarize());
}
fn notify_two<T: Summary + Display>(item: &T) { }
fn notify_where<T>(item: &T)
where
T: Summary + Display,
{ }
impl Trait 是泛型参数的语法糖,但调用者不能显式指定类型。
12.4 trait 对象与动态分发#
struct Podcast;
impl Summary for Podcast {
fn summarize_author(&self) -> String {
String::from("@podcast")
}
}
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Podcast),
Box::new(Tweet {
username: String::from("ferris"),
content: String::from("hello"),
}),
];
for item in &items {
println!("{}", item.summarize());
}
| 方式 | 分发 | 特点 |
|---|---|---|
泛型 T: Trait |
静态 | 编译期单态化,快,代码体积大 |
dyn Trait |
动态 | 运行时查虚表,灵活,有一次间接调用开销 |
trait 对象要求 trait 是“对象安全”的:不能有泛型方法,不能返回 Self 等。
12.5 关联类型#
trait Container {
type Item;
fn first(&self) -> Option<&Self::Item>;
}
struct Numbers(Vec<i32>);
impl Container for Numbers {
type Item = i32;
fn first(&self) -> Option<&i32> {
self.0.first()
}
}
关联类型适合“每个实现只有一种关联类型”的场景;泛型参数适合一个类型有多个实现(如 From<T>)。
12.6 运算符重载#
运算符就是 trait:
use std::ops::Add;
#[derive(Debug, Copy, Clone)]
struct Meters(f64);
impl Add for Meters {
type Output = Meters;
fn add(self, other: Meters) -> Meters {
Meters(self.0 + other.0)
}
}
12.7 supertrait#
trait Animal {
fn name(&self) -> String;
}
trait Pet: Animal {
fn cuddle(&self) -> String {
format!("抚摸 {}", self.name())
}
}
实现 Pet 之前必须先实现 Animal。
12.8 blanket impl#
use std::fmt::Display;
trait Describe {
fn describe(&self) -> String;
}
impl<T: Display> Describe for T {
fn describe(&self) -> String {
format!("值:{self}")
}
}
为所有实现了 Display 的类型统一提供方法。注意避免与具体实现冲突。
12.9 孤儿规则与 newtype#
孤儿规则:trait 和类型至少有一个是当前 crate 的。
// impl Display for Vec<i32> { } // ❌ 两者都来自外部
use std::fmt::{Display, Formatter, Result};
struct Wrapper(Vec<i32>);
impl Display for Wrapper {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self.0)
}
}
newtype 包装一层即可绕过限制。
12.10 derive#
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
struct Config {
retries: u32,
verbose: bool,
}
12.11 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
missing method ... in implementation |
没有实现 trait 的必需方法 | 补全方法 |
only traits defined in the current crate can be implemented for arbitrary types |
违反孤儿规则 | 用 newtype 包装 |
the trait ... cannot be made into an object |
trait 不满足对象安全 | 去掉泛型方法或 Self 返回 |
conflicting implementations |
blanket impl 与具体 impl 冲突 | 调整约束或删除一个 |
12.12 练习#
- 定义
Shapetrait,为圆和矩形实现area。 - 用默认方法实现
describe。 - 用
Box<dyn Trait>存放多种形状并计算总面积。 - 用 newtype 为
Vec<i32>实现自定义Display。
运行输出(节选)
=== 1. 定义 trait 并实现 ===
article.summarize() = Rust 1.99 发布,作者 Ferris
tweet.summarize() = rustlang: Rust 让系统编程更安全
=== 2. 默认方法 ===
article.preview() = 默认预览:点击阅读全文
tweet.preview() = 默认预览:点击阅读全文
=== 3. trait 作为参数:impl Trait ===
本课小结
- trait 定义共享行为,默认方法可被覆盖
dyn Trait动态分发,泛型静态分发- 孤儿规则要求 trait 或类型至少一个在当前 crate
📖 延伸阅读:The Rust Book · Trait
🧪 本课练习与自测:练习提示 的“第 12 课”。
第 13 课:生命周期#
运行:cargo run --bin 13_lifetimes
源码:src/bin/13_lifetimes.rs
难度:★★★ | 前置:05、12 | 建议用时:60–90 分钟 | 定位:核心
本课目标
- 读懂并手写生命周期标注
- 掌握生命周期省略规则
- 理解结构体引用、方法与
'static
13.1 为什么需要生命周期#
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
编译器需要知道返回值和哪个参数绑定。'a 表示 x、y、返回值至少共享同一个有效区间。
生命周期是编译期检查,不产生运行时开销。
13.2 多个输入生命周期#
fn first_arg<'a, 'b>(x: &'a str, _y: &'b str) -> &'a str {
x
}
fn choose<'a, 'b: 'a>(x: &'a str, y: &'b str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
'b: 'a 表示 'b 至少和 'a 一样长,因此 &'b str 可以当作 &'a str 使用。
13.3 结构体持有引用#
struct Excerpt<'a> {
part: &'a str,
}
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let excerpt = Excerpt { part: first_sentence };
Excerpt 不能比它借用的数据活得更久。
13.4 生命周期省略规则#
- 每个输入引用参数各自获得一个生命周期。
- 如果只有一个输入生命周期,它被赋给所有输出生命周期。
- 如果有多个输入,但其中一个是
&self/&mut self,则self的生命周期被赋给所有输出。
fn first_word(s: &str) -> &str { // 规则 2:等价于 fn first_word<'a>(s: &'a str) -> &'a str
s.split_whitespace().next().unwrap_or(s)
}
规则无法确定输出生命周期时,必须手写标注。
13.5 'static#
let s: &'static str = "我活在只读数据段里";
'static 表示整个程序运行期间都有效。字符串字面量天然是 &'static str。
Box::leak 可以故意泄漏内存,得到 'static 引用,但这不是常规做法。
注意:'static 是“可以活这么久”,不是“必须活这么久”。
13.6 方法中的生命周期#
impl<'a> Excerpt<'a> {
fn announce(&self, announcement: &str) -> &str {
println!("{announcement}");
self.part
}
fn part(&self) -> &'a str {
self.part
}
}
part 返回的引用与结构体内部数据同寿,而不是与 &self 这次借用同寿。
13.7 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
missing lifetime specifier |
多个输入引用,输出无法推断 | 手写 <'a> |
does not live long enough |
引用比数据活得久 | 调整作用域或返回拥有所有权的值 |
returns a value referencing data owned by the current function |
返回局部变量引用 | 返回 String 或调整所有权 |
13.8 练习#
- 写一个返回两个字符串中较长者的函数。
- 定义
Excerpt<'a>,保存一段文本的引用。 - 解释为什么
fn f(x: &str, y: &str) -> &str需要手写生命周期。 - 用
'static定义一个全局字符串常量。
运行输出(节选)
=== 1. 为什么需要生命周期 ===
longest = abcd
=== 2. 函数签名中的生命周期 ===
=== 3. 多个输入生命周期 ===
first_arg = long lived
choose = long lifetime
本课小结
- 生命周期是编译期约束,不产生运行时开销
- 省略规则:单输入给输出,
&self给所有输出 'static表示“可以活这么久”,不是“必须活这么久”
📖 延伸阅读:The Rust Book · 生命周期
🧪 本课练习与自测:练习提示 的“第 13 课”。
第 14 课:闭包与迭代器#
运行:cargo run --bin 14_closures_and_iterators
源码:src/bin/14_closures_and_iterators.rs
难度:★★★ | 前置:05、12 | 建议用时:50–70 分钟 | 定位:进阶
本课目标
- 理解闭包捕获与
move - 区分
Fn/FnMut/FnOnce - 掌握迭代器适配器、消费者和自定义迭代器
14.1 闭包基础#
let add = |a: i32, b: i32| a + b;
let square = |x: i32| x * x;
let transform = |x: i32| {
let doubled = x * 2;
doubled + 1
};
println!("{}", add(2, 3));
println!("{}", square(5));
println!("{}", transform(10));
闭包可以捕获定义处作用域中的变量。
14.2 捕获环境#
let factor = 3;
let multiply = |x: i32| x * factor; // 不可变借用
println!("{}", multiply(5));
let mut count = 0;
let mut increment = || {
count += 1; // 可变借用
count
};
println!("{}", increment());
14.3 move 闭包#
let owned = String::from("hello");
let take = move || owned;
move 强制闭包取得捕获变量的所有权。返回 owned 的闭包是 FnOnce,只能调用一次。
14.4 Fn / FnMut / FnOnce#
| Trait | 调用次数 | 捕获方式 |
|---|---|---|
Fn |
多次 | 只读借用 |
FnMut |
多次 | 可变借用 |
FnOnce |
一次 | 可能消耗捕获值 |
所有闭包至少实现其中一种;Fn 也实现 FnMut / FnOnce。
14.5 迭代器的惰性#
let iter = v.iter().map(|x| {
println!("处理 {x}");
x * 2
});
println!("collect 之前不会执行");
let doubled: Vec<i32> = iter.collect();
适配器只是构造新迭代器,消费者才触发计算。
14.6 常用适配器#
| 适配器 | 作用 |
|---|---|
map |
转换每个元素 |
filter |
保留满足条件的元素 |
filter_map |
同时过滤和映射 |
flat_map |
展开嵌套结构 |
chain |
连接两个迭代器 |
zip |
配对两个迭代器 |
enumerate |
带索引 |
take / skip |
取前 n 个 / 跳过前 n 个 |
rev |
反转 |
step_by |
步长 |
14.7 常用消费者#
| 消费者 | 作用 |
|---|---|
collect |
收集到集合 |
sum / product |
求和 / 求积 |
count |
计数 |
fold |
自定义聚合 |
any / all |
是否存在 / 是否全部 |
find / position |
查找元素 / 位置 |
max / min |
最大 / 最小 |
max_by_key |
按 key 取最大 |
partition |
按条件分成两部分 |
14.8 collect 到不同容器#
let vec: Vec<i32> = (1..=5).collect();
let set: HashSet<i32> = [1, 2, 2, 3].into_iter().collect();
let map: HashMap<&str, usize> = ["a", "bb"].iter()
.map(|s| (*s, s.len()))
.collect();
let joined: String = ["a", "b", "c"].into_iter().collect();
14.9 自定义迭代器#
struct Counter {
count: u32,
max: u32,
}
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
if self.count < self.max {
self.count += 1;
Some(self.count)
} else {
None
}
}
}
实现 Iterator 只需要提供 next()。
14.10 返回 impl Iterator#
fn even_squares(limit: u32) -> impl Iterator<Item = u32> {
(1..=limit).filter(|x| x % 2 == 0).map(|x| x * x)
}
14.11 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
closure may outlive the current function |
闭包借用了局部变量 | 用 move 或调整生命周期 |
cannot move out of ... which is behind a shared reference |
Fn 闭包试图移出捕获值 |
用 clone 或改为 move |
use of moved value |
move 闭包取得了所有权 |
不再使用原变量或先 clone |
FnOnce 只能调用一次 |
闭包消耗了捕获值 | 改为 FnMut / Fn 或只调用一次 |
14.12 练习#
- 用闭包实现一个计数器。
- 用
filter_map解析一组可能失败的字符串。 - 用
fold实现sum和product。 - 实现一个生成斐波那契数列的迭代器。
运行输出(节选)
=== 1. 闭包基础 ===
add(2, 3) = 5
square(5) = 25
transform(10) = 21
=== 2. 捕获环境 ===
multiply(5) = 15
factor 仍然可用:3
increment() = 1
本课小结
- 闭包按需捕获,
move强制取得所有权 Fn可多次只读,FnMut可多次修改,FnOnce只能一次- 迭代器是惰性的,消费者才触发计算
📖 延伸阅读:The Rust Book · 闭包 / 迭代器
🧪 本课练习与自测:练习提示 的“第 14 课”。
第 15 课:智能指针#
运行:cargo run --bin 15_smart_pointers
源码:src/bin/15_smart_pointers.rs
难度:★★★ | 前置:05、12 | 建议用时:50–70 分钟 | 定位:进阶
本课目标
- 理解
Box、Deref、Drop - 用
Rc/RefCell做单线程共享 - 用
Arc/Mutex做多线程共享
15.1 Box#
let boxed = Box::new(5);
println!("{}", *boxed);
enum List {
Cons(i32, Box<List>),
Nil,
}
Box<T> 把值分配到堆上,栈上只留一个指针。适合递归类型和大型数据。
15.2 Deref 与 Drop#
use std::ops::Deref;
struct MyBox<T>(T);
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
实现 Deref 后可以使用 * 解引用,并享受解引用强制转换。
struct Droppable(&'static str);
impl Drop for Droppable {
fn drop(&mut self) {
println!("drop {}", self.0);
}
}
Drop 在离开作用域时自动执行。drop 顺序与声明顺序相反。
15.3 Rc#
use std::rc::Rc;
let shared = Rc::new(String::from("shared"));
let clone1 = Rc::clone(&shared);
let clone2 = Rc::clone(&shared);
println!("{}", Rc::strong_count(&shared)); // 3
Rc 通过引用计数实现单线程共享所有权。克隆只增加计数,不复制数据。
Rc 不是线程安全的,多线程用 Arc。
Rc 循环引用会导致内存泄漏。用 Weak 打破环:
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Node {
next: RefCell<Option<Rc<Node>>>,
parent: RefCell<Weak<Node>>,
}
let parent = Rc::new(Node {
next: RefCell::new(None),
parent: RefCell::new(Weak::new()),
});
let child = Rc::new(Node {
next: RefCell::new(None),
parent: RefCell::new(Rc::downgrade(&parent)),
});
*parent.next.borrow_mut() = Some(Rc::clone(&child));
println!("parent strong = {}, weak = {}", Rc::strong_count(&parent), Rc::weak_count(&parent));
15.4 RefCell#
use std::cell::RefCell;
let cell = RefCell::new(5);
*cell.borrow_mut() += 1;
println!("{}", cell.borrow());
RefCell 把借用检查从编译期推迟到运行时:
borrow():不可变借用borrow_mut():可变借用- 同时两个可变借用会 panic
适合“编译器无法证明安全,但逻辑上确实安全”的场景。
15.5 Rc<RefCell>#
let shared_list = Rc::new(RefCell::new(vec![1, 2]));
let handle = Rc::clone(&shared_list);
handle.borrow_mut().push(3);
println!("{:?}", shared_list.borrow());
Rc 提供共享所有权,RefCell 提供内部可变性,组合起来可以让多个所有者修改同一份数据(单线程)。
15.6 Arc<Mutex>#
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(std::thread::spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("{}", *counter.lock().unwrap()); // 4000
Arc 是原子引用计数,可跨线程共享。Mutex 保证同一时间只有一个线程能访问内部值。
Mutex::lock() 返回 LockResult<MutexGuard>,guard 离开作用域时自动解锁。同一个线程在 guard 尚未释放时再次 lock 同一个 Mutex 会死锁(std 的 Mutex 不可重入)。
15.7 如何选择#
| 需求 | 类型 |
|---|---|
| 独占所有权 + 堆分配 | Box<T> |
| 单线程共享只读所有权 | Rc<T> |
| 单线程内部可变性 | RefCell<T> |
| 单线程共享可变数据 | Rc<RefCell<T>> |
| 多线程共享只读所有权 | Arc<T> |
| 多线程互斥访问 | Mutex<T> |
| 多线程共享可变数据 | Arc<Mutex<T>> |
15.8 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
Rc<...> cannot be sent between threads safely |
Rc 不是 Send |
用 Arc |
already borrowed: BorrowMutError |
RefCell 同时可变借用 |
缩小借用范围或用 try_borrow_mut |
Rc 循环引用导致内存泄漏 |
两个 Rc 互相指向 |
用 Weak 打破环 |
Mutex 死锁 |
guard 未释放时重复 lock |
避免嵌套锁或缩小锁范围 |
15.9 练习#
- 用
Box实现一个递归链表。 - 为自定义类型实现
Deref和Drop。 - 用
Rc<RefCell<Vec<i32>>>让两个变量修改同一个 Vec。 - 用
Arc<Mutex<i32>>启动 4 个线程各加 1000 次。
运行输出(节选)
=== 1. Box<T>:最简单的智能指针 ===
boxed = 5, *boxed = 5
boxed_string = hello
=== 2. Box 用于递归类型 ===
list = Cons(1, Cons(2, Cons(3, Nil)))
list_sum = 6
=== 3. Deref:像引用一样使用 ===
本课小结
Box提供堆分配和递归类型所需的间接层Rc/RefCell适合单线程共享可变数据Arc/Mutex适合多线程;锁 guard 离开作用域自动释放
📖 延伸阅读:The Rust Book · 智能指针
🧪 本课练习与自测:练习提示 的“第 15 课”。
第 16 课:测试与调试#
运行:cargo run --bin 16_testing
测试:cargo test --bin 16_testing
源码:src/bin/16_testing.rs
难度:★★☆ | 前置:03 | 建议用时:40–60 分钟 | 定位:进阶
本课目标
- 写单元测试和常用断言
- 掌握
should_panic、ignore、Result 测试 - 分清单元/集成/文档测试
16.1 为什么测试#
Rust 把测试框架直接内置在 Cargo 里,不需要额外依赖。测试能帮你:
- 固定函数的行为,重构时快速发现回归
- 把“边界条件”写清楚,而不是靠记忆
- 用测试代替临时
println!调试
16.2 第一个单元测试#
fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
}
#[cfg(test)]:只在测试构建时编译这个模块#[test]:标记一个测试函数use super::*:把父模块的函数引入测试模块assert_eq!:失败时 panic,测试框架捕获并报告
16.3 常用断言#
| 宏 | 作用 |
|---|---|
assert!(cond) |
条件为假时失败 |
assert_eq!(a, b) |
两个值不相等时失败 |
assert_ne!(a, b) |
两个值相等时失败 |
assert!(cond, "message") |
带自定义信息 |
assert_eq!(a, b, "message") |
带自定义信息 |
16.4 测试 panic 与忽略测试#
#[test]
#[should_panic(expected = "boom")]
fn panic_is_expected() {
panic!("boom");
}
#[test]
#[ignore = "演示忽略测试"]
fn ignored_by_default() {
assert_eq!(1 + 1, 2);
}
#[should_panic] 用来断言代码会 panic;expected 可以检查 panic 信息是否包含指定文本。#[ignore] 默认跳过,用 cargo test -- --ignored 单独运行。
16.5 测试 Result#
#[test]
fn divide_by_zero_returns_err() {
let error = divide(10.0, 0.0).unwrap_err();
assert!(error.contains("zero"));
}
测试函数本身也可以返回 Result<(), E>,用 ? 传播错误:
#[test]
fn parse_works() -> Result<(), std::num::ParseIntError> {
let n: i32 = "42".parse()?;
assert_eq!(n, 42);
Ok(())
}
16.6 单元测试、集成测试、文档测试#
| 类型 | 位置 | 能访问什么 |
|---|---|---|
| 单元测试 | 同一文件的 #[cfg(test)] mod tests |
私有项 |
| 集成测试 | tests/ 目录 |
只能访问公共 API |
| 文档测试 | /// 注释中的代码块 |
按文档示例运行 |
集成测试示例(假设 add 已作为公共 API 放在 library crate 中):
// tests/integration_test.rs
use your_crate::add;
#[test]
fn add_works() {
assert_eq!(add(2, 3), 5);
}
注意:集成测试只能访问 library crate 的公共 API。本项目的 add 在 src/bin/16_testing.rs 里,属于 binary crate,tests/ 无法直接 use 它;要实测,可把 add / divide 移入 src/lib.rs 并加 pub。
16.7 调试工具#
cargo test --bin 16_testing
cargo test -- --nocapture
cargo test -- --ignored
RUST_BACKTRACE=1 cargo run --bin 16_testing
cargo clippy
cargo fmt
dbg!(x):打印变量、文件名、行号,并把值返回RUST_BACKTRACE=1:panic 时打印调用栈cargo clippy:发现常见代码问题cargo fmt:统一代码格式
16.8 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
test failed / assertion failed |
断言条件不成立 | 检查期望值与实际值 |
should_panic test did not panic |
代码没有 panic | 确认触发条件或去掉属性 |
unresolved import in tests |
测试模块没有引入被测项 | 加 use super::* |
cargo test 运行 0 个测试 |
缺少 #[test] 或 #[cfg(test)] |
检查属性与模块 |
16.9 练习#
- 为
divide写正常、除零、负数三个测试。 - 用
#[should_panic(expected = "...")]测试一个 panic。 - 把测试移到
tests/目录,观察私有函数是否还能访问。 - 用
dbg!找出一个循环的中间值。
运行输出(节选)
=== 1. 断言宏 ===
断言全部通过
=== 2. 被测函数 ===
add(2, 3) = 5
divide(10, 2) = Ok(5.0)
divide(10, 0) = Err("division by zero")
=== 3. dbg! 调试 ===
本课小结
- 测试和被测代码放在同一文件,可访问私有项
- 集成测试在
tests/,只能访问公共 API dbg!、RUST_BACKTRACE、clippy、fmt是常用调试工具
📖 延伸阅读:The Rust Book · 测试
🧪 本课练习与自测:练习提示 的“第 16 课”。
第 17 课:并发与线程#
运行:cargo run --bin 17_concurrency
源码:src/bin/17_concurrency.rs
难度:★★★ | 前置:15 | 建议用时:60–80 分钟 | 定位:进阶
本课目标
- 创建线程并
join - 用
Arc<Mutex>和mpsc共享数据 - 理解
thread::scope与Send/Sync
17.1 线程基础#
use std::thread;
let handle = thread::spawn(|| {
println!("子线程");
42
});
let result = handle.join().unwrap();
println!("{result}");
spawn 创建线程,join 等待线程结束并取回返回值。线程 panic 时,join 返回 Err。
17.2 move 闭包#
use std::thread;
let data = vec![1, 2, 3];
let handle = thread::spawn(move || data.iter().sum::<i32>());
println!("{}", handle.join().unwrap());
线程可能比当前函数活得更久,所以闭包通常需要 move 把数据移入线程。移动后原变量不能再用。
17.3 Arc<Mutex>#
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(std::thread::spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("{}", *counter.lock().unwrap());
Arc:原子引用计数,可跨线程共享所有权Mutex:互斥锁,保证同一时间只有一个线程访问lock()返回MutexGuard,离开作用域自动解锁- 锁的范围越小越好,避免死锁和性能问题
17.4 mpsc 消息传递#
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
let producer = std::thread::spawn(move || {
for i in 1..=5 {
tx.send(i).unwrap();
}
});
for value in rx {
println!("{value}");
}
producer.join().unwrap();
mpsc 是“多生产者、单消费者”通道。发送端全部 drop 后,接收端的 for 循环结束。消息传递通常比共享内存更容易推理。
17.5 thread::scope#
let mut numbers = vec![1, 2, 3, 4];
std::thread::scope(|scope| {
let _ = scope.spawn(|| println!("{numbers:?}"));
let _ = scope.spawn(|| println!("{numbers:?}"));
});
numbers.push(5);
thread::scope 保证作用域内的线程先结束,因此可以安全借用局部变量,不需要 Arc。
17.6 Send 与 Sync#
| Trait | 含义 |
|---|---|
Send |
所有权可以安全地转移到另一个线程 |
Sync |
&T 可以安全地被多个线程共享 |
Rc<T>/RefCell<T>不是Send/SyncArc<T>在T: Send + Sync时是Send/SyncMutex<T>提供内部同步,Arc<Mutex<T>>可跨线程共享可变状态- 编译器会在跨线程时自动检查这些约束
17.7 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
closure may outlive the current function |
线程闭包借用了局部变量 | 用 move 或 thread::scope |
Rc<...> cannot be sent between threads safely |
Rc 不是 Send |
用 Arc |
Mutex 死锁 |
guard 未释放时重复 lock |
缩小锁范围,避免嵌套锁 |
send 返回 Err |
接收端已经 drop | 检查通道生命周期 |
17.8 练习#
- 启动 4 个线程,各自计算一段区间之和,再合并结果。
- 用
mpsc实现一个简单的生产者-消费者模型。 - 用
thread::scope并行修改一个Vec的不同部分(注意索引不重叠)。 - 解释为什么
Rc<RefCell<T>>不能直接跨线程。
运行输出(节选)
=== 1. thread::spawn 与 join ===
子线程:1
子线程:2
子线程:3
子线程返回值:42
=== 2. move 闭包 ===
子线程求和:10
(线程输出顺序可能不同)
本课小结
- 线程闭包通常需要
move把数据移入线程 Arc<Mutex<T>>共享可变状态,锁范围越小越好mpsc适合消息传递,thread::scope可借用局部变量
📖 延伸阅读:The Rust Book · 并发
🧪 本课练习与自测:练习提示 的“第 17 课”。
第 18 课:宏入门#
运行:cargo run --bin 18_macros
源码:src/bin/18_macros.rs
难度:★★☆ | 前置:03 | 建议用时:40–60 分钟 | 定位:进阶
本课目标
- 分清宏和函数
- 用
macro_rules!匹配片段和重复模式 - 了解 derive / 属性 / 函数式过程宏
18.1 宏与函数的区别#
| 特性 | 函数 | 宏 |
|---|---|---|
| 展开时机 | 运行时调用 | 编译期展开 |
| 参数 | 值/引用 | 语法片段 |
| 可变参数 | 不支持 | 支持 |
| 可读性 | 好 | 复杂时较差 |
| 调试 | 容易 | 展开后较难 |
宏适合消除样板代码、创建 DSL;普通逻辑优先用函数和泛型。
18.2 macro_rules! 基本语法#
macro_rules! square {
($x:expr) => {
($x) * ($x)
};
}
println!("{}", square!(4)); // 16
macro_rules!定义声明宏$x:expr匹配一个表达式片段=>后面是展开后的代码- 多个规则用
;分隔
18.3 重复模式#
macro_rules! my_vec {
() => { Vec::new() };
($($x:expr),+ $(,)?) => {{
let mut values = Vec::new();
$(values.push($x);)+
values
}};
}
let v = my_vec![1, 2, 3];
$(...),+表示“一个或多个,用逗号分隔”$(...),*表示“零个或多个”$(,)?表示可选尾逗号$x在重复块中逐个展开
18.4 多条规则与可选参数#
macro_rules! greet {
($name:expr) => {
println!("Hello, {}!", $name)
};
($name:expr, $times:expr) => {
for _ in 0..$times {
println!("Hello, {}!", $name);
}
};
}
宏按规则顺序匹配,因此更具体的规则要放在前面。
18.5 用宏生成结构体#
macro_rules! make_struct {
($name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
struct $name {
$($field: $ty),*
}
};
}
make_struct!(Point { x: i32, y: i32 });
ident 匹配标识符,ty 匹配类型,expr 匹配表达式。宏可以生成类型定义、函数、模块等。
18.6 声明宏与过程宏#
| 类型 | 例子 | 说明 |
|---|---|---|
| 声明宏 | macro_rules! |
按模式匹配展开 |
| derive 宏 | #[derive(Debug)] |
为类型生成 trait 实现 |
| 属性宏 | #[tokio::main] |
改写被标注的项 |
| 函数式宏 | sql!、html! |
像函数一样调用,生成代码 |
过程宏需要单独的 crate,通常用 proc-macro 编写,进阶阶段再学。
18.7 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
no rules expected the token |
宏模式不匹配 | 检查分隔符与片段类型 |
macro expansion ignores ... |
重复变量使用不正确 | 检查 $() 与分隔符 |
expected expression, found ... |
展开后的代码语法不合法 | 给展开结果加括号 |
cannot find macro |
宏定义在使用之后 | 把 macro_rules! 移到前面 |
18.8 练习#
- 写一个
max!宏,比较两个表达式。 - 写一个
hashmap!宏,支持key => value语法。 - 用
macro_rules!生成一个带new方法的结构体。 - 解释
square!(1 + 2)为什么要给参数加括号。
运行输出(节选)
=== 1. 宏与函数 ===
square!(4) = 16
square!(1 + 2) = 9
=== 2. 自定义 vec! ===
empty = []
numbers = [1, 2, 3]
trailing = [10, 20, 30]
本课小结
- 宏在编译期展开,参数是语法片段
$x:expr匹配表达式,$(...),*处理重复- 宏按规则顺序匹配,具体规则放前面
📖 延伸阅读:The Rust Book · 宏
🧪 本课练习与自测:练习提示 的“第 18 课”。
第 19 课:异步编程入门#
运行:cargo run --bin 19_async
源码:src/bin/19_async.rs
难度:★★★ | 前置:14、17 | 建议用时:60–90 分钟 | 定位:进阶
本课目标
- 理解
async fn与.await - 用最小
block_on理解Future::poll - 区分 async、线程与 Tokio 的适用场景
19.1 async fn 与 .await#
async fn add(a: i32, b: i32) -> i32 {
a + b
}
async fn pipeline() -> i32 {
let x = add(1, 2).await;
x * 2
}
async fn 返回一个实现了 Future 的值;调用它不会立即执行,必须被 .await 或执行器 poll。
19.2 Future 是惰性的#
let future = add(1, 1); // 只是创建 Future
println!("还没有执行");
let value = block_on(future); // 这时才真正运行
Future 是一个状态机:每次 poll 推进一点,返回 Poll::Ready 或 Poll::Pending。
19.3 最小 block_on#
use std::future::Future;
use std::pin::pin;
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
struct NoopWaker;
impl Wake for NoopWaker {
fn wake(self: Arc<Self>) {}
}
fn block_on<F: Future>(future: F) -> F::Output {
let waker = Waker::from(Arc::new(NoopWaker));
let mut context = Context::from_waker(&waker);
let mut future = pin!(future);
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(value) => return value,
Poll::Pending => std::thread::yield_now(),
}
}
}
真实的执行器会注册 I/O 事件,在 Future 就绪时调用 wake,而不是忙等。
19.4 async move#
let owned = String::from("hello");
let future = async move {
format!("{owned} async")
};
println!("{}", block_on(future));
async move 把捕获的变量移入 Future,适合把数据交给后台任务。
19.5 async 与线程#
| 场景 | 选择 |
|---|---|
| CPU 密集计算 | 线程 / rayon |
| 大量 I/O 并发 | async + Tokio |
| 简单并行任务 | thread::spawn / thread::scope |
| 阻塞 API | 放进 spawn_blocking 或独立线程 |
async 不是“自动多线程”。单线程执行器也可以同时管理很多 Future;真正的并发来自 I/O 多路复用和任务调度。
19.6 执行器与 Tokio#
真实项目通常使用 Tokio。下面这段需要额外依赖,不能直接在本课程项目中运行。
Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
let value = async { 42 }.await;
println!("{value}");
}
Tokio 提供运行时、异步 I/O、定时器、通道等。学习 async 语法后,下一步是理解 Future、Pin、Waker 和运行时模型。
19.7 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
| async 函数没有执行 | 只创建 Future,没有 poll | 用 .await 或执行器 |
future cannot be sent between threads safely |
Future 捕获了非 Send 类型 |
用 Arc / Mutex 或调整作用域 |
跨 .await 持有借用 |
借用跨越 await,状态机无法保存 | 缩小作用域或克隆 |
| 在 async 中执行阻塞操作 | 阻塞执行器线程 | 用 spawn_blocking 或独立线程 |
19.8 练习#
- 写两个
async fn,用.await串联。 - 用
block_on运行一个async块。 - 解释 Future 为什么是惰性的。
- 比较
thread::spawn和tokio::spawn的适用场景。
运行输出(节选)
=== 1. async fn 与 .await ===
add(2, 3) = 5
pipeline = 3 + data
=== 2. async 块 ===
value = 20
=== 3. Future 是惰性的 ===
future 已创建,还没有 poll
本课小结
async fn返回 Future,调用不会立即执行- Future 由执行器 poll,
Pending时注册唤醒 - async 不是自动多线程,CPU 密集任务应交给线程
📖 延伸阅读:The Rust Book · async / await
🧪 本课练习与自测:练习提示 的“第 19 课”。
第 20 课:不安全 Rust 与 FFI#
运行:cargo run --bin 20_unsafe
源码:src/bin/20_unsafe.rs
难度:★★★ | 前置:05 | 建议用时:60–90 分钟 | 定位:进阶
本课目标
- 知道
unsafe允许的五类操作 - 安全地使用裸指针和
unsafe fn - 理解 FFI、
static mut、union与安全封装
20.1 unsafe 能做什么#
unsafe 允许五类额外操作:
- 解引用裸指针
- 调用
unsafe函数或方法 - 访问或修改可变静态变量
- 实现
unsafetrait - 访问
union字段
unsafe 不会关闭借用检查,也不会让代码自动变快。它只是把安全责任从编译器转移给程序员。
20.2 裸指针#
let mut value = 42;
let pointer: *mut i32 = &mut value;
unsafe {
*pointer += 1;
println!("{}", *pointer);
}
裸指针可以绕过借用规则,但越界、悬垂、别名都是未定义行为(UB)。使用前必须确认指针有效、对齐、可解引用。
20.3 unsafe fn 与 Safety 注释#
/// # Safety
///
/// - `pointer` 必须非空、对齐,并指向至少 `length` 个已初始化、有效的 `i32`
/// - 从 `pointer` 开始的这块内存在 `'a` 期间必须保持有效
/// - 调用期间不能通过其他指针修改这块内存(别名规则)
unsafe fn slice_from_raw<'a>(pointer: *const i32, length: usize) -> &'a [i32] {
unsafe { std::slice::from_raw_parts(pointer, length) }
}
'a 由调用者选定,所以调用者必须保证数据真的能活到 'a;如果把 'a 推成 'static,就等于承诺这块内存永久有效。
调用 unsafe fn 时必须自己保证前置条件。公开 API 应尽量把 unsafe 封装在内部,对外提供安全接口。
20.4 FFI:调用 C 函数#
unsafe extern "C" {
fn abs(input: i32) -> i32;
}
let n = unsafe { abs(-42) };
extern "C"声明 C ABI- Edition 2024 要求 extern 块本身标记
unsafe - 链接时由系统 libc 提供
abs - FFI 边界上要特别注意字符串、内存所有权和错误码
20.5 可变静态变量与 union#
static mut COUNTER: u32 = 0;
unsafe {
COUNTER += 1;
}
可变静态变量在 Rust 2024 中限制更严格:上面只做直接读写;一旦要取 &COUNTER / &mut COUNTER,Edition 2024 会因 static_mut_refs 直接报错。真实项目应使用 Mutex、Atomic* 或 OnceLock。union 也属于 unsafe 领域,通常用于 FFI 或底层布局优化。
20.6 安全封装原则#
- 先尝试用安全 Rust 解决问题
- 把
unsafe缩小到最小范围 - 每个
unsafe块写清 Safety 前提 - 对外只暴露安全 API
- 用测试、Miri、模糊测试验证边界
20.7 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
dereference of raw pointer is unsafe |
裸指针解引用没有 unsafe |
包在 unsafe 块中 |
use of mutable static is unsafe |
访问 static mut |
用 unsafe + 同步原语 |
extern block must be unsafe |
Edition 2024 要求 | 写 unsafe extern "C" |
| 段错误 / UB | 指针越界、悬垂、别名 | 检查 Safety 前提 |
20.8 练习#
- 用裸指针交换两个变量的值。
- 写一个
unsafe fn,并补全 Safety 注释。 - 调用 libc 的
abs或strlen(注意 C 字符串)。 - 把 unsafe 代码封装成一个安全函数,并写测试。
运行输出(节选)
=== 1. 裸指针 ===
value = 43
=== 2. unsafe fn ===
slice = [43]
=== 3. FFI:调用 libc ===
abs(-42) = 42
本课小结
unsafe只解锁五类操作,不关闭借用检查- 每个
unsafe fn都要写清 Safety 契约 - 优先把 unsafe 封装在安全 API 内部
📖 延伸阅读:The Rust Book · Unsafe Rust
🧪 本课练习与自测:练习提示 的“第 20 课”。
第 21 课:项目实战:词频统计 CLI#
运行:
cargo run --bin 21_project
cargo run --bin 21_project -- Cargo.toml
cargo run --bin 21_project -- README.md
源码:src/bin/21_project.rs
难度:★★★ | 前置:08–20 | 建议用时:90–150 分钟 | 定位:毕业项目
本课目标
- 读参数、读文件并处理错误
- 用
HashMap统计词频并排序 - 把核心逻辑拆成可测试的纯函数
21.1 需求#
实现一个命令行词频统计工具:
- 从命令行参数读取文件路径
- 没有参数时使用内置文本
- 统计每个单词出现次数
- 按次数降序、单词升序输出 Top 10
- 文件读取失败时打印错误并以非零状态退出
21.2 读取参数与文件#
use std::env;
use std::fs;
use std::process;
let args: Vec<String> = env::args().collect();
let text = if args.len() > 1 {
match fs::read_to_string(&args[1]) {
Ok(text) => text,
Err(error) => {
eprintln!("读取 {} 失败:{error}", args[1]);
process::exit(1);
}
}
} else {
String::from("the quick brown fox")
};
env::args() 返回迭代器,第一个元素是程序名。fs::read_to_string 返回 Result,错误时用 eprintln! 输出到 stderr。
21.3 统计词频#
use std::collections::HashMap;
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for raw in text.split_whitespace() {
let word = raw
.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase();
if !word.is_empty() {
*counts.entry(word).or_insert(0) += 1;
}
}
counts
}
split_whitespace按空白分割trim_matches去掉首尾标点to_lowercase统一大小写entry(...).or_insert(0)是 HashMap 的经典计数写法
21.4 排序与输出#
fn top_words(counts: &HashMap<String, usize>, limit: usize) -> Vec<(String, usize)> {
let mut ranking: Vec<(String, usize)> = counts
.iter()
.map(|(word, count)| (word.clone(), *count))
.collect();
ranking.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
ranking.truncate(limit);
ranking
}
sort_by 先按次数降序,次数相同再按单词升序。truncate 只保留前 N 个。
21.5 错误处理与退出码#
// 节选:match 的 Err 分支
Err(error) => {
eprintln!("读取 {} 失败:{error}", args[1]);
process::exit(1);
}
- 用户输入/文件错误用
Result和eprintln! - 无法继续时用
process::exit(1) - 不要把错误信息写到 stdout,避免污染数据输出
21.6 测试#
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_case_insensitively() {
let counts = count_words("The the THE cat");
assert_eq!(counts["the"], 3);
}
#[test]
fn strips_punctuation() {
let counts = count_words("hello, hello! (world)");
assert_eq!(counts["hello"], 2);
}
}
把核心逻辑拆成纯函数,测试就不需要真的读文件。
21.7 常见错误#
| 错误 | 原因 | 解决 |
|---|---|---|
No such file or directory |
路径错误或当前目录不对 | 检查参数与工作目录 |
index out of bounds |
空输入后直接访问 ranking[0] |
先检查长度 |
borrow of moved value |
把 String 传进函数后又使用 |
传 &str 或先克隆 |
process::exit 后代码不执行 |
提前退出 | 检查分支与返回值 |
21.8 练习与扩展#
- 支持
--top N参数,自定义输出数量。 - 增加停用词表,过滤
the、a、is等。 - 输出 JSON 格式,方便其他程序消费。
- 把结果写入文件,并处理写入错误。
运行输出(节选)
词频 Top 10:
3 the
1 brown
1 dog
1 end
1 fox
1 jumps
1 lazy
1 over
本课小结
- CLI 参数和文件读取都可能失败,要显式处理
HashMap计数后排序、截断输出- 纯函数让测试不需要真实文件
📖 延伸阅读:The Rust Book · I/O 项目
🧪 本课练习与自测:练习提示 的“第 21 课”。
语法速查表#
变量与类型#
| 语法 | 说明 |
|---|---|
let x = 1; |
不可变绑定 |
let mut x = 1; |
可变绑定 |
let x = 1; let x = "a"; |
遮蔽,可改变类型 |
const N: u32 = 10; |
编译期常量 |
static S: &str = "hi"; |
静态变量 |
x as u8 |
数值转换,可能截断 |
T::from(x) / x.into() |
无损转换 |
T::try_from(x) / x.try_into() |
可能失败的转换 |
函数与控制流#
| 语法 | 说明 |
|---|---|
fn f(x: i32) -> i32 { x + 1 } |
函数,最后表达式是返回值 |
return x; |
提前返回 |
if cond { } else { } |
条件表达式 |
loop { break value; } |
无限循环,可带值退出 |
while cond { } |
条件循环 |
for x in 1..=10 { } |
遍历区间 |
'outer: loop { break 'outer; } |
循环标签 |
while let Some(x) = opt { } |
模式匹配循环 |
let Some(x) = opt else { return; }; |
匹配失败提前退出 |
所有权与借用#
| 语法 | 说明 |
|---|---|
let b = a; |
移动(非 Copy)或复制(Copy) |
let b = a.clone(); |
深拷贝 |
&x |
不可变借用 |
&mut x |
可变借用 |
*r |
解引用 |
&s[..] |
切片 |
drop(x) |
提前释放 |
结构体、枚举、Trait#
| 语法 | 说明 |
|---|---|
struct S { x: i32 } |
命名字段结构体 |
struct S(i32); |
元组结构体 |
struct S; |
单元结构体 |
impl S { fn f(&self) {} } |
方法 |
impl S { fn new() -> Self {} } |
关联函数 |
enum E { A, B(i32), C { x: i32 } } |
枚举 |
match x { ... } |
模式匹配 |
trait T { fn f(&self); } |
定义 trait |
impl T for S { ... } |
实现 trait |
Box<dyn T> |
trait 对象 |
错误处理#
| 语法 | 说明 |
|---|---|
panic!("...") |
不可恢复错误 |
Result<T, E> |
可恢复错误 |
? |
传播错误,自动 From |
unwrap() / expect() |
成功取值,失败 panic |
ok_or / ok_or_else |
Option 转 Result |
Box<dyn Error> |
通用错误类型 |
泛型、生命周期、闭包#
| 语法 | 说明 |
|---|---|
fn f<T>(x: T) {} |
泛型函数 |
struct S<T> { x: T } |
泛型结构体 |
T: Trait + Other |
trait bound |
where T: Trait |
where 子句 |
fn f<'a>(x: &'a str) -> &'a str |
生命周期标注 |
'static |
整个程序期间有效 |
|x| x + 1 |
闭包 |
move |x| x + 1 |
move 闭包 |
Fn / FnMut / FnOnce |
闭包 trait |
impl Iterator<Item = T> |
返回迭代器 |
常用内置宏#
| 宏 | 用途 | 示例 |
|---|---|---|
println! |
打印并换行 | println!("x = {x}"); |
print! |
打印不换行 | print!("."); |
format! |
返回格式化字符串 | let s = format!("{a}-{b}"); |
vec! |
创建 Vec |
let v = vec![1, 2, 3]; |
assert! |
断言条件为真 | assert!(x > 0); |
assert_eq! |
断言相等 | assert_eq!(add(1, 2), 3); |
matches! |
判断是否匹配某个模式 | matches!(opt, Some(_)) |
dbg! |
打印变量和位置并返回它 | let y = dbg!(x * 2); |
todo! / unimplemented! |
标记未实现,调用时 panic | todo!("later") |
include_str! |
编译期嵌入文本文件 | let s = include_str!("data.txt"); |
标准库常用 trait#
| Trait | 作用 | 常见实现 |
|---|---|---|
Debug |
{:?} 调试打印 |
大多数类型,通常 derive |
Display |
{} 用户友好打印 |
需要手动实现 |
Clone |
显式深拷贝 | String、Vec、自定义类型 |
Copy |
赋值时按位复制 | 整数、浮点、bool、char |
PartialEq / Eq |
== / != 比较 |
大多数类型 |
PartialOrd / Ord |
<、>、排序 |
可比较类型 |
Hash |
作为 HashMap/HashSet 的键 | 整数、String、&str |
Default |
默认值 | #[derive(Default)] |
From / Into |
类型转换 | i64::from(i32) |
Iterator |
迭代器协议 | Vec::iter、Range |
Drop |
析构清理 | Box、Rc、自定义类型 |
Deref / DerefMut |
解引用与自动转换 | Box、Rc、String |
格式化输出#
| 写法 | 含义 | 示例 |
|---|---|---|
{} |
Display 输出 |
println!("{}", x); |
{:?} |
Debug 输出 |
println!("{:?}", v); |
{:#?} |
美化 Debug 输出 |
println!("{:#?}", v); |
{x} |
捕获同名变量 | println!("{x}"); |
{0} {1} |
位置参数 | println!("{0} {1}", a, b); |
{:>8} |
右对齐,宽度 8 | println!("{:>8}", "hi"); |
{:<8} |
左对齐,宽度 8 | println!("{:<8}", "hi"); |
{:^8} |
居中,宽度 8 | println!("{:^8}", "hi"); |
{:08} |
补零到宽度 8 | println!("{:08}", 42); |
{:.2} |
保留两位小数 | println!("{:.2}", 3.14159); |
{:x} / {:X} |
十六进制 | println!("{:x}", 255); |
{:b} |
二进制 | println!("{:b}", 10); |
常用标准库模块#
| 模块 | 用途 | 课程 |
|---|---|---|
std::collections |
HashMap、HashSet、BTreeMap、VecDeque |
08 |
std::vec |
Vec |
08 |
std::fmt |
Display、Debug 格式化 |
12 |
std::io |
标准输入输出、读写 | 10 |
std::fs |
文件系统操作 | 10 |
std::env |
环境变量、命令行参数 | 10 |
std::process |
进程、退出码 | 10 |
std::thread |
线程 | 17 |
std::sync |
Arc、Mutex、mpsc |
17 |
std::cell / std::rc |
RefCell、Rc |
15 |
std::future / std::task |
Future、Poll、Waker |
19 |
std::ffi |
C 字符串与 FFI 类型 | 20 |
std::ops |
运算符重载 trait | 12 |
std::error |
Error trait |
10 |
std::time |
Duration、Instant |
17 |
测试、并发、异步与 unsafe#
测试
| 语法 | 说明 |
|---|---|
#[test] |
标记测试函数 |
#[cfg(test)] |
只在测试构建时编译 |
assert_eq!(a, b) |
断言相等 |
#[should_panic] |
断言会 panic |
cargo test |
运行测试 |
并发
| 语法 | 说明 |
|---|---|
thread::spawn |
创建线程 |
join() |
等待线程结束 |
Arc<T> |
原子引用计数,跨线程共享 |
Mutex<T> |
互斥锁 |
mpsc::channel |
多生产者单消费者通道 |
thread::scope |
借用局部数据的线程作用域 |
Send / Sync |
跨线程安全约束 |
异步
| 语法 | 说明 |
|---|---|
async fn |
返回 impl Future |
.await |
等待 Future 完成 |
Future::poll |
推进状态机 |
block_on |
最小执行器示例 |
#[tokio::main] |
Tokio 运行时入口 |
unsafe 与宏
| 语法 | 说明 |
|---|---|
unsafe { ... } |
承担安全责任的代码块 |
*const T / *mut T |
裸指针 |
unsafe fn |
调用者需满足 Safety 前提 |
unsafe extern "C" |
Edition 2024 的 FFI 声明 |
macro_rules! |
声明宏 |
$x:expr / $(...),* |
宏片段与重复模式 |
常见编译错误对照#
| 错误码 | 含义 | 常见原因 | 解决 |
|---|---|---|---|
| E0384 | cannot assign twice to immutable variable | 对不可变变量赋值 | 加 mut |
| E0382 | use of moved value | 值被移动后继续使用 | clone 或传引用 |
| E0502 | cannot borrow as mutable because also borrowed as immutable | 可变借用与不可变借用冲突 | 缩小借用范围 |
| E0499 | cannot borrow as mutable more than once | 多个可变借用 | 同一时间只保留一个 |
| E0596 | cannot borrow as mutable | 变量本身不可变 | 声明为 mut |
| E0106 | missing lifetime specifier | 返回引用但无法推断生命周期 | 添加 <'a> |
| E0308 | mismatched types | 类型不一致 | 检查类型或转换 |
| E0277 | trait bound not satisfied | 类型未实现所需 trait | 实现 trait 或换类型 |
| E0433 | failed to resolve | 路径/模块不存在 | 检查 mod、use、crate 名 |
| E0507 | cannot move out of borrowed content | 试图从借用中移出值 | 用 clone 或引用 |
| E0515 | cannot return reference to local variable | 返回局部变量引用 | 返回拥有所有权的值 |
| E0716 | temporary value dropped while borrowed | 临时值提前释放 | 绑定到变量延长生命周期 |
| E0133 | unsafe operation outside unsafe block | 裸指针/unsafe fn 未包裹 | 加 unsafe 块 |
读错误的三步法
- 看错误码和第一行信息:编译器通常直接告诉你“不能做什么”。
- 看
help:和note::大多数错误都有修复建议。 - 看箭头指向的变量:问自己“它现在被谁拥有/借用?借用持续到什么时候?”
编译器报错实战#
真实项目里,读错误信息比背语法更重要。下面保留编译器输出的关键部分,练习从 error[Exxxx]、help: 和箭头里找答案。
案例 1:移动后继续使用(E0382)#
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}"); // ❌ s1 已被移动
println!("{s2}");
}
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:4:16
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`
3 | let s2 = s1;
| -- value moved here
4 | println!("{s1}");
| ^^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++
怎么修:确实需要两份数据就 s1.clone();只是读取就改成 let s2 = &s1;。先判断“谁应该拥有这份数据”。
案例 2:可变借用与不可变借用冲突(E0502)#
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4); // ❌ first 仍在借用
println!("{first}");
}
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:4:5
|
3 | let first = &v[0];
| - immutable borrow occurs here
4 | v.push(4);
| ^^^^^^^^^ mutable borrow occurs here
5 | println!("{first}");
| ----- immutable borrow later used here
怎么修:把 first 的最后一次使用提前,或先复制出值(let first = v[0];),让不可变借用先结束。
案例 3:变量不是 mut(E0596)#
struct Counter;
impl Counter {
fn bump(&mut self) {}
}
fn main() {
let counter = Counter;
counter.bump(); // ❌ counter 不是 mut
}
error[E0596]: cannot borrow `counter` as mutable, as it is not declared as mutable
--> src/main.rs:9:5
|
9 | counter.bump();
| ^^^^^^^ cannot borrow as mutable
|
help: consider changing this to be mutable
|
8 | let mut counter = Counter;
| +++
怎么修:方法签名要 &mut self,调用方的绑定也必须是 let mut counter。两处都要改。
案例 4:返回引用但没有生命周期来源(E0106)#
fn make() -> &String {
let s = String::from("local");
&s // ❌ 返回局部变量的引用
}
error[E0106]: missing lifetime specifier
--> src/main.rs:1:14
|
1 | fn make() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value,
but there is no value for it to be borrowed from
help: instead, you are more likely to want to return an owned value
|
1 - fn make() -> &String {
1 + fn make() -> String {
|
怎么修:不要返回局部变量的引用。返回 String(移交所有权),或让调用者传入一个引用并标注生命周期。
读错误的三步法#
- 先看
error[Exxxx]和第一行:它通常直接说明“哪个值、哪条规则”。 - 再看
help::编译器经常给出可直接套用的改法。 - 最后看箭头:问自己“这个值现在归谁所有?借用持续到什么时候?”
十个常见坑#
- 忘记
mut:let x = 1; x = 2;会报 E0384。需要修改就写let mut x。 - 移动后继续使用:
let b = a;后a可能已失效(非 Copy 类型)。用clone或传引用。 - 同时可变借用和不可变借用:借用规则是“多个
&T或一个&mut T”,不能同时存在。 - 返回局部变量的引用:局部变量在函数结束时释放,返回
&local会报 E0515。返回String或调整所有权。 - 字符串按字节切片:
&text[..7]可能切在 UTF-8 字符中间。用chars()或确保边界。 - 用
==比较浮点数:0.1 + 0.2 != 0.3。用误差范围或approx类库。 - HashMap 无序:遍历顺序不固定。需要顺序时用
BTreeMap或先排序。 - 滥用
unwrap():遇到None/Err会 panic。生产代码用match、?或默认值。 Rc循环引用:两个Rc互相指向会泄漏。用Weak打破环。RefCell运行时 panic:同时borrow_mut()两次会 panic;Mutex重复 lock 会死锁。
术语表#
| 英文 | 中文 | 一句话解释 | 课程 |
|---|---|---|---|
| ownership | 所有权 | 每个值有唯一所有者,所有者离开作用域时值被释放 | 05 |
| borrowing | 借用 | 通过 &T / &mut T 临时使用值而不取得所有权 |
05 |
| move | 移动 | 把值的所有权从一个变量转交给另一个变量 | 05 |
| copy | 复制 | 按位复制,原变量仍然可用 | 05 |
| lifetime | 生命周期 | 编译器用来验证引用有效性的作用域范围 | 13 |
| trait | 特征 | 定义类型共享行为的抽象 | 12 |
| generic | 泛型 | 用类型参数编写可复用代码 | 11 |
| monomorphization | 单态化 | 编译期为每个具体类型生成专用代码 | 11 |
| trait object | trait 对象 | dyn Trait,运行时动态分发 |
12 |
| object safety | 对象安全 | trait 能作为 dyn Trait 使用的条件 |
12 |
| orphan rule | 孤儿规则 | trait 和类型至少有一个属于当前 crate 才能实现 | 12 |
| interior mutability | 内部可变性 | 通过 RefCell / Mutex 在不可变引用下修改值 |
15 |
| zero-cost abstraction | 零成本抽象 | 高层抽象编译后与手写底层代码性能相当 | 11、14 |
| RAII | 资源获取即初始化 | 用构造函数获取资源、析构函数释放资源 | 06、15 |
| NLL | 非词法生命周期 | 引用的有效期到最后一次使用为止 | 05、13 |
| unit test | 单元测试 | 测试同一模块内的函数 | 16 |
| integration test | 集成测试 | tests/ 目录,测试公共 API |
16 |
| concurrency | 并发 | 多个任务同时推进 | 17 |
| channel | 通道 | 线程间消息传递 | 17 |
| Send / Sync | 发送 / 同步 | 跨线程安全约束 | 17 |
| async / await | 异步 / 等待 | 用状态机表达非阻塞任务 | 19 |
| Future | 未来值 | 异步任务的状态机 | 19 |
| macro | 宏 | 编译期展开的代码生成器 | 18 |
| FFI | 外部函数接口 | 调用 C 等外部语言 | 20 |
| UB | 未定义行为 | 编译器不做保证的程序行为 | 20 |
| Miri | Miri | 检测 unsafe UB 的工具 | 20 |
练习提示#
每课包括:4 条开放练习的思路提示、1 个验收任务(含参考实现)、3 问自测(概念 / 读代码 / 改错)。开放练习没有唯一答案;验收任务和自测用来确认“确实会了”。先自己做,再看答案。
第 00 课:Cargo#
- 用
cargo new建项目,先跑cargo check再cargo run。 src/bin/*.rs自动成为 binary,不需要在 Cargo.toml 里逐个声明。- 依赖、profile、workspace 都在 Cargo.toml 里配置。
验收任务
运行 cargo run --bin 00_cargo,说出 package 名、当前 binary target 名,以及 Cargo.toml 的位置。
参考实现
package 是rust,target 是 00_cargo,Cargo.toml 在项目根目录。
自测:概念 / 读代码 / 改错
1. 概念:Cargo.toml 里的 name 和 src/bin/foo.rs 的 target 名是什么关系?
2. 读代码:运行 cargo run 时,--bin 后面的名字应该写 package 名还是文件名?
3. 改错:cargo run --bin 00 报错,应该怎么改?
答案与解析:
1. name 决定 package 名和默认 lib crate 名;target 名是文件名 foo。
2. --bin 后面写 target 名,即 foo。
3. 改成 cargo run --bin 00_cargo,target 名包含数字和完整文件名。
第 01 课:变量#
- 用
let mut声明可变变量,连续赋值并打印。 - 遮蔽可以直接复用变量名,类型可以改变。
const必须标注类型;static适合全局固定地址。
验收任务
写一段代码:先让一个变量不可变,再用遮蔽把它变成 usize,最后用 mut 修改一个整数,并让两个断言通过。
参考实现
fn main() {
let s = "hello";
let s = s.len();
let mut n = 1;
n += 1;
assert_eq!(s, 5);
assert_eq!(n, 2);
}
自测:概念 / 读代码 / 改错
1. 概念:let x = 1; let x = "a"; 和 let mut x = 1; x = "a"; 有什么区别?
2. 读代码:下面输出什么?let x = 5; { let x = x * 2; println!("{x}"); } println!("{x}");
3. 改错:let x = 1; x = 2; 报什么错?怎么改?
答案与解析:
1. 前者是遮蔽,创建新变量且可换类型;后者是给同一变量赋值,类型必须仍是 i32。
2. 先输出 10,再输出 5;内层遮蔽只在内层生效。
3. E0384;把 let x 改成 let mut x。
第 02 课:数据类型#
- 用
std::mem::size_of::<T>()打印大小。 - 用
0b、0o、0x写不同进制。 checked_add返回Option,用match或unwrap_or处理。chars().count()是字符数,len()是字节数。
验收任务
让下面两个断言通过:300_i32 as u8 的结果,以及 u8::try_from(300_i32) 是否失败。
参考实现
fn main() {
assert_eq!(300_i32 as u8, 44);
assert!(u8::try_from(300_i32).is_err());
}
自测:概念 / 读代码 / 改错
1. 概念:为什么 300_i32 as u8 得到 44?
2. 读代码:下面输出什么?println!("{}", -1_i32 as u8);
3. 改错:let x: u8 = 300; 怎么改才能安全处理?
答案与解析:
1. as 做截断:300 对 256 取模得到 44。
2. 255;负数按二进制补码截断。
3. 用 u8::try_from(300) 或 checked 转换,不要直接赋给 u8。
第 03 课:函数#
- 返回
(min, max)元组,调用处解构。 - 函数指针数组:
[fn(i32) -> i32; 4]。 const fn里不能做堆分配和 I/O。- 斐波那契迭代版用两个变量滚动。
验收任务
写 factorial 和一个块表达式,让 factorial(5) == 120、块的值等于 9。
参考实现
fn factorial(n: u64) -> u64 {
if n <= 1 { 1 } else { n * factorial(n - 1) }
}
fn main() {
assert_eq!(factorial(5), 120);
assert_eq!({ let x = 3; x * x }, 9);
}
自测:概念 / 读代码 / 改错
1. 概念:函数最后一行加分号和不加分号,返回值有什么不同?
2. 读代码:下面输出什么?let y = { let x = 3; x * x };
3. 改错:fn f() -> i32 { let x = 1; } 为什么报错?
答案与解析:
1. 不加分号时块的值是表达式结果;加分号后语句值为 (),会与声明的返回类型不匹配。
2. 9;块的值是最后一个不带分号的表达式。
3. 函数体最后是 let 语句,返回 ();去掉 let 或显式返回 x。
第 04 课:流程控制#
loop { break value; }把值带出循环。- 用
'outer:标签和break 'outer;跳出多层循环。 while let Some(x) = iter.next()适合消费迭代器。let-else的else必须return/break/continue/panic!。
验收任务
用 for 和 if 求 1 到 100 的奇数和,再用 loop { break value; } 返回 42;让两个断言通过。
参考实现
fn main() {
let mut sum = 0;
for i in 1..=100 {
if i % 2 == 1 { sum += i; }
}
let value = loop { break 42; };
assert_eq!(sum, 2500);
assert_eq!(value, 42);
}
自测:概念 / 读代码 / 改错
1. 概念:break 和 break 'outer value 分别能带出什么?
2. 读代码:下面循环结束后 result 是多少?let result = loop { break 7; };
3. 改错:if 1 { } 为什么编译失败?
答案与解析:
1. break 跳出当前循环;带标签的 break 可跳出指定外层循环,并把 value 作为 loop 的结果。
2. 7。
3. 条件必须是 bool;改成 if 1 == 1 或 if true。
第 05 课:所有权#
- 传值会 move;传
&str/&T只借用。 let s2 = s1;后s1不能再用;需要保留就clone。- NLL 让借用持续到最后一次使用,而不是整个作用域。
first_word返回&str,参数和返回值共享生命周期。
验收任务
创建一个 String,取得它的长度和 &str 借用,再 clone 一份;让三个断言通过。
参考实现
fn main() {
let s = String::from("hi");
let n = s.len();
let r = &s;
assert_eq!(n, 2);
assert_eq!(r, "hi");
let c = s.clone();
assert_eq!(c, s);
}
自测:概念 / 读代码 / 改错
1. 概念:为什么 let s2 = s1; 之后 s1 不能再用?
2. 读代码:下面输出什么?let s = String::from("hi"); let r = &s; println!("{r}");
3. 改错:let s = String::from("x"); let a = &s; s.push('!'); 怎么修?
答案与解析:
1. String 不是 Copy,赋值是 move:所有权转到 s2,s1 被标记为已移动。
2. hi;不可变借用可以读取。
3. 把 a 的最后使用提前,或先 clone 再修改;不能同时存在可变和不可变借用。
第 06 课:结构体#
Rectangle实现area、can_hold、square。email_domain用split('@').nth(1)。- 结构体更新语法
..user.clone()避免移动原值。 #[derive(Debug, PartialEq)]后可以直接==和{:?}。
验收任务
定义 Rect { w, h } 和 area(&self),让 Rect { w: 3.0, h: 4.0 }.area() == 12.0。
参考实现
struct Rect { w: f64, h: f64 }
impl Rect {
fn area(&self) -> f64 { self.w * self.h }
}
fn main() {
let r = Rect { w: 3.0, h: 4.0 };
assert_eq!(r.area(), 12.0);
}
自测:概念 / 读代码 / 改错
1. 概念:&self、&mut self、self 三种方法第一个参数有什么区别?
2. 读代码:下面输出什么?struct P; impl P { fn f(&self) {} } let p = P; p.f();
3. 改错:let p = Point { x: 1.0 }; p.x = 2.0; 报什么错?
答案与解析:
1. &self 只读借用;&mut self 可修改;self 取得所有权,调用后原值通常不可用。
2. 编译通过,没有输出;&self 方法不消耗 p。
3. p 不是 mut;改成 let mut p。
第 07 课:枚举与匹配#
Shape枚举用match分别计算面积。- 命令行解析可以用
match args[1].as_str()。 Option组合子可以减少嵌套if let。- 递归链表用
Box<List>或Rc<List>。
验收任务
定义 Shape 枚举(圆/正方形)和 area 函数,让正方形面积断言通过。
参考实现
enum Shape { Circle(f64), Square(f64) }
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle(r) => 3.0 * r * r,
Shape::Square(a) => a * a,
}
}
fn main() {
assert_eq!(area(&Shape::Square(3.0)), 9.0);
}
自测:概念 / 读代码 / 改错
1. 概念:match 为什么必须穷尽?
2. 读代码:下面输出什么?let x = Some(3); match x { Some(n) => println!("{n}"), None => println!("none") }
3. 改错:match x { Some(n) => n } 为什么报错?
答案与解析:
1. match 是表达式,编译器要求每个可能值都有分支,避免漏处理枚举变体。
2. 3。
3. Option 还有 None 分支;补 None 或加 _。
第 08 课:集合#
- 词频统计用
HashMap::entry(word).or_insert(0)。 - 栈用
Vec::push/pop;队列用VecDeque::push_back/pop_front。 HashSet的intersection、union、difference返回迭代器。Vec按索引 O(1),HashMap平均 O(1),有序查找用BTreeMap。
验收任务
用 HashMap 统计 ["a", "b", "a"],让 map["a"] == 2。
参考实现
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
for word in ["a", "b", "a"] {
*map.entry(word).or_insert(0) += 1;
}
assert_eq!(map["a"], 2);
}
自测:概念 / 读代码 / 改错
1. 概念:Vec 的 get 和 [] 索引有什么区别?
2. 读代码:下面输出什么?let v = vec![1, 2]; println!("{:?}", v.get(5));
3. 改错:let v = vec![1]; println!("{}", v[3]); 怎么避免 panic?
答案与解析:
1. get 返回 Option,越界是 None;[] 越界会 panic。
2. None。
3. 用 v.get(3) 返回 Option,或先检查 len。
第 09 课:模块#
- 内联模块用
mod math { pub fn add(...) }。 - 文件模块:
src/math.rs或src/math/mod.rs。 pub(crate)只在本 crate 可见。pub use把内部路径重新导出成稳定 API。
验收任务
写一个内联 mod math,导出 add,在 main 中调用并断言结果为 5。
参考实现
mod math {
pub fn add(a: i32, b: i32) -> i32 { a + b }
}
fn main() {
assert_eq!(math::add(2, 3), 5);
}
自测:概念 / 读代码 / 改错
1. 概念:package、crate、module 三者的关系是什么?
2. 读代码:下面能编译吗?mod m { fn f() {} } fn main() { m::f(); }
3. 改错:调用另一个模块的私有函数报错,怎么改?
答案与解析:
1. package 管 Cargo.toml,包含一个 lib crate 和多个 bin crate;crate 内的命名空间由 module 组织。
2. 不能;f 默认私有。
3. 给 f 加 pub,或用 pub(crate) 控制范围。
第 10 课:错误处理#
parse_positive对空串、负数分别返回不同错误。- 用
?串联parse、divide、sqrt。 - 自定义错误实现
Display+Error,并用From接入?。 Box<dyn Error>适合应用层快速汇总错误。
验收任务
写 parse_positive(&str) -> Result<i32, String>:空串/负数返回 Err,正常数字返回 Ok;让两个断言通过。
参考实现
fn parse_positive(input: &str) -> Result<i32, String> {
let n: i32 = input.parse().map_err(|e| format!("{e}"))?;
if n < 0 { Err(String::from("negative")) } else { Ok(n) }
}
fn main() {
assert_eq!(parse_positive("3"), Ok(3));
assert!(parse_positive("-1").is_err());
}
自测:概念 / 读代码 / 改错
1. 概念:? 在 Err 时实际做了什么?
2. 读代码:下面能编译吗?fn main() { let n: i32 = "3".parse()?; }
3. 改错:fn main() -> () 里用 ? 报错,怎么改?
答案与解析:
1. 提前 return Err(From::from(e)),把底层错误转换成函数声明的错误类型。
2. 不能;main 返回 (),? 需要 Result。
3. 把 main 改成 fn main() -> Result<(), Box<dyn Error>>,或使用 match/unwrap。
第 11 课:泛型#
min_max<T: PartialOrd + Copy>一次返回两个值。Pair<T, U>::swap返回Pair<U, T>。where子句适合约束较多的情况。- const 泛型:
fn sum<const N: usize>(arr: [i32; N]) -> i32。
验收任务
写泛型 largest<T: PartialOrd + Copy>,让 largest(&[1, 5, 3]) == 5。
参考实现
fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
let mut max = items[0];
for &item in items {
if item > max { max = item; }
}
max
}
fn main() {
assert_eq!(largest(&[1, 5, 3]), 5);
}
自测:概念 / 读代码 / 改错
1. 概念:泛型和 dyn Trait 的分发方式有什么不同?
2. 读代码:fn f<T>(x: T) { x > x; } 能编译吗?
3. 改错:largest(&[1, 2]) 报 trait bound 错误,怎么改?
答案与解析:
1. 泛型在编译期单态化,静态分发;dyn Trait 运行时查虚表,动态分发。
2. 不能;T 没有实现 PartialOrd。
3. 加 T: PartialOrd(需要时再 Copy)。
第 12 课:Trait#
- 定义
Shapetrait,为圆、矩形实现area。 - 默认方法可以调用其他必须实现的方法。
Vec<Box<dyn Shape>>存放不同形状。- newtype 包装
Vec<i32>后实现Display。
验收任务
定义 Area trait,为 Square 实现,让 Square(3.0).area() == 9.0。
参考实现
trait Area { fn area(&self) -> f64; }
struct Square(f64);
impl Area for Square {
fn area(&self) -> f64 { self.0 * self.0 }
}
fn main() {
assert_eq!(Square(3.0).area(), 9.0);
}
自测:概念 / 读代码 / 改错
1. 概念:什么时候必须用 Box<dyn Trait> 而不能用泛型?
2. 读代码:trait T { fn f(&self) -> Self; } 能作为 dyn T 吗?
3. 改错:impl Display for Vec<i32> 报孤儿规则错误,怎么改?
答案与解析:
1. 需要把不同类型放进同一个集合,或运行时决定具体类型时。
2. 不能;返回 Self 使 trait 不对象安全。
3. 用 newtype 包一层,再为本地类型实现 Display。
第 13 课:生命周期#
longest<'a>(x: &'a str, y: &'a str) -> &'a str。Excerpt<'a>保存&'a str。- 多个输入引用且返回引用时,通常需要手写生命周期。
'static表示可以活到程序结束,不是必须活那么久。
验收任务
写 longest,返回两个 &str 中较长者,并让断言通过。
参考实现
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
fn main() {
let a = String::from("abc");
let b = String::from("de");
assert_eq!(longest(&a, &b), "abc");
}
自测:概念 / 读代码 / 改错
1. 概念:生命周期标注会改变程序运行时间吗?
2. 读代码:fn f(x: &str) -> &str { x } 能编译吗?
3. 改错:fn f(x: &str, y: &str) -> &str { x } 为什么报错?
答案与解析:
1. 不会,它只是编译期约束;生命周期本身没有运行时开销。
2. 能;只有一个输入引用,省略规则把它的生命周期给输出。
3. 多个输入引用时输出无法推断;加 <'a>(x: &'a str, y: &str) -> &'a str。
第 14 课:闭包与迭代器#
- 闭包计数器用
FnMut,捕获count。 filter_map(|s| s.parse().ok())过滤解析失败项。fold(0, |acc, x| acc + x)实现求和。- 斐波那契迭代器保存
(a, b)两个状态。
验收任务
用迭代器求 [1, 2, 3] 中奇数的和,再用闭包把 3 变成 6;让两个断言通过。
参考实现
fn main() {
let values = vec![1, 2, 3];
let sum: i32 = values.iter().filter(|x| **x % 2 == 1).sum();
let double = |x: i32| x * 2;
assert_eq!(sum, 4);
assert_eq!(double(3), 6);
}
自测:概念 / 读代码 / 改错
1. 概念:迭代器的 map 为什么不会立即执行?
2. 读代码:let f = move || s; 调用两次会怎样?
3. 改错:闭包借用了局部变量,线程里报 may outlive,怎么改?
答案与解析:
1. map 只是构造新的惰性迭代器;collect、sum 等消费者才真正驱动迭代。
2. 如果闭包返回 s,它是 FnOnce,第二次调用会报 use of moved value。
3. 用 move 把变量移入线程,或改用 thread::scope。
第 15 课:智能指针#
Box<List>实现递归链表。Deref返回&self.0,Drop里打印释放顺序。Rc<RefCell<Vec<i32>>>让多个变量修改同一个 Vec。Arc<Mutex<i32>>启动多个线程累加。
验收任务
用 Rc 共享一个 String,让 strong_count == 2,并验证两个 Rc 指向同一份数据。
参考实现
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("x"));
let b = Rc::clone(&a);
assert_eq!(Rc::strong_count(&a), 2);
assert_eq!(&*a, &*b);
}
自测:概念 / 读代码 / 改错
1. 概念:Rc<RefCell<T>> 和 Arc<Mutex<T>> 分别适合什么场景?
2. 读代码:Rc::strong_count 在两个 Rc::clone 后是多少?
3. 改错:Rc 跨线程报 cannot be sent,怎么改?
答案与解析:
1. 前者单线程共享可变数据;后者多线程共享可变数据,Mutex 提供互斥。
2. 3(原始 1 加两个 clone)。
3. 改用 Arc;需要可变再加 Mutex。
第 16 课:测试#
- 正常、除零、负数各写一个测试。
#[should_panic(expected = "boom")]检查 panic 信息。- 集成测试放
tests/,只能调用公共 API。 dbg!(value)打印后继续使用原值。
验收任务
给一个 add 函数写单元测试,包含正常值和边界值;运行 cargo test 让测试通过。
参考实现
fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_numbers() {
assert_eq!(add(2, 3), 5);
assert_eq!(add(-1, 1), 0);
}
}
自测:概念 / 读代码 / 改错
1. 概念:单元测试和集成测试能访问的项有什么不同?
2. 读代码:测试模块里直接调用父模块私有函数,需要写什么?
3. 改错:cargo test 运行 0 个测试,可能是什么原因?
答案与解析:
1. 单元测试在模块内,可访问私有项;集成测试在 tests/,只能访问 library 的公共 API。
2. use super::*;。
3. 缺少 #[test]、#[cfg(test)],或测试文件不在 tests/。
第 17 课:并发#
- 4 个线程分别计算区间和,
join后合并。 mpsc::channel生产者发送,主线程接收。thread::scope内可以借用局部变量,线程结束后自动 join。Rc<RefCell<T>>不是Send,跨线程要用Arc<Mutex<T>>。
验收任务
用 4 个线程各把 Arc<Mutex<i32>> 加 1000 次,最后断言结果是 4000。
参考实现
use std::sync::{Arc, Mutex};
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(std::thread::spawn(move || {
for _ in 0..1000 { *counter.lock().unwrap() += 1; }
}));
}
for handle in handles { handle.join().unwrap(); }
assert_eq!(*counter.lock().unwrap(), 4000);
}
自测:概念 / 读代码 / 改错
1. 概念:Arc<Mutex<T>> 中锁的 guard 什么时候释放?
2. 读代码:thread::spawn(|| data) 为什么要求 move?
3. 改错:两个线程同时修改 Vec 报 cannot borrow as mutable,怎么改?
答案与解析:
1. guard 离开作用域时自动释放;锁的范围应尽量小。
2. 线程可能比当前函数活得久,必须把 data 的所有权移入闭包。
3. 用 Arc<Mutex<Vec<_>>>,或让每个线程处理不重叠的切片。
第 18 课:宏#
max!用($a:expr, $b:expr)两个片段。hashmap!可以用$($k:expr => $v:expr),*。- 生成结构体时用
$name:ident和$($field:ident : $ty:ty),*。 square!(1 + 2)要加括号,否则展开后优先级错误。
验收任务
写 square! 宏,让 square!(4) == 16 且 square!(1 + 2) == 9。
参考实现
macro_rules! square {
($x:expr) => { ($x) * ($x) };
}
fn main() {
assert_eq!(square!(4), 16);
assert_eq!(square!(1 + 2), 9);
}
自测:概念 / 读代码 / 改错
1. 概念:macro_rules! 中 $x:expr 和 $x:ident 有什么区别?
2. 读代码:square!(1 + 2) 展开后是多少?
3. 改错:宏定义在使用之后报 cannot find macro,怎么改?
答案与解析:
1. expr 匹配表达式片段,ident 匹配标识符;片段类型决定能接受什么语法。
2. 9;宏体里的括号保证优先级。
3. 把 macro_rules! 移到调用之前,或放到模块顶部。
第 19 课:异步#
- 两个
async fn用.await串联。 block_on不断poll,直到Poll::Ready。- Future 创建后不会自动执行,必须被 poll。
- CPU 密集用线程,I/O 密集用 async + Tokio。
验收任务
创建 let future = async { 42 };,解释为什么它不执行,再用一个执行器/block_on 得到 42。
参考实现
创建 Future 只是构造状态机;必须被 poll。用第 19 课的block_on(future) 才会得到 42。
自测:概念 / 读代码 / 改错
1. 概念:Future 返回 Pending 后,执行器怎么知道何时再 poll?
2. 读代码:let f = async { println!("run"); }; 会打印吗?
3. 改错:在 async fn 里调用阻塞 sleep 有什么问题?
答案与解析:
1. Future 通过 waker 注册唤醒;事件就绪时调用 wake,执行器再次 poll。
2. 不会;创建 Future 不执行,必须 poll/await。
3. 阻塞执行器线程;改用异步 sleep 或 spawn_blocking。
第 20 课:unsafe#
- 用裸指针交换两个值,注意不能同时创建别名可变引用。
unsafe fn的# Safety注释写清指针有效性和长度。- 调用 libc 的
abs需要unsafe extern "C"和unsafe块。 - 安全封装:对外只暴露安全函数,内部集中处理 unsafe。
验收任务
用裸指针把 x 从 42 改成 43,并断言修改成功;注意把解引用放进 unsafe。
参考实现
fn main() {
let mut x = 42;
let pointer = &mut x as *mut i32;
unsafe { *pointer += 1; }
assert_eq!(x, 43);
}
自测:概念 / 读代码 / 改错
1. 概念:unsafe 会关闭借用检查吗?
2. 读代码:*p += 1; 直接写为什么报错?
3. 改错:extern "C" { fn abs(x: i32) -> i32; } 在 Edition 2024 报错,怎么改?
答案与解析:
1. 不会。unsafe 只解锁五类额外操作,借用检查仍然生效。
2. 裸指针解引用必须放在 unsafe 块里。
3. 写成 unsafe extern "C",调用处再包 unsafe。
第 21 课:项目实战#
--top N:读取第二个参数并parse::<usize>()。- 停用词:用
HashSet<&str>过滤。 - JSON 输出:手动拼接字符串,注意转义引号。
- 写文件:
fs::write返回Result,用?或match处理。
验收任务
把第 21 课的 count_words 抽成纯函数,让 count_words("The the cat")["the"] == 2。
参考实现
use std::collections::HashMap;
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
let word = word.trim_matches(|c: char| !c.is_alphanumeric()).to_lowercase();
if !word.is_empty() { *counts.entry(word).or_insert(0) += 1; }
}
counts
}
fn main() {
let counts = count_words("The the cat");
assert_eq!(counts["the"], 2);
}
自测:概念 / 读代码 / 改错
1. 概念:为什么把 count_words 做成纯函数更容易测试?
2. 读代码:count_words("The the cat")["the"] 是多少?
3. 改错:读文件失败时直接 unwrap 有什么风险?
答案与解析:
1. 它只依赖输入字符串、返回 HashMap,不需要真实文件或全局状态,测试可以直接断言。
2. 2;大小写被统一,标点被去掉。
3. 文件不存在时 panic;应返回 Result 或用 match/eprintln 处理。
常见问题 FAQ#
String 和 &str 怎么选?#
- 需要拥有、修改、增长字符串:
String - 只读参数、字符串字面量:
&str - 函数参数优先
&str,因为它更通用,&String会自动解引用
Vec、数组、切片怎么选?#
- 长度固定、元素类型相同:数组
[T; N] - 需要动态增长:
Vec<T> - 只想借用一段连续数据:切片
&[T]
clone、Copy、move 有什么区别?#
Copy:按位复制,原变量仍可用,通常用于小类型clone:显式深拷贝,可能很昂贵move:所有权转移,原变量失效
Box、Rc、Arc 怎么选?#
Box<T>:独占所有权 + 堆分配,适合递归类型和大对象Rc<T>:单线程共享只读所有权Arc<T>:多线程共享只读所有权- 需要共享可变数据:
Rc<RefCell<T>>或Arc<Mutex<T>>
RefCell、Cell、Mutex 怎么选?#
Cell<T>:轻量内部可变性;get要求T: Copy,set/replace可用于任意TRefCell<T>:单线程内部可变性,运行时借用检查Mutex<T>:多线程互斥访问RwLock<T>:多读少写场景
Result 和 panic! 怎么选?#
- 可恢复错误(用户输入、文件、网络):
Result - 程序 bug、违反不变量:
panic! - 库函数应返回
Result,把选择权交给调用者
Box、自定义错误、thiserror/anyhow 怎么选?#
- 小项目/应用层:
Box<dyn Error> - 需要区分错误类型:自定义枚举 +
Display+Error - 大型项目:
thiserror定义错误,anyhow处理应用层错误
impl Trait 和 dyn Trait 怎么选?#
- 静态分发、性能优先:
impl Trait/ 泛型 - 需要存放不同类型、运行时多态:
dyn Trait dyn Trait有虚表开销,且要求对象安全
什么时候需要手写生命周期?#
- 函数返回引用,且有多个输入引用
- 结构体保存引用
- 编译器无法根据省略规则推断输出生命周期
- 大多数简单函数不需要手写
async 和线程怎么选?#
- CPU 密集:线程 / rayon
- 大量 I/O 并发:async + Tokio
- 阻塞库:
spawn_blocking或独立线程 - 简单并行:
thread::spawn/thread::scope
为什么 Rust 编译慢?#
- 单态化会为每个具体类型生成代码
- 借用检查、trait 解析、宏展开都需要时间
- 增量编译、
cargo check、减少泛型爆炸可以缓解
如何组织大型 Rust 项目?#
- 用 workspace 拆分多个 crate
- 用 feature 控制可选依赖
- 公共 API 用
pub use做门面 - 模块按业务拆分,避免循环依赖
- 用
cargo test、clippy、fmt保持质量
学习建议与练习#
三遍学习法#
第一遍:跑通
- 按顺序运行 00–21。
- 对照源码看输出,不要求全部记住。
- 重点理解“为什么这样写”。
第二遍:修改
- 把示例中的类型、变量名、函数逻辑改掉。
- 取消注释中的错误代码,观察编译器报错。
- 尝试用不同写法实现同一功能。
第三遍:复写
- 不看源码,独立实现每课的核心示例。
- 写一个小项目,把多个知识点串起来。
推荐练习项目#
更完整的阶段项目、验收命令和 rubric 见 阶段项目与验收。
| 项目 | 涉及课程 |
|---|---|
| 命令行待办清单 | 01–10、16 |
| 词频统计工具 | 08、10、14、21 |
| JSON 解析器(简化版) | 07、08、10、11、16 |
| 简易 HTTP 路由器 | 09、10、12、17 |
| 多线程下载器 | 10、15、17 |
| 小型解释器 | 06、07、11、12、13 |
| 异步任务队列 | 17、19 |
调试技巧#
dbg!(x)打印变量和位置,比println!更方便。RUST_BACKTRACE=1 cargo run查看 panic 回溯。cargo test运行测试,cargo test -- --nocapture显示打印输出。cargo check比cargo build快,适合频繁检查。cargo clippy提供额外的代码质量建议。cargo fmt自动格式化代码。
心态建议#
- 借用检查器不是敌人,它在帮你发现潜在 bug。
- 不要为了“通过编译”而到处
clone,先理解所有权关系。 - 遇到生命周期错误,画一画每个引用的有效区间。
- Rust 的学习曲线前期陡,后期会越来越顺。
阶段项目与验收#
只看课不做项目,知识会停在“认识”层面。下面四个阶段项目分别对应不同课组,每个都有最小验收标准;做完再进入下一阶段。项目在自己的 crate 里实现,不要求放进本仓库。
项目 1:命令行单位换算器(01–04)#
要求
- 从命令行读一个数字和单位(
c/f),输出另一种温度。 - 非数字、未知单位返回错误并以非零码退出。
- 至少 3 个单元测试:正常值、边界值、错误输入。
验收
cargo run --bin unit -- 100 c # 212 f
cargo run --bin unit -- abc c # 错误信息 + 非零退出
cargo test
加分:支持 --help、摄氏/华氏/开尔文三种单位。
项目 2:库存管理(05–07)#
要求
- 用
struct Item { name, quantity, price }和enum Category。 - 用
HashMap<String, Item>保存库存。 - 支持入库、出库、按分类统计;数量不足返回
Result。
验收
- 出库数量大于库存时返回
Err,库存不变。 - 按分类统计的总金额正确。
- 测试覆盖正常入库/出库/不足三种情况。
加分:把库存保存成文本文件,启动时读回。
项目 3:文本统计与简化 JSON(08–13)#
要求(二选一)
- A:统计文件的行数、单词数、字符数,按词频输出 Top 10。
- B:解析一个简化 JSON(对象/数组/字符串/数字),错误带位置。
验收
- 文件不存在时返回
Result,不 panic。 - 空文件、单行文件、含中文文件都能处理。
- 核心逻辑拆成纯函数,至少有 3 个测试。
加分:用 trait 抽象“数据源”(文件/标准输入),用泛型复用统计逻辑。
项目 4:并发词频统计(14–17)#
要求
- 把大文本切成 N 段,用
thread::scope或Arc<Mutex<...>>并行统计。 - 合并各线程的
HashMap,输出总词频 Top 10。 - 不
unwrap线程/锁错误;主线程能感知子线程 panic。
验收
- 单线程和并行版本结果一致。
- 4 线程运行不 panic、不死锁。
- 至少一个测试验证合并逻辑。
加分:用 mpsc 把各线程结果发给主线程,而不是共享锁。
毕业项目(18–21)#
以第 21 课词频工具为基础,补上:
--top N、--stopwords、--json中至少两个参数;- 自定义错误类型 +
?; - 单元测试 + 集成测试;
- README 使用说明。
验收 rubric#
| 维度 | 合格 | 良好 | 优秀 |
|---|---|---|---|
| 功能 | 主流程能跑 | 边界输入不崩 | 有清晰的 CLI 帮助 |
| 错误处理 | 有 Result |
不滥用 unwrap |
错误信息可行动 |
| 测试 | 有 1–3 个测试 | 覆盖边界 | 覆盖错误与回归 |
| 所有权 | 能编译 | 少 clone | 借用设计清晰 |
| 代码质量 | fmt 通过 |
clippy 无警告 |
模块拆分合理 |
| 文档 | 有运行命令 | 有示例输入输出 | 有设计取舍说明 |
完成定义#
一个项目算“完成”,需要同时满足:
cargo fmt --check、cargo clippy --all-targets、cargo test通过;- 有一条命令能复现主要功能;
- 你能不看代码,向别人解释数据流和错误路径;
- 至少写下一个“下次会怎么改”的改进点。
进阶支线:从标准库到生态#
本课程刻意不引入第三方依赖,保证 cargo run 离线可用。真正做项目时,通常会加下面几类 crate。它们不放进主项目,而是作为独立支线练习。
| 方向 | 常用 crate | 解决什么问题 |
|---|---|---|
| 异步运行时 | tokio |
异步 I/O、定时器、任务调度 |
| 序列化 | serde、serde_json、toml |
结构体与 JSON/TOML 互转 |
| 命令行 | clap |
参数、子命令、帮助信息 |
| 错误处理 | thiserror、anyhow |
库错误定义 / 应用层错误汇总 |
| 日志 | tracing、log |
结构化日志与诊断 |
| Web | axum、actix-web |
HTTP 服务 |
异步支线:Tokio#
[dependencies]
tokio = { version = "1", features = ["full"] }
anyhow = "1"
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let a = tokio::spawn(async { 20 });
let b = tokio::spawn(async { 22 });
println!("{}", a.await? + b.await?);
Ok(())
}
练习:把第 19 课的 block_on 换成 Tokio,并加一个 tokio::time::sleep 的并发任务。
序列化支线:serde#
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Config {
name: String,
retries: u32,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let text = r#"{"name":"demo","retries":3}"#;
let config: Config = serde_json::from_str(text)?;
println!("{}", serde_json::to_string_pretty(&config)?);
Ok(())
}
练习:给第 21 课的词频工具加 --json 输出;用 serde_json::to_writer 写到 stdout。
命令行支线:clap#
[dependencies]
clap = { version = "4", features = ["derive"] }
use clap::Parser;
/// 词频统计工具
#[derive(Parser)]
struct Args {
/// 输入文件
file: Option<String>,
/// 输出前 N 个
#[arg(long, default_value_t = 10)]
top: usize,
}
fn main() {
let args = Args::parse();
println!("file = {:?}, top = {}", args.file, args.top);
}
练习:把第 21 课的 env::args() 换成 clap,补上 --top N 和 --stopwords。
错误支线:thiserror + anyhow#
[dependencies]
thiserror = "2"
anyhow = "1"
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("输入不能为空")]
Empty,
#[error("解析失败: {0}")]
Parse(#[from] std::num::ParseIntError),
}
fn run(input: &str) -> Result<i32, AppError> {
if input.trim().is_empty() {
return Err(AppError::Empty);
}
Ok(input.parse::<i32>()? * 2)
}
练习:把第 10 课的自定义错误改成 thiserror,在 main 里用 anyhow::Result 汇总。
怎么练#
- 新建一个独立 crate:
cargo new advanced-lab。 - 每次只加一类依赖,先跑通最小示例,再迁移第 21 课的一部分。
- 给每个支线写 2 个测试,再提交。
- 不要把
advanced-lab放进本课程的 workspace,保持主项目无依赖、可离线运行。
进阶路线#
掌握本课程后,可以继续学习:
| 方向 | 内容 | 课程 |
|---|---|---|
| 测试 | #[test]、assert_eq!、cargo test、集成测试 |
16 |
| 并发 | thread、channel、Arc、Mutex、thread::scope |
17 |
| 宏 | macro_rules!、过程宏 |
18 |
| 异步编程 | async / await、Future、Tokio |
19 |
| 不安全 Rust | 裸指针、unsafe、FFI |
20 |
| 项目实战 | CLI、文件 I/O、错误处理、测试 | 21 |
| 序列化 | serde、serde_json、toml |
进阶 |
| 命令行 | clap、anyhow、thiserror |
进阶 |
| 工程化 | workspace、feature、CI、文档注释、cargo doc |
进阶 |
| Web | Axum、Actix Web、Rocket | 进阶 |
| 系统编程 | 文件 I/O、网络、进程、信号 | 进阶 |
推荐下一步
- 以第 21 课的词频工具为基础,用
clap改造参数解析。 - 用
serde增加 JSON 输出,用thiserror定义错误。 - 用
cargo test给项目补集成测试。 - 学习 Tokio,把阻塞 I/O 改成异步任务。
- 阅读《The Rust Programming Language》和
std文档,参与开源项目。
附录:项目结构与运行方式#
目录结构#
.
├── Cargo.toml
├── build.rs
├── README.md
├── run_all.sh
├── LICENSE
├── CHANGELOG.md
├── CONTRIBUTING.md
├── scripts
│ └── check_doc_snippets.py
├── docs
│ ├── Rust学习文档.md
│ ├── assets
│ │ ├── string-memory.svg
│ │ ├── lifetime-timeline.svg
│ │ └── async-state-machine.svg
│ ├── fonts-src
│ │ └── ZhuZiAWan.woff2
│ ├── build_web.py
│ ├── subset_font.py
│ ├── requirements.txt
│ └── web
│ ├── index.html
│ ├── style.css
│ ├── app.js
│ ├── favicon.svg
│ ├── manifest.webmanifest
│ ├── sw.js
│ ├── icons
│ │ ├── icon-192.png
│ │ └── icon-512.png
│ └── fonts
│ └── ZhuZiAWan-subset.woff2
└── src
├── main.rs
├── lib.rs
├── module_demo.rs
├── module_demo
│ ├── greetings.rs
│ ├── math.rs
│ └── math
│ └── advanced.rs
└── bin
├── 00_cargo.rs
├── 01_variables.rs
├── 02_data_types.rs
├── 03_functions.rs
├── 04_control_flow.rs
├── 05_ownership.rs
├── 06_structs.rs
├── 07_enums_and_matching.rs
├── 08_collections.rs
├── 09_packages_and_modules.rs
├── 10_error_handling.rs
├── 11_generics.rs
├── 12_traits.rs
├── 13_lifetimes.rs
├── 14_closures_and_iterators.rs
├── 15_smart_pointers.rs
├── 16_testing.rs
├── 17_concurrency.rs
├── 18_macros.rs
├── 19_async.rs
├── 20_unsafe.rs
└── 21_project.rs
运行方式#
# 新建项目
cargo new my_app
cd my_app
# 添加依赖
cargo add serde --features derive
# 查看课程列表
cargo run
# 运行某一课
cargo run --bin 00_cargo
cargo run --bin 01_variables
cargo run --bin 05_ownership
cargo run --bin 13_lifetimes
cargo run --bin 16_testing
cargo run --bin 21_project -- Cargo.toml
# 检查所有课程
cargo check --all-targets
# 依次运行全部课程
./run_all.sh
# 运行测试
cargo test
cargo test --bin 16_testing
cargo test --bin 21_project
# 生成并打开文档
cargo doc --open
# 格式化代码
cargo fmt
# 代码检查
cargo clippy
# 发布构建
cargo build --release
网页版#
docs/web/index.html 是静态网页版,支持 h2/h3 章节导航、全文搜索(/ 或 Ctrl/Cmd+K)、上一课/下一课、阅读位置恢复、阅读时间、字号/版心/高对比/减少动效、深色模式、代码高亮与复制、学习进度勾选、打印封面/目录/页码,以及 PWA 离线缓存。界面使用 Tokyo Night 配色,正文使用筑紫A圆体E(ZhuZiAWan)子集,代码高亮使用 One Dark Pro Darker。
# 启动本地服务器
python3 -m http.server 8000 --directory docs/web
然后访问 http://localhost:8000。修改 Markdown 后重新生成(首次先安装 Python 依赖):
python3 -m pip install -r docs/requirements.txt
python3 docs/build_web.py
python3 docs/subset_font.py
学习记录表#
可以用下面的表格记录进度:勾选运行/修改/复写,给自测打 0–3 分,并填下次复习日期。网页版会统计待复习数量,并支持导出/导入 JSON。
| 课程 | 已运行 | 已修改 | 已复写 | 自测分 | 下次复习 | 备注 |
|---|---|---|---|---|---|---|
| 00 Cargo | ☐ | ☐ | ☐ | |||
| 01 变量 | ☐ | ☐ | ☐ | |||
| 02 数据类型 | ☐ | ☐ | ☐ | |||
| 03 函数 | ☐ | ☐ | ☐ | |||
| 04 流程控制 | ☐ | ☐ | ☐ | |||
| 05 所有权 | ☐ | ☐ | ☐ | |||
| 06 结构体 | ☐ | ☐ | ☐ | |||
| 07 枚举与匹配 | ☐ | ☐ | ☐ | |||
| 08 集合 | ☐ | ☐ | ☐ | |||
| 09 模块 | ☐ | ☐ | ☐ | |||
| 10 错误处理 | ☐ | ☐ | ☐ | |||
| 11 泛型 | ☐ | ☐ | ☐ | |||
| 12 Trait | ☐ | ☐ | ☐ | |||
| 13 生命周期 | ☐ | ☐ | ☐ | |||
| 14 闭包与迭代器 | ☐ | ☐ | ☐ | |||
| 15 智能指针 | ☐ | ☐ | ☐ | |||
| 16 测试与调试 | ☐ | ☐ | ☐ | |||
| 17 并发与线程 | ☐ | ☐ | ☐ | |||
| 18 宏入门 | ☐ | ☐ | ☐ | |||
| 19 异步编程 | ☐ | ☐ | ☐ | |||
| 20 unsafe 与 FFI | ☐ | ☐ | ☐ | |||
| 21 项目实战 | ☐ | ☐ | ☐ |
最后的话
Rust 的核心不是语法,而是“用类型系统表达约束”。 当你能自然地想到“这个值归谁所有、谁在借用、能活多久”, 就已经跨过了 Rust 最难的部分。
祝学习顺利,Happy Rustacean! 🦀