🦀 Rust 语法学习文档 DOCS · EDITION 2024

Rust 语法学习文档

配套 22 个可运行示例 · 从 Cargo 到异步与项目实战 · 中文详解


文档说明#

适用对象

  • 有编程基础、第一次系统学习 Rust 的开发者
  • 学过其他语言,想理解 Rust 所有权、借用、生命周期等核心机制的人
  • 需要一份可以边看边运行的 Rust 语法速查手册的人

配套代码

本文档配套项目中的 22 个课程文件,全部位于 src/bin/ 目录。每课都是一个独立的可执行程序,可以单独运行:

bash
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 的设计目标可以概括为三句话:

  1. 内存安全:没有 GC,也没有悬垂指针和数据竞争。
  2. 零成本抽象:泛型、迭代器、Trait 在运行时通常没有额外开销。
  3. 所有权驱动:用“谁拥有、谁借用、能活多久”替代手动 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

学习原则

  1. 先运行,再阅读,再修改。不要只看不写。
  2. 遇到编译错误,先读错误码和编译器建议,再查文档。
  3. 每学完一课,尝试不看源码复写关键示例。
  4. 不要过早追求“零 clone”。先让程序正确,再优化所有权。

自学手册#

这份文档可以“读”,但更推荐“练”。自学最容易失败的地方不是看不懂语法,而是:卡住没人问、写完不知道对不对、学完没有复习。下面是一套最小闭环。

学习闭环#

每一课按这个顺序走:

  1. 先跑:cargo run --bin <课程名>,看输出,不改代码。
  2. 再读:读本课正文,遇到不懂的回到源码对照。
  3. 改一改:改类型、改变量名、改分支,观察编译器和输出变化。
  4. 合上书:完成“本课目标”里的可验收动作,不看源码复写关键例子。
  5. 做验收:完成练习区的验收任务,跑断言;卡住 15 分钟再看参考实现。
  6. 做自测:概念、读代码、改错三问;答不出就回到对应小节。
  7. 复习:第 1、3、7、14 天各花 10 分钟回看小结和错题。

规则:先自己写,再看答案;答案不是“读过”,而是“能复现”。本课目标里的“理解/掌握”,以能否完成验收任务和自测为准,不以“看过”为准。

卡住时怎么办#

按顺序排查,不要一上来就问人或让 AI 重写:

  1. 读错误码和第一行:error[E0382] 比后面的几十行更重要。
  2. 看 help: / note::编译器经常直接给出改法。
  3. 跑 rustc --explain E0382:标准库官方解释,比搜索更快。
  4. 做最小复现:把代码删到 10 行以内,直到错误还出现。
  5. 加 dbg! / RUST_BACKTRACE=1:确认值、分支、panic 位置。
  6. 问三个问题:谁拥有这个值?谁在借用?借用持续到什么时候?
  7. 查文档/搜索:标准库文档 → 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 天 回看错题和“卡住记录”,再写一次

网页版的学习记录表可以记录“自测分”和“下次复习”,并支持导出/导入进度。

笔记模板#

每课只记三行,避免抄书:

text
本课解决:(一句话)
我卡住的点:(一句话)
能复述的一句话:(一句话)

错题单独记:

text
错误码/现象:
我原来的理解:
正确规则:
下次如何识别:

提问模板#

问人之前,先准备这四样,通常自己就解决了:

text
目标:我想让程序做什么
最小代码:删到能复现问题的最短版本
完整报错:从 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#

toml
[package]
name = "rust"
version = "0.1.0"
edition = "2024"
rust-version = "1.99"
default-run = "rust"

[dependencies]
  • name 是 package 名,也是 library crate 的默认名字
  • edition 决定语言版本;本项目使用 2024
  • rust-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 课就是这种情况:

text
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 目录约定#

text
.
├── 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 的公共 API
  • examples/*.rs 用 cargo run --example <name> 运行
  • target/ 是构建产物,通常不提交

0.5 依赖、profile 与 workspace#

添加依赖:

bash
cargo add serde --features derive

等价于在 Cargo.toml 写:

toml
[dependencies]
serde = { version = "1", features = ["derive"] }

发布优化:

toml
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1

多个 crate 共存时可以用 workspace:

toml
[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 练习#

  1. 用 cargo new 建一个自己的项目,运行 cargo check 和 cargo run。
  2. 把 src/main.rs 改名成 src/bin/hello.rs,观察 cargo run --bin hello。
  3. 给项目加一个 tests/smoke.rs,写一个最简单的 #[test]。
  4. 用 cargo doc --open 打开文档,找到标准库的 String 页面。

运行输出(节选)

text
=== 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/ 目录描述 target
  • cargo 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 的变量默认不可变:

rust
let x = 5;
println!("x = {x}");

// x = 6; // ❌ cannot assign twice to immutable variable

不可变是默认值,不是限制。它让编译器可以放心优化,也让读代码的人知道这个值不会悄悄变化。

1.2 可变绑定#

需要修改变量时,加 mut:

rust
let mut y = 10;
y += 5;
y *= 2;
println!("y = {y}"); // 30

mut 只允许改变值,不允许改变类型:

rust
let mut n = 1;
// n = "hello"; // ❌ 类型不匹配

1.3 变量遮蔽(shadowing)#

用同一个名字重新 let,会创建一个新变量,旧变量被遮蔽:

rust
let spaces = "   ";          // &str
let spaces = spaces.len();   // usize,类型可以改变
println!("{spaces}");        // 3

遮蔽与 mut 的区别:

特性 mut 遮蔽
是否同一变量 是 否,是新变量
能否改变类型 不能 能
能否改变可变性 不能 能
典型用途 原地修改 转换后继续用同一个名字

内层作用域的遮蔽只在内层有效:

rust
let value = 1;
{
    let value = value * 10;
    println!("内层:{value}"); // 10
}
println!("外层:{value}"); // 1

1.4 常量 const#

rust
const MAX_POINTS: u32 = 100_000;
const SECONDS_PER_DAY: u32 = 24 * 60 * 60;

规则:

  • 必须标注类型
  • 值必须是编译期可计算的常量表达式
  • 命名规范:SCREAMING_SNAKE_CASE
  • 可以声明在函数内、模块内或全局
  • 使用处会被内联,没有固定内存地址

1.5 静态变量 static#

rust
static GREETING: &str = "hello";

static 有固定内存地址,生命周期是整个程序('static)。可变静态变量需要 unsafe:

rust
// static mut COUNTER: u32 = 0;
// unsafe { COUNTER += 1; } // 不推荐

初学阶段尽量避免 static mut,需要共享可变状态时用 Mutex 或 Atomic*。

1.6 类型标注与命名规范#

rust
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 练习#

  1. 声明一个 i32 变量,用 mut 修改三次并打印。
  2. 用遮蔽把 &str 转成 usize,再转成 String。
  3. 定义常量 PI 和静态变量 APP_NAME,分别在函数内外使用。

运行输出(节选)

text
=== 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 整数字面量#

rust
let dec = 98_222;        // 十进制
let hex = 0xff;          // 十六进制
let octal = 0o77;        // 八进制
let binary = 0b1111_0000;// 二进制
let byte = b'A';         // u8 字节字面量

2.3 整数溢出#

rust
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 浮点数#

rust
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 表示无穷大
rust
let sum: f64 = 0.1 + 0.2;
println!("{}", (sum - 0.3).abs() < 1e-9); // true

f64::EPSILON 是 1.0 附近的最小间隔,不是通用容差;比较浮点数应使用与量级相关的相对误差,或使用 approx 这类库。

2.5 元组#

元组长度固定,元素类型可以不同:

rust
let tup: (i32, f64, char) = (500, 6.4, 'x');
let (a, b, c) = tup;          // 解构
println!("{}", tup.0);        // 索引访问

单元类型 () 是空元组,表示“没有有意义的值”。

2.6 数组#

数组长度固定,元素类型相同:

rust
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 切片#

切片是对连续数据的借用视图,由指针和长度组成:

rust
let slice: &[i32] = &arr[1..4]; // 左闭右开
let text = "hello 世界";
let hello = &text[..5];
let world = &text[6..];

字符串切片按字节索引,必须落在 UTF-8 字符边界上,否则 panic。

2.8 类型转换#

rust
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:

rust
let a: i64 = i64::from(42_i32);
let b: i64 = 42_i32.into();

可能失败的转换用 TryFrom / TryInto:

rust
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 练习#

  1. 打印 i32、u64、usize、char 的 size_of。
  2. 用四种进制定义整数并打印。
  3. 用 checked_add 处理 u8::MAX + 1。
  4. 遍历字符串的 chars() 和 bytes(),比较数量。

运行输出(节选)

text
=== 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 基本语法#

rust
fn add(a: i32, b: i32) -> i32 {
    a + b
}
  • 参数必须标注类型
  • 返回类型写在 -> 后面
  • 最后一个表达式就是返回值,不要加分号
  • 没有返回值时返回 ()

3.2 表达式与语句#

语句执行动作,不返回值;表达式求值并返回值。

rust
let y = {
    let x = 3;
    x * x // 没有分号,块的值是 9
};

如果最后一行加了分号,块的值就变成 ():

rust
let unit = {
    let x = 3;
    let _ = x * x; // 块仍然返回 ()
};

3.3 提前返回#

rust
fn abs(n: i32) -> i32 {
    if n < 0 {
        return -n;
    }
    n
}

return 适合提前退出,最后一个表达式适合自然返回。

3.4 发散函数#

返回类型 ! 表示函数永远不会正常返回:

rust
fn boom() -> ! {
    panic!("boom")
}

panic!、std::process::exit、无限 loop 都属于发散表达式。

3.5 函数指针与高阶函数#

函数名可以当作函数指针:

rust
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 递归#

rust
fn factorial(n: u64) -> u64 {
    if n <= 1 { 1 } else { n * factorial(n - 1) }
}

递归要有终止条件。朴素 fib 是指数级复杂度,真实项目应使用迭代或记忆化。

3.7 嵌套函数与 const fn#

rust
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 参数传递#

rust
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 没有函数重载和默认参数#

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 练习#

  1. 写一个返回 (min, max) 的函数。
  2. 用函数指针数组实现加减乘除。
  3. 用 const fn 计算编译期常量。
  4. 写一个递归函数计算斐波那契,再改成迭代版本。

运行输出(节选)

text
=== 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 表达式#

rust
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#

rust
let result = loop {
    break 42;
};

while countdown > 0 {
    countdown -= 1;
}

for i in 1..=5 {
    println!("{i}");
}

区间:

  • 1..5:左闭右开,1,2,3,4
  • 1..=5:左闭右闭,1,2,3,4,5
  • .rev():倒序
  • .step_by(3):步长

4.3 循环标签#

rust
'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#

rust
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#

rust
for input in ["1", "不是数字"] {
    let Ok(value) = input.parse::<i32>() else {
        println!("解析失败");
        continue;
    };
    println!("{value}");
}

else 分支必须发散:return、break、continue 或 panic!。

4.6 match 预览#

rust
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 练习#

  1. 用 loop 计算 1 到 100 的和,用 break 返回值。
  2. 用带标签的循环找到第一个满足条件的 (i, j)。
  3. 用 while let 清空一个 Vec。
  4. 用 let-else 解析命令行参数。

运行输出(节选)

text
=== 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 三条所有权规则#

  1. 每个值都有一个所有者。
  2. 同一时刻只能有一个所有者。
  3. 所有者离开作用域时,值被自动 drop。

5.2 栈与堆#

  • 栈:大小固定,分配释放快,存放 Copy 类型和 String 的元数据。
  • 堆:大小可变,通过指针访问,String 的字符数据在堆上。

String 在栈上存三部分:指针、长度、容量。

String 的栈与堆布局:栈上保存指针、长度、容量,堆上保存 UTF-8 字节
String 的栈上元数据和堆上字节;移动只复制栈上的三个字段。

5.3 移动(move)#

rust
let s1 = String::from("hello");
let s2 = s1; // s1 被移动

// println!("{s1}"); // ❌ borrow of moved value

赋值只复制栈上的三字段,然后把 s1 标记为已移动。不会深拷贝,也不会双重释放。

5.4 克隆(clone)#

rust
let s3 = s2.clone();

clone 深拷贝堆数据,可能很昂贵。不要无脑 clone。

5.5 Copy 类型#

整数、浮点、bool、char、由 Copy 类型组成的元组/数组都实现了 Copy:

rust
let x = 5;
let y = x; // 按位复制,x 仍可用

String、Vec、Box 拥有堆内存,不是 Copy。

5.6 所有权与函数#

rust
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。

rust
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 切片也是借用#

rust
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 与作用域#

rust
{
    let temp = String::from("temp");
} // temp 在这里释放

let bye = String::from("bye");
drop(bye); // 提前释放

5.10 引用不能悬垂#

rust
// 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 练习#

  1. 写一个函数,接收 String 并返回它的长度,分别用传值和传引用实现。
  2. 解释为什么 let s2 = s1; println!("{s1}") 会失败。
  3. 用 NLL 改写一段借用冲突的代码。
  4. 写一个 first_word 函数,返回 &str。

运行输出(节选)

text
=== 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 三种结构体#

rust
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

struct Color(i32, i32, i32); // 元组结构体

struct AlwaysEqual; // 单元结构体

6.2 创建实例#

rust
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;

字段初始化简写:

rust
let username = String::from("crab");
let user1 = User {
    active: true,
    username, // 等价于 username: username
    email: String::from("crab@example.com"),
    sign_in_count: 0,
};

结构体更新语法:

rust
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 方法与关联函数#

rust
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 块#

rust
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#

rust
#[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 打印#

rust
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 练习#

  1. 定义 Rectangle,实现 area、can_hold、square。
  2. 为 User 添加 email_domain 方法。
  3. 用结构体更新语法复制一个用户并修改邮箱。
  4. 给结构体加 #[derive(Debug, PartialEq)],比较两个实例。

运行输出(节选)

text
=== 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 枚举基础#

rust
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#

标准库最重要的两个枚举:

rust
enum Option<T> {
    Some(T),
    None,
}

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Option<T> 把“可能没有值”编码进类型系统,替代 null。

7.3 match 与穷尽性#

rust
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 绑定、守卫、范围、或模式、@#

rust
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 解构#

rust
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!#

rust
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 组合子#

rust
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 递归枚举#

rust
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 练习#

  1. 定义 Shape 枚举,计算圆、矩形、三角形面积。
  2. 用 match 实现简单的命令行解析。
  3. 用 Option 组合子改写一段嵌套 if let。
  4. 用 Box 实现一个链表并求和。

运行输出(节选)

text
=== 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#

rust
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,更安全

遍历与修改:

rust
for x in &v {
    println!("{x}");
}

for x in &mut v {
    *x *= 10;
}

常用操作:

rust
v.insert(0, 9);
v.remove(0);
v.pop();
v.sort();
v.dedup();
v.retain(|x| x % 2 == 0);
v.reverse();

8.2 String#

rust
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 注意点:

rust
let text = String::from("hello 世界");
println!("{}", text.len());          // 字节数
println!("{}", text.chars().count()); // 字符数

字符串切片按字节索引,必须落在字符边界:

rust
let hello = &text[..5]; // ✅
// let bad = &text[..7]; // ❌ panic

8.3 HashMap#

rust
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:

rust
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#

rust
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 练习#

  1. 统计一段文本中每个单词出现的次数,按词频排序输出。
  2. 用 Vec 实现一个简单的栈和队列。
  3. 用 HashSet 求两个数组的交集、并集、差集。
  4. 比较 Vec 和 HashMap 查找元素的时间复杂度。

运行输出(节选)

text
=== 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 内联模块#

rust
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#

rust
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 重新导出#

rust
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.rs
  • src/foo.rs 中 mod bar; → src/foo/bar.rs
  • 现代风格推荐 foo.rs + foo/ 目录

本项目的第 09 课使用:

text
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 练习#

  1. 创建 mod math { pub fn add... },在 main 中调用。
  2. 把内联模块拆成 math.rs 和 math/advanced.rs。
  3. 用 pub(crate) 限制一个函数只在 crate 内可见。
  4. 用 pub use 重新导出内部模块。

运行输出(节选)

text
=== 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!#

rust
// panic!("something is wrong");

以下操作也会 panic:

  • 数组/Vec 越界
  • unwrap() 遇到 None / Err
  • debug 构建下的整数溢出
  • assert! / assert_eq! 失败

10.3 Result#

rust
enum Result<T, E> {
    Ok(T),
    Err(E),
}
rust
match divide(10.0, 2.0) {
    Ok(value) => println!("{value}"),
    Err(error) => println!("{error}"),
}

10.4 unwrap 与 expect#

rust
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 ? 运算符#

rust
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 会直接提前返回:

rust
fn first_char(s: &str) -> Option<char> {
    let c = s.chars().next()?;
    Some(c.to_ascii_uppercase())
}

10.6 自定义错误#

rust
#[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#

rust
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 互转#

rust
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 常用组合子#

rust
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 练习#

  1. 写一个 parse_positive 函数,负数返回自定义错误。
  2. 用 ? 串联三个可能失败的函数。
  3. 为自定义错误实现 Display 和 Error。
  4. 用 Box<dyn Error> 改写一个错误类型复杂的函数。

运行输出(节选)

text
=== 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 泛型函数#

rust
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 泛型结构体#

rust
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 多个类型参数#

rust
struct Pair<T, U> {
    first: T,
    second: U,
}

fn make_pair<T, U>(a: T, b: U) -> (T, U) {
    (a, b)
}

11.4 泛型枚举#

rust
enum MyOption<T> {
    Some(T),
    None,
}

enum MyResult<T, E> {
    Ok(T),
    Err(E),
}

标准库的 Option 和 Result 就是泛型枚举。

11.5 trait bound 与 where#

rust
fn print_all<T: Display>(items: &[T]) { }

fn notify<T>(item: &T)
where
    T: Summary + Display,
{ }

多个 bound 用 + 连接。where 子句适合约束较多、签名较长的情况。

11.6 const 泛型#

rust
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#

rust
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 练习#

  1. 写一个泛型 min_max 函数。
  2. 定义 Pair<T, U>,实现 swap 方法。
  3. 用 where 改写一个 bound 较多的函数。
  4. 用 const 泛型实现固定长度数组求和。

运行输出(节选)

text
=== 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 定义与实现#

rust
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 可以提供默认实现,实现者可以重写:

rust
trait Greet {
    fn name(&self) -> String;

    fn greet(&self) -> String {
        format!("Hello, {}!", self.name())
    }
}

12.3 trait 作为参数#

rust
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 对象与动态分发#

rust
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 关联类型#

rust
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:

rust
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#

rust
trait Animal {
    fn name(&self) -> String;
}

trait Pet: Animal {
    fn cuddle(&self) -> String {
        format!("抚摸 {}", self.name())
    }
}

实现 Pet 之前必须先实现 Animal。

12.8 blanket impl#

rust
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 的。

rust
// 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#

rust
#[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 练习#

  1. 定义 Shape trait,为圆和矩形实现 area。
  2. 用默认方法实现 describe。
  3. 用 Box<dyn Trait> 存放多种形状并计算总面积。
  4. 用 newtype 为 Vec<i32> 实现自定义 Display。

运行输出(节选)

text
=== 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 为什么需要生命周期#

rust
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

编译器需要知道返回值和哪个参数绑定。'a 表示 x、y、返回值至少共享同一个有效区间。

生命周期是编译期检查,不产生运行时开销。

生命周期示意图:x、y 和返回值的有效区间
返回值的有效期取输入中较短的那个;x 本身可以活得更久。

13.2 多个输入生命周期#

rust
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 结构体持有引用#

rust
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 生命周期省略规则#

  1. 每个输入引用参数各自获得一个生命周期。
  2. 如果只有一个输入生命周期,它被赋给所有输出生命周期。
  3. 如果有多个输入,但其中一个是 &self / &mut self,则 self 的生命周期被赋给所有输出。
rust
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#

rust
let s: &'static str = "我活在只读数据段里";

'static 表示整个程序运行期间都有效。字符串字面量天然是 &'static str。

Box::leak 可以故意泄漏内存,得到 'static 引用,但这不是常规做法。

注意:'static 是“可以活这么久”,不是“必须活这么久”。

13.6 方法中的生命周期#

rust
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 练习#

  1. 写一个返回两个字符串中较长者的函数。
  2. 定义 Excerpt<'a>,保存一段文本的引用。
  3. 解释为什么 fn f(x: &str, y: &str) -> &str 需要手写生命周期。
  4. 用 'static 定义一个全局字符串常量。

运行输出(节选)

text
=== 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 闭包基础#

rust
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 捕获环境#

rust
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 闭包#

rust
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 迭代器的惰性#

rust
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 到不同容器#

rust
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 自定义迭代器#

rust
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#

rust
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 练习#

  1. 用闭包实现一个计数器。
  2. 用 filter_map 解析一组可能失败的字符串。
  3. 用 fold 实现 sum 和 product。
  4. 实现一个生成斐波那契数列的迭代器。

运行输出(节选)

text
=== 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#

rust
let boxed = Box::new(5);
println!("{}", *boxed);

enum List {
    Cons(i32, Box<List>),
    Nil,
}

Box<T> 把值分配到堆上,栈上只留一个指针。适合递归类型和大型数据。

15.2 Deref 与 Drop#

rust
use std::ops::Deref;

struct MyBox<T>(T);

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

实现 Deref 后可以使用 * 解引用,并享受解引用强制转换。

rust
struct Droppable(&'static str);

impl Drop for Droppable {
    fn drop(&mut self) {
        println!("drop {}", self.0);
    }
}

Drop 在离开作用域时自动执行。drop 顺序与声明顺序相反。

15.3 Rc#

rust
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 打破环:

rust
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#

rust
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>#

rust
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>#

rust
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 练习#

  1. 用 Box 实现一个递归链表。
  2. 为自定义类型实现 Deref 和 Drop。
  3. 用 Rc<RefCell<Vec<i32>>> 让两个变量修改同一个 Vec。
  4. 用 Arc<Mutex<i32>> 启动 4 个线程各加 1000 次。

运行输出(节选)

text
=== 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 第一个单元测试#

rust
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 与忽略测试#

rust
#[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#

rust
#[test]
fn divide_by_zero_returns_err() {
    let error = divide(10.0, 0.0).unwrap_err();
    assert!(error.contains("zero"));
}

测试函数本身也可以返回 Result<(), E>,用 ? 传播错误:

rust
#[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 中):

rust
// 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 调试工具#

bash
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 练习#

  1. 为 divide 写正常、除零、负数三个测试。
  2. 用 #[should_panic(expected = "...")] 测试一个 panic。
  3. 把测试移到 tests/ 目录,观察私有函数是否还能访问。
  4. 用 dbg! 找出一个循环的中间值。

运行输出(节选)

text
=== 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 线程基础#

rust
use std::thread;

let handle = thread::spawn(|| {
    println!("子线程");
    42
});

let result = handle.join().unwrap();
println!("{result}");

spawn 创建线程,join 等待线程结束并取回返回值。线程 panic 时,join 返回 Err。

17.2 move 闭包#

rust
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>#

rust
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 消息传递#

rust
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#

rust
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 / Sync
  • Arc<T> 在 T: Send + Sync 时是 Send / Sync
  • Mutex<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 练习#

  1. 启动 4 个线程,各自计算一段区间之和,再合并结果。
  2. 用 mpsc 实现一个简单的生产者-消费者模型。
  3. 用 thread::scope 并行修改一个 Vec 的不同部分(注意索引不重叠)。
  4. 解释为什么 Rc<RefCell<T>> 不能直接跨线程。

运行输出(节选)

text
=== 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! 基本语法#

rust
macro_rules! square {
    ($x:expr) => {
        ($x) * ($x)
    };
}

println!("{}", square!(4)); // 16
  • macro_rules! 定义声明宏
  • $x:expr 匹配一个表达式片段
  • => 后面是展开后的代码
  • 多个规则用 ; 分隔

18.3 重复模式#

rust
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 多条规则与可选参数#

rust
macro_rules! greet {
    ($name:expr) => {
        println!("Hello, {}!", $name)
    };
    ($name:expr, $times:expr) => {
        for _ in 0..$times {
            println!("Hello, {}!", $name);
        }
    };
}

宏按规则顺序匹配,因此更具体的规则要放在前面。

18.5 用宏生成结构体#

rust
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 练习#

  1. 写一个 max! 宏,比较两个表达式。
  2. 写一个 hashmap! 宏,支持 key => value 语法。
  3. 用 macro_rules! 生成一个带 new 方法的结构体。
  4. 解释 square!(1 + 2) 为什么要给参数加括号。

运行输出(节选)

text
=== 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#

rust
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 是惰性的#

rust
let future = add(1, 1); // 只是创建 Future
println!("还没有执行");
let value = block_on(future); // 这时才真正运行

Future 是一个状态机:每次 poll 推进一点,返回 Poll::Ready 或 Poll::Pending。

Future 状态机:创建、poll、Pending、wake、Ready
Future 创建后不会自动执行;执行器 poll,Pending 时注册 waker,唤醒后再次 poll。

19.3 最小 block_on#

rust
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#

rust
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:

toml
[dependencies]
tokio = { version = "1", features = ["full"] }
rust
#[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 练习#

  1. 写两个 async fn,用 .await 串联。
  2. 用 block_on 运行一个 async 块。
  3. 解释 Future 为什么是惰性的。
  4. 比较 thread::spawn 和 tokio::spawn 的适用场景。

运行输出(节选)

text
=== 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 允许五类额外操作:

  1. 解引用裸指针
  2. 调用 unsafe 函数或方法
  3. 访问或修改可变静态变量
  4. 实现 unsafe trait
  5. 访问 union 字段

unsafe 不会关闭借用检查,也不会让代码自动变快。它只是把安全责任从编译器转移给程序员。

20.2 裸指针#

rust
let mut value = 42;
let pointer: *mut i32 = &mut value;

unsafe {
    *pointer += 1;
    println!("{}", *pointer);
}

裸指针可以绕过借用规则,但越界、悬垂、别名都是未定义行为(UB)。使用前必须确认指针有效、对齐、可解引用。

20.3 unsafe fn 与 Safety 注释#

rust
/// # 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 函数#

rust
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#

rust
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 练习#

  1. 用裸指针交换两个变量的值。
  2. 写一个 unsafe fn,并补全 Safety 注释。
  3. 调用 libc 的 abs 或 strlen(注意 C 字符串)。
  4. 把 unsafe 代码封装成一个安全函数,并写测试。

运行输出(节选)

text
=== 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#

运行:

bash
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 需求#

实现一个命令行词频统计工具:

  1. 从命令行参数读取文件路径
  2. 没有参数时使用内置文本
  3. 统计每个单词出现次数
  4. 按次数降序、单词升序输出 Top 10
  5. 文件读取失败时打印错误并以非零状态退出

21.2 读取参数与文件#

rust
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 统计词频#

rust
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 排序与输出#

rust
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 错误处理与退出码#

rust
// 节选:match 的 Err 分支
Err(error) => {
    eprintln!("读取 {} 失败:{error}", args[1]);
    process::exit(1);
}
  • 用户输入/文件错误用 Result 和 eprintln!
  • 无法继续时用 process::exit(1)
  • 不要把错误信息写到 stdout,避免污染数据输出

21.6 测试#

rust
#[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 练习与扩展#

  1. 支持 --top N 参数,自定义输出数量。
  2. 增加停用词表,过滤 the、a、is 等。
  3. 输出 JSON 格式,方便其他程序消费。
  4. 把结果写入文件,并处理写入错误。

运行输出(节选)

text
词频 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 块

读错误的三步法

  1. 看错误码和第一行信息:编译器通常直接告诉你“不能做什么”。
  2. 看 help: 和 note::大多数错误都有修复建议。
  3. 看箭头指向的变量:问自己“它现在被谁拥有/借用?借用持续到什么时候?”

编译器报错实战#

真实项目里,读错误信息比背语法更重要。下面保留编译器输出的关键部分,练习从 error[Exxxx]、help: 和箭头里找答案。

案例 1:移动后继续使用(E0382)#

rust
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("{s1}"); // ❌ s1 已被移动
    println!("{s2}");
}
text
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)#

rust
fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    v.push(4); // ❌ first 仍在借用
    println!("{first}");
}
text
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)#

rust
struct Counter;

impl Counter {
    fn bump(&mut self) {}
}

fn main() {
    let counter = Counter;
    counter.bump(); // ❌ counter 不是 mut
}
text
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)#

rust
fn make() -> &String {
    let s = String::from("local");
    &s // ❌ 返回局部变量的引用
}
text
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(移交所有权),或让调用者传入一个引用并标注生命周期。

读错误的三步法#

  1. 先看 error[Exxxx] 和第一行:它通常直接说明“哪个值、哪条规则”。
  2. 再看 help::编译器经常给出可直接套用的改法。
  3. 最后看箭头:问自己“这个值现在归谁所有?借用持续到什么时候?”

十个常见坑#

  1. 忘记 mut:let x = 1; x = 2; 会报 E0384。需要修改就写 let mut x。
  2. 移动后继续使用:let b = a; 后 a 可能已失效(非 Copy 类型)。用 clone 或传引用。
  3. 同时可变借用和不可变借用:借用规则是“多个 &T 或一个 &mut T”,不能同时存在。
  4. 返回局部变量的引用:局部变量在函数结束时释放,返回 &local 会报 E0515。返回 String 或调整所有权。
  5. 字符串按字节切片:&text[..7] 可能切在 UTF-8 字符中间。用 chars() 或确保边界。
  6. 用 == 比较浮点数:0.1 + 0.2 != 0.3。用误差范围或 approx 类库。
  7. HashMap 无序:遍历顺序不固定。需要顺序时用 BTreeMap 或先排序。
  8. 滥用 unwrap():遇到 None / Err 会 panic。生产代码用 match、? 或默认值。
  9. Rc 循环引用:两个 Rc 互相指向会泄漏。用 Weak 打破环。
  10. 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 修改一个整数,并让两个断言通过。

参考实现
rust
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) 是否失败。

参考实现
rust
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。

参考实现
rust
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;让两个断言通过。

参考实现
rust
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 一份;让三个断言通过。

参考实现
rust
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。

参考实现
rust
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 函数,让正方形面积断言通过。

参考实现
rust
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。

参考实现
rust
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。

参考实现
rust
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;让两个断言通过。

参考实现
rust
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。

参考实现
rust
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#

  • 定义 Shape trait,为圆、矩形实现 area。
  • 默认方法可以调用其他必须实现的方法。
  • Vec<Box<dyn Shape>> 存放不同形状。
  • newtype 包装 Vec<i32> 后实现 Display。

验收任务

定义 Area trait,为 Square 实现,让 Square(3.0).area() == 9.0。

参考实现
rust
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 中较长者,并让断言通过。

参考实现
rust
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;让两个断言通过。

参考实现
rust
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 指向同一份数据。

参考实现
rust
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 让测试通过。

参考实现
rust
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。

参考实现
rust
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。

参考实现
rust
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。

参考实现
rust
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。

参考实现
rust
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 可用于任意 T
  • RefCell<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 个单元测试:正常值、边界值、错误输入。

验收

bash
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 无警告 模块拆分合理
文档 有运行命令 有示例输入输出 有设计取舍说明

完成定义#

一个项目算“完成”,需要同时满足:

  1. cargo fmt --check、cargo clippy --all-targets、cargo test 通过;
  2. 有一条命令能复现主要功能;
  3. 你能不看代码,向别人解释数据流和错误路径;
  4. 至少写下一个“下次会怎么改”的改进点。

进阶支线:从标准库到生态#

本课程刻意不引入第三方依赖,保证 cargo run 离线可用。真正做项目时,通常会加下面几类 crate。它们不放进主项目,而是作为独立支线练习。

方向 常用 crate 解决什么问题
异步运行时 tokio 异步 I/O、定时器、任务调度
序列化 serde、serde_json、toml 结构体与 JSON/TOML 互转
命令行 clap 参数、子命令、帮助信息
错误处理 thiserror、anyhow 库错误定义 / 应用层错误汇总
日志 tracing、log 结构化日志与诊断
Web axum、actix-web HTTP 服务

异步支线:Tokio#

toml
[dependencies]
tokio = { version = "1", features = ["full"] }
anyhow = "1"
rust
#[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#

toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rust
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#

toml
[dependencies]
clap = { version = "4", features = ["derive"] }
rust
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#

toml
[dependencies]
thiserror = "2"
anyhow = "1"
rust
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 汇总。

怎么练#

  1. 新建一个独立 crate:cargo new advanced-lab。
  2. 每次只加一类依赖,先跑通最小示例,再迁移第 21 课的一部分。
  3. 给每个支线写 2 个测试,再提交。
  4. 不要把 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、网络、进程、信号 进阶

推荐下一步

  1. 以第 21 课的词频工具为基础,用 clap 改造参数解析。
  2. 用 serde 增加 JSON 输出,用 thiserror 定义错误。
  3. 用 cargo test 给项目补集成测试。
  4. 学习 Tokio,把阻塞 I/O 改成异步任务。
  5. 阅读《The Rust Programming Language》和 std 文档,参与开源项目。

附录:项目结构与运行方式#

目录结构#

text
.
├── 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

运行方式#

bash
# 新建项目
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。

bash
# 启动本地服务器
python3 -m http.server 8000 --directory docs/web

然后访问 http://localhost:8000。修改 Markdown 后重新生成(首次先安装 Python 依赖):

bash
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! 🦀