本篇导读:今天我们学习 Rust 类型系统中最具表达力、也最能体现其安全哲学的特性之一——枚举(enum),以及与之形影不离的 match 模式匹配。
如果你来自 C/C++/Java,可能以为枚举就是“一组起了名字的整数常量”。Rust 的枚举强大得多:每个变体(variant)都可以携带属于自己的、类型不同的数据,这种类型在函数式语言中被称为代数数据类型(ADT)。
本篇你将学会:定义带数据的枚举、标准库最重要的两个枚举 Option<T> 与 Result<T, E>、用 match 穷尽匹配、用 if let / let-else 简化代码。理解了它们,你就理解了 Rust 如何从类型系统层面彻底消灭空指针异常。
一、定义枚举
1. 最简单的枚举
enum IpAddrKind {
V4,
V6,
}
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
枚举的变体通过 枚举名::变体名 使用。
2. 变体可以携带数据
Rust 枚举的强大之处在于:每个变体可以独立地携带数据,数据类型还可以各不相同。假设 IP 地址 V4 是四个数字、V6 是一个字符串:
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
这比用“结构体 + 枚举”组合要简洁得多。变体甚至可以携带结构体,或者任意复杂的数据:
struct Ipv4Addr { /* ... */ }
struct Ipv6Addr { /* ... */ }
enum IpAddr2 {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
3. 一个更丰富的例子:消息类型
enum Message {
Quit, // 不带数据
Move { x: i32, y: i32 }, // 带具名字段,像结构体
Write(String), // 带一个 String
ChangeColor(i32, i32, i32), // 带三个 i32
}
一个枚举就把四种完全不同形态的消息统一到了同一个 Message 类型下,这在其他语言里往往需要一个公共基类加四个子类才能表达。枚举同样可以用 impl 定义方法:
impl Message {
fn call(&self) {
println!("处理了一条消息");
}
}
let m = Message::Write(String::from("hello"));
m.call();
二、Option<T>:编码“可能没有值”
这是标准库中使用频率最高的枚举,也是 Rust 消灭空指针的关键。
1. 空值的问题
许多语言都有“空值(null / nil / None)”概念,代表“这里没有值”。但它也是无数 bug 的根源:你以为变量里有东西,调用它的方法,结果它是 null,程序当场崩溃——Tony Hoare 称自己发明 null 为“十亿美元错误”。
Rust 中根本没有 null。那如何表示“一个值可能存在、也可能不存在”呢?答案是用 Option<T> 枚举:
enum Option<T> {
Some(T), // 有值,值的类型是 T
None, // 没有值
}
Option<T> 如此常用,以至于它的两个变体被直接放进了 prelude——你可以直接写 Some 和 None,无需前缀:
let some_number = Some(5);
let some_string = Some("一段文字");
let absent_number: Option<i32> = None; // None 时需要标注类型
2. 为什么这样更安全?
关键在于:Option<T> 和 T 不是同一个类型。编译器不允许你把一个“可能为空”的值当作“一定有值”来直接使用:
let x: i8 = 5;
let y: Option<i8> = Some(5);
// let sum = x + y; // 错误:i8 与 Option<i8> 不能直接相加
在你想使用 y 之前,必须先处理“它是 None 怎么办”。空值的隐患从“运行时才爆炸”变成了“编译期就逼你处理”。Rust 不是让空值消失,而是让你无法对空值视而不见。
3. 取出 Option 中的值
Option<T> 提供了大量方法,常用的有:
let x = Some(10);
assert_eq!(x.unwrap_or(0), 10); // 是 Some 就取值,None 用默认值 0
assert_eq!(x.is_some(), true); // 是否为 Some
assert_eq!(x.is_none(), false); // 是否为 None
let y: Option<i32> = None;
assert_eq!(y.unwrap_or(0), 0);
生产代码中应尽量避免裸用 unwrap()(None 时会 panic),优先用 unwrap_or 或下面的 match。
三、match:穷尽式匹配
match 是处理枚举的“标配武器”,第 2 篇我们已经用它比较数字。它的强大在于可以把变体携带的数据直接取出来。
1. 匹配枚举并取出数据
fn route(ip: IpAddr) {
match ip {
IpAddr::V4(a, b, c, d) => println!("IPv4 地址:{a}.{b}.{c}.{d}"),
IpAddr::V6(addr) => println!("IPv6 地址:{addr}"),
}
}
V4(a, b, c, d) 这个模式不仅匹配了变体,还把它携带的四个值绑定到了变量 a b c d 上。
2. 处理 Option
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
Some(n) => Some(n + 1),
None => None,
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
3. match 必须穷尽
Rust 要求 match 覆盖所有可能,漏掉 None 分支就编译报错:
match x {
Some(n) => println!("{n}"),
// 错误:pattern None not covered
}
这保证你永远不会忘记处理“没有值”的情况。
4. 通配符 _
当你不想逐一列出剩余所有情况时,用 _ 作为兜底模式:
let dice = 3;
match dice {
1 => println!("一点"),
2 => println!("两点"),
_ => println!("其他点数"), // 匹配除 1、2 外的任意值
}
_ 会匹配任意值但不绑定数据。如果想在兜底时停止循环等,可以在分支中写多条语句。
5. 匹配是表达式
别忘了 match 是表达式,可以直接用于赋值:
let coin = 5;
let value = match coin {
1 => 1,
5 => 5,
10 => 10,
_ => 0,
};
四、if let 与 let-else:只关心一种情况时
match 很严谨,但有时你只关心一个变体,其余全部忽略,写一堆 _ => () 很啰嗦。这时用 if let:
let some_value = Some(3);
match some_value {
Some(3) => println!("是三!"),
_ => (),
}
// 等价的简洁写法
if let Some(3) = some_value {
println!("是三!");
}
if let 还可以带 else,相当于 match 里的兜底分支:
let coin: Option<i32> = None;
let mut count = 0;
if let Some(i) = coin {
println!("硬币面值:{i}");
} else {
count += 1;
println!("没有硬币,计数:{count}");
}
let ... else:提前返回
较新的 Rust 提供了 let-else 语法,非常适合“如果不是我期望的模式,就提前退出”:
fn describe(s: Option<&str>) {
let Some(text) = s else {
println!("没有内容,直接返回");
return;
};
// 到这里 text 一定是有效的 &str
println!("内容是:{text}");
}
它让“先处理异常路径、再走主逻辑”的代码格外清晰,是现代 Rust 的推荐写法。
五、综合示例:一个简易计算器的指令解析
我们用枚举 + match 实现一个能解析指令的小系统:
#[derive(Debug)]
enum Op {
Add(f64, f64),
Sub(f64, f64),
Mul(f64, f64),
Div(f64, f64),
}
impl Op {
fn calculate(&self) -> Option<f64> {
match self {
Op::Add(a, b) => Some(a + b),
Op::Sub(a, b) => Some(a - b),
Op::Mul(a, b) => Some(a * b),
Op::Div(a, b) => {
if *b == 0.0 {
None // 除数为零,返回 None 表示失败
} else {
Some(a / b)
}
}
}
}
}
fn run(op: Op) {
match op.calculate() {
Some(result) => println!("{op:?} = {result}"),
None => println!("{op:?} 无法计算:除数为零"),
}
}
fn main() {
run(Op::Add(2.0, 3.0));
run(Op::Div(10.0, 2.0));
run(Op::Div(1.0, 0.0));
}
输出:
Add(2.0, 3.0) = 5
Div(10.0, 2.0) = 5
Div(1.0, 0.0) 无法计算:除数为零
这个例子完整展示了枚举建模、match 解构、用 Option 表达“计算可能失败”、再用 match 处理结果。这就是 Rust 处理“多种情况 + 可能失败”的标准范式。
六、本篇小结
- Rust 的枚举变体可以携带不同类型和数量的数据,是强大的代数数据类型,也能用
impl 定义方法;
Option<T>(Some(T) / None)编码“可能有值”,Rust 没有 null,且 Option<T> 与 T 类型不同,逼迫你在使用前处理空的情况,从根本上避免空指针异常;
match 能匹配变体并取出其数据,必须穷尽所有可能,_ 用于兜底;
match 是表达式,可用于赋值;
- 只关心一种情况时用
if let(可带 else);需要“不匹配就提前退出”时用 let-else。
七、动手练习
- 定义一个枚举
Shape { Circle(f64), Rectangle(f64, f64), Triangle(f64, f64) },其中 Triangle 携带底和高,实现一个方法计算各自面积(圆:πr²,三角形:底×高/2);
- 写函数
fn first_element(items: &[i32]) -> Option<i32> 返回切片第一个元素,空切片返回 None;
- 用
match 处理上一题的返回值,有值时打印,无值时打印“空集合”;
- 用
if let Some(x) = ... 重写第 3 题中“只关心有值”的分支;
- (选做)定义枚举表示链表的两种情况:空
Nil 或一个节点 Cons(i32, Box<List>),体会递归枚举为何需要 Box(第 16 篇详解)。
下篇预告:第 08 篇|包、Crate 与模块系统
随着代码增多,把所有东西都塞进一个文件会变得混乱。下一篇我们学习 Rust 组织大型代码的方式——模块系统:package(包)、crate(编译单元)、module(模块)、路径(path)、use 关键字、pub 可见性,以及如何用工作区(workspace)管理多个相关项目。
理解了模块系统,你才能顺畅地阅读标准库和第三方库的源码,并为自己的大型项目搭好骨架。下篇见。
更多 Rust 学习内容,欢迎访问 云栈社区。