用类型表达:没有 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` 只有两个分支,而且是穷尽的。