如果你从 Python、JavaScript 或 Java 转过来,Rust 很可能会先用一个看似鸡毛蒜皮的问题欢迎你:函数最后一行到底要不要分号。
fn double(n: i32) -> i32 {
n * 2;
}
fn main() {
println!("{}", double(21));
}这段代码不能通过编译。编译器会把问题指向 n * 2;,告诉你函数声明要返回 i32,实际得到的却是 ():
error[E0308]: mismatched types
expected `i32`, found `()`
help: remove this semicolon to return this value你可能会想:一个分号而已,至于吗?至于,而且这次编译器不是在挑格式。Rust 把函数体、普通代码块、if、match 和 loop 都放进了同一套“表达式可以产生值”的规则里。末尾没有分号的 n * 2 会把整数交给函数;加上分号,则表示“算一下,但这个结果我不要”,于是函数体只能交出单元值 ()。
这套规则刚接触时很容易绊脚。好消息是,它不是一堆彼此无关的语法特例。只要抓住两个问题,后面的控制流就会连起来:这段代码会产生什么值?这个值在流动时,是被复制、移动,还是借用了原数据?本章就围着这两个问题走。我们会故意写出一些过不了编译的代码,再看这位严格但负责的搭档到底在拦什么。
你不用期待读完一遍就再也不会漏分号,也不用因为第一次写 match 被提示“模式未覆盖”而怀疑自己。这里确实有一段适应期:其他语言常把控制流当成只负责跳转的骨架,Rust 却让控制流同时参与类型和所有权计算。刚开始你会觉得编译器连“显然能跑”的代码都要盘问;等你习惯之后,会发现这些盘问把很多原本要到测试、甚至线上才暴露的路径提前摆到了桌面上。
阅读后面的例子时,建议先别急着背语法名称。看到大括号就找它最后交出的值,看到分支就找汇合后的共同类型,看到变量进入函数或模式就确认所有权有没有离开。用这条线追代码,比孤立记住“if 是表达式”“let 是语句”更可靠。

let 绑定:名字默认不会被改写先看另一个几乎人人都会遇到的错误:
fn main() {
let retries = 2;
retries += 1;
println!("{retries}");
}编译器会说不能给不可变变量 retries 重新赋值,并建议你把声明改成 let mut retries = 2;。这里的关键词是“绑定”。let retries = 2; 把名字 retries 绑定到值 2,这个绑定默认不可变。Rust 希望修改是代码里看得见的决定,而不是变量声明几百行后突然发生的意外。
默认不可变并不等于程序里的数据永远不能变化。它只是在说,通过这个绑定不能重新赋值或取得可变借用。确实要维护计数器、累积结果或更新状态时,用 mut 很正常:
fn main() {
let max_retries = 3;
let mut attempts = 0;
while attempts < max_retries {
attempts += 1;
println!("第 {attempts} 次尝试");
}
}第 1 次尝试
第 2 次尝试
第 3 次尝试不要把 mut 当成坏味道,也别一遇到错误就给所有变量都加上它。判断方法很朴素:这个名字代表的是逐步变化的同一个状态,还是一连串处理阶段?计数器通常适合 mut;输入从文本变成数字、再变成校验后的业务值,通常更适合遮蔽。
遮蔽的写法是再次使用 let,让同一个名字指向一个新绑定:

fn main() {
let port = " 8080 ";
let port = port.trim();
let port: u16 = port.parse().expect("端口必须是数字");
println!("监听端口:{port}");
}这里先有 &str,清理空白后仍是 &str,最后变成 u16。三个阶段都叫 port,因为它们在业务上就是同一份信息的不同形态。每一行创建的绑定仍然不可变,类型却可以变化。
这和 mut 不一样。下面的代码不能靠可变绑定完成:
fn main() {
let mut port = "8080";
port = port.parse::<u16>().unwrap();
}第一次赋值已经把 port 的类型确定为 &str,后面不能把 u16 塞回同一个绑定。mut 允许值变化,不允许变量在运行途中换类型;遮蔽创建新绑定,所以能换类型。
遮蔽也遵守作用域。内层同名绑定结束后,外层绑定会重新可见:
fn main() {
let timeout = 30;
{
let timeout = timeout * 1000;
println!("内部按毫秒计算:{timeout}");
}
println!("外部仍按秒计算:{timeout}");
}内部按毫秒计算:30000
外部仍按秒计算:30这很方便,也有代价。如果内外层的同名变量含义已经不同,读者会需要来回确认单位和类型。遮蔽适合“同一概念的处理阶段”,不适合把完全无关的东西硬塞进同一个名字。
let 左边其实是模式let x = 5; 看起来像最普通的变量声明,但更完整的读法是:让右侧表达式先产生值,再用左侧模式接住它。变量名 x 只是最简单的模式。
fn main() {
let response = (200, "OK");
let (status, message) = response;
println!("{status} {message}");
}200 OK普通 let 要求模式必定能匹配。let (status, message) = response; 一定能拆开二元组,所以没有失败分支。let Some(value) = maybe_value; 却不行,因为右侧还可能是 None。这不是编译器不懂你的意图,而是它看见了一条你没处理的路。后面讲 if let 和 let-else 时,我们会把这条路补上。
判断 mut 和遮蔽时,可以问自己:我是在更新同一份状态,还是在把数据加工成新的阶段?前者常用 mut,后者常用遮蔽。两者都能写出好代码,关键是让名字的变化符合读者的直觉。
Rust 的 { ... } 有两份工作。第一份是划出作用域:块里声明的局部变量只在块内可见。第二份是计算:块可以把最后一个表达式的值交给外面。
fn main() {
let subtotal = 120;
let total = {
let shipping = 12;
let discount = 8;
subtotal + shipping - discount
};
println!("应付:{total}");
}应付:124块内先执行两个 let 语句,最后计算 subtotal + shipping - discount。末尾没有分号,因此整个块的值是 124。shipping 和 discount 离开右侧代码块后就不可见,但它们算出的结果已经交给 total。
你可以把代码块想成一间有结算窗口的工作室:局部工具留在房间里,最终成品从最后一行递出来。要是最后一行加了分号,成品就被丢进回收箱,窗口只能递出 ()。
局部绑定不是“整个块里到处都有效”。它从声明之后开始可见:
fn main() {
let name = "外层";
{
println!("声明前看到:{name}");
let name = "内层";
println!("声明后看到:{name}");
}
}声明前看到:外层
声明后看到:内层这条规则让遮蔽中的右侧表达式能够读取旧绑定。let x = x + 1; 的右侧 x 是旧的,语句完成后,新 x 才接管这个名字。
对拥有资源的值来说,作用域还决定了通常何时运行清理逻辑。下面把临时字符串限制在一个小块里:
fn main() {
let length = {
let temporary = String::from("rust");
temporary.len()
};
println!("长度:{length}");
}temporary.len() 只借用字符串来读取长度,得到的 usize 不依赖那段字符串继续存活。块结束后,temporary 可以被释放,length 仍然有效。
如果试图把引用递出去,编译器会拦住:
fn main() {
let borrowed = {
let temporary = String::from("rust");
&temporary
};
println!("{borrowed}");
}问题不是代码块不能返回引用,而是这个引用指向的主人 temporary 先离开了作用域。编译器不肯让你拿着一张已经作废的取件码继续往下走。解决方式取决于真实需求:可以把 String 本身移出代码块,也可以让所有者活得更久。
fn main() {
let owned = {
let temporary = String::from("rust");
temporary
};
println!("{owned}");
}这里最后一行把 String 的所有权交出代码块,所以资源没有在内层结束时被释放,而是归 owned 管。
初学时把代码简单分成“有分号的语句”和“没分号的表达式”很有帮助,但写到稍复杂的地方,这条口诀还不够。Rust 更关心表达式出现的上下文:当前位置是要一个值,还是只要执行它的效果?
fn make_number() -> i32 {
42
}
fn main() {
let number = make_number();
make_number();
}第一次调用处在 let 初始化器中,返回的 i32 被绑定给 number。第二次调用处在表达式语句中,函数照样执行,但返回值被忽略。表达式本身没有变,周围对它提出的任务变了。
这能解释很多看似古怪的类型错误。下面的代码把一个带值的代码块单独放在函数体中:
fn main() {
{
40 + 2
}
}它不是 main 的尾表达式,而是一个处在语句位置的块。这个位置不接收 i32,因此编译器会要求你明确丢弃结果,通常就是在块后加分号,或者让块本身只产生 ()。若把相同的块放到 let answer = { ... }; 的右侧,42 就有了去处。
let 本身不产生可继续传递的业务值许多语言允许把赋值嵌进条件,Rust 刻意让普通赋值的结果是 (),let 也主要是声明语句。下面这种写法不会把 5 当成条件:
fn main() {
let mut value = 0;
if value = 5 {
println!("更新完成");
}
}条件期待 bool,value = 5 却产生 ()。这挡住了一类很经典的笔误:本来想写 value == 5,少敲一个等号后,程序竟然悄悄赋值并把结果当真。Rust 选择让它在编译期吵起来。
模式条件是另一回事。if let Some(value) = optional 中的 let 是条件语法的一部分,它判断模式是否匹配,而不是普通声明被硬塞进布尔位置。匹配成功时条件成立,绑定只在成功分支内存在。
match 分支之间常用逗号分隔,这个逗号不是在丢弃分支值。分支体仍然可以返回值:
fn category(value: i32) -> &'static str {
match value {
n if n < 0 => "negative",
0 => "zero",
_ => "positive",
}
}如果分支体是带大括号的块,末尾逗号常可省略;保留逗号通常更方便以后调整。真正决定分支块返回什么的,仍是块内部最后一个表达式是否带分号。
函数调用后的分号、结构字段后的逗号、控制流的闭合大括号,看起来都像“这一段结束了”,语义却不同。遇到类型问题时别按标点外形猜,先确认自己站在函数体、块尾、分支体还是表达式语句的位置。
空代码块 {} 的值是 ()。只包含声明或所有表达式结果都被分号丢弃的普通代码块,最终也会得到 ():
fn main() {
let empty = {};
let worked = {
let mut total = 0;
total += 2;
total += 3;
};
println!("empty = {empty:?}, worked = {worked:?}");
}empty = (), worked = ()worked 这个名字很能迷惑人:块里确实完成了计算,但没有把 total 放在末尾交出来。要得到 5,在最后补一行不带分号的 total。Rust 区分“做过计算”和“把计算结果交给外面”,正是为了让数据流可见。
回到开头的编译错误。Rust 里,表达式会求出一个值,例如 2 + 3、函数调用、代码块和 if。语句则是代码块中按顺序执行的组成部分,let 声明就是典型的声明语句。一个普通表达式后面加分号,通常表示把它放在语句位置执行并忽略结果。

fn main() {
let kept = {
40 + 2
};
let discarded = {
40 + 2;
};
println!("kept = {kept}");
println!("discarded = {discarded:?}");
}kept = 42
discarded = ()() 叫单元类型,也写作它唯一的值 ()。它表达的不是“程序什么都没做”,而是“这里没有需要继续传递的业务结果”。println! 会产生输出这个副作用,但它的返回值是 ();只负责修改集合的某些操作也会返回 ()。
下面这个小实验把末尾表达式、分号和代码块类型放在一起。你可以切换写法,直接观察一个字符怎样改变块最终交出的值。
没有显式返回类型的函数,返回类型就是 ():
fn announce(message: &str) {
println!("{message}");
}
fn announce_explicitly(message: &str) -> () {
println!("{message}");
}两个函数的返回类型相同。平时省略 -> () 更清楚,因为读者一眼就知道这个函数主要为了做事,而不是算出结果。
下面两个函数只差一个字符:
fn pop_and_return(values: &mut Vec<i32>) -> Option<i32> {
values.pop()
}
fn pop_and_ignore(values: &mut Vec<i32>) {
values.pop();
}第一个函数把 pop() 的 Option<i32> 交给调用者;第二个函数仍然弹出了元素,但明确丢掉返回值。分号在这里写出了意图。
控制流表达式处在语句位置时,末尾分号常常可以省略,例如单独写一个只执行副作用的 if:
fn log_if_needed(verbose: bool) {
if verbose {
println!("正在处理");
}
}这里的 if 没有 else,它整体只能用于产生 ()。你当然可以在闭合大括号后再写分号,但 Rust 代码通常省掉它。不要因此误以为所有地方的分号都可有可无:当代码块的值要参与赋值或函数返回时,末尾表达式有没有分号会直接改变类型。
看到 expected i32, found () 时,最有效的排查顺序是:先看期望返回 i32 的函数或代码块,接着找最后一条可达路径,最后确认那条路径是不是以分号结束,或者根本没有产生值。反过来,如果看到 expected (), found i32,通常是一个本该只做副作用的语句位置留下了整数值。
“删掉分号”不是万能修复。如果你原本就不该返回那个值,真正的问题可能是函数签名写错了,或者漏掉了后续处理。编译器给出的建议很具体,但业务意图仍然要由你决定。
if:分支不是岔路装饰,而是一次求值从动态语言过来的人常会顺手写出:
fn main() {
let pending = 3;
if pending {
println!("还有任务");
}
}Rust 不接受“非零就算真”。if 条件必须是 bool,所以要把判断写完整:if pending > 0。这多几个字符,却让条件的业务含义留在代码里。半年后读代码时,你不用猜 0 到底代表空、失败、默认值还是尚未初始化。

Rust 的 if 会产生值,因此常见写法不是先造一个可变变量再分别赋值,而是让分支直接完成计算:
fn shipping_fee(member: bool, amount: u32) -> u32 {
if member || amount >= 200 {
0
} else {
12
}
}
fn main() {
let fee = shipping_fee(false, 160);
println!("运费:{fee}"
运费:12两个分支都产生 u32,所以整个 if 也是 u32。如果一边返回整数,另一边返回字符串,编译器无法给这个表达式安排一个确定类型:
fn main() {
let compact = true;
let label = if compact { 1 } else { "one" };
println!("{label:?}");
}这会得到类型不匹配。Rust 不会在背后把整数偷偷转成文本。你可以明确统一成 String,也可以用枚举保留两种业务形态。不要为了让代码通过就立刻上特征对象;类型不一致有时是在提醒你,两个分支其实表达了不同概念。
fn main() {
let compact = true;
let label = if compact {
1.to_string()
} else {
String::from("one")
};
println!("{label}");
}如果你还不确定编译器怎样为多个分支寻找共同类型,可以在下面的交互工具里逐个修改分支结果。它会把冲突发生的位置和统一后的类型并排展示出来。
else 的 if 只能交出 ()如果没有 else,条件为假时就没有分支可以提供业务值,因此这种 if 适合做副作用:
fn warn_if_large(size: usize) {
if size > 1024 {
println!("数据量较大");
}
}不能写成 let level = if size > 1024 { "large" }; 并期待条件为假时出现一个神秘默认值。Rust 不替你发明默认值。需要结果时把 else 写出来,或者使用 Option 明确表示“可能没有值”。
下面的两个分支表面上并不相同:一个 return,一个产生 u32,代码却能通过编译。
fn normalized(value: i32) -> u32 {
let value = if value < 0 {
return 0;
} else {
value as u32
};
value
}原因是 return 不会把控制权交回当前表达式,它的类型可看作“永不在这里完成”的 !。这样的分支能适配另一边需要的类型。你现在不必深挖 ! 的全部细节,只需记住:提前退出的路径不需要再贡献一个普通值,因为它根本到不了汇合点。
match:让每一种形状都有去处现在假设你在处理任务状态:
enum JobState {
Queued,
Running { progress: u8 },
Finished(String),
Failed(String),
}
fn label(state: JobState) -> String {
match state {
JobState::Queued => String::from("排队中"),
JobState
编译器会指出 JobState::Failed(_) 没有被覆盖。它并不知道线上任务一定不会失败,也不会因为你暂时不想写错误界面就睁一只眼闭一只眼。枚举声明里存在这个状态,match 就要求它有去处。
补全之后,match 可以直接作为函数返回值:
enum JobState {
Queued,
Running { progress: u8 },
Finished(String),
Failed(String),
}
fn label(state: &JobState) -> String {
match state {
JobState::Queued => String::from("排队中"),
这里匹配的是 &JobState,各分支借用内部字段,因此调用 label(&state) 后还能继续使用 state。如果签名写成 fn label(state: JobState) 并按值绑定 String 字段,调用会把整个状态交给函数。这两种写法没有谁天然更高级,取决于函数是否要取得所有权。
模式可以写字面量、范围、多个备选和通配符:
fn describe(code: u16) -> &'static str {
match code {
200 => "成功",
201 | 202 => "已接受",
400..=499 => "请求有问题",
500..=599 => "服务端有问题",
_ => "其他状态",
}
}_ 匹配剩下的一切,但不绑定值。如果你还要打印那个值,就给它一个名字:other => ...。分支从上到下检查,第一个匹配项胜出,所以宽泛模式放得太早,会让后面的具体分支永远到不了,编译器通常也会提醒不可达模式。
穷尽检查不是要求你每次把每个整数都写一遍。对于大范围类型,_ 能接住其余情况。真正需要判断的是:其余情况可以被同一种策略处理吗?如果不同状态应该有不同日志、重试或用户提示,一个过早的 _ => {} 会把遗漏悄悄扫到地毯下面。
match 和 if 一样是表达式。所有能到达汇合点的分支要产生可统一的类型:
fn retry_delay(attempt: u8) -> Option<u64> {
match attempt {
0 => Some(1),
1..=3 => Some(5),
_ => None,
}
}用 Option<u64> 之后,“不再重试”不是一个随手约定的 0,而是类型里明确存在的 None。调用者不得不面对它。这正是表达式和枚举组合起来的手感:控制流不只是执行路线,也能把每条路线的结果塑造成同一个业务类型。
模式的价值不只在“选哪个分支”,还在于能把内部数据当场取出来。看一个命令枚举:
enum Command {
Move { x: i32, y: i32 },
Write(String),
Resize(u32, u32),
Quit,
}
fn execute(command: Command) {
match command {
Command::Move { x, y } => println!("移动到 ({x}, {y})"),
Command::Move { x, y } 同时确认变体并把字段绑定为局部变量。字段名和变量名相同时可以简写;想换名就写 x: target_x。Command::Resize(width, height) 则按位置拆开元组变体。
_ 和 .. 都会忽略,但尺度不同_ 忽略一个位置,.. 忽略当前结构中剩余的若干部分:
struct Request {
method: String,
path: String,
body: Vec<u8>,
trace_id: String,
}
fn route(request: &Request) {
let Request { method, path, .. } = request;
println!("{method} {path}");
}这里我们只关心两个字段。由于右侧是 &Request,绑定得到的是对字段的借用,不会把 String 从请求里拆走。若右侧是拥有的 Request,按值绑定 method 和 path 会移动这两个 String,随后不能再把整个 request 当作完整值使用。这就是“模式也参与所有权”的地方。
模式守卫写在分支模式后的 if:
fn bucket(value: Option<i32>) -> &'static str {
match value {
Some(n) if n < 0 => "负数",
Some(0) => "零",
Some(n) if n % 2 == 0 => "正偶数",
Some
匹配顺序是先看模式,再计算守卫。Some(n) if n < 0 先确认有数字,随后判断它是否为负。守卫为假时不会直接跳到 _,而是继续尝试后面的分支。
守卫不会替你完成穷尽证明。即使你写了 Some(n) if n >= 0 和 Some(n) if n < 0,编译器通常仍要求一个没有守卫的 Some(_) 兜底,因为它不把任意布尔表达式当成可证明的完整分类。别跟它赌逻辑题,写出清晰的兜底分支就好。
@ 同时保留整体值并限制形状有时既要检查范围,又要使用匹配到的原值,可以用 @:
fn priority(level: u8) -> String {
match level {
urgent @ 8..=10 => format!("紧急级别 {urgent}"),
normal @ 1..=7 => format!("普通级别 {normal}"),
0 => String::from("未设置"),
other => format!(
urgent @ 8..=10 可以读成“值要符合右边这个范围,同时把整个值命名为 urgent”。它能减少先匹配、再在分支里重复取值的代码。
| 两侧必须提供一致的绑定多个模式可以用 | 合并,但无论命中哪一边,分支体能使用的变量都必须一致:
enum Event {
Open(String),
Close(String),
Tick,
}
fn show(event: Event) {
match event {
Event::Open(name) | Event::Close(name) => println!("对象:{name}"),
Event::Tick => println!(
两边都绑定了同类型的 name,分支体才能放心使用。若一边有 name、另一边没有,命中后一半时 name 从哪里来?编译器拒绝这种含糊,理由相当实在。
模式有一个听起来有点学术、实际很直白的区别:有些模式对给定类型永远能匹配,有些可能失败。前者叫不可反驳模式,后者叫可反驳模式。
let x = value; 永远能把整个值绑定给 x;let (left, right) = pair; 对二元组也一定能拆开。它们适合普通 let。Some(value)、Ok(value)、字面量 0、范围 1..=10 都可能不匹配,因此需要一个能处理失败的控制流位置。
fn main() {
let optional = Some(7);
let Some(value) = optional;
println!("{value}");
}这段普通 let 会被拒绝,因为 optional 的类型允许 None。即使你在这一行清楚看见它初始化为 Some(7),变量类型表达的可能性仍然包括 None。程序以后可能把值改成函数参数,编译器不能靠当前常量碰巧成立来定义语言规则。
你有三种常见处理方式。要把每种情况都认真处理,用 match;只在匹配时做一件附加工作,用 if let;失败必须提前离开、成功绑定要留给后续主流程,用 let-else。选哪一种,不是看谁字符少,而是看失败路径在业务里处于什么位置。
while let 让“还能拆出值”成为循环条件模式条件也能驱动循环。栈不断 pop() 时返回 Option<T>,有元素就处理,没有元素就停止:
fn main() {
let mut stack = vec![10, 20, 30];
while let Some(value) = stack.pop() {
println!("取出 {value}");
}
}取出 30
取出 20
取出 10每一轮都会重新计算 stack.pop(),再尝试 Some(value)。一旦得到 None,循环结束。等价的 loop 加 match 也能写,只是 while let 更直接地把继续条件说成“只要还能取出一个值”。
这也提醒我们注意副作用:条件里的 stack.pop() 会修改集合。不要把它误读成纯检查。如果只想查看末尾元素而不移除,可以匹配 stack.last(),它返回的是借用。
mut 修改的是新绑定下面的 mut 放在模式中:
fn main() {
let pair = (String::from("rust"), 3);
let (mut name, count) = pair;
name.push('!');
println!("{name} {count}");
}mut name 表示新绑定 name 可变。它不等于借用原值,也不表示整个 pair 可变。这里 String 被移动到 name,数字被复制到 count。模式左边几个字符的摆放,会同时影响绑定是否可变以及值如何转移。
如果右侧是可变引用,可以通过解构拿到可变借用的字段:
struct Counter {
current: u32,
limit: u32,
}
fn advance(counter: &mut Counter) {
let Counter { current, limit } = counter;
if *current < *limit {
*current += 1;
}
}这里 current 和 limit 来自对结构的可变借用。使用时要解引用。更常见的写法可能直接访问 counter.current,但解构在需要同时处理多个字段时很方便。
ref 适合你确实要控制绑定模式时按值匹配一个拥有的结构时,可以用 ref 借用某个字段,避免把它移走:
struct Document {
title: String,
pages: u32,
}
fn main() {
let document = Document {
title: String::from("Rust 笔记"),
pages: 12,
};
let Document { ref title, pages } = document;
title 是 &String,字段没有被移出;pages 是可复制的 u32。很多匹配引用的场景有自动绑定规则,不需要显式写 ref。但当你按值处理结构、又只想借用特定字段时,理解 ref 能让所有权意图更准确。
Rust 的模式能嵌套枚举、结构体、元组、范围、@ 和守卫。把一次检查写得很精准很诱人,但一条分支塞进三层解构再接长守卫,通常不如先拆一层、给中间概念起名字。
好的模式会把数据形状展示出来,例如 Some(Record { score: 0..=59, .. }) 一眼说明只关心不及格记录。过度复杂的模式则让人必须模拟编译器。这里没有硬性长度标准:如果分支体使用了多个绑定,而你读两遍仍说不清每个绑定来自哪里,就值得拆开。
if let 与 let-else:只关心一种形状时少写一点完整 match 的穷尽检查很可靠,但有些场景确实只关心一种情况。例如配置里有一个可选端口,有值就打印,没有值就什么也不做:

fn main() {
let configured_port = Some(8080);
if let Some(port) = configured_port {
println!("使用端口 {port}");
}
}if let PATTERN = EXPRESSION 会先计算右侧,再尝试模式。匹配成功时,模式中的绑定只在对应代码块内可见;失败则跳过,或者进入可选的 else。
它大致对应一个只认真处理一个分支、其余分支统一忽略的 match。优点是短,代价是失去穷尽检查。对于“有头像就展示”的附加行为,这个取舍通常合理;对于支付状态、权限结果之类不能漏处理的业务,用 match 往往更稳。
if let 也可以产生值因为它仍然是 if 表达式,所以可以有 else 并返回统一类型:
fn display_name(name: Option<&str>) -> String {
if let Some(name) = name {
format!("用户:{name}")
} else {
String::from("匿名用户")
}
}只是当分支逐渐增多、又要加入多个不同模式时,继续叠 else if let 可能比一个 match 更难读。短不等于永远清楚。
let-else 把失败路径赶到旁边现在看一个常见场景:函数收到 Option<&str>,没有输入就返回错误,有输入则让后续主流程继续。先写一个不能通过的普通 let:
fn parse_id(input: Option<&str>) -> Result<u64, String> {
let Some(raw) = input;
raw.parse::<u64>().map_err(|_| String::from("编号不是整数"))
}Some(raw) 是可能失败的模式,编译器要求处理 None。let-else 正好表达这件事:
fn parse_id(input: Option<&str>) -> Result<u64, String> {
let Some(raw) = input else {
return Err(String::from("缺少编号"));
};
raw.parse::<u64>()
.map_err(
匹配成功后,raw 在后面的外层作用域里可用;匹配失败则执行 else。关键限制是 else 必须离开当前流程,例如 return、break、continue 或 panic!,不能执行完又回来。否则后续代码仍然无法保证 raw 已经被绑定。
这正是 let-else 的用途:把不满足前置条件的路径尽早送走,让剩余代码在已经满足条件的前提下展开。若失败分支也要产生一个值并和成功分支汇合,用 match 或带 else 的 if let 更合适。
Rust 提供 loop、while 和 for。三种循环都能反复执行代码,但它们强调的问题不同:loop 表示先循环,退出条件在内部决定;while 表示只要布尔条件成立就继续;for 表示逐个处理一组可迭代的值。

loop 适合重试,并且能交出结果假设你轮询一个队列,拿到任务才继续:
fn next_job(mut attempts: u8) -> String {
loop {
attempts += 1;
if attempts == 3 {
break format!("job-{attempts}");
}
}
}
fn main() {
println!("{}", next_job(0));
job-3break value 不只是退出循环,还把 value 变成整个 loop 表达式的结果。如果可能从多个地方 break,这些值的类型也要统一。单独的 break; 等价于交出 ()。
只有 loop 能用普通的 break value 返回非单元结果;while 和 for 的结果是 ()。如果你试图在 while 里写 break 42;,编译器会拒绝。需要边循环边产出最终值时,可以改用 loop,也可以在循环外维护结果,具体看哪种更清楚。
while 让继续条件站在门口fn main() {
let mut remaining = 3;
while remaining > 0 {
println!("剩余 {remaining}");
remaining -= 1;
}
println!("开始");
}while 每轮开始前检查 bool 条件。它适合剩余次数、连接状态、缓冲区是否为空这类一眼能写成条件的循环。如果退出条件散落在循环体多个位置,硬塞成一个复杂 while 条件反而会难读,此时 loop 配合清楚的 break 可能更诚实。
for 把迭代规则交给迭代器fn main() {
let scores = [82, 91, 76, 88];
let mut total = 0;
for score in scores {
total += score;
}
println!("总分:{total}");
}总分:337for pattern in expression 里的左侧同样是模式。遍历二元组时可以直接解构:
fn main() {
let entries = [("rust", 3), ("go", 2), ("python", 5)];
for (name, count) in entries {
println!("{name}: {count}");
}
}比起自己维护下标,for 通常更不容易写出越界和边界偏一的问题。确实需要下标时,可以用迭代器的 enumerate(),把位置和值一起拿到,而不是手动让两个状态保持同步。
对拥有堆数据的集合,for item in values 往往会消费集合;for item in &values 只读借用;for item in &mut values 可变借用每个元素:
fn main() {
let mut names = vec![String::from("rust"), String::from("cargo")];
for name in &names {
println!("读取:{name}");
}
for name in &mut names {
name.make_ascii_uppercase();
}
如果第一轮写成 for name in names,Vec<String> 会被转入迭代,循环结束后不能再打印 names。这不是 for 专门针对你的陷阱,而是表达式交给迭代器时发生了所有权选择。
break 与 continue:把跳转目标写清楚嵌套循环里,不带标签的 break 和 continue 只影响最近的一层。假设我们在矩阵里寻找第一个负数:
fn main() {
let grid = [[1, 2, 3], [4, -5, 6], [7, 8, 9]];
let mut found = None;
'rows: for (row_index, row) in grid.iter().enumerate() {
Some((1, 1, -5))标签以单引号开头,写在循环前:'rows:。break 'rows 明确退出外层循环。这里的单引号和生命周期语法长得一样,但语境不同:它是循环标签,不是在声明引用能活多久。
多层循环最难的地方往往不是语法,而是脑中容易跟丢跳转目标。下面的追踪器会把 break 和 continue 的落点逐步标出来,适合对照标签前后的差别。
continue 'label 则结束当前工作,并从指定循环的下一轮开始。例如检查多行数据,只要某个字段非法,就跳过整行:
fn main() {
let rows = [[2, 4, 6], [8, -1, 10], [12, 14, 16]];
'row: for row in rows {
for value in row {
if value < 0 {
println!
没有标签的话,continue 只会让内层循环处理下一个字段,含负数的整行仍可能被当成有效数据。
标签能消除标志变量,有时非常干净。但三四层嵌套再配上多个标签,读者还是会迷路。那通常不是“标签不够多”,而是这段逻辑该提取成函数了。提取后可以用 return 直接表达找到结果或校验失败。
return、break、continue 退出的层级不同这三个词经常放在一起记,实际目标不同:
return value 结束当前函数,并把值交给调用者。break 结束目标循环;在 loop 中还可以给循环一个结果。continue 结束当前轮次,回到目标循环的开头。它们本身都是不会在当前点正常完成的表达式,所以能出现在本来期待别的类型的位置。下面的函数用 continue 跳过奇数,用 return 在找到答案时结束整个函数:
fn first_even_over(values: &[i32], threshold: i32) -> Option<i32> {
for &value in values {
if value % 2 != 0 {
continue;
}
if value > threshold {
return Some(value);
}
}
末尾的 None 是正常走完整个循环后的函数结果。这里不用写 return None;,因为函数体本身就是代码块表达式。显式 return 更适合提前结束;自然落到末尾时,保留尾表达式通常更顺。
控制流和所有权一碰面,Rust 的严格感会明显上升。先看一个很真实的错误:

fn send(message: String) {
println!("发送:{message}");
}
fn main() {
let message = String::from("部署完成");
let should_send = true;
if should_send {
send(message);
}
println!("归档:{message}");
}编译器会说 message 可能已经被移动。即使你肉眼看到 should_send 此刻是 true,语言规则仍按控制流分析:进入分支时,send(message) 取得了 String 的所有权;分支之后再使用它就不安全。要是函数签名或条件以后变化,这种账不能靠运气维持。
如果 send 只需要读取,最直接的修复是借用:
fn send(message: &str) {
println!("发送:{message}");
}
fn main() {
let message = String::from("部署完成");
let should_send = true;
if should_send {
send(&message);
}
println!("归档:{message}");
所有权错误经常来自“其中一条分支移动了值,但后面还想继续使用”。下面的时间线可以逐步查看每条路径上的移动和借用何时开始、何时结束,再回来看编译器为什么必须拦住冲突。
如果发送操作确实要接管字符串,那么后面就不该继续使用旧绑定,或者你要提前 clone() 出一份独立数据。克隆不是禁术,但它会分配和复制。先确认语义上是否真的需要两个所有者,再为这个成本买单。
fn choose(primary: bool) -> String {
let first = String::from("主节点");
let second = String::from("备用节点");
let selected = if primary {
first
} else {
second
};
selected
}两个分支都产生 String,其中一个值的所有权移动到 selected。这完全合法。需要注意的是,if 后不能再无条件使用 first 或 second,因为根据运行路径,其中一个已经离开原绑定。
若只是选择查看哪个字符串,可以让分支产生引用:
fn main() {
let first = String::from("主节点");
let second = String::from("备用节点");
let primary = true;
let selected: &str = if primary {
&first
} else {
&second
};
两个引用必须有一个能统一的生命周期:selected 使用期间,真正被选择的所有者必须仍然活着。上例里两个 String 都活到函数末尾,所以没有问题。
match 的绑定方式决定复制、移动还是借用enum Payload {
Text(String),
Code(u16),
}
fn inspect(payload: &Payload) {
match payload {
Payload::Text(text) => println!("文本长度:{}", text.len()),
Payload::Code(code) => println!("代码:{code}"),
因为被匹配的是 &Payload,text 是借用,code 也是相应的借用绑定,原值不会被拆走。现代 Rust 的匹配规则会根据引用上下文自动调整常见绑定方式,所以很多时候不必到处写 ref。
如果按值匹配 Payload,Text(text) 会把内部 String 移进分支变量;Code(code) 的 u16 实现了 Copy,会复制这个小值。代码长得相似,所有权结果由右侧值的类型和匹配上下文共同决定。
struct Profile {
name: String,
age: u8,
}
fn main() {
let profile = Profile {
name: String::from("小林"),
age: 28,
};
let Profile { name, age } = profile;
println!(
name 被移出,age 被复制。之后不能再把 profile 当作完整结构使用,因为它的一部分已经不在了。你仍可在规则允许时使用没有被移动的独立字段,但把一个“缺了零件”的结构整体传走是不成立的。想保留完整结构,就匹配引用:let Profile { name, age } = &profile;。
fn main() {
let mut message = String::from("hello");
let view = &message;
println!("读取:{view}");
message.push_str(" rust");
println!("更新:{message}");
}这段代码可以通过,因为不可变借用 view 的最后一次使用发生在修改之前。编译器按实际使用范围分析借用,不会机械地把它拖到整个大括号结束。但如果把 println!("{view}") 移到 push_str 后面,可变修改和仍需使用的不可变借用就会冲突。
把编译器看成对账搭档会更轻松:它并不是禁止分支和模式做复杂事,只是要求每条可达路径上的所有者、借用者和最终结果都能对上。
所有权错误最让人沮丧的时刻,往往不是值明显被移动,而是借用夹在控制流中间,你不确定它究竟什么时候结束。我们从一段能通过的代码开始:
fn main() {
let mut names = vec![String::from("小周"), String::from("小林")];
let first = names.first();
if let Some(name) = first {
println!("首位:{name}");
}
names.push
first 借用了 names,但它的最后一次使用在 if let 中。分支结束后不再读取 first,共享借用可以结束,随后 push 取得可变借用。
如果在 push 后再打印 first,情况就变了:
fn main() {
let mut names = vec![String::from("小周"), String::from("小林")];
let first = names.first();
names.push(String::from("小陈"));
println!("{first:?}");
}这会失败。Vec 扩容时可能搬动内部存储,旧的元素引用不能在修改后继续被使用。编译器不是武断地不让你一边读一边写,而是在保护一个可能因重新分配而悬空的引用。
fn main() {
let mut value = 10;
let increase = true;
if increase {
let borrowed = &mut value;
*borrowed += 5;
}
println!("{value}");
}可变借用只在需要它的范围内存在,离开分支后可以再次读取 value。这也是把临时借用放进小块的价值:你不仅在组织视觉结构,也在缩短资源被占用的时间。
但如果把可变引用作为分支结果交给外面,借用就会跟着结果继续活:
fn main() {
let mut left = 10;
let mut right = 20;
let choose_left = true;
let selected = if choose_left {
&mut left
} else {
&mut right
};
*selected += 1;
println!(
在 selected 的使用期内,被选中的变量处于可变借用状态。若中途直接读取 left,编译器要考虑 selected 可能正指向它,因此会阻止冲突访问。把 selected 的最后一次使用提前,或用小作用域包住它,常能让后续访问自然恢复。
fn main() {
let text = String::from("rust");
let take_ownership = true;
let selected = if take_ownership {
text
} else {
&text
};
println!("{selected:?}");
}一边是 String,另一边是 &String,类型不同;更麻烦的是,第一条路拿走所有权,第二条路只借用。你必须先决定汇合点需要什么语义。若只是读,两边都返回 &str;若后续必须拥有,就让两边都产生 String,可能需要克隆或重新安排所有者。
编译器不会替你在某条路克隆,因为克隆有成本,也会改变“这是同一个对象还是一份副本”的语义。显式决定看似麻烦,实际避免了隐藏分配。
match 守卫需要先查看绑定来决定分支是否成立。只有模式和守卫共同通过,才会进入对应分支。下面按引用匹配最容易推理:
enum Task {
Named(String),
Anonymous,
}
fn describe(task: &Task) -> &'static str {
match task {
Task::Named(name) if name.len() > 8 => "长名称任务",
Task::Named(_)
守卫只读取 name 的长度。若守卫为假,匹配继续进入下一分支,原任务没有被破坏。写复杂所有权逻辑时,优先匹配引用通常能减少“守卫没通过时值去哪了”的心智负担。
fn choose<'a>(prefer_left: bool, left: &'a str, right: &'a str) -> &'a str {
if prefer_left {
left
} else {
right
}
}两个分支都返回来自参数的引用,生命周期关系由签名说清楚。反过来,不能在某个分支里创建局部 String 再返回 &str,因为那个所有者会随分支块结束而消失。if 能统一分支类型,却不能把局部数据变成永久数据。
遇到跨分支借用错误时,可以连续问三个问题:引用指向谁?最后一次在哪里使用?这期间是否发生了移动或可变访问?问题通常会从一团“生命周期太难了”缩小成一个具体冲突点。
?:把成功值留下,把失败交还给调用者错误处理里最容易见到一串重复的 match:

fn parse_pair(left: &str, right: &str) -> Result<i32, std::num::ParseIntError> {
let left = match left.parse::<i32>() {
Ok(value) => value,
Err(error) => return Err(error),
};
这段代码没有错,只是成功路径被重复的分支包围了。? 把同一种控制流压缩到表达式尾部:
fn parse_pair(left: &str, right: &str) -> Result<i32, std::num::ParseIntError> {
let left = left.parse::<i32>()?;
let right = right.parse::<i32>()?;
读 left.parse::<i32>()? 时,可以先用这个直觉:成功就把内部的 i32 留在当前位置,让表达式继续;失败就结束当前函数,把错误交给调用者。它既像“拆开成功值”,又像“遇错提前 return”。
? 是表达式的一部分它不只出现在 let 右侧,也能接着调用方法:
fn first_number(input: Option<&str>) -> Option<i32> {
let raw = input?;
raw.parse::<i32>().ok()
}input? 在 Some(raw) 时产生内部的 &str,在 None 时让函数立刻返回 None。因此后面可以把它当普通值继续使用。
对 Result<T, E>,成功路径得到 T;错误路径提前返回兼容的错误。错误类型不必永远字面相同,只要存在合适的转换,? 可以把底层错误变成当前函数声明的错误类型:
use std::fs;
use std::io;
fn load_trimmed(path: &str) -> Result<String, io::Error> {
let content = fs::read_to_string(path)?;
Ok(content.trim().to_owned())
}这里读取失败时直接交出 io::Error,读取成功时继续处理文本。更复杂的项目常会定义自己的错误枚举,让不同底层错误都能明确转换到统一错误类型。
?下面的 main 默认返回 (),因此不能直接传播 Result 错误:
fn main() {
let number = "abc".parse::<i32>()?;
println!("{number}");
}? 需要一个能接住失败结果的边界。可以让 main 返回合适的 Result,也可以在这里用 match、if let 或其他方式处理错误。更重要的是,? 只负责传播,不负责记录日志、重试或给用户补充上下文。如果当前层恰好最了解如何处理错误,就不该为了短而把它一股脑推走。
Result 和 Option 也不能随意混着传播。返回 Option<T> 的函数可以用 ? 传播 None,返回 Result<T, E> 的函数可以传播错误;要跨类型,就先明确转换,例如把 Option 用 ok_or_else 变成 Result。编译器要求你写出这个转换,是在追问一个很有用的问题:缺失值在当前业务里到底算不算错误,如果算,错误信息是什么?
? 的边界是当前可传播上下文在普通函数里,? 影响当前函数;在返回兼容类型的闭包里,它影响当前闭包;在异步代码块里,它影响那个异步结果。不要把它理解成“从所有外层一口气跳出去”。它只把失败交给当前边界的调用者。
所以,? 的表达式直觉可以浓缩成一句话:沿着成功路径继续求值,沿着失败路径提前离开。它和 return、分支类型统一、所有权移动属于同一套控制流,不是一枚神秘的错误处理魔法。
return、let-else、? 各说不同的话这三个工具都能让函数提前结束,很容易被当成可互换的短写。它们表达的重点其实不同。
return 是最直接的函数出口。你已经有了要返回的完整值,或者当前分支需要做额外处理时,用它最清楚:
fn price(quantity: u32) -> u32 {
if quantity == 0 {
return 0;
}
quantity * 25
}let-else 强调前置形状。你需要先从一个值里拆出后续主流程依赖的绑定,拆不开就离开:
fn domain(email: &str) -> Result<&str, String> {
let Some((_, domain)) = email.split_once('@') else {
return Err(String::from("邮箱缺少 @"));
};
if domain.is_empty() {
return
? 强调传播。当前操作已经用 Result 或 Option 表达失败,而这一层不打算改变处理策略,就把失败交给调用者:
fn port(input: &str) -> Result<u16, std::num::ParseIntError> {
let port = input.trim().parse::<u16>()?;
Ok(port)
}真实函数常会同时出现三者,因为失败原因不同:
fn ratio(input: Option<&str>, total: u32) -> Result<f64, String> {
if total == 0 {
return Err(String::from("总数不能为零"));
}
let Some(raw) = input else {
return Err
return 处理已经明确的业务前置条件;let-else 从可选输入中取出后续需要的文本;? 传播解析失败。它们不是为了统一风格而互相淘汰,而是各自把退出原因写在最贴近问题的地方。
? 吞掉当前层应该补充的信息假设底层只会返回“解析整数失败”,但当前层知道正在解析的是端口。直接 ? 虽然短,调用者拿到的错误可能缺乏上下文。可以先转换错误:
fn parse_port(input: &str) -> Result<u16, String> {
input
.trim()
.parse::<u16>()
.map_err(|error| format!("端口 `{input}` 无法解析:{error}"))
}如果后面还要继续校验范围或保留结构化错误,定义错误枚举会比拼字符串更可靠。本章不展开错误类型设计,但要记住:传播的是信息,不只是控制流。? 能少写分支,不能自动替你补齐语境。
? 退出的是函数,不是本轮循环fn sum_numbers(inputs: &[&str]) -> Result<i32, std::num::ParseIntError> {
let mut total = 0;
for input in inputs {
total += input.parse::<i32>()?;
}
Ok(total)
任何一个元素解析失败,? 会结束整个 sum_numbers,不是像 continue 那样只跳过坏元素。若需求是忽略非法输入,就要明确写出分支:
fn sum_valid_numbers(inputs: &[&str]) -> i32 {
let mut total = 0;
for input in inputs {
let Ok(number) = input.parse::<i32>() else {
continue;
};
total += number;
}
两个函数都合理,但业务承诺完全不同:前者保证全成功或返回错误,后者接受部分成功。别让一个标点替你偷偷决定产品行为。
下面做一个小型批处理:输入若干文本行,每行格式是 名字:分数。空行跳过,格式错误立刻返回,找到第一个不及格记录后停止,并把结果交给调用者。
#[derive(Debug, PartialEq)]
struct Record {
name: String,
score: u8,
}
fn parse_record(line: &str) -> Result<Record, String> {
let Some((name, score)) = line.split_once(':') else {
首个不及格记录:小林 54这段代码没有刻意炫技,却把本章的线索串了起来。let Some(...) = ... else 解构可能失败的格式,并把失败路径提前送走;两次遮蔽让 name 和 score 从原始切片逐步变成干净的业务值;? 让解析错误退出当前函数;for 借用输入切片中的元素;continue 跳过空行;return Ok(Some(record)) 把拥有的记录移交给调用者;最后的 match 穷尽处理成功有值、成功无值和失败三种结果。
你也能看到这些工具各自的边界。若输入错误不该立刻停止,而是要收集所有错误,? 就不是主流程的最佳选择;若只关心是否存在不及格项,不需要完整记录,可以返回 Result<bool, String>;若数据量很大,还可以进一步改成迭代器处理。表达式风格不是追求最短,而是让每条控制流都交出符合业务含义的值。
遇到语句与表达式相关的错误时,别从标点开始乱试。先按一个稳定顺序排查。
第一步,看当前位置期待什么类型。函数签名、let 类型标注、if 或 match 的其他分支,都会给出期望类型。第二步,看当前路径实际产生什么。末尾分号会不会让块变成 ()?某个分支是不是漏了值?第三步,再看值怎么到这里。是 Copy 类型被复制,还是 String 被移动?你拿到的是拥有值、共享引用还是可变引用?
例如这段代码同时藏了类型和所有权问题:
fn pick(use_left: bool, left: String, right: String) -> String {
let selected = if use_left {
left;
} else {
right
};
selected
}先不要急着研究移动。if 左分支的 left; 产生 (),右分支产生 String,类型已经无法统一。删掉分号后,两个分支都会把各自的 String 移到 selected,整个函数成立。若后面还要使用 left 和 right,这时才进入所有权选择:让函数返回引用、克隆其中一个,或重新设计数据流。
另一个常见误判是把所有错误都归给借用检查器。下面其实只是模式不穷尽:
fn value(input: Option<i32>) -> i32 {
match input {
Some(number) => number,
}
}补上 None 分支即可。编译器的信息通常会直接写出缺少的模式。先读它指出的期望类型、未覆盖模式和移动位置,往往比凭印象改代码快得多。
编译器报告类型不匹配时,expected 往往来自外围上下文,found 来自当前表达式。下面的期望类型来自变量标注:
fn main() {
let enabled: bool = 1;
println!("{enabled}");
}外围明确要求 bool,右侧却是整数。Rust 不会把 1 隐式变成 true。再看一个没有显式标注的例子:
fn main() {
let enabled = if cfg!(debug_assertions) {
true
} else {
0
};
println!("{enabled:?}");
}这里的期望由另一个分支建立。编译器先看到一条路产生 bool,另一条路却交出整数。修复前先回答业务问题:enabled 本来就应该是布尔值,还是分支分别想表达两类结果?若是前者把 0 改成 false;若是后者定义一个枚举会比强行转成字符串更准确。
函数签名也会提供期望类型:
fn status(ok: bool) -> &'static str {
if ok {
"ready"
} else {
String::from("failed")
}
}成功分支是字符串切片,失败分支是拥有的 String,函数又承诺返回静态字符串切片。不能靠给某一边加 & 草率解决,因为对分支里临时创建的 String 取引用会留下悬空风险。要么两边都返回静态字面量,要么把签名改成 String 并让两边都产生拥有值。
这种排查方式比记住某个错误编号更通用:沿着表达式向外找是谁提出期望,再向内找是哪条路径交错了东西。
fn main() {
let message = String::from("rust");
let view = &message;
let owned = message;
println!("{view}");
println!("{owned}");
}view 指向 message 管理的数据,而 let owned = message; 想把所有权搬走。只要后面还要使用 view,编译器就不能允许这次移动。最简单的修复不是立刻克隆,而是看程序实际需要的顺序:如果先打印 view,并且之后不再使用它,那么移动可以发生在借用最后一次使用之后。
fn main() {
let message = String::from("rust");
let view = &message;
println!("{view}");
let owned = message;
println!("{owned}");
}如果引用和拥有值确实要长期同时存在,你要重新安排数据结构或创建独立副本。先调整生命周期和操作顺序,通常比无脑 clone() 更贴近原意。
反方向的错误也常见:拿到可变引用后又直接访问原变量。
fn main() {
let mut count = 1;
let borrowed = &mut count;
count += 1;
*borrowed += 1;
}borrowed 还会被使用,因此它的独占借用仍然有效。此时通过 count 直接修改,相当于同时打开第二条写入通道。可以全程通过 borrowed 修改,或先完成它的最后一次使用,再访问 count。
fn consume(value: String) {
println!("{value}");
}
fn main() {
let value = String::from("payload");
let enabled = false;
if enabled {
consume(value);
}
println!("{value}");
}人眼知道示例中的 enabled 是 false,但把这种写法放进真实函数,条件通常来自参数或运行状态。Rust 对局部值执行可靠的控制流分析,不会让后续代码依赖“刚好没有进入移动分支”这种不稳定事实。
一种改法是让两个分支都明确产生新的状态:
fn consume(value: String) {
println!("{value}");
}
fn main() {
let value = String::from("payload");
let enabled = false;
let remaining = if enabled {
consume(value);
None
} else {
Some
现在类型把结果说清楚了:发送后没有剩余值,未发送时仍拥有它。另一种更常见的改法是让 consume 接收借用,前提是它不需要取得所有权。
breakfn find(limit: u32) -> Option<u32> {
let mut current = 0;
let answer = loop {
if current > limit {
break None;
}
if current == 5 {
break current;
}
current +=
第一条 break 让 loop 的结果像是 Option<u32>,第二条却交出 u32。修复为 break Some(current); 后,两条退出路径才能汇合。若循环里还有一个裸 break;,它会交出 (),同样造成冲突。
检查 loop 的类型时,不要只看最后一行。循环结果由所有关联的 break 路径共同决定;永远继续的路径不提供普通结果。带标签的嵌套循环还要确认每个 break 究竟指向哪一层,别把内层退出值误算到外层。
let-else 的失败块为什么一定要离开fn show(value: Option<i32>) {
let Some(number) = value else {
println!("没有数字");
};
println!("数字是 {number}");
}这段代码的 else 打印完还会继续。可一旦右侧是 None,后面的 number 根本不存在。编译器要求失败块必须发散,意思就是它不能正常落回下一条语句。
fn show(value: Option<i32>) {
let Some(number) = value else {
println!("没有数字");
return;
};
println!("数字是 {number}");
}加上 return 后,能到达最后一行的路径都已经成功绑定 number。同理,在循环里可以用 continue 让失败的当前轮次离开,或用 break 结束循环。要求提前离开不是语法洁癖,而是后续变量可用性的逻辑前提。
? 报错时,先找返回边界如果编译器说 ? 不能用于当前函数或闭包,先不要修改出错的那次函数调用。向外看当前边界返回什么:是 ()、Option<T>,还是 Result<T, E>?失败形式是否兼容?
fn maybe_number(input: Option<&str>) -> Option<i32> {
let raw = input?;
let number = raw.parse::<i32>()?;
Some(number)
}这里第二个 ? 会失败,因为 parse 返回 Result,当前函数返回 Option。你需要明确决定解析错误在这里是不是等同于缺失。如果是,可以先写 .ok()? 把错误信息丢掉;如果不是,函数就应该返回 Result,并为缺失输入构造错误。
fn maybe_number(input: Option<&str>) -> Result<i32, String> {
let raw = input.ok_or_else(|| String::from("缺少输入"))?;
raw.parse::<i32>()
.map_err(|_|
这个改动不只是迎合类型系统。它确定了 API 的承诺:调用者能区分“没传值”和“传了但格式错误”。
学会 if、match、块和 ? 都能产生值之后,很容易进入另一个阶段:恨不得把整个函数塞进一次赋值。下面的代码能够表达完整逻辑,但阅读时要同时记住三层分支:
fn label(input: Option<&str>, compact: bool) -> String {
let label = if let Some(raw) = input {
if let Ok(number) = raw.parse::<u32>() {
if compact {
number.to_string()
}
它没有违反表达式规则,分支也都返回 String,但主流程藏在大括号深处。若格式错误和缺失输入都是失败,可以把函数签名改为 Result,用 let-else 和 ? 拉直前置处理:
fn label(input: Option<&str>, compact: bool) -> Result<String, String> {
let Some(raw) = input else {
return Err(String::from("缺少编号"));
};
let number = raw
.parse::
现在成功路径从上到下走,最后的 if 只负责展示形式。代价是 API 从“永远返回一段文本”变成“可能失败”。如果原需求就是把错误也展示成标签,第一种返回 String 的设计可能更符合调用者预期;这时也可以提取一个解析辅助函数,而不是强行改成 Result。
同样,match 分支体过长时可以提取函数,复杂守卫可以先算出有名字的布尔值,多层循环可以把内层搜索提成返回 Option 的函数。Rust 的表达式能力给你的是组合工具,不是代码高尔夫计分器。判断重构是否有效,可以看读者能否快速回答三件事:失败从哪里离开,成功值在哪里形成,所有权最终交给谁。
类型统一、借用合法、模式穷尽,说明代码满足语言规则,却不保证控制流符合需求。例如 _ => None 能快速满足穷尽检查,但可能把新增枚举变体静默归为“无结果”;continue 能消除错误,却可能让批处理悄悄漏数据;clone() 能避开移动错误,却可能在热路径制造大量复制。
每次用一个语法修复编译错误后,再把变化翻译回业务语言:删掉分号后,函数现在确实要返回这个值吗?加上借用后,被调用函数是否只需要读取?把错误改成 ? 后,调用者是否真的负责处理?用 _ 兜底后,未来新增状态是否应该自动走这里?
这种复核不需要写成冗长流程。很多时候在脑中多问一句就够了。编译器负责证明内存、类型和可达路径的规则成立,你负责确认这些路径表达的是正确产品行为。双方分工清楚,Rust 的严格才会从阻力变成反馈。
你不需要一开始就凭直觉写出所有权完美、分支类型一次对齐的代码。先写出你认为自然的数据流,让编译器指出哪条路径没交值、哪个值被提前拿走,再据此调整。Rust 的学习曲线确实陡,但错误信息通常是在给你一张控制流对账单。
先别只看代码觉得“我懂了”。语句和表达式的区别很容易在阅读时点头、动手时又被一个分号放倒。下面几题都围绕真实的编译问题设计。
下面的函数为什么不能编译?在不修改函数签名的前提下修复它。
fn area(width: u32, height: u32) -> u32 {
width * height;
}mut 和遮蔽之间做选择把文本 " 42 " 依次清理为空白、解析为整数、再乘以二。要求每一步之后的绑定都保持不可变,并继续使用名字 value。
loop 返回命中的值从 1 开始递增,找到第一个能被 7 和 11 同时整除的整数,让 loop 直接把它交给变量 answer。
下面的函数按值匹配 Message,导致内部 String 被移出。请改成只读检查,使调用之后仍能使用原消息。
enum Message {
Text(String),
Empty,
}
fn show(message: Message) {
match message {
Message::Text(text) => println!("{text}"),
Message::Empty => println!("空消息"),
}
}let-else 拉直主流程完成下面的函数:当切片为空时返回错误,否则把首元素乘以二。要求成功时绑定出的 first 在 let-else 之后继续使用。
你在读取配置文件。底层函数返回 Result<String, std::io::Error>。如果当前函数只负责加载并清理文本,应该用 ? 传播;如果当前层掌握重试次数和备用路径,就应该在这里用 match 处理。请分别写出两种骨架。
学完这一章,你不必把每段 Rust 都写成一条巨大的表达式。清楚比短更重要。真正需要建立的是一种稳定读法:代码块最后交出什么值,分支能否汇合成同一类型,循环从哪里退出,模式绑定会不会拿走所有权,? 又把失败送到了哪个边界。能沿着这条线把值的去向讲清楚,语句、表达式和控制流就不再是零散语法,而是一套可以预测的规则。
输出为 84。
输出为 77。这里不能把 loop 直接换成 while 后继续写 break candidate,因为 while 不接受带非单元值的普通 break。
text 在这里是借用,message 没有被消费。
use std::fs;
use std::io;
fn load_with_fallback(primary: &str, fallback: &str) -> Result<String, io::Error> {
match fs::read_to_string(primary) {
Ok(content) => Ok(content.trim().to_owned()),
Err(_) => {
let content = fs::read_to_string(fallback)?;
Ok(content.trim().to_owned())
}
}
}真实项目里通常还要保留主路径错误的上下文。这里的重点是:? 适合当前层不处理的失败,match 适合当前层要据此做决定的失败。