2026最新Rust入门教程[06]

Rust Book 编译版 06 · 枚举与模式匹配:enum、Option 与 match

编译自 The Rust Programming Language(2024 edition),在原文基础上做了删减与补充。© The Rust Project Developers,依据 MIT OR Apache-2.0 许可编译转载。

开始之前

第 5 章的结构体回答了「怎么把一堆相关数据组织成一个类型」,本章的枚举(enumeration,简称 enum)回答另一个问题:怎么把「一个值可能处的几种状态」组织成一个类型。这是 Rust「把错误状态建模进类型系统」的第一课------流行的说法叫 make illegal states unrepresentable(让非法状态无法表示)。典型例子就是 null:多数语言里任何引用都可能是 null,于是任何解引用都可能爆炸;Rust 干脆没有 null,用类型系统回答「这个值可能不存在吗」------答案写在类型里,编译器替你检查。

本章路线:先定义并使用枚举,看它如何连同数据一起编码含义;然后认识标准库里最重要的枚举 Option,它表达「一个值要么是某个东西、要么什么都没有」;接着用 match 做模式匹配(pattern matching),让不同变体运行不同代码;最后是两个更轻量的惯用写法:if let 和 let...else。

前置知识 :第 5 章结构体(自定义类型、impl 方法)。

你会学到:

  • 定义携带数据的枚举,理解它与结构体各自的分工
  • 说清 Rust 为什么没有 null,Option<T> 如何表达「有值 / 没值」
  • 用 match 做穷尽匹配,在模式中绑定并取出变体内部的数据
  • 用通配模式与 _ 占位符处理「其余所有情况」
  • 权衡 if let / let...else 与 match:何时值得用穷尽性检查换简洁

定义枚举

结构体把相关的字段组织在一起(比如带 width 和 height 的 Rectangle),枚举则表达「一个值是一组可能值中的一个」------比如 Rectangle 是 Rectangle、Circle、Triangle 这组可能的形状之一。什么场景下枚举比结构体更合适?拿 IP 地址来说:如今只有第四版和第六版两个标准,程序遇到的 IP 地址只可能是这两者之一,可以把所有可能一一列举(enumerate)出来------这正是「枚举」名字的由来。而且一个 IP 地址要么是 v4、要么是 v6,不可能同时是两者;但处理任意 IP 地址的代码又应把它们当同一个类型看待。这种「多选一、同属一家」的关系正是枚举的用武之地。

用 IpAddrKind 枚举列出 IP 地址所有可能的种类------V4 和 V6,它们就是这个枚举的变体(variant):

rust 复制代码
enum IpAddrKind {
    V4,
    V6,
}

IpAddrKind 现在是一个自定义数据类型,可以在代码的其他地方使用。

枚举值

像这样创建两个变体的实例:

rust 复制代码
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;

注意变体位于枚举标识符的命名空间之下,用双冒号分隔------于是 IpAddrKind::V4 和 IpAddrKind::V6 同属 IpAddrKind 这一个类型。我们可以定义一个接受任何 IpAddrKind 的函数:

rust 复制代码
fn route(ip_kind: IpAddrKind) {}

并用任一变体调用它:

rust 复制代码
route(IpAddrKind::V4);
route(IpAddrKind::V6);

但这个枚举还存不下实际的 IP 地址数据 ,只知道它的种类。刚学过结构体的你,可能会想到用结构体把两者捆在一起,如示例 6-1。

rust 复制代码
enum IpAddrKind {
    V4,
    V6,
}

struct IpAddr {
    kind: IpAddrKind,
    address: String,
}

let home = IpAddr {
    kind: IpAddrKind::V4,
    address: String::from("127.0.0.1"),
};

let loopback = IpAddr {
    kind: IpAddrKind::V6,
    address: String::from("::1"),
};

示例 6-1:使用结构体存储 IP 地址的数据和 IpAddrKind 变体

结构体 IpAddr 有两个字段:kind 是前面定义的枚举,address 是 String。home 捆绑了 V4 和 127.0.0.1,loopback 捆绑了 V6 和 ::1------变体就这样与值关联起来了。

不过只用枚举表达同样的概念更简洁:与其把枚举塞进结构体,不如把数据直接放进每个变体。IpAddr 的这个新定义让 V4 和 V6 都关联一个 String 值:

rust 复制代码
enum IpAddr {
    V4(String),
    V6(String),
}

let home = IpAddr::V4(String::from("127.0.0.1"));

let loopback = IpAddr::V6(String::from("::1"));

数据直接附在变体上,结构体就不需要了。这里还能看出枚举的一个细节:每个变体的名字同时也是一个构造枚举实例的函数------IpAddr::V4() 是一个接受 String、返回 IpAddr 实例的函数调用,定义枚举的同时就自动获得了这个构造函数。

枚举还有一项结构体做不到的本事:每个变体可以携带不同类型、不同数量的数据。第四版 IP 地址由四个 0 到 255 的数值分量组成,想把 V4 存成四个 u8、V6 存成一个 String?结构体无法优雅地做到,枚举轻松应对:

rust 复制代码
enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);

let loopback = IpAddr::V6(String::from("::1"));

存储 IP 地址并编码其种类这个需求实在太常见,以至于标准库里已有现成定义:同样的枚举与变体,只是把地址数据以两个不同结构体的形式嵌进变体,每个变体各对应一个结构体:

rust 复制代码
struct Ipv4Addr {
    // --省略--
}

struct Ipv6Addr {
    // --省略--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}

可见枚举变体里可以放任何类型的数据------字符串、数值类型、结构体,甚至另一个枚举。另外,标准库中的类型往往并不比你自己能想出来的复杂多少。还有一点:尽管标准库已有 IpAddr,我们仍能定义并使用自己的同名版本而不冲突,因为我们没有把标准库的定义引入作用域------作用域的话题留到第 7 章。

示例 6-2 是变体中嵌入多种多样类型的另一个例子:

rust 复制代码
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

示例 6-2:一个 Message 枚举,其变体各自存储不同数量和类型的值

四个变体形态各异:

  • Quit:不关联任何数据
  • Move:包含命名字段,类似结构体
  • Write:包含一个 String
  • ChangeColor:包含三个 i32 值

定义这样的枚举,类似于定义几种不同的结构体(不用 struct 关键字),但所有变体都归在 Message 这一个类型之下。下面这些结构体可以存放与前面各变体相同的数据:

rust 复制代码
struct QuitMessage; // 单元结构体
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // 元组结构体
struct ChangeColorMessage(i32, i32, i32); // 元组结构体

代价是:这些结构体各有各的类型,你没法像使用 Message(单一类型)那样,轻松定义一个能接收任何一种消息的函数。

枚举和结构体还有一个相似之处:正如 impl 能在结构体上定义方法,它也能用在枚举上。比如 Message 枚举上的 call 方法:

rust 复制代码
impl Message {
    fn call(&self) {
        // 方法体将在这里定义
    }
}

let m = Message::Write(String::from("hello"));
m.call();

方法体通过 self 拿到调用它的那个值------本例中 m 的值是 Message::Write(String::from("hello")),m.call() 运行时,call 体内的 self 就是它。

再来看标准库中另一个非常常用且有用的枚举 :Option。

Option 枚举

Option 编码了一个极其常见的场景:一个值可能是某个东西,也可能什么都不是,是的,它和Null诞生的场景相同。请求非空列表的第一个元素,你会得到一个值;请求空列表的第一个元素,你什么也得不到。把这个概念放进类型系统,编译器就能检查你是否处理了所有该处理的情况------恰好预防了其他语言里极其常见的一类 bug。

设计语言时,人们常想的是加哪些特性,但排除哪些特性同样重要:Rust 没有 null。null 是一个表示「这里没有值」的值;在拥有 null 的语言中,变量永远处于 null 或非 null 两种状态之一。null 的发明者 Tony Hoare 在 2009 年的演讲《Null 引用:十亿美元的错误》中这样说道:

我把它称为我十亿美元的错误。当时,我正在为一门面向对象语言设计第一个全面的引用类型系统。我的目标是让引用的所有使用都绝对安全,检查由编译器自动完成。但我没能抵住诱惑,加入了 null 引用,仅仅因为它实现起来太容易了。这引发了无数错误、漏洞和系统崩溃,在过去四十年里可能已经造成了十亿美元的痛苦和损失。

null 值的问题在于:把 null 当非 null 用会触发某种错误,而 null 无处不在,这种错误极易发生。不过 null 想表达的概念本身是有用的------一个当前无效、或因某种原因缺席的值。问题不在概念,在实现。所以 Rust 没有 null,而是用一个枚举编码「值存在或缺失」,这个枚举就是 Option<T>,它在标准库中的定义如下:

rust 复制代码
enum Option<T> {
    None,
    Some(T),
}

Option<T> 太有用了,整个类型连同变体都被纳入 prelude:无需显式引入作用域,Some 和 None 也可以不加 Option:: 前缀直接使用。Option<T> 仍然只是一个普通的枚举。

<T> 是我们还没讲过的语法:泛型类型参数,第 10 章详谈。现在只需知道,<T> 表示 Some 变体可以持有任意类型的一个数据,而每一个用于替代 T 的具体类型,都会让整个 Option<T> 成为一种不同的类型:

rust 复制代码
let some_number = Some(5);
let some_char = Some('e');

let absent_number: Option<i32> = None;

some_number 的类型是 Option<i32>,some_char 是 Option<char>------不同的类型,由 Some 里装的值推断而来。而 None 单独出现时,编译器无从推断对应的 Some 会装什么类型,所以 absent_number 必须标注整体的 Option 类型,这里告诉 Rust 它是 Option<i32>。

拿到 Some 值,就知道值存在且在 Some 里面;拿到 None,某种意义上和 null 含义相同:手头没有有效的值。那 Option<T> 比 null 好在哪?简而言之:Option<T> 和 T(T 可以是任何类型)是不同的类型,编译器不允许我们把 Option<T> 当一个肯定有效的值来用。下面的代码无法通过编译,因为它试图把 i8 与 Option<i8> 相加:

rust 复制代码
let x: i8 = 5;
let y: Option<i8> = Some(5);

let sum = x + y;

运行这段代码,会得到类似这样的错误信息:

bash 复制代码
cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`
help: the following other types implement trait `Add<Rhs>`
 --> /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/ops/arith.rs:98:8
  |
  = note: `i8` implements `Add`
 ::: /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/ops/arith.rs:113:0
  |
  = note: in this macro invocation
 --> /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/internal_macros.rs:22:8
  |
  = note: `&i8` implements `Add<i8>`
 ::: /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/internal_macros.rs:33:8
  |
  = note: `i8` implements `Add<&i8>`
 ::: /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/internal_macros.rs:44:8
  |
  = note: `&i8` implements `Add`
  = note: this error originates in the macro `add_impl` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error

信息量很大,但核心就一句:i8 和 Option<i8> 是不同的类型,Rust 不知道怎么把它们相加。在 Rust 中,持有一个 i8 这样的值,就意味着编译器保证它始终有效------可以放心直接用,不必先检查是否为 null;只有当类型是 Option<i8>(或任何正在处理的 Option 类型)时,才需要考虑「可能没有值」,而编译器会确保你在使用之前处理好这种情况。换句话说,必须先把 Option<T> 转换成 T,才能对它执行 T 的操作。这正好抓住了 null 最常见的问题之一:以为某个东西不是 null,实际上却是。

消除「错误地假定值非 null」的风险,让你对自己的代码更有信心:想要一个可能为 null 的值,就必须显式把它的类型定为 Option<T>,使用时就必须显式处理 None 的情形;而在所有类型不是 Option<T> 的地方,你都可以放心地假定它不是 null。这是 Rust 的一项刻意设计决策,目的是限制 null 的蔓延、提高代码安全性。

编译注: 「用类型回答 null」这条路上其他语言也在走,但走法不同:Java 8 的 Optional 是类库层面的包装,编译器并不强制你拆开它,null 本尊依然存在;Kotlin 的 T? 与 TypeScript 的 strictNullChecks 和 Rust 一样走类型系统路线。Rust 的特别之处在于 Option<T> 只是个普通枚举,没有任何语言特权------本章教你用 match 拆 Coin 的那套机制,原封不动就能用在 Option 上。

手里有 Option<T>,怎么从 Some 变体里取出 T 来用?Option<T> 自带大量实用方法,值得翻一翻它的文档。但更根本的思路是:让代码分别处理每个变体------Some(T) 时运行这些代码(能用里面的 T),None 时运行那些代码(没有 T 可用)。match 表达式正是这样的控制流构造:按持有的变体运行不同的代码,而且这些代码能使用匹配值内部的数据。

match 控制流构造

Rust 有一个极其强大的控制流构造 match:把一个值与一系列模式(pattern)比较,按匹配到的模式执行相应代码。模式可以由字面值、变量名、通配符等构成。match 的强大一半来自模式的表现力 ,另一半来自编译器会确认所有可能的情况都已处理。

可以把 match 想象成一台硬币分拣机:硬币沿着一侧开有一排大小各异的孔的轨道滑下,每枚硬币从它遇到的第一个能容纳它的孔中掉下去;类似地,值依次「经过」各个模式,遇到第一个合适的就落入与之关联的代码块。说到硬币,那就用硬币演示------写一个函数,接收一枚未知面额的美国硬币,像硬币清点机那样判定种类,返回以美分为单位的面值,如示例 6-3。

rust 复制代码
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,
    }
}

示例 6-3:一个枚举,以及一个以该枚举的变体为模式的 match 表达式

拆解 value_in_cents 里的 match。先是 match 关键字加一个表达式,这里是值 coin------写法很像 if 的条件,但有个大区别:if 的条件必须求值为布尔值,而这里可以是任何类型,本例就是第一行定义的 Coin 枚举。

接下来是各个 match 分支(match arm)。一个分支 = 模式 + 代码:第一个分支的模式是 Coin::Penny,=> 分隔模式与要运行的代码(这里是值 1),分支之间用逗号隔开。执行时,值按顺序与每个分支的模式比较,匹配则执行对应代码,不匹配则继续下一个分支,就像硬币分拣机。分支数量不限。每个分支关联的代码都是表达式,匹配到的分支中表达式的结果,就是整个 match 表达式的返回值。

分支代码很短时(比如每个分支只返回一个值)通常不用花括号;要运行多行代码就必须用花括号,此时分支后的逗号可选。下面的代码在函数每次以 Coin::Penny 被调用时都会打印「Lucky penny!」,但仍返回代码块的最后一个值 1:

rust 复制代码
fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        }
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

绑定值的模式

match 分支还有一个有用的特性:可以绑定到值中与模式匹配的那些部分------这正是我们从枚举变体中提取值的方式。

举个例子,把其中一个变体改成内部持有数据。1999 到 2008 年间,美国铸造的 25 美分硬币(quarter)一面为 50 个州各印一种不同图案,其他硬币没有州图案------只有 quarter 带这份额外信息。把 Quarter 变体改为携带一个 UsState 值,如示例 6-4。

rust 复制代码
#[derive(Debug)] // 方便稍后查看这个州的值
enum UsState {
    Alabama,
    Alaska,
    // --省略--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

示例 6-4:Coin 枚举,其 Quarter 变体中还持有一个 UsState 值

想象一位朋友正在收集全部 50 枚州版 quarter:我们按种类整理零钱的同时报出每枚 quarter 的州名,碰到朋友还没有的就能加进收藏。在匹配 Coin::Quarter 变体的模式里加入一个名为 state 的变量,匹配到时 state 就绑定到那枚硬币所含的州值,随后即可在分支代码中使用:

rust 复制代码
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
        }
    }
}

调用 value_in_cents(Coin::Quarter(UsState::Alaska)) 时,coin 就是 Coin::Quarter(UsState::Alaska)。它与前面的分支都匹配不上,直到 Coin::Quarter(state):state 绑定为 UsState::Alaska,println! 里就能用这个绑定------Quarter 变体内部的州值就这样被取了出来。

用 match 匹配 Option<T>

上一节想从 Some 情形中取出里面的 T 值,Option<T> 同样能用 match 处理------比较的不是硬币而是 Option<T> 的变体,机制并无二致。写一个接受 Option<i32> 的函数:里面有值就加 1;没有值就返回 None,不做任何操作。得益于 match,它写起来非常容易,如示例 6-5。

rust 复制代码
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(five) 时,x 的值是 Some(5),与各分支依次比较:

rust 复制代码
None => None,

Some(5) 与 None 不匹配,继续下一个分支:

rust 复制代码
Some(i) => Some(i + 1),

Some(5) 与 Some(i) 匹配!变体相同,i 绑定到 Some 里包含的值 5。随后分支代码执行:i 加 1,用结果 6 创建一个新的 Some 值。

再看第二次调用,此时 x 是 None。进入 match 先与第一个分支比较:

rust 复制代码
None => None,

匹配成功!没有可以相加的值,程序停止并返回 => 右侧的 None。第一个分支已匹配,其他分支不再参与比较。

对枚举做 match、把变量绑定到其中的数据、再据此执行代码------这种组合你会在 Rust 代码里大量见到。一开始会有些绕,习惯之后你会希望所有语言都有它;它始终位列用户最喜爱的特性之一。

匹配必须穷尽

关于 match 还有一点:各分支的模式必须覆盖所有可能的情况。这版 plus_one 有 bug 且无法通过编译------它没有处理 None:

rust 复制代码
fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        Some(i) => Some(i + 1),
    }
}

幸运的是,这种 bug 正是 Rust 擅长抓的。编译会得到如下错误:

bash 复制代码
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 中的匹配是穷尽的 (exhaustive):必须穷尽最后一种可能性,代码才合法。尤其在 Option<T> 上,Rust 阻止我们忘记显式处理 None,就是在保护我们不要在可能没有值的时候假定一定有值------前面那个「十亿美元的错误」在 Rust 里无从发生。

通配模式与 _ 占位符

match 还能只对少数几个特定值做特殊处理,其余值走同一个默认操作。比如实现一个掷骰子游戏:掷出 3 时棋子不移动、得到一顶漂亮的新帽子;掷出 7 时失去一顶漂亮帽子;其他点数棋子移动相应格数。下面的 match 实现了该逻辑,骰子结果硬编码而非随机,其余逻辑用没有函数体的函数表示(真正实现它们超出本例范围):

rust 复制代码
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 的所有取值,这段代码也能编译------最后一个模式兜住了所有未明确列出的值,满足穷尽性要求。注意通配分支必须放在最后:模式按顺序求值,通配放前面,其他分支就永远不会运行;在通配分支之后再添加分支,Rust 会发出警告。

Rust 还有一种「想要通配、却又不想使用 通配值」的模式:_ 是特殊模式,匹配任何值但不绑定它------等于告诉 Rust 我们不会使用这个值,因此不会有未使用变量的警告。改一下游戏规则:只要掷出的不是 3 也不是 7,就必须再掷一次。不再需要通配值,把 other 换成 _:

rust 复制代码
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 以外的点数时,回合什么都不发生。用单元值(unit value,即第 3 章「元组类型」一节提到的空元组类型)作为 _ 分支的代码:

rust 复制代码
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 表达式显得啰嗦的场景下,它会很有用。

使用 if let 和 let...else 的简洁控制流

if let 把 if 和 let 组合在一起,用不那么繁琐的方式处理「只关心匹配某一个模式、忽略其余情况」的值。示例 6-6 对 config_max 变量中的 Option<u8> 值做匹配,但只想在值为 Some 变体时执行代码:

rust 复制代码
let config_max = Some(3u8);
match config_max {
    Some(max) => println!("The maximum is configured to be {max}"),
    _ => (),
}

示例 6-6:一个只关心值为 Some 时才执行代码的 match

值是 Some 时,模式把值绑定到变量 max 并打印出来;None 时我们什么也不想做。但为了满足 match 的要求,处理完一个变体之后还得补上 _ => ()------这种样板代码写起来挺烦人。改用 if let,同样的逻辑短得多:

rust 复制代码
let config_max = Some(3u8);
if let Some(max) = config_max {
    println!("The maximum is configured to be {max}");
}

if let 接受一个模式和一个表达式,二者用等号隔开。工作方式与 match 相同:模式相当于第一个分支------本例中 Some(max) 的 max 绑定到 Some 里面的值,可以像在对应 match 分支中那样在块主体里使用;块中的代码只在值匹配模式时运行。

if let 意味着更少的输入、更少的缩进、更少的样板代码,但代价是失去 match 强制的穷尽性检查------正是它确保你不会忘记处理任何情况。在 match 和 if let 之间怎么选,取决于具体场景,以及「换取简洁」是否值得付出「失去穷尽性检查」的代价。换句话说,if let 可以视为 match 的语法糖:值匹配某一个模式时运行代码,忽略其他所有值。

if let 还可以搭配 else。else 对应的代码块,与「等价的 match 表达式」中 _ 情形的代码块相同。回想示例 6-4 的 Coin 枚举,Quarter 变体持有一个 UsState 值。想在报出各枚 quarter 所属州的同时、统计看到的所有其他硬币,可以用一个 match 表达式完成:

rust 复制代码
let mut count = 0;
match coin {
    Coin::Quarter(state) => println!("State quarter from {state:?}!"),
    _ => count += 1,
}

也可以用 if let 加 else:

rust 复制代码
let mut count = 0;
if let Coin::Quarter(state) = coin {
    println!("State quarter from {state:?}!");
} else {
    count += 1;
}

用 let...else 保持「快乐路径」

还有一种常见模式:值存在时执行一些计算,否则返回默认值。继续用带 UsState 的硬币例子------想根据 25 美分硬币上州的历史长短说点俏皮话,先给 UsState 引入一个检查州「年龄」的方法:

rust 复制代码
impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // --省略--
        }
    }
}

然后用 if let 匹配硬币种类,在条件主体中引入 state 变量,如示例 6-7。

rust 复制代码
fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

示例 6-7:通过嵌套在 if let 中的条件判断,检查一个州在 1900 年是否已经存在

能完成任务,但它把要做的工作推进了 if let 语句的主体里;工作一复杂,各顶层分支之间的关系就难看清了。也可以利用「表达式会产生值」这一事实,要么从 if let 中得到 state,要么提前返回,如示例 6-8(用 match 也能写出类似的代码)。

rust 复制代码
fn describe_state_quarter(coin: Coin) -> Option<String> {
    let state = if let Coin::Quarter(state) = coin {
        state
    } else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

示例 6-8:使用 if let 产出值或提前返回

不过这种写法读起来也别扭:if let 的一个分支产生值,另一个分支却干脆从函数返回。

为了更优雅地表达这种常见模式,Rust 提供了 let...else。左边是一个模式、右边是一个表达式,与 if let 非常相似,但它没有 if 分支、只有 else 分支:模式匹配时,在外层作用域中绑定模式里的值;模式不 匹配时,程序流入 else 分支,而该分支必须从函数返回。示例 6-9 是用 let...else 替换示例 6-8 后的样子:

rust 复制代码
fn describe_state_quarter(coin: Coin) -> Option<String> {
    let Coin::Quarter(state) = coin else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

示例 6-9:使用 let...else 理清函数的控制流

这样一来,函数主体始终保持在「快乐路径」(happy path)上,不再像 if let 那样让两个分支的控制流大相径庭。程序里如果有用 match 表达会显得过于啰嗦的逻辑,别忘了 if let 和 let...else 也在你的 Rust 工具箱里。

小结与下一步

  • 枚举把「一个值只能是这几种之一」编码进类型,每个变体还能各自携带不同形状的数据,并且支持 impl 方法
  • Rust 没有 null:Option<T> 让「有值 / 没值」成为类型系统里看得见的区别,把 Option<T> 当 T 用,编译器直接拦下
  • match 的穷尽性检查保证每个变体都被处理,模式还能绑定变体内部的数据;通配分支必须放最后,_ 表示「匹配任意但不使用」
  • if let / let...else 是用穷尽性检查换简洁的语法糖;类型还会演化时优先 match
  • 加上第 5 章的结构体,你已经能用自定义类型表达领域概念,让编译器保证函数只拿到它期望类型的值

下一篇:第 7 章「包、crate 与模块」------代码开始变大,聊聊 Rust 怎么组织与封装。

相关推荐
长坡夜行7 小时前
复盘:一个 `continue` 让风控把 13% 持仓当成 0,自动触发了 -15.7% 假熔断
程序员
Solara7 小时前
我重算了 11 天,抓出自己抄错的 1 天:AI 会把"公式"记成"数列"
人工智能·程序员·ai编程
cyf319 小时前
程序员到架构师工作和生活反思
程序员·生活·架构师
Thneonl9 小时前
故意弄坏生产:混沌工程不是乱砸,是实验设计
后端·程序员
Thneonl9 小时前
别上来就 strace:60 秒十条命令看清一台病机
后端·程序员
Sam_Deep_Thinking10 小时前
CountDownLatch实现原理
java·后端·面试·程序员
程序员cxuan1 天前
腾讯又来一王炸,开源版 WorkBuddy 太夯了!
人工智能·后端·程序员
穆梓兰煊1 天前
WebAssembly AI 推理学习路线图
程序员
穆梓东海1 天前
公司从 400 多人缩减到 100 多人,我开始重新思考程序员的未来
程序员