Rust 中的 Enum 和 Pattern Matching

1. 概述

在许多编程语言中,enum 只是一个命名常量列表。在 Rust 中,enum 的每个变体 (variant) 还可以携带自己的数据。当与 match 结合使用时,编译器会强制你处理所有情况。

本文从 enum 的定义开始,依次讲解 match、guard、@ 绑定,然后是 Option、Result、if let、let else 和 matches!。读完之后,你可以用 enum 对业务状态建模,并避免常见的错误。

本文使用 Rust 1.65 或更高版本(需要用 let else),无需外部库。

2. 定义带数据的 Enum

假设你需要描述订单的状态:

#[derive(Debug, Clone, PartialEq)]
enum OrderStatus {
    Pending,
    Paid(u64),
    Shipped { carrier: String, tracking: String },
    Cancelled { reason: String },
}

fn main() {
    let statuses = vec![
        OrderStatus::Pending,
        OrderStatus::Paid(250_000),
        OrderStatus::Shipped {
            carrier: "GHN".to_string(),
            tracking: "GHN123".to_string(),
        },
        OrderStatus::Cancelled { reason: "库存不足".to_string() },
    ];

    for s in &statuses {
        println!("{:?}", s);
    }
}

这个 enum 有三种 variant:

  • Pending 是 unit variant,不携带数据。
  • Paid(u64) 是 tuple variant,按位置携带支付金额。
  • Shipped { .. } 和 Cancelled { .. } 是 struct variant,携带具有命名字段的数据。

#[derive(Debug, Clone, PartialEq)] 允许用 {:?} 打印值、克隆和使用 == 比较。在编写测试时需要这一点。

Pending
Paid(250000)
Shipped { carrier: "GHN", tracking: "GHN123" }
Cancelled { reason: "库存不足" }

关键点:一个 Pending 订单不可能“意外地”拥有追踪码。数据只存在于需要它的 variant 中。使用有许多 Option 字段的 struct 方法不能提供这种保证。

3. 基本 match 和完整性检查 (exhaustiveness)

match 按顺序将值与每个 pattern 进行匹配。第一个匹配的分支将被执行:

fn describe(status: &OrderStatus) -> String {
    match status {
        OrderStatus::Pending => "等待支付".to_string(),
        OrderStatus::Paid(amount) => format!("已支付 {amount} 越南盾"),
        OrderStatus::Shipped { carrier, tracking } => {
            format!("通过 {carrier} 配送,单号 {tracking}")
        }
        OrderStatus::Cancelled { reason } => format!("已取消:{reason}"),
    }
}

需要注意的几点:

  • status 的类型是 &OrderStatus。由于 default binding modes,你可以像处理常规值一样编写 pattern。amount、carrier、tracking 变量将是引用(&u64、&String),因此没有数据被 move。
  • match 是一个 表达式。所有分支必须返回相同的类型,这里是 String。

现在尝试删除 Cancelled 分支。编译器会拒绝构建:

error[E0004]: non-exhaustive patterns: `&OrderStatus::Cancelled { .. }` not covered
 --> src/main.rs:12:11
   |
12 |     match status {
   |           ^^^^^^ pattern `&OrderStatus::Cancelled { .. }` not covered

这是 match 的最大价值。当你添加新的 variant,比如 Refunded 时,所有尚未处理它的 match 都会报错。你不需要手动寻找需要修改的地方。

4. 高级 Pattern:tuple、|、guard、range 和 @

Pattern 不仅用于 enum。你可以 match 一个 tuple 来同时检查多个条件:

enum CustomerTier {
    Regular,
    Silver,
    Gold,
}

fn discount(tier: &CustomerTier, total: u64) -> u64 {
    match (tier, total) {
        (CustomerTier::Gold, _) => total / 10,
        (CustomerTier::Silver, t @ 1_000_000..) => t / 20,
        (CustomerTier::Silver | CustomerTier::Regular, t) if t >= 2_000_000 => 50_000,
        _ => 0,
    }
}

fn main() {
    println!("{}", discount(&CustomerTier::Gold, 300_000));
    println!("{}", discount(&CustomerTier::Silver, 1_200_000));
    println!("{}", discount(&CustomerTier::Regular, 2_500_000));
    println!("{}", discount(&CustomerTier::Regular, 100_000));
}

每个分支的工作方式如下:

  • _ 匹配任何值且不绑定任何变量。
  • t @ 1_000_000.. 检查 total 是否在从 1 百万及以上的范围内,同时将该值绑定到 t。
  • CustomerTier::Silver | CustomerTier::Regular 使用 | 将多个 pattern 合并到一个分支中。
  • if t >= 2_000_000 是 match guard。这是一个额外的条件,仅在 pattern 匹配后才进行评估。
  • _ => 0 是默认分支。由于 u64 有无穷多个值,这个分支是必需的。
30000
60000
50000
0

对于 struct variant,你可以使用 .. 来忽略不关心的字段:

fn shipping_fee(status: &OrderStatus) -> u64 {
    match status {
        OrderStatus::Shipped { carrier, .. } if carrier == "GHN" => 22_000,
        OrderStatus::Shipped { .. } => 30_000,
        _ => 0,
    }
}

注意: 编译器在检查完整性时 不会 使用 guard。如果你只写带 guard 的 Shipped { .. } if ... 分支,但没有不带 guard 的 Shipped 分支,你仍然需要添加其他分支来覆盖所有情况。

5. Option 和 Result 也只是 Enum

标准库定义了 Option<T>(包含 Some(T) 和 None)和 Result<T, E>(包含 Ok(T) 和 Err(E))。由于它们是 enum,你可以用上面的工具来处理它们:

fn find_customer(id: u32) -> Option<&'static str> {
    match id {
        1 => Some("张三"),
        2 => Some("李四"),
        _ => None,
    }
}

fn parse_quantity(input: &str) -> Result<u32, String> {
    match input.trim().parse::<u32>() {
        Ok(0) => Err("数量必须大于 0".to_string()),
        Ok(n) => Ok(n),
        Err(e) => Err(format!("无效:{e}")),
    }
}

fn main() {
    println!("{:?}", find_customer(2));
    println!("{:?}", find_customer(9));
    println!("{:?}", parse_quantity(" 3 "));
    println!("{:?}", parse_quantity("0"));
    println!("{:?}", parse_quantity("abc"));
}

Ok(0) 是嵌套 pattern。它在结果是 Ok 且 内部值等于 0 时匹配。由于 Ok(0) 在 Ok(n) 之前,数字 0 在到达通用分支之前被拦截。

Some("李四")
None
Ok(3)
Err("数量必须大于 0")
Err("无效:invalid digit found in string")

Rust 中没有 null。所有“可能不存在”的值都必须通过 Option,编译器强制你处理 None 情况。

6. 简洁语法:if let、let else、while let、matches!

当你只关心一个 variant 时,编写完整的 match 会很冗长。Rust 为这种情况提供了一些更简洁的语法。

6.1. if let

fn tracking_code(status: &OrderStatus) -> Option<&str> {
    if let OrderStatus::Shipped { tracking, .. } = status {
        Some(tracking.as_str())
    } else {
        None
    }
}

if let 在 pattern 匹配时执行代码块。else 部分是可选的。此语法不要求你完全处理所有情况,因此仅当其他 variant 真的不重要时才使用它。

6.2. let else

let else 适合“早期返回”模式:如果匹配则获取数据,否则立即退出。

fn charge(status: &OrderStatus) -> Result<u64, String> {
    let OrderStatus::Paid(amount) = status else {
        return Err("订单未支付".to_string());
    };
    Ok(*amount)
}

在 let 行之后,变量 amount 可在函数的其余部分使用,而无需额外嵌套。else 块 必须 通过 return、break、continue 或 panic! 退出当前流程。

6.3. while let 和 matches!

fn main() {
    let mut queue = vec![
        OrderStatus::Paid(100_000),
        OrderStatus::Pending,
        OrderStatus::Paid(400_000),
    ];

    let paid = queue
        .iter()
        .filter(|s| matches!(s, OrderStatus::Paid(_)))
        .count();
    println!("已支付订单数:{paid}");

    while let Some(status) = queue.pop() {
        println!("处理:{status:?}");
    }
}
  • matches!(值, pattern) 返回 bool。这个宏在 filter 或 assert! 中非常方便。
  • while let Some(status) = queue.pop() 循环直到 pop() 返回 None。
已支付订单数:2
处理:Paid(400000)
处理:Pending
处理:Paid(100000)

7. 为 Enum 实现方法:状态转换建模

Enum 也有像 struct 一样的 impl。一个很好的用法是表示 有效的状态转换:

impl OrderStatus {
    fn ship(self, carrier: &str, tracking: &str) -> Result<OrderStatus, String> {
        match self {
            OrderStatus::Paid(_) => Ok(OrderStatus::Shipped {
                carrier: carrier.to_string(),
                tracking: tracking.to_string(),
            }),
            other => Err(format!("无法在状态 {other:?} 下发货")),
        }
    }

    fn cancel(self, reason: &str) -> Result<OrderStatus, String> {
        match self {
            OrderStatus::Pending | OrderStatus::Paid(_) => Ok(OrderStatus::Cancelled {
                reason: reason.to_string(),
            }),
            other => Err(format!("无法在状态 {other:?} 下取消订单")),
        }
    }

    fn is_final(&self) -> bool {
        matches!(self, OrderStatus::Cancelled { .. })
    }
}

需要注意的几点:

  • ship 和 cancel 接收 按值传递的 self,所以旧状态被消费。转换后你不能继续使用旧的状态变量。
  • other => ... 分支将剩余的所有值绑定到变量 other 以包含在错误消息中。
  • is_final 是只读的,所以接收 &self。

8. 为主要行为编写测试

Rust 有内置的测试框架。你在同一文件中将测试放在 #[cfg(test)] 模块中:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn paid_order_can_be_shipped() {
        let result = OrderStatus::Paid(250_000).ship("GHN", "GHN123");

        assert_eq!(
            result,
            Ok(OrderStatus::Shipped {
                carrier: "GHN".to_string(),
                tracking: "GHN123".to_string(),
            })
        );
    }

    #[test]
    fn pending_order_cannot_be_shipped() {
        let result = OrderStatus::Pending.ship("GHN", "X");

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Pending"));
    }

    #[test]
    fn cancelled_order_is_final() {
        let status = OrderStatus::Pending.cancel("顾客改变主意").unwrap();

        assert!(status.is_final());
        assert!(matches!(status, OrderStatus::Cancelled { ref reason } if reason == "顾客改变主意"));
    }
}

可以使用 assert_eq! 因为 enum 已经 derive 了 PartialEq 和 Debug。当测试失败时,Rust 会打印出预期值和实际值。在最后的测试中,ref reason 按引用绑定字段,因此 status 不会被移动到 matches! 中。

cargo test
running 3 tests
test tests::cancelled_order_is_final ... ok
test tests::paid_order_can_be_shipped ... ok
test tests::pending_order_cannot_be_shipped ... ok

test result: ok. 3 passed; 0 failed; 0 ignored

9. 常见错误和何时不应该使用

滥用 _ 会失去完整性检查。 如果你为自己管理的 enum 写 _ => ...,以后添加新 variant 时编译器会保持沉默。新 variant 会直接落入默认分支,这通常不是你想要的。对于业务 enum,请明确列出每个 variant。

从引用中移出数据。 写 match *status { OrderStatus::Cancelled { reason } => ... } 会导致错误:

error[E0507]: cannot move out of `status` as enum variant `Cancelled` which is behind a shared reference

有三种方法可以修复:直接 match status(引用)、使用 ref reason,或者如果你真的需要拥有它就 clone 数据。

将通用分支放在特定分支之前。 如果 Ok(n) 在 Ok(0) 之前,Ok(0) 分支永远不会被执行。编译器只会警告 unreachable pattern 而不是报错,所以你容易漏掉。

Variant 太大。 enum 的大小等于最大 variant 的大小加上标签部分。如果一个 variant 包含大数组,enum 的每个值都会消耗那么多内存。用 Box<...> 包装那个 variant。Lint clippy::large_enum_variant 会提醒你这一点。

注意: 如果你在编写库并想在以后添加 variant 而不破坏用户代码,用 #[non_exhaustive] 标记 enum。然后,其他 crate 中的代码必须有 _ 分支。

何时不应该使用 enum? Enum 适合用于案例集合是 闭合的 且由你控制的情况,例如订单状态或错误类型。如果类型集合是 开放的,例如库用户需要添加新的支付方法,请使用 trait(Box<dyn PaymentMethod> 或泛型)。添加 variant 需要修改原始 enum,而 trait 允许从外部扩展。

10. 结论

Rust 中的 enum 允许每种情况携带其正确的数据。match 强制你在编译时处理所有情况。你已经了解了:

  • Unit、tuple 和 struct variant。
  • match 与 binding、|、guard、range、@ 和 ..。
  • Option 和 Result 作为常规 enum。
  • 简洁语法 if let、let else、while let、matches!。
  • 如何在 enum 上使用方法来建模状态转换,包括测试。

最容易记住的规则:对于你拥有的 enum,请列出所有 variant 而不是使用 _。这样,每次添加新 variant 时,编译器都会指出你需要修改的所有地方。

rustenumpattern matchingmatchoption