enum 与模式匹配

11 分钟
本页目录

enum 表达多个可能形态中的一个,每个变体可携带不同数据。相比一个状态数字加一组可空字段,它能让“哪些字段在这个状态存在”直接进入类型。

Disconnected无连接参数Connectingaddress: StringConnectedpeer: String,session: u64把不同状态的数据放进对应变体每个值只处于一个变体;类型排除无意义的字段组合,但状态转换规则仍需实现。

状态和数据一起定义

enum Connection {
    Disconnected,
    Connecting { address: String },
    Connected { peer: String, session: u64 },
}
fn describe(state: &Connection) -> String {
    match state {
        Connection::Disconnected => "offline".to_owned(),
        Connection::Connecting { address } => format!("connecting to {address}"),
        Connection::Connected { peer, session } => format!("{peer}, session {session}"),
    }
}
fn main() {
    let states = [
        Connection::Disconnected,
        Connection::Connecting { address: "localhost".into() },
        Connection::Connected { peer: "localhost".into(), session: 7 },
    ];
    assert_eq!(describe(&states[0]), "offline");
    assert!(describe(&states[1]).contains("connecting"));
    assert!(describe(&states[2]).contains("session 7"));
}

现在 Disconnected 不会意外携带一个未清理的 session 字段。但 enum 本身不禁止程序从任意状态跳到另一个状态;若要限制转换,仍需封装字段、方法和构造入口。

“带标签联合体”是有用的语义模型,不保证物理存储一定有独立 tag。实际大小还受最大负载、对齐和 niche 影响,不能机械相加,更不能忽略 String 等拥有类型的字段尺寸。

从字段组合推导类型,而不是先写一组 Option

如果用 status: u8、address: Option<String> 和 session: Option<u64> 表达三个状态,字段组合还可能出现“已连接却没有 session”“离线但残留 session”等情形。enum 将每个形态允许的数据直接收进变体,减少调用方反复确认的条件。

这常被称为和类型:值处于多个候选之一。struct 则将字段组合在一起,常称积类型。有限集合的简单例子中,两个 bool 有 2×2=4 种组合;一个 Pending | Finished(bool) 有 1+2=3 种形态。这里计算的是语义取值,不是布局字节数。

建模选择适合表达额外约束
Option正常存在或缺失缺失原因通常不细分
Result<T, E>成功结果或可报告错误失败类别由 E 设计
自定义 enum各状态有不同负载转换、资源归属和不变量
多个独立字段条件确实可独立组合不要人为禁止合法组合

组合中哪些状态根本不该存在?

表格只看负载是否存在:三个状态乘以两个可空字段的四种组合,共有 12 种形状,其中本例只需要 3 种。选择任意组合,观察 enum 是如何让多余的形状无法表示的。这里的数量不是对象大小,也不是包含全部字符串取值后的状态总数。

正在呈现知识画面
组合中哪些状态根本不该存在?

普通字段产生 12 种形状;此处 enum 只允许离线、连接中、已连接三种负载形状。

状态转换:消费旧值,并明确失败后谁拥有它

DrafttextQueuedtext + ticketDonereceiptSubmitComplete文本非空;完成事件的 ticket 必须匹配当前任务非法转换返回原状态和原因;字段组合合法不等于任意状态跃迁都合法。状态形态由 enum 表达,允许的转换由函数检查

下面的转换函数按值接收 Job。成功时旧状态被消费,数据移动进新状态;失败时返回原状态和错误,调用方可以继续编辑或记录,不必为了失败路径复制 String。完成事件还携带 ticket,避免旧任务的完成事件误推进当前任务。

#[derive(Debug, PartialEq)]
enum Job {
    Draft { text: String },
    Queued { text: String, ticket: u64 },
    Done { receipt: String },
}
enum Event {
    Submit { ticket: u64 },
    Complete { ticket: u64, receipt: String },
}
fn advance(job: Job, event: Event) -> Result<Job, (Job, &'static str)> {
    match (job, event) {
        (Job::Draft { text }, Event::Submit { ticket }) if !text.trim().is_empty() => {
            Ok(Job::Queued { text, ticket })
        }
        (Job::Queued { ticket, .. }, Event::Complete { ticket: received, receipt })
            if ticket == received => Ok(Job::Done { receipt }),
        (previous, _) => Err((previous, "invalid transition")),
    }
}
fn main() {
    let draft = Job::Draft { text: "report".into() };
    let queued = advance(draft, Event::Submit { ticket: 7 }).unwrap();
    let (queued, reason) = advance(queued, Event::Complete {
        ticket: 6, receipt: "stale".into(),
    }).unwrap_err();
    assert_eq!(reason, "invalid transition");
    assert!(matches!(&queued, Job::Queued { ticket: 7, .. }));
    let done = advance(queued, Event::Complete {
        ticket: 7, receipt: "stored".into(),
    }).unwrap();
    assert_eq!(done, Job::Done { receipt: "stored".into() });
    let empty = Job::Draft { text: String::new() };
    assert!(advance(empty, Event::Submit { ticket: 8 }).is_err());
}

这里的 guard 在决定分支是否选中时检查条件,条件失败仍可交给后面的匹配分支。成功完成时不再需要 text,于是旧文本随被消费的状态释放;如果业务还需要审计全文,应把它保存在新状态或外部记录中,不能期望 enum 自动保留。

这个函数保证所列的局部转换规则,但没有执行外部任务、验证 ticket 的全局唯一性或持久化结果。若 Job 及其变体构造全部公开,其他代码仍能绕过函数直接构造 Done;对外强制入口需要模块封装。若希望编译期禁止对 Draft 调用完成操作,可以进一步使用不同类型的 typestate 接口,代价是运行时状态集合和异构容器处理更复杂。

match 对值的可能形态做穷尽检查

没有兜底的穷尽 match 会在新增变体后提示补充分支。已经使用 _ 的 match 则可以继续编译,因此不能说所有匹配处都会因新变体报错。公共 non_exhaustive 枚举又要求下游为将来扩展保留匹配方式。

match 按源码顺序寻找匹配分支。guard 是模式成功后额外检查的布尔条件;编译器一般不会把任意 guard 的逻辑当作完整覆盖证明。match

fn classify(input: Option<i32>) -> &'static str {
    match input {
        Some(value @ 1..=5) if value % 2 == 1 => "small odd",
        Some(_) => "other number",
        None => "missing",
    }
}
fn main() {
    assert_eq!(classify(Some(3)), "small odd");
    assert_eq!(classify(Some(2)), "other number");
    assert_eq!(classify(None), "missing");
}

@ 同时保存匹配到的值并检查子模式。if let 适合只处理一个形态,while let 适合重复取同一类结果;忽略其余情况应是接口意图,不能把语法简短当作错误处理已完整。

穷尽性是覆盖证明,不是业务正确性证明

Some(x) if x > 0 与 Some(x) if x <= 0 看起来覆盖所有 i32,但编译器不会普遍证明任意 guard 的互补关系,仍需要相应的无 guard 覆盖分支。一个 _ => {} 能让编译通过,却可能把新增的重要状态静默忽略。

or-pattern 的每个分支必须绑定相同名字、兼容类型和绑定模式,例如 Some(1 | 2) 只测试值,而 Ok(value) | Err(value) 还要求两边的 value 类型一致。@ 则在检查子模式的同时绑定值,不是复制一个任意大小的数据副本。

fn payload_len(value: Result<&str, &str>) -> usize {
    match value {
        Ok(text) | Err(text) => text.len(),
    }
}
fn command(input: &[u8]) -> Option<(&[u8], u8)> {
    match input {
        [0x7e, payload @ .., checksum] => Some((payload, *checksum)),
        _ => None,
    }
}
fn main() {
    assert_eq!(payload_len(Err("bad")), 3);
    assert_eq!(command(&[0x7e, 1, 2, 3]), Some((&[1, 2][..], 3)));
    assert_eq!(command(&[0x7e, 3]), Some((&[][..], 3)));
    assert_eq!(command(&[0x7e]), None);
}

切片模式示例只检查形状和开头标记,没有真的验证 checksum。模式擅长描述结构,业务关系仍要显式计算。普通 let 要求不可反驳模式;可能不匹配的 Some(x) 则适合 match、if let 或 let-else。

模式绑定可能移动,也可能借用

fn main() {
    let item = Some(String::from("hello"));
    match &item {
        Some(text) => assert_eq!(text.len(), 5), // text: &String
        None => unreachable!(),
    }
    let Some(text) = item else { return; }; // 此处移动 String
    assert_eq!(text, "hello");
}

对 &item 匹配时,匹配人体工学规则让内层绑定借用字段;对 item 按值解构非 Copy 字段则转移所有权。let-else 要求 else 分支发散,例如 return、break 或 panic,使后续路径能依赖模式已成功。

模式中的 _ 不创建绑定;_name 仍创建变量,可以移动并拥有值。Rust 2024 对某些隐式借用模式中再显式写 ref/mut 的组合有更严格规则,清楚选择匹配 &value 还是 value 通常更易读。模式

把被匹配表达式与绑定类型一起看

输入与写法text 的类型/行为后续是否保留原容器
match item,Some(text)String 按值移动已被移动部分不能再照常使用
match &item,Some(text)&String保留,借用期间遵守共享规则
match &mut item,Some(text)&mut String保留,允许经借用修改字段
match item.as_deref()Option<&str> 的内层 &str保留,不转移字符串所有权
item.take()取走 Option 的原值原位置成为 None

mut text 让绑定可重新赋值,不等价于“借到 &mut String”;ref mut text 才是在相应按值模式上下文中请求可变借用。Rust 2024 限制某些默认借用模式下再写 ref/mut 的组合,优先显式选择匹配 &item 或 &mut item,让输入层次清楚。Rust 2024 匹配规则

fn main() {
    let mut item = Some(String::from("hello"));
    if let Some(text) = &mut item {
        text.push('!');
    }
    assert_eq!(item.as_deref(), Some("hello!"));
    let taken = item.take().unwrap();
    assert_eq!(taken, "hello!");
    assert!(item.is_none());

    let ignored = Some(String::from("still here"));
    if let Some(_) = ignored {
        assert_eq!(ignored.as_deref(), Some("still here"));
    }
    // Some(_) 没有绑定并移动 String;Some(_text) 则会绑定该字段。
    assert!(ignored.is_some());
}

不能通过 &mut Option<String> 直接把 String 搬走并留一个未初始化洞,take() 通过放入合法的 None 解决这一点。对其他类型可研究 mem::replace,但替代值应是业务允许的状态,不能只为绕过借用错误发明一个无意义状态。Option::take

部分移动也需要区分“整个值还能否使用”和“未移动字段还能否使用”。从没有自定义 Drop 的 struct 中移动一个 String 字段后,其他未移动字段可能仍能访问;对整个 struct 进行借用或调用依赖完整值的方法则不同。含自定义 Drop 的类型还有不能直接移出其字段的限制。

Option 与 Result 是常用的枚举协议

普通引用不能为 null,但 Rust 的裸指针允许空值,所以“Rust 没有 null”过度概括。Option 让正常安全接口表达缺失,调用者仍可选择 unwrap 并在 None 时 panic,类型不会自动替人选择恢复方案。

fn safe_div(a: i32, b: i32) -> Option<i32> { a.checked_div(b) }
fn main() {
    assert_eq!(safe_div(10, 2), Some(5));
    assert_eq!(safe_div(10, 0), None);
    assert_eq!(safe_div(i32::MIN, -1), None);
}

只检查 b == 0 会漏掉最小有符号整数除以 -1 的溢出。若调用者需要区分两种失败,就应返回带类别的 Result,而不是把所有原因压成 None。

Result 把可报告的失败纳入函数返回类型,不保证内部无 panic 或没有外部副作用。错误传播与组合方法见 Result 与 Option。

公共 enum 的演进与 wire 解码

#[non_exhaustive] 主要约束定义 crate 之外的使用;crate 内仍能完整匹配自己当前定义的变体。下游需要预留未知变体分支,但这不是运行时把任意整数变成合法 enum 的许可。non_exhaustive

如果 wire 状态码允许将来增加,可把未知数值保存为明确的 Unknown(u8)。这与 Rust enum 的非法 discriminant 完全不同:Unknown 是已经定义的合法变体,携带原始码供日志、转发或后续处理。

#[derive(Debug, PartialEq)]
enum Status { Ready, Busy, Unknown(u8) }
impl From<u8> for Status {
    fn from(code: u8) -> Self {
        match code {
            1 => Self::Ready,
            2 => Self::Busy,
            other => Self::Unknown(other),
        }
    }
}
fn main() {
    assert_eq!(Status::from(1), Status::Ready);
    assert_eq!(Status::from(2), Status::Busy);
    assert_eq!(Status::from(255), Status::Unknown(255));
}

这个接口选择保留未知码,但需要理解才能执行的操作仍应拒绝。对于不允许未知值的输入,可实现 TryFrom 并返回错误。不要用 transmute 将任意字节解释为有限变体 enum;布局大小相同不能证明 discriminant 有效。

enum 与 match 的组合把形态检查、字段访问和所有权转移放在一起。使用时依次确认:哪些状态需要不同数据;哪些转换允许;分支是否显式覆盖重要情况;绑定是在移动还是借用;未知输入如何进入合法类型。这样既便于快速定位语法,也能保持模型与业务行为一致。