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 时,编译器都会指出你需要修改的所有地方。