`match` 控制流结构

Rust 拥有一个极其强大的控制流结构,称为 match,它允许你将一个值与一系列模式进行比较,然后根据匹配的模式执行代码。模式可以由字面值、变量名、通配符和许多其他内容组成;第 18 章 介绍了所有不同类型的模式及其功能。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() {}
Listing 6-3: An enum and a match expression that has the variants of the enum as its patterns

让我们来分析 value_in_cents 函数中的 match。首先我们列出 match 关键字,后面跟着一个表达式,在这个例子中是值 coin。这看起来非常类似于使用 if 的条件表达式,但有一个很大的不同:if 的条件需要评估为布尔值,但在这里它可以是任何类型。coin 在这个例子中的类型是我们第一行定义的 Coin 枚举。

接下来是 match 分支。一个分支有两个部分:一个模式和一些代码。这里的第一个分支的模式是值 Coin::Penny,然后是分隔模式和要执行的代码的 => 操作符。在这种情况下,代码只是值 1。每个分支之间用逗号分隔。

match 表达式执行时,它会按顺序将结果值与每个分支的模式进行比较。如果一个模式匹配该值,则执行与该模式关联的代码。如果该模式不匹配该值,则继续执行下一个分支,就像在硬币分类机中一样。我们可以根据需要拥有任意数量的分支:在清单 6-3 中,我们的 match 有四个分支。

与每个分支关联的代码是一个表达式,匹配分支中的表达式的结果值就是整个match表达式返回的值。

我们通常不在 match 分支代码很短时使用大括号,例如在清单 6-3 中每个分支只是返回一个值。如果你想在一个 match 分支中运行多行代码,你必须使用大括号,然后分支后面的逗号就变成可选的了。例如,以下代码每次使用 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年,美国铸造了带有50个州不同设计的硬币,每个州的设计都在硬币的一侧。其他硬币没有州的设计,所以只有 quarters 拥有这个额外的价值。我们可以通过更改 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() {}
Listing 6-4: A Coin enum in which the Quarter variant also holds a UsState value

让我们想象一个朋友正在尝试收集所有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)。当我们用每个匹配臂与该值进行比较时,直到我们到达Coin::Quarter(state),它们都不会匹配。在这一点上,state的绑定值将是UsState::Alaska。然后我们可以在println!表达式中使用该绑定,从而从Coin枚举变体的Quarter中获取内部状态值。

Option<T> 匹配

在上一节中,我们希望在使用 Option<T> 时从 Some 情况中获取内部的 T 值;我们也可以使用 match 处理 Option<T>,就像我们处理 Coin 枚举一样!我们将比较 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);
}
Listing 6-5: A function that uses a match expression on an Option<i32>

让我们更详细地 examine the first execution of plus_one。当我们调用 plus_one(five) 时,plus_one 函数体中的变量 x 将具有值 Some(5)。然后我们将该值与每个 match arm 进行比较:

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。然后执行 match 分支中的代码,所以我们给 i 的值加 1,并创建一个包含我们总计 6 的新 Some 值。

现在让我们考虑列表 6-5 中 plus_one 的第二次调用,其中 xNone。我们进入 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 对枚举进行匹配,将变量绑定到内部数据,然后根据这些数据执行代码。一开始可能会有点 tricky,但一旦你习惯了,你就会希望在所有语言中都能使用它。它一直是最受用户喜爱的功能之一。

匹配是详尽的

还有 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 情况,所以这段代码会导致一个 bug。幸运的是,这是一个 Rust 能够捕获的 bug。如果我们尝试编译这段代码,我们会得到这个错误:

$ 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
   --> file:///home/.rustup/toolchains/1.82/lib/rustlib/src/rust/library/core/src/option.rs:571:1
    |
571 | pub enum Option<T> {
    | ^^^^^^^^^^^^^^^^^^
...
575 |     None,
    |     ---- 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 中的匹配是穷尽的:我们必须穷尽每一种可能性,代码才能有效。特别是在处理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) {}
}

对于前两个分支,模式是字面值 37。对于最后一个覆盖所有其他可能值的分支,模式是我们选择命名为 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 我们不会使用任何不匹配前面 arm 中模式的值,并且在这种情况下我们不想运行任何代码。

关于模式和匹配的更多内容,我们将在第19章中介绍。现在,我们将继续讨论if let语法,这种语法在match表达式显得有些冗长的情况下非常有用。