第三章 结构体与枚举 (Structs and Enums)
一、导读
1.1 本章学习目标
掌握 Rust 结构体(struct)的三种形式:命名字段结构体、元组结构体和单元结构体
理解结构体方法的定义方式,区分关联函数(associated function)与实例方法(method)
掌握枚举(enum)的定义与模式匹配(pattern matching),理解 match 表达式的穷举性
理解 Option<T> 和 Result<T, E> 枚举的设计哲学及其在错误处理中的核心地位
掌握 impl 块的使用方式,学会为自定义类型添加行为
理解枚举的"代数数据类型"(Algebraic Data Type)本质,掌握和类型与积类型的概念
掌握 if let 和 while let 语法的使用场景
能够使用结构体和枚举设计符合 Rust 惯用法的数据模型
1.2 为什么本章如此重要
结构体和枚举是 Rust 中定义自定义数据类型的两大基石。如果说所有权系统是 Rust 的灵魂,那么结构体和枚举就是 Rust 的骨肉——它们决定了你如何组织和表达程序中的数据。几乎所有有意义的 Rust 程序都需要定义自己的结构体和枚举来建模业务领域。
与面向对象语言中的"类"不同,Rust 的结构体只包含数据,不包含行为。行为通过 impl 块与结构体关联,这种数据与行为的分离设计使得代码组织更加灵活。一个结构体可以有多个 impl 块,不同的 impl 块可以定义在不同的文件中,甚至可以由不同的库为同一类型添加方法(通过 trait)。
枚举是 Rust 类型系统中最强大的特性之一。与 C 语言的枚举(仅仅是整数的别名)完全不同,Rust 的枚举是"代数数据类型"(ADT),每个变体(variant)可以携带不同类型和数量的数据。结合模式匹配,枚举提供了一种优雅且安全的方式来处理多种可能的情况。Rust 标准库中的 Option<T> 和 Result<T, E> 两个枚举彻底改变了错误处理和空值处理的方式,消除了空指针异常和未检查错误这两类最常见的运行时错误。
1.3 核心问题引导
Rust 的结构体与 Java/C++ 的类有什么本质区别?为什么 Rust 选择将数据和方法分离?
枚举的每个变体为什么可以携带不同类型的数据?这与"标签联合"(tagged union)有什么关系?
match 表达式为什么要求穷举所有可能的模式?这种设计如何防止遗漏边界情况?
Option<T> 如何替代 null 引用?它比 null 更安全的原因是什么?
什么是"代数数据类型"?和类型(sum type)与积类型(product type)分别对应什么?
if let 相比 match 在什么场景下更合适?它们各自的优势是什么?
二、核心概念详解
3.1 结构体的定义与使用
Rust 提供三种结构体形式,适用于不同的场景。
命名字段结构体是最常用的形式,每个字段都有名称和类型:
struct User {
name: String,
email: String,
age: u32,
active: bool,
}创建实例时使用字段初始化语法,Rust 还提供了字段初始化简写(当变量名与字段名相同时):
let user = User {
name: String::from("Alice"),
email: String::from("alice@example.com"),
age: 30,
active: true,
};更新语法(struct update syntax)允许基于已有实例创建新实例:
let user2 = User {
name: String::from("Bob"),
..user // 其余字段从 user 复制
};元组结构体没有字段名,只有类型,适用于需要给元组一个名字的场景:
struct Point(f64, f64);
struct Color(u8, u8, u8);
let p = Point(3.0, 4.0);
let red = Color(255, 0, 0);单元结构体没有任何字段,适用于需要为某个类型实现 trait 但不需要存储数据的场景:
struct Marker;3.2 方法定义与 impl 块
通过 impl 块为结构体定义方法。方法的第一个参数是实例本身,有三种形式:&self(不可变借用)、&mut self(可变借用)和 self(获取所有权)。
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
// 关联函数(构造函数)
fn new(width: f64, height: f64) -> Self {
Rectangle { width, height }
}
// 实例方法(不可变借用)
fn area(&self) -> f64 {
self.width * self.height
}
// 实例方法(可变借用)
fn scale(&mut self, factor: f64) {
self.width *= factor;
self.height *= factor;
}
// 判断方法
fn is_square(&self) -> bool {
(self.width - self.height).abs() < f64::EPSILON
}
}关联函数(如 new)没有 self 参数,通过 :: 语法调用:Rectangle::new(10.0, 5.0)。它们通常用于创建实例的工厂函数。
多个 impl 块:Rust 允许为同一类型定义多个 impl 块,这在组织代码和条件编译时非常有用:
impl Rectangle {
fn perimeter(&self) -> f64 {
2.0 * (self.width + self.height)
}
}3.3 枚举与变体
Rust 的枚举是真正的代数数据类型,每个变体可以携带不同的数据:
enum Shape {
Circle(f64), // 元组变体
Rectangle { width: f64, height: f64 }, // 结构体变体
Point, // 单元变体
}枚举变体在命名空间中通过 :: 语法访问:
let circle = Shape::Circle(5.0);
let rect = Shape::Rectangle { width: 10.0, height: 5.0 };枚举可以为所有变体定义公共方法:
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle { width, height } => width * height,
Shape::Point => 0.0,
}
}
}3.4 模式匹配与 match
match 是 Rust 中最强大的控制流结构,它要求穷举所有可能的模式。编译器会在编译期检查是否所有情况都被处理,遗漏任何情况都会导致编译错误。
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u32 {
match coin {
Coin::Penny => {
println!("Lucky penny!");
1
}
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}匹配绑定:match 可以在匹配的同时绑定变量:
fn describe_number(x: Option<i32>) -> String {
match x {
Some(n) if n < 0 => format!("{} 是负数", n),
Some(0) => String::from("零"),
Some(n) => format!("{} 是正数", n),
None => String::from("没有值"),
}
}通配符 `_`:当不需要穷举所有情况时,使用 _ 匹配剩余情况:
match value {
1 => println!("one"),
2 => println!("two"),
_ => (), // 忽略其他情况
}3.5 Option 与 Result 枚举
Option<T> 和 Result<T, E> 是 Rust 标准库中最重要的两个枚举,它们替代了 null 和异常。
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}Option<T> 表示"可能有值也可能没有",替代了其他语言中的 null。由于 Rust 强制要求处理 None 的情况,空引用错误在编译期就被捕获。
fn find_user(id: u32) -> Option<String> {
if id == 1 {
Some(String::from("Alice"))
} else {
None
}
}
// 使用 match 处理
match find_user(1) {
Some(name) => println!("找到用户:{}", name),
None => println!("用户不存在"),
}Result<T, E> 表示操作可能成功或失败,是 Rust 错误处理的基础。
三、深入分析与讨论
3.1 代数数据类型:和类型与积类型
从类型理论的角度看,结构体是"积类型"(Product Type),枚举是"和类型"(Sum Type)。积类型的值数量是各字段值数量的乘积——一个有 3 个 bool 字段的结构体有 2×2×2=8 种可能的值。和类型的值数量是各变体值数量的总和——一个有 3 个无数据变体的枚举恰好有 3 种可能的值。
这种数学基础使得 Rust 的类型系统能够精确地建模业务领域的状态空间。通过精心设计结构体和枚举的组合,可以让"不可能的状态不可表示"(make impossible states unrepresentable)。例如,一个"用户注册状态"不应该用两个独立的布尔值表示(因为会产生 4 种状态,其中一些是无意义的),而应该用枚举表示(只有 3 种有效状态:未注册、已注册未验证、已注册已验证)。
3.2 模式匹配的穷举性与安全性
match 的穷举性检查是 Rust "如果它编译通过,它就是正确的"理念的重要体现。当你在枚举中添加新变体时,编译器会指出所有需要更新的 match 表达式,确保没有遗漏。这种"编译期穷举检查"在大型项目中尤其有价值——它使得代码重构更加安全。
与 switch 语句不同,match 是表达式(有返回值),并且支持"守卫"(guard)条件、嵌套解构和绑定。这些特性使得 match 不仅能替代 switch,还能表达更复杂的条件逻辑。
3.3 结构体与枚举的组合设计
在实际项目中,结构体和枚举经常组合使用。枚举的变体可以包含结构体,结构体的字段可以是枚举。这种组合使得数据建模既灵活又安全。
struct IpAddr {
kind: IpAddrKind,
address: String,
}
enum IpAddrKind {
V4,
V6,
}
// 更简洁的写法:将数据直接放入枚举变体
enum IpAddr2 {
V4(String),
V6(String),
}四、实践案例
4.1 实现一个简单的图形计算器
使用枚举和结构体建模不同图形,计算面积和周长。
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { a: f64, b: f64, c: f64 },
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => std::f64::consts::PI * radius.powi(2),
Shape::Rectangle { width, height } => width * height,
Shape::Triangle { a, b, c } => {
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
}
}
fn perimeter(&self) -> f64 {
match self {
Shape::Circle { radius } => 2.0 * std::f64::consts::PI * radius,
Shape::Rectangle { width, height } => 2.0 * (width + height),
Shape::Triangle { a, b, c } => a + b + c,
}
}
fn is_valid(&self) -> bool {
match self {
Shape::Circle { radius } => *radius > 0.0,
Shape::Rectangle { width, height } => *width > 0.0 && *height > 0.0,
Shape::Triangle { a, b, c } => a + b > c && a + c > b && b + c > a,
}
}
}
fn main() {
let shapes = vec![
Shape::Circle { radius: 5.0 },
Shape::Rectangle { width: 10.0, height: 5.0 },
Shape::Triangle { a: 3.0, b: 4.0, c: 5.0 },
];
for shape in &shapes {
if shape.is_valid() {
println!("面积: {:.2}, 周长: {:.2}", shape.area(), shape.perimeter());
}
}
}4.2 命令行任务管理器
使用结构体建模任务,使用枚举管理任务状态。
#[derive(Debug)]
enum Status {
Todo,
InProgress,
Done,
}
#[derive(Debug)]
struct Task {
title: String,
status: Status,
priority: u8,
}
impl Task {
fn new(title: &str, priority: u8) -> Self {
Task {
title: String::from(title),
status: Status::Todo,
priority,
}
}
fn advance(&mut self) {
self.status = match self.status {
Status::Todo => Status::InProgress,
Status::InProgress => Status::Done,
Status::Done => Status::Done,
};
}
fn status_text(&self) -> &str {
match self.status {
Status::Todo => "待办",
Status::InProgress => "进行中",
Status::Done => "已完成",
}
}
}
fn main() {
let mut tasks = vec![
Task::new("学习 Rust", 1),
Task::new("写项目", 2),
Task::new("复习", 3),
];
tasks[0].advance();
for task in &tasks {
println!("[{}] {} (优先级: {})", task.status_text(), task.title, task.priority);
}
}五、常见误区与注意事项
忘记为枚举变体实现所有分支:match 要求穷举所有情况。添加新变体后,必须更新所有相关的 match 表达式。可以使用 _ 通配符避免编译错误,但这样做会失去穷举性检查的保护。
混淆关联函数和实例方法:关联函数没有 self 参数,通过 Type::function() 调用;实例方法有 self 参数,通过 instance.method() 调用。构造函数通常是关联函数。
过度使用 `unwrap()`:对 Option 和 Result 使用 unwrap() 在遇到 None 或 Err 时会 panic。生产代码中应使用 match、if let、? 运算符或 unwrap_or() 等方法安全地处理。
结构体字段使用 `String` 而非 `&str`:当结构体拥有数据时使用 String,当结构体只是借用数据时使用 &str。选择取决于结构体是否需要数据的所有权。
枚举变体数据过大导致内存浪费:枚举的大小由其最大变体决定。如果某个变体包含大量数据,考虑使用 Box<T> 将其放到堆上,以减小枚举的整体大小。
六、本章小结
本章深入讲解了 Rust 的结构体和枚举两大自定义数据类型。结构体通过命名字段、元组和单元三种形式,提供了灵活的数据组织方式。impl 块将行为与数据关联,关联函数和实例方法的区分使得代码意图更加清晰。枚举作为代数数据类型,每个变体可以携带不同类型的数据,配合 match 表达式的穷举性检查,提供了安全且表达力强大的模式匹配能力。Option<T> 和 Result<T, E> 两个标准库枚举从根本上改变了空值处理和错误处理的方式,将运行时错误前移到编译期。结构体与枚举的组合使用是 Rust 数据建模的核心技能。
七、思考题与练习
设计一个 Book 结构体,包含书名、作者、ISBN 和价格字段。为它实现 display_info 方法和 apply_discount 方法。
使用枚举建模一个"支付方式"类型,支持信用卡(含卡号和有效期)、借记卡(含卡号)、电子钱包(含用户名和余额)和现金(无数据)。为每种方式实现 transaction_fee 方法。
解释为什么 match 必须穷举所有模式。如果允许不穷举,可能导致什么问题?给出一个具体的例子。
使用 Option<i32> 实现一个 divide 函数,当除数为零时返回 None。分别使用 match 和 if let 处理返回值。
设计一个状态机,使用枚举表示状态,结构体存储上下文数据。实现一个"交通信号灯"的状态转换逻辑。
解释"让不可能的状态不可表示"的含义。设计一个用户注册流程的数据模型,使得"已验证但未注册"这样的非法状态无法表示。
研究 #[derive(Debug)]、#[derive(Clone)] 等派生宏的作用。为自定义结构体添加适当的 derive 属性。
实现一个简易的表达式求值器,使用枚举表示不同的表达式类型(数字、加法、乘法等),使用递归方式计算结果。