用类型表达:没有 null,也没有异常

模式匹配:match 不只是 switch

它在解决什么

上一篇说 enum 能把「有哪几种情况」写进类型。 但把情况写进类型只是一半 —— 另一半是每次处理它的时候,编译器都逼你 把每种情况都考虑到。

这一半由 match 承担。它和 switch 长得像,实际差三件事:

  1. 它是表达式,有值,可以直接 let x = match ...。
  2. 它穷尽,漏一种情况编译不过。
  3. 它的分支不是常量比较,是模式 —— 能解构、能带条件、能绑定。

穷尽性:enum 的价值在这里兑现

漏掉一个变体就是 E0004,而且编译器会把漏掉的那个 变体的名字告诉你。

这条规则真正的价值不在写的时候,在改的时候:给 enum 加一个变体, 所有没考虑它的 match 会当场全部报错,编译器替你列出了待办清单。 在用整数常量 + switch 的语言里,这件事只能靠搜索和记性。

🚨 但一个 _ 就能把它作废 —— 而且是安静地

这两段代码都编译通过,差别只在输出:

match s {
    Status::Draft => "草稿",
    _ => "其他",              // ← 把 Published 和 Archived 都吞了
}

想象这个 _ 是半年前写的,而 Archived 是上周加的。 编译器一句话都不会说 —— 新变体安静地落进了「其他」, 而上面那条「加变体会让所有地方报错」的保证,在这个文件里已经不存在了。

⇒ 判据:_ 只用在「未来新增的都该走这条」确实成立的地方。

典型的成立场景是匹配外部数据(HTTP 状态码、用户输入的字符串)—— 那些东西本来就是开放集合。而匹配自己定义的 enum 时, 多写两行远比丢掉穷尽性划算。

📌 有个折中写法值得知道:把兜底分支写成 other => ... 而不是 _ => ... 并不能救回穷尽性,但至少下次读代码的人能看到那个值被用到了哪里。 真正想保留穷尽性又不想写全,只能老实列。

解构可以一层层嵌下去

模式不是「一个值」,是「一个形状」。enum 变体里的 struct 字段里的字面量, 一次匹配完:

Shape::Circle { center: Point { x: 0, y: 0 }, r } => format!("原点圆,半径 {r}"),

这一行同时做了四件事:判断是不是 Circle、判断 center 的 x 和 y 是不是 0、 把 r 绑出来。在别的语言里这是四个 if 加两次取字段。

.. 表示「剩下的字段不关心」:Point { x: x1, .. } 只要 x。

分支顺序有意义

match 从上往下,第一个匹配上的赢。

把更具体的分支挪到宽的后面, 它就永远轮不上了 —— 而这件事只是一条警告,不是错误: 对照项照常编译通过,只是输出变了。

⇒ 写法上的习惯:从最具体排到最宽泛。发现某个分支好像没生效时, 先往上看有没有更宽的分支把它截了。

守卫:模式管形状,if 管取值

模式里表达不了的条件(比较、取模、调方法)放进守卫:

x if x < 0 => "负数",

⚠️ 守卫不参与穷尽性检查。 编译器不会去证明 x < 0 和 x % 2 == 0 合起来覆盖了所有整数 —— 它做不到, 所以带守卫的 match 通常还是需要一个兜底分支。

这不是缺陷,是能力边界:一旦条件里可以写任意代码,穷尽性就变成了 不可判定的问题。知道这条边界在哪,比记住语法有用。

@:既要判断范围,又要拿到值

范围模式 1..=9 能判断,但判断完那个值就没名字了。 @ 让你一边判断一边起名:

small @ 1..=9 => format!("个位数 {small}"),

去掉绑定之后想用那个名字,就是 E0425。

if let 和 let ... else 各自的位置

match 写全所有分支有时是杀鸡用牛刀。两个简写各有各的场景:

if let —— 只关心一种情况,而且后续逻辑就在那个花括号里:

if let Some(name) = config.name {
    println!("{name}");
}

let ... else —— 只关心一种情况,但后续逻辑在花括号外面:

let Ok(n) = s.parse::<u16>() else {
    return 8080;
};
n            // ← n 在这里仍然可用

⭐ 两者的区别就是这一点:if let 把绑出来的名字困在块里, 于是「解析失败就返回默认值」这种写法会被迫嵌套下去; let ... else 把失败分支提前送走,主线保持平铺。

⚠️ 所以 else 块必须发散 —— return、break、continue、panic! 都行, 但不能返回一个值。对照项返回 8080 就编译不过。 道理很直白:如果 else 能正常走完,后面那个 n 就没有值可用了。

⇒ 一条实用的判据:写 if let 时如果发现自己在往右缩进, 多半应该换成 let ... else。

下一步

到这里,enum 和 match 这套组合已经完整了。接下来看标准库怎么用它 —— 见《Option:没有 null 怎么活》: 它就是一个只有两个变体的普通 enum,而它替掉了 null 这个 「价值十亿美元的错误」。

全部篇目见 Rust 教程首页。

本篇示例

下面每一条都是完整的、能单独编译的程序,由npm run test:rust 在每次构建前用真的 rustc 跑一遍。 「编译不过、报 E0382」这种话在这里是被验证过的断言。 报错原文和对照项的结果由同一道闸门自动回写,会随工具链更新,但不作为断言。

穷尽性:漏一个变体就编译不过

enum Status {
    Draft,
    Published,
    Archived,
}

fn label(s: &Status) -> &'static str {
    match s {
        Status::Draft => "草稿",
        Status::Published => "已发布",
        Status::Archived => "已归档",
    }
}

fn main() {
    println!(
        "{} {} {}",
        label(&Status::Draft),
        label(&Status::Published),
        label(&Status::Archived)
    );
}

编译通过 · 输出 "草稿 已发布 已归档\n"

对照:删掉 `Archived` 那个分支
enum Status {
    Draft,
    Published,
    Archived,
}

fn label(s: &Status) -> &'static str {
    match s {
        Status::Draft => "草稿",
        Status::Published => "已发布",
    }
}

fn main() {
    println!(
        "{} {} {}",
        label(&Status::Draft),
        label(&Status::Published),
        label(&Status::Archived)
    );
}

编译不过:error[E0004]

对照项报 `E0004: non-exhaustive patterns`,而且编译器会**把漏掉的那个变体名字告诉你**。这是 enum 相对「一堆常量」的核心优势:加一个变体,所有没考虑它的地方当场全部报错。

🚨 一个 `_` 就能把穷尽性作废,而且是**安静地**

enum Status {
    Draft,
    Published,
    Archived,
}

fn label(s: &Status) -> &'static str {
    match s {
        Status::Draft => "草稿",
        _ => "其他",
    }
}

fn main() {
    println!("{} {}", label(&Status::Published), label(&Status::Archived));
}

编译通过 · 输出 "其他 其他\n"

对照:把 `_` 换成逐个写全的分支
enum Status {
    Draft,
    Published,
    Archived,
}

fn label(s: &Status) -> &'static str {
    match s {
        Status::Draft => "草稿",
        Status::Published => "已发布",
        Status::Archived => "已归档",
    }
}

fn main() {
    println!("{} {}", label(&Status::Published), label(&Status::Archived));
}

编译通过,输出:"已发布 已归档\n"

⭐ 两边**都编译通过**,差别只在输出:带 `_` 的版本把两个本该各有说法的变体都塞进了「其他」。想象这个 `_` 是半年前写的、而 `Archived` 是上周加的 —— 编译器一句话都不会说。⇒ 判据:**`_` 只用在「未来新增的都该走这条」确实成立的地方**,否则宁可逐个写。

解构可以嵌套 —— 但分支顺序有意义

struct Point { x: i32, y: i32 }

enum Shape {
    Circle { center: Point, r: i32 },
    Line(Point, Point),
}

fn describe(s: &Shape) -> String {
    match s {
        Shape::Circle { center: Point { x: 0, y: 0 }, r } => format!("原点圆,半径 {r}"),
        Shape::Circle { center, r } => format!("圆心 ({}, {}),半径 {r}", center.x, center.y),
        Shape::Line(Point { x: x1, .. }, Point { x: x2, .. }) => format!("横跨 {x1} 到 {x2}"),
    }
}

fn main() {
    println!("{}", describe(&Shape::Circle { center: Point { x: 0, y: 0 }, r: 5 }));
    println!("{}", describe(&Shape::Line(Point { x: 1, y: 2 }, Point { x: 9, y: 2 })));
}

编译通过 · 输出 "原点圆,半径 5\n横跨 1 到 9\n"

对照:把「原点圆」那个**更具体**的分支挪到后面
struct Point { x: i32, y: i32 }

enum Shape {
    Circle { center: Point, r: i32 },
    Line(Point, Point),
}

fn describe(s: &Shape) -> String {
    match s {
        Shape::Circle { center, r } => format!("圆心 ({}, {}),半径 {r}", center.x, center.y),
        Shape::Circle { center: Point { x: 0, y: 0 }, r } => format!("原点圆,半径 {r}"),
        Shape::Line(Point { x: x1, .. }, Point { x: x2, .. }) => format!("横跨 {x1} 到 {x2}"),
    }
}

fn main() {
    println!("{}", describe(&Shape::Circle { center: Point { x: 0, y: 0 }, r: 5 }));
    println!("{}", describe(&Shape::Line(Point { x: 1, y: 2 }, Point { x: 9, y: 2 })));
}

编译通过,输出:"圆心 (0, 0),半径 5\n横跨 1 到 9\n"

模式可以一层层嵌下去:enum 变体里的 struct 字段里的字面量,一次匹配完。`..` 表示「剩下的字段不关心」。⚠️ 对照项**编译通过**(只有一条 unreachable 警告),但输出变了 —— **从上往下第一个匹配上的分支赢**,把宽的放前面会让窄的永远轮不上。

守卫:模式管形状,`if` 管取值

fn classify(n: i32) -> &'static str {
    match n {
        x if x < 0 => "负数",
        0 => "零",
        x if x % 2 == 0 => "正偶数",
        _ => "正奇数",
    }
}

fn main() {
    println!("{} {} {} {}", classify(-1), classify(0), classify(4), classify(7));
}

编译通过 · 输出 "负数 零 正偶数 正奇数\n"

对照:去掉 `if x % 2 == 0` 这个守卫
fn classify(n: i32) -> &'static str {
    match n {
        x if x < 0 => "负数",
        0 => "零",
        x => "正偶数",
        _ => "正奇数",
    }
}

fn main() {
    println!("{} {} {} {}", classify(-1), classify(0), classify(4), classify(7));
}

编译通过,输出:"负数 零 正偶数 正偶数\n"

守卫是模式之外的一个布尔条件,模式里表达不了的(比较、取模、调方法)都放这儿。⚠️ **守卫不参与穷尽性检查** —— 编译器不会去证明 `x < 0` 和 `x % 2 == 0` 合起来覆盖了所有情况,所以带守卫的 match 通常还是需要一个兜底分支。

`@` 绑定:既要判断范围,又要拿到值

fn check(n: i32) -> String {
    match n {
        small @ 1..=9 => format!("个位数 {small}"),
        big @ 10..=99 => format!("两位数 {big}"),
        other => format!("其他 {other}"),
    }
}

fn main() {
    println!("{} {} {}", check(5), check(42), check(1000));
}

编译通过 · 输出 "个位数 5 两位数 42 其他 1000\n"

对照:把 `small @ 1..=9` 里的绑定去掉,只留 `1..=9`
fn check(n: i32) -> String {
    match n {
        1..=9 => format!("个位数 {small}"),
        big @ 10..=99 => format!("两位数 {big}"),
        other => format!("其他 {other}"),
    }
}

fn main() {
    println!("{} {} {}", check(5), check(42), check(1000));
}

编译不过:error[E0425]

`1..=9` 这样的范围模式能判断,但判断完那个值就没名字了。`@` 的作用就是**一边判断一边起名**。对照项报 `E0425`:找不到叫 `small` 的东西。

`let ... else`:不匹配就走人

fn parse_port(s: &str) -> u16 {
    let Ok(n) = s.parse::<u16>() else {
        return 8080;
    };
    n
}

fn main() {
    println!("{} {}", parse_port("3000"), parse_port("not a number"));
}

编译通过 · 输出 "3000 8080\n"

对照:把 `else` 里的 `return 8080;` 改成一个值 `8080`
fn parse_port(s: &str) -> u16 {
    let Ok(n) = s.parse::<u16>() else {
        8080
    };
    n
}

fn main() {
    println!("{} {}", parse_port("3000"), parse_port("not a number"));
}

编译不过:error[E0308]

`let ... else` 解决的是「匹配上就继续用,匹配不上就提前退出」这个极常见的形状 —— 关键在于 `n` 在 `else` **之后**仍然可用,不像 `if let` 那样把它困在花括号里。⚠️ `else` 块必须**发散**(`return` / `break` / `continue` / `panic!`),对照项返回一个值就编译不过。