全景:Rust 的定位与现状
钻进语法之前先回答三个问题:Rust 解决什么问题、今天它用在哪里、和邻近语言怎么选。后面每一章都是这张地图的放大。
Rust 的立身之本是无 GC 的内存安全:靠所有权 + 借用检查,在编译期消灭悬垂指针、二次释放与数据竞争,同时保住 C/C++ 级性能——安全不靠运行时,靠类型系统。
今天它占据哪些疆域
- CLI 工具:ripgrep、fd、bat 这批「重写经典 Unix 工具」的代表作;
- 基础设施:AWS 的 Firecracker,以及 JS 工具链的「Rust 化」浪潮(SWC、Turbopack、Rspack);
- 还有 WebAssembly 的头号源语言,以及自 6.1 起正式接纳 Rust 的 Linux 内核。
和邻居怎么选
- vs C++:Rust 赢在编译期安全保证与现代工具链,C++ 胜在数十年生态存量与既有团队;
- vs Go:普通业务后端 Go 常常够用,要极致控制力与零成本抽象才轮到 Rust;
- 什么时候别选:脚本与胶水、快速原型、团队没人愿意过借用检查这一关。
// 没有 GC,也没有悬垂指针:所有权在编译期算清一切
fn main() {
let langs = vec!["C++", "Go", "Rust"];
let short: Vec<_> = langs
.iter()
.filter(|l| l.len() < 4)
.collect(); // 零成本抽象:迭代器编译成裸循环
println!("{:?}", short);
} // langs 在此自动释放——无需手动 free,也无 GC 停顿Rc 循环引用和 std::mem::forget 在纯 safe 代码里就能泄漏内存。跑通第一个程序
在讲任何语法之前,先让机器把你写的东西跑起来。这一章只做四件事:装好工具链、用 cargo 建出并运行第一个程序、逐行看懂它、以及学会读 rustc 的报错——最后这件对 Rust 尤其要紧:你会和编译器打非常多交道,而它的报错是这门语言最好的老师。
Rust 的上手是主流系统语言里最省事的:一个 rustup 装完全部——编译器、包管理、构建、测试、格式化、文档,全在一条 cargo 命令下面。
装工具链
- 照 rustup.rs 首页给的一条命令装即可;Windows 会提示需要 MSVC 生成工具,按指引装上;
rustup是工具链管理器而不是编译器——它管着 stable/nightly 与交叉编译目标。
建项目、跑起来
cargo new hello生成Cargo.toml(项目清单)+src/main.rs+ 一个初始化好的 git 仓库;cargo run一条命令编译并运行——日常几乎只用run/check/test三条。
// ── 终端里依次执行 ──────────────────
// cargo new hello 建项目(含 Cargo.toml + src/main.rs + git)
// cd hello
// cargo run 编译并运行
//
// Compiling hello v0.1.0 (/path/hello)
// Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
// Running `target/debug/hello`
// Hello, world!
// ── Cargo.toml:项目清单 ────────────
// [package]
// name = "hello"
// version = "0.1.0"
// edition = "2024" ← 版次,cargo new 现在默认给 2024
//
// [dependencies] ← 依赖写这里(用 cargo add 自动添加)
// ── src/main.rs:cargo new 生成的内容 ──
fn main() {
println!("Hello, world!");
}rustc 直接编译真实项目——它只适合编译单个 .rs 文件做小实验,不管依赖、不管构建配置。日常一律走 cargo:一旦要引入第三方 crate,rustc 那条路就走不通了。另外 target/ 目录动辄上 GB,不要提交进 Git,也不要在里面找源码。Cargo.toml 里的 edition = "2024" 是版次:它让语言能引入不兼容改进而不破坏老代码,不同 edition 的 crate 能互相调用。另外 cargo run 默认是 debug 构建,要测真实性能必须加 --release。cargo new 生成的程序只有三行有效代码,但每行都牵出 Rust 的一条基本规则。
fn main() 与 println!
- 可执行程序必须有且只有一个
main,且所有代码都得写在某个函数里; println!末尾那个!表示它是宏不是函数——宏在编译期展开,所以它能检查格式串与参数是否对得上。
let 默认不可变,以及那个分号
let x = 5;把值绑定到名字上,默认不可变,想改必须写let mut——这是 Rust 与几乎所有主流语言相反的默认值;- 分号决定它是语句还是表达式:块尾去掉分号,那个值就是整个块的返回值。这条贯穿全语言。
fn main() { // ① 程序入口
let name = "Rust"; // ② 绑定,默认不可变
let mut count = 0; // 要改就得写 mut
count += 1;
println!("{name} 第 {count} 次"); // ③ ! = 宏,花括号里可直接写变量名
}
// 表达式 vs 语句:分号决定有没有值
fn double(x: i32) -> i32 {
x * 2 // ✅ 无分号 = 这就是返回值
}
fn broken(x: i32) -> i32 {
x * 2; // ❌ 有分号 = 丢弃值,函数返回 ()
} // error[E0308]: mismatched types
// 上面那行下方会标:expected `i32`, found `()`
// if 也是表达式,能直接给变量赋值(注意这行同样得在 fn 里)
// let label = if count > 0 { "有" } else { "无" };let 只能出现在函数体内,写在文件顶层会得到 error: expected item, found keyword let。顶层只能放 fn、struct、const、static 这类「条目」。cargo fmt(自动格式化)和 cargo clippy(比编译器更啰嗦的 lint)。Clippy 尤其值得初学就用上:它会直接告诉你「这里有更地道的写法」,比如把手写循环换成迭代器、把 match 换成 if let——相当于一个免费的代码评审。你会和 Rust 编译器打非常多交道。好消息是:rustc 的报错是这门语言最好的老师——它不只说哪里错了,还会指出值是在哪一行被移走的、借用在哪里还活着。
一条报错的四个部分
- ① 标题行
error[E0382]: borrow of moved value——方括号里是错误码,同一类问题永远同一个码; - ② 位置、③ span 标注(把相关几行代码连同箭头一起画出来)、④ help/note;
- 看到错误码就能
rustc --explain E0382——那是官方写好的一篇小教程。
两条读法
- 先读 span 标注再读文字:那几行带箭头的代码往往比描述更快让你看懂;
- 从第一条错误改起,后面的常常是它的连锁反应。
fn main() { // ← src/main.rs 第 1 行
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}");
}
// cargo run 得到(这是 rustc 1.9x 的真实输出):
//
// error[E0382]: borrow of moved value: `s1` ← ① 错误码 + 一句话
// --> src/main.rs:4:16 ← ② 位置
// | ← ③ span 标注:一个故事
// 2 | let s1 = String::from("hello");
// | -- move occurs because `s1` has type `String`,
// | which does not implement the `Copy` trait
// 3 | let s2 = s1;
// | -- value moved here ← 在这被移走
// 4 | println!("{s1}");
// | ^^ value borrowed here after move ← 移走后又用了
// |
// help: consider cloning the value if the performance cost is acceptable
// | ← ④ 可直接照抄的补丁
// 3 | let s2 = s1.clone();
// | ++++++++
//
// For more information about this error, try `rustc --explain E0382`.
// ── 另外两个初学期必遇的错误码 ──────────────
// error[E0384]: cannot assign twice to immutable variable `x`
// help: consider making this binding mutable → let mut x = 5;
//
// error[E0502]: cannot borrow `v` as mutable because it is also
// borrowed as immutable ← 借用规则,见 04 章help: 不是「标准答案」,只是「能编过的最短路径」。E0382 建议你 .clone(),照做确实能编过——但在循环里对大对象这么干,就把一个编译期问题换成了运行期的性能问题。先问「为什么它认为这里有冲突」。cargo check 只做类型与借用检查、不生成机器码,比 cargo build 快好几倍。学所有权那几章时用它做快速反馈循环最合适:改一行、cargo check、看报错,几秒一轮。等真要运行时再 cargo run。基础语法:类型、控制流与结构体
Rust 是静态强类型,数值类型显式区分位宽与符号。if 是表达式、loop 能带值返回,控制流贯彻表达式思维;println! 宏家族与 read_line 覆盖终端输入输出。结构体(struct)+ impl 块组织数据与行为,derive 宏自动生成常用 trait。
01 章已经立过三句话(fn main 是入口、let 绑定且默认不可变、类型多数能自动推断)。这一卡把类型家族补齐——Rust 的类型比多数语言分得细,但每一条区分都有明确用途。
先说清「栈」和「堆」
- 这两个词后面每一章都要用,现在花三十秒建立印象:栈是函数调用时自动分配、返回时自动回收的一小块内存,快、但要求大小在编译期已知;
- 堆是运行时按需申请的大块内存,能装编译期不知道多大的东西(比如用户输入的字符串),代价是分配更慢、且需要有人负责归还;
- 「谁负责归还堆内存」正是各语言的分水岭:C 靠你手写
free,Java/Go 靠垃圾回收,Rust 靠所有权在编译期算清楚——这就是 03 章的主题。
标量类型
- 整数按位宽与符号分:
i8..i128/u8..u128,外加指针宽的isize/usize(usize是索引和长度的类型);不确定时用i32,它是默认值也是多数场合最快的; - 浮点
f32/f64,默认f64; char是 4 字节 Unicode 标量而非一个字节——所以'🦀'是合法的单个 char;bool就是true/false,且不能和整数互转(没有「非零即真」)。
复合类型:定长在栈,可变长在堆
- 元组
(i32, f64, bool):定长、元素类型可以不同,用解构取值; - 数组
[i32; 3]:定长、元素类型必须相同,长度是类型的一部分——放在栈上; Vec<T>:可增长的同构序列,数据放在堆上。日常用Vec的时候远多于数组。
// ⚠️ let 只能写在函数体内——下面整段都在 main 里
fn main() {
let x = 5; // x = 6; ❌ 默认不可变,改不了
let mut y = 5;
y += 1; // ✅ 写了 mut 才能修改
let big: u64 = 1_000_000; // _ 只是可读分隔,不影响值
let hex = 0xFF; // 推断为 i32
let pi: f64 = 3.14159;
let emoji: char = '🦀'; // char 是 4 字节 Unicode 标量
// 元组:定长,元素类型可不同
let tup: (i32, f64, bool) = (500, 6.4, true);
let (a, b, c) = tup; // 解构取值
// 数组(栈上,定长) vs Vec(堆上,可增长)
let arr: [i32; 3] = [1, 2, 3];
let mut v = vec![1, 2, 3]; // vec! 宏
v.push(4); // Vec 能增长,数组不能
println!("{x} {y} {big} {hex} {pi} {emoji} {a} {b} {c} {arr:?} {v:?}");
} // {:?} 是 Debug 格式,见 06 章checked_add(溢出返回 None)、saturating_add(封顶)、wrapping_add(明确回绕)。len() 的返回值都是 usize——拿 i32 变量当下标直接编译失败:error[E0277]: the type `[{integer}]` cannot be indexed by `i32`(rustc 1.97)。循环计数、下标变量一开始就声明成 usize,能省掉满屏的 as usize;字面量要指定类型时用后缀:255u8、1.5f32。Rust 的控制流贯彻「一切皆表达式」:if、loop、代码块都能产出值,所以 Rust 干脆没有三元运算符——let x = if cond { a } else { b }; 就是三元,代价是两个分支的类型必须一致(编译器要能给 x 定唯一类型)。
四种循环各司其职
loop:无限循环,也是唯一能用 break 带值把结果传出来的循环——「重试直到成功并拿到结果」场景的标配;while:条件循环;变体while let Some(x) = ...在模式匹配成功期间持续执行,等 Option 变成 None 自动停(模式匹配详见 07 章);for:遍历一切迭代器——Range(0..n半开、0..=n闭区间)、数组、Vec、HashMap 都行(迭代器适配器见 09 章);Rust 没有 C 风格 for(;;),需要下标就写for i in 0..v.len()或更地道的enumerate();- 多层嵌套用标签:
'outer: for ...配break 'outer,一次跳出所有层,不需要 flag 变量。
// if 是表达式:可直接赋值,分支类型必须一致(Rust 没有三元运算符)
let n = 7;
let parity = if n % 2 == 0 { "偶" } else { "奇" };
// loop:唯一能用 break 带值传出结果的循环
let mut count = 0;
let total = loop {
count += 1;
if count == 10 { break count * 2; } // total = 20
};
// while let:循环到不匹配自动停(完整示例见 07 章模式匹配)
// for:遍历 Range 与一切迭代器
for i in 0..3 { print!("{i} "); } // 0 1 2(半开)
for i in 0..=3 { print!("{i} "); } // 0 1 2 3(闭区间)
let v = vec!["a", "b"];
for s in &v { println!("{s}"); } // 借用遍历,v 之后仍可用
// 标签:一次跳出多层嵌套
'outer: for x in 0..5 {
for y in 0..5 {
if x * y > 6 { break 'outer; }
}
}for x in vec 会把 vec 的所有权 move 进循环,循环结束后 vec 就不能再用了(所有权规则见 03 章)!只读遍历写 for x in &vec(或 .iter()),边遍历边改元素写 for x in &mut vec(或 .iter_mut())。另一个高发错误:if 两个分支返回不同类型(如一支 i32、一支 &str)直接编译失败——表达式必须有唯一类型。()。让 if/match 直接作为 let 右值或函数返回值,是写出地道 Rust 的第一步。另注意:break 值 只在 loop 中合法,while/for 的 break 不能带值(它们可能因条件不满足而「正常结束」,没有值可给)。格式化输出是一个宏家族:println! 打到标准输出并换行,print! 不换行,eprintln! 走标准错误(错误信息不污染管道输出),format! 不打印、直接生成 String——四者共用同一套占位符语法。
占位符速查
{}走 Display(面向用户,基本类型自带);{:?}走 Debug(面向开发者,自定义类型加#[derive(Debug)]即可用);{:#?}是多行美化版 Debug;{0}/{1}位置参数可重复引用;Rust 1.58 起可直接内嵌变量名:println!("{x}"),最推荐;- 宽度/精度:
{:8}最小宽 8、{:.2}保留 2 位小数、{:>8.2}右对齐组合用。
读输入的标准姿势
io::stdin().read_line(&mut buf)读一行追加进 buf(含结尾换行);- 先
trim()去掉换行与首尾空白,再parse::<i32>()解析成数字——parse 返回 Result,原型期用 expect,正式代码用?传播(见 08 章错误处理)。
use std::io;
// 占位符:{} 走 Display,{:?} 走 Debug,{:.2} 控制精度
let name = "Rust";
let pi = 3.14159;
println!("{} 的 pi ≈ {:.2}", name, pi); // Rust 的 pi ≈ 3.14
println!("{name}"); // 直接内嵌变量(1.58 起,推荐)
println!("{0}-{1}-{0}", "a", "b"); // 位置参数:a-b-a
#[derive(Debug)]
struct Point { x: i32, y: i32 }
let p = Point { x: 1, y: 2 };
println!("{:?}", p); // 单行:Point { x: 1, y: 2 }
println!("{:#?}", p); // 多行美化,调试大结构体用它
// 读一行 → 去换行 → 解析成数字
let mut buf = String::new();
io::stdin().read_line(&mut buf).expect("读取失败");
let n: i32 = buf.trim().parse().expect("请输入整数");
// 等价写法(turbofish 显式指定类型):buf.trim().parse::<i32>()read_line 是追加语义:它把新读到的一行接在 buf 已有内容后面,不会清空——循环里读多次必须每轮先 buf.clear(),否则第二轮起 parse 必定失败。读进来的内容自带结尾换行(\n,Windows 下是 \r\n),不 trim 直接 parse 会得到 Err。Result<i32, ParseIntError>,除了 expect,还能用 ? 向上传播,或 match 后给用户友好提示并重试(见 08 章)。Rust 没有 class:数据用 struct 声明,行为写在 impl 块里,两者分开又配合——这一卡看它们怎么拼出别的语言里「类」承担的角色。
数据与行为分开写
struct 定义数据,impl 块定义行为。关联函数无 self(类似静态方法,常用作构造器 new);方法首参是 &self(只读)或 &mut self(可改)。字段同名可简写。#[derive(...)] 自动生成 Debug/Clone/PartialEq 等。
「没有 class」带来的三个实际差别
- 一个类型可以有多个
impl块,甚至分散在不同文件里(只要同一 crate)。所以「这个类型有哪些方法」不是看一处声明,而是看所有 impl——包括impl SomeTrait for Point; - 没有继承。复用行为的手段是 trait(默认方法)与组合,不是父类。这不是省略,是刻意的——继承带来的隐式行为与 Rust 的显式风格相冲;
new没有任何语言层地位:它就是个普通关联函数,叫create、from_parts都行。约定俗成而已,也没有「构造函数一定会被调用」这种保证;- 方法的第一个参数决定了调用方拿到什么:
&self只读借用、&mut self可变借用、self消耗掉调用者(常见于 builder 的链式方法与into_*系列)。这三者的区别直接就是 03/04 章那套所有权规则,方法签名一眼能看出它会不会拿走你的值。
struct User { name: String, age: u32, active: bool }
impl User {
fn new(name: String) -> Self { // 关联函数(构造器)
User { name, age: 0, active: true } // 字段简写
}
fn greet(&self) -> String { // 方法(只读)
format!("Hi, {}", self.name)
}
fn deactivate(&mut self) { self.active = false; }
}
// 结构体更新语法 + derive
#[derive(Debug, Clone, PartialEq)]
struct Point { x: f64, y: f64 }
let p = Point { x: 1.0, y: 2.0 };
println!("{:?}", p); // Debug 打印String/Vec 等堆类型时,把整个结构体(或字段)赋给别处会移动所有权,原值随即失效;要保留原值就 .clone(),或让方法收 &self 借用而非 self。也正因如此,含堆字段的结构体不能 #[derive(Copy)]——只有全部字段都是 Copy 才行。#[derive(Debug)]:没有它 {:?} 打印直接编译失败——error[E0277]: `Point` doesn't implement `Debug`(rustc 1.97),报错里就带「consider annotating with #[derive(Debug)]」的补丁。另外 new 只是约定俗成的构造器名字,没有任何语言层特殊地位——它就是个普通关联函数。所有权
所有权是 Rust 的灵魂——无 GC 却内存安全的根基。三条规则:每个值有唯一所有者;同一时刻只能有一个所有者;所有者离开作用域时值被自动释放(drop)。
所有权是 Rust 最独特、也最先让人却步的设计。它要解决的问题很具体:堆上的内存该由谁负责释放。C 交给你手写 free(忘了就泄漏、重复了就崩溃),Java/Go 交给垃圾回收器(省心但有停顿)。Rust 的答案是在编译期就把这件事算清楚,运行时零开销。
三条规则
- 每个值有唯一的所有者;
- 同一时刻只能有一个所有者;
- 所有者离开作用域时,值被自动释放(drop)——不需要你写任何释放代码。
「移动」是什么
- 把一个堆上的值(
String/Vec等)赋给另一个变量、或传给函数,所有权会移动(move),原变量随即失效,之后再用它就是编译错误; - 这不是「拷贝了一份」——底层只是把那三个字(指针、长度、容量)复制过去,堆上的数据一份没动,代价和拷贝一个整数一样小;
- 为什么要让原变量失效?因为两个变量都指向同一块堆内存的话,作用域结束时会释放两次——这正是 C/C++ 里 double free 崩溃的成因。移动语义从根上杜绝了它。
栈上类型的例外:Copy
i32/bool/f64/char这类完全放在栈上、大小固定的类型实现了Copy:赋值就是按位复制一份,两个变量都继续有效;- 判据很简单:复制它不涉及堆内存,就可以是 Copy。所以
String不是 Copy,而(i32, bool)是。
fn main() {
let s1 = String::from("hello");
let s2 = s1; // 所有权移动给 s2,s1 失效
// println!("{}", s1); // ❌ 编译错误:s1 已被移走
println!("{}", s2); // ✅
// 栈上基本类型实现 Copy,赋值是复制
let x: i32 = 5;
let y = x; // x 被复制,x 和 y 都有效
println!("{} {}", x, y); // ✅
} // s2 离开作用域,String 内存自动释放(drop)error[E0382]: borrow of moved value。别急着 .clone()——编译器的 help 会建议 clone,但那只是「能编过的最短路径」。先问自己:我真的需要两份数据吗?绝大多数时候答案是否,你要的只是「看一眼」,那就该用借用(下一章):把 let s2 = s1; 改成 let s2 = &s1;,一个字符解决,且零开销。clone 是真的需要两份独立数据时才用的。函数调用遵守同一套规则,没有例外:传参 = 移动进函数,返回 = 移动出来。理解这一点,才能理解下一章的借用为什么是必需的而不是可选的。
值进了函数就不再属于你
- 把
String传给函数,所有权移进去;函数结束时它在函数内部被 drop,调用方的原变量已经失效; - 如果函数把它
return出来,所有权又移回调用方——但这意味着每个只想「读一下」的函数都得把值还给你,签名变成fn f(s: String) -> String,调用处还得let s = f(s);接住。
所以才需要借用
- 上面那种「借了必须还」的写法能用,但极其笨拙——真实 Rust 代码里几乎见不到;
- 正确做法是借用:
fn f(s: &String),函数只是临时看一眼,所有权自始至终没离开调用方。下一章讲的就是它; - 本卡的意义在于:先看清没有借用时有多麻烦,借用的价值才立得住。
fn takes(s: String) { // s 获得所有权
println!("{}", s);
} // s 离开作用域,String 被释放
fn gives() -> String { // 返回值把所有权传出
String::from("returned")
}
fn main() {
let s1 = String::from("hello");
takes(s1); // s1 所有权移进函数
// println!("{}", s1); // ❌ s1 已无效
let s2 = gives(); // 函数把所有权给 s2
}for x in v 会把 v 整个移走,循环结束后 v 就不能再用了——这是初学期非常容易撞上的一次「意外移动」,因为它长得完全不像赋值。想循环后还留着容器,写 for x in &v(借用每个元素)或 for x in v.iter();要在循环里改元素则用 for x in &mut v。String/Vec<T> 的函数会「吃掉」你的值,收 &str/&[T] 的只是借看一眼。自己写函数时默认收引用,只有确实要把值留下(塞进结构体、发给别的线程)才收所有权——这能免去调用方大量不必要的 clone。需要保留原值、又要一份独立数据时,显式调 .clone()。关键词是显式:Rust 不让你「不经意间」复制堆数据,昂贵操作必须在代码里看得见。
clone vs Copy
.clone()是深拷贝:分配一块新的堆内存,把内容复制过去,得到两份彻底独立的数据。要你手写,因为它可能很贵;Copy是按位复制:只涉及栈上那几个字节,编译器隐式做,你察觉不到——因为它便宜到不值得你关心;- 只含栈数据的自定义类型可以
#[derive(Copy, Clone)]让它走复制语义。
为什么堆类型不能 Copy
- 因为按位复制会得到两个指向同一块堆内存的值,两者各自 drop 时就会释放两次;
- 所以只要类型里含
String/Vec/Box,编译器就不允许它Copy——这不是限制,是那条「唯一所有者」规则的必然推论。
let s1 = String::from("hello");
let s2 = s1.clone(); // 深拷贝,s1 和 s2 都有效
// 仅栈数据可以 Copy
#[derive(Copy, Clone)]
struct Point { x: f64, y: f64 }
let p1 = Point { x: 1.0, y: 2.0 };
let p2 = p1; // Copy,p1 仍有效String/Vec/Box 等堆数据的类型不能 Copy(按位复制会导致双重释放)。另外,当你发现自己到处 .clone() 来「绕过」借用检查器时,通常说明设计该用引用——clone 满天飞是代码异味,而非解决方案。#[derive(Copy)] 必须和 Clone 一起写——Copy 以 Clone 为前提,只 derive 前者会报 error[E0277]: the trait bound `P: Clone` is not satisfied(rustc 1.97)。惯用组合是 #[derive(Copy, Clone)],坐标、句柄这类纯栈小类型都值得加上,赋值传参从此不用操心移动。借用与引用
引用(&T / &mut T)让你「访问」值而不夺走所有权。借用检查器在编译期保证引用永远有效,从根本上消灭数据竞争与悬垂指针,且运行时零成本。
上一章的结论是:光有移动,写起来太别扭。引用补上了这一环——它让你「访问」一个值而不夺走所有权,用完自动归还,且运行时零开销(底层就是个指针)。
两种引用
&T不可变引用:只读地借用,可以同时存在很多个;&mut T可变引用:可以修改借来的值,但同一时刻只能有一个(下一卡展开为什么);- 「借用」和「引用」在 Rust 语境里基本同义:
&这个动作叫借用,得到的东西叫引用。
要改值,两处都得写 mut
- 变量本身要可变:
let mut s = ...; - 借出去时也要声明可变:
&mut s,函数签名同样写&mut String; - 漏了任一处都编译不过——这是初学期最高频的一条报错,好在编译器的提示非常直接。
借用不会触发 drop
- 引用离开作用域时什么都不会发生——它本来就不拥有那个值,自然不负责释放;
- 所有权自始至终在原变量手里,借用只是暂时让别人看/改一下。
fn len(s: &String) -> usize { // 不可变引用,只读
s.len()
}
fn append(s: &mut String) { // 可变引用,可改
s.push_str(", world");
}
fn main() {
let mut s = String::from("hello"); // 变量需 mut
let n = len(&s); // 借用,s 仍有效
append(&mut s); // 传可变引用
println!("{} ({})", s, n); // "hello, world" (5)
}mut,会撞上两条不同的报错,别混淆:变量本身没写 mut → cannot borrow `s` as mutable, as it is not declared as mutable,改 let 为 let mut;借用时没写 &mut → cannot borrow `*s` as mutable, as it is behind a `&` reference,把 &s 改成 &mut s。看清报错说的是「声明处」还是「借用处」,就知道该改哪一个。s.len() 不用写成 (&s).len(),s.push_str(...) 也自动按 &mut 借——显式的 &/&mut 主要出现在传参和 let 绑定处。这也是为什么漏写 mut 的报错常爆在方法调用那一行:那里正发生一次隐式的可变借用。这是整门语言的核心,只有两条,但推论极多。值得把它背下来——之后你和借用检查器的每一次冲突,都能归到这两条上。
两条铁律
- ① 任意时刻,要么存在任意多个不可变引用
&T,要么存在有且仅有一个可变引用&mut T——二者不能同时存在; - ② 引用必须始终有效,不能指向已经被释放的数据(不能悬垂)。
为什么这两条就够了
- 数据竞争的定义是:两个指针同时访问同一数据、其中至少一个在写、且没有同步机制;
- 规则 ① 直接让这个前提不成立——有人在写(
&mut)时,别人连读都读不到; - 所以 Rust 的并发安全不是靠运行时加锁换来的,而是编译期就排除了这种可能。这也是「无畏并发」这个说法的由来。
NLL:比看起来宽松得多
- 引用的活跃范围是「到它最后一次被使用为止」,而不是到作用域结束——这叫 NLL(非词法生命周期);
- 所以只要可变借用和不可变借用的使用区间不重叠,就完全合法,哪怕它们写在同一个函数里;
- 这条让大量直觉上「应该没问题」的代码确实能编过。撞墙时先想想:那个旧引用后面还用到吗?用不到的话,问题多半在别处。
let mut s = String::from("hi");
// ✅ 多个不可变引用可共存
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2); // r1/r2 最后一次使用后即"失效"
// ✅ r1/r2 不再使用,现在可创建可变引用
let r3 = &mut s;
r3.push_str("!");
// ❌ 不可变与可变引用不能同时活跃:
// let a = &s; let b = &mut s; println!("{}", a); // 编译拒绝let first = &v[0]; 之后再 v.push(4);,报 error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable。这不是编译器多事——push 可能触发扩容,把整个数组搬到新的堆地址,那个 first 就变成了指向已释放内存的悬垂指针。C++ 里这正是 vector 迭代器失效,能编过、能跑、然后在某天读到垃圾数据。Rust 把它变成了一条编译错误。{} 块,冲突就自然消失了(NLL 让引用在最后一次使用后立刻失效)。如果确实需要「多处同时读写同一份数据」,那说明你要的是共享所有权而不是借用,那是 12 章 Rc/RefCell 的场景,别在这里硬扛。切片是对集合中一段连续元素的引用——不拥有数据,只是「指向哪里、多长」。它让你把「一部分」当作一个整体传来传去,而不必复制。
两种常见切片
&[T]数组/Vec 的切片,如&v[1..3];&str字符串切片,指向一段 UTF-8 数据。字符串字面量本身就是&str——它指向编译进程序里的一段只读数据,所以字面量没有所有权问题;- 切片是胖指针:除了地址还带着长度,所以它知道自己有多长,越界会被检查出来。
函数参数优先用切片
- 参数写
&str比写&String好:它同时接受&String和&str(前者会自动转换),适用面更广; - 同理,参数写
&[T]比&Vec<T>好——它还能接受数组; - 这是 Rust 里一条很稳的经验法则:参数收得越宽越好,返回给得越具体越好。
let s = String::from("hello world");
let hello = &s[0..5]; // "hello"([start..end),字节索引)
let world = &s[6..]; // "world"(省略=到末尾)
let lit: &str = "hi"; // 字面量就是 &str
// 参数用 &str 最通用
fn first_word(s: &str) -> &str {
for (i, &b) in s.as_bytes().iter().enumerate() {
if b == b' ' { return &s[0..i]; }
}
s
}
let arr = [1, 2, 3, 4, 5];
let sl: &[i32] = &arr[1..3]; // [2, 3]&str 的索引是字节,不是字符。对含中文/emoji 的字符串写 &s[0..1],如果切在了一个多字节字符的中间,程序会在运行时 panic(报「不是 char 边界」并指出该字符占哪几个字节)——注意这是运行期崩溃,编译器拦不住。另外 s[0] 这种整数索引根本不能编译:error[E0277]: the type `str` cannot be indexed by `{integer}`,编译器会提示改用 .chars().nth() 或 .bytes().nth()。按字符处理一律走 .chars()。s.get(0..n):它返回 Option,切在多字节字符中间给 None 而不是 panic——rustc 1.97 对 "中文abc",get(0..1) 得 None、get(0..3) 得 Some("中")。要按「字符数」取子串,用 char_indices() 先找到字节边界再切。生命周期
生命周期标注('a)描述多个引用之间的有效期关系。它不改变任何引用的存活时间,只是给编译器信息去验证引用是否始终有效——Rust 无 GC 又无悬垂指针的关键。
生命周期是三章里最后一块、也是最让人困惑的一块。先破除最大的误解:生命周期标注不改变任何东西的实际存活时间,它只是把引用之间的关系告诉编译器,好让借用检查器能做判断。
为什么需要标注
- 函数返回一个引用时,编译器需要知道它借的是哪个参数,才能保证调用方拿到的引用不会悬垂;
- 参数只有一个时编译器能猜出来(下一卡的省略规则);有两个引用参数、又返回引用时就猜不动了——此时必须你来标注;
<'a>读作「生命周期参数 a」,写法上和泛型参数是一套机制。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str 在说什么
- 它不是说「x 和 y 活得一样久」;
- 它说的是:返回的那个引用,有效期不超过 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("xy");
let r = longest(&s1, &s2);
println!("{}", r); // ✅ 在 s2 有效范围内使用
}
// 此处用 r 会 ❌:s2 已 drop,r 可能悬垂
}'a 都没用(报 cannot return reference to local variable):那个值在函数返回时就被 drop 了,任何指向它的引用必然悬垂。正确做法是返回拥有所有权的值(返回 String 而不是 &str)。看到这条报错,是在提示你「这里该转移所有权,而不是借用」。'a:先按直觉写不带标注的签名,等编译器报 error[E0106]: missing lifetime specifier 再补——它的 help 常常直接给出完整签名(rustc 1.97 对 longest 给的就是 fn longest<'a>(x: &'a str, y: &'a str) -> &'a str,照抄即可)。生命周期是「按需标注」,不是「处处声明」。日常写 Rust 时你其实很少手写 'a——因为编译器有一套省略规则能自动推断出绝大多数情况。知道这三条规则,就知道什么时候编译器会突然要你标注。
结构体存引用时必须标注
- 结构体字段是引用时,必须声明生命周期:
struct Excerpt<'a> { part: &'a str }; - 它的含义是:这个结构体实例不能比它引用的数据活得更久;
- 因此实践中优先让结构体持有所有权(存
String而非&str)——除非你明确要做零拷贝解析,否则带生命周期的结构体会让后续代码处处受牵连。
三条省略规则
- ① 每个引用参数各自获得一个独立的生命周期参数;
- ② 若只有一个输入生命周期,它被赋给所有输出引用——这条覆盖了绝大多数函数;
- ③ 若参数里有
&self或&mut self(即方法),self的生命周期被赋给所有输出——这条覆盖了绝大多数方法; - 三条走完仍确定不了输出的生命周期,编译器才会报错要你手写。所以「什么时候要标注」= 「这三条都没覆盖到的时候」。
'static
'static表示引用在整个程序运行期间都有效,字符串字面量就是&'static str;- 被报错逼着加
'static时先停一下:多数情况下真正的问题是设计上该转移所有权,而不是真的需要一个永生的引用。
// 持有引用的结构体必须标注
struct Excerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. ...");
let first = novel.split('.').next().unwrap();
let e = Excerpt { part: first }; // e 不能比 novel 活更久
}
// 省略规则使其无需标注
fn word(s: &str) -> &str { &s[0..1] }
let forever: &'static str = "我永远有效";'static 当成万能药。编译器提示「consider adding 'static」时,照做常常只是把错误推到别处,或逼你到处 Box::leak 泄漏内存。'static 的正确用法是描述本来就永生的东西(字面量、全局常量),而不是用来「说服」编译器接受一个活得不够久的引用。真正的解法通常是三选一:让被引用的数据活得更久、改成传所有权、或用 Rc/Arc 共享(见 12 章)。impl 行要把 'a 先声明再使用:impl<'a> Excerpt<'a> { ... }——写成 impl Excerpt<'a> 会报 error[E0261]: use of undeclared lifetime name `'a`(rustc 1.97),help 会提示在 impl 后补 <'a>。规矩和泛型 impl<T> 完全一样:先声明、后使用。Trait 系统
Trait 定义共享行为(类似接口),是 Rust 抽象与多态的核心。配合泛型实现编译期零成本的静态分发,或用 dyn 做运行时动态分发。
接口的 Rust 版本,但方向反过来——不是类型声明自己是什么,而是任何人都能事后为类型补上一份行为。
接口,但方向反过来
trait 规定类型必须实现哪些方法,可带默认实现(实现者可覆盖也可沿用)。用 impl Trait for Type 为具体类型实现。这就是 Rust 版的接口,但更强——可为别人的类型实现自己的 trait——但受孤儿规则约束:impl 某 Trait for 某类型 至少要有一方(trait 或类型)是你自己 crate 里定义的。否则两个 crate 各给同一个外部类型实现同一个外部 trait,编译器就无从选择。想绕开时用 newtype 包一层(struct MyVec(Vec<i32>))。
孤儿规则:报什么(rustc 1.97.1)
impl std::fmt::Display for Vec<i32> { ... }
error[E0117]: only traits defined in the current crate can be
implemented for types defined outside of the crate- 规则本身:
impl 某Trait for 某类型时,trait 与类型至少有一方定义在你自己的 crate 里; - 为什么必须有这条:假设两个 crate 都给
Vec<i32>实现了Display,你的程序同时依赖它们——编译器无从选择,而且这个冲突还会随依赖升级凭空出现。孤儿规则保证了「实现」的唯一性; - 绕开的标准手段是 newtype:
struct MyVec(Vec<i32>)包一层,这个类型就是你的了。零运行时成本(新类型在编译后就消失),代价是要手动转发需要的方法或实现Deref; - 反过来看它的价值:你可以给别人的类型实现自己的 trait——这在 Java/C# 里做不到,是 Rust 抽象能力的一大来源。
trait Summary {
fn author(&self) -> String; // 必须实现
fn summarize(&self) -> String { // 默认实现
format!("(by {})", self.author())
}
}
struct Tweet { user: String }
impl Summary for Tweet {
fn author(&self) -> String {
format!("@{}", self.user)
}
// summarize 用默认实现,无需写
}Vec<i32> 实现 std 的 Display(trait 和类型都不是你的),rustc 1.97 报 error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate——此时唯一出路就是 explain 里说的 newtype 包一层。Iterator 只要求 next,几十个方法全是默认实现搭出来的。实现者负担最小,你还能随时给 trait 加新默认方法而不破坏下游。同一个 trait 有两条使用路径——编译期展开的泛型约束和运行时查表的 trait 对象,选哪条是写 Rust 每天都要做的决定。
静态分发还是动态分发
约束泛型须实现某 trait:简单场景用 &impl Trait,复杂约束用 where 子句更清晰。返回 impl Trait 隐藏具体类型。&dyn Trait / Box<dyn Trait> 做运行时动态分发(用于异构集合)。
两条路的实际差别
泛型 T: Trait | dyn Trait | |
|---|---|---|
| 分发 | 编译期单态化,直接调用 | 运行时查虚表 |
| 能否内联 | 能 | 不能 |
| 指针大小 | &Add = 8 字节 | &dyn Op = 16 字节(胖指针:数据 + 虚表) |
| 异构集合 | 做不到 | 能(Vec<Box<dyn T>>) |
| 编译产物 | 每个具体类型一份,体积膨胀 | 只有一份 |
- 3 亿次调用,泛型 88–93 ms,dyn 93–126 ms(同一量级,差距不稳定)。结论值得记住——dyn 的代价主要不是那一次间接跳转(现代 CPU 的间接分支预测很准),而是它挡住了内联,进而挡住了后续所有跨函数优化;
- 推论:方法体很小、调用极频繁时 dyn 才会明显吃亏(内联本可以把整个调用消掉);方法体一大,两者几乎没差别;
- 所以选择标准应该是语义而不是性能:需要异构集合、需要在运行时决定类型、想控制编译产物体积 →
dyn;否则默认泛型; - 不是所有 trait 都能
dyn:有泛型方法或返回Self的 trait 报error[E0038]: the trait `T` is not dyn compatible(这个诊断在较新版本里由「object safe」改称「dyn compatible」)。
// 静态分发:impl Trait(编译期单态化,零开销)
fn notify(item: &impl Summary) {
println!("{}", item.summarize());
}
// 复杂约束用 where
fn both<T, U>(a: &T, b: &U)
where T: Summary + Clone, U: Summary {}
// 动态分发:Box<dyn Trait> 存异构集合
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Tweet { user: "a".into() }),
];dyn Trait:方法返回 Self、带泛型参数、或没有 self 的关联函数都会破坏 dyn 兼容——rustc 1.97 报 error[E0038]: the trait `Shape` is not dyn compatible,并逐条指出是哪个方法坏的事。给关联函数加 where Self: Sized 可以把它从 vtable 里排除(可修复)。impl Trait 是静态分发(编译期单态化,零运行时开销,但每个具体类型生成一份代码,体积变大)。dyn Trait 是动态分发(运行时 vtable 查找,有轻微开销,但只一份代码)。默认用 impl;需要异构集合或运行时多态才用 dyn。标准库的能力大多以 trait 的形式发放——认识这十来个,等于拿到一整套基础设施的钥匙。
一整套基础设施的钥匙
值得记住的标准 trait:Display({} 输出)、Debug({:?},可 derive)、From/Into(类型转换,实现 From 自动获得 Into)、PartialEq/Eq/Hash/Ord/Default(多可 derive)、Iterator(实现 next 即获得 map/filter/sum 等几十个方法)。
实现一个,白得一批
| 实现它 | 白得什么 |
|---|---|
From<A> for B | Into 自动有了,且 ? 能自动转换错误类型 |
Iterator(只需写 next) | map/filter/sum/collect 等几十个方法 |
Display | ToString 自动有了(.to_string()) |
PartialOrd + Ord | sort、max、BTreeMap 的键 |
Hash + Eq | HashMap/HashSet 的键 |
From与?的联动是错误处理的地基(08 章):给自己的错误类型实现From<io::Error>,函数里就能直接?一个 IO 调用;Debug几乎总该 derive:没有它{:?}直接编译失败,error[E0277]: `P` doesn't implement `Debug`;Default配..Default::default()的结构体更新语法,是 Rust 里「可选参数」的常用替代;- 要注意
Deref别乱实现:它是给智能指针用的(12 章),拿它模拟继承会让方法来源变得难以追踪,这是社区公认的反模式。
use std::fmt;
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
let s: String = "hi".into(); // Into 自动来自 From
// 实现 Iterator:next 一个方法换来一整套迭代器方法
struct Counter { n: u32 }
impl Iterator for Counter {
type Item = u32; // 关联类型
fn next(&mut self) -> Option<u32> {
if self.n < 5 { self.n += 1; Some(self.n) }
else { None }
}
}use 进作用域才能调用:对 Vec<u8> 调 write_all 而没写 use std::io::Write,rustc 1.97 报 error[E0599]: no method named `write_all` found,帮助信息里那句「trait `Write` ... is implemented but not in scope」才是真相——看到「方法不存在」先想想是不是少了个 use。Display 就自动获得 .to_string()(标准库的 blanket impl),别再手动实现 ToString;同理实现 From 自动获得 Into——两对都只写「正向」那一个。注意 Display 没有派生宏:#[derive(Display)] 报 cannot find derive macro `Display` in this scope,展示格式必须自己写。枚举与模式匹配
Rust 的枚举可携带数据,是代数数据类型。配合 match 穷举匹配,编译器强制你处理每种情况——Option 取代 null、Result 取代异常的根基。
枚举在 Rust 里不是整数常量表,而是「这个值只能是这几种形态之一」的完整表达——Option 就是它最著名的应用。
只能是这几种形态之一
枚举每个变体可携带不同形态的数据(无数据/元组/匿名结构体)。标准库的 Option<T>(Some(T)/None)用类型系统取代 null——「可能没有值」被编码进类型,编译器强制你处理 None,从根上消灭空指针。
Option 不要钱:空指针优化
size_of::<i32>() = 4 size_of::<Option<i32>>() = 8 ← 多了一个判别标记 size_of::<Box<i32>>() = 8 size_of::<Option<Box<i32>>>() = 8 ← 一个字节都没多
- 第二组是关键:对于「不可能为 0」的类型(引用、
Box、NonZero*),编译器直接拿全 0 位模式表示None——这就是空指针优化(niche optimization); - 所以「用
Option<&T>代替可空指针」在内存布局上与 C 的裸指针完全一致,但类型系统强制你处理None。安全是白拿的,这也是 Rust FFI 能直接对接 C 的可空指针的原因; - 同一个优化也解释了
size_of::<Result<(), io::Error>>()只有 8 字节; - 枚举的通用规则是:大小 = 最大变体 + 判别标记(可能因优化省掉)并按对齐取整。所以一个变体特别大的枚举会拖大所有变体——常见解法是把大的那个装进
Box。
enum Message {
Quit, // 无数据
Move { x: i32, y: i32 }, // 匿名结构体
Write(String), // 元组变体
Color(u8, u8, u8),
}
// Option:可能没有值,由类型强制处理
fn find(id: u32) -> Option<String> {
if id == 1 { Some("Alice".into()) } else { None }
}
let name = find(1).unwrap_or("Guest".into()); // None 给默认Option<i32> 不是 i32,不能直接参与运算:Some(5) + 1 在 rustc 1.97 报 error[E0369]: cannot add `{integer}` to `Option<{integer}>`——必须先用 map/unwrap_or/if let 把值取出来。习惯了 null 自动参与运算的语言,这是第一道不适应。if x.is_some() { x.unwrap() }——判断和取值合成一步 if let Some(v) = x,既省一次 unwrap,重构时也不会漏掉判断。只要默认值的话还有更短的 unwrap_or/map_or。match 的价值不在语法糖,而在编译器替你检查「每种情况都处理了吗」——漏分支这类 bug 在这里活不到运行时。
编译器替你检查漏没漏
match 必须穷举所有可能(用 _ 兜底),支持范围、守卫条件、解构。只关心一个变体时用 if let 简化;循环消费用 while let;快速判断用 matches! 宏。穷举性让「漏处理某种情况」在编译期就暴露。
穷举检查是什么样
enum E { A, B }
fn f(e: E) -> i32 { match e { E::A => 1 } }
error[E0004]: non-exhaustive patterns: `E::B` not covered- 价值不在写起来短,而在「加一个变体,所有漏处理它的地方立刻变成编译错误」——这让扩展枚举变成一件安全的事,而不是一场靠 grep 的排查;
- 正因如此,能不写
_兜底就别写:一个_ => {}会让穷举检查对这个 match 永远失效,新增变体时它静默沉默; - 公开 API 里的枚举常标
#[non_exhaustive],强制下游必须写_——这是库作者保留「以后加变体」权利的手段,读到它就知道对方打算演化; - 三个简化形式各有场合:只关心一个变体用
if let;循环消费直到不匹配用while let;只要一个 bool用matches!宏。
let desc = match n {
0 => "零",
1..=5 => "一到五", // 范围
x if x % 2 == 0 => "偶数", // 守卫
_ => "其他", // 必须兜底
};
// if let:只处理一个变体
if let Some(v) = opt {
println!("有值 {}", v);
}
// while let:循环到不匹配
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("{}", top); // 3, 2, 1
}
let ok = matches!(opt, Some(x) if x > 0); // bool_ 或宽泛模式放前面,后面的分支永远走不到——rustc 1.97 只给 warning: unreachable pattern,是警告不是错误,容易漏看。另外 if let 没有穷尽性检查,用它就放弃了 match 的这层保护。_ 兜底:兜底会把将来新增的变体也静默吃掉。不写 _ 时漏了分支直接编译不过——rustc 1.97 报 error[E0004]: non-exhaustive patterns: `None` not covered——将来加变体时所有 match 点会被逐个点名,这才是把穷尽性检查用起来的姿势。错误处理
Rust 无异常。可恢复错误用 Result<T, E> 显式传播,? 运算符让链式传播极简;不可恢复错误才用 panic。
Rust 没有异常,错误就是普通的返回值——? 把「层层上抛」这件事压缩成了一个字符。
错误就是普通返回值
Result<T, E>(Ok(T)/Err(E))是可失败操作的返回类型。? 运算符是核心:成功取出值、失败则自动转换错误类型并提前返回,把样板代码压成一个符号。常用方法:unwrap_or(默认值)、map/map_err(转换)、ok(转 Option)。? 是错误传播的简写:表达式成功则取出其值继续执行,失败则立即将错误返回给调用者并结束当前函数,省去手写 if 判断。
? 到底展开成什么
let v = expr?; // 等价于 let v = match expr { Ok(v) => v, Err(e) => return Err(From::from(e)), // ← 注意这个自动转换 };
- 那个
From::from是?一半的价值所在:只要目标错误类型实现了From<源错误>,不同来源的错误就能在一条函数里自由混用; - 两条边界:函数返回类型不对时报
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option`;错误类型转换不了时报error[E0277]: `?` couldn't convert the error to …——后者的修法就是补一个From实现(或用anyhow); ?也能用在返回Option的函数里(None直接短路返回),但不能在同一个函数里混着用 Result 和 Option 的?;- 「Rust 没有异常」的实际后果:函数签名里写明了它会不会失败。调用方不可能忽略——不处理
Result至少会得到一个must_use警告。
use std::fs;
fn read(path: &str) -> Result<String, std::io::Error> {
let content = fs::read_to_string(path)?; // ? 失败即返回 Err
Ok(content)
}
// ? 等价于:match ... { Ok(v) => v, Err(e) => return Err(e.into()) }
let r: Result<i32, &str> = Ok(42);
r.unwrap_or(0); // Err 时用默认值
r.map(|v| v * 2); // Ok 时转换
r.map_err(|e| format!("err: {}", e));unwrap()/expect() 在 Err/None 时直接 panic,只适合原型或「逻辑上绝不会失败」的场合。生产代码里应该用 ? 传播、或 match/if let 显式处理——把 unwrap() 当默认习惯是新手最常见的坑。main 里用 ?,得先把签名改成 fn main() -> Result<(), Box<dyn std::error::Error>>:默认的 main 返回 (),rustc 1.97 报 error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option`,帮助信息会直接给出这个签名。两个 crate 分工只有一句话——库要让调用者能 match 出具体错误,应用只要把错误带着上下文报出去。
库与应用的分工
自定义错误只需实现 Display+Error+From(让 ? 自动转换),但样板多。库代码用 thiserror 派生宏一键生成精确错误类型;应用代码不关心精确类型时用 anyhow(动态错误 + .context() 加说明 + bail! 快速返回)。
选哪个,只看一个问题
thiserror | anyhow | |
|---|---|---|
| 给谁用 | 库 | 应用 / 二进制 |
| 错误类型 | 你定义的具体 enum | 擦除成一个 anyhow::Error |
调用方能否 match | 能 | 基本不能(要 downcast) |
| 加上下文 | 自己在变体里带字段 | .context("读配置失败") |
| 样板量 | 派生宏,中等 | 几乎为零 |
- 判据一句话:调用者需要根据错误种类做不同处理 → thiserror;只需要把错误连同上下文报出去 → anyhow;
- 库里用 anyhow 是常见的错误决定:它把类型信息擦掉了,下游想区分「文件不存在」和「权限不足」就只能去匹配字符串;
- 反过来在应用里堆 thiserror 也是浪费——
.context()串起来的那条链,在日志里比精确的类型有用得多; - 两者可以共存:底层库各自用 thiserror 定义精确错误,最外层的应用用 anyhow 汇总。
// 库:thiserror 派生(精确类型,方便调用者匹配)
use thiserror::Error;
#[derive(Debug, Error)]
enum MyError {
#[error("IO 错误: {0}")]
Io(#[from] std::io::Error), // #[from] 自动生成 From
#[error("未找到用户 {id}")]
NotFound { id: u32 },
}
// 应用:anyhow(动态错误,开发快)
use anyhow::{Result, Context, bail};
fn run() -> Result<String> {
let s = std::fs::read_to_string("f.txt")
.context("读取配置失败")?; // 加说明
if s.is_empty() { bail!("文件为空"); }
Ok(s)
}#[from]:两个变体都挂 #[from] std::io::Error 等于生成两份相同的 From 实现——rustc 1.97 这种重复实现报 error[E0119]: conflicting implementations of trait `From<std::io::Error>`,这种场景改用 #[source] 加手动构造。另外 anyhow::Error 本身不实现 std::error::Error,别把它放进库的公开 API。? 能自动换错误类型,靠的全是 From:#[from] 就是替你生成 impl From<io::Error> for MyError。不想引 crate 时手写这个 From 一样能让 ? 工作(rustc 1.97);缺了它则报 error[E0277]: `?` couldn't convert the error to `MyErr`。泛型与迭代器
泛型让代码对任意类型工作,编译期单态化零开销。迭代器是惰性的、可链式组合的数据处理管道,是 Rust 写出高效又优雅代码的主力。
闭包是迭代器、线程、异步 API 的通用货币——而它捕获变量的方式完全沿用所有权那套规则,没有新东西。
捕获规则沿用所有权
闭包是能捕获环境变量的匿名函数,写法 |参数| 表达式(如 |x| x + 1);多条语句用花括号 |x| { ... },参数与返回类型通常可推断。捕获方式由闭包体如何使用变量自动决定:只读则借用(&T)、要改则可变借用(&mut T)、要拿走所有权则移动——在参数列表前加 move 关键字可强制按值捕获,把变量所有权移进闭包(跨线程/异步任务必用,见 11 章)。按对捕获变量的使用强度,闭包自动实现三个 trait 之一:Fn(只读捕获,可多次调用)、FnMut(可变捕获,可多次调用)、FnOnce(消耗捕获值,只能调用一次)。下面迭代器的 map/filter 等适配器接收的正是闭包。
三个 trait 不是你选的,是编译器推的
| 闭包体怎么用捕获的变量 | 自动实现 | 能调几次 |
|---|---|---|
| 只读 | Fn(同时也是 FnMut、FnOnce) | 多次 |
| 要改 | FnMut(同时也是 FnOnce) | 多次 |
| 消耗掉(move 走) | FnOnce | 一次 |
FnOnce闭包调第二次报error[E0382]: use of moved value: `c`——报错说的是「值被移走了」,因为调用FnOnce本身就是消耗它;move关键字不改变闭包实现哪个 trait,它只强制「按值捕获」。一个move闭包如果只是读取捕获的值,照样是Fn;- 另一条闭包可变借用了变量之后,闭包还活着期间原变量不能再用——
error[E0502]: cannot borrow `v` as immutable because it is also borrowed as mutable。这就是 04 章借用规则在闭包上的直接应用,没有新规则; - 跨线程或塞进异步任务时必须
move(11 章):否则闭包借用的是当前栈上的变量,而线程可能活得比它久。
// 最简闭包:|参数| 表达式
let add_one = |x| x + 1;
println!("{}", add_one(5)); // 6
// 捕获环境变量:默认按需借用
let factor = 3;
let times = |x| x * factor; // 借用 factor
println!("{}", times(10)); // 30
// move:强制把所有权移进闭包(跨线程/异步常用)
let s = String::from("hi");
let owns = move || println!("{s}"); // s 被移入,之后不能再用 s
owns();
// 闭包作参数:用 Fn / FnMut / FnOnce 约束
fn apply<F: Fn(i32) -> i32>(f: F, v: i32) -> i32 {
f(v)
}
println!("{}", apply(add_one, 9)); // 10
// 迭代器适配器接收的就是闭包
let v: Vec<i32> = (1..=3).map(|x| x * 2).collect(); // [2, 4, 6]error[E0502]: cannot borrow `v` as immutable because it is also borrowed as mutable。另外把捕获值移出去的闭包是 FnOnce,调第二次报 error[E0382]: use of moved value,报错还会解释「closure cannot be invoked more than once」。Fn;修改 → FnMut;把值移出或消耗掉 → FnOnce。三者能力递减、范围递增:Fn ⊂ FnMut ⊂ FnOnce——要求 FnOnce 的函数能接收任何闭包,要求 Fn 的最严格。写一份代码适配所有类型,还不掏运行时代价——单态化是 Rust 泛型和多数语言泛型最大的分界。
单态化,零运行时开销
泛型参数 <T> 写一份代码适配多类型,编译期为每个具体类型单态化(生成专用代码,零运行时开销)。可加 trait 约束(T: PartialOrd)。const 泛型把编译期已知的整数当类型参数(如数组长度)。
单态化的代价在编译期
- 「零成本」说的是运行时:
foo::<i32>和foo::<String>被编译成两份独立的机器码,各自能被完全优化,没有任何间接层; - 代价转移到了另外两处:编译时间(每个具体类型都要编一遍并优化一遍)与产物体积(代码膨胀,还会挤占指令缓存)。Rust 编译慢的一大来源就在这;
- 控制手段:把泛型函数里与类型无关的那部分抽成非泛型的内部函数(标准库里到处是这种写法),这样膨胀的只有薄薄的外壳;或者在合适的地方改用
dyn; - 与 Java/TS 的擦除式泛型正好相反:Rust 的泛型信息在编译期用完就消失,但用得非常彻底;擦除式泛型编译快、产物小,代价是运行时的装箱与类型检查;
const泛型把编译期已知的整数当参数(如[T; N]的N),让「数组长度」也能被抽象——这是过去必须靠宏才能做到的事。
// 约束 T 必须可比较
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut m = &list[0];
for x in list { if x > m { m = x; } }
m
}
largest(&[34, 50, 25]); // T = i32
largest(&['y', 'a']); // T = char(同一份源码,两份实例)
// 泛型结构体 + const 泛型
struct Pair<A, B> { first: A, second: B }
fn show<const N: usize>(arr: [i32; N]) {}fn largest<T> 里直接写 x > m,rustc 1.97 报 error[E0369]: binary operation `>` cannot be applied to type `&T`,并提示加 PartialOrd 约束——检查发生在定义处而不是实例化时,所以报错指向你,而不是指向调用你的人。::<> 现场指定:"42".parse::<i32>()、collect::<Vec<_>>()。只写 let n = "42".parse() 推不出目标类型,rustc 1.97 报 error[E0284]: type annotations needed。把「遍历-筛选-变换-汇总」写成一条链,读起来是声明式的,编译完却和手写循环一样快。
写起来声明式,编译完像手写循环
迭代器是惰性的:适配器(map/filter/zip/flat_map)返回新迭代器、不立即计算,直到消费者(collect/sum/any)才执行,整条链只遍历一次。区分 iter()(借 &T)、into_iter()(取所有权 T)、iter_mut()(可变借 &mut T)。collect 的目标容器由类型标注决定,可落进 Vec、String 或 HashMap(键值集合,详细用法见 std 文档)。
「零成本」是可以量的
5000 万个元素,筛出能被 3 整除的再乘 2 求和: 手写 for 循环 23 ms 迭代器链 24 ms (filter + map + fold) 结果完全一致
- 差距在噪声范围内。原因是迭代器适配器全是泛型 + 内联:单态化之后编译器看到的就是一个循环,中间那些
Map/Filter结构体在优化后完全消失; - 这条性质让「可读」和「快」不再是取舍——默认写迭代器链,不必为了性能退回手写循环;
- 惰性的两个实际后果:①只
.next()一次就只算一个元素;②不消费就什么都不发生——只写v.iter().map(...)不 collect,编译器会给warning: unused `Map` that must be used; - 三个入口别用混:
iter()借&T、iter_mut()借&mut T、into_iter()拿走T。into_iter()之后原容器就不能再用了; collect()的目标类型由标注决定,不写标注报error[E0283]: type annotations needed——它能收进Vec、String、HashMap,甚至Result<Vec<_>, E>(把一串 Result 折叠成一个)。
let v = vec![1, 2, 3, 4, 5];
// 惰性适配器 + 消费者,整条链只遍历一次
let out: Vec<i32> = v.iter()
.filter(|&&x| x % 2 == 0) // 保留偶数
.map(|&x| x * 2) // 乘 2
.collect(); // 消费 → [4, 8]
let sum: i32 = v.iter().sum(); // 15
let has = v.iter().any(|&x| x > 3); // true
// into_iter 取所有权;zip 合并;collect 到 HashMap
use std::collections::HashMap;
let names = vec!["A", "B"];
let scores = vec![90, 85];
let m: HashMap<_, _> = names.into_iter().zip(scores).collect();v.iter().map(|x| println!("{}", x)) 编译运行都不报错但什么也不打印——rustc 1.97 只给 warning: unused `Map` that must be used,note 直说「iterators are lazy and do nothing unless consumed」。要副作用就用 for 循环或 for_each。.inspect(|x| eprintln!("{x:?}"))——它原样透传元素、只加副作用,不用拆链,调试完删掉这一行即可(rustc 1.97 可用)。标准库:深水区
前面几章讲的是语言规则,这一章潜入标准库的深水区——同一件事有三个方法时该挑哪个、挑错了编译器会怎么骂你,以及那些文档不会主动告诉你的约定。Rust 的特别之处在于:这些契约绝大多数是编译期强制的,违反了不是运行时出怪结果,而是当场编译不过。所以本章的「输出」是真实的 rustc 报错原文:iter() 和 into_iter() 选错会得到 E0382: borrow of moved value;给字符串按下标索引会得到 E0277 外加一句「你该用 .chars().nth()」;迭代器忘了消费只会得到一个警告——而这个警告是这门语言里最值得认识的一个。末卡是按用途排布的标准库速查,备查用。
这三个方法名字只差几个字母,是 Rust 新手最大的一个困惑源。但它们的区别其实完全对应 03、04 章那套所有权规则——想清楚「我要不要把集合还回去」,选哪个是确定的。
三个方法,三种关系
iter()→ 迭代出&T(不可变引用)。集合还是你的,遍历完还能接着用。日常九成场景用它。iter_mut()→ 迭代出&mut T。要就地修改每个元素时用,集合仍然是你的。into_iter()→ 迭代出T本身,把集合消耗掉。之后原变量不能再用。要把元素搬进别的结构(或者返回它们)时用。- 记忆的钩子就在名字里:
into_前缀在整个 Rust 生态里都表示「拿走所有权」(into_string、into_bytes、into_boxed_slice全是这个意思)。
选错了会怎样:真实报错
for x in v.into_iter() 之后还想用 v:
error[E0382]: borrow of moved value: `v`
--> b.rs:4:22
|
2 | let v = vec![1, 2, 3];
| - move occurs because `v` has type `Vec<i32>`,
| which does not implement the `Copy` trait
3 | for x in v.into_iter() { println!("{}", x); }
| ----------- `v` moved due to this method call
4 | println!("{:?}", v);
| ^ value borrowed here after move
|
note: `into_iter` takes ownership of the receiver `self`, which moves `v`- rustc 把话说得非常直白,连「是哪个方法把它移走的」都标出来了——看到
value borrowed here after move,第一反应就该是「我是不是该用iter()」。 - 改法通常只是加一个
&或把into_iter()换成iter(),一个字符的事。
for 循环里的隐式选择
for x in &v等价于v.iter();for x in &mut v等价于v.iter_mut();for x in v等价于v.into_iter()——会把v吃掉。- 所以「循环写完之后变量不能用了」几乎总是因为漏了那个
&。这是新手最高频的一次报错,也是最容易改的一个。 - 元素是
Copy类型(i32这些)时iter()迭代出的是&i32,比较时经常要解引用或者用.copied()/.cloned()转成值。
let mut v = vec![1, 2, 3];
// 只读遍历:v 还是你的
for x in v.iter() { println!("{}", x); } // x: &i32
for x in &v { } // 同上,更常见的写法
// 就地修改
for x in v.iter_mut() { *x *= 2; } // x: &mut i32
// 拿走所有权:之后 v 不可用
for x in v.into_iter() { } // x: i32
// println!("{:?}", v); // error[E0382]: borrow of moved value
// Copy 元素:把 &i32 变回 i32
let total: i32 = nums.iter().copied().sum();
let big: Vec<i32> = nums.iter().filter(|&&x| x > 10).copied().collect();
// 要把元素搬进新结构,就该用 into_iter
let owned: Vec<String> = names.into_iter().map(|s| s.to_uppercase()).collect();.iter() 之后再 .collect() 会得到一个引用的集合。v.iter().collect::<Vec<_>>() 的类型是 Vec<&T> 而不是 Vec<T>——它借着原集合,原集合的生命周期一结束它就不能用了。要独立的副本得加 .cloned()(元素实现 Clone)或 .copied()(元素是 Copy)。还有一个容易混的:
&Vec<T> 上调 into_iter() 迭代出的是 &T 而不是 T——因为实现在引用类型上的那份 IntoIterator 就是这么定义的。所以 (&v).into_iter() 和 v.iter() 等价,别指望前者能拿到所有权。iter()。它最保守——集合还在你手里,编译器如果不满意会告诉你该换成什么。反过来先写 into_iter() 则可能在几十行之后才因为「变量已被移动」报错,回头找原因更费劲。另外
HashMap 的这三个方法迭代出的是键值对元组:iter() 给 (&K, &V)、iter_mut() 给 (&K, &mut V)(键不可变,因为改了键会破坏哈希位置)、into_iter() 给 (K, V)。Rust 的迭代器只有被消费时才会真正跑。这一条如果不知道,你会写出一段编译通过、运行也不报错、但什么都没发生的代码——好在编译器会警告你,而这个警告值得专门认识一下。
那个必须认识的警告
v.iter().map(|x| println!("{}", x)); —— 看着像在打印,实际什么也不会打印:
warning: unused `Map` that must be used
--> lazy.rs:3:5
|
3 | v.iter().map(|x| println!("{}", x));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: iterators are lazy and do nothing unless consumed- 最后那句
iterators are lazy and do nothing unless consumed就是全部答案。map只是构造了一个新的迭代器类型(这里叫Map),并没有执行闭包。 - 注意这只是 warning 不是 error——代码照样能编译运行,所以开着
-D warnings或至少认真看警告很重要。 - 纯粹为副作用而遍历,应该用
for循环,或者用for_each(它是消费者)。用map做副作用是意图表达错误,即便加了collect也一样。
适配器 vs 消费者
- 适配器(返回新迭代器,惰性):
map、filter、take、skip、zip、chain、enumerate、rev、flat_map、peekable。 - 消费者(真正驱动执行,返回具体值):
collect、sum、count、fold、for_each、find、any、all、max/min、last、nth。 - 判断很简单:返回值还是迭代器 → 适配器;返回值是别的东西 → 消费者。
惰性带来的好处不只是省一次遍历
- 链式调用不会产生中间集合:
v.iter().filter(..).map(..).collect()全程只遍历一次、只分配最后那一个Vec。换成先filter收集一次再map收集一次,就是两次分配两次遍历。 - 惰性还让无限迭代器成为可能:
(1..).filter(|n| n % 7 == 0).take(5)完全合法——take(5)一满足就停,前面的无限区间不会真的跑下去。 - 这也是「零成本抽象」的典型:这一长串适配器编译后通常和手写循环生成一样的机器码,不为可读性付运行时代价。
collect 的目标类型要说清楚
collect()能收成Vec、String、HashMap、HashSet,甚至Result<Vec<_>, E>——具体收成什么由类型推断决定,推断不出来就得你明说。- 两种写法:
let v: Vec<i32> = it.collect();或it.collect::<Vec<i32>>()(turbofish)。 - 一个特别顺手的用法:迭代器里全是
Result时,可以直接collect成Result<Vec<T>, E>——任何一个是Err就整体短路成Err,不用自己写循环判断。Option同理。
// 什么都不会发生 —— 只有一个 warning 提醒你
v.iter().map(|x| println!("{}", x));
// 要副作用就用 for,或者 for_each(消费者)
for x in &v { println!("{}", x); }
v.iter().for_each(|x| println!("{}", x));
// 链式:只遍历一次,只分配最后那个 Vec
let result: Vec<i32> = v
.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * 10)
.collect();
// 惰性让无限迭代器可用
let first5: Vec<i32> = (1..).filter(|n| n % 7 == 0).take(5).collect();
// collect 成 Result:任何一个 Err 就整体短路
let nums: Result<Vec<i32>, _> = inputs
.iter()
.map(|s| s.parse::<i32>())
.collect();
// 目标类型推断不出来时用 turbofish 说明
let n = v.iter().collect::<Vec<_>>().len();collect 出需要的东西再改,要么用 retain/drain 这类专门为此设计的方法。另外
collect::<String>() 只对迭代出 char 或 &str 的迭代器成立,别指望它能把任意类型拼成字符串——那要先 map(|x| x.to_string())。enumerate() 要放在 filter() 前面还是后面,结果完全不同。放前面拿到的是原始下标,放后面拿到的是过滤后的序号。这两个都合理但含义不一样,写的时候明确一下想要哪个——「下标对不上」这类 bug 十有八九出在这里。Option 和 Result 上挂着几十个方法,名字还都很像。但它们其实只有四类,认准分类就不用背了。这一节相当于 C++ STL 章讲「怎么选容器」——选错不会编译不过,只会让代码绕远路。
四类方法
- ① 变换里面的值:
map(T → U,包装还在)。闭包返回普通值时用它。 - ② 变换并且可能再次失败:
and_then(T → Option<U>/Result<U,E>)。闭包本身又返回一个 Option/Result 时用它——用map的话会套成Option<Option<U>>。「闭包返回值带不带包装」就是 map 和 and_then 的唯一分界线。 - ③ 取出值,给个兜底:
unwrap_or(给现成的默认值)、unwrap_or_else(默认值需要计算时用它,闭包只在真的缺值时才执行)、unwrap_or_default。 - ④ 两者互转:
ok_or/ok_or_else(Option → Result,补上错误信息)、.ok()(Result → Option,丢掉错误)。
unwrap_or 和 unwrap_or_else 的实际差别
unwrap_or(expensive())里的expensive()无论如何都会被求值——它是普通函数参数,在调用前就算好了。unwrap_or_else(|| expensive())只在真的是None/Err时才执行。- 所以规则是:默认值是常量就用
unwrap_or,需要计算/分配/查库就用unwrap_or_else。这个区别在热路径上是实打实的。
? 运算符:能用就用,但它有边界
?是「成功就取出值、失败就提前返回」的语法糖,能把一长串嵌套的match压成一行。- 边界一:函数的返回类型必须匹配。在返回
Result的函数里才能对Result用?,返回Option的函数里才能对Option用。main默认返回(),所以直接在main里用?会编译不过——把main的签名改成-> Result<(), Box<dyn Error>>即可。 - 边界二:错误类型要能转换。
?会自动调用From做转换,所以自定义错误类型只要实现了From<io::Error>之类,就能直接?过来。这正是thiserror帮你生成的东西(08 章讲过)。 - 边界三:
?不能跨Option和Result。要转就先.ok_or(...)或.ok()。
unwrap 什么时候可以用
- 可以:写测试、写一次性脚本、以及你能证明它不可能失败的地方(比如刚
is_some()检查过、或者解析一个写死在代码里的常量)。后一种情况更推荐用expect("说明为什么这里不会失败")——panic 信息会带上你的解释,排查时省一大截时间。 - 不可以:库代码、处理外部输入、任何「失败了应该让调用方决定怎么办」的地方。库里
unwrap等于替调用方决定「这时候整个进程都该死」,这个决定不该由你做。 unwrap的 panic 信息是called `Option::unwrap()` on a `None` value加行号——只告诉你哪里炸了,不告诉你为什么,这就是expect更值得写的原因。
// ① 闭包返回普通值 -> map
let len: Option<usize> = name.map(|s| s.len());
// ② 闭包又返回 Option/Result -> and_then(用 map 会套两层)
let first: Option<char> = name.and_then(|s| s.chars().next());
// ③ 兜底:默认值要计算就用 _else
let port = cfg_port.unwrap_or(8080); // 常量,随便
let conf = cached.unwrap_or_else(|| load_config()); // 只在缺失时才跑
// ④ 互转
let r: Result<User, MyErr> = maybe_user.ok_or(MyErr::NotFound);
let o: Option<User> = fetch_user().ok(); // 丢掉错误信息
// ? :成功取值,失败提前返回
fn read_cfg(p: &str) -> Result<Config, Box<dyn Error>> {
let text = fs::read_to_string(p)?; // io::Error 自动转换
let cfg: Config = toml::from_str(&text)?;
Ok(cfg)
}
// main 里要用 ? 得改签名
fn main() -> Result<(), Box<dyn Error>> {
let cfg = read_cfg("app.toml")?;
Ok(())
}
// 要 unwrap 就写 expect,把「为什么不会失败」留下来
let re = Regex::new(r"^\d+$").expect("字面量正则,编译期就是对的");? 会吞掉上下文。一个函数里连着五个 ?,任何一个失败返回的都是原始错误——调用方只看到 No such file or directory,不知道是哪个文件、哪一步。所以库代码里应该在 ? 之前补上下文:用 .map_err(|e| ...) 包一层,或者用 anyhow 的 .context("读取配置文件")?(08 章)。另外注意
.ok() 是有损的:它把 Result 变成 Option,错误信息直接丢弃。写起来顺手,但排查线上问题时你会想把它找回来——只有在「确实不关心为什么失败」时才用。if let 和 let else 常常比组合子更好读。组合子链一旦超过两三环,就不如直接写 if let Some(x) = opt { ... }。而 let Some(x) = opt else { return; };(Rust 1.65+)专门解决「取不出来就提前退出」这个模式——它让主流程保持在最外层缩进,比嵌套的 match 清爽得多。别为了显得地道而硬串组合子。Rust 有两种字符串类型,还禁止你对它们按下标索引。这两件事都让初学者不适应,但它们指向同一个道理:UTF-8 里「第 n 个字符」不是一个 O(1) 能回答的问题,Rust 拒绝假装它是。
String 和 &str:拥有 vs 借用
String:拥有内容、可增长、在堆上。相当于Vec<u8>加上「保证是合法 UTF-8」。&str:借用的字符串切片,只是「指针 + 长度」,不拥有数据。字符串字面量"hello"的类型就是&'static str。- 关系和
Vec<T>与&[T]完全一样。函数参数一律优先写&str——String能自动解引用成&str,反过来不行,所以收&str的函数两种都能接。 - 互转:
s.to_string()/String::from(s)(分配 + 拷贝)、&string或string.as_str()(零成本)。
按下标索引:编译器直接拦住
let c = s[0]; 得到的不是运行时错误,是编译错误:
error[E0277]: the type `str` cannot be indexed by `{integer}`
--> idx.rs:3:15
|
3 | let c = s[0];
| ^ string indices are ranges of `usize`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`
= note: you can use `.chars().nth()` or `.bytes().nth()`- rustc 连替代方案都给了:
.chars().nth(n)(按字符)或.bytes().nth(n)(按字节)——注意这两个都是 O(n),因为必须从头扫。 - 为什么禁止?因为
s[0]到底该返回什么没有好答案:返回字节,那对非 ASCII 就是半个字符;返回字符,那就不是 O(1) 了,一个看着像数组下标的操作却要遍历。Rust 选择不提供,逼你明确说出要哪一种。 &s[0..4]这种范围切片是允许的,但切在字符中间会运行时 panic(byte index is not a char boundary)——这是少数几个「Rust 也只能留到运行时」的地方。
三个层次,和 Go 那边一致
s.len()是字节数,不是字符数。"héllo".len()是 6。s.chars()迭代char(一个 Unicode 码点,固定 4 字节)。数字符个数用s.chars().count(),这是 O(n)。s.bytes()迭代u8。s.char_indices()同时给出字节位置和字符。- 再往上一层是字形簇(用户眼里的一个字符,可能由多个码点组成,比如带修饰符的 emoji)——标准库不处理,要用
unicode-segmentation这个 crate。
let s = String::from("héllo");
s.len() // 6 —— 字节数
s.chars().count() // 5 —— 字符数,O(n)
// let c = s[0]; // error[E0277]: 编译不过
s.chars().nth(1) // Some('é'),O(n)
&s[0..1] // "h" —— 范围切片可以,但切错位置会 panic
// 同时要位置和字符
for (i, c) in s.char_indices() {
println!("{} {}", i, c); // 0 h / 1 é / 3 l / 4 l / 5 o
}
// 函数参数收 &str,String 和字面量都能传进来
fn greet(name: &str) { }
greet("world");
greet(&s);
// 拼接:push_str 原地追加;format! 更灵活但会分配
let mut out = String::with_capacity(64); // 已知大小就预分配
out.push_str("hello");
out.push(' ');
let msg = format!("{}: {}", name, count);
// + 的签名很特别:吃掉左边的 String,右边要 &str
let joined = a + &b; // a 被移动,之后不可用&s[i..j] 切在字符中间会 panic,信息是 byte index N is not a char boundary。做「截断显示」这类需求时,纯 ASCII 下测试全过、遇到中文或 emoji 就崩——这是这一类 bug 的标准剧本。安全的做法是用 s.chars().take(n).collect::<String>(),或者用 char_indices() 找到合法的边界再切。另外
char 是 4 字节的 Unicode 码点,不是 C 那种「一个字节」。as u8 转换会对非 ASCII 静默截断,需要字节请明确用 s.as_bytes()。+ 或 format!,用 push_str。理由和 Go 那边的 strings.Builder 完全一样:每次 format! 都会新分配一块内存。String 本身就是可增长的缓冲区,push_str 是摊还 O(1) 的追加;已知总长度时先 String::with_capacity(n),连扩容都省了。前面几张卡讲道理,这一张是拿来查的——按容器分组扫一眼,找回那个想不起名字的方法。
null
Option<T>(有值 Some / 无值 None,详见 07 章 Option、08 章 ?)
map(f)有值则变换、None 透传;and_then(f)变换且 f 返回 Option(扁平化)unwrap_or(d)/unwrap_or_else(f)取值或给默认(后者惰性求值)ok_or(e)Option → Result;filter(p)不满足谓词则变 Noneis_some()/is_none()判定;?遇 None 提前return None
map(f)变换 Ok;map_err(f)变换 Err;and_then(f)链式(f 返回 Result)unwrap_or(d)取值或默认;ok()Result → Option(丢弃错误);?遇 Err 提前返回
push(x)/pop()尾部增删;get(i)越界安全(返回 Option);len()长度iter()借用迭代;contains(&x)是否含;sort()/sort_by(f)排序retain(p)原地保留满足谓词的元素;dedup()去除相邻重复
insert(k, v)插入(返回旧值 Option);get(&k)查(返回 Option)entry(k).or_insert(d)无键则插默认、返回可变引用(计数常用);contains_key(&k)keys()/values()遍历键 / 值
push_str(s)追加;len()字节长度;chars()按字符迭代split(p)切分(返回迭代器);trim()去首尾空白;replace(a, b)替换starts_with(p)前缀判定;to_string()转 String;parse::<T>()解析为 T(返回 Result)
- 变换:
map/filter/filter_map(变换 + 过滤 None) /flatten(摊平) /chain(拼接) - 索引:
enumerate(带下标) /zip(配对) /take(n)/skip(n)/rev(反转) - 消费:
collect/fold/sum/count/find/any/all/max/min
速查时最该盯住的三组
- Option/Result 的转换族:
ok_or/ok/map_err/and_then/unwrap_or_else——它们让你在两种类型之间流转而不必写match。「一串if let」通常都能压成一条链; unwrap家族的取舍:unwrap()出事时的 panic 信息几乎没有线索;expect("说明")才有。原型代码里也建议直接写expect,成本一样;- 集合的选择:默认
Vec;要按键查用HashMap;需要有序遍历或范围查询才用BTreeMap;VecDeque用于两端进出。HashMap默认的哈希算法抗 HashDoS 但不是最快的,性能敏感场景常换ahash/FxHash。
// 迭代器链:过滤 + 映射 + 收集(惰性,collect 才执行)
let v = vec![1, 2, 3, 4];
let r: Vec<i32> = v.iter().filter(|&&x| x > 1).map(|x| x * 10).collect(); // [20, 30, 40]
// Option:有值变换、无值给默认
let n = Some(5).map(|x| x * 2).unwrap_or(0); // 10
// HashMap 计数:entry().or_insert 拿可变引用累加
use std::collections::HashMap;
let mut cnt: HashMap<&str, i32> = HashMap::new();
for w in ["a", "b", "a"] { *cnt.entry(w).or_insert(0) += 1; } // a=2
// split + parse:切分、解析数字、求和
let sum: i32 = "1,2,3".split(',').filter_map(|s| s.parse().ok()).sum(); // 6Vec<f64> 上调 sort() 编译不过:rustc 1.97 报 error[E0277]: the trait bound `f64: Ord` is not satisfied——浮点数因为 NaN 只有 PartialOrd。排浮点用 v.sort_by(|a, b| a.total_cmp(b))(可用,NaN 也有确定位置)。collect / sum 等消费者就不会执行。collect 的目标类型由类型标注决定(如 : Vec<_>、: HashMap<_, _>、String),同一条链可收集成不同容器。unsafe 最常被误解成「关掉安全检查的开关」。它不是。它只解锁五件编译器无法自行证明安全的操作,除此之外的一切——借用检查、生命周期、类型系统——统统照常生效。理解这条边界,才知道审查 unsafe 代码时该盯哪儿。
五项超能力(rustc 1.97.1 逐条跑通)
- ① 解引用裸指针:
*const T/*mut T。let p: *mut i32 = &mut n; unsafe { *p += 5; }把 10 改成 15。 - ② 调用 unsafe 函数,包括 FFI。
unsafe extern "C" { fn abs(i: i32) -> i32; }调 C 的abs(-7)得 7。 - ③ 访问或修改可变静态
static mut。 - ④ 实现 unsafe trait,典型是手工
unsafe impl Send for S {}——你在向编译器担保这个类型跨线程搬运是安全的。 - ⑤ 访问 union 字段。把
1065353216按f32读出来是1(同一批位的两种解释)。
它不解锁的:借用检查
- 在
unsafe块里写两个&mut指向同一个变量,照样报error[E0499]: cannot borrow `n` as mutable more than once at a time——一个字都没放松。 - 更有意思的是同时还收到
warning: unnecessary `unsafe` block:编译器发现这个块里压根没有需要 unsafe 的操作。这条警告是个好用的自查——凡是unsafe块被报「多余」,说明你误以为要用它。 - 所以正确的心智是:
unsafe是你向编译器出具的一份担保书,范围仅限那五件事;写下它意味着「这段的正确性由我负责」,而不是「这段不用守规矩」。
edition 2024 收紧了两处(对照)
extern块必须标 unsafe:2024 下写extern "C" { … }直接error: extern blocks must be unsafe,要写成unsafe extern "C"。理由是「声明一个外部函数的签名」本身就是一次担保——签名写错了,调用处再怎么小心也没用。- 对
static mut取引用变成硬错误:2024 下println!("{}", COUNTER)(隐式取共享引用)报error: creating a shared reference to mutable static;同一份代码在 2021 下只是 warning。改法是走裸指针&raw const COUNTER,或者干脆换成AtomicU32/OnceLock(11 章)。
// ① 解引用裸指针
let mut n = 10;
let p: *mut i32 = &mut n;
unsafe { *p += 5; } // n == 15
// ② 调用 unsafe 函数 / FFI(2024 起 extern 块要标 unsafe)
unsafe extern "C" { fn abs(i: i32) -> i32; }
unsafe { abs(-7) }; // 7
// ③ static mut(2024 起取引用是硬错误,要走裸指针)
static mut COUNTER: u32 = 0;
unsafe { COUNTER += 1; println!("{}", *(&raw const COUNTER)); }
// ④ 实现 unsafe trait ⑤ 读 union 字段
unsafe impl Send for S {}
union U { i: i32, f: f32 }
unsafe { U { i: 1065353216 }.f }; // 1.0
// ✗ unsafe 不放松借用检查:
unsafe { let r1 = &mut n; let r2 = &mut n; }
// error[E0499]: cannot borrow `n` as mutable more than once
// warning: unnecessary `unsafe` blockunsafe 当成「编译器烦人时的逃生门」。它解锁的那五件事里没有一件能绕开借用检查——被 E0499 / E0502 拦住时套一层 unsafe 是白费功夫(还会拿到 unnecessary unsafe 警告)。真正需要改的是所有权结构:换 RefCell(12 章)、拆分借用范围、或者重新设计数据流。写下 unsafe 之前先问一句:我要做的是不是那五件事之一?Vec / Rc 内部全是 unsafe,你用它们却从不需要写 unsafe。审查时也只需盯那几行,而不是整个 crate。并发
「无畏并发」:所有权与借用规则在编译期就防住了数据竞争。线程间用消息传递(channel)或共享状态(Arc+Mutex),异步用 async/await + Tokio。
Rust 并发的第一姿势不是共享内存,而是把数据的所有权顺着 channel 递给另一个线程——递出去就摸不到了,竞争无从谈起。
把所有权递过去
thread::spawn(闭包) 起新线程,join() 等它结束。线程间推荐消息传递:mpsc::channel(多生产者单消费者),tx.send 发、rx 迭代收(所有 tx drop 后结束)。闭包用 move 把所需变量的所有权移进线程。
为什么 Rust 的并发叫「无畏」
- 关键不是它提供了更好的并发原语,而是数据竞争在编译期就编不过。两个 trait 承担了全部工作:
Send(这个类型可以被移到另一个线程)与Sync(&T可以被多个线程同时持有); - 把
Rcmove 进thread::spawn直接编译失败——Rc的引用计数不是原子的,所以它没有实现Send。编译器替你记住了「哪些类型跨线程不安全」这件事,不必靠人记; - 这两个 trait 绝大多数时候是自动推导的:一个结构体的所有字段都是
Send,它就是Send。你几乎永远不需要手写它们; - 消息传递之所以是首选姿势,是因为它顺着所有权的纹路:
send把值移走,发送方就再也碰不到它——不需要任何约定,编译器保证了「不会有两个线程同时改同一份数据」。
use std::{thread, sync::mpsc};
fn main() {
let (tx, rx) = mpsc::channel();
let tx2 = tx.clone(); // 多个发送者
thread::spawn(move || { // move 转移 tx 所有权
tx.send("来自线程1").unwrap();
});
thread::spawn(move || {
tx2.send("来自线程2").unwrap();
});
for msg in rx { // 收到所有 tx drop 才结束
println!("收到: {}", msg);
}
}clone 了发送端却把原始 tx 留在 main 里不 drop,for msg in rx 就永远不会结束——rustc 1.97 程序收完消息后直接挂起。接收循环要求所有发送端都被 drop:把原始 tx 也 move 进某个线程,或显式 drop(tx)。spawn 必留 JoinHandle 并 join()。闭包忘写 move 时编译器会拦下——rustc 1.97 报 error[E0373]: closure may outlive the current function, but it borrows `v`,按提示加 move 即可。真要让多个线程同时读写同一份数据时,Arc 解决「谁都能拥有」,Mutex 解决「同一时刻只有一个能改」。
两件事,两个类型
跨线程共享所有权用 Arc(原子引用计数),内部要可变就再套 Mutex(互斥锁)。Arc::clone 只增加计数不复制数据;lock() 拿锁,锁随 MutexGuard 离开作用域自动释放。多读少写用 RwLock。安全由 Send/Sync 两个 trait 在编译期保证。
为什么必须是两个类型套起来
| 解决 | 不解决 | |
|---|---|---|
Arc<T> | 多个线程都拥有(原子引用计数) | 可变性(Arc 只给你 &T) |
Mutex<T> | 同一时刻只有一个能改 | 共享所有权 |
- 所以
Arc<Mutex<T>>不是啰嗦,是两个正交问题各配一个解——这也是 Rust 类型组合风格的典型例子; - 锁的释放靠作用域:
lock()返回MutexGuard,它Drop时解锁。好处是不可能忘记解锁;代价是要留意 guard 的存活范围——在一个长表达式里持有 guard,锁就一直被占着; - Rust 不能防死锁:所有权规则管的是数据竞争,不是加锁顺序。多把锁时仍要靠约定固定加锁顺序;
- 「中毒」是 Rust 特有的一条:持锁线程 panic 后,
lock()会返回Err——这是在提醒你「里面的数据可能处于半修改状态」。很多代码直接.unwrap()忽略它,但至少要知道它为什么存在; - 多读少写换
RwLock;只是一个计数器就直接用AtomicUsize,比锁便宜得多。
use std::{sync::{Arc, Mutex}, thread};
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let c = Arc::clone(&counter); // 计数+1,不复制数据
handles.push(thread::spawn(move || {
let mut n = c.lock().unwrap(); // 拿锁
*n += 1;
})); // 锁在此自动释放
}
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap()); // 10
}std::sync::Mutex 不可重入:同一线程对同一把锁再次 lock() 会永久死锁,rustc 1.97 程序原地挂起,编译器帮不了你。另外持锁线程 panic 会让锁「中毒」,之后 lock().unwrap() 报 PoisonError { .. } panic——别在持锁区间里放可能 panic 的代码。Rc 顶替:rustc 1.97 直接编译失败 error[E0277]: `Rc<i32>` cannot be sent between threads safely——这正是 Send 约束在把不安全的共享拦在编译期,换 Arc 即可。锁的粒度尽量小:用花括号块或提前 drop(guard) 缩短持锁时间。async 不是开更多线程,而是把成千上万个等待中的任务交给少数线程轮流推进,等 IO 的时间不再白占一条线程。
少数线程轮流推进大量任务
async fn 返回 Future,.await 驱动它执行(只在 await 点让出)。需要异步运行时(Tokio)来跑:#[tokio::main] 把 main 变成入口。并发多个任务用 tokio::join!(等全部完成)或 tokio::spawn(后台任务)。
三条与直觉不同的性质
- Future 是惰性的——这与 04 章讲 Dart 时正好相反。写下
async_fn()什么都不会发生,必须.await或交给tokio::spawn才开始跑。忘了 await 会得到unused implementer of Future警告; - 只在
.await点让出:两个 await 之间的代码是不可中断地跑完的。所以在 async 函数里做重计算或调用阻塞式 API,会卡住整个执行器线程——重活要用tokio::task::spawn_blocking; - 需要一个运行时:标准库只定义了
Future这个 trait,不提供执行器。这是 Rust 刻意的分层(嵌入式场景可以换别的运行时),代价是库的异步生态会被运行时切分——依赖了 Tokio 具体 API 的库,在别的运行时上跑不了; tokio::join!等全部完成、tokio::spawn起后台任务并立刻返回句柄、select!取最先完成的那个。spawn的任务要求'static且Send,这就是为什么闭包几乎总要写move。
// tokio = { version = "1", features = ["full"] }
// reqwest = { version = "0.12", features = ["json"] }
#[tokio::main]
async fn main() {
// 两个任务并发,总耗时 ≈ max(t1, t2)
let (a, b) = tokio::join!(fetch(1), fetch(2));
let h = tokio::spawn(async { work().await });
h.await.unwrap();
}
async fn fetch(id: u32) -> Result<String, reqwest::Error> {
let url = format!("https://api.test/u/{}", id);
let text = reqwest::get(&url).await?.text().await?;
Ok(text)
}.await 跨越点持有 std::sync::Mutex 的锁——它不是为异步设计的,会阻塞整个执行线程甚至死锁,且常导致 Future 不是 Send 而编译失败。锁要跨 await 时改用 tokio::sync::Mutex。同理,别在 async 里跑阻塞式 IO/重计算,用 tokio::task::spawn_blocking。async fn 调用后什么都不做,直到被 .await——rustc 1.97 不 await 会警告 unused implementer of `Future` that must be used,备注原话:futures do nothing unless you .await or poll them。tokio::spawn 出去的任务要拿结果或错误,就 .await 它返回的 JoinHandle。智能指针
智能指针是「拥有数据并附带额外能力」的类型。Box 堆分配,Rc/Arc 多所有者,RefCell 运行时借用检查,Cow 写时复制——精确控制内存与可变性。
最简单的智能指针只干一件事:把值搬到堆上,栈里留一个大小固定的指针,编译器就有了它要的确定尺寸。
把值搬到堆上
Box<T> 把数据放堆上(指针在栈)。三大用途:编译期大小未知的递归类型(如链表)、转移大数据所有权而不复制、装 dyn Trait 对象(trait 对象需固定大小,Box 提供)。
三大用途,其实是同一个理由
- 三种场景——递归类型、trait 对象、避免大值拷贝——共同的诉求都是「让编译器拿到一个大小已知的东西」;
- 递归类型:
struct Node { next: Node }的大小是无穷,包上Box就变成一个指针宽度; - trait 对象:
fn f(_: dyn T)直接报error[E0277]: the size for values of type `(dyn T + 'static)` cannot be known at compilation time——必须&dyn T或Box<dyn T>; Box是零开销抽象的典范:它就是一个指针,Drop时释放。没有引用计数、没有运行时检查——所以「不确定用哪个智能指针时先用Box」是对的;- 顺带一条
Box::leak让你在纯 safe 代码里泄漏内存,编译器一句话不说。Rust 保证的是内存安全,不是「不泄漏」——泄漏被明确排除在安全保证之外。
// 递归类型:Box 给编译器一个确定大小
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1,
Box::new(List::Cons(2, Box::new(List::Nil))));
// 返回 dyn Trait 对象(固定大小包装)
fn make(kind: &str) -> Box<dyn Draw> {
match kind {
"circle" => Box::new(Circle { r: 5.0 }),
_ => Box::new(Square { s: 3.0 }),
}
}error[E0072]: recursive type `List` has infinite size,help 原话是 insert some indirection (e.g., a Box, Rc, or &) to break the cycle——枚举里包含自己必须隔一层指针,否则编译器算不出大小。Box<dyn Trait> 与 impl Trait 之间选:同一个函数要能返回多种具体类型才需要 Box(动态分发);始终返回同一种类型就用 impl Trait,零额外开销也不用堆分配。编译期借用检查表达不了的场景,Rc 和 RefCell 把「多所有者」与「可变性」推迟到运行时兑现——代价是出错从编译错误变成 panic。
把检查推迟到运行时
Rc<T> 引用计数,允许多个所有者(仅单线程;跨线程用 Arc)。RefCell<T> 把借用检查从编译期推迟到运行时(内部可变性):能在持有不可变引用时改内部数据,违反借用规则时运行时 panic。经典组合 Rc<RefCell<T>> = 多所有者 + 可变。通常 Rust 在编译期就禁止可变借用冲突;RefCell 将这一检查推迟到运行时,由开发者自行保证规则,一旦违反会在运行时 panic。
推迟的代价是可以看见的
let c = RefCell::new(1);
let a = c.borrow_mut();
let b = c.borrow_mut(); // 编译完全通过
运行时: thread 'main' panicked at
'RefCell already borrowed'- 这就是它与编译期借用检查的全部区别:同样的错误,从「编译不过」变成了「运行时 panic」。用
RefCell就是自愿把这份保证降级; - 所以判据很明确:能用编译期检查表达的就别用
RefCell。它是为「编译器证明不了、但你能证明」的场景准备的——图结构、观察者模式、需要在&self方法里改缓存; Rc只在单线程内有效(引用计数非原子,跨线程编译失败);跨线程对应的是Arc+Mutex;Rc的循环引用会泄漏:两个节点互相持有Rc,计数永远归不了零。解法是让其中一个方向用Weak——这是 Rust 里少数几个需要你自己想清楚的内存问题。
use std::{rc::Rc, cell::RefCell};
let a = Rc::new(5);
let b = Rc::clone(&a); // 计数+1,不复制
println!("{}", Rc::strong_count(&a)); // 2
// Rc<RefCell<T>>:多所有者 + 运行时可变
let shared = Rc::new(RefCell::new(vec![1, 2]));
let clone = Rc::clone(&shared);
clone.borrow_mut().push(3); // 运行时借用检查
println!("{:?}", shared.borrow()); // [1, 2, 3]Rc<RefCell<T>> 互相引用会形成引用循环:计数永远归不了零 → 内存泄漏(Rust 唯一能安全发生的泄漏)。父子/双向结构中,让一方持有 Weak<T>(弱引用,不增加 strong 计数)来打破环。另外 RefCell 借用冲突是运行时 panic,不是编译错误。Ref/RefMut 越早还越好:用花括号限制作用域或显式 drop;可能冲突的路径用 try_borrow_mut() 拿 Result 自己处理。rustc 1.97 冲突 panic 原文就一句:RefCell already borrowed(反向则是 already mutably borrowed)。大多数调用根本不需要修改数据,Cow 让你只在真要改的那一刻才付复制的钱。
只在真要改时才付钱
Cow<'a, B> 要么是借用(Borrowed,零分配)、要么是拥有(Owned,已复制)。适合"大多数时候只读、偶尔才需修改"的场景:不改时直接借,要改时才分配——按需付费,避免无谓拷贝。
它最典型的形状
fn normalize(s: &str) -> Cow<'_, str> {
if s.contains(' ') {
Cow::Owned(s.replace(' ', "_")) // 真要改 → 这时才分配
} else {
Cow::Borrowed(s) // 不用改 → 零分配
}
}- 价值在于「返回类型统一」:没有
Cow时,这个函数要么永远返回String(多数调用白白分配),要么返回&str(改不了)。Cow让两条路径共用一个签名; - 调用方几乎无感:
Cow<str>实现了Deref<Target=str>,可以直接当&str用;真要拿所有权就.into_owned(); - 典型场景:字符串清洗、转义、路径规范化、反序列化——这些操作在多数输入上其实什么都不用改;
- 别滥用:如果几乎每次都要修改,
Cow只是多了一层枚举判断,不如直接返回String。
use std::borrow::Cow;
fn ensure_upper(s: &str) -> Cow<str> {
if s.chars().all(|c| c.is_uppercase()) {
Cow::Borrowed(s) // 已是大写:借用,零分配
} else {
Cow::Owned(s.to_uppercase()) // 需改:才分配新 String
}
}
let a = ensure_upper("HELLO"); // 借用,无分配
let b = ensure_upper("hello"); // 拥有,一次分配to_mut() 一被调用就把 Borrowed 克隆成 Owned,哪怕你随后一个字节都没改——rustc 1.97 调用后 matches!(c, Cow::Owned(_)) 即为 true。先判断确实要改再调它。另外 Cow<'a, B> 带生命周期参数,放进结构体会把 'a 传染给整个结构体签名。Box;多所有者单线程 → Rc;多所有者多线程 → Arc;内部可变单线程 → RefCell;内部可变多线程 → Mutex/RwLock;读多写少且偶尔改 → Cow。axum Web 框架
axum 是 Tokio 团队出品的 Web 框架(当前 v0.8.x),也是 2026 年 Rust 新项目的默认选择。零宏路由、类型安全的提取器、复用整个 Tower 中间件生态——前面学的 async/trait/错误处理/Arc 在这里全部派上用场。
单文件玩具和真实工程之间隔着三样东西——模块、依赖管理和测试,搭 Web 服务之前先把它们立住。
工程的三根柱子
真正的工程不是单文件——搭 axum 服务前先立住工程组织。模块系统:mod 声明模块(可内联 mod m { ... },也可对应同名 .rs 文件),条目默认私有,加 pub 才对外可见;use 把路径引入当前作用域少打字;crate 指本包根、super 指父模块。Cargo 工作流:Cargo.toml 声明包名、版本与依赖([dependencies]),cargo build 编译、cargo run 运行、cargo add xxx 加依赖、cargo build --release 出优化版。测试:给函数加 #[test] 属性,内部用 assert!/assert_eq! 断言,cargo test 自动发现并运行全部测试。多个相关 crate 可用 workspace(顶层 Cargo.toml 的 [workspace])统一管理、共享编译产物——axum 项目常把路由、领域逻辑、数据访问拆成多个模块或 crate。
模块系统的三条规则
- 条目默认私有——包括模块本身。
mod m声明了它但没导出,外部还是看不见,要pub mod m; mod是「把文件内容嵌进这里」,use只是「起个短名字」。这是新手最大的困惑来源:漏了mod foo;,那个文件根本不会被编译,而use再多也没用;- 路径三个起点:
crate::本包根、super::父模块、self::当前模块。库 crate 的根是src/lib.rs,二进制是src/main.rs; - 测试的两个位置:单元测试写在同文件的
#[cfg(test)] mod tests里(能访问私有项);集成测试放tests/目录,只能用公开 API——这个区别本身就是一种设计压力; - workspace 让多个 crate 共享一份编译产物与依赖解析,对编译时间的改善很实际——axum 项目常把路由、领域逻辑、数据访问拆开。
// Cargo.toml:包元数据与依赖
// [package]
// name = "myapp"
// version = "0.1.0"
// [dependencies]
// axum = "0.8"
// src/main.rs:mod 声明模块,pub 导出,use 引入
mod routes {
pub fn hello() -> &'static str { "hi" } // pub 才对外可见
}
use routes::hello; // 引入路径,少打字
fn main() {
println!("{}", hello());
}
// 单元测试:#[test] + cargo test 自动发现
#[test]
fn it_works() {
assert_eq!(hello(), "hi");
}mod routes; 声明,use routes::hello 直接 error[E0432]: unresolved import `routes`(rustc 1.97 ,help 会明确提示 use mod routes in this file to declare the module)。声明了 mod 还要给条目加 pub,否则 error[E0603]: function `hello` is private。cargo new 名 建项目、cargo add 依赖 加依赖、cargo run 跑、cargo test 测。模块既可内联写,也可拆成 src/routes.rs 或 src/routes/mod.rs 文件——文件名即模块名,大型 axum 工程正是这样把路由与逻辑分文件组织。一个 Router、几个 async 函数、一行 serve,路由声明不靠宏,axum 的最小可跑服务就这么多。
最小可跑服务
Router::new().route(路径, get(处理函数)) 声明路由,无需任何宏。handler 是 async 函数,参数是提取器、返回任何实现 IntoResponse 的类型(&str→纯文本、Json→JSON)。axum 0.8 路径参数用新语法 {id}(旧版是 :id)。启动用 axum::serve(listener, app)。
「零宏路由」意味着什么
- 路由就是普通的方法调用链:
Router::new().route("/users/{id}", get(handler))。没有属性宏,所以 IDE 跳转、类型推断、报错信息全部正常; - 对比宏路由框架,代价是路径与 handler 的对应关系写在两处(路由表与函数定义),好处是编译错误指向你的代码而不是宏展开;
- axum 0.8 把路径参数语法从
:id改成了{id}——网上大量 0.7 时代的例子直接照抄会匹配不上,这是升级时最先撞到的一处; - handler 能返回任何实现
IntoResponse的类型:&str→ 纯文本、Json<T>→ JSON、(StatusCode, T)元组 → 带状态码。「返回什么就是什么响应」这条让 handler 保持了普通函数的样子。
// axum = "0.8" tokio = { version = "1", features = ["full"] }
use axum::{routing::get, Json, Router};
use serde_json::json;
async fn root() -> &'static str { "Hello" } // → 200 纯文本
async fn health() -> Json<serde_json::Value> {
Json(json!({ "status": "ok" }))
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(root))
.route("/health", get(health))
.route("/users/{id}", get(get_user)); // 0.8 新语法
let lis = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(lis, app).await.unwrap();
}:id 语法编译能过,但服务一启动就 panic——axum 0.8 报错原文:Path segments must not start with `:`. For capture groups, use `{capture}`。0.8 起路径参数一律花括号,读到冒号写法的资料先看它讲的是哪个版本。.route("/foo", get(show).post(create))。任何实现 IntoResponse 的都能返回,理解这个 trait 能省下大量「如何返回不同类型」的困惑。把「从请求里取什么数据」写成 handler 参数的类型,解析和校验就全交给了框架。
类型即契约
提取器从请求里声明式取数据,作为 handler 参数、由 axum 自动解析并校验:Path<T> 路径参数、Query<T> 查询串、Json<T> 请求体(配 serde 的 Deserialize)。类型即契约:handler 编译通过就保证参数类型正确,运行时若请求缺参或反序列化失败,axum 会自动返回 4xx 拒绝响应(如 Json 体解析失败默认 422)。
一条容易踩的顺序规则
- 消耗请求体的提取器(
Json、String、Bytes)必须放在参数列表的最后一个——因为请求体只能被读一次。放错位置是编译错误,但报错信息不太直观; - 其余提取器(
Path、Query、State、HeaderMap)只读请求的元数据,顺序随意; - 提取失败时 axum 自动返回 4xx,handler 根本不会被调用:
Path类型不匹配 → 400,Json反序列化失败 → 422。这意味着「参数校验」这一层代码你不用写; - 想自定义失败时的响应格式,就用
Option<Json<T>>或Result<Json<T>, JsonRejection>把拒绝接管过来——这是把「框架默认的 4xx」换成自己 API 错误格式的标准做法。
use axum::extract::{Path, Query, Json};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Page { page: Option<u32> }
#[derive(Deserialize)]
struct NewUser { name: String, email: String }
async fn get_user(Path(id): Path<u64>) -> String {
format!("user {}", id)
}
async fn list(Query(q): Query<Page>) -> String {
format!("page {}", q.page.unwrap_or(1))
}
async fn create(Json(u): Json<NewUser>) -> String {
format!("created {}", u.name)
}Json、String)必须放在参数列表最后——它会拿走 body,后面的提取器就取不到了。其余只读 Parts 的提取器(Path/Query/HeaderMap)顺序随意。Path<u64> 收到非数字返回 400(Cannot parse `abc` to a `u64`)、JSON 体缺字段返回 422、Content-Type 不是 application/json 返回 415——每个都带一句能直接照着修的错误说明,调接口时先看响应体。连接池和配置这类依赖只在启动时初始化一次,靠 State 一路注入到每个需要它的 handler。
启动时初始化一次
数据库连接池、配置等共享依赖用 State:定义一个 #[derive(Clone)] 的 AppState(内部把池/配置包进 Arc 以便廉价克隆),.with_state(state) 注入路由,handler 用 State(s): State<AppState> 取出。类型不匹配会编译报错。
为什么 AppState 要 Clone
- axum 对每个请求都会 clone 一次 state,所以它必须实现
Clone; - 这看起来很贵——但正确的写法是让内部字段本身是廉价可克隆的:连接池(
PgPool内部就是Arc)、配置包进Arc。这样 clone 只是几次原子加一; - 反模式是把一大坨数据直接放进
AppState:每个请求都真的拷一遍; - 要在 state 里放可变数据,仍然是
Arc<Mutex<T>>或Arc<RwLock<T>>(11 章)——axum 没有发明新东西,前面学的并发原语在这里直接用; - 类型不匹配是编译错误:handler 里写
State<OtherType>编译不过。这条比运行时的「依赖注入找不到」友好得多。
use axum::extract::State;
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
db: Arc<Pool>, // 共享只读依赖包进 Arc
config: Arc<Config>,
}
async fn get_user(
State(st): State<AppState>, // 注入状态
Path(id): Path<u64>,
) -> String {
let _pool = &st.db; // 用 st.db 查库
format!("user {}", id)
}
// main 里:
let state = AppState { db: Arc::new(pool), config: Arc::new(cfg) };
let app = Router::new().route("/u/{id}", get(get_user)).with_state(state);.with_state(state) 不会拖到运行时才炸:axum 0.8 在 axum::serve 处编译失败,error[E0277]: the trait bound `Router<AppState>: Service<...>` is not satisfied——Router<AppState> 这个类型参数就记录着「还欠一个状态没交」,交齐才变成能服务的 Router<()>。main 里初始化连接池/配置各一次,放进 AppState 注入。请求间共享的只读依赖放 State;请求级派生数据(如鉴权得到的用户)放 Extension 或自定义提取器。错误也是响应:教会 AppError 如何变成 HTTP 响应,handler 里就能放心用问号传播错误了。
错误也是响应
任何实现 IntoResponse 的类型都能返回:Json(v)、(StatusCode, Json(v)) 元组带状态码。错误处理的惯用法:为自定义错误类型实现 IntoResponse(把错误映射成状态码+JSON),handler 返回 Result<T, AppError>,这样就能在 handler 里大胆用 ? 传播错误。
把 ? 引进 handler 的那一步
// ① 定义自己的错误类型(用 thiserror,08 章) // ② 为它实现 IntoResponse:错误 → 状态码 + JSON impl IntoResponse for AppError { fn into_response(self) -> Response { ... } } // ③ handler 返回 Result<T, AppError> async fn h() -> Result<Json<User>, AppError> { let u = db.find(id).await?; // ← 现在可以用 ? 了 Ok(Json(u)) }
- 关键是 08 章那条
From联动:给AppError实现From<sqlx::Error>、From<serde_json::Error>,各种来源的错误就都能被?自动转换; - 务必区分「给用户看的」和「给日志看的」:
into_response里返回泛化的信息(「服务内部错误」),同时把详细错误tracing::error!出去。直接把数据库错误原文返回给客户端是信息泄漏; - 这一整套的效果是:handler 的正常路径保持干净,错误处理集中在一处——这正是 Rust 错误处理相对 try/catch 的优势落到 Web 场景上的样子。
use axum::{response::IntoResponse, http::StatusCode, Json};
enum AppError { NotFound, Internal }
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (code, msg) = match self {
AppError::NotFound => (StatusCode::NOT_FOUND, "未找到"),
AppError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "内部错误"),
};
(code, Json(serde_json::json!({ "error": msg }))).into_response()
}
}
// handler 返回 Result,内部可用 ? 传播
async fn get_user(Path(id): Path<u64>)
-> Result<Json<User>, AppError>
{
let u = find(id).ok_or(AppError::NotFound)?;
Ok(Json(u))
}error[E0277]: the trait bound `fn(...) ...: Handler<_, _>` is not satisfied,外加一条备注 Consider using #[axum::debug_handler] to improve the error message——照做加上这个属性宏,才能看到人话版的病因。AppError 实现 From<底层错误>(如 sqlx::Error),handler 里就能对任何底层调用直接 ?;成功响应要带状态码,直接返回 (StatusCode::CREATED, Json(v)) 元组即可。axum 不发明自己的中间件——日志、CORS、鉴权,都是往 Tower 的洋葱上再包一层 layer。
往 Tower 洋葱上再包一层
axum 没有自己的中间件系统,直接复用 Tower / tower-http 生态:.layer(TraceLayer::new_for_http()) 加日志、CorsLayer 加 CORS、.layer(...) 加超时/压缩等,全部开箱即用。自定义中间件用 middleware::from_fn。.nest() 挂子路由、.merge() 合并路由、给某组 .layer() 加专属中间件(如鉴权)。
layer 的顺序是反过来的
.layer()后加的在外层:最后调用的 layer 最先看到请求、最后看到响应。这与「从上往下读」的直觉相反,是 Tower 洋葱模型的直接结果;- 所以日志(
TraceLayer)通常加在最外层(写在最后),才能记录到包括其它中间件在内的完整耗时; - 给某组路由单独加中间件用
.nest()+ 该子路由的.layer()——鉴权就是这么挂的:公开路由与受保护路由分成两个 Router,只给后者加鉴权 layer,再.merge(); - 复用整个 Tower 生态是 axum 最大的实用优势:超时、并发限流、重试、压缩、CORS、追踪都不用自己写,而且这些中间件在其它 Tower 系框架里也通用;
- 自定义中间件用
middleware::from_fn,签名是async fn(Request, Next) -> Response——比手写Servicetrait 简单得多。
use axum::{Router, routing::get, middleware};
use tower_http::{trace::TraceLayer, cors::CorsLayer};
// 自定义鉴权中间件
async fn auth(req: axum::extract::Request, next: middleware::Next)
-> Result<axum::response::Response, axum::http::StatusCode>
{
if req.headers().contains_key("authorization") {
Ok(next.run(req).await)
} else {
Err(axum::http::StatusCode::UNAUTHORIZED)
}
}
let api = Router::new()
.route("/users", get(list))
.layer(middleware::from_fn(auth)); // 仅此组鉴权
let app = Router::new()
.nest("/api", api) // 挂到 /api 下
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http());.layer() 只包住在它之前注册的路由:axum 0.8,在 .layer(middleware::from_fn(auth)) 之后再 .route() 加的接口不带鉴权直接 200 放行。想全局生效,就把 layer 放在整条链的最后。把路由、提取器、State、错误处理串成一个真的能上线的服务,前面学的每一块才算落了地。
上生产要补的几样
生产栈:axum + sqlx(编译期校验 SQL 的异步数据库)+ serde + tracing。一个 CRUD 把前面所有部件串起来:State 注入连接池、提取器收参、Result+? 处理错误、Json 出响应。上线前加优雅关闭(监听信号后停止接收新请求、等在途完成)。
玩具和生产之间差这五件
- 数据库:
sqlx的query!宏会在编译期连库校验 SQL 与类型——写错列名编译就失败。代价是构建时要有可达的数据库或离线缓存(cargo sqlx prepare); - 优雅关闭:
axum::serve(...).with_graceful_shutdown(signal)。没有它,重新部署时正在处理的请求会被直接掐断; - 可观测:
tracing+TraceLayer,给每个请求带上 span。Rust 的日志生态以tracing为准,异步场景下它比log有用得多(能跨 await 保持上下文); - 配置与密钥:走环境变量(
dotenvy只用于开发),别编译进二进制; - 超时与限流:
tower::timeout、tower::limit——不加的话一个慢下游就能把连接池吃干净。
use axum::{routing::{get, post}, Router, Json, extract::{State, Path}};
async fn create_user(
State(st): State<AppState>,
Json(input): Json<NewUser>,
) -> Result<Json<User>, AppError> {
let u = sqlx::query_as!(User,
"INSERT INTO users (name) VALUES ($1) RETURNING *", input.name)
.fetch_one(&*st.db).await?; // ? 传播数据库错误
Ok(Json(u))
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init(); // 结构化日志
let app = Router::new()
.route("/users", post(create_user))
.route("/users/{id}", get(get_user))
.with_state(state);
let lis = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(lis, app)
.with_graceful_shutdown(shutdown_signal()) // 优雅关闭
.await.unwrap();
}sqlx::query_as! 这类宏在编译期连数据库校验 SQL:没设 DATABASE_URL 或库不可达时编译直接失败。CI 与离线构建按官方做法用 cargo sqlx prepare 生成查询缓存,提交进仓库。tracing 结构化日志 + TraceLayer、with_graceful_shutdown 等在途请求收尾、超时与体积限制 layer、/health 健康检查、用 sqlx 的编译期 SQL 校验避免运行时炸库。工程:模块、测试与宏
把一个 crate 组织起来:模块与可见性、三类测试、以及 macro_rules! 声明宏。
和 Go 一样,Rust 把构建、测试、依赖、格式化、文档收进一个命令。这几条现在记个脸熟即可,后面章节会各自展开。
日常四条
cargo check只检查不生成产物,最快,改代码时的默认动作;cargo run编译并运行;加--release用优化构建(测性能必须加);cargo test跑所有测试;对库 crate 还会额外运行文档注释里的示例代码(doc-test)——这是 Rust 的特色,文档不会烂掉(纯二进制项目不收集 doc-test);cargo fmt官方格式化,无配置项可争。
依赖管理
cargo add serde --features derive添加依赖并写进Cargo.toml(不用手写版本号);- 版本锁在
Cargo.lock里:提交它——官方现在建议所有项目(含库)都把它纳入版本控制,早年那条「库不提交」的说法已经废弃; - 第三方包叫 crate,都发布在
crates.io;文档自动生成在docs.rs,任何 crate 都能查。
两条能救命的
cargo clippy官方 lint,会指出「有更地道写法」的地方,初学就该开着;cargo doc --open为你的项目和所有依赖生成文档并打开浏览器——离线查第三方库 API 最快的方式。
// ── 日常 ──────────────────────────
// cargo check 只检查,最快
// cargo run 编译并运行
// cargo run --release 优化构建(测性能必须加)
// cargo test 跑测试(含文档示例)
// cargo fmt 官方格式化
// ── 检查 ──────────────────────────
// cargo clippy 官方 lint,教你写地道 Rust
// ── 依赖 ──────────────────────────
// cargo add tokio --features full 添加依赖
// cargo update 升级依赖
// cargo doc --open 生成并打开本地文档
// ── 查文档,不用开浏览器 ──────────
// rustc --explain E0382 查某个错误码的完整说明overflow-checks 这个编译选项决定,而 Cargo 的 release profile 默认把它关掉了。这不是 bug,但确实是个坑。所以「debug 好好的,release 结果不对」在 Rust 里往往就是这条。要两边行为一致,用 checked_add / wrapping_add / saturating_add 明确表达你想要哪种语义。~/.cargo/config.toml 里配置镜像源(各高校镜像站都有现成的配置片段可抄)。另外 cargo 的子命令是可扩展的:cargo install cargo-edit 之类会直接变成新的 cargo xxx 命令。Rust 的模块树和目录结构不是一回事——文件放在哪里只影响编译器去哪找源码,真正定义模块树的是 mod 声明。默认一切私有,包括模块自己;pub 是逐层开门,不是一次全开。
三条基本规则
- 默认私有:函数、结构体、字段、模块,不写
pub就只有本模块及其子模块能看见。子模块能看父模块的私有项,反过来不行——这条方向性是新手最常搞反的。 - 路径三种写法:
crate::从本 crate 根开始(绝对),super::上一层,self::本层。兄弟模块之间互相调用必须走crate::或super::,不能直接写模块名。 - 可见性有层级:
pub(完全公开)、pub(crate)(本 crate 内可见,最常用)、pub(super)(父模块可见)、pub(in 路径)(指定范围)。库作者的默认习惯应该是pub(crate),只把真正的 API 标pub——标出去就是承诺,改动即破坏性变更。
重导出:把深层的东西提到门口
pub use 内部::深::Thing;让使用者写my_crate::Thing而不是my_crate::内部::深::Thing。内部怎么组织是你的自由,对外的路径保持扁平——几乎所有成熟 crate 都这么做。- 一个坑:
pub(crate)的项不能被pub use重导出,报error[E0364]: `crate_only` is only public within the crate, and cannot be re-exported outside,编译器还会提示「consider marking as pub」。想重导出就得把源头也标成pub——可见性只能收窄不能放大。
文件怎么放
mod foo;让编译器去找foo.rs或foo/mod.rs;后者是老写法,新代码推荐foo.rs+foo/目录并存的现代布局。- 没有任何
mod声明指向的.rs文件根本不会被编译——不报错、不警告,就是不存在。「我明明写了这个文件怎么找不到」十有八九是漏了mod。
// src/lib.rs
mod internal { // 私有模块
pub fn helper() -> i32 { 42 }
pub(crate) fn crate_only() -> i32 { 7 }
}
pub mod api {
pub fn call() -> i32 {
crate::internal::helper() // 兄弟模块要走 crate:: 或 super::
}
}
// 重导出:使用者写 my_crate::helper 就行
pub use internal::helper;
// ✗ pub(crate) 的项不能 pub use 出去
// pub use internal::crate_only;
// error[E0364]: `crate_only` is only public within the
// crate, and cannot be re-exported outsidemod 就等于不存在——编译器不会提醒你有个孤儿文件;② pub 不是递归的——pub fn 放在一个私有 mod 里,外面照样看不见。要从外部可达,路径上每一层都得是公开的。报「private module」时先从最外层往里逐层查,而不是盯着那个函数。pub use 到 lib.rs,其余一律 pub(crate)。这样内部随便重构都不算破坏性变更,而 cargo doc 生成的文档首页也正好是你想让人看的那份清单。Rust 把测试做进了语言和 Cargo 里,不需要挑框架。cargo test 一条命令跑三类测试,其中文档测试是别的语言少见的设计——写在文档注释里的示例代码会被真的编译执行,从根上解决「文档里的例子早就跑不通了」。
三类测试,三段独立输出
- 单元测试:写在被测文件里的
#[cfg(test)] mod tests。#[cfg(test)]保证这段只在测试时编译,不进发布产物。因为它是子模块,能看到父模块的私有项——在 tests 模块里直接调私有的private_mod::helper()通过。 - 集成测试:
tests/目录下每个文件是一个独立 crate,像外部使用者一样use 你的crate名::…,因此只能碰公开 API。这正是它的价值——逼你检查对外接口是否好用。 - 文档测试:文档注释(
///和//!)里三反引号围起来的代码块。cargo test输出三段:Running unittests src\lib.rs(1 个)、Running tests\integration.rs(1 个)、Doc-tests probe(3 个)。
文档测试真的会跑
- 故意在文档里写一个错的断言
assert_eq!(add(1, 1), 3),cargo test当场失败并给出完整 panic:assertion `left == right` failed / left: 2 / right: 3,还标出是src\lib.rs - add (line 15)那个例子。 - 所以 Rust 生态里文档示例的可信度天然比别的语言高一截——它们过不了 CI。写库时把「怎么用」直接写成文档测试,一份代码同时当文档和回归测试。
//!写的是模块级文档(放在文件顶部,描述这个 crate/模块整体),///是紧跟其后那一项的文档。两者里的代码块都会被执行。
常用开关
cargo test 名字片段只跑名字匹配的;cargo test -- --nocapture放行println!(默认通过的测试不打印输出);cargo test --doc只跑文档测试;cargo test --lib只跑单元测试。#[should_panic(expected = "片段")]断言会 panic 且信息包含某段文字;#[ignore]标记默认跳过的慢测试,用-- --ignored单独跑。- 返回
Result的测试函数可以直接用?,比一路unwrap干净(08 章)。
//! 模块级文档,这里的代码块也会被跑
//!
//! ```
//! assert_eq!(probe::add(2, 3), 5);
//! ```
/// 把两个数加起来。
///
/// # 示例
/// ```
/// assert_eq!(probe::add(1, 1), 2);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn 看得见私有项() {
assert_eq!(add(2, 2), 4);
}
}
// tests/integration.rs —— 独立 crate,只能用公开 API
#[test]
fn 对外接口好不好用() {
assert_eq!(probe::add(2, 3), 5);
}pub(crate) 的东西——它是外部 crate。想在 tests/ 里测内部逻辑,要么把它提成公开 API(那就得想清楚是否愿意长期维护),要么改成单元测试放回源文件里。另外文档测试默认要能编译并运行,示例里如果引用了不存在的变量也会失败;确实只想展示不想运行,给代码块标 ignore 或 no_run。cargo doc 生成的页面里读者能直接看到可运行的用法。私有实现细节则交给 #[cfg(test)] mod tests——那里能看到私有项,适合测边界条件。你从第一行 Rust 就在用宏——println!、vec!、assert_eq! 后面那个 ! 就是标记。宏能做函数做不到的两件事:接受任意数量、任意类型的参数,以及在编译期生成代码。macro_rules! 是其中最容易上手的一种。
它是「模式 => 展开」,不是文本替换
- 写法是一组
(模式) => { 展开 };规则,从上往下匹配第一个合上的。捕获用$名字:类型,常见类型有expr(表达式)、ident(标识符)、ty(类型)、tt(单个 token 树)、pat(模式)。 - 重复用
$( … ),+——括号里是要重复的部分,逗号是分隔符,+表示一次或多次(*是零次或多次)。自己写的my_vec![1, 2, 3]展开成三次push,得到[1, 2, 3];my_vec![]走空规则得到[]。 - 关键区别于 C 宏:它作用在语法树上,捕获的
expr是一个完整表达式而不是一串字符。所以不存在 C 里那种「忘了加括号导致优先级错乱」的经典陷阱——trace!(2 + 3 * 4)得到14,参数作为整体被求值。
卫生性:宏里的变量不会污染外面
- 写一个宏,body 里是
let x = 999;。调用处外面也有一个x = 1。展开之后外面的 x 仍然是 1——宏引入的标识符和调用处的同名标识符不是同一个,编译器给它们打了不同的「语法上下文」。 - 编译器甚至会提示
help: `x` is captured in macro and introduced a unused variable——它清楚地知道这是宏引进来的那个 x。 - 这就是「卫生宏」,C 的宏没有这个性质(所以 C 宏里的临时变量要起
__tmp_xyz这种名字防撞)。
三类宏,别搞混
- 声明宏
macro_rules!——本卡讲的这种,写在普通代码里即可。 - 派生宏
#[derive(Debug, Clone)]——自动为类型实现 trait,日常用得最多(06 章)。 - 过程宏——包括自定义 derive、属性宏(如
#[tokio::main])、函数式过程宏。它们是真正的 Rust 程序,输入输出都是 token 流,必须放在单独的proc-macrocrate 里。写起来重得多,一般用syn+quote两个 crate 辅助。
// 一组「模式 => 展开」规则,从上往下匹配
macro_rules! my_vec {
() => { Vec::new() };
($($x:expr),+ $(,)?) => {{
let mut v = Vec::new();
$( v.push($x); )+ // 重复展开
v
}};
}
let v: Vec<i32> = my_vec![1, 2, 3]; // [1, 2, 3]
// 语法级:拿得到源码文本,参数整体求值
macro_rules! trace {
($e:expr) => { println!("{} = {:?}", stringify!($e), $e) };
}
trace!(2 + 3 * 4); // 2 + 3 * 4 = 14
// 卫生性:宏里的 x 和外面的 x 不是同一个
macro_rules! shadow { () => { let x = 999; }; }
let x = 1;
shadow!();
println!("{}", x); // 仍是 1HashMap::new(),展开到一个没 use std::collections::HashMap 的地方就编译不过。写给别人用的宏要把路径写全(::std::collections::HashMap,前导 :: 避免被同名局部模块劫持)。另外宏定义必须在使用之前出现(按文件顺序,不像函数可以后定义),跨模块使用还要 #[macro_export] 导到 crate 根。vec!)、需要拿到源码文本(stringify!、断言里打印表达式原文)、需要生成重复的 impl 块。除此之外,宏会让报错信息、IDE 跳转和可读性统统变差。调试时用 cargo expand 看展开结果,比盯着宏定义猜快得多。Rust 的界面:TUI 与 GUI
Rust 的界面生态是倒挂的:终端 UI 已经是全语言里最成熟的一档,桌面 GUI 却还没有定论。这一章先讲清这个格局,再把 ratatui 的心智模型和四条 GUI 路线的取舍摊开。
Rust 的终端 UI 已经收敛到 ratatui 一家,不少命令行工具的界面都是它画的;桌面 GUI 则还有五六个并行的方案,各自占着一块场景。这一卡列出当前的选手、版本和适用场景。
当前的选手与版本
| 方案 | 当前版本 | 它是什么 |
|---|---|---|
| ratatui | 0.30.2 | TUI 的事实标准,配 crossterm 0.29 做终端后端 |
| egui / eframe | 0.35.0 | 即时模式 GUI,代码最短,工具与调试面板首选 |
| iced | 0.14.0 | Elm 架构(Message / update / view),状态管理干净 |
| Slint | 1.17.1 | 自带声明式 DSL,编译成 Rust,主打嵌入式与桌面 |
| Tauri | 2.11.5 | 界面用 Web 技术写,Rust 只做后端 |
版本号本身就是信息
- 注意上表里 GUI 那几个大多还在 0.x——按 Cargo 的语义化版本规则,
0.x的每一个次版本号都可以破坏兼容。这不是作者不上心,是这些项目确实还在改设计。 - 实际影响:跟着教程写的代码,隔半年就可能编译不过。动手前先看目标版本的 CHANGELOG 和官方示例,别照搬博客。只有 Slint(1.17)和 Tauri(2.11)已进入 1.0 之后的稳定承诺。
怎么选
- 命令行工具想加个仪表盘 / 交互列表 → ratatui。成熟、生态好、没有原生依赖。
- 工具类窗口、参数调试面板、嵌进游戏或渲染器 → egui。它就是为这个场景设计的。
- 要做一个结构清楚、会长期维护的桌面应用 → iced 或 Slint。
- 你本来就会前端 → Tauri,界面按 Web 写,产物比 Electron 小得多(用系统 WebView 而不是自带一个浏览器)。
// 各条路线的最小依赖声明(Cargo.toml)
// 终端 UI —— 成熟档
[dependencies]
ratatui = "0.30"
crossterm = "0.29" // 终端后端:原始模式、备用屏、事件
// 即时模式 GUI —— 工具面板
eframe = "0.35" // egui + 窗口与渲染后端的一站式封装
// Elm 架构 GUI
iced = "0.14"
// 声明式 DSL
slint = "1.17"
// Web 前端 + Rust 后端(还需 npm 侧的脚手架)
tauri = "2"examples/ 都能十分钟内跑起来,动手前先各跑一个。ratatui 不是控件树,也不接管程序流程。它负责的只有一件事:把 widget 排进一个字符缓冲区。事件循环、终端状态、何时重画都由你自己写。
心智模型:每帧重画一遍
- 你写一个循环:读事件 → 改自己的状态 → 调
terminal.draw(|f| ...)把整个界面重画一遍。没有「控件对象」需要你持有和更新,widget 是每帧临时构造的值。 - 好处是状态只有一份(你自己的结构体),不存在「界面和数据不同步」这种经典 GUI bug。
- 终端本身的事——原始模式、备用屏幕、鼠标捕获、按键事件——由后端负责,最常用的是 crossterm(跨平台,Windows 也能跑)。ratatui 只管往缓冲区里填字符。
它能不用终端就测
TestBackend可以在没有真终端的环境里完成渲染:把Paragraph和Gauge画进一个 40×6 的缓冲区,再逐格取buf[(x, y)].symbol()读回内容做断言。- 第一行读出来是
┌换 算 ────…┐、第二行是│25 °C = 77.0 °F …│——边框字符、标题、内容都能精确比对,CI 里跑得起来。 - 这是 TUI 相对桌面 GUI 一个实打实的优势:界面就是字符矩阵,天然可断言,不需要截图比对那一套。
中文 TUI 的第一个坑
- 缓冲区是按单元格组织的,而 CJK 字符占两格。标题「换算」在缓冲区里逐格读出来是
换、空、算、空——第二格是宽字符的续格,不是空格。 - 后果:所有按「字符数」算的排版都会错位。截断、居中、算列宽都要按显示宽度来,用
unicode-widthcrate 的width(),不能用chars().count()。 - 这条在纯英文界面上永远不会暴露,一上中文就整排歪掉——写之前就按显示宽度算,比事后改省事得多。
use ratatui::{
backend::TestBackend,
layout::{Constraint, Layout},
widgets::{Block, Borders, Gauge, Paragraph},
Terminal,
};
// TestBackend:不需要真终端,渲染到内存缓冲区
let mut terminal = Terminal::new(TestBackend::new(40, 6))?;
terminal.draw(|f| {
let areas = Layout::vertical([
Constraint::Length(3),
Constraint::Length(3),
]).split(f.area());
f.render_widget(
Paragraph::new("25 °C = 77.0 °F")
.block(Block::default().borders(Borders::ALL).title("换算")),
areas[0],
);
f.render_widget(
Gauge::default().percent(65),
areas[1],
);
})?;
// 读回缓冲区做断言 —— 界面就是字符矩阵
let buf = terminal.backend().buffer();
let line0: String = (0..40).map(|x| buf[(x, 0)].symbol().to_string()).collect();
// line0 = "┌换 算 ──────…──┐" ← 注意「换」后面那一格reset 救回来)。正确做法是装一个 panic hook:在里面先 disable_raw_mode() 和 LeaveAlternateScreen,再调原来的 hook 打印 panic 信息。不装这个 hook 的话,崩溃信息会打在备用屏上,切回来就看不到了。examples/ 拿一份改,比自己从头写少踩几个恢复不干净的坑。四个方案的架构差别很大,下面按各自适用的场景排列,逐个说明它给什么、要什么。
egui / eframe(0.35):即时模式,最快出东西
- 每帧重建整个 UI,代码写起来像
if ui.button("跑").clicked() { ... }——没有回调、没有信号槽、没有状态同步问题,逻辑读起来是线性的。 - 最适合:参数调试面板、内部工具、嵌进游戏或渲染器的调试界面。它本来就是为这些场景生的。
- 代价:不用原生控件,一切自己绘制。无障碍支持、复杂文本编辑、输入法(IME)是弱项——做中文输入密集的应用之前,先自己一遍输入法能不能正常用。
iced(0.14):Elm 架构,结构最清楚
- 强制你把程序拆成 State + Message + update + view:所有变化都经由一个 Message 枚举,改哪儿了一目了然。写过 Redux 或 Elm 会立刻熟悉。
- 最适合:会长期维护、状态关系复杂的桌面应用。架构本身替你挡住了一类混乱。
- 代价:控件数量和生态仍在追赶,遇到没有的控件要自己实现;样式系统也还在演进。
Slint(1.17):写一门小语言
- 界面用它自己的声明式 DSL 描述(
.slint文件),编译期生成 Rust 代码;有可视化预览工具。 - 最适合:嵌入式设备与桌面——它对资源受限环境做了专门优化,这是其它几家没有的。
- 代价:多学一门 DSL;许可证要先确认——它同时提供 GPL、商业授权与免版税授权三种,选哪种取决于你要怎么发布,动手前就该看清楚。
Tauri(2.11):界面交给 Web
- 前端用 HTML/CSS/JS(配任意框架),Rust 写后端逻辑,两边通过命令通信。
- 相对 Electron 的核心差别:用系统自带的 WebView,不打包整个浏览器——产物体积小一个数量级。
- 代价:你得会前端;而且各平台的 WebView 实现不同(Windows 是 WebView2、macOS 是 WKWebView、Linux 是 WebKitGTK),渲染差异和 bug 要逐平台,这正是 Electron 用自带浏览器换掉的那个麻烦。
// egui:即时模式 —— 界面就是每帧跑一遍的代码
impl eframe::App for MyApp {
fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("温度换算");
ui.add(egui::Slider::new(&mut self.c, -40.0..=100.0).text("°C"));
ui.label(format!("{:.1} °F", self.c * 9.0 / 5.0 + 32.0));
if ui.button("重置").clicked() { // 没有回调,直接读点击结果
self.c = 25.0;
}
});
}
}
// iced:Elm 架构 —— 所有变化都经由 Message
enum Message { 温度改变(f32), 重置 }
fn update(state: &mut State, msg: Message) {
match msg {
Message::温度改变(v) => state.c = v,
Message::重置 => state.c = 25.0,
}
}从这里到精通:路线图
地图铺完了,剩下的路要亲手写出来。最后这一章给出收尾路线:难度递进的动手项目、按阶段的资料,以及一条自测标准。
Rust 的知识必须在与编译器的反复对话中长成肌肉记忆。下面四个项目按难度递进,每一个都针对性地攻克一道关卡。
动手项目(难度递进)
- ① CLI 工具:用
clap写一个 grep 或 todo 命令行工具,产出一个能cargo install的可执行文件——把 Cargo 工作流、模块组织和Result错误处理练熟。 - ② 过所有权关:亲手实现一个单向链表和一个 LRU 缓存(HashMap + 双向链表),被 borrow checker 拒绝就推倒重来——
Box/Rc/RefCell的适用边界在这里一次打通。 - ③ Web 服务:用 axum + tokio + sqlx 写一个带数据库的 REST API,含优雅关闭与 tracing 日志——async、trait、Arc 共享状态全部落地成生产形态。
- ④ 走进社区:把自用工具补齐文档、测试、CI 后发布到 crates.io,或给 ripgrep、tokio 这类项目修一个 good-first-issue——读真实工程代码是从「会写」到「写得地道」的必经之路。
书与资料(按阶段)
- 入门:官方 The Rust Programming Language(社区俗称 The Book,有中文版),配 Rust by Example 对照着读;
- 练习:Rustlings——官方小练习集,每题对着编译器报错改到通过,是过借用检查关的最佳陪练;
- 进阶:《Programming Rust (2nd)》比 The Book 深一层,系统讲透所有权与并发;宏(
macro_rules!)与unsafe的入门见本页 14 章与 10 章,再往深(过程宏、UnsafeCell与 UB 的精确定义)看官方 Book 与《Rust for Rustaceans》; - 日常:docs.rs 查任何 crate 的文档;标准库文档本身就是最好的 API 设计教材。
.clone() 和 Rc<RefCell<T>> 一律套上——能编译,但所有权这关等于没过,写出来的还是别的语言。链表练习卡死时去读 Learn Rust With Entirely Too Many Linked Lists,而不是绕过去。