Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第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 让你轻松切换工具链、添加交叉编译目标、安装组件(如 rustfmtclippy):

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-analyzerrustfmtclippy,你就有了顺手的开发环境。

练习

  1. cargo new 创建一个项目,写一个函数返回斐波那契数列前 N 项,用 cargo runcargo test 验证。
  2. 给项目加一个依赖(如 rand),用 cargo doc --open 查看生成的文档。
  3. 故意写一段会被 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}");
}

mut vs 遮蔽mut 改的是同一个变量的值,类型不能变;遮蔽是新建一个变量,可以变类型。把字符串转成长度时用遮蔽很自然,用 mut 做不到。

常量

const 与不可变变量不同:它编译期求值、必须标注类型、全大写、可在任意作用域声明:

#![allow(unused)]
fn main() {
const MAX_POINTS: u32 = 100_000;
}

2.2 标量类型

类型含义示例
i8i128, isize有符号整数-5, 42
u8u128, usize无符号整数0, 255
f32, f64浮点数3.14, 2.0
bool布尔true, false
charUnicode 标量值(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!("小");
    }
}

循环:loopwhilefor

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(借用)。ifloop 是表达式,函数以最后一个无分号表达式作返回值。这些规则简单,却支撑了后面所有权、泛型、错误处理等所有主题。

练习

  1. 写一个函数 fizzbuzz(n: u32),按经典 FizzBuzz 规则打印 1 到 n。
  2. 用元组从一个函数同时返回商与余数:fn divmod(a: i32, b: i32) -> (i32, i32)
  3. for 与范围计算 1 到 100 的整数和,体会为何不必用 while 索引循环。

第3章:所有权与借用

所有权是 Rust 最独特的特性,也是它无需垃圾回收却保证内存安全的根基。本章讲清三条所有权规则、借用与借用检查器、生命周期,以及切片。理解了这一章,你就能读懂编译器的报错,并明白它为何这样要求你。

学习目标

  • 掌握所有权的三条规则与 move 语义。
  • 理解借用:共享引用 &T 与可变引用 &mut T
  • 掌握借用的规则与“同一时刻多个引用”的限制。
  • 用生命周期标注让引用之间的关系显式化。
  • 用切片 &[T] / &str 借用一段连续数据。

3.1 所有权的三条规则

Rust 内存管理的核心是三条规则:

  1. 每个值有且仅有一个所有者——变量。
  2. 当所有者离开作用域,值被丢弃(析构运行,内存释放)。
  3. 赋值或传参时,所有权转移(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!
}

借用的两条规则

借用检查器在编译期强制两条规则:

  1. 任意时刻,可以有多个共享引用 &T,或者一个可变引用 &mut T,二者不能并存。
  2. 引用必须始终有效(不能悬垂)。
#![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}");
}

函数中的生命周期省略规则

多数情况下不必手写标注。编译器按三条省略规则自动补:

  1. 每个引用参数各自获得一个生命周期。
  2. 若只有一个输入生命周期,它赋给所有输出引用。
  3. 若有 &self/&mut selfself 的生命周期赋给所有输出引用。

不满足时编译器会报错,要求你显式标注——这通常意味着你的 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 最陡的那道坎。

练习

  1. 解释为何 let s2 = s1;s1String)后 s1 失效,而 let b = a;ai32)后 a 仍可用。
  2. 写一个函数 fn longest_word(s: &str) -> &str,返回输入中第一个最长的单词(用空格分隔)。标注需要的生命周期,体会省略规则。
  3. 写一个持有 &str 的结构体 Config<'a>,并构造一个实例,验证它不能比借用的 String 活得更久。

第4章:结构体与枚举

现实世界的数据很少是孤立的。Rust 用结构体把相关字段组合成自定义类型,用枚举表达“一个值可能是几种形态之一”。配合模式匹配,这两者让你精确建模业务领域,并让非法状态在编译期就无法表达。

学习目标

  • 定义结构体、方法与关联函数。
  • 用枚举建模“多选一”的数据,理解 OptionResult 也是枚举。
  • matchif 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 letmatch 简洁:

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 类型安全的设计精髓。

练习

  1. 定义一个 Point 结构体与一个 Shape 枚举(CircleRectangleTriangle),用 match 为每种形状计算面积。
  2. User 结构体加一个 birthday(&mut self) 方法让 age 加 1,并写一个关联函数 User::new(name, age)
  3. 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 既能抽象又不损失性能的关键。

练习

  1. 写一个泛型函数 fn first<T>(v: &[T]) -> Option<&T>,返回切片首元素。
  2. 定义一个 Drawable trait(fn draw(&self)),为两个不同结构体实现它,用 Vec<Box<dyn Drawable>> 持有并遍历。
  3. 给上面的 Counter 加一个 take_n 方法(用 impl Iterator 返回值),体会关联类型如何随迭代器传播。

第6章:错误处理

错误处理是 Rust 与主流语言分道扬镳最明显之处,也是它“无畏”名声的来源。Rust 不抛异常,而是让失败的可能性显式出现在函数签名里,编译器逼你在编译期决定“出错时怎么办”。代价是更多的类型标注,回报是大量在别处运行时才暴露的崩溃,在这里被挡在了编译期之外。

学习目标

  • 区分可恢复错误(Result)与不可恢复错误(panic!)。
  • Option<T> 表示“值的缺失”。
  • Result<T, E> 与模式匹配处理预期失败。
  • ? 操作符优雅地传播错误。
  • From trait 转换错误类型。
  • 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 组合子:mapand_thenunwrap_orunwrap_or_defaultis_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())
}
}

?ResultOption 都适用。

串联多个可失败步骤

? 让一连串可失败步骤读起来像直线代码:

#![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 派生样板(DebugDisplayFrom):

# 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 最佳实践

  1. 把失败建模进类型系统。 调用方可能想处理的,用 Result 而非 panic!
  2. ? 传播。 别每个错误都 match——? 更清晰更短。
  3. 尽早附加上下文。.context(),让顶层错误说明“你在做什么”,而非只有底层原因。
  4. 库用结构化错误。 thiserror 暴露公开 Error 枚举,让调用方能 match。
  5. 应用用 anyhow 顶层编排与胶水代码用 anyhow::Result
  6. 生产路径别用 unwrap/expect 留给测试、示例、真正不可能的状态。
  7. 别吞错误。 永不写 let _ = fallible();,除非真要忽略——即便如此也加注释。

6.10 小结

Rust 把错误当值。不可恢复 bug 成 panic!,预期失败成 Result? 让传播简洁,From trait(常经 thiserror)让转换自动,anyhow 让应用代码整洁。结果是一种显式到可推理、又顺手到处处可用的错误处理。

练习

  1. 写一个 fn parse_pair(s: &str) -> Result<(i32, i32), ParseIntError>,把 "3,4" 解析成 (3, 4);再扩展为自定义错误,能报告“缺少逗号”。
  2. thiserror 定义 WeatherError(网络/解析两个变体),写一个异步函数用 ? 拉取并解析类 JSON 字符串。
  3. 把一个用了三次 unwrap() 的函数改写成用 ?anyhow::Result,每个可失败步骤加 .context()

第7章:集合类型与数据结构

到目前为止,我们持有的值要么在栈上,要么是固定大小的数组。真实程序需要在运行时动态增长数据——任务队列、记录缓存、唯一 ID 集合。Rust 标准库提供了一组精挑细选的集合类型来满足这些需求。本章覆盖日常使用最多的三种——VecHashMapHashSet——以及让它们富于表达力的迭代器机制,并通过一个实战项目把这些组件拼装成可运行的应用。

学习目标

  • 正确使用 Vec<T>,理解容量(capacity)与切片。
  • HashMapHashSet 之间做出选择并高效使用。
  • 用迭代器与闭包组合出简洁的数据处理流水线。
  • 知道何时该用 BTreeMapVecDequeLinkedList
  • 避开各类结构的常见性能陷阱。

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}");
    }
}

陷阱insertremove 涉及元素后移,是 O(n)。若你只需要“无序增删”,用 swap_remove 更快——它把末尾元素换到目标位置再弹出,O(1)。


7.2 HashMap<K, V>:键值查找

HashMap 存储键值对,查找、插入、删除平均 O(1)。键必须实现 HashEq

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 APIentry().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:?}");
}

集合支持 unionintersectiondifferencesymmetric_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 最佳实践

  1. 默认 Vec 简单、连续、缓存友好;需要按键访问时再换 map。
  2. 能预分配就预分配。 Vec::with_capacity 是最便宜的优化。
  3. 能用迭代器组合子就别写显式循环。 filter/map/collect 更清晰,且零成本。
  4. 只迭代一次就别 collect。 结果只是循环用,就保持惰性。
  5. 栈/队列用 Vec/VecDeque,别用 LinkedList 链表在 Rust 里既慢又难用。

7.8 小结

Vec 是默认集合——可增长、连续、缓存友好。HashMapHashSet 提供平均 O(1) 的键查找与去重。迭代器与闭包把这些结构变成富于表达力、零成本的数据流水线。选最简单的、够用的结构,能预分配就预分配,让借用检查器引导你走向正确的访问方式。

练习

  1. 实现 dedup_preserve_order<T: Eq + Hash + Clone>(v: &[T]) -> Vec<T>,用 HashSet 记录已见元素以保持顺序去重。
  2. 用纯迭代器组合子(无显式循环)实现:输入 Vec<i32>,返回其中正数平方之和。
  3. 用 entry API 构建一个 HashMap<String, Vec<String>>,按首字母分组单词。

第8章:模块系统与工程化

当程序规模超过一屏,组织方式就和正确性同样重要。Rust 的模块系统控制着可见性、解析路径,并把代码拆分到 crateworkspace 之中。本章讲清楚如何组织一个项目,使其从几百行平稳扩展到大型代码库而不致沦为乱麻。

学习目标

  • mod 声明模块与子模块。
  • pubpub(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::connectuse 声明用来缩短它:

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.rsfoo/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 就始终在作用域里——VecStringOptionResultprintln!。这就是 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 最佳实践

  1. 从扁平开始,等模式浮现再抽模块。 别为小程序预先搭一棵深目录树。
  2. 在 crate 根再导出干净的公开 API。 用户应当 use your_crate::Thing,而不是钻进你的内部模块树。
  3. 内部用 pub(crate) 而非 pub 让公开面保持小。
  4. 逻辑放进库,而非 main.rs 第一次写测试时就会尝到甜头。
  5. 把相关常量与类型归到一个模块,而不是让它们漂在 crate 根。

8.10 小结

Rust 模块系统的核心是受控可见性:一切默认私有,你只暴露调用方需要的部分。use 把路径引入作用域,mod(配合文件)把代码拆到文件系统,crate 与 workspace 把结构扩展到跨团队。保持公开 API 窄、库厚、main.rs 薄。

练习

  1. 把一个含三件事(解析、处理、输出)的单文件程序拆成三个文件中的模块。
  2. 加一个被两个模块共用的 pub(crate) 辅助函数,验证外部用户无法命名它。
  3. 把一个单 crate 包改造成 workspace:一个 core 库 + 一个依赖它的 cli 二进制。

第9章:并发编程

Rust “无畏并发”(fearless concurrency)的承诺建立在一个事实上:防止内存错误的同一套所有权与借用规则,也在编译期防止了数据竞争。如果两个线程共享数据,编译器会坚持要求这种共享是安全的——要么只读,要么有同步原语守护。本章覆盖两种主流模型——带共享状态的线程消息传递——以及让它们安全的 Send/Sync trait。

学习目标

  • thread::spawn 创建线程并用 join 等待。
  • ArcMutexRwLock 安全地共享数据。
  • mpsc 通道在线程间通信。
  • 理解 SendSync 及其意义。
  • 避开共享状态并发的常见死锁与陷阱。

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 SendSync

这两个标记 trait 是线程安全的基石。你不必自己实现它们;当所有字段都是 Send/Sync 时,编译器会自动派生。

  • Send:类型 TSend,表示把 T 移动到另一个线程是安全的。
  • Sync:类型 TSync,表示多个线程同时持有 &T 是安全的(即 &TSend 的)。

绝大多数类型两者皆是。例外是“无同步的内可变性”类型——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 陷阱

  1. 死锁:不同线程以不同顺序获取两把锁会死锁。按全局一致顺序加锁,或用一把锁守护两份资源。
  2. .await 持有 std::sync::Mutex:见上节,改用 tokio::sync::Mutex 或缩小作用域。
  3. 跨线程用 Rc:编译器会拒绝(Rc 不是 Send),改用 Arc
  4. 忘记 join:分离的线程可能比它引用的数据活得久——不过 Rust 强制 move'static 借用,这在编译期就被捕获。
  5. 锁太多:每个操作都取全局锁,等于串行化了。改用更细粒度的锁、分片数据或通道。

9.9 小结

Rust 让数据竞争在构造上不可能:共享可变状态需要 Mutex,跨线程引用计数需要 ArcSend/Sync 在编译期检查。线程间通信用通道,共享可变数据用 Arc<Mutex<T>>(或 RwLock)。借用检查器把其他语言里难以复现的并发 bug,在这里变成了编译错误。

练习

  1. 起 10 个线程,各对一个共享的 Arc<Mutex<i32>> 自增 1000 次,打印最终值。
  2. 用通道重做上题:每个线程把自己的增量发给接收端汇总。
  3. 用两个通道搭一条流水线:一线程产生数字、一线程平方、一线程打印。

第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}");
            }
        });
    }
}

超出玩具规模就该上框架——axumactix-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(紧凑二进制)、tomlyaml、经 prostprotobuf——背后是同一套 Serialize/Deserialize 派生。


10.6 最佳实践

  1. BufReader / BufWriter 逐字节读 socket 慢得可怕;缓冲几乎总是对的。
  2. 给读取设上限。 永远别按不可信的长度字段无上限分配缓冲——经典的 DoS 入口。
  3. 设超时。 永不收数据的 socket 会无限挂起。用 stream.set_read_timeout(Some(...)),或异步里用 tokio::time::timeout
  4. 高扇出用异步。 预期数千并发连接时,每连接一线程浪费内存。
  5. 生产用 TLS。 学习用明文 TCP 无妨;面向互联网的任何东西都要终结 TLS(如 rustls)。

10.7 小结

std::net 给你同步 TCP/UDP;Tokio 给你同样原语的非阻塞版本,让单线程管理数千 socket。HTTP 是 TCP 上的文本,小服务器触手可及,但生产代码应交给 axum/hyper。无论何处,serde 在字节与类型化结构体之间搬运。Rust 的网络编程该底层时能底层,该省事时能省事。

练习

  1. 把同步 TCP 服务器改为逐行回显,直到客户端断开。
  2. 用 Tokio 改写为异步版本,并为每个连接加 5 秒读超时。
  3. 实现一个基于 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 把所有行装入 Vecfetch_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 最佳实践

  1. 绑定,绝不拼接。 参数既安全又常常更快(驱动可缓存预编译语句)。
  2. 池只建一次,处处共享。 启动时建一个池,把 Pool 句柄(内部是 Arc)克隆进各 handler。
  3. 事务保持短小。 长事务持锁,拖累吞吐。
  4. 部署期迁移,而非每次请求。 迁移作为启动步骤或独立命令运行。
  5. query_as! 拿类型安全。 编译期校验免费消除一大类 bug。

11.9 小结

sqlx 给 Rust 提供异步、类型检查的数据库访问。用参数绑定防注入,用 FromRow 映射行,全程序共享一个连接池,多步写操作用事务守护,用迁移演进 schema。结果是与你程序其余部分一样经过静态检查的数据库代码。

练习

  1. 写一个 CLI,对 SQLite 中的待办事项进行新增、列表、完成操作。
  2. 增加 usersposts 两张表,用事务原子地创建用户与其首篇帖子。
  3. 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))
}
}

提取器顺序有讲究:PathQuery 放哪都行,但消费请求体的提取器(JsonString)必须放在最后


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 最佳实践

  1. handler 要薄。 把逻辑推进库;handler 只解析输入、调服务、塑形响应。
  2. 每个 API 一个错误类型。IntoResponse 映射,让错误长相统一。
  3. 在边界校验。 在畸形输入进入领域代码前就拒掉——serdevalidator crate 覆盖多数情况。
  4. 状态走 Arc,别用 static 这样能与测试组合。
  5. 早加可观测性。 TraceLayertracing 给你结构化日志,生产时会感激。

12.9 小结

axum 把 HTTP 变成有类型的 Rust:提取器解析请求,Json 序列化请求体,State 共享资源,错误类型配 IntoResponse 让响应统一。中间件层加日志、CORS、异常恢复。结果是一个与你静态检查的代码库其余部分观感一致的 Web 服务。

练习

  1. GET(列表)、POST(创建)、GET /:id(详情)实现 /todos 资源,背后用 Mutex 包一个内存 Vec
  2. 加一个 ApiError 类型:未知 id 返回 404,空标题返回 400
  3. TraceLayertracing 订阅器,记录每个请求的方法、路径、状态。

第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_capacitywrite! 写入 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 但较慢。对可信、非对抗性键,ahashrustc-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 的数据并行。rayoniter()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 最佳实践

  1. 改前改后都测。 没有测量的改动只是猜测。
  2. 剖析整个程序,而非微基准——当你关心端到端速度时。
  3. 先砍分配。 这是地道 Rust 里最容易的大红利。
  4. 尊重缓存。 连续、顺序、可预测的访问胜出。
  5. 别和优化器较劲。 写清晰、单态化的代码;cargo build --release 负责其余。

13.10 小结

Rust 的性能工作从测量开始:criterion 做微基准,flamegraph 看全程序。常见红利是更少分配、更好的缓存布局、合适的并发模型——CPU 用线程与 rayon,I/O 用异步。多数 Rust 代码已经很快;这些技术让它随规模增长依然快。

练习

  1. Vec::push 有无 with_capacity 做基准测试,报告差异。
  2. 把一个数组结构体示例改成结构体数组,对单字段求和做基准对比。
  3. 在热循环里把 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 风格)。
  • secrecy crate 把密钥包进 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(用 getrandomuuid v4)。
  • 服务端存会话,映射到用户 id,带过期。
  • cookie 设 HttpOnly(JS 不可访问)、Secure(仅 HTTPS)、SameSite=Lax(防 CSRF)。
  • 权限变更(登录、提权)时轮换 token。

无状态 token(JWT)用强算法(EdDSA 或 HS256 配长密钥),设短过期,敏感操作用服务端 denylist 撤销。


14.9 最佳实践

  1. 所有外部输入都视为敌意,直到被校验。
  2. 绑定,绝不拼接——SQL、shell、URL 构造都是。
  3. 用 Argon2 哈希密码;永不存储或记录。
  4. 密钥从环境加载,包起来让它无法被打印。
  5. 公共互联网处处 TLS。
  6. 设安全头,尤其 CSP。
  7. 保持依赖更新——cargo audit 标记已知漏洞。

14.10 小结

Rust 移除了内存安全攻击面——这是真实漏洞里很大的一块。剩下的是应用层:校验输入、防注入、哈希密码、保护密钥、终结 TLS。把 Rust 的编译期保证与这些边界纪律结合,你就得到一个比多数技术栈显著更硬的目标。

练习

  1. 给注册 handler 加 validator,拒掉短于 3 字符的用户名与非法邮箱。
  2. argon2 哈希并验证密码,把哈希串存进 SQLite 行。
  3. 把 API key 包进 secrecy::Secret,确认编译器拒绝意外的 println!
  4. 给 Axum 路由加 Strict-Transport-Security 与一个基础 Content-Security-Policy 头。

第15章:测试与调试

Rust 的测试能力内建于语言:编译器认识 #[test],标准库自带断言,cargo test 一条命令跑完一切。本章覆盖单元测试、集成测试与文档测试,再讲到编译器查不出的那些 bug 的工具——基于性质的测试、结构化日志与调试器。

学习目标

  • 编写单元测试、集成测试与文档测试。
  • 组织测试模块并使用常用断言宏。
  • 测试异步代码与外部依赖。
  • 用基于性质的测试生成随机输入。
  • tracinglldb 调试。

实战项目:构建一个自动化测试系统,覆盖多层级测试、性能监控与报告生成。


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::pauseadvance 让测试无需真实延时即可确定性推进。


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 最佳实践

  1. 测行为,不测实现。 钻进私有内部的测试每次重构都会碎。
  2. 一个测试一个断言(尽量)。窄测试能定位失败。
  3. 保持快速测试快。 把慢的集成测试藏在 feature flag 后,让 cargo test 保持利落。
  4. 先写失败的测试。 它先确认 bug 存在,再修。
  5. 纯函数用性质测试。 那是 proptest 的用武之地。

15.10 小结

cargo test#[cfg(test)] 模块里的单元测试、tests/ 里的集成测试、/// 注释里的文档测试——一条命令,三种覆盖。用 trait 注入依赖来隔离测试,用 proptest 猎杀边界,行为出错时上 tracinglldb。Rust 的测试无聊得恰到好处:它就是代码,由同一套工具链编译运行。

练习

  1. 给一个 sort 包装函数加单元测试与文档测试,确认都在 cargo test 下运行。
  2. proptest 验证:对一个 Vec 反转两次得到原值。
  3. 给函数加 tracing span,用 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 产出静态(或近静态)链接的二进制,所以运行时镜像可以极小。多阶段构建在完整镜像里编译,再把二进制拷进 scratchdebian: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 让日志聚合器索引。
  • 指标——计数器与直方图,经 prometheusmetrics crate,在 /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 最佳实践

  1. 一次构建,处处运行。 一个从环境读配置的 release 二进制,在 Docker、systemd、Kubernetes 里都不用改。
  2. 配置缺失就快速失败。 启动时若必需变量缺失,带着清晰消息退出——别带病运行。
  3. 保持运行时镜像小。 scratch 或 slim 基础镜像,无工具链、无源码。
  4. 两个健康端点都实现。 liveness 不等于 readiness。
  5. 处理 SIGTERM 优雅关闭是滚动更新安全的保证。
  6. 从第一天就可观测。 后补日志与指标很痛苦。

16.8 小结

一个 Rust 服务以单个优化过的二进制形式交付,装在小容器里,由环境变量配置,经健康检查监督,在 SIGTERM 时排干。release profile 与多阶段 Docker 镜像是机械核心;liveness/readiness 端点、优雅关闭、结构化可观测性是让它能在生产运营的关键。Rust 的产出异常易部署——把这份轻松花在良好的运维卫生上即可。

练习

  1. 配置 release profile(ltostrippanic = "abort"),对比二进制大小与启动时间。
  2. 写一个多阶段 Dockerfile,构建你的 Axum 服务并以非 root 用户运行。
  3. /health/ready 端点,以及 SIGTERM 优雅关闭处理。
  4. tracing_subscriber 输出 JSON 日志,并通过 RUST_LOG 按级别过滤。

第17章:嵌入式 Rust

Rust 不仅能跑在服务器上,也能跑在单片机里。所有权模型在 Web 服务里防止的内存错误,正是嵌入式开发长期以来的痛点;而 no_std 让你可以完全抛开标准库,只保留语言内核。本章是一次短途导览:no_std 意味着什么、embedded-hal 抽象层如何让代码可移植,以及如何在典型单片机上点亮一颗 LED。

学习目标

  • 理解 #![no_std] 以及 core / alloc / std 三个层次。
  • 使用 embedded-hal trait 编写可移植的外设驱动。
  • 为单片机目标进行交叉编译。
  • 用 HAL/PAC crate 实现一个闪烁 LED 的程序。
  • 知道嵌入式生态的深入方向。

17.1 三个层次:core、alloc、std

Rust 代码可以面向三个层次之一,由属性控制:

层次提供内容属性典型场景
std堆、线程、文件、网络(默认)桌面、服务端
allocBoxVecStringArc#![no_std] + extern crate allocOS 内核、较大的嵌入式系统
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-hal trait,提供安全 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);
    }
}

三点值得注意:

  1. #![no_main]——没有标准 maincortex-m_rt::entry 定义了复位处理函数。
  2. panic_halt as _——#![no_std] 二进制必须提供 panic 处理器;这里让 CPU 停机。
  3. 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 交叉编译到裸机目标。结果是具备与服务端同等内存安全保证的单片机固件——对一个长期被缓冲区溢出与悬垂指针困扰的领域而言,这是实实在在的改变。

练习

  1. 写一个 #![no_std] 函数 fn count_ones(bytes: &[u8]) -> u32 统计置位比特数,并在主机上用 cargo test 做单元测试。
  2. 安装 thumbv7em-none-eabihf 目标,确认一个 #![no_std] crate 能为之构建。
  3. 阅读 Embedded Rust Book 第一章,为你手头的板子找出对应的 PAC、HAL、BSP。

第18章:Rust 进阶资源与官方书导读

你已经读到了本书的末尾,但 Rust 是一门庞大的语言,生态也在快速演进。本章是一张经过筛选的地图:接下来该读哪些权威资料、该装哪些工具、该常备哪些参考,以及一条从“能写 Rust”到“熟练驾驭 Rust”的进阶路径。

学习目标

  • 了解官方文档体系,知道何时查阅哪一份。
  • rustupcargoclippyrustfmt 搭建日常工具链。
  • 在 crate 生态中导航并评估质量。
  • 沿一条 deliberate 的路径走向熟练。

18.1 官方文档

Rust 项目维护着一批免费且互相交叉引用的书籍,各司其职:

资源网址用途
The Rust Programming Language(“the Book”)doc.rust-lang.org/book带项目实战的引导式入门,最权威的起点
Rust by Exampledoc.rust-lang.org/rust-by-example按主题组织、可直接运行的代码片段,速查用
The Rust Referencedoc.rust-lang.org/reference精确、权威的语言语义(非教程)
The Rustonomicondoc.rust-lang.org/nomicon“黑魔法”:unsafe、FFI、底层内存
The Async Bookrust-lang.github.io/async-bookasync/await 底层原理
The Cargo Bookdoc.rust-lang.org/cargo构建系统与打包的一切
The Edition Guidedoc.rust-lang.org/edition-guide2015、2018、2021 edition 之间的差异
The API Guidelinesrust-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用途
序列化serdeserde_json通用(反)序列化层
错误处理thiserroranyhow库与应用的错误类型
异步运行时tokio主流异步运行时
HTTP 服务axumactix-webWeb 框架
HTTP 客户端reqwest高层阻塞/异步客户端
数据库sqlx异步、编译期校验的 SQL
日志tracingtracing-subscriber结构化日志与 span
随机数rand随机数生态
正则regexPerl 风格正则
CLI 解析clap带 derive 宏的参数解析
日期时间chronotime日期时间运算
并行rayon数据并行迭代器

评估一个 crate:crates.io 看下载量与近期版本日期,读 README,扫一眼未关闭的 issue,优先选维护活跃、文档完善的。一个两年没更新的 crate 是负债。


18.4 走向熟练的路径

  1. 通读 the Book。 相比其覆盖面,它并不长,而且边讲边搭一个 grep 克隆项目。
  2. rustlings 一组小练习,补上 the Book 留给读者练习的空档。
  3. 做个真实项目。 一个 CLI 工具、一个小 Web 服务、一个小游戏——自有项目会暴露教程预见不到的问题。
  4. 读优秀的代码。 标准库、serdetokioaxum 都写得很好,值得学习。
  5. 最后再写 unsafe 多数 Rust 程序员很少需要它;真要用时,先读 Rustonomicon。
  6. 融入社区。 rust-users 论坛、Discord、本地 meetup 都很友好且底蕴深厚。

18.5 保持同步

Rust 每六周发布一次——稳定的发布列车,而非漫长的大版本间隔。多数发布是增量式的。留意偶尔出现的 edition(一次引入小幅语言便利而不破坏生态的机会),以及年度 Rust 调查,了解社区走向。

rustup update 放进日常,浏览发布说明,把小幅风格变化交给 clippyrustfmt 吸收即可。


18.6 结语

Rust 的承诺是:你可以写出快速、底层的代码,而不必背负通常随之而来的恐惧。编译器很严格,但正是这份严格,让你能放心地重构大型代码库、上线一个不会因空指针崩溃的服务、发布一段不会缓冲区溢出的固件。学习投入是真实的,回报同样真实。

让标准库文档常开,每天写一点代码,让借用检查器来教你。欢迎来到 Rust 的世界。