别小看Rust枚举:一个enum顶半个设计模式,完整实战指南

别小看Rust枚举:一个enum顶半个设计模式
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必须覆盖所有可能的情况,少一个都不行。
忘了处理Up和Down:
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) |
| 递归数据结构 | 枚举 + Box | Cons(1, Box::new(Nil)) |
| 枚举嵌套结构体 | 组合使用 | Status::Online(Coordinates{..}) |
你在项目里用枚举解决过什么有意思的问题?或者踩过什么坑?评论区聊聊。