第1章:Rust 概述与环境搭建
Rust 是一门追求“安全与性能兼得”的系统编程语言:没有垃圾回收,却能在编译期杜绝整类内存错误。本章带你了解 Rust 为何存在、它的核心特性,以及如何搭建开发环境并跑通第一个程序。
学习目标
- 理解 Rust 的设计理念与核心特性。
- 用
rustup安装并管理 Rust 工具链。 - 用
cargo创建、构建、运行项目。 - 了解编译与发布构建的区别。
1.1 为什么是 Rust
Rust 诞生于 Mozilla(2006 年起步,2010 年公开),目标是同时拥有 C++ 的性能与控制力,又不再被内存 bug 折磨。它的三大支柱是:
- 内存安全:所有权(ownership)、借用(borrowing)、生命周期在编译期检查,杜绝空指针、悬垂引用、缓冲区溢出、数据竞争——无需垃圾回收,也无需手动
free。 - 零成本抽象:高层抽象(迭代器、泛型、trait)编译后与手写底层代码一样快。
- 无畏并发:同一套所有权规则也在编译期防止数据竞争,让你放心写多线程代码。
代价是学习曲线——借用检查器起初会“拒绝”你写的代码,但它拒绝的正是真实 bug。掌握之后,这套约束会变成可靠的重构保障。
1.2 安装工具链
Rust 官方用 rustup 管理工具链。在 macOS/Linux 上:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Windows 用户下载 rustup-init.exe 即可。安装后重启 shell,验证:
rustc --version
cargo --version
rustup 让你轻松切换工具链、添加交叉编译目标、安装组件(如 rustfmt、clippy):
rustup update # 更新到最新稳定版
rustup component add clippy rustfmt
rustup target add wasm32-unknown-unknown # 添加 WebAssembly 目标
提示:日常开发用
stable通道即可。想尝鲜新特性可用nightly,但不要在生产依赖它。
1.3 第一个程序:Hello, Cargo
cargo 是 Rust 的构建工具与包管理器,几乎所有 Rust 项目都从它开始:
cargo new hello_rust
cd hello_rust
生成的目录结构:
hello_rust/
├── Cargo.toml # 项目清单(依赖、元数据)
└── src/
└── main.rs # 源码入口
src/main.rs 默认内容:
fn main() { println!("Hello, world!"); }
构建并运行:
cargo run
# 输出:Hello, world!
cargo run 会先编译再运行。cargo build 只编译不运行;cargo check 只做类型检查不生成二进制——开发时反馈最快的命令。
1.4 Cargo 基础
Cargo.toml 是项目的清单:
[package]
name = "hello_rust"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
edition:语言版本(2015/2018/2021)。新项目用2021。[dependencies]:声明依赖。cargo会从 crates.io 拉取并写进Cargo.lock锁定版本。
常用命令一览:
| 命令 | 作用 |
|---|---|
cargo new <name> | 新建二进制项目 |
cargo new --lib <name> | 新建库项目 |
cargo build | 编译(调试构建) |
cargo build --release | 优化构建,用于发布/基准测试 |
cargo run | 编译并运行 |
cargo check | 仅类型检查(最快) |
cargo test | 运行所有测试 |
cargo fmt | 格式化代码 |
cargo clippy | 运行 lint |
cargo doc --open | 生成并打开文档 |
调试 vs 发布:默认
cargo build是调试构建(opt-level = 0,编译快、含调试信息)。基准测试或部署必须用--release,否则结果没有代表性。
1.5 一个稍大的例子
用一个函数体会 Rust 的风格——显式类型、表达式语义、零成本抽象:
fn main() { let numbers = vec![1, 2, 3, 4, 5, 6]; // 用迭代器组合子:过滤偶数、翻倍、求和 let result: i32 = numbers .iter() .filter(|&&n| n % 2 == 0) .map(|&n| n * 2) .sum(); println!("偶数翻倍之和 = {result}"); // 4 + 8 + 12 = 24 }
这段代码读起来像数学公式,编译后却与手写循环一样快。这就是“零成本抽象”的直观体现——后面章节会拆解每个机制。
1.6 工具链与生态一览
- rust-analyzer:IDE 后端,给 VS Code / Vim / Emacs 提供补全、跳转、内联类型提示。装上它,Rust 的开发体验会有质变。
- rustfmt:官方格式化器,消除代码风格争论。
- clippy:lint 工具,捕捉一长串常见错误与不地道写法。
- crates.io:包仓库。
cargo add <crate>即可引入依赖。 - docs.rs:每个发布到 crates.io 的 crate 都有自动生成的文档。
1.7 小结
Rust 用所有权在编译期保证内存安全与并发安全,用零成本抽象让高层代码不牺牲性能。rustup 管理工具链,cargo 管理项目与依赖,cargo check/run/test 是日常三件套。装好 rust-analyzer、rustfmt、clippy,你就有了顺手的开发环境。
练习
- 用
cargo new创建一个项目,写一个函数返回斐波那契数列前 N 项,用cargo run与cargo test验证。 - 给项目加一个依赖(如
rand),用cargo doc --open查看生成的文档。 - 故意写一段会被
clippy警告的代码(如多余的return),运行cargo clippy并修正。
第2章:变量、数据类型与控制流
本章是 Rust 语法的基本功:如何声明变量、有哪些数据类型、如何用控制流与函数组织逻辑。这些是后面所有章节的基石——尤其是“默认不可变”这一设计决定,它会贯穿你写的每一行 Rust。
学习目标
- 用
let声明变量,理解可变(mut)与不可变、以及变量遮蔽。 - 掌握标量类型(整数、浮点、布尔、字符)与复合类型(元组、数组)。
- 理解字符串:
String与&str的区别。 - 用
if/loop/while/for与模式匹配写控制流。 - 定义函数,理解表达式语义与返回值。
2.1 变量与可变性
Rust 用 let 声明变量,默认不可变:
fn main() { let x = 5; // x = 6; // 错误:x 不可变 println!("{x}"); let mut y = 5; y = 6; // OK:y 用 mut 声明,可变 println!("{y}"); }
默认不可变是有意为之:它让代码更可预测,编译器也能据此做更多优化。当你确实需要修改时,显式写 mut——它是一个“这里状态会变”的信号。
变量遮蔽(shadowing)
可以用同一个名字重新声明变量,新变量会遮蔽旧变量。遮蔽还能改变类型:
fn main() { let x = 5; let x = x + 1; // 用旧值算新值 let x = x * 2; // {x} = 12 let spaces = " "; // &str let spaces = spaces.len(); // usize——类型也变了 println!("{x} {spaces}"); }
mutvs 遮蔽:mut改的是同一个变量的值,类型不能变;遮蔽是新建一个变量,可以变类型。把字符串转成长度时用遮蔽很自然,用mut做不到。
常量
const 与不可变变量不同:它编译期求值、必须标注类型、全大写、可在任意作用域声明:
#![allow(unused)] fn main() { const MAX_POINTS: u32 = 100_000; }
2.2 标量类型
| 类型 | 含义 | 示例 |
|---|---|---|
i8…i128, isize | 有符号整数 | -5, 42 |
u8…u128, usize | 无符号整数 | 0, 255 |
f32, f64 | 浮点数 | 3.14, 2.0 |
bool | 布尔 | true, false |
char | Unicode 标量值(4 字节) | 'A', '中', '🦀' |
fn main() { let a: i32 = -42; let b: u64 = 1_000_000; // 下划线分隔,提升可读性 let c: f64 = 2.71828; let flag: bool = true; let heart: char = '🦀'; println!("{a} {b} {c} {flag} {heart}"); }
整数字面量的类型:
42默认推断为i32。若上下文需要别的类型,标注即可:let n: u8 = 42;。整数溢出在调试构建会 panic,在 release 构建会回绕——要紧时用checked_*、wrapping_*、saturating_*方法显式处理。
2.3 复合类型:元组与数组
元组(tuple)把多个不同类型的值固定在一起,长度不可变:
fn main() { let tup: (i32, f64, &str) = (500, 6.4, "hello"); let (x, _, s) = tup; // 解构 println!("{x} {s}"); println!("{}", tup.0); // 索引访问 }
数组(array)是固定长度、同类型、栈上连续存储:
fn main() { let arr = [1, 2, 3, 4, 5]; let zeros = [0; 10]; // 10 个 0 println!("first = {}, len = {}", arr[0], arr.len()); // 越界访问在运行时 panic(调试构建),不会像 C 那样读越界内存 // let oob = arr[10]; // panic }
数组 vs
Vec:数组长度编译期固定,适合小而确定的集合;运行时可增长用Vec(第 7 章)。
2.4 字符串:String 与 &str
Rust 有两种字符串,初学者常被绊倒:
&str:字符串切片,是对某处 UTF-8 字节序列的借用。字面量"hello"是&'static str。String:堆分配、可增长、 owned 的字符串。
fn main() { let literal: &str = "hello"; // 借用,不可变 let mut owned = String::from("hello"); // 堆上,可增长 owned.push_str(", world"); owned.push('!'); // 互转 let from_slice: String = literal.to_string(); let to_slice: &str = &owned; println!("{owned} {from_slice} {to_slice}"); }
经验法则:函数参数优先用 &str(既能接 &str 也能接 &String);需要拥有、修改或返回时用 String。
2.5 控制流
if 是表达式
if 有返回值,所有分支类型必须一致:
fn main() { let n = 7; let label = if n % 2 == 0 { "偶" } else { "奇" }; println!("{label}"); if n > 10 { println!("大"); } else if n > 3 { println!("中"); } else { println!("小"); } }
循环:loop、while、for
fn main() { // loop:无限循环,可用 break 返回值 let mut count = 0; let result = loop { count += 1; if count == 10 { break count * 2; } }; println!("{result}"); // 20 // while:条件循环 let mut n = 3; while n > 0 { n -= 1; } // for:迭代集合,最常用 for x in [1, 2, 3] { println!("{x}"); } for i in 0..5 { print!("{i} "); } // 0 1 2 3 4 for i in (1..=3).rev() { print!("{i} "); } // 3 2 1 }
for 配合范围 a..b(半开)与 a..=b(闭区间)使用。Rust 里几乎不用 while 索引循环——用迭代器更安全。
2.6 函数
函数用 fn 定义,参数需标注类型。Rust 是表达式语言:不写 return 时,最后一个表达式(无分号)就是返回值:
fn add(a: i32, b: i32) -> i32 { a + b // 表达式,是返回值 } fn greet(name: &str) { // 无 -> 表示返回单元类型 () println!("hi, {name}"); } fn abs(x: i32) -> i32 { if x < 0 { -x } else { x } // if 表达式作为返回值 } fn main() { greet("alice"); println!("{} {}", add(2, 3), abs(-7)); }
语句 vs 表达式:
let x = 5;是语句(无值);x + 1是表达式(有值)。函数体里加分号就把表达式变成了语句——返回值就丢了。初学者常见的“漏掉返回值”错误,多半是多写了一个分号。
语句与发散函数
不返回值的函数返回单元类型 ()。永不返回的函数标 -> !(发散):
#![allow(unused)] fn main() { fn forever() -> ! { loop {} } }
2.7 小结
Rust 变量默认不可变,需要变时显式 mut;遮蔽允许复用名字甚至改类型。标量与复合类型是基础,字符串要分清 String(拥有)与 &str(借用)。if 与 loop 是表达式,函数以最后一个无分号表达式作返回值。这些规则简单,却支撑了后面所有权、泛型、错误处理等所有主题。
练习
- 写一个函数
fizzbuzz(n: u32),按经典 FizzBuzz 规则打印 1 到 n。 - 用元组从一个函数同时返回商与余数:
fn divmod(a: i32, b: i32) -> (i32, i32)。 - 用
for与范围计算 1 到 100 的整数和,体会为何不必用while索引循环。
第3章:所有权与借用
所有权是 Rust 最独特的特性,也是它无需垃圾回收却保证内存安全的根基。本章讲清三条所有权规则、借用与借用检查器、生命周期,以及切片。理解了这一章,你就能读懂编译器的报错,并明白它为何这样要求你。
学习目标
- 掌握所有权的三条规则与 move 语义。
- 理解借用:共享引用
&T与可变引用&mut T。 - 掌握借用的规则与“同一时刻多个引用”的限制。
- 用生命周期标注让引用之间的关系显式化。
- 用切片
&[T]/&str借用一段连续数据。
3.1 所有权的三条规则
Rust 内存管理的核心是三条规则:
- 每个值有且仅有一个所有者——变量。
- 当所有者离开作用域,值被丢弃(析构运行,内存释放)。
- 赋值或传参时,所有权转移(move)——除非类型实现了
Copy。
fn main() { { let s = String::from("hello"); // s 是所有者 println!("{s}"); } // s 离开作用域,String 的内存自动释放——无需 free let s1 = String::from("hello"); let s2 = s1; // 所有权从 s1 move 到 s2 // println!("{s1}"); // 错误:s1 已被 move,不再有效 println!("{s2}"); }
Move 与 Copy
String 拥有堆内存,赋值时是 move——旧变量失效,避免双重释放。栈上的简单类型(整数、布尔、字符、固定大小数组等)实现了 Copy trait,赋值时是按位拷贝,旧变量仍可用:
fn main() { let a = 5; let b = a; // i32 是 Copy,a 仍可用 println!("{a} {b}"); let s1 = String::from("hi"); let s2 = s1; // String 不是 Copy,s1 被 move // println!("{s1}"); // 错误 }
函数传参也是 move:把
String传给函数后,调用方就不能再用它。想要“借用而不转移所有权”,就用下一节的引用。
3.2 借用与引用
借用让函数使用值而不获取所有权。&T 是共享引用(只读),&mut T 是可变引用:
fn calculate_length(s: &String) -> usize { s.len() } // s 是借用,这里不释放任何东西 fn append(s: &mut String) { s.push_str("!"); } fn main() { let mut s = String::from("hello"); let len = calculate_length(&s); // 借用,s 仍归 main 所有 println!("{s} 长度 {len}"); append(&mut s); println!("{s}"); // hello! }
借用的两条规则
借用检查器在编译期强制两条规则:
- 任意时刻,可以有多个共享引用
&T,或者一个可变引用&mut T,二者不能并存。 - 引用必须始终有效(不能悬垂)。
#![allow(unused)] fn main() { let mut s = String::from("hello"); let r1 = &s; let r2 = &s; // OK:多个共享引用 // let r3 = &mut s; // 错误:已有共享引用时不能再借可变 println!("{r1} {r2}"); let mut s = String::from("hi"); let r1 = &mut s; // let r2 = &mut s; // 错误:同一时刻只能一个可变引用 println!("{r1}"); }
为何这么严格? 多个可变引用混用正是数据竞争与迭代器失效的根源。编译期拒绝它们,就把一整类并发 bug 提前消灭了。
NLL:非词法生命周期
借用检查器较新(NLL,Non-Lexical Lifetimes)会看引用实际最后一次使用的位置,而非作用域结尾:
#![allow(unused)] fn main() { let mut s = String::from("hello"); let r1 = &s; let r2 = &s; println!("{r1} {r2}"); // r1、r2 此后不再使用 let r3 = &mut s; // OK:旧的共享引用已不再需要 println!("{r3}"); }
3.3 悬垂引用
函数不能返回对局部变量的引用——变量离开函数就被释放,引用会悬垂。编译器会拒绝:
#![allow(unused)] fn main() { // fn dangle() -> &String { // let s = String::from("hi"); // &s // } // 错误:s 在此处释放,返回的引用会悬垂 // 正确做法:直接返回 String,转移所有权 fn no_dangle() -> String { let s = String::from("hi"); s } }
3.4 生命周期
当引用来自多个地方、编译器无法推断它们谁活得更久时,需要生命周期标注显式说明关系。标注不改变引用实际寿命,只是声明约束。
// 'a 表示:返回的引用至少和 x、y 中较短的那个一样长 fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } fn main() { let s1 = String::from("long string"); let s2 = String::from("hi"); let result = longest(s1.as_str(), s2.as_str()); println!("更长的是: {result}"); }
函数中的生命周期省略规则
多数情况下不必手写标注。编译器按三条省略规则自动补:
- 每个引用参数各自获得一个生命周期。
- 若只有一个输入生命周期,它赋给所有输出引用。
- 若有
&self/&mut self,self的生命周期赋给所有输出引用。
不满足时编译器会报错,要求你显式标注——这通常意味着你的 API 需要重新考虑。
结构体里的生命周期
结构体持有引用时必须标注:
struct Excerpt<'a> { part: &'a str, } fn main() { let novel = String::from("call me Ishmael. Some years ago..."); let first = novel.split('.').next().unwrap(); let e = Excerpt { part: first }; println!("{:?}", e.part); }
'a 表示 Excerpt 不能比它借用的字符串活得更久。
3.5 切片
切片是对连续序列的借用,不拥有数据。&[T] 是数组/Vec 的切片,&str 是字符串切片:
fn first_word(s: &str) -> &str { let bytes = s.as_bytes(); for (i, &b) in bytes.iter().enumerate() { if b == b' ' { return &s[..i]; } } &s[..] } fn main() { let s = String::from("hello world"); let word = first_word(&s); // word 借用 s println!("{word}"); // 若这里修改 s,借用检查器会因 word 仍存活而拒绝 }
切片让函数同时适用于 &String、&str、&[T]、&Vec<T>——这是 Rust 写出通用代码的关键之一。
3.6 一个完整例子:所有权流转
把所有权、借用、切片串起来——一个不用拷贝即可统计文本最长行的函数:
fn longest_line<'a>(lines: &'a [&'a str]) -> Option<&'a str> { lines.iter().copied().max_by_key(|l| l.len()) } fn main() { let text = ["short", "a longer line", "mid"]; if let Some(longest) = longest_line(&text) { println!("最长: {longest}"); } }
longest_line 只借用切片、返回借用,没有发生任何堆分配。这正是 Rust 零成本抽象的写照。
3.7 小结
所有权三规则、move 语义、借用的两条铁律、生命周期标注、切片——这些构成了 Rust 内存安全的骨架。借用检查器看似严苛,实则消灭了空指针、悬垂引用、双重释放与数据竞争。它拒绝的不是你的意图,而是你代码里潜藏的 bug。掌握本章,你就跨过了 Rust 最陡的那道坎。
练习
- 解释为何
let s2 = s1;(s1是String)后s1失效,而let b = a;(a是i32)后a仍可用。 - 写一个函数
fn longest_word(s: &str) -> &str,返回输入中第一个最长的单词(用空格分隔)。标注需要的生命周期,体会省略规则。 - 写一个持有
&str的结构体Config<'a>,并构造一个实例,验证它不能比借用的String活得更久。
第4章:结构体与枚举
现实世界的数据很少是孤立的。Rust 用结构体把相关字段组合成自定义类型,用枚举表达“一个值可能是几种形态之一”。配合模式匹配,这两者让你精确建模业务领域,并让非法状态在编译期就无法表达。
学习目标
- 定义结构体、方法与关联函数。
- 用枚举建模“多选一”的数据,理解
Option与Result也是枚举。 - 用
match与if let做模式匹配。 - 用
impl块给类型附加行为。
4.1 结构体
结构体把命名字段组合成一个类型。三种形式:具名结构体、元组结构体、单元结构体:
// 具名结构体——最常用 struct User { name: String, age: u32, active: bool, } // 元组结构体——字段无名字,适合轻量包装 struct Color(i32, i32, i32); // 单元结构体——无字段,常用于 trait struct Marker; fn main() { let u = User { name: "alice".into(), age: 30, active: true }; println!("{} {} {}", u.name, u.age, u.active); let c = Color(255, 128, 0); println!("{} {} {}", c.0, c.1, c.2); }
字段私有性:结构体字段默认私有。在同模块外访问字段需要
pub——详见第 8 章。
字段简写与更新语法
当变量名与字段名相同时可简写;用 .. 复制其余字段:
fn main() { let name = String::from("bob"); let u1 = User { name, age: 25, active: true }; // name 简写 let u2 = User { age: 26, ..u1 }; // 其余字段复制自 u1 println!("{} {}", u2.name, u2.age); }
注意:
..u1会 move 出u1的字段。u1.name已被 move,u1整体此后不可用(除非被复制字段都Copy)。
4.2 方法与关联函数:impl 块
用 impl 给类型附加行为。fn 带 &self/&mut self/self 是方法;不带 self 是关联函数(类似静态方法):
struct Rectangle { width: f64, height: f64, } impl Rectangle { // 关联函数——构造器,类似 Rectangle::new fn new(width: f64, height: f64) -> Self { Rectangle { width, height } } // 方法——借用 self fn area(&self) -> f64 { self.width * self.height } // 方法——可变借用 fn scale(&mut self, factor: f64) { self.width *= factor; self.height *= factor; } } fn main() { let mut r = Rectangle::new(3.0, 4.0); println!("area = {}", r.area()); // 12 r.scale(2.0); println!("area = {}", r.area()); // 48 }
Self 是当前类型的别名。impl 块可以写多个,常用于把方法按功能分组。
4.3 枚举:多选一的值
枚举表示一个值可能是几种变体之一。Rust 的枚举很强——每个变体可以携带不同类型、不同数量的数据:
enum Message { Quit, // 无数据 Move { x: i32, y: i32 }, // 具名字段 Write(String), // 一个值 ChangeColor(i32, i32, i32), // 元组 } fn main() { let m = Message::Write("hello".into()); process(m); } fn process(msg: Message) { // 必须处理所有变体——编译器强制穷尽 match msg { Message::Quit => println!("quit"), Message::Move { x, y } => println!("move to {x},{y}"), Message::Write(text) => println!("write: {text}"), Message::ChangeColor(r, g, b) => println!("color {r},{g},{b}"), } }
枚举 vs 结构体:当一个值“是 A 或 B 或 C”时用枚举;当它“同时有 A 和 B 和 C”时用结构体。
Option<T>:标准库的枚举
Option 用枚举表达“有值或无值”,取代了 null:
#![allow(unused)] fn main() { enum Option<T> { Some(T), None, } }
Rust 里没有 null——要表示可能缺失,就用 Option<T>,编译器强制你处理 None。第 6 章会展开它的错误处理用法。
4.4 模式匹配:match
match 对枚举做穷尽式分支,是 Rust 最强大的控制流之一:
fn describe(n: i32) -> &'static str { match n { 0 => "零", 1..=9 => "个位", 10 | 20 | 30 => "整十", _ if n < 0 => "负数", // 守卫 _ => "其它", } } fn main() { println!("{}", describe(0)); println!("{}", describe(7)); println!("{}", describe(-3)); }
要点:
- 必须穷尽所有可能,
_是通配兜底。 - 分支可以绑定变量(如
Message::Move { x, y }把x/y绑到字段值)。 - 可以加守卫(
if 条件)做额外过滤。
if let:只关心一个分支
只想处理一种情况、忽略其余时,if let 比 match 简洁:
fn main() { let m = Message::Write("hi".into()); if let Message::Write(text) = m { println!("要写: {text}"); } else { println!("不是 Write"); } }
while let 同理,用于循环里反复解构。
4.5 实战:一个状态机
用枚举 + 模式匹配建模订单状态机——非法转换在编译期就无法写出:
enum OrderState { Pending, Paid, Shipped, Delivered, Cancelled, } impl OrderState { fn next(self) -> OrderState { match self { OrderState::Pending => OrderState::Paid, OrderState::Paid => OrderState::Shipped, OrderState::Shipped => OrderState::Delivered, // 已完成或已取消——没有下一个状态 OrderState::Delivered | OrderState::Cancelled => self, } } fn label(&self) -> &'static str { match self { OrderState::Pending => "待支付", OrderState::Paid => "已支付", OrderState::Shipped => "已发货", OrderState::Delivered => "已送达", OrderState::Cancelled => "已取消", } } } fn main() { let mut s = OrderState::Pending; for _ in 0..4 { println!("{}", s.label()); s = s.next(); } }
这个例子体现了枚举的核心价值:把业务规则编码进类型系统,让“已送达又变成待支付”这种非法状态根本无法表达。
4.6 小结
结构体把相关字段组合成自定义类型,枚举表达“多选一”的值,impl 块附加行为,match 做穷尽式分支。Option 用枚举取代了 null,强制你处理缺失。把这些用起来,就能把业务约束编码进类型,让非法状态在编译期无处遁形——这是 Rust 类型安全的设计精髓。
练习
- 定义一个
Point结构体与一个Shape枚举(Circle、Rectangle、Triangle),用match为每种形状计算面积。 - 给
User结构体加一个birthday(&mut self)方法让age加 1,并写一个关联函数User::new(name, age)。 - 用
Option<i32>写一个函数,返回列表中第一个正数;用if let处理结果。
第5章:泛型与特征
泛型与特征是 Rust 最重要的抽象机制:泛型让你写一份代码适用于多种类型,特征定义类型能做什么。二者结合,带来“既灵活又类型安全”的代码,并且——得益于单态化——零运行时开销。
学习目标
- 用泛型函数、泛型结构体写出类型无关的代码。
- 定义与实现特征,理解默认方法。
- 用特征边界约束泛型类型。
- 区分静态分发(泛型)与动态分发(trait object)。
- 用关联类型设计更贴合领域的接口。
5.1 泛型
泛型用一个占位类型 T 代替具体类型,调用时再填入。一个 largest 函数对任何“可比较的切片”都适用:
fn largest<T: PartialOrd>(list: &[T]) -> &T { let mut biggest = &list[0]; for item in &list[1..] { if item > biggest { biggest = item; } } biggest } fn main() { let nums = vec![3, 1, 4, 1, 5, 9, 2, 6]; println!("最大: {}", largest(&nums)); let chars = vec!['a', 'z', 'm']; println!("最大: {}", largest(&chars)); }
泛型结构体与枚举
struct Pair<T> { first: T, second: T, } impl<T> Pair<T> { fn new(first: T, second: T) -> Self { Pair { first, second } } } fn main() { let p = Pair::new(1, 2); println!("{} {}", p.first, p.second); }
Option<T>、Result<T, E>、Vec<T> 本质都是泛型枚举/结构体。
零成本:泛型在编译期单态化——编译器为每个具体类型生成一份专用代码。
largest::<i32>与largest::<char>是两个独立函数,各自可内联,运行时无分发开销。
5.2 特征:类型能做什么
特征定义一组方法签名,类型通过 impl 提供实现,声明“我能做这些事”:
trait Summary { fn summarize(&self) -> String; // 默认方法——实现者可不重写 fn preview(&self) -> String { format!("{}...", &self.summarize()[..self.summarize().len().min(20)]) } } struct Article { title: String, content: String, } impl Summary for Article { fn summarize(&self) -> String { format!("{}: {}", self.title, self.content) } } fn main() { let a = Article { title: "Rust 发布".into(), content: "Rust 2021 edition 已稳定".into(), }; println!("{}", a.summarize()); println!("{}", a.preview()); // 用默认实现 }
特征可以有默认实现,实现者按需覆盖。
5.3 特征边界:约束泛型
泛型 T 默认什么都能做(几乎)。要调用其方法,就用特征边界声明 T 必须实现的 trait:
#![allow(unused)] fn main() { // T: Summary + Display —— T 必须同时实现这两个 trait fn report<T: Summary>(item: &T) { println!("报告: {}", item.summarize()); } }
where 子句
边界多时,where 子句更清晰:
#![allow(unused)] fn main() { fn merge<T, U>(a: &T, b: &U) -> String where T: Summary, U: Summary, { format!("{} | {}", a.summarize(), b.summarize()) } }
impl Trait 语法
参数与返回值可用 impl Trait 简写:
#![allow(unused)] fn main() { // 参数:接任何实现了 Summary 的类型 fn report(item: &impl Summary) { /* ... */ } // 返回:返回某个实现了 Summary 的类型(调用方无需知道具体类型) fn make() -> impl Summary { Article { title: "x".into(), content: "y".into() } } }
返回
impl Trait的限制:只能返回单一具体类型。想返回多种类型要用 trait object(下节)。
5.4 静态分发 vs 动态分发
泛型 + 特征边界是静态分发:编译期单态化,每个具体类型一份代码,调用直接、可内联。代价是二进制体积略大。
当你需要在运行时持有“多种不同类型”的值(如一个 Vec 装不同种 Summary),就要动态分发——trait object:
fn main() { // &dyn Summary 是 trait object:运行时通过虚表分发 let items: Vec<Box<dyn Summary>> = vec![ Box::new(Article { title: "a".into(), content: "b".into() }), ]; for it in &items { println!("{}", it.summarize()); } }
| 方式 | 分发 | 开销 | 能否装多种类型 |
|---|---|---|---|
泛型 T: Trait | 静态(单态化) | 无 | 否(一类型一份) |
&dyn Trait / Box<dyn Trait> | 动态(虚表) | 一次间接调用 | 是 |
经验法则:能用泛型就用泛型(更快);必须运行时多态才用 dyn。
5.5 关联类型
关联类型让 trait 持有一个“由实现者决定”的类型位,比泛型参数更贴合领域语义。Iterator 是经典例子:
trait Iterator { type Item; // 关联类型 fn next(&mut self) -> Option<Self::Item>; } struct Counter { count: u32 } impl Iterator for Counter { type Item = u32; // Counter 产出 u32 fn next(&mut self) -> Option<u32> { self.count += 1; if self.count <= 5 { Some(self.count) } else { None } } } fn main() { for n in Counter { count: 0 } { println!("{n}"); } }
关联类型与泛型参数的区别:一个类型对一个 trait 只能有一份 impl(关联类型固定),而泛型 trait 可以有多份 impl(每套类型参数一份)。Iterator 用关联类型,因为“一个迭代器产出什么”是确定的。
5.6 小结
泛型用占位类型写出类型无关代码,编译期单态化、零成本;特征定义“类型能做什么”,用特征边界约束泛型。静态分发(泛型)快但不能运行时多态,动态分发(dyn Trait)灵活但有虚表开销。关联类型让 trait 的接口更贴合领域。这套机制是 Rust 既能抽象又不损失性能的关键。
练习
- 写一个泛型函数
fn first<T>(v: &[T]) -> Option<&T>,返回切片首元素。 - 定义一个
Drawabletrait(fn draw(&self)),为两个不同结构体实现它,用Vec<Box<dyn Drawable>>持有并遍历。 - 给上面的
Counter加一个take_n方法(用impl Iterator返回值),体会关联类型如何随迭代器传播。
第6章:错误处理
错误处理是 Rust 与主流语言分道扬镳最明显之处,也是它“无畏”名声的来源。Rust 不抛异常,而是让失败的可能性显式出现在函数签名里,编译器逼你在编译期决定“出错时怎么办”。代价是更多的类型标注,回报是大量在别处运行时才暴露的崩溃,在这里被挡在了编译期之外。
学习目标
- 区分可恢复错误(
Result)与不可恢复错误(panic!)。 - 用
Option<T>表示“值的缺失”。 - 用
Result<T, E>与模式匹配处理预期失败。 - 用
?操作符优雅地传播错误。 - 用
Fromtrait 转换错误类型。 - 用
thiserror设计自定义错误类型,在应用层选用anyhow。 - 在异步代码中应用错误处理最佳实践。
6.1 两类错误
Rust 把错误分成两族,本章一切皆从此而来:
| 类别 | 类型 | 含义 | 示例 |
|---|---|---|---|
| 不可恢复 | panic! | bug 或不变量被破坏,程序无法安全继续 | 下标越界、除零、Mutex 中毒 |
| 可恢复 | Result<T, E> | 预期内会发生的失败,调用方可反应 | 文件不存在、网络超时、格式错误 |
心智模型:panic 是程序在说“出了我无法修的问题,立刻停”;Result 是函数在说“这事可能失败——给你值,或给你失败原因,你来定”。现实里多数失败是可恢复的,所以你的错误处理大多用 Result。
panic!:真出问题了
panic 会展开栈(或中止)并结束当前线程。用它表示正确代码里绝不应该发生的情况:
fn main() { let nums = [10, 20, 30]; let v = nums[5]; // 越界——逻辑 bug,Rust panic println!("{v}"); }
unwrap() 与 expect() 是会 panic 的快捷方式。原型与测试里很好用,生产路径里危险——它把可恢复失败变成崩溃:
#![allow(unused)] fn main() { let n: i32 = "42".parse().unwrap(); // 解析失败会 panic let m: i32 = "abc".parse().expect("输入必须是整数"); // 带上下文 }
经验法则:
unwrap/expect适合原型和测试。处理用户输入或外部系统时,用?和Result。
6.2 Option<T>:值的缺失
先看“缺失”。当函数可能合理地返回“无值”(不是失败,就是没有)时,用 Option<T>:
fn find_user(users: &[&str], name: &str) -> Option<&str> { for u in users { if *u == name { return Some(u); } } None } fn main() { let users = ["alice", "bob", "carol"]; match find_user(&users, "bob") { Some(name) => println!("找到 {name}"), None => println!("无此用户"), } // 组合子——简洁且空安全 let upper = find_user(&users, "alice").map(str::to_uppercase); println!("{upper:?}"); // Some("ALICE") let display = find_user(&users, "zoe").unwrap_or("guest"); println!("{display}"); // guest }
常用 Option 组合子:map、and_then、unwrap_or、unwrap_or_default、is_some/is_none。优先用组合子而非层层嵌套 match。
6.3 Result<T, E>:可恢复失败
Result 是 Rust 错误处理的主力,本质是个枚举:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
会失败的函数返回 Result 而非 panic。读文件是经典例子:
use std::fs; fn read_config(path: &str) -> Result<String, std::io::Error> { fs::read_to_string(path) } fn main() { match read_config("config.toml") { Ok(contents) => println!("配置已加载:\n{contents}"), Err(e) => eprintln!("读取失败: {e}"), } }
错误类型 io::Error 具体且信息丰富。match 让你按失败种类分支:
use std::io::{self, fs}; fn main() { match fs::read_to_string("missing.txt") { Ok(_) => println!("读取成功"), Err(e) => match e.kind() { io::ErrorKind::NotFound => eprintln!("文件不存在"), io::ErrorKind::PermissionDenied => eprintln!("无权限"), _ => eprintln!("其它 io 错误: {e}"), }, } }
6.4 ? 操作符:干净的传播
每个错误都 match 会很啰嗦。? 是传播错误的地道写法:“成功就继续;失败就立刻把错误返回给调用者。”
#![allow(unused)] fn main() { use std::fs; use std::io; fn read_config(path: &str) -> Result<String, io::Error> { let contents = fs::read_to_string(path)?; // 出错则传播 Ok(contents.trim().to_string()) } }
? 对 Result 和 Option 都适用。
串联多个可失败步骤
? 让一连串可失败步骤读起来像直线代码:
#![allow(unused)] fn main() { use std::fs; use std::io; fn load_and_parse(path: &str) -> Result<i32, io::Error> { let text = fs::read_to_string(path)?; let value: i32 = text.trim().parse().map_err(|e| { // 把解析错误转成 io::Error,让签名对齐 io::Error::new(io::ErrorKind::InvalidData, e) })?; Ok(value * 2) } }
map_err 在 ? 无法自动转换时用来适配错误类型(见下节)。
6.5 用 From 转换错误
? 还做一件自动事:若函数的错误类型 E 对内部错误实现了 From,? 会自动转换。这让不同子系统产生的不同错误类型汇聚到一个边界错误类型:
#![allow(unused)] fn main() { use std::fs; use std::io; use std::num::ParseIntError; #[derive(Debug)] enum AppError { Io(io::Error), Parse(ParseIntError), } // 这些转换让 `?` 无需 map_err 即可工作 impl From<io::Error> for AppError { fn from(err: io::Error) -> Self { AppError::Io(err) } } impl From<ParseIntError> for AppError { fn from(err: ParseIntError) -> Self { AppError::Parse(err) } } fn load_number(path: &str) -> Result<i32, AppError> { let text = fs::read_to_string(path)?; // io::Error 自动转 AppError let n: i32 = text.trim().parse()?; // ParseIntError 自动转 AppError Ok(n) } }
手写 From 很机械。实践中用 derive 宏代劳——下节。
6.6 用 thiserror 设计自定义错误
库应定义专门的错误枚举,用 thiserror 派生样板(Debug、Display、From):
# Cargo.toml
[dependencies]
thiserror = "1"
#![allow(unused)] fn main() { use std::io; use std::num::ParseIntError; use thiserror::Error; #[derive(Debug, Error)] enum ConfigError { #[error("读取文件失败: {0}")] Io(#[from] io::Error), #[error("配置中数字非法: {0}")] Parse(#[from] ParseIntError), #[error("缺少必需的键: {key}")] Missing { key: String }, } fn load_port(path: &str) -> Result<u16, ConfigError> { let text = std::fs::read_to_string(path)?; // 自动转换 let port: u16 = text.trim().parse()?; if port == 0 { return Err(ConfigError::Missing { key: "port".into() }); } Ok(port) } }
#[from] 生成 From impl,? 直接可用;#[error("...")] 提供人类可读的 Display。这是任何打算复用的代码建模错误的推荐方式。
6.7 thiserror vs anyhow:库 vs 应用
常见困惑:该用哪种错误类型?取决于你是库(被别人调用)还是应用(顶层程序)。
- 库应返回具体、结构化的错误类型,让调用方能 match 并反应。用
thiserror。 - 应用多数时候只想把任何错误打包加上上下文,在顶层报告。用
anyhow——它提供一个能装任何错误的anyhow::Error,与.context(...)方法附加上下文:
[dependencies]
anyhow = "1"
use anyhow::{Context, Result}; use std::fs; fn load_port(path: &str) -> Result<u16> { let text = fs::read_to_string(path) .with_context(|| format!("读取配置文件 {path:?} 失败"))?; let port: u16 = text.trim().parse() .with_context(|| format!("{path:?} 里的 port 不是合法数字"))?; Ok(port) } fn main() -> Result<()> { let port = load_port("config.toml")?; println!("监听 {port}"); Ok(()) }
失败时 anyhow 打印一条链:
Error: "config.toml" 里的 port 不是合法数字
Caused by:
invalid digit found in string
准则:库返回 thiserror 错误;二进制、测试、胶水代码用 anyhow::Result。两者完美组合——anyhow::Error 能包任何实现了 std::error::Error 的错误,thiserror 类型都满足。
6.8 异步代码里的错误处理
async 函数里 ? 的行为完全一样——只是错误经由 Future 传出而非直接返回。唯一要留意的是:当 future 跨线程发送时(多线程 Tokio 运行时),错误类型需 Send。
[dependencies]
tokio = { version = "1", features = ["full"] }
anyhow = "1"
use anyhow::{Context, Result}; use tokio::fs; use tokio::io::AsyncReadExt; async fn read_head(path: &str) -> Result<String> { let mut file = fs::File::open(path) .await .with_context(|| format!("打开 {path:?}"))?; let mut buf = [0u8; 64]; let n = file.read(&mut buf).await.context("读取首字节")?; Ok(String::from_utf8_lossy(&buf[..n]).into_owned()) } #[tokio::main] async fn main() -> Result<()> { let head = read_head("README.md").await?; println!("{head}"); Ok(()) }
模式与同步版一致:每个可失败 .await 后跟 ? 或 .context(...)。把异步错误处理当成“恰好被 .await 打断”的普通 Result 处理即可。
6.9 最佳实践
- 把失败建模进类型系统。 调用方可能想处理的,用
Result而非panic!。 - 让
?传播。 别每个错误都match——?更清晰更短。 - 尽早附加上下文。 用
.context(),让顶层错误说明“你在做什么”,而非只有底层原因。 - 库用结构化错误。
thiserror暴露公开Error枚举,让调用方能 match。 - 应用用
anyhow。 顶层编排与胶水代码用anyhow::Result。 - 生产路径别用
unwrap/expect。 留给测试、示例、真正不可能的状态。 - 别吞错误。 永不写
let _ = fallible();,除非真要忽略——即便如此也加注释。
6.10 小结
Rust 把错误当值。不可恢复 bug 成 panic!,预期失败成 Result。? 让传播简洁,From trait(常经 thiserror)让转换自动,anyhow 让应用代码整洁。结果是一种显式到可推理、又顺手到处处可用的错误处理。
练习
- 写一个
fn parse_pair(s: &str) -> Result<(i32, i32), ParseIntError>,把"3,4"解析成(3, 4);再扩展为自定义错误,能报告“缺少逗号”。 - 用
thiserror定义WeatherError(网络/解析两个变体),写一个异步函数用?拉取并解析类 JSON 字符串。 - 把一个用了三次
unwrap()的函数改写成用?与anyhow::Result,每个可失败步骤加.context()。
第7章:集合类型与数据结构
到目前为止,我们持有的值要么在栈上,要么是固定大小的数组。真实程序需要在运行时动态增长数据——任务队列、记录缓存、唯一 ID 集合。Rust 标准库提供了一组精挑细选的集合类型来满足这些需求。本章覆盖日常使用最多的三种——Vec、HashMap、HashSet——以及让它们富于表达力的迭代器机制,并通过一个实战项目把这些组件拼装成可运行的应用。
学习目标
- 正确使用
Vec<T>,理解容量(capacity)与切片。 - 在
HashMap与HashSet之间做出选择并高效使用。 - 用迭代器与闭包组合出简洁的数据处理流水线。
- 知道何时该用
BTreeMap、VecDeque或LinkedList。 - 避开各类结构的常见性能陷阱。
7.1 Vec<T>:动态数组
Vec 把值连续地存放在堆上,内部维护三样东西:指向数据的指针、长度(已存元素数)、容量(已分配内存可容纳的元素数)。尾部追加是均摊 O(1),按下标访问是 O(1)。
fn main() { // 三种创建方式 let mut a: Vec<i32> = Vec::new(); // 空 let b = vec![1, 2, 3]; // 宏 let mut c = Vec::with_capacity(100); // 预分配 a.push(10); a.push(20); c.extend([1, 2, 3]); // 按下标读取(越界会 panic),或用 get 返回 Option(安全) let first = b[0]; // 1 let maybe = b.get(10); // None println!("{first} {maybe:?}"); }
容量很关键
Vec 容量耗尽时会分配一块更大的内存(通常翻倍)并把元素拷过去。如果你知道最终大小,预先分配可以避免反复重分配:
#![allow(unused)] fn main() { // 好:只分配一次 let mut squares: Vec<i32> = Vec::with_capacity(1000); for i in 0..1000 { squares.push(i * i); } }
Vec::with_capacity 是日常 Rust 里杠杆率最高的优化之一。只要大小已知或可估,就用它。
访问、修改与切片
fn main() { let mut v = vec![3, 1, 4, 1, 5, 9, 2, 6]; v[2] = 10; // 修改 v.push(7); // 追加 let popped = v.pop(); // 弹出末尾 v.insert(0, 0); // 插入(O(n),需后移) let removed = v.remove(0); // 删除(O(n)) let slice: &[i32] = &v[1..=3]; // 切片借用,不拷贝 v.sort(); v.dedup(); // 去除连续重复 // 查找元素位置 if let Some(idx) = v.iter().position(|&x| x == 5) { println!("found 5 at {idx}"); } }
陷阱:
insert与remove涉及元素后移,是 O(n)。若你只需要“无序增删”,用swap_remove更快——它把末尾元素换到目标位置再弹出,O(1)。
7.2 HashMap<K, V>:键值查找
HashMap 存储键值对,查找、插入、删除平均 O(1)。键必须实现 Hash 与 Eq。
use std::collections::HashMap; fn main() { let mut scores: HashMap<String, i32> = HashMap::new(); scores.insert("alice".into(), 10); scores.insert("bob".into(), 7); // entry:仅在键不存在时插入默认值,避免二次查找 scores.entry("alice".into()).or_insert(50); // 已存在,不覆盖 scores.entry("carol".into()).or_insert(3); // 不存在,插入 3 if let Some(s) = scores.get("alice") { println!("alice: {s}"); } }
entry API(entry().or_insert())是“不存在则插入、否则读取/修改”的地道写法,一次查找搞定。用统计词频来体会:
use std::collections::HashMap; fn word_count(text: &str) -> HashMap<&str, u32> { let mut counts = HashMap::new(); for word in text.split_whitespace() { let c = counts.entry(word).or_insert(0); *c += 1; } counts } fn main() { let counts = word_count("the quick brown fox the lazy dog the"); println!("{counts:?}"); // {"the": 3, "quick": 1, ...} }
7.3 HashSet<T>:唯一值集合
HashSet 是没有值的 HashMap——一个唯一元素的集合,操作同样平均 O(1)。
use std::collections::HashSet; fn main() { let mut seen: HashSet<&str> = HashSet::new(); for word in ["a", "b", "a", "c", "b"] { // insert 在元素已存在时返回 false if !seen.insert(word) { println!("重复: {word}"); } } println!("唯一: {seen:?}"); }
集合支持 union、intersection、difference、symmetric_difference,均返回惰性迭代器。
7.4 迭代器与闭包
集合一旦与迭代器结合就变得强大。迭代器是惰性的:按需产出元素,且零成本(编译后等价于手写循环)。
fn main() { let nums = vec![1, 2, 3, 4, 5, 6]; // 流水线:filter -> map -> collect let doubled_evens: Vec<i32> = nums .iter() .filter(|&&n| n % 2 == 0) // 闭包:保留偶数 .map(|&n| n * 2) // 闭包:每个翻倍 .collect(); // 物化为 Vec println!("{doubled_evens:?}"); // [4, 8, 12] let sum: i32 = nums.iter().sum(); let max = nums.iter().copied().max(); println!("sum={sum} max={max:?}"); }
所有权与借用迭代器
.iter()产出&T——借用。.iter_mut()产出&mut T——可变借用。.into_iter()产出T——消费集合。
#![allow(unused)] fn main() { let v = vec![1, 2, 3]; let borrowed: Vec<&i32> = v.iter().collect(); // v 仍可用 let owned: Vec<i32> = v.into_iter().collect(); // v 被消费 }
陷阱:
|&&n|这种双引用模式来自对&Vec<i32>迭代(迭代器产出&i32,再模式匹配解一层)。看到它不必慌——是借用叠加。
7.5 其他集合与如何选择
| 需求 | 选用 | 说明 |
|---|---|---|
| 可增长、有序、可下标 | Vec<T> | 默认选择,缓存友好 |
| 快速键查找 | HashMap<K,V> | 无序,平均 O(1) |
| 唯一值 | HashSet<T> | 支持集合运算 |
| 有序键查找 | BTreeMap<K,V> | O(log n),键有序 |
| 双端队列 | VecDeque<T> | 两端都快 |
| 栈(LIFO) | Vec<T> | 用 push / pop |
| 双向链表 | LinkedList<T> | 在 Rust 里极少是正确选择 |
use std::collections::BTreeMap; fn main() { // BTreeMap:键有序,适合需要按顺序遍历的场景 let mut map = BTreeMap::new(); map.insert("charlie", 3); map.insert("alice", 1); map.insert("bob", 2); for (k, v) in &map { println!("{k}: {v}"); // 按 alice / bob / charlie 顺序输出 } }
默认用 Vec。 对中等规模数据,凭借缓存局部性它几乎总是最快的。只有真正需要按键访问时才换 HashMap/BTreeMap。
7.6 实战项目:Todo 管理器
把上面的组件拼起来——一个命令行待办管理器,用 Vec<Todo> 存储条目、HashMap 按标签建立索引、serde 持久化到 JSON。
# Cargo.toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use std::collections::HashMap; use std::env; use std::fs; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] struct Todo { id: u32, title: String, done: bool, tags: Vec<String>, } struct TodoStore { todos: Vec<Todo>, next_id: u32, path: String, } impl TodoStore { fn open(path: &str) -> Self { let todos: Vec<Todo> = fs::read_to_string(path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); let next_id = todos.iter().map(|t| t.id).max().unwrap_or(0) + 1; TodoStore { todos, next_id, path: path.into() } } fn add(&mut self, title: &str, tags: &[String]) -> u32 { let id = self.next_id; self.next_id += 1; self.todos.push(Todo { id, title: title.into(), done: false, tags: tags.to_vec(), }); id } fn complete(&mut self, id: u32) -> bool { if let Some(t) = self.todos.iter_mut().find(|t| t.id == id) { t.done = true; true } else { false } } fn list(&self) { for t in &self.todos { let mark = if t.done { "[x]" } else { "[ ]" }; println!("{} #{} {} {}", mark, t.id, t.title, t.tags.join(",")); } } /// 用 HashMap 建立标签 -> 待办条目下标的倒排索引。 fn tag_index(&self) -> HashMap<&str, Vec<u32>> { let mut index: HashMap<&str, Vec<u32>> = HashMap::new(); for t in &self.todos { for tag in &t.tags { index.entry(tag).or_default().push(t.id); } } index } fn save(&self) -> std::io::Result<()> { let json = serde_json::to_string_pretty(&self.todos)?; fs::write(&self.path, json)?; Ok(()) } } fn main() { let mut store = TodoStore::open("todos.json"); let args: Vec<String> = env::args().collect(); match args.get(1).map(String::as_str) { Some("add") => { let title = args.get(2).cloned().unwrap_or_default(); let tags: Vec<String> = args[3..].to_vec(); let id = store.add(&title, &tags); println!("added #{id}"); } Some("done") => { let id: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); if !store.complete(id) { eprintln!("no todo #{id}"); } } Some("list") => store.list(), Some("tags") => { for (tag, ids) in store.tag_index() { println!("{tag}: {ids:?}"); } } _ => { eprintln!("usage: todo [add <title> <tags...> | done <id> | list | tags]"); return; } } store.save().expect("failed to save"); }
用法:
cargo run -- add "写周报" work urgent
cargo run -- add "买牛奶" life
cargo run -- done 1
cargo run -- list
cargo run -- tags
这个项目把本章的组件串了起来:Vec 存主数据、HashMap 建倒排索引、serde 做持久化、迭代器与闭包做查找。它也是后续章节(错误处理、模块化、测试)的现成素材。
7.7 最佳实践
- 默认
Vec。 简单、连续、缓存友好;需要按键访问时再换 map。 - 能预分配就预分配。
Vec::with_capacity是最便宜的优化。 - 能用迭代器组合子就别写显式循环。
filter/map/collect更清晰,且零成本。 - 只迭代一次就别 collect。 结果只是循环用,就保持惰性。
- 栈/队列用
Vec/VecDeque,别用LinkedList。 链表在 Rust 里既慢又难用。
7.8 小结
Vec 是默认集合——可增长、连续、缓存友好。HashMap 与 HashSet 提供平均 O(1) 的键查找与去重。迭代器与闭包把这些结构变成富于表达力、零成本的数据流水线。选最简单的、够用的结构,能预分配就预分配,让借用检查器引导你走向正确的访问方式。
练习
- 实现
dedup_preserve_order<T: Eq + Hash + Clone>(v: &[T]) -> Vec<T>,用HashSet记录已见元素以保持顺序去重。 - 用纯迭代器组合子(无显式循环)实现:输入
Vec<i32>,返回其中正数平方之和。 - 用 entry API 构建一个
HashMap<String, Vec<String>>,按首字母分组单词。
第8章:模块系统与工程化
当程序规模超过一屏,组织方式就和正确性同样重要。Rust 的模块系统控制着可见性、解析路径,并把代码拆分到 crate 与 workspace 之中。本章讲清楚如何组织一个项目,使其从几百行平稳扩展到大型代码库而不致沦为乱麻。
学习目标
- 用
mod声明模块与子模块。 - 用
pub与pub(crate)精确控制可见性。 - 用
use把条目引入作用域,包括别名与再导出。 - 按 Rust 的路径约定把一个 crate 拆分到多个文件。
- 用 Cargo workspace 组织多 crate 项目。
8.1 模块基础
模块把相关条目分组并给它们一个命名空间。用 mod 声明:
mod network { pub fn connect(host: &str) { println!("connecting to {host}"); configure(); } fn configure() { // 私有——只在 network 内部可见 println!("configuring socket"); } } fn main() { network::connect("example.com"); // network::configure(); // 错误:configure 是私有的 }
条目默认私有。pub 让它对模块外可见。这个默认与许多语言相反,是有意为之的安全特性:你必须显式选择暴露 API。
8.2 路径与 use
引用条目时用路径限定:crate::network::connect,或从其他模块 network::connect。use 声明用来缩短它:
mod network { pub mod tcp { pub fn listen(port: u16) { println!("listening on {port}"); } } } use network::tcp::listen; // 把 listen 引入作用域 fn main() { listen(8080); // 无需限定 }
两个常用的 use 形式:
#![allow(unused)] fn main() { // 从同一模块批量引入 use std::io::{self, Read, Write}; // 再导出,让调用方看到更短的路径 pub use network::tcp::listen as tcp_listen; }
两个导入同名时,给其中一个起别名:use std::fmt::Result as FmtResult;。
8.3 把代码拆分到文件
Rust 允许把模块体放到另一个文件里。约定如下:
src/
├── main.rs
├── network.rs // main.rs 里 `mod network` 对应的内容
└── network/
└── tcp.rs // network.rs 里 `mod tcp` 对应的内容
在 main.rs 里声明模块(不带体),Rust 会找到对应文件:
// src/main.rs mod network; fn main() { network::tcp::listen(8080); }
#![allow(unused)] fn main() { // src/network.rs pub mod tcp; // Rust 查找 src/network/tcp.rs }
#![allow(unused)] fn main() { // src/network/tcp.rs pub fn listen(port: u16) { println!("listening on {port}"); } }
规则:不带体的 mod foo; 告诉 Rust 去找 foo.rs 或 foo/mod.rs。子模块在对应父模块的文件里声明。
8.4 可见性进阶
可见性控制的是谁能命名一个条目。
| 可见性 | 可访问范围 |
|---|---|
| (默认)私有 | 仅当前模块及其后代 |
pub | 任何能命名它的模块 |
pub(crate) | 当前 crate 内任意位置 |
pub(super) | 父模块 |
pub(in path) | 指定的祖先模块 |
pub(crate) 是库内部多个模块共享、但不想暴露给 crate 用户的利器:
#![allow(unused)] fn main() { pub(crate) fn internal_cache_key(s: &str) -> String { format!("cache:{s}") } }
一个微妙的规则:把结构体设为 pub 并不会让其字段公开。每个字段要单独标 pub:
#![allow(unused)] fn main() { pub struct User { pub name: String, // 公开 created_at: u64, // 私有——调用方既不能读也不能写 } }
8.5 Crate 与包
crate 是编译单元。包(package)是带 Cargo.toml、包含一个或多个 crate 的目录。二进制 crate 有 main 函数;库 crate 没有。
一个同时交付库与二进制的常见布局:
src/
├── lib.rs // 库 crate 根
├── main.rs // 二进制 crate 根——使用这个库
└── ...
#![allow(unused)] fn main() { // src/lib.rs pub fn greet(name: &str) { println!("hello, {name}"); } }
// src/main.rs use my_crate::greet; // 二进制依赖自己的库 fn main() { greet("world"); }
把真实逻辑放进库、让 main.rs 保持纤薄,代码就可测——测试能直接链接库。
8.6 工作空间(Workspace)
当一个项目包含多个一起演进的 crate,workspace 共享一个 target/ 目录与一份 Cargo.lock:
# 工作空间根的 Cargo.toml
[workspace]
members = ["core", "cli", "server"]
每个成员是独立 crate,有自己的 Cargo.toml,彼此按路径依赖:
# cli/Cargo.toml
[dependencies]
core = { path = "../core" }
工作空间让构建时间可控(一个 target/),又能一起版本化与测试,同时保持边界清晰。
8.7 标准库 prelude
有些条目无需 use 就始终在作用域里——Vec、String、Option、Result、println!。这就是 prelude:标准库再导出的一小撮最常用类型。你永远不必导入它们。
8.8 实战:把单文件程序拆成模块
把一个包含“解析、处理、输出”三件事的单文件程序拆成三个模块文件:
src/
├── main.rs
├── parser.rs
├── processor.rs
└── output.rs
// src/main.rs mod parser; mod processor; mod output; fn main() { let raw = "1,2,3"; let parsed = parser::parse(raw); // &str -> Vec<i32> let processed = processor::double(&parsed); output::print(&processed); }
#![allow(unused)] fn main() { // src/parser.rs pub fn parse(s: &str) -> Vec<i32> { s.split(',').filter_map(|t| t.trim().parse().ok()).collect() } }
#![allow(unused)] fn main() { // src/processor.rs pub fn double(v: &[i32]) -> Vec<i32> { v.iter().map(|x| x * 2).collect() } }
#![allow(unused)] fn main() { // src/output.rs pub fn print(v: &[i32]) { println!("{v:?}"); } }
main.rs 只做编排,每个模块职责单一。这正是把第 7 章的 Todo 管理器拆成 store/cli/persistence 模块时该用的结构。
8.9 最佳实践
- 从扁平开始,等模式浮现再抽模块。 别为小程序预先搭一棵深目录树。
- 在 crate 根再导出干净的公开 API。 用户应当
use your_crate::Thing,而不是钻进你的内部模块树。 - 内部用
pub(crate)而非pub。 让公开面保持小。 - 逻辑放进库,而非
main.rs。 第一次写测试时就会尝到甜头。 - 把相关常量与类型归到一个模块,而不是让它们漂在 crate 根。
8.10 小结
Rust 模块系统的核心是受控可见性:一切默认私有,你只暴露调用方需要的部分。use 把路径引入作用域,mod(配合文件)把代码拆到文件系统,crate 与 workspace 把结构扩展到跨团队。保持公开 API 窄、库厚、main.rs 薄。
练习
- 把一个含三件事(解析、处理、输出)的单文件程序拆成三个文件中的模块。
- 加一个被两个模块共用的
pub(crate)辅助函数,验证外部用户无法命名它。 - 把一个单 crate 包改造成 workspace:一个
core库 + 一个依赖它的cli二进制。
第9章:并发编程
Rust “无畏并发”(fearless concurrency)的承诺建立在一个事实上:防止内存错误的同一套所有权与借用规则,也在编译期防止了数据竞争。如果两个线程共享数据,编译器会坚持要求这种共享是安全的——要么只读,要么有同步原语守护。本章覆盖两种主流模型——带共享状态的线程与消息传递——以及让它们安全的 Send/Sync trait。
学习目标
- 用
thread::spawn创建线程并用join等待。 - 用
Arc、Mutex、RwLock安全地共享数据。 - 用
mpsc通道在线程间通信。 - 理解
Send与Sync及其意义。 - 避开共享状态并发的常见死锁与陷阱。
9.1 并发与并行
先厘清两个常被混用的词:
- 并发(Concurrency):多个任务在重叠的时间段内推进,由调度器在它们之间切换。强调“同时处理多件事”。
- 并行(Parallelism):多个任务真正在同一时刻执行(在多个 CPU 核上)。强调“同时做多件事”。
并发是结构,并行是执行。Rust 的所有权规则对两者都提供安全保证。
9.2 线程
std::thread::spawn 启动一个 OS 线程,返回 JoinHandle。调用 .join() 等待它结束:
use std::thread; fn main() { let handle = thread::spawn(|| { for i in 0..5 { println!("子线程说 {i}"); } }); for i in 0..3 { println!("主线程说 {i}"); } handle.join().unwrap(); // 等待子线程 }
闭包与 move
派生线程不能借用局部变量——除非这些变量能活得够久,而 main 可能在子线程结束前就返回了。解法是 move,它把捕获的变量所有权转移进闭包:
use std::thread; fn main() { let data = vec![1, 2, 3]; let handle = thread::spawn(move || { // data 现在归这个线程所有 println!("拿到 {} 项", data.len()); }); handle.join().unwrap(); // println!("{:?}", data); // 错误:data 已被 move 进线程 }
9.3 Send 与 Sync
这两个标记 trait 是线程安全的基石。你不必自己实现它们;当所有字段都是 Send/Sync 时,编译器会自动派生。
Send:类型T是Send,表示把T移动到另一个线程是安全的。Sync:类型T是Sync,表示多个线程同时持有&T是安全的(即&T是Send的)。
绝大多数类型两者皆是。例外是“无同步的内可变性”类型——Rc<T> 是经典反例:它的引用计数不是原子的,因此不是 Send/Sync。跨线程共享要用 Arc<T>(原子引用计数)。
9.4 共享状态:Arc + Mutex
要在线程间共享可变数据,组合使用:
Arc<T>:原子引用计数指针,多个线程可共享同一份分配。Mutex<T>:锁,保证对内部值的独占访问。
use std::sync::{Arc, Mutex}; use std::thread; fn main() { // 受锁保护的共享计数器,经 Arc 共享 let counter = Arc::new(Mutex::new(0)); let handles: Vec<_> = (0..10) .map(|_| { let counter = Arc::clone(&counter); thread::spawn(move || { let mut num = counter.lock().unwrap(); *num += 1; }) }) .collect(); for h in handles { h.join().unwrap(); } println!("最终 = {}", *counter.lock().unwrap()); // 10 }
.lock().unwrap() 需要解释:持有锁的线程若 panic,Mutex 会被中毒(poisoned),此后 .lock() 返回 Err。调用 .unwrap() 会让 panic 继续传播,这通常是对的——中毒的锁意味着数据可能处于不一致状态。
RwLock:多读单写
读远多于写时,RwLock 允许多个读者同时持锁:
#![allow(unused)] fn main() { use std::sync::RwLock; let cache = RwLock::new(0); { let r1 = cache.read().unwrap(); let r2 = cache.read().unwrap(); // 多个读锁可并存 println!("读: {} {}", *r1, *r2); } { let mut w = cache.write().unwrap(); // 写锁独占 *w += 1; } }
9.5 消息传递:通道
另一种并发模型是“通过通信来共享内存”,而非共享内存。Rust 的 std::sync::mpsc(多生产者、单消费者)通道正是为此而生。
use std::sync::mpsc; use std::thread; use std::time::Duration; fn main() { let (tx, rx) = mpsc::channel(); let sender = thread::spawn(move || { let msgs = ["hi", "from", "the", "thread"]; for m in msgs { tx.send(m).unwrap(); thread::sleep(Duration::from_millis(10)); } }); // rx 是迭代器:持续产出收到的值,直到发送端被丢弃 for received in rx { println!("收到: {received}"); } sender.join().unwrap(); }
send返回Result,因为接收端可能已被丢弃。- 多生产者:用
tx.clone()复制发送端,分别移进不同线程。
通道解耦了线程:发送方不必知道谁在读,加锁逻辑藏在通道实现里。
9.6 一个小型工作池
把组件拼起来——一组 worker 线程从共享队列消费任务:
use std::sync::{Arc, Mutex}; use std::sync::mpsc; use std::thread; fn main() { let (tx, rx) = mpsc::channel::<Box<dyn FnOnce() + Send>>(); let rx = Arc::new(Mutex::new(rx)); // 起四个 worker,共享接收端 let mut workers = Vec::new(); for _ in 0..4 { let rx = Arc::clone(&rx); workers.push(thread::spawn(move || loop { let job = { let lock = rx.lock().unwrap(); lock.recv() }; match job { Ok(task) => task(), Err(_) => break, // 所有发送端已丢弃——退出 } })); } // 投递几个任务 for i in 0..8 { tx.send(Box::new(move || { println!("任务 {i} 跑在 {:?}", thread::current().id()); })) .unwrap(); } drop(tx); // 关闭通道,让 worker 能退出 for w in workers { w.join().unwrap(); } }
接收端外面包 Mutex 是必要的——mpsc::Receiver 不是 Sync,同一时刻只能有一个线程调用 recv。
9.7 异步并发
async/await 是另一种并发模型:用 .await 点替代 OS 线程切换,单线程即可管理成千上万个并发任务,非常适合 I/O 密集型场景。其错误处理与所有权规则与本章一致,只是 Future 在 .await 点之间被挂起。
跨线程共享
Future的注意点:不要让std::sync::Mutex的守卫跨越.await——它不是为异步设计的。在异步代码里用tokio::sync::Mutex,或把守卫的作用域限制在.await之前。
异步网络的实战在第 10、12 章展开。
9.8 陷阱
- 死锁:不同线程以不同顺序获取两把锁会死锁。按全局一致顺序加锁,或用一把锁守护两份资源。
- 跨
.await持有std::sync::Mutex:见上节,改用tokio::sync::Mutex或缩小作用域。 - 跨线程用
Rc:编译器会拒绝(Rc不是Send),改用Arc。 - 忘记
join:分离的线程可能比它引用的数据活得久——不过 Rust 强制move或'static借用,这在编译期就被捕获。 - 锁太多:每个操作都取全局锁,等于串行化了。改用更细粒度的锁、分片数据或通道。
9.9 小结
Rust 让数据竞争在构造上不可能:共享可变状态需要 Mutex,跨线程引用计数需要 Arc,Send/Sync 在编译期检查。线程间通信用通道,共享可变数据用 Arc<Mutex<T>>(或 RwLock)。借用检查器把其他语言里难以复现的并发 bug,在这里变成了编译错误。
练习
- 起 10 个线程,各对一个共享的
Arc<Mutex<i32>>自增 1000 次,打印最终值。 - 用通道重做上题:每个线程把自己的增量发给接收端汇总。
- 用两个通道搭一条流水线:一线程产生数字、一线程平方、一线程打印。
第10章:网络编程
Rust 是系统级语言,这意味着与网络打交道是一等公民。标准库提供同步的 TCP 与 UDP;生态(Tokio、Hyper)提供高性能的异步网络。本章从原始 socket 一路讲到 HTTP 服务器,让你理解每一层,而不是只会调框架。
学习目标
- 用
std::net建立 TCP 与 UDP 连接。 - 实现一个简单的同步 TCP echo 服务器与客户端。
- 用 Tokio 处理异步、并发的网络连接。
- 手写一个极简 HTTP 请求处理。
- 用
serde序列化与反序列化结构化数据。
实战项目:构建一个分布式聊天系统,支持多房间、消息持久化等功能。
10.1 标准库的 TCP
std::net::TcpStream 是一个双向字节流。最简单的客户端:连接、写入、读回:
use std::io::{prelude::*, BufReader}; use std::net::TcpStream; fn main() -> std::io::Result<()> { let mut stream = TcpStream::connect("example.com:80")?; // 手动发一个 HTTP/1.0 请求 write!(stream, "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")?; // 读响应的第一行 let mut reader = BufReader::new(stream); let mut status = String::new(); reader.read_line(&mut status)?; println!("{status}"); Ok(()) }
TCP 服务器在循环里 accept 连接:
use std::io::{prelude::*, BufReader}; use std::net::{TcpListener, TcpStream}; fn handle(mut stream: TcpStream) -> std::io::Result<()> { let mut reader = BufReader::new(&stream); let mut line = String::new(); reader.read_line(&mut line)?; println!("收到: {line}"); stream.write_all(b"ack\n")?; Ok(()) } fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:7878")?; for stream in listener.incoming() { let stream = stream?; handle(stream)?; } Ok(()) }
TcpListener::bind 返回 io::Result——绑定可能失败(端口被占)。incoming() 是迭代器,每次产出一个 io::Result<TcpStream>。
每连接一线程
上面的服务器串行处理客户端。要并发服务,把每个连接挪到独立线程:
use std::net::TcpListener; use std::thread; fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:7878")?; for stream in listener.incoming() { let stream = stream?; thread::spawn(move || { let _ = std::io::copy(&mut &stream[..], &mut &stream[..]); }); } Ok(()) }
这对数千空闲连接够用,但每个客户端耗一个 OS 线程——高并发场景该用异步。
10.2 UDP
UDP 是无连接的:发数据报,不建立流。
use std::net::UdpSocket; fn main() -> std::io::Result<()> { let socket = UdpSocket::bind("127.0.0.1:34254")?; let mut buf = [0; 1024]; // 把收到的数据报回显给发送方 loop { let (amt, src) = socket.recv_from(&mut buf)?; socket.send_to(&buf[..amt], src)?; } }
UDP 适用于可丢包的场景(遥测、游戏、DNS),或你打算自己实现可靠性层时。
10.3 用 Tokio 做异步网络
Tokio 提供非阻塞 TCP/UDP,API 形状相同,只是前缀 Async。优势:单线程即可靠 I/O 多路复用(epoll/kqueue/IOCP)同时等待成千上万个 socket。
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; #[tokio::main] async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:7878").await?; loop { let (mut socket, _) = listener.accept().await?; // 每连接派生一个任务——开销小,不是 OS 线程 tokio::spawn(async move { let mut buf = [0; 1024]; loop { let n = match socket.read(&mut buf).await { Ok(0) => return, // 对端关闭 Ok(n) => n, Err(_) => return, }; if socket.write_all(&buf[..n]).await.is_err() { return; } } }); } }
这是一个能并发服务大量客户端的 echo 服务器。每个 tokio::spawn 创建轻量任务,不是 OS 线程。
10.4 一个极简 HTTP 服务器
HTTP/1.1 是 TCP 之上的文本。一个小服务器只需解析请求行并响应:
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; async fn handle(mut stream: TcpStream) -> std::io::Result<()> { let mut reader = BufReader::new(&mut stream); let mut request_line = String::new(); reader.read_line(&mut request_line).await?; let path = request_line.split_whitespace().nth(1).unwrap_or("/"); let (status, body) = match path { "/" => ("200 OK", "hello, world".to_string()), "/time" => ("200 OK", format!("{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs())), _ => ("404 Not Found", "not found".to_string()), }; let response = format!( "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len() ); stream.write_all(response.as_bytes()).await?; Ok(()) } #[tokio::main] async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:8080").await?; loop { let (stream, _) = listener.accept().await?; tokio::spawn(async move { if let Err(e) = handle(stream).await { eprintln!("error: {e}"); } }); } }
超出玩具规模就该上框架——axum、actix-web 或直接用 hyper——它们正确处理分块编码、keep-alive、路由与 TLS。
10.5 用 serde 序列化
网络数据是字节,程序想要结构体。serde 是标准序列化框架,serde_json 是它的 JSON 前端。
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug)] struct User { name: String, age: u8, } fn main() { let user = User { name: "alice".into(), age: 30 }; let json = serde_json::to_string(&user).unwrap(); println!("{json}"); // {"name":"alice","age":30} let parsed: User = serde_json::from_str(&json).unwrap(); println!("{parsed:?}"); }
serde 支持多种格式——bincode(紧凑二进制)、toml、yaml、经 prost 的 protobuf——背后是同一套 Serialize/Deserialize 派生。
10.6 最佳实践
- 用
BufReader/BufWriter。 逐字节读 socket 慢得可怕;缓冲几乎总是对的。 - 给读取设上限。 永远别按不可信的长度字段无上限分配缓冲——经典的 DoS 入口。
- 设超时。 永不收数据的 socket 会无限挂起。用
stream.set_read_timeout(Some(...)),或异步里用tokio::time::timeout。 - 高扇出用异步。 预期数千并发连接时,每连接一线程浪费内存。
- 生产用 TLS。 学习用明文 TCP 无妨;面向互联网的任何东西都要终结 TLS(如
rustls)。
10.7 小结
std::net 给你同步 TCP/UDP;Tokio 给你同样原语的非阻塞版本,让单线程管理数千 socket。HTTP 是 TCP 上的文本,小服务器触手可及,但生产代码应交给 axum/hyper。无论何处,serde 在字节与类型化结构体之间搬运。Rust 的网络编程该底层时能底层,该省事时能省事。
练习
- 把同步 TCP 服务器改为逐行回显,直到客户端断开。
- 用 Tokio 改写为异步版本,并为每个连接加 5 秒读超时。
- 实现一个基于 TCP 的 JSON 服务:接收
serde请求结构体,返回响应结构体。
第11章:数据库操作
大多数应用最终都会把数据存进数据库。Rust 的数据库方案以 sqlx 为核心——一个异步、编译期校验 SQL 的工具包——配合 serde 在行与结构体之间搬运。本章覆盖连接、查询、连接池、事务与迁移,示例使用 SQLite,无需额外服务即可在本机运行。
学习目标
- 用
sqlx连接数据库并执行查询。 - 把行映射为结构体,并用编译期 SQL 校验。
- 管理连接池,理解其调优参数。
- 用事务保证多语句更新的原子性。
- 编写并执行数据库迁移。
实战项目:构建一个任务管理系统,支持团队协作、任务分配、进度跟踪。
11.1 准备工作
sqlx 支持 PostgreSQL、MySQL/MariaDB、SQLite、MSSQL。本章用 SQLite,让每个示例都能在本机直接跑。
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "macros"] }
serde = { version = "1", features = ["derive"] }
SQLite 的连接串就是一个文件路径:
sqlite://todos.db?mode=rwc
mode=rwc 表示文件不存在则创建。
11.2 连接与查询
SqlitePool 是连接池;查询时会从中取出连接、执行、归还。
use sqlx::sqlite::SqlitePool; async fn create_table(pool: &SqlitePool) -> sqlx::Result<()> { sqlx::query( "CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0 )", ) .execute(pool) .await?; Ok(()) } #[tokio::main] async fn main() -> sqlx::Result<()> { let pool = SqlitePool::connect("sqlite:todos.db?mode=rwc").await?; create_table(&pool).await?; Ok(()) }
用参数绑定插入
永远把用户提供的值作为参数绑定——绝不要拼进 SQL 字符串,那等于 inviting 注入:
#![allow(unused)] fn main() { async fn add_todo(pool: &SqlitePool, title: &str) -> sqlx::Result<i64> { let result = sqlx::query("INSERT INTO todos (title) VALUES (?)") .bind(title) .execute(pool) .await?; Ok(result.last_insert_rowid()) } }
11.3 把行映射为结构体
用 query_as 配合派生 FromRow 的结构体:
#![allow(unused)] fn main() { use sqlx::FromRow; #[derive(Debug, FromRow)] struct Todo { id: i64, title: String, done: bool, } async fn list_todos(pool: &SqlitePool) -> sqlx::Result<Vec<Todo>> { sqlx::query_as::<_, Todo>("SELECT id, title, done FROM todos ORDER BY id") .fetch_all(pool) .await } }
fetch_all 把所有行装入 Vec;fetch_one 返回单行;fetch 返回 Stream,适合大结果集。
11.4 编译期 SQL 校验
sqlx::query!(与 query_as!)宏会在编译期对照真实数据库(或保存的 schema)解析并类型检查你的 SQL。拼错列名,构建直接失败:
#![allow(unused)] fn main() { async fn titles(pool: &SqlitePool) -> sqlx::Result<Vec<String>> { // 构建期对照 todos 表校验 let rows = sqlx::query!("SELECT title FROM todos WHERE done = 0") .fetch_all(pool) .await?; Ok(rows.into_iter().map(|r| r.title).collect()) } }
要用这些宏,要么设 DATABASE_URL 让宏在构建时连库,要么运行 cargo sqlx prepare 生成 .sqlx/ 缓存签入版本控制——CI 无库环境必需。
11.5 连接池
池保持一批热连接,避免每次查询重连。三个调优旋钮:
max_connections——活跃连接上限。min_connections——保持打开的下限。acquire_timeout——连接全忙时的等待时长。
#![allow(unused)] fn main() { use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; use std::time::Duration; let pool = SqlitePoolOptions::new() .max_connections(10) .min_connections(2) .acquire_timeout(Duration::from_secs(5)) .connect("sqlite:todos.db?mode=rwc") .await?; }
常见误区是把 max_connections 调得很大——数据库自身有上限,超订只会引发争用而非提速。
11.6 事务
事务让一组语句原子化:要么全部提交,要么全部回滚。这是账户间转账、插入父记录与子记录、更新必须一致的计数器的唯一正确做法。
#![allow(unused)] fn main() { async fn transfer(pool: &SqlitePool, from: i64, to: i64, amount: i64) -> sqlx::Result<()> { let mut tx = pool.begin().await?; sqlx::query("UPDATE accounts SET balance = balance - ? WHERE id = ?") .bind(amount).bind(from) .execute(&mut *tx).await?; sqlx::query("UPDATE accounts SET balance = balance + ? WHERE id = ?") .bind(amount).bind(to) .execute(&mut *tx).await?; tx.commit().await?; // 两步一起生效——否则回滚 Ok(()) } }
任一语句失败,? 提前返回,tx 被 drop 时自动回滚。需要主动中止时可显式 tx.rollback().await。
11.7 迁移
schema 会演进。sqlx::migrate! 把迁移文件打包,启动时应用待执行的:
migrations/
├── 20240101000000_init.sql
└── 20240201000000_add_index.sql
-- migrations/20240101000000_init.sql
CREATE TABLE todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
);
async fn main() -> sqlx::Result<()> { let pool = SqlitePool::connect("sqlite:todos.db?mode=rwc").await?; sqlx::migrate!("./migrations").run(&pool).await?; Ok(()) }
sqlx 在 _sqlx_migrations 表里记录已应用的迁移,所以重复运行二进制只会补上缺失的。
11.8 最佳实践
- 绑定,绝不拼接。 参数既安全又常常更快(驱动可缓存预编译语句)。
- 池只建一次,处处共享。 启动时建一个池,把
Pool句柄(内部是Arc)克隆进各 handler。 - 事务保持短小。 长事务持锁,拖累吞吐。
- 部署期迁移,而非每次请求。 迁移作为启动步骤或独立命令运行。
- 用
query_as!拿类型安全。 编译期校验免费消除一大类 bug。
11.9 小结
sqlx 给 Rust 提供异步、类型检查的数据库访问。用参数绑定防注入,用 FromRow 映射行,全程序共享一个连接池,多步写操作用事务守护,用迁移演进 schema。结果是与你程序其余部分一样经过静态检查的数据库代码。
练习
- 写一个 CLI,对 SQLite 中的待办事项进行新增、列表、完成操作。
- 增加
users与posts两张表,用事务原子地创建用户与其首篇帖子。 - 把
query_as调用改为query_as!宏,并用cargo sqlx prepare为 CI 生成缓存。
第12章:Web开发
Rust 的 Web 服务通常建立在 tokio + axum 技术栈之上:Tokio 提供异步运行时,Axum 用清晰、类型驱动的 API 提供路由、提取器与响应处理。本章端到端地构建一个小型 JSON API——路由、状态、校验、错误响应——让你看清各组件如何组合。
学习目标
- 用
axum基于tokio构建 HTTP API。 - 用提取器读取请求体与路径/查询参数。
- 在 handler 之间安全地共享状态。
- 返回类型化的 JSON 响应与一致的错误格式。
- 组合中间件(日志、异常恢复)。
实战项目:构建一个博客系统,覆盖多用户、权限管理、内容管理与评论。
12.1 第一个服务器
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.7"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use axum::{routing::get, Router}; async fn hello() -> &'static str { "hello, world" } #[tokio::main] async fn main() { let app = Router::new().route("/", get(hello)); let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await.unwrap(); axum::serve(listener, app).await.unwrap(); }
handler 就是一个返回实现了 IntoResponse 类型的 async fn。&'static str 变成 200 OK + 文本正文。路由把 HTTP 方法与路径映射到 handler。
12.2 路径与查询参数
提取器(extractor)是 Axum 的招牌特性:编译器读取 handler 的参数类型,自动为你解析请求。
#![allow(unused)] fn main() { use axum::extract::Path; // /users/42 -> id = 42 async fn show_user(Path(id): Path<u32>) -> String { format!("user {id}") } }
#![allow(unused)] fn main() { use axum::extract::Query; use serde::Deserialize; #[derive(Deserialize)] struct Pagination { page: Option<u32>, size: Option<u32> } // /items?page=2&size=10 async fn list_items(Query(p): Query<Pagination>) -> String { format!("page {:?}, size {:?}", p.page.unwrap_or(1), p.size.unwrap_or(20)) } }
提取器顺序有讲究:Path 与 Query 放哪都行,但消费请求体的提取器(Json、String)必须放在最后。
12.3 JSON 请求体与响应
Json<T> 既能反序列化请求体,又能序列化响应:
#![allow(unused)] fn main() { use axum::{Json, response::IntoResponse}; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct CreateTodo { title: String } #[derive(Serialize)] struct Todo { id: u64, title: String, done: bool } // 接收 {"title":"..."},返回创建的 todo 为 JSON async fn create_todo(Json(input): Json<CreateTodo>) -> impl IntoResponse { let todo = Todo { id: 1, title: input.title, done: false }; (axum::http::StatusCode::CREATED, Json(todo)) } }
请求体解析失败时,Axum 自动返回 400 Bad Request——你不必写这段代码。
12.4 共享状态
多数 handler 需要数据库池或缓存。把状态放进一个包了 Arc 的结构体,传给 Router::with_state,再用 State 提取:
use axum::extract::State; use std::sync::Arc; #[derive(Clone)] struct AppState { counter: Arc<std::sync::atomic::AtomicU64>, } async fn increment(State(state): State<Arc<AppState>>) -> String { let n = state.counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); format!("你是第 {} 位访客", n + 1) } #[tokio::main] async fn main() { let state = Arc::new(AppState { counter: Arc::new(std::sync::atomic::AtomicU64::new(0)), }); let app = Router::new() .route("/visit", get(increment)) .with_state(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await.unwrap(); axum::serve(listener, app).await.unwrap(); }
状态类型必须 Clone(通常靠内部 Arc),因为 Axum 给每个请求一个廉价副本。
12.5 统一的错误格式
从 handler 返回 Result 可以集中处理错误。定义自己的错误类型与 IntoResponse 实现,把它映射为统一的 JSON 形状:
#![allow(unused)] fn main() { use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde_json::json; enum ApiError { NotFound, BadRequest(String), } impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, msg) = match self { ApiError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), ApiError::BadRequest(reason) => (StatusCode::BAD_REQUEST, reason), }; let body = Json(json!({ "error": msg })); (status, body).into_response() } } async fn get_todo(Path(id): Path<u32>) -> Result<String, ApiError> { if id == 0 { return Err(ApiError::BadRequest("id 必须为正".into())); } if id > 100 { return Err(ApiError::NotFound); } Ok(format!("todo {id}")) } }
这样每个错误响应都是 {"error": "..."} 形状,handler 专注于正常路径。
12.6 中间件
中间件包裹路由,加入横切行为。tower_http 提供常用 layer:日志、CORS、压缩,以及把 panic 转成 500 的兜底:
[dependencies]
tower-http = { version = "0.5", features = ["trace", "cors"] }
tower = "0.4"
tracing-subscriber = "0.3"
#![allow(unused)] fn main() { use tower_http::trace::TraceLayer; use tower_http::cors::CorsLayer; let app = Router::new() .route("/", get(hello)) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()); }
layer 按相反顺序应用:最后加的 .layer 在请求时最先执行。
12.7 静态文件与模板
要在 API 旁边服务前端,用 tower_http::services::ServeDir 作 fallback;服务端渲染 HTML 可用 askama(编译期模板,类 Jinja2)或 maud(把 HTML 写成 Rust 宏)。两者都避免运行时模板解析,选哪个看口味。
12.8 最佳实践
- handler 要薄。 把逻辑推进库;handler 只解析输入、调服务、塑形响应。
- 每个 API 一个错误类型。 用
IntoResponse映射,让错误长相统一。 - 在边界校验。 在畸形输入进入领域代码前就拒掉——
serde加validatorcrate 覆盖多数情况。 - 状态走
Arc,别用static。 这样能与测试组合。 - 早加可观测性。
TraceLayer加tracing给你结构化日志,生产时会感激。
12.9 小结
axum 把 HTTP 变成有类型的 Rust:提取器解析请求,Json 序列化请求体,State 共享资源,错误类型配 IntoResponse 让响应统一。中间件层加日志、CORS、异常恢复。结果是一个与你静态检查的代码库其余部分观感一致的 Web 服务。
练习
- 用
GET(列表)、POST(创建)、GET /:id(详情)实现/todos资源,背后用Mutex包一个内存Vec。 - 加一个
ApiError类型:未知 id 返回404,空标题返回400。 - 加
TraceLayer与tracing订阅器,记录每个请求的方法、路径、状态。
第13章:性能优化
优化的第一条法则是:先测量,再优化。 Rust 默认就很快,所以多数“优化”其实是别把这份速度挥霍掉——避免无谓的分配、为缓存布局数据、选择合适的并发模型。本章既是寻找瓶颈的工具箱,也是一张“哪些技术真正管用”的清单。
学习目标
- 在改动代码前用基准测试建立性能基线。
- 用 CPU 与内存剖析找到真实瓶颈,而非凭猜测。
- 减少分配与拷贝——Rust 中最常见的性能红利。
- 为缓存局部性布局数据。
- 根据工作负载在线程与异步之间做选择。
实战项目:构建一个高性能缓存服务,覆盖内存池、性能监控与故障恢复。
13.1 先测量
别凭直觉优化。用 criterion 建立基线,它跑统计基准测试并报告噪声:
# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "string_join"
harness = false
#![allow(unused)] fn main() { // benches/string_join.rs use criterion::{criterion_group, criterion_main, Criterion}; fn bench_join(c: &mut Criterion) { let words: Vec<String> = (0..1000).map(|i| i.to_string()).collect(); c.bench_function("join_with_plus", |b| { b.iter(|| { let mut s = String::new(); for w in &words { s += w; } s }) }); c.bench_function("join_with_iter", |b| { b.iter(|| words.join("")) }); } criterion_group!(benches, bench_join); criterion_main!(benches); }
cargo bench 运行,criterion 告诉你改动是真提升还是落在噪声内。
13.2 剖析
对整个程序,用 perf(Linux)、Instruments(macOS)或 cargo flamegraph 剖析:
cargo install flamegraph
cargo flamegraph --bin myapp
火焰图按调用树聚合展示 CPU 时间花在哪。留意意外的热点——你没料到的 clone、紧凑循环里的 format!、占比惊人的哈希函数。
13.3 分配是通常的嫌犯
堆分配很便宜,但每秒成千上万次就累积成成本。最大的红利通常来自减少分配:
#![allow(unused)] fn main() { // 差:每次调用都分配新 String fn bad(items: &[i32]) -> String { let mut s = String::new(); for x in items { s += &x.to_string(); } s } // 好:一次分配,预先留好容量 fn good(items: &[i32]) -> String { // 每个 i32 最多 11 个字符;预分配避免反复扩容 let mut s = String::with_capacity(items.len() * 11); for x in items { s.push_str(&x.to_string()); } s } }
值得质疑的常见分配模式:
- 循环里的
clone()——能不能改借用? - 为了和
&str比较而to_string()——直接用==比较。 - 通常被关掉的日志仍用
format!——改用log/tracing宏,级别关闭时跳过格式化。 - 收集进
Vec只为迭代一次——保持迭代器惰性。
13.4 字符串处理
String 是堆分配的可增长字符串;&str 是借用的切片。函数参数优先用 &str。必须构建字符串时,用 String::with_capacity 或 write! 写入 String:
#![allow(unused)] fn main() { use std::fmt::Write; let mut out = String::with_capacity(64); write!(out, "x={}, y={}", 10, 20).unwrap(); }
对 ASCII 标识符,CompactString 或内联的 &'static str 可避免每次调用分配。
13.5 缓存局部性与数据布局
现代 CPU 算术快、访存慢。连续且顺序访问的数据远快于指针跳转。这就是 Vec 几乎总胜过 LinkedList 的原因,也是“结构体数组”在只迭代某字段时常胜过“数组结构体”的原因:
#![allow(unused)] fn main() { // 数组结构体——自然,但每项要碰三条缓存行 struct Particle { x: f64, y: f64, v: f64 } let aos: Vec<Particle> = /* ... */; // 结构体数组——迭代 xs 时流式扫一块连续内存 struct Particles { xs: Vec<f64>, ys: Vec<f64>, vs: Vec<f64> } }
如果剖析发现你花时间加载了从没用到的数据,把数据重构成结构体数组(或把大结构体拆成“热/冷”两部分)常常是 2–10 倍的收益。
13.6 哈希
默认 HashMap 用 SipHash,抗 DoS 但较慢。对可信、非对抗性键,ahash 或 rustc-hash(FNV 风格)快好几倍:
[dependencies]
ahash = "0.8"
#![allow(unused)] fn main() { use ahash::AHashMap; let mut m: AHashMap<&str, i32> = AHashMap::new(); }
13.7 内联与泛型
Rust 的泛型函数是单态化的——编译器为每个具体类型生成一份副本,从而能内联,通常比动态分发快。热路径里优先用泛型而非 dyn Trait:
#![allow(unused)] fn main() { // 泛型——单态化、可内联、快 fn sum<T: Copy + std::ops::Add<Output = T>>(xs: &[T], zero: T) -> T { xs.iter().fold(zero, |a, &b| a + b) } }
#[inline] 要节制——编译器自己很在行;只对跨 crate 边界的小叶子函数留提示。
13.8 异步 vs 线程
-
CPU 密集: 用线程或
rayon的数据并行。rayon把iter()变par_iter():#![allow(unused)] fn main() { use rayon::prelude::*; let total: u64 = (0..1_000_000).into_par_iter().map(|i| i * i).sum(); } -
I/O 密集: 用异步。每连接派生一个任务远比派生线程便宜。
-
混合: 把阻塞型 CPU 工作挪出异步运行时,用
tokio::task::spawn_blocking,别让它卡住反应器。
13.9 最佳实践
- 改前改后都测。 没有测量的改动只是猜测。
- 剖析整个程序,而非微基准——当你关心端到端速度时。
- 先砍分配。 这是地道 Rust 里最容易的大红利。
- 尊重缓存。 连续、顺序、可预测的访问胜出。
- 别和优化器较劲。 写清晰、单态化的代码;
cargo build --release负责其余。
13.10 小结
Rust 的性能工作从测量开始:criterion 做微基准,flamegraph 看全程序。常见红利是更少分配、更好的缓存布局、合适的并发模型——CPU 用线程与 rayon,I/O 用异步。多数 Rust 代码已经很快;这些技术让它随规模增长依然快。
练习
- 对
Vec::push有无with_capacity做基准测试,报告差异。 - 把一个数组结构体示例改成结构体数组,对单字段求和做基准对比。
- 在热循环里把
HashMap换成AHashMap,测量变化。
第14章:安全编程
Rust 在构造层面消灭了一整类漏洞——缓冲区溢出、释放后使用、空指针解引用、数据竞争在安全代码里都是编译期错误,而非运行时漏洞。但内存安全不是全部:一个安全的应用还必须校验输入、管理密钥、认证用户,并抵御针对任何 Web 服务的攻击。本章讲的是在 Rust 保证之上叠加的实战安全实践。
学习目标
- 理解 Rust 自动防止了什么、没有防止什么。
- 校验与净化不可信输入。
- 正确地哈希与加盐密码。
- 管理密钥而不泄露到日志或源码。
- 应用 TLS 与常见 Web 安全响应头。
实战项目:构建一个安全认证服务,覆盖多因素认证、密码管理与安全审计。
14.1 Rust 防止了什么,没有防止什么
安全代码里,Rust 的所有权模型让以下问题不可能发生:
- 缓冲区溢出——下标越界是 panic 而非溢出。
- 释放后使用与双重释放——move/borrow 系统禁止对已释放内存的别名可变访问。
- 空指针解引用——没有 null;缺失用
Option<T>。 - 数据竞争——
Send/Sync让无同步的并发可变成为编译错误。
Rust 没有防止的:
- 逻辑 bug——内存正确,答案错误。
- release 构建的整数溢出(会回绕;要紧时用
checked_*/saturating_*)。 - 不可信输入导致的 panic——对攻击者可控数据
unwrap会崩进程。 - 注入——用原始输入拼 SQL、HTML、shell 命令。
- 泄露密钥——持有密码的
String只是一段内存,编译器照样会打印它。
因此安全的关键在于你的程序与不可信数据之间的边界。
14.2 在边界校验输入
第一道防线是在畸形输入进入领域逻辑前就拒掉。serde 反序列化已能捕获类型错误;语义规则用 validator crate:
[dependencies]
validator = { version = "0.16", features = ["derive"] }
#![allow(unused)] fn main() { use validator::Validate; #[derive(serde::Deserialize, Validate)] struct Signup { #[validate(length(min = 3, max = 32))] username: String, #[validate(email)] email: String, #[validate(length(min = 8))] password: String, } fn handle_signup(input: Signup) -> Result<(), String> { input.validate().map_err(|e| e.to_string())?; Ok(()) } }
所有外部数据——HTTP 请求体、查询串、环境变量、文件内容——都应视为不可信,直到被校验。
14.3 SQL 与命令注入
规则是绝对的:永远不要把不可信数据插进命令字符串。 SQL 用参数绑定(第 11 章),子进程用类型化参数数组:
#![allow(unused)] fn main() { use std::process::Command; // 好——参数是传进去的,不由 shell 解析 let output = Command::new("ls") .arg("-l") .arg(user_path) // 即使含空格或 ";" 也安全 .output()?; }
避免 Command::new("sh").arg("-c").arg(format!("ls {user_path}"))——那把用户输入交给了 shell,重新打开注入。
14.4 密码:哈希,永不存储
永远不要明文或可逆密码学方式存储密码。用为密码设计的慢、加盐哈希。argon2 是当前标准:
[dependencies]
argon2 = "0.5"
#![allow(unused)] fn main() { use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, }; fn hash_password(plain: &str) -> Result<String, argon2::password_hash::Error> { let salt = SaltString::generate(&mut OsRng); let hash = Argon2::default().hash_password(plain.as_bytes(), &salt)?; Ok(hash.to_string()) } fn verify_password(plain: &str, stored: &str) -> Result<(), argon2::password_hash::Error> { let parsed = PasswordHash::new(stored)?; Argon2::default().verify_password(plain.as_bytes(), &parsed) } }
存储的字符串内嵌盐与参数,所以验证是一行。永远别自己造哈希。
14.5 密钥管理
密钥(API key、数据库密码、token)要满足三条:来自环境而非源码;加载一次留在内存;永远不进日志。
#![allow(unused)] fn main() { use std::env; struct Config { db_url: String, api_key: String, } impl Config { fn from_env() -> Result<Self, String> { Ok(Config { db_url: env::var("DATABASE_URL").map_err(|_| "DATABASE_URL missing")?, api_key: env::var("API_KEY").map_err(|_| "API_KEY missing")?, }) } } }
实战防护:
- 启动时从环境变量或密钥管理器加载——绝不硬编码、绝不提交进 git。
- 标记密钥字段让日志 crate 跳过(
tracing配合secrecy支持 redact 风格)。 - 用
secrecycrate 把密钥包进Secret<String>,它不实现Display,意外println!会是编译错误。
[dependencies]
secrecy = "0.8"
#![allow(unused)] fn main() { use secrecy::Secret; let api_key: Secret<String> = Secret::new(env::var("API_KEY")?); // println!("{}", api_key); // 编译不过 }
14.6 TLS
公共互联网上的明文不可接受。用 rustls(纯 Rust TLS 栈)终结 TLS,要么在服务器内,要么在反向代理。客户端 HTTPS,reqwest 默认用 rustls:
#![allow(unused)] fn main() { let resp = reqwest::get("https://example.com").await?.text().await?; }
固定到特定 TLS 版本(要求 TLS 1.2+)与精选密码套件;默认值保守且通常正确。
14.7 Web 安全响应头
几个响应头能阻止整类浏览器攻击:
| 头 | 作用 |
|---|---|
Content-Security-Policy | 限制脚本/样式可从哪加载——击溃多数 XSS |
Strict-Transport-Security | 强制未来访问走 HTTPS(HSTS) |
X-Content-Type-Options: nosniff | 阻止 MIME 嗅探 |
X-Frame-Options: DENY | 防止 iframe 点击劫持 |
在 axum 里用 tower_http::set_header::SetResponseHeaderLayer 加这些,或用专门的中间件。CSP 最强——严格的策略即使你有渲染 bug 也能挡住反射型 XSS。
14.8 认证与会话
基于 cookie 的认证:
- 发放随机、不可猜测的会话 token(用
getrandom或uuidv4)。 - 服务端存会话,映射到用户 id,带过期。
- cookie 设
HttpOnly(JS 不可访问)、Secure(仅 HTTPS)、SameSite=Lax(防 CSRF)。 - 权限变更(登录、提权)时轮换 token。
无状态 token(JWT)用强算法(EdDSA 或 HS256 配长密钥),设短过期,敏感操作用服务端 denylist 撤销。
14.9 最佳实践
- 所有外部输入都视为敌意,直到被校验。
- 绑定,绝不拼接——SQL、shell、URL 构造都是。
- 用 Argon2 哈希密码;永不存储或记录。
- 密钥从环境加载,包起来让它无法被打印。
- 公共互联网处处 TLS。
- 设安全头,尤其 CSP。
- 保持依赖更新——
cargo audit标记已知漏洞。
14.10 小结
Rust 移除了内存安全攻击面——这是真实漏洞里很大的一块。剩下的是应用层:校验输入、防注入、哈希密码、保护密钥、终结 TLS。把 Rust 的编译期保证与这些边界纪律结合,你就得到一个比多数技术栈显著更硬的目标。
练习
- 给注册 handler 加
validator,拒掉短于 3 字符的用户名与非法邮箱。 - 用
argon2哈希并验证密码,把哈希串存进 SQLite 行。 - 把 API key 包进
secrecy::Secret,确认编译器拒绝意外的println!。 - 给 Axum 路由加
Strict-Transport-Security与一个基础Content-Security-Policy头。
第15章:测试与调试
Rust 的测试能力内建于语言:编译器认识 #[test],标准库自带断言,cargo test 一条命令跑完一切。本章覆盖单元测试、集成测试与文档测试,再讲到编译器查不出的那些 bug 的工具——基于性质的测试、结构化日志与调试器。
学习目标
- 编写单元测试、集成测试与文档测试。
- 组织测试模块并使用常用断言宏。
- 测试异步代码与外部依赖。
- 用基于性质的测试生成随机输入。
- 用
tracing与lldb调试。
实战项目:构建一个自动化测试系统,覆盖多层级测试、性能监控与报告生成。
15.1 单元测试
单元测试紧挨着被测代码,放在 #[cfg(test)] 模块里,只在 cargo test 时编译:
#![allow(unused)] fn main() { // src/math.rs pub fn add(a: i32, b: i32) -> i32 { a + b } #[cfg(test)] mod tests { use super::*; #[test] fn add_works() { assert_eq!(add(2, 2), 4); assert_eq!(add(-1, 1), 0); } #[test] #[should_panic(expected = "overflow")] fn overflow_panics() { // 演示断言会按预期 panic panic!("overflow"); } } }
核心断言:
| 宏 | 检查 |
|---|---|
assert!(cond) | 条件为真 |
assert_eq!(a, b) | 两值相等 |
assert_ne!(a, b) | 两值不等 |
should_panic | 测试会 panic(可带消息) |
测试要小、聚焦、独立——每个测一种行为。
15.2 集成测试
集成测试放在 tests/ 目录,像外部用户一样操作 crate 的公开 API。每个文件是独立二进制:
#![allow(unused)] fn main() { // tests/api.rs use my_crate::add; #[test] fn add_from_outside() { assert_eq!(add(3, 4), 7); } }
端到端路径用集成测试,内部分支用单元测试。
15.3 文档测试
/// 文档注释里的代码块会被 cargo test 编译并运行。它既是示例,又是“文档里写的 API 确实能用”的正确性检查:
#![allow(unused)] fn main() { /// 把两个整数相加。 /// /// ``` /// use my_crate::add; /// assert_eq!(add(2, 2), 4); /// ``` pub fn add(a: i32, b: i32) -> i32 { a + b } }
示例不该运行就标 ```no_run 或 ```ignore。文档测试让你的文档保持诚实。
15.4 测试异步代码
tokio 提供 #[tokio::test] 属性,把测试包进运行时:
#![allow(unused)] fn main() { #[tokio::test] async fn fetches_a_value() { let result = some_async_fn().await; assert_eq!(result, 42); } }
带定时器的代码,用 tokio::time::pause 与 advance 让测试无需真实延时即可确定性推进。
15.5 依赖注入与“模拟”
Rust 没有内置 mock 框架,这是有意为之——地道的做法是用 trait 做依赖注入。为外部依赖定义一个小 trait,测试里写假实现,传进去:
#![allow(unused)] fn main() { pub trait Clock { fn now(&self) -> u64; } pub fn greet(name: &str, clock: &dyn Clock) -> String { let hour = (clock.now() / 3600) % 24; if hour < 12 { format!("早上好, {name}") } else { format!("你好, {name}") } } #[cfg(test)] mod tests { use super::*; struct FixedClock(u64); impl Clock for FixedClock { fn now(&self) -> u64 { self.0 } } #[test] fn morning() { assert_eq!(greet("alice", &FixedClock(7 * 3600)), "早上好, alice"); } } }
更重的 mock,用 mockall 自动生成 trait 的 mock 实现。
15.6 基于性质的测试
与其一次写一个示例,不如陈述一个应始终成立的性质,让框架搜索反例。proptest 是标准 crate:
[dev-dependencies]
proptest = "1"
#![allow(unused)] fn main() { proptest::proptest! { #[test] fn add_is_commutative(a in -1000i32..1000, b in -1000i32..1000) { proptest::prop_assert_eq!(add(a, b), add(b, a)); } #[test] fn sort_is_idempotent(mut v in proptest::collection::vec(-100i32..100, 0..100)) { v.sort(); let mut w = v.clone(); w.sort(); proptest::prop_assert_eq!(v, w); } } }
性质测试能找到你不会想到的边界——空输入、最大值、差一——并把失败的随机用例收缩到最小复现。
15.7 用 tracing 调试
println! 能用,但 tracing 给你结构化、分级、带上下文的日志,跨异步任务也能用:
[dependencies]
tracing = "0.1"
tracing-subscriber = "0.3"
use tracing::{info, instrument, span, Level}; #[instrument] fn process(user: &str) { let _span = span!(Level::INFO, "step", user = %user).entered(); info!("开始处理"); // ... } fn main() { tracing_subscriber::fmt::init(); process("alice"); }
span 给其下每条日志附加上下文(函数名、参数),这在大量请求交错时极有价值。
15.8 调试器
日志不够时,用 lldb(或 gdb、IDE 调试器)配合调试构建:
cargo build
lldb -- target/debug/myapp
用 b 函数名 设断点,n/s 单步,p 变量 检查。对 panic,用 RUST_BACKTRACE=1 无需调试器就拿到栈回溯:
RUST_BACKTRACE=1 cargo run
15.9 最佳实践
- 测行为,不测实现。 钻进私有内部的测试每次重构都会碎。
- 一个测试一个断言(尽量)。窄测试能定位失败。
- 保持快速测试快。 把慢的集成测试藏在 feature flag 后,让
cargo test保持利落。 - 先写失败的测试。 它先确认 bug 存在,再修。
- 纯函数用性质测试。 那是
proptest的用武之地。
15.10 小结
cargo test 跑 #[cfg(test)] 模块里的单元测试、tests/ 里的集成测试、/// 注释里的文档测试——一条命令,三种覆盖。用 trait 注入依赖来隔离测试,用 proptest 猎杀边界,行为出错时上 tracing 与 lldb。Rust 的测试无聊得恰到好处:它就是代码,由同一套工具链编译运行。
练习
- 给一个
sort包装函数加单元测试与文档测试,确认都在cargo test下运行。 - 用
proptest验证:对一个Vec反转两次得到原值。 - 给函数加
tracingspan,用tracing_subscriber::fmt检查输出。
第16章:部署与运维
写完代码只是一半工作,发布与运行是另一半。本章覆盖一个 Rust 服务在生产中的实际生命周期:release 构建、容器镜像、配置、健康检查、优雅关闭、可观测性,以及滚动更新。Rust 静态、单一二进制的产出让这一切异常轻松。
学习目标
- 产出优化的 release 二进制,理解
--release做了什么。 - 把应用打包成最小 Docker 镜像。
- 用环境变量与文件做配置,符合十二要素(twelve-factor)部署。
- 实现健康检查与优雅关闭。
- 用日志、指标、链路追踪观测一个运行中的服务。
实战项目:部署一个微服务架构系统,覆盖服务拆分、容器编排与 CI/CD。
16.1 Release 构建
调试构建用于开发;生产跑 cargo build --release。release profile 开启优化(opt-level = 3)、关闭调试断言,并使用为吞吐调优的系统分配器。对服务,考虑收紧它:
# Cargo.toml
[profile.release]
lto = "thin" # 跨 crate 链接时优化
codegen-units = 1 # 更好的优化,更慢的编译
strip = true # 去掉调试符号,更小的二进制
panic = "abort" # 更小二进制,无 unwind 开销
panic = "abort" 是权衡:更小更快的二进制,但没有栈展开——panic 的线程会拖垮整个进程,这对由重启策略监督的服务通常正是你想要的。
16.2 最小 Docker 镜像
Rust 产出静态(或近静态)链接的二进制,所以运行时镜像可以极小。多阶段构建在完整镜像里编译,再把二进制拷进 scratch 或 debian:bookworm-slim:
# 构建阶段
FROM rust:1.78 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
# 运行阶段
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/myapp /usr/local/bin/myapp
RUN useradd -r -s /bin/false appuser
USER appuser
EXPOSE 8080
ENTRYPOINT ["myapp"]
结果是一个几十兆、内部无工具链的镜像——更小的攻击面、更快的拉取。
16.3 配置
按 twelve-factor 方法论,把配置放在环境里。典型做法读环境变量,开发时用本地文件:
#![allow(unused)] fn main() { use std::env; struct Config { port: u16, database_url: String, log_level: String, } impl Config { fn from_env() -> Result<Self, String> { Ok(Config { port: env::var("PORT").unwrap_or_else(|_| "8080".into()).parse().map_err(|_| "PORT 不是数字")?, database_url: env::var("DATABASE_URL").map_err(|_| "DATABASE_URL missing")?, log_level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".into()), }) } } }
绝不要把密钥烤进镜像。运行时从密钥管理器或编排平台(Kubernetes secrets、Docker secrets、云密钥管理器)注入。
16.4 健康检查与就绪
编排器需要知道你的服务是否存活、是否就绪。暴露两个端点:
/health(liveness)——“进程还在”。无条件返回200,用于决定是否重启容器。/ready(readiness)——“我能接流量”。仅当数据库已连、预热完成时返回200,用于决定是否路由流量。
#![allow(unused)] fn main() { use axum::{routing::get, Router, http::StatusCode}; let app = Router::new() .route("/health", get(|| async { StatusCode::OK })) .route("/ready", get(|| async { StatusCode::OK })); }
依赖出问题应让 /ready 返回 503,而不是崩进程。
16.5 优雅关闭
部署滚动时,编排器发 SIGTERM,等一个短暂宽限期再 SIGKILL。你的服务应停止接新连接、完成在途请求、再退出。axum::serve 直接支持:
use axum::{routing::get, Router}; async fn handler() -> &'static str { "ok" } #[tokio::main] async fn main() { let app = Router::new().route("/", get(handler)); let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await .unwrap(); } async fn shutdown_signal() { use tokio::signal; let ctrl_c = async { signal::ctrl_c().await.expect("install ctrl-c handler"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("install terminate handler") .recv() .await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, } println!("收到关闭信号"); }
配合就绪检查,这带来零停机滚动更新:流量在进程退出前排干。
16.6 可观测性
生产服务需要三个信号:日志、指标、链路追踪。
- 日志——结构化,经
tracing。输出 JSON 让日志聚合器索引。 - 指标——计数器与直方图,经
prometheus或metricscrate,在/metrics暴露供抓取。 - 追踪——分布式 span,经
tracing-opentelemetry,让你跨服务跟踪一个请求。
#![allow(unused)] fn main() { tracing_subscriber::fmt() .json() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); }
最有用的指标是 RED 三件套:请求的 Rate、Errors、Duration。按路由跟踪它们,运营需要的大半就齐了。
16.7 最佳实践
- 一次构建,处处运行。 一个从环境读配置的 release 二进制,在 Docker、systemd、Kubernetes 里都不用改。
- 配置缺失就快速失败。 启动时若必需变量缺失,带着清晰消息退出——别带病运行。
- 保持运行时镜像小。
scratch或 slim 基础镜像,无工具链、无源码。 - 两个健康端点都实现。 liveness 不等于 readiness。
- 处理
SIGTERM。 优雅关闭是滚动更新安全的保证。 - 从第一天就可观测。 后补日志与指标很痛苦。
16.8 小结
一个 Rust 服务以单个优化过的二进制形式交付,装在小容器里,由环境变量配置,经健康检查监督,在 SIGTERM 时排干。release profile 与多阶段 Docker 镜像是机械核心;liveness/readiness 端点、优雅关闭、结构化可观测性是让它能在生产运营的关键。Rust 的产出异常易部署——把这份轻松花在良好的运维卫生上即可。
练习
- 配置 release profile(
lto、strip、panic = "abort"),对比二进制大小与启动时间。 - 写一个多阶段 Dockerfile,构建你的 Axum 服务并以非 root 用户运行。
- 加
/health与/ready端点,以及SIGTERM优雅关闭处理。 - 用
tracing_subscriber输出 JSON 日志,并通过RUST_LOG按级别过滤。
第17章:嵌入式 Rust
Rust 不仅能跑在服务器上,也能跑在单片机里。所有权模型在 Web 服务里防止的内存错误,正是嵌入式开发长期以来的痛点;而 no_std 让你可以完全抛开标准库,只保留语言内核。本章是一次短途导览:no_std 意味着什么、embedded-hal 抽象层如何让代码可移植,以及如何在典型单片机上点亮一颗 LED。
学习目标
- 理解
#![no_std]以及 core / alloc / std 三个层次。 - 使用
embedded-haltrait 编写可移植的外设驱动。 - 为单片机目标进行交叉编译。
- 用 HAL/PAC crate 实现一个闪烁 LED 的程序。
- 知道嵌入式生态的深入方向。
17.1 三个层次:core、alloc、std
Rust 代码可以面向三个层次之一,由属性控制:
| 层次 | 提供内容 | 属性 | 典型场景 |
|---|---|---|---|
std | 堆、线程、文件、网络 | (默认) | 桌面、服务端 |
alloc | Box、Vec、String、Arc | #![no_std] + extern crate alloc | OS 内核、较大的嵌入式系统 |
core | 切片、迭代器、Option/Result | #![no_std] | 单片机、引导加载器 |
#![no_std] 二进制会丢弃 std,只链接 core(可选地链接 alloc)。凡是只依赖 core 写成的代码,在任何地方都能用——包括 std 程序——所以库作者在可行时都倾向于写 no_std 兼容的代码。
#![allow(unused)] #![no_std] fn main() { // 只有 core 可用:没有 Vec、没有 String、没有 println!、没有线程。 pub fn sum(slice: &[i32]) -> i32 { slice.iter().copied().sum() } }
17.2 embedded-hal:可移植的 trait
嵌入式生态的精妙之处在于 embedded-hal:一组用 trait 通用描述外设的接口——GPIO 引脚、串口、I²C 总线、定时器。只要 HAL 实现了这些 trait,针对 trait 写的代码在任何芯片上都能原样运行。
#![allow(unused)] fn main() { use embedded_hal::digital::OutputPin; // 这个函数能让任何实现了 OutputPin 的引脚闪烁——任何芯片、任何 HAL。 pub fn blink<P: OutputPin>(pin: &mut P, count: u8) { for _ in 0..count { let _ = pin.set_high(); // 延时此处省略 let _ = pin.set_low(); } } }
因为用了泛型,同一段 blink 既能在 STM32 上跑,也能在 ESP32、nRF52 上跑——改变的只是调用处的具体引脚类型。
17.3 PAC、HAL、BSP 三层结构
嵌入式 Rust 是分层的:
- PAC(外设访问 crate)——由芯片的 SVD 文件生成,提供按地址访问寄存器的原始接口。
- HAL(硬件抽象层)——在 PAC 之上实现
embedded-haltrait,提供安全 API。 - BSP(板级支持包)——针对某块板子预先接好引脚与外设(例如“用户 LED 在 PB5”)。
通常你面向 HAL/BSP 写代码,只有用到特殊寄存器时才下沉到 PAC。
17.4 交叉编译
Rust 的交叉编译只需安装目标并让 cargo 指向它:
# 添加一个目标(示例:Cortex-M4F,常见于 STM32 / nRF52)
rustup target add thumbv7em-none-eabihf
# 不带标准库、不带 std 定义的入口点进行构建
cargo build --release --target thumbv7em-none-eabihf
目标三元组 thumbv7em-none-eabihf 编码了架构、ABI 与硬浮点。#![no_std] 二进制还需要自定义入口点和链接脚本,cortex-m-rt crate 与 cortex-m-quickstart 模板提供了这些。
17.5 闪烁程序的结构
一个 blinky 程序的大致形状(细节随 HAL 而异):
#![no_std] #![no_main] use cortex_m_rt::entry; use embedded_hal::digital::OutputPin; use panic_halt as _; // 提供 panic 处理器:停机 #[entry] fn main() -> ! { let (mut led, mut delay) = board::take_peripherals(); loop { led.set_high(); delay.delay_ms(500); led.set_low(); delay.delay_ms(500); } }
三点值得注意:
#![no_main]——没有标准main;cortex-m_rt::entry定义了复位处理函数。panic_halt as _——#![no_std]二进制必须提供 panic 处理器;这里让 CPU 停机。main -> !——嵌入式main永不返回,它死循环。
17.6 单片机上的异步
embedded-hal 已有异步变体,embassy 这类执行器能在没有 OS 的单片机上跑 future。于是你可以用与服务端相同的 async/await 写非阻塞驱动——一边读传感器一边让 LED 闪烁——而芯片只有几十 KB 内存。
17.7 延伸资源
- The Embedded Rust Book——
docs.rust-embedded.org/book——权威教程。 embedded-hal文档——trait 参考。probe-rs——通过调试探针烧录与调试,替代厂商工具链。embassy——异步嵌入式框架,发展迅速。
17.8 小结
嵌入式 Rust 用 core 换掉 std,面向 embedded-hal 写可移植驱动,并用你早已熟悉的 cargo 交叉编译到裸机目标。结果是具备与服务端同等内存安全保证的单片机固件——对一个长期被缓冲区溢出与悬垂指针困扰的领域而言,这是实实在在的改变。
练习
- 写一个
#![no_std]函数fn count_ones(bytes: &[u8]) -> u32统计置位比特数,并在主机上用cargo test做单元测试。 - 安装
thumbv7em-none-eabihf目标,确认一个#![no_std]crate 能为之构建。 - 阅读 Embedded Rust Book 第一章,为你手头的板子找出对应的 PAC、HAL、BSP。
第18章:Rust 进阶资源与官方书导读
你已经读到了本书的末尾,但 Rust 是一门庞大的语言,生态也在快速演进。本章是一张经过筛选的地图:接下来该读哪些权威资料、该装哪些工具、该常备哪些参考,以及一条从“能写 Rust”到“熟练驾驭 Rust”的进阶路径。
学习目标
- 了解官方文档体系,知道何时查阅哪一份。
- 用
rustup、cargo、clippy、rustfmt搭建日常工具链。 - 在 crate 生态中导航并评估质量。
- 沿一条 deliberate 的路径走向熟练。
18.1 官方文档
Rust 项目维护着一批免费且互相交叉引用的书籍,各司其职:
| 资源 | 网址 | 用途 |
|---|---|---|
| The Rust Programming Language(“the Book”) | doc.rust-lang.org/book | 带项目实战的引导式入门,最权威的起点 |
| Rust by Example | doc.rust-lang.org/rust-by-example | 按主题组织、可直接运行的代码片段,速查用 |
| The Rust Reference | doc.rust-lang.org/reference | 精确、权威的语言语义(非教程) |
| The Rustonomicon | doc.rust-lang.org/nomicon | “黑魔法”:unsafe、FFI、底层内存 |
| The Async Book | rust-lang.github.io/async-book | async/await 底层原理 |
| The Cargo Book | doc.rust-lang.org/cargo | 构建系统与打包的一切 |
| The Edition Guide | doc.rust-lang.org/edition-guide | 2015、2018、2021 edition 之间的差异 |
| The API Guidelines | rust-lang.github.io/api-guidelines | 如何设计地道的 Rust API |
| std API 文档 | doc.rust-lang.org/std | 标准库参考 |
一个好习惯:写代码时常开 doc.rust-lang.org/std,遇到常用的类型就读读它的源码——标准库本身就是典范级的 Rust 代码。
18.2 工具集
每位 Rust 开发者都应把以下工具接入编辑器与 CI:
rustup——管理工具链与目标。rustup update保持最新;rustup component add添加组件。cargo——构建、测试、生成文档、发布。cargo check是快速反馈循环;cargo build --release用于交付。rustfmt——官方格式化器。运行cargo fmt,让格式永远不必成为代码评审的话题。clippy——lint 工具。cargo clippy能揪出一长串常见错误与不地道写法。认真对待它的告警,其中不少就是真 bug。cargo doc --open——为你的 crate 及其依赖生成并启动文档服务。读自己生成的文档是评估 API 的好办法。
rustup component add rustfmt clippy
cargo fmt
cargo clippy --all-targets -- -D warnings
cargo test
cargo doc --open
18.3 crate 生态
有些 crate 用得如此之广,几乎算语言的一部分。认识它们能省去重复造轮子:
| 领域 | crate | 用途 |
|---|---|---|
| 序列化 | serde、serde_json | 通用(反)序列化层 |
| 错误处理 | thiserror、anyhow | 库与应用的错误类型 |
| 异步运行时 | tokio | 主流异步运行时 |
| HTTP 服务 | axum、actix-web | Web 框架 |
| HTTP 客户端 | reqwest | 高层阻塞/异步客户端 |
| 数据库 | sqlx | 异步、编译期校验的 SQL |
| 日志 | tracing、tracing-subscriber | 结构化日志与 span |
| 随机数 | rand | 随机数生态 |
| 正则 | regex | Perl 风格正则 |
| CLI 解析 | clap | 带 derive 宏的参数解析 |
| 日期时间 | chrono、time | 日期时间运算 |
| 并行 | rayon | 数据并行迭代器 |
评估一个 crate: 在 crates.io 看下载量与近期版本日期,读 README,扫一眼未关闭的 issue,优先选维护活跃、文档完善的。一个两年没更新的 crate 是负债。
18.4 走向熟练的路径
- 通读 the Book。 相比其覆盖面,它并不长,而且边讲边搭一个
grep克隆项目。 - 做
rustlings。 一组小练习,补上 the Book 留给读者练习的空档。 - 做个真实项目。 一个 CLI 工具、一个小 Web 服务、一个小游戏——自有项目会暴露教程预见不到的问题。
- 读优秀的代码。 标准库、
serde、tokio、axum都写得很好,值得学习。 - 最后再写
unsafe。 多数 Rust 程序员很少需要它;真要用时,先读 Rustonomicon。 - 融入社区。
rust-users论坛、Discord、本地 meetup 都很友好且底蕴深厚。
18.5 保持同步
Rust 每六周发布一次——稳定的发布列车,而非漫长的大版本间隔。多数发布是增量式的。留意偶尔出现的 edition(一次引入小幅语言便利而不破坏生态的机会),以及年度 Rust 调查,了解社区走向。
把 rustup update 放进日常,浏览发布说明,把小幅风格变化交给 clippy 与 rustfmt 吸收即可。
18.6 结语
Rust 的承诺是:你可以写出快速、底层的代码,而不必背负通常随之而来的恐惧。编译器很严格,但正是这份严格,让你能放心地重构大型代码库、上线一个不会因空指针崩溃的服务、发布一段不会缓冲区溢出的固件。学习投入是真实的,回报同样真实。
让标准库文档常开,每天写一点代码,让借用检查器来教你。欢迎来到 Rust 的世界。