match 控制流结构
Rust 提供了一种功能极其强大的控制流结构,称为 match。它可以将一个值与一系列模式进行比较,然后根据匹配的模式执行代码。模式可以由字面值、变量名、通配符以及许多其他内容组成;第 19 章会介绍各种模式及其作用。match 的强大之处在于模式表达能力丰富,而且编译器会确认所有可能的情况都已处理。
可以把 match 表达式想象成一台硬币分拣机:硬币沿着一条轨道滑下,轨道上有大小不同的孔,每枚硬币都会从遇到的第一个能通过的孔中落下。同样,值会依次与 match 中的每个模式进行比较;一旦某个模式与值“匹配”,该值就会进入对应的代码块并在执行时使用。
说到硬币,我们就用硬币作为 match 的示例!我们可以编写一个函数,接收一枚未知的美国硬币,并像分拣机一样判断它是哪种硬币,然后返回其美分面值,如示例 6-3 所示。
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
fn main() {}
match 表达式,其中枚举变体用作匹配模式来拆解一下 value_in_cents 函数中的 match。首先写出关键字 match,后面跟一个表达式,此处是 coin 的值。这看起来与 if 条件表达式很相似,但有一个重要区别:if 的条件必须计算为布尔值,而这里可以是任何类型。本例中 coin 的类型是第一行定义的 Coin 枚举。
接下来是 match 分支。每个分支由两部分组成:一个模式和一些代码。这里第一个分支的模式是 Coin::Penny,后面使用 => 运算符分隔模式与要运行的代码。此处的代码只有值 1。每个分支之间用逗号分隔。
执行 match 表达式时,会按顺序将结果值与各分支的模式进行比较。如果模式匹配该值,就执行与模式关联的代码;如果不匹配,就像硬币分拣机一样继续检查下一个分支。分支数量可以按需增加:示例 6-3 中的 match 有四个分支。
每个分支关联的代码都是一个表达式,而匹配分支中表达式的结果值,就是整个 match 表达式的返回值。
如果匹配分支中的代码很短,通常不使用花括号,例如示例 6-3 中每个分支都只返回一个值。如果希望在一个分支中运行多行代码,就必须使用花括号;此时分支后面的逗号可以省略。例如,下面的代码每次使用 Coin::Penny 调用该方法时都会打印 “Lucky penny!”,但仍返回代码块的最后一个值 1:
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => {
println!("Lucky penny!");
1
}
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
fn main() {}
绑定值的模式
match 分支的另一个实用特性是可以将匹配值中的部分内容绑定到变量上。我们可以借此从枚举变体中提取值。
例如,我们来修改一个枚举变体,让它包含数据。1999 年至 2008 年间,美国铸造的 25 美分硬币背面采用了代表 50 个州的不同图案。其他硬币没有州的图案,因此只有 25 美分硬币带有这个额外值。我们可以修改 enum,让 Quarter 变体包含一个 UsState 值,如示例 6-4 所示。
#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
Alabama,
Alaska,
// --snip--
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn main() {}
Coin 枚举的 Quarter 变体还包含一个 UsState 值假设有位朋友想集齐代表 50 个州的 25 美分硬币。我们按硬币种类整理零钱时,也会说出每枚 25 美分硬币所代表的州名;如果朋友还没有这枚硬币,就可以把它加入收藏。
在这段代码的 match 表达式中,我们在匹配 Coin::Quarter 变体值的模式里添加了一个名为 state 的变量。当 Coin::Quarter 匹配时,state 变量就会绑定到该枚硬币所代表的州值。然后,我们可以在该分支的代码中使用 state,如下所示:
#[derive(Debug)]
enum UsState {
Alabama,
Alaska,
// --snip--
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("State quarter from {state:?}!");
25
}
}
}
fn main() {
value_in_cents(Coin::Quarter(UsState::Alaska));
}
如果调用 value_in_cents(Coin::Quarter(UsState::Alaska)),那么 coin 的值就是 Coin::Quarter(UsState::Alaska)。将该值与每个 match 分支进行比较时,直到遇到 Coin::Quarter(state) 才会匹配。此时,state 绑定的值就是 UsState::Alaska。随后我们可以在 println! 表达式中使用这个绑定,从而取出 Coin 枚举的 Quarter 变体中包含的州值。
Option<T> 的 match 模式
上一节中,我们希望在使用 Option<T> 时从 Some 情况中取出内部的 T 值;我们也可以像处理 Coin 枚举那样,使用 match 处理 Option<T>!区别只是这里匹配的是 Option<T> 的变体,而 match 表达式的工作方式仍然相同。
假设我们想编写一个函数,接收 Option<i32>,如果其中包含值,就将该值加 1;如果不包含值,函数就应返回 None,而不尝试执行任何操作。
得益于 match,这个函数很容易编写,如示例 6-5 所示。
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
match 表达式处理 Option<i32> 的函数我们来更详细地看看第一次执行 plus_one 的过程。调用 plus_one(five) 时,plus_one 函数体中的变量 x 将具有值 Some(5)。然后,我们将它与每个 match 分支进行比较:
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
Some(5) 值与模式 None 不匹配,因此继续检查下一个分支:
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
Some(5) 与 Some(i) 匹配吗?匹配!它们是相同的变体。i 会绑定到 Some 中包含的值,因此 i 的值为 5。随后执行该匹配分支中的代码,将 i 的值加 1,并创建一个内部值为 6 的新 Some 值。
现在来看示例 6-5 中第二次调用 plus_one 的情况,此时 x 为 None。我们进入 match,并将其与第一个分支进行比较:
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
它匹配了!没有值可以相加,因此程序会停止,并返回 => 右侧的 None 值。由于第一个分支已经匹配,不会再比较其他分支。
在许多情况下,将 match 与枚举结合起来都很有用。你会在 Rust 代码中经常看到这种模式:匹配枚举,将变量绑定到其中的数据,然后据此执行代码。起初可能有点难理解,但熟悉之后,你会希望所有语言都具备这一功能。它一直深受用户喜爱。
匹配必须穷尽所有情况
关于 match 还有一个方面需要讨论:分支中的模式必须覆盖所有可能性。看下面这个有缺陷且无法编译的 plus_one 函数版本:
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
我们没有处理 None 情况,因此这段代码会导致错误。幸运的是,Rust 能够捕获这种错误。尝试编译这段代码时,会得到以下错误:
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
--> src/main.rs:3:15
|
3 | match x {
| ^ pattern `None` not covered
|
note: `Option<i32>` defined here
--> /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/option.rs:597:0
::: /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/option.rs:601:4
|
= note: not covered
= note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
4 ~ Some(i) => Some(i + 1),
5 ~ None => todo!(),
|
For more information about this error, try `rustc --explain E0004`.
error: could not compile `enums` (bin "enums") due to 1 previous error
Rust 知道我们没有覆盖所有可能情况,甚至知道遗漏了哪个模式!Rust 中的 match 是穷尽的:代码要有效,就必须覆盖每一种可能性。尤其对于 Option<T>,Rust 会阻止我们忘记显式处理 None,从而避免我们在可能没有值时误以为值存在,也就杜绝了前面提到的价值十亿美元的错误。
全匹配模式与 _ 占位符
使用枚举时,我们也可以对少数特定值采取特殊操作,并对所有其他值执行一个默认操作。假设我们正在实现一款游戏:掷出 3 时,玩家不移动,而是获得一顶漂亮的新帽子;掷出 7 时,玩家会失去一顶漂亮的帽子;其他点数则让玩家在棋盘上移动相应格数。下面的 match 实现了这一逻辑,其中骰子结果是硬编码的,而不是随机值;其他逻辑则用没有函数体的函数表示,因为实际实现它们不属于本示例的范围:
fn main() {
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
other => move_player(other),
}
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
fn move_player(num_spaces: u8) {}
}
前两个分支的模式是字面值 3 和 7。覆盖所有其他可能值的最后一个分支,其模式是我们命名为 other 的变量。other 分支中的代码通过将该变量传给 move_player 函数来使用它。
即使我们没有列出 u8 的所有可能值,这段代码仍然可以编译,因为最后一个模式会匹配所有未明确列出的值。这个全匹配模式满足了 match 必须穷尽所有情况的要求。请注意,全匹配分支必须放在最后,因为模式按顺序求值。如果把全匹配分支放在前面,其他分支就永远不会运行,因此 Rust 会在全匹配分支之后还有其他分支时发出警告!
如果我们想使用全匹配模式,却不想使用匹配到的值,Rust 还提供了 _ 这一特殊模式。它可以匹配任何值,但不会将值绑定到变量上。这样就告诉 Rust 我们不会使用该值,因此 Rust 不会因为未使用变量而发出警告。
我们再修改一下游戏规则:现在,掷出 3 或 7 以外的任何点数时,都必须重新掷骰子。我们不再需要使用全匹配值,因此可以用 _ 代替名为 other 的变量:
fn main() {
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => reroll(),
}
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
fn reroll() {}
}
这个示例也满足穷尽性要求,因为我们在最后一个分支中明确忽略了所有其他值,没有遗漏任何情况。
最后,我们再修改一次游戏规则:如果掷出的点数不是 3 或 7,本回合就不发生其他事情。我们可以将单元值(即“元组类型”一节中介绍的空元组类型)用作 _ 分支的代码来表达这一点:
fn main() {
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => (),
}
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
}
这里我们明确告诉 Rust:对于前面的分支都未匹配的其他值,我们既不打算使用,也不希望执行任何代码。
关于模式和匹配的更多内容将在第 19 章介绍。目前我们将转向 if let 语法;当 match 表达式显得有些冗长时,它会很有用。