跳到主要内容
预计阅读 27 分钟

结构体、枚举与模式匹配 —— 给数据穿上合身的衣服

在真实的编程世界里,数据很少是孤零零的一个数字或一段文字。一个用户有姓名、邮箱、年龄;一个订单有商品、数量、状态。把这些相关的数据组织在一起,就需要”自定义类型”。Rust用结构体(struct)和枚举(enum)来帮你给数据穿上量身定做的衣服。

📋 开篇自测:你已经知道多少?

  1. 你用过其他语言的class或struct吗?Rust的struct和它们有什么异同?
  2. 你知道”代数数据类型”(ADT)这个概念吗?Rust的enum有多强大?
  3. 模式匹配(pattern matching)和传统的switch-case有什么本质区别?

一、结构体(Struct)——数据的收纳盒

1.1 定义和实例化

结构体就像一个带标签的收纳盒——每个格子有自己的名字和用途:

struct Player {
    username: String,
    level: u32,
    health: f64,
    is_online: bool,
}

fn main() {
    let player1 = Player {
        username: String::from("DragonSlayer"),
        level: 42,
        health: 98.5,
        is_online: true,
    };

    println!(
        "玩家{},等级{},生命值{:.1}%",
        player1.username, player1.level, player1.health
    );
}

用点号(.)访问字段,简单直接。

1.2 可变结构体

如果要修改结构体的字段,整个实例必须是mut的——Rust不允许只让某些字段可变:

fn main() {
    let mut player1 = Player {
        username: String::from("DragonSlayer"),
        level: 42,
        health: 98.5,
        is_online: true,
    };

    player1.level = 43;  // 升级!
    player1.health = 100.0;  // 回满血
    println!("恭喜升到{}级!", player1.level);
}

1.3 字段初始化简写

如果变量名和字段名相同,可以省略冒号后面的部分:

fn create_player(username: String, level: u32) -> Player {
    Player {
        username,        // 等同于 username: username
        level,           // 等同于 level: level
        health: 100.0,
        is_online: true,
    }
}

1.4 结构体更新语法

基于已有实例创建新实例,只修改部分字段:

fn main() {
    let player1 = create_player(String::from("Warrior"), 10);

    let player2 = Player {
        username: String::from("Mage"),
        ..player1  // 其余字段从player1复制/移动
    };

    // 注意:使用 ..player1 时,Copy类型字段(health、is_online等)被复制,
    // 非Copy类型字段(如String)会被移动。由于我们单独指定了username,
    // player1这里没有被移动的字段,仍然可以正常使用。
    // 但如果没有单独指定username,player1的username就会被移动,
    // 导致player1部分失效(partial move),此时只能访问其Copy类型字段。
    println!("新玩家:{}", player2.username);
}

1.5 元组结构体和单元结构体

有时候你只需要给类型一个名字,不需要给字段命名:

// 元组结构体:有名字,字段没名字
struct Color(u8, u8, u8);
struct Coordinate(f64, f64);

fn main() {
    let red = Color(255, 0, 0);
    let origin = Coordinate(0.0, 0.0);
    println!("红色的R值:{}", red.0);
    println!("原点坐标:({}, {})", origin.0, origin.1);
}

// 单元结构体:没有任何字段,常用于实现trait
struct Marker;

1.6 给结构体添加方法——impl块

Rust没有class,但struct可以通过impl块拥有方法:

struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    // 关联函数(类似其他语言的静态方法/构造函数)
    fn new(width: f64, height: f64) -> Self {
        Rectangle { width, height }
    }

    // 方法(第一个参数是&self)
    fn area(&self) -> f64 {
        self.width * self.height
    }

    fn perimeter(&self) -> f64 {
        2.0 * (self.width + self.height)
    }

    fn is_square(&self) -> bool {
        (self.width - self.height).abs() < f64::EPSILON
    }

    // 可变方法(第一个参数是&mut self)
    fn scale(&mut self, factor: f64) {
        self.width *= factor;
        self.height *= factor;
    }
}

fn main() {
    let mut rect = Rectangle::new(10.0, 5.0);
    println!("面积:{}", rect.area());
    println!("周长:{}", rect.perimeter());
    println!("是正方形吗?{}", rect.is_square());

    rect.scale(2.0);
    println!("放大后面积:{}", rect.area());
}

几个关键点:

  • Self是当前类型的别名(这里等同于Rectangle
  • &selfself: &Self的语法糖——不可变借用当前实例
  • &mut self允许修改当前实例
  • Rectangle::new()用双冒号调用——这是关联函数(没有self参数),常用作构造函数

🤔 想一想 Rust为什么不用class而用struct + impl的方式?想想”数据”和”行为”分开定义的好处——你可以在不同的地方为同一个struct添加不同的impl块,甚至可以为别人的struct实现自己的trait(后面会讲)。


二、枚举(Enum)——“多选一”的数据类型

2.1 基础枚举

如果说结构体是”把多个数据打包在一起”,那枚举就是”在多种可能中选一个”:

enum Season {
    Spring,
    Summer,
    Autumn,
    Winter,
}

fn weather_tip(season: Season) {
    match season {
        Season::Spring => println!("春天来了,万物复苏"),
        Season::Summer => println!("注意防晒,多喝水"),
        Season::Autumn => println!("天凉了,加件外套"),
        Season::Winter => println!("出门记得戴围巾"),
    }
}

fn main() {
    weather_tip(Season::Summer);
}

到这里还像其他语言的枚举。但Rust的枚举有一个杀手锏——

2.2 枚举变体可以携带数据

Rust的枚举每个变体可以存储不同类型、不同数量的数据。这远远超出了C/Java枚举的能力:

enum Message {
    Quit,                        // 不携带数据
    Echo(String),                // 携带一个String
    Move { x: i32, y: i32 },    // 携带命名字段(类似匿名struct)
    Color(u8, u8, u8),           // 携带三个u8
}

fn process_message(msg: Message) {
    match msg {
        Message::Quit => println!("收到退出指令"),
        Message::Echo(text) => println!("回声:{}", text),
        Message::Move { x, y } => println!("移动到({}, {})", x, y),
        Message::Color(r, g, b) => println!("颜色:#{:02X}{:02X}{:02X}", r, g, b),
    }
}

fn main() {
    process_message(Message::Echo(String::from("你好世界")));
    process_message(Message::Move { x: 100, y: 200 });
    process_message(Message::Color(255, 128, 0));
    process_message(Message::Quit);
}

这种设计把”类型”和”数据”合二为一,非常强大。在其他语言中实现同样的功能,你可能需要定义一个接口加上多个实现类。

2.3 最重要的两个枚举:Option和Result

Rust标准库中有两个枚举,你几乎在每个程序中都会用到:

Option:处理”有或没有”

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

Rust没有null。取而代之的是Option——当一个值可能不存在时,必须用Option包装。这迫使你在使用值之前先检查它是否存在。

fn find_student(id: u32) -> Option<String> {
    match id {
        1 => Some(String::from("张三")),
        2 => Some(String::from("李四")),
        _ => None,
    }
}

fn main() {
    match find_student(1) {
        Some(name) => println!("找到了:{}", name),
        None => println!("查无此人"),
    }

    // 更简洁的写法
    if let Some(name) = find_student(2) {
        println!("学生:{}", name);
    }
}

Result:处理”成功或失败”

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

我们会在第六章详细讲解Result的用法。

2.4 给枚举添加方法

和结构体一样,枚举也可以有impl块:

#[derive(Debug)]
enum TrafficLight {
    Red,
    Yellow,
    Green,
}

impl TrafficLight {
    fn duration(&self) -> u32 {
        match self {
            TrafficLight::Red => 60,
            TrafficLight::Yellow => 5,
            TrafficLight::Green => 45,
        }
    }

    fn next(&self) -> TrafficLight {
        match self {
            TrafficLight::Red => TrafficLight::Green,
            TrafficLight::Green => TrafficLight::Yellow,
            TrafficLight::Yellow => TrafficLight::Red,
        }
    }
}

fn main() {
    let light = TrafficLight::Red;
    println!("红灯持续{}秒", light.duration());
    let next_light = light.next();
    println!("下一个灯持续{}秒", next_light.duration());
}

🤔 想一想 没有null意味着什么?在Java中,NullPointerException是最常见的运行时错误之一。Rust用Option替代null后,为什么就不会出现空指针异常了?


三、模式匹配——Rust的”超级分拣机”

3.1 match的穷尽性检查

match的一个硬性要求:必须覆盖所有可能的情况。编译器会帮你检查是否遗漏:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u32 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        // 如果这里少了Quarter,编译器会报错!
        Coin::Quarter => 25,
    }
}

这就像海关检查行李——每一种物品都必须有明确的处理规则,不允许”我不知道怎么处理”的情况出现。

3.2 通配符与默认分支

当你只关心其中几种情况时,用_捕获剩余的所有情况:

fn describe_number(n: i32) -> &'static str {
    match n {
        0 => "零",
        1 => "壹",
        2..=9 => "个位数",
        10..=99 => "两位数",
        _ => "很大的数",
    }
}

3.3 解构与绑定

模式匹配可以”拆开”复合数据,提取内部的值:

struct Point {
    x: f64,
    y: f64,
}

fn classify_point(p: &Point) {
    // 注意:Rust 不允许在 match 模式中使用浮点数字面量(因为浮点数只实现了 PartialEq 而非 Eq,
    // match 模式要求精确相等),需要用 match guard
    match p {
        Point { x, y } if *x == 0.0 && *y == 0.0 => println!("原点"),
        Point { x, y } if *y == 0.0 => println!("在X轴上,x={}", x),
        Point { x, y } if *x == 0.0 => println!("在Y轴上,y={}", y),
        Point { x, y } => println!("普通点({}, {})", x, y),
    }
}

3.4 if let——当你只关心一种情况

如果你只想处理一种匹配,不关心其他情况,if letmatch更简洁:

fn main() {
    let favorite_color: Option<&str> = Some("蓝色");

    // 用match(略显冗余)
    match favorite_color {
        Some(color) => println!("你最喜欢的颜色是{}", color),
        None => (),
    }

    // 用if let(更简洁)
    if let Some(color) = favorite_color {
        println!("你最喜欢的颜色是{}", color);
    }
}

3.5 while let——循环版的if let

fn main() {
    let mut stack = vec![1, 2, 3, 4, 5];

    while let Some(top) = stack.pop() {
        println!("弹出:{}", top);
    }
    println!("栈已清空");
}

3.6 复杂模式匹配示例

enum Command {
    Attack { target: String, power: u32 },
    Defend,
    Heal(u32),
    Flee,
}

fn execute(cmd: Command) {
    match cmd {
        Command::Attack { target, power } if power > 50 => {
            println!("对{}发动强力攻击!伤害:{}", target, power);
        }
        Command::Attack { target, power } => {
            println!("对{}发动普通攻击,伤害:{}", target, power);
        }
        Command::Defend => println!("举盾防御!"),
        Command::Heal(amount) if amount > 30 => {
            println!("强力治疗!恢复{}点生命", amount);
        }
        Command::Heal(amount) => {
            println!("轻微治疗,恢复{}点生命", amount);
        }
        Command::Flee => println!("三十六计走为上计!"),
    }
}

fn main() {
    execute(Command::Attack {
        target: String::from("恶龙"),
        power: 80,
    });
    execute(Command::Heal(15));
    execute(Command::Flee);
}

注意if power > 50这样的匹配守卫(match guard)——它在模式匹配的基础上增加了额外的条件判断,让分支逻辑更加精细。

⚠️ 常见误区

  1. 忘了match必须穷尽所有可能——如果你的枚举有5个变体,match必须处理全部5个(或者用_兜底)。这不是麻烦,是保护——当你给枚举新增一个变体时,编译器会告诉你所有需要更新的地方。
  2. 过度使用clone来回避所有权问题——在struct更新语法中,如果被”借用”的字段是String等Move类型,原结构体会部分失效。新手常用clone回避这个问题,但更好的做法是仔细设计数据的所有权归属。
  3. 把Option当成null来用——不要到处unwrap()!unwrap()在值为None时会panic。正确的做法是用match、if let或者?运算符来安全地处理Option。
  4. 混淆方法和关联函数——有&self参数的是方法(用.调用),没有的是关联函数(用::调用)。Rectangle::new()是关联函数,rect.area()是方法。

📝 掌握度自测

  1. 结构体:定义一个Book结构体(title、author、pages、price字段),为它实现一个describe()方法,输出书的信息。
  2. 枚举:定义一个Shape枚举,包含Circle(f64)(半径)、Rectangle(f64, f64)(宽高)、Triangle(f64, f64, f64)(三边长),为它实现一个area()方法。
  3. 模式匹配:解释为什么下面的代码无法编译,并修复它:
    enum Direction { North, South, East, West }
    fn go(d: Direction) {
        match d {
            Direction::North => println!("向北"),
            Direction::South => println!("向南"),
        }
    }
  4. Option:写一个函数safe_divide(a: f64, b: f64) -> Option<f64>,当除数为0时返回None,否则返回Some(结果)。
  5. 综合:用枚举+模式匹配实现一个简易计算器:定义Operation枚举(Add、Sub、Mul、Div),写一个calculate(op: Operation, a: f64, b: f64) -> Option<f64>函数。

💡 自我评估

  • 答对5题:结构体和枚举已经得心应手,你对Rust的类型系统有了扎实的理解。
  • 答对3-4题:核心概念已掌握,模式匹配的高级用法可能还需要多练习。
  • 答对0-2题:建议把每个代码示例都在编辑器中运行一遍,尤其关注枚举携带数据和match的穷尽性检查。

购买课程解锁全部内容

内存安全 + 零成本抽象:Rust 系统编程实战

¥29.90