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() {}
示例 6-3:一个枚举和一个以该枚举的变体作为模式的 match 表达式

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

接下来是 match 的“分支”(arms)。一个分支包含两部分:一个模式和一些代码。这里第一个分支的模式是值 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 年,美国铸造的 25 美分硬币(quarter)一面印有 50 个州的其中一个州的图案。其他硬币没有州图案,因此只有 quarter 有这个额外的值。我们可以通过修改 Quarter 变体,使其包含一个存储在内部的 UsState 值来将此信息添加到我们的 enum 中,如示例 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() {}
示例 6-4:一个 Coin 枚举,其中的 Quarter 变体也包含一个 UsState

假设一位朋友正在收集所有 50 个州图案的 quarter 硬币。在我们按硬币类型分拣零钱时,我们也会大声说出每个 quarter 关联的州名称,以便如果这是朋友没有的硬币,他们可以将其加入收藏。

在这段代码的 match 表达式中,我们在匹配变体 Coin::Quarter 值的模式中添加了一个名为 state 的变量。当匹配到 Coin::Quarter 时,state 变量将绑定到该 quarter 的州值。然后我们可以在该分支的代码中使用 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> 进行模式匹配

在上一节中,我们想在使用 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);
}
示例 6-5:一个对 Option<i32> 使用 match 表达式的函数

让我们更详细地检查 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。然后执行 match 分支中的代码,我们将 i 的值加 1,并创建一个新的 Some 值,其内部总和为 6

现在考虑示例 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,将变量绑定到内部数据,然后根据数据执行代码。起初可能有点棘手,但一旦习惯了,你会希望所有语言都有这个特性。它一直深受用户喜爱。

match 的穷尽性

关于 match,我们还需要讨论另一个方面:分支的模式必须覆盖所有可能性。考虑一下我们 plus_one 函数的这个版本,它有一个 bug 并且无法编译:

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.85/lib/rustlib/src/rust/library/core/src/option.rs:572:1
    |
572 | pub enum Option<T> {
    | ^^^^^^^^^^^^^^^^^^
...
576 |     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 中的 match 是“穷尽的”(exhaustive):我们必须穷尽所有可能性,代码才能有效。特别是在处理 Option<T> 时,Rust 会阻止我们忘记显式地处理 None 情况,从而保护我们免于在可能为 null 的情况下错误地认为存在值,这使得前面讨论的十亿美元错误成为不可能。

捕获所有情况的模式与 _ 占位符

使用枚举时,我们也可以对一些特定值采取特殊操作,而对所有其他值采取默认操作。想象一下,我们正在实现一个游戏,如果掷骰子掷出 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,我们不会使用与之前分支中模式不匹配的任何其他值,并且在这种情况下也不想运行任何代码。

关于模式和模式匹配,我们将在 第 19 章 中详细介绍。现在,我们将继续讨论 if let 语法,它在 match 表达式有些冗长的情况下会很有用。