模式匹配

用穷尽匹配、解构、守卫和绑定模式表达数据分支,并在不必要的移动与通配分支吞错前发现问题。

难度 进阶 时长 标准深度约 12分钟
版本 Rust 1.98
what

模式匹配 用一个模式检查值的形状与内容,还能在同一步中取出内部数据;match 会把这套机制用于穷尽的分支选择。

trap

_ 会让代码在枚举新增变体后继续编译,却可能悄悄走错分支;按值解构还可能移动非 Copy 字段,使原值整体失效。

fix

对封闭枚举优先列出所有变体,先决定要借用还是取得所有权,再选择 matchif letlet-elsematches!

是什么,为什么存在

Rust 的模式描述一个值必须具有的结构,并可为结构中的数据创建绑定。模式能匹配枚举变体、结构体字段、元组、切片、字面值和范围;它不是只比较一个标量的 switch 标签。

match 把一个待匹配值与若干分支从上到下比较,只执行第一个成功分支。编译器要求所有可能值都有去处,并警告永远到不了的分支,因此给枚举添加变体时,显式列举变体的调用点会立刻暴露遗漏。

模式还把检查和取值合成一次操作。Some(order) 同时证明 Option 不是 None,并把内部值绑定为 order;不需要先查询状态,再执行可能失效的取值。

你会在 match 分支、let、函数参数、forif letwhile letlet-elsematches! 中遇到模式。它们使用同一套模式语言,但允许失败的程度不同,所以不能只按代码长短互换。宏生成模式、复杂嵌套模式与 API 演进策略等内容属于 rust/pattern-matching-advanced,这里不重复展开。

工作原理

分支选择与穷尽性

match subject { pattern => expression, ... } 先计算一次 subject,再按源码顺序尝试分支。选中的分支产生整个 match 表达式的值,因此所有可到达分支必须得到兼容的结果类型。

穷尽性检查根据类型与模式覆盖范围进行,不会执行代码来猜测条件。布尔值只有两个可能值,普通枚举有已声明的变体,而整数通常需要范围或兜底模式;匹配守卫中的任意布尔表达式不算静态覆盖。

顺序会改变行为。较宽的范围、变量绑定或 _ 放在前面会遮住后面的精确模式,编译器通常以 unreachable_patterns 警告指出这种分支。

解构与绑定

模式既能测试,也能绑定。Request::Read { id } 先要求值是 Read 变体,再把字段绑定为局部变量 idRequest::Read { id: 0 } 则测试字段,但不创建同名变量。

_ 匹配并忽略一个值,.. 忽略结构或序列中剩余的部分。name @ subpattern 在要求 subpattern 成功的同时,把匹配到的完整部分绑定为 name,适合既要验证范围又要继续使用原值的场景。

多个模式可用 | 连接,但每个候选必须创建相同名字、相同类型和相同绑定方式。1 | 2 | 3 不创建变量;小写裸标识符通常创建新绑定,而不是引用外层同名变量。

可反驳性决定语法位置

能匹配其类型每个可能值的模式是不可反驳模式。let (x, y) = pair 一定成功,所以普通 let、函数参数和 for 绑定可以直接使用它。

可能失败的是 可反驳模式 ,例如 Some(value)1..=9 或固定长度的切片模式。它必须出现在有失败路径的位置,如 match 分支、if letwhile letlet-elselet-elseelse 必须通过 returnbreakcontinue 或发散表达式离开当前路径。

形式适合的意图失败时发生什么穷尽检查
match多个有意义的情况或需要返回值进入另一个分支
if let只处理一个成功形状可选 else
while let成功时反复取值循环结束
let-else失败就提前离开else 必须离开针对该模式
matches!只需要布尔答案返回 false

绑定方式与所有权

模式绑定也遵守 Rust 的移动、复制与借用规则。按值匹配拥有型值时,非 Copy 字段默认会被移动,Copy 字段则复制;只移出部分字段会产生 部分移动 ,剩余字段可能还能分别使用,但原结构不能再作为完整值使用。

匹配借用值时, 匹配人体工程学 会在常见结构模式中自动解引用,并让绑定成为引用。match &job { Job { owner, .. } => ... } 中的 owner 因此是借用,而不是从 job 中移出 String

所有权选择应从匹配对象开始读:match value 允许取得其内容,match &value 只读借用,match &mut value 允许可变借用。refref mut 仍可在模式中显式借用字段,但在新代码里,借用整个匹配对象通常更容易审查。

守卫与失败路径

匹配守卫 是分支模式后的 if condition。只有模式先成功,守卫才会求值;守卫为 false 时,匹配继续尝试后面的分支。

守卫能引用该模式创建的绑定,也能读取外部变量。它不把自身条件加入穷尽性证明,所以 Some(x) if x > 0 后面仍要处理其他 Some 值以及 None

A | B if condition 中,守卫作用于整个 A | B,不是只作用于 B。如果候选需要不同条件,应拆成两个分支,让顺序与条件边界都明确。

示例

下面四个例子依次展示穷尽分支、切片解析、借用与移动,以及循环式匹配。所有输出都来自本地 rustc 编译后实际运行的程序。

用枚举封住状态空间

路由函数显式处理每个 Request 变体,并把 match 直接作为返回值。Write 的守卫只处理超限情况,后一个 Write 分支仍负责其余大小。

route.rs
enum Request {
    Health,
    Read { id: u32 },
    Write { bytes: usize },
}

fn route(request: Request) -> (&'static str, u16) {
    match request {
        Request::Health => ("health", 200),
        Request::Read { id: 0 } => ("invalid id", 400),
        Request::Read { .. } => ("read", 200),
        Request::Write { bytes } if bytes > 1_024 => ("too large", 413),
        Request::Write { .. } => ("write", 202),
    }
}

fn main() {
    let requests = [
        Request::Health,
        Request::Read { id: 0 },
        Request::Read { id: 7 },
        Request::Write { bytes: 2_048 },
    ];

    for request in requests {
        let (label, status) = route(request);
        println!("{label}: {status}");
    }
}
health: 200
invalid id: 400
read: 200
too large: 413

如果新增 Request::Delete,此函数会因匹配不穷尽而停止编译。若用 _ 取代最后两个显式变体分支,这个提醒便会消失,这正是通配分支有时会削弱演进安全性的原因。

id: 0 是字段子模式,不会绑定 id。下一个分支用 .. 表示该变体的其余字段对当前决定无关,也不会承诺以后新增字段时读取它们。

用切片模式解析命令

split_first() 先把空输入从主路径排除,let-else 让后续代码直接使用 verbargsmatch 再根据动词和参数切片的长度同时检查命令形状。

commands.rs
#![allow(dead_code)]

#[derive(Debug)]
enum Command<'a> {
    Show { id: u32 },
    Tag { id: u32, label: &'a str },
}

fn parse_command<'a>(parts: &[&'a str]) -> Result<Command<'a>, String> {
    let Some((verb, args)) = parts.split_first() else {
        return Err(String::from("empty command"));
    };

    match (*verb, args) {
        ("show", [id]) => id
            .parse::<u32>()
            .map(|id| Command::Show { id })
            .map_err(|_| String::from("invalid id")),
        ("tag", [id, label @ ("urgent" | "normal")]) => id
            .parse::<u32>()
            .map(|id| Command::Tag { id, label })
            .map_err(|_| String::from("invalid id")),
        _ => Err(String::from("unknown command")),
    }
}

fn main() {
    for parts in [
        &[][..],
        &["show", "17"][..],
        &["tag", "17", "urgent"][..],
        &["tag", "17", "later"][..],
    ] {
        println!("{:?}", parse_command(parts));
    }
}
Err("empty command")
Ok(Show { id: 17 })
Ok(Tag { id: 17, label: "urgent" })
Err("unknown command")

[id] 只匹配恰好一个参数,[id, label] 只匹配恰好两个参数,因此额外参数不会被静默忽略。label @ ("urgent" | "normal") 同时限制允许值并保留命中的标签。

兜底分支在这里合理,因为输入字符串的状态空间开放,函数的契约就是把未支持的形状归为一种错误。它与封闭的内部枚举不同;是否使用 _ 取决于类型边界与错误策略。

先借用检查,再按值取出

inspect() 匹配 &Job,其中的字段绑定都是借用。主函数随后仍拥有 job,可以把它交给 take_owner(),在那里按值解构并移动 String 字段。

binding_modes.rs
struct Job {
    owner: String,
    retries: u8,
}

fn inspect(job: &Job) {
    match job {
        Job {
            owner,
            retries: attempts @ 1..=3,
        } => println!("retry {attempts} for {owner}"),
        Job { owner, retries: 0 } => println!("first attempt for {owner}"),
        Job { owner, retries } => println!("retry {retries} for {owner}"),
    }
}

fn take_owner(job: Job) -> String {
    let Job { owner, .. } = job;
    owner
}

fn main() {
    let job = Job {
        owner: String::from("Mina"),
        retries: 2,
    };

    inspect(&job);
    println!("owner: {}", take_owner(job));
}
retry 2 for Mina
owner: Mina

attempts @ 1..=3 既测试闭区间,也保留实际重试次数。因为匹配对象是共享引用,owner 不会移走字符串;inspect() 返回后,该借用结束。

take_owner() 消费整个 Job,所以移动 owner 符合函数契约。若调用方之后仍要使用 job,正确做法通常是让函数接收 &Job&str,而不是为了让旧签名编译就克隆整个值。

在循环中只处理连续成功值

while let 很适合消费返回 OptionResult 的状态型操作。这个例子逐个弹出字符串,let-else 跳过解析失败项,再用范围和守卫分类有效数字。

readings.rs
fn main() {
    let mut pending = vec!["101", "bad", "42", "7"];

    while let Some(text) = pending.pop() {
        let Ok(value) = text.parse::<i32>() else {
            println!("skip: {text}");
            continue;
        };

        match value {
            even @ 0..=100 if even % 2 == 0 => println!("even: {even}"),
            odd @ 0..=100 => println!("odd: {odd}"),
            outside => println!("outside: {outside}"),
        }
    }

    println!("empty: {}", pending.is_empty());
}
odd: 7
even: 42
skip: bad
outside: 101
empty: true

向量采用后进先出顺序,所以输出从 "7" 开始。解析失败通过 continue 满足 let-else 必须离开当前路径的要求,而不合法输入是否应跳过,应由调用方契约决定。

第一个分支的守卫只筛选偶数;第二个范围分支仍需接住区间内的奇数。把第二个分支删掉不会让第一个守卫代表整个 0..=100,编译器也不会把任意 % 条件纳入覆盖证明。

陷阱

深入 可反驳性不是运行时猜测

可反驳性不是运行时猜测

编译器从类型和模式形式判断一个模式是否可能失败。变量绑定、只有变量的元组解构以及字段齐全的单一结构体模式通常不可反驳;枚举变体、字面值、范围和限定长度的切片模式通常可反驳。

同一段模式换到不同语法位置,要求也随之变化。普通 let 没有失败分支,只接受不可反驳模式;if let 接受失败并选择是否运行代码块;let-else 接受失败,但要求失败路径离开,以保证后续绑定一定已初始化。

if let 使用不可反驳模式通常会产生警告,因为条件永远为真。它虽然可能编译,却隐藏了作者原本想测试什么;把它改成普通 let,或恢复真正可失败的子模式。

let-else 的作用域

let PATTERN = expression else { ... }; 成功后,模式创建的绑定进入后续外层作用域。这与 if let 不同,后者的绑定只存在于成功代码块中。

else 必须发散,才能保证控制流到达下一句时模式已经成功。返回错误、continue 当前循环或调用返回 ! 的函数都可以;只记录日志然后落回主路径不行。

复杂验证不必全部塞进一个模式。模式适合表达类型结构,业务谓词适合普通条件或返回 Result 的验证函数;边界分开后,失败原因也更容易保留。

匹配人体工程学与部分移动

模式作用于位置,而不仅是临时数值。读取 match value 时,分支可以取得 value 内部字段的所有权;读取 match &value 时,自动解引用让结构模式仍保持简洁,但默认绑定成为共享引用。

这种自动调整就是匹配人体工程学。它减少了层层 &ref,但不会改变底层所有权规则;不确定绑定类型时,可以加一个局部类型标注,或让编辑器显示推断类型,而不是根据变量拼写猜测。

按值模式可以混合复制与移动。若结构含 u32String,绑定前者会复制,绑定后者会移动;未移动的字段仍可单独访问,但对整个结构调用方法通常需要完整值,因此会被拒绝。

借用整个匹配对象通常可避免部分移动。另一种精确写法是在字段模式上用 ref nameref mut name,但它混合了多种绑定方式,审查者必须逐字段追踪;除非只需借用少数字段,否则优先让函数签名和匹配对象表达借用意图。

实现 Drop 的类型不能随意通过模式移出字段,因为析构函数需要接收完整的 &mut self。需要有意取出资源时,可消费整个值的专用方法,或把字段放进 Option 并用 take() 留下合法状态。

或模式、@ 绑定与守卫

或模式 left | right 表示候选集合的并集。所有候选必须创建相同绑定,否则分支表达式无法获得一组稳定的局部变量;绑定类型或借用方式不同也会被拒绝。

@ 的左侧创建绑定,右侧继续约束同一个匹配部分。id @ 1..=9 比仅写范围多保留了实际 id;嵌套结构中也能绑定整个子对象,同时检查其字段。

优先级容易误读。守卫写在整个分支模式之后,因此 A | B if ready 等价于 (A | B) if ready;若只有 B 需要 ready,应写成两个分支。

守卫为普通表达式,可以调用函数、读取外部状态,甚至产生副作用。副作用会让分支选择依赖求值时机,也会使测试更难;守卫最好保持短小且无副作用,复杂决策移到命名清楚的函数中。

穷尽性、可达性与 API 边界

穷尽性保证每个类型层面的可能值都能选到一个分支,不保证每个分支的业务行为正确。_ => Ok(()) 能通过检查,却可能把未处理事件报告成成功;覆盖完整和语义完整必须分别审查。

可达性从上到下计算。变量模式会匹配其类型的任何值,所以放在前面时尤其危险;宽范围也可能完全覆盖后面的窄范围。警告不应被机械禁止,因为它通常揭示模式顺序或命名错误。

公开 API 的枚举可使用 #[non_exhaustive] 要求其他 crate 的调用方保留兜底分支。此时通配分支是兼容性契约的一部分,但它仍应返回明确错误或保守行为,而不是默认 unreachable!()

对字符串、整数和外部协议字段,输入空间本来就是开放的。此类匹配需要安全兜底;对自己控制的封闭枚举,显式变体通常更能利用编译器帮助完成重构。

选择最窄但不丢信息的形式

match 最适合多个分支都影响结果,或枚举的每个变体都应得到明确处理。它也是表达状态转换的自然位置,因为输入状态与事件可以组成一个元组一起匹配。

if let 适合只关心一个形状,而且失败确实无需动作。添加 else 后若两边都很重要,match 往往更清楚,也能在类型变化时提供穷尽提醒。

let-else 适合把失败路径提前移走,让主路径保持平直。它不负责描述多个恢复策略;失败原因需要区分时,应匹配 Result 或使用 ? 保留错误信息。

while let 适合某个状态型操作连续成功时循环,例如反复 pop() 或接收通道消息。它会把首次不匹配解释为正常终止,所以错误与结束共享一个 Err 时,可能需要显式 match 才不会吞掉错误。

matches!(value, pattern if guard) 只返回布尔值,不提供绑定给后续代码。它适合过滤与断言;若紧接着还要从同一值中取数据,再匹配一次通常是重复工作,应直接使用 matchif let

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

前置内容 所有权 Option result 即将上线
下一篇 Pattern matching advanced 即将上线 借用规则 Error handling 即将上线 闭包
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?