别小看Rust枚举:一个enum顶半个设计模式

Rust枚举完整指南 Rust的enum,远不止"几个常量"那么简单

你可能一直在"委屈"Rust的enum

写一个函数,参数只有几种可能的值。比如方向:上、下、左、右。你怎么表示?

用字符串?"up""down""left""right"

那传个"Up"进去行不行?"LEFT"呢?"dowm"打错字了呢?编译器不管,运行时才炸。这种bug藏得深,查起来要命。

如果你之前写过C或者Java,你可能会说:这不就是枚举嘛,一组固定的常量值,我知道的。

但Rust的枚举完全不是这回事。

Rust的enum更像是一把瑞士军刀。它不光能列出"上下左右"这几个选项,还能让每个选项携带不同类型的数据,驱动状态机,替代null。Rust官方文档 管它叫"代数数据类型"(Algebraic Data Types),熟悉函数式编程的话你应该不陌生。

给变量画一个"围栏"

假设你在做一个游戏,角色可以朝四个方向移动:

enum Direction {
    Up,
    Down,
    Left,
    Right,
}

就像去餐厅点饮料,菜单上写着可乐、雪碧、橙汁、矿泉水。你点一杯"随便",服务员不认。

fn main() {
    let go = Direction::Up;

    match go {
        Direction::Up => println!("往上走!"),
        Direction::Down => println!("往下走!"),
        Direction::Left => println!("往左转。"),
        Direction::Right => println!("往右转。"),
    }
}

match是Rust的模式匹配,检查变量是哪个变体(variant),执行对应的分支。

编译器逼你不留死角

Rust有个"硬规矩":match必须覆盖所有可能的情况,少一个都不行。

忘了处理UpDown

fn main() {
    let direction = Direction::Left;

    match direction {
        Direction::Left => println!("左转。"),
        Direction::Right => println!("右转。"),
        // 忘了Up和Down...
    }
}

编译直接报错。不是警告,不是运行时panic,是编译不过去。

你写了一个处理订单状态的函数,后来同事新加了一个"退款中"的状态。用字符串的话,你可能完全意识不到自己的代码少处理了一种情况,直到线上出事。用enum加match,编译器立刻告诉你漏了分支。bug在编译期就被消灭了,不用等到凌晨三点被电话叫醒。

有些分支确实不关心的话,用通配符_兜底:

match go {
    Direction::Up => println!("往上走!"),
    Direction::Down => println!("往下走!"),
    _ => println!("其他方向。"),
}

_就是个"其他所有情况"的收纳箱。但谨慎使用,它会掩盖新增变体时编译器的提醒。

枚举可以携带数据

到这里为止,枚举看起来跟别的语言差不多。接下来不一样了。

Rust的枚举变体可以携带数据。这就好比去银行取号排队。普通的叫号系统,号码牌上就一个数字。但Rust的叫号系统,每张号码牌上还能附带不同的信息:有的写着"存款3000元",有的写着"开户,需要身份证",有的就一张白牌"咨询"。同一个队列,每个人带的信息类型完全不一样。

enum Message {
    Quit,                       // 没有数据,就是一个信号
    Move { x: i32, y: i32 },   // 带命名字段,像个小struct
    Write(String),              // 带一个String
    ChangeColor(i32, i32, i32), // 带一个三元组(RGB值)
}

四个变体,四种完全不同的数据结构,但它们都是Message类型。别的语言里你可能需要一个基类加四个子类,或者搞个tagged union。Rust一个enum搞定。

模式匹配自动把数据"拆"出来:

fn process_message(msg: Message) {
    match msg {
        Message::Quit => println!("收到退出信号。"),
        Message::Write(text) => println!("写入:{}", text),
        Message::Move { x, y } => println!("移动到坐标:({}, {})", x, y),
        Message::ChangeColor(r, g, b) => {
            println!("改变颜色为 R={}, G={}, B={}", r, g, b);
        }
    }
}

fn main() {
    process_message(Message::Quit);
    process_message(Message::Move { x: 10, y: 20 });
    process_message(Message::Write(String::from("你好,Rust枚举!")));
    process_message(Message::ChangeColor(255, 165, 0));
}
收到退出信号。
移动到坐标:(10, 20)
写入:你好,Rust枚举!
改变颜色为 R=255, G=165, B=0

我第一次见到这个写法的时候挺震撼的。一个类型,四种截然不同的内部结构,match的时候编译器还帮你检查有没有漏掉某种情况。之前在Java里做类似的事情,光是类继承和visitor模式就能写到天荒地老。

枚举和结构体混着用

枚举和结构体可以互相嵌套。比如表示用户在线状态:

struct Coordinates {
    lat: f64,
    lon: f64,
}

enum Status {
    Online(Coordinates),
    Offline,
}

fn main() {
    let user = Status::Online(Coordinates { lat: 39.9042, lon: 116.4074 });

    match user {
        Status::Online(coords) => {
            println!("用户在线,位置:({}, {})", coords.lat, coords.lon);
        }
        Status::Offline => println!("用户离线。"),
    }
}

反过来也行。结构体里用枚举做字段,很适合做业务状态建模:

enum TaskStatus {
    Todo,
    InProgress,
    Done,
}

struct Task {
    title: String,
    status: TaskStatus,
}

“任务只能处于三种状态之一"这个业务规则,直接写进了类型系统里。不用靠文档约定,编译器替你守着。

fn print_task(task: &Task) {
    print!("任务「{}」", task.title);
    match task.status {
        TaskStatus::Todo => println!(" - 待办"),
        TaskStatus::InProgress => println!(" - 进行中"),
        TaskStatus::Done => println!(" - 已完成"),
    }
}

fn main() {
    let mut task = Task {
        title: String::from("学习Rust枚举"),
        status: TaskStatus::Todo,
    };

    print_task(&task);

    task.status = TaskStatus::InProgress;
    print_task(&task);

    task.status = TaskStatus::Done;
    print_task(&task);
}
任务「学习Rust枚举」 - 待办
任务「学习Rust枚举」 - 进行中
任务「学习Rust枚举」 - 已完成

Rust对null说"不”

写过JavaScript、Python或者Java的话,你一定被null/None/NullPointerException折磨过。Tony Hoare,null的发明者,自己都管它叫“十亿美元的错误”

Rust的做法很干脆:没有null。取而代之的是标准库里的Option枚举:

enum Option<T> {
    Some(T),  // 有值
    None,     // 没有值
}

去快递站取过件吧?柜子要么有你的快递(Some(包裹)),要么是空的(None)。不会出现"看起来有东西但一打开就炸"的情况。

编译器强制你处理None。你不能假装值一定存在,然后在运行时炸掉。

直接看个实际场景,在数组里查找元素:

fn find_index(arr: &[i32], target: i32) -> Option<usize> {
    for (index, &value) in arr.iter().enumerate() {
        if value == target {
            return Some(index);
        }
    }
    None
}

fn main() {
    let numbers = [10, 20, 30, 40];

    match find_index(&numbers, 30) {
        Some(idx) => println!("找到了,在索引 {} 处", idx),
        None => println!("没找到。"),
    }

    // 只关心"找到"的情况,可以用 if let
    if let Some(index) = find_index(&numbers, 20) {
        println!("20 在索引 {} 处", index);
    }
}

Result:错误处理变成"必答题"

Option管"有没有值",Result管"操作成没成功":

enum Result<T, E> {
    Ok(T),   // 成功,携带结果
    Err(E),  // 失败,携带错误信息
}

去ATM取钱。成功就吐钞票(Ok(金额)),余额不足或者卡被锁了,都有明确的错误信息(Err(原因))。

fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("除数不能为零"))
    } else {
        Ok(a / b)
    }
}

fn main() {
    match divide(10.0, 2.0) {
        Ok(val) => println!("结果:{}", val),
        Err(e) => println!("错误:{}", e),
    }

    match divide(10.0, 0.0) {
        Ok(val) => println!("结果:{}", val),
        Err(e) => println!("错误:{}", e),
    }
}
结果:5
错误:除数不能为零

和异常机制不同的是,你没法假装错误不存在然后让它一路冒泡到顶层。Result把错误处理从"选修课"变成了"必答题"。Rust标准库里,文件读写、网络请求、JSON解析,几乎所有可能失败的操作都返回Result

刚开始会觉得烦,每个调用都要match或者?。但写久了你会发现,这比半夜debug一个"不知道从哪冒出来的异常"要舒服得多。

用枚举建模状态机

如果你以前用过其他语言做状态管理,你可能见过这种写法:一个status字段存字符串或整数,另外几个字段存关联数据,然后到处写if判断哪些字段在当前状态下是有效的。

Rust的做法是把状态和数据绑在一起。比如文件下载:

enum DownloadStatus {
    NotStarted,
    InProgress(u8),        // 下载进度百分比
    Completed(String),     // 文件保存路径
    Failed(String),        // 错误信息
}

fn check_status(status: DownloadStatus) {
    match status {
        DownloadStatus::NotStarted => println!("等待开始..."),
        DownloadStatus::InProgress(pct) => println!("下载中... {}%", pct),
        DownloadStatus::Completed(path) => println!("下载完成!文件在:{}", path),
        DownloadStatus::Failed(err) => println!("下载失败:{}", err),
    }
}

fn main() {
    check_status(DownloadStatus::NotStarted);
    check_status(DownloadStatus::InProgress(50));
    check_status(DownloadStatus::Completed(String::from("/downloads/report.pdf")));
    check_status(DownloadStatus::Failed(String::from("网络超时")));
}
等待开始...
下载中... 50%
下载完成!文件在:/downloads/report.pdf
下载失败:网络超时

“状态是Completed但文件路径为空”?这种矛盾在类型层面就不可能存在。状态和它对应的数据长在一起,拆不开。

递归枚举

最后来个稍微烧脑的。枚举可以递归引用自己,构建链表、树这类数据结构:

enum List {
    Cons(i32, Box<List>),
    Nil,
}

Cons是一个节点,包含一个整数值和一个指向下一个节点的指针。Nil表示链表结束。

这里用了Box<List>。为什么?Rust需要在编译期知道类型的大小,递归类型如果不装箱(box),大小是无限的。Box就是个固定大小的堆指针(64位系统上8字节),不管里面装的是什么,箱子本身大小不变。

use List::{Cons, Nil};

fn main() {
    // 构建链表:1 -> 2 -> 3 -> Nil
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));

    match list {
        Cons(first, _) => println!("链表第一个元素:{}", first),
        Nil => println!("空链表"),
    }
}

实际项目中很少手写链表,标准库的Vec够用了。但递归枚举这个思路在写AST解析器、树形配置、文件系统遍历的时候会反复用到。

回头看看

Rust的enum好在哪?说白了就两件事。

它把"这个值有哪几种可能"和"每种可能对应什么数据"合并成了一个类型。你不用在脑子里维护"状态A对应字段X,状态B对应字段Y"这种映射关系,类型系统帮你记着。然后编译器强制你处理每一种可能性。Option逼你面对"值可能不存在",Result逼你面对"操作可能失败",match逼你覆盖所有分支。

刚开始写Rust的时候我觉得编译器管得太宽了,到处报错,写什么都过不去。后来发现,这些报错拦住的东西,恰好是以前在别的语言里最难调的那类bug。被编译器骂几句,总比被线上告警骂要强。

速查表

场景用法例子
有限的固定选项基础枚举Direction::Up
变体携带不同数据带数据的枚举Message::Move { x, y }
值可能不存在Option<T>Some(42) / None
操作可能失败Result<T, E>Ok(data) / Err(msg)
状态机建模枚举 + 关联数据DownloadStatus::InProgress(50)
递归数据结构枚举 + BoxCons(1, Box::new(Nil))
枚举嵌套结构体组合使用Status::Online(Coordinates{..})

你在项目里用枚举解决过什么有意思的问题?或者踩过什么坑?评论区聊聊。