用类型表达:没有 null,也没有异常
struct 与 enum:把状态说清楚
它在解决什么
所有权那一章从头到尾在回答一个问题:值归谁、活多久。 这一章换一个方向:怎么把「有哪几种情况」写进类型里,让编译器帮你数。
这两件事在别的语言里通常是分开的 —— 内存归 GC 管,而「有哪几种情况」 靠约定、靠注释、靠 code review。Rust 把后者也交给了编译器, 靠的主要是一样东西:能装数据的 enum。
先从 struct 说起,它比较平淡。
三种 struct
struct Point { x: i32, y: i32 } // 具名字段
struct Meters(f64); // 元组结构体
struct Marker; // 单元结构体
具名字段是默认选择,没什么好说的。
元组结构体按位置访问字段(.0、.1),写错下标就是 E0609。
它最有价值的用法是只有一个字段的时候 —— 那叫 newtype,下面单说。
单元结构体不存任何数据,实测占 0 字节。 它用来挂 trait 实现,或者纯粹当个类型标记。
newtype:一层包装换编译期的防呆
Meters(f64) 和 Feet(f64) 在运行时都只是一个 f64,
零额外开销,但在类型系统里是两种东西。
把 Feet 传给要 Meters 的函数当场 E0308。
⇒ 判据:一个裸的 f64 / String / u64 在你的领域里如果有单位或含义,
就值得包一层。 「用户 id」和「订单 id」都是 u64,包成两个 newtype 之后,
传错参数这一整类 bug 就消失了。
impl:方法也在做所有权的选择
四个方法四种签名,差别全在第一个参数:
| 写法 | 意思 | 典型用途 |
|---|---|---|
fn new() -> Self |
没有 self,关联函数 | 构造,用 Counter::new() 调 |
fn get(&self) |
借一下,只读 | 查询 |
fn bump(&mut self) |
借一下,要改 | 修改 |
fn into_inner(self) |
按值接收,把对象吃掉 | 转换、拆包 |
最后一个值得留意:self 不带 & 意味着调用它等于把整个对象交出去。
调完之后原来那个变量就失效了,报 E0382 ——
这就是移动那条规则在方法上的直接体现。
📌 标准库里叫 into_xxx 的方法基本都是这个签名。看到这个名字就该知道
「调完原来那个就没了」,而 as_xxx / to_xxx 通常是借用或复制。
enum 不是 C 的枚举
这是本篇的重点,也是从别的语言过来最需要重装的一块。
在 C 或 Java 里,枚举值本质上是一组整数常量。Rust 的 enum 每个变体都能带自己的数据,而且形状可以各不相同:
enum Shape {
Circle(f64), // 元组式
Rect { w: f64, h: f64 }, // 具名字段式
Unit, // 什么都不带
}
在 C 里 Circle 只能是个整数常量,半径得另外找地方放 ——
放在一个并列的 union 里、放在旁边的字段里、或者放在一个「调用方自己知道」
的约定里。而「另外找地方放」正是一切「字段和当前状态对不上」的起点。
⚠️ 顺带看一眼对照项:具名字段式变体的字段是有名字的,
match 里写错名字就是编译错误。这一点和 struct 完全一致。
🚨 把「不可能的状态」变成编译不出来的状态
这一条是整章的立论点。
假设要表示一个「要么成功带数据、要么失败带错误信息」的响应。 很多语言里的自然写法是两个可空字段:
struct Response {
data: Option<String>,
error: Option<String>,
}
这个类型允许四种组合,而其中只有两种有意义。另外两种呢 —— 两个都有、两个都没有 —— 它们没有意义,但类型系统不拦你构造它们。
于是处理它的代码被迫长成这样(对照项就是这段):
match (&r.data, &r.error) {
(Some(d), None) => /* 成功 */,
(None, Some(e)) => /* 失败 */,
_ => "这不该发生", // ← 这一行就是证据
}
⭐ 那个对照项编译通过、正常跑完,打印出「这不该发生」 —— 这才是它最该被注意的地方。它不报错、不崩溃,只是安静地走进了一个 本不该存在的分支。
换成 enum:
enum Response {
Ok(String),
Err(String),
}
那两种无意义的组合压根构造不出来,
于是 match 只有两个分支,而且是穷尽的 —— 没有兜底分支,
也不需要。
⇒ 这就是「用类型表达」在实践中的含义:不是把类型写得更精确好看,
而是让错误的状态无法被表示出来。你少写的那个 _ => 分支,
就是少了一处未来会有人踩的坑。
📌 一个可操作的信号:当你发现自己在写「这不该发生」「unreachable」 「理论上不会走到这里」的时候,先回头看一眼类型。 十有八九是类型 允许了太多东西。
顺带一个坑:..base 是移动
结构体更新语法很方便:
let dev = Config { port: 8080, ..base };
但它是移动,不是复制。..base 把没写出来的字段
从 base 里搬过来,而只要其中有一个不是 Copy(比如 String),
base 就变成了部分移动的状态 —— 之后再用它就是 E0382。
这和移动那篇里结构体字段那一条是同一回事, 只是换了个语法外壳。⇒ 所有权的规则不会因为语法糖而消失。
下一步
enum 把「有哪几种情况」写进了类型,而真正把这件事的价值兑现出来的是
match —— 它的穷尽性检查会在你漏掉一个变体时直接编译失败。
见《模式匹配:match 不只是 switch》:
解构、守卫、绑定,以及 if let / let else 各自的位置。
全部篇目见 Rust 教程首页。
本篇示例
下面每一条都是完整的、能单独编译的程序,由npm run test:rust 在每次构建前用真的 rustc 跑一遍。 「编译不过、报 E0382」这种话在这里是被验证过的断言。 报错原文和对照项的结果由同一道闸门自动回写,会随工具链更新,但不作为断言。
enum 能带数据 —— 这是它和 C/Java 枚举的根本区别
enum Shape {
Circle(f64),
Rect { w: f64, h: f64 },
Unit,
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle(r) => 3.14 * r * r,
Shape::Rect { w, h } => w * h,
Shape::Unit => 0.0,
}
}
fn main() {
println!(
"{} {} {}",
area(&Shape::Circle(1.0)),
area(&Shape::Rect { w: 2.0, h: 3.0 }),
area(&Shape::Unit)
);
}编译通过 · 输出 "3.14 6 0\n"
对照:把 `Rect { w, h }` 里的 `w` 写成 `width`
enum Shape {
Circle(f64),
Rect { w: f64, h: f64 },
Unit,
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle(r) => 3.14 * r * r,
Shape::Rect { width, h } => width * h,
Shape::Unit => 0.0,
}
}
fn main() {
println!(
"{} {} {}",
area(&Shape::Circle(1.0)),
area(&Shape::Rect { w: 2.0, h: 3.0 }),
area(&Shape::Unit)
);
}编译不过:error[E0026]、error[E0027]
三个变体各带不同形状的数据:元组式、具名字段式、什么都不带。在 C 或 Java 里 `Circle` 只能是个整数常量,半径得另外找地方放 —— 而「另外找地方放」就是一切「字段和状态对不上」的起点。
三种 struct,各有各的用处
struct Point { x: i32, y: i32 }
struct Meters(f64);
struct Marker;
fn main() {
let p = Point { x: 1, y: 2 };
let m = Meters(3.5);
let _k = Marker;
println!("{} {} {}", p.x + p.y, m.0, std::mem::size_of::<Marker>());
}编译通过 · 输出 "3 3.5 0\n"
对照:把 `m.0` 写成 `m.1`
struct Point { x: i32, y: i32 }
struct Meters(f64);
struct Marker;
fn main() {
let p = Point { x: 1, y: 2 };
let m = Meters(3.5);
let _k = Marker;
println!("{} {} {}", p.x + p.y, m.1, std::mem::size_of::<Marker>());
}编译不过:error[E0609]
元组结构体的字段按位置访问(`.0`、`.1`),写错就是 `E0609`。单元结构体**占 0 字节** —— 它不存数据,只用来挂 trait 实现或者当个类型标记。
newtype:用一层包装换编译期的防呆
struct Meters(f64);
struct Feet(f64);
fn describe(m: Meters) -> String {
format!("{} 米", m.0)
}
fn main() {
let _ = Feet(3.0);
println!("{}", describe(Meters(10.0)));
}编译通过 · 输出 "10 米\n"
对照:传 `Feet(10.0)` 进去
struct Meters(f64);
struct Feet(f64);
fn describe(m: Meters) -> String {
format!("{} 米", m.0)
}
fn main() {
let _ = Feet(3.0);
println!("{}", describe(Feet(10.0)));
}编译不过:error[E0308]
两个类型在运行时都只是一个 `f64`,**零额外开销**,但它们在类型系统里是两种东西。对照项报 `E0308` —— 单位搞混这一整类 bug 被挡在了编译期。
`&self` / `&mut self` / `self`:方法也在做所有权的选择
struct Counter { n: i32 }
impl Counter {
fn new() -> Self { Counter { n: 0 } }
fn get(&self) -> i32 { self.n }
fn bump(&mut self) { self.n += 1; }
fn into_inner(self) -> i32 { self.n }
}
fn main() {
let mut c = Counter::new();
c.bump();
c.bump();
println!("{}", c.get());
println!("{}", c.into_inner());
}编译通过 · 输出 "2\n2\n"
对照:在 `into_inner()` 之后再调一次 `c.get()`
struct Counter { n: i32 }
impl Counter {
fn new() -> Self { Counter { n: 0 } }
fn get(&self) -> i32 { self.n }
fn bump(&mut self) { self.n += 1; }
fn into_inner(self) -> i32 { self.n }
}
fn main() {
let mut c = Counter::new();
c.bump();
c.bump();
println!("{}", c.get());
println!("{}", c.into_inner());
println!("{}", c.get());
}编译不过:error[E0382]
`self` 不带 `&` 就是**按值接收**,调用它等于把整个对象交出去 —— 对照项报 `E0382`。⇒ 标准库里叫 `into_xxx` 的方法基本都是这个签名,看到这个名字就该知道「调完原来那个就没了」。
`..base` 很方便,但它是**移动**
#[derive(Debug)]
struct Config {
host: String,
port: u16,
debug: bool,
}
fn main() {
let base = Config { host: String::from("localhost"), port: 80, debug: false };
let dev = Config { port: 8080, ..base };
println!("{dev:?}");
}编译通过 · 输出 "Config { host: \"localhost\", port: 8080, debug: false }\n"
对照:之后再打印一次 `base`
#[derive(Debug)]
struct Config {
host: String,
port: u16,
debug: bool,
}
fn main() {
let base = Config { host: String::from("localhost"), port: 80, debug: false };
let dev = Config { port: 8080, ..base };
println!("{dev:?}");
println!("{base:?}");
}编译不过:error[E0382]
`..base` 把没写出来的字段从 `base` 里搬过来,而 `host` 是 `String`(不是 `Copy`)⇒ 这是一次**部分移动**,`base` 从此不完整。对照项报 `E0382`。这和[移动那篇](/rust/ownership/move/)里结构体字段那一条是同一回事。
🚨 用 enum 把「不可能的状态」变成编译不出来的状态
enum Response {
Ok(String),
Err(String),
}
fn describe(r: &Response) -> String {
match r {
Response::Ok(d) => format!("成功:{d}"),
Response::Err(e) => format!("失败:{e}"),
}
}
fn main() {
println!("{}", describe(&Response::Ok(String::from("hi"))));
}编译通过 · 输出 "成功:hi\n"
对照:改用「两个 `Option` 字段」的 struct 表示同一件事
struct Response {
data: Option<String>,
error: Option<String>,
}
fn describe(r: &Response) -> String {
match (&r.data, &r.error) {
(Some(d), None) => format!("成功:{d}"),
(None, Some(e)) => format!("失败:{e}"),
_ => String::from("这不该发生"),
}
}
fn main() {
let r = Response { data: None, error: None };
println!("{}", describe(&r));
}编译通过,输出:"这不该发生\n"
⭐ 对照项**编译通过、正常跑完**,打印出「这不该发生」—— 这才是它最该被注意的地方:struct 版本允许构造出两个字段都是 `None` 的对象,于是你被迫写一个 `_ =>` 兜底分支去处理一个**本不该存在**的情况。enum 版本里那个情况压根构造不出来,所以 `match` 只有两个分支,而且是穷尽的。