第4章:数据建模 —— 结构体、枚举、模式匹配与方法
约 7 分钟 · 更新于 2026-09-01
第4章:数据建模 —— 结构体、枚举、模式匹配与方法
本章围绕“交付工作项”建立领域模型。我们不从结构体语法开始,而是先列出必须成立的规则,再选择元组、结构体、枚举、模式匹配和方法,把规则逐步压进类型系统。
一、从不变量倒推类型
假设一个交付工作项有编号、标题、优先级和状态。状态只能在“待安排、执行中、已完成”三者中选择;执行中必须记录负责人,完成后必须记录复核结论。
如果把这些信息拆成若干布尔值和可空字段,调用者很容易组合出矛盾状态。更稳妥的做法是先写不变量:
- 一个时刻只能处于一种状态;
- 只有执行中状态拥有负责人;
- 只有完成状态拥有复核结果;
- 状态转换由类型的方法统一执行。
然后再为每类关系选择工具:
| 关系 | 建模工具 |
|---|
| 多个字段同时构成一个对象 | 结构体 |
| 多种状态只能选择一种 | 枚举 |
| 从数据形状中取出字段 | 模式匹配 |
| 合法的状态转换 | 方法 |
| 短期返回多个值 | 元组 |
| 数量固定的同类元素 | 数组 |
建模顺序
先写“永远必须成立什么”,再写类型;不要先创建一堆字段,最后才用注释解释哪些组合不合法。
二、元组:轻量组合,但不要承载过多语义
函数需要同时返回最小和最大工时,可以先用元组:
rust
fn estimate_range(hours: u8) -> (u8, u8) {
(hours.saturating_sub(1), hours.saturating_add(2))
}
fn main() {
let range = estimate_range(5);
println!("{}..={}", range.0, range.1);
let (min, max) = range;
println!("最少 {min} 小时,最多 {max} 小时");
}
元组长度固定,各位置可以是不同类型;既能用 .0 访问,也能用模式解构。它适合短生命周期的临时组合,但 (u8, u8) 没有说明两个位置的业务含义。语义稳定后,应改成命名结构体:
rust
struct EstimateRange {
min: u8,
max: u8,
}
元组结构体则适合给基础值增加类型身份:
rust
struct WorkItemId(u64);
let id = WorkItemId(42);
println!("工作项编号:{}", id.0);
WorkItemId 与普通 u64 不再是同一类型,可以减少把用户编号、订单编号和工作项编号混用的机会。
三、结构体与数组:描述一个完整工作项
先组合「同时存在」的字段:
rust
#[derive(Debug)]
struct WorkItem {
id: u64,
title: String,
estimate_hours: u8,
tags: [String; 2],
}
let item = WorkItem {
id: 1,
title: String::from("整理 Rust 学习笔记"),
estimate_hours: 6,
tags: [String::from("学习"), String::from("Rust")],
};
println!("{}: {}", item.id, item.title);
println!("{item:#?}");
#[derive(Debug)] 让编译器生成调试输出能力。它适合开发期观察数据,不等于面向用户的正式展示格式。
当变量名与字段名相同,可以使用字段初始化简写:
rust
fn build_item(id: u64, title: String) -> WorkItem {
WorkItem {
id,
title,
estimate_hours: 1,
tags: [String::from("待分类"), String::from("默认")],
}
}
也可以基于旧值构造新值:
rust
let copied = WorkItem {
id: 2,
title: String::from("复核学习笔记"),
..item
};
..item 不是继承,也不保证完整复制。String 等非 Copy 字段可能被移动,旧值之后不能再作为完整值使用。
数组 [T; N] 把元素类型与长度同时写进类型。[String; 2] 与 [String; 3] 是不同类型,适合表达「恰好两个标签」。安全访问优先考虑 .get():
rust
match copied.tags.get(2) {
Some(tag) => println!("第三个标签:{tag}"),
None => println!("这个工作项只有两个标签"),
}
直接使用 tags[2] 越界会在运行时 panic;.get(2) 返回 Option<&String>,调用者必须处理没有值的情况。若标签数量需要动态增减,应改用 Vec<T>。
四、枚举:让无效状态不可表示
工作项状态不应只是三个字符串,因为不同状态携带的数据不同:
rust
#[derive(Debug, Clone, Copy)]
enum Priority {
Low,
Medium,
High,
}
#[derive(Debug)]
enum WorkItemStatus {
Todo,
Doing { assignee: String },
Done { needs_review: bool },
}
三个变体属于同一个 WorkItemStatus 类型,却分别携带不同数据:
rust
let todo = WorkItemStatus::Todo;
let doing = WorkItemStatus::Doing {
assignee: String::from("Lin"),
};
let done = WorkItemStatus::Done {
needs_review: true,
};
这样建模后:
- Todo 根本没有负责人字段;
- Doing 一旦存在,就一定有负责人;
- Done 一旦存在,就一定有复盘标记;
- 无法构造「Doing 但负责人为空」这种结构性非法状态。
把状态加入工作项:
rust
#[derive(Debug)]
struct WorkItem {
id: u64,
title: String,
priority: Priority,
status: WorkItemStatus,
estimate_hours: u8,
tags: [String; 2],
}
标准库的 Option<T> 也是枚举:Some(T) 表示有值,None 表示没有值。Rust 不提供可随处出现的 null,而是让「缺失」成为必须处理的类型分支。
五、match:判断、解构与返回值一次完成
给状态生成展示文本:
rust
fn status_text(status: &WorkItemStatus) -> String {
match status {
WorkItemStatus::Todo => String::from("待处理"),
WorkItemStatus::Doing { assignee } => format!("进行中:{assignee}"),
WorkItemStatus::Done { needs_review: true } => String::from("已完成,待复盘"),
WorkItemStatus::Done { needs_review: false } => String::from("已完成"),
}
}
match 是表达式,每个分支产生一个值。它还要求穷尽:若新增 Cancelled,编译器会指出遗漏的匹配位置。这是枚举状态比字符串状态可靠的关键原因。
只读取携带 String 的枚举时,优先匹配引用,避免移动内部数据:
rust
fn print_status(item: &WorkItem) {
match &item.status {
WorkItemStatus::Todo => println!("{} 还没开始", item.title),
WorkItemStatus::Doing { assignee } => println!("由 {assignee} 处理"),
WorkItemStatus::Done { needs_review } => println!("复盘:{needs_review}"),
}
}
_ 可以忽略整个值,.. 可以忽略结构中剩余字段:
rust
match item {
WorkItem { id, title, .. } => println!("#{id} {title}"),
}
但核心业务分支不要过早使用 _,否则新增枚举变体时不会得到遗漏提醒。
模式还可组合范围、绑定与守卫:
rust
fn urgency(item: &WorkItem) -> &'static str {
match item {
WorkItem {
priority: Priority::High,
estimate_hours: hours @ 8..=u8::MAX,
..
} => {
println!("大型高优工作项:{hours} 小时");
"立即拆分并开始"
}
WorkItem { estimate_hours, .. } if *estimate_hours <= 1 => "快速完成",
_ => "正常排期",
}
}
hours @ pattern 在匹配的同时绑定值;守卫 if ... 负责模式语法不便表达的额外条件。
六、if let 与 matches!:只关心一个模式
如果只关心「进行中」,无需写完整 match:
rust
if let WorkItemStatus::Doing { assignee } = &item.status {
println!("当前负责人:{assignee}");
}
如果只需要布尔值,使用 matches!:
rust
let is_high = matches!(item.priority, Priority::High);
let is_active = matches!(
&item.status,
WorkItemStatus::Todo | WorkItemStatus::Doing { .. }
);
let owned_by_lin = matches!(
&item.status,
WorkItemStatus::Doing { assignee } if assignee == "Lin"
);
选择规则:
- 需要处理所有分支或产生返回值:match;
- 只关心一个模式:if let;
- 只需要真假:matches!。
如果「不匹配」也有业务意义,就不要为了少写几行而使用 if let,显式 match 更容易审查。
七、方法:把合法状态转换放回类型身边
用 impl 为工作项定义构造、查询与变更:
rust
impl WorkItem {
fn new(
id: u64,
title: &str,
priority: Priority,
estimate_hours: u8,
tags: [String; 2],
) -> Self {
Self {
id,
title: title.to_string(),
priority,
status: WorkItemStatus::Todo,
estimate_hours,
tags,
}
}
fn start(&mut self, assignee: &str) -> bool {
match self.status {
WorkItemStatus::Todo => {
self.status = WorkItemStatus::Doing {
assignee: assignee.to_string(),
};
true
}
_ => false,
}
}
fn finish(&mut self, needs_review: bool) -> bool {
match self.status {
WorkItemStatus::Doing { .. } => {
self.status = WorkItemStatus::Done { needs_review };
true
}
_ => false,
}
}
fn status_text(&self) -> String {
status_text(&self.status)
}
fn is_urgent(&self) -> bool {
matches!(self.priority, Priority::High)
&& !matches!(&self.status, WorkItemStatus::Done { .. })
}
}
接收者表达方法权限:
| 写法 | 含义 | 典型用途 |
|---|
| &self | 只读借用 | 查询、格式化、计算 |
| &mut self | 可变借用 | 修改字段、转换状态 |
| self | 取得所有权 | 消耗或转换成另一类型 |
WorkItem::new 没有 self 参数,是关联函数。new 不是关键字,只是 Rust 社区常用的构造命名。
调用主流程:
rust
fn main() {
let mut item = WorkItem::new(
1,
"整理 Rust 学习笔记",
Priority::High,
6,
[String::from("学习"), String::from("Rust")],
);
println!("{}", item.status_text());
assert!(item.start("Lin"));
assert!(!item.start("Kai"));
assert!(item.finish(true));
println!("{}", item.status_text());
}
这里用 bool 保持示例简洁。工程代码通常应使用第 6 章的 Result<T, E> 解释「为什么转换失败」。
八、常见误区
- 用字符串模拟有限状态:"hight" 也能编译;枚举能消灭拼写分叉并支持穷尽检查。
- 用多个布尔字段表示互斥状态:is_todo、is_doing、is_done 可以同时为真;枚举天然保证三选一。
- 把 ..old 当完整复制:非 Copy 字段可能被移动,旧值不一定还能使用。
- 按值匹配只读数据:枚举携带 String 时可能移动字段;观察数据时匹配 &value。
- 核心匹配过早使用 _:会吞掉未来新增变体,失去编译器提醒。
- 所有逻辑都塞进 impl:方法适合类型自身行为;跨多个对象、外部服务和持久化的流程应放在更高层模块。
九、实践练习
练习一:为标识建立类型身份
定义 WorkItemId(u64) 与 UserId(u64),让二者不能被误传。为它们派生必要的比较和调试 Trait,并写一个只接收 WorkItemId 的查询函数。
练习二:重构非法状态
先用 active: bool、owner: Option<String>、reviewed: bool 描述工作项,再改成携带数据的枚举。列出旧模型能构造、而新模型无法构造的矛盾组合。
练习三:完整处理状态
实现 status_text(&self) -> String,使用 match 覆盖所有状态。随后给枚举增加 Blocked { reason: String },观察编译器如何指出遗漏分支。
练习四:约束状态转换
实现 start 与 finish 方法,让非法转换返回 Result。不要直接公开状态字段,确保调用方只能通过方法改变状态。
练习五:选择集合类型
分别用 [String; 2] 与 Vec<String> 表示标签,说明固定长度和动态长度对 API、验证与内存布局意味着什么。
十、总结
- 建模应从不变量出发,而不是从字段清单出发。
- 结构体表达多个字段同时存在,枚举表达有限状态中的互斥选择。
- 携带数据的枚举能让每种状态只保存自己需要的信息,从结构上排除矛盾组合。
- match 同时完成分支、解构与结果返回,并在状态扩展时帮助发现遗漏。
- if let 适合只关心单一模式,matches! 适合得到布尔判断。
- 方法应维护类型不变量,把合法状态转换放在数据旁边,而不是散落在调用方。
- 好的类型设计会让错误代码难写,让正确用法自然出现。
十一、下一章
现在的工作项模型足够准确,但还缺少复用能力:工作项和里程碑如何共享行为?容器如何支持不同类型?返回引用时,编译器如何确认它不会悬垂?
下一章进入 第5章:抽象能力。
- Rust 深度教程
- 第3章:所有权
- 第5章:抽象能力
- Claude Code Harness 深度教程 —— 连续教程的结构参考
- Pi-Agent 深度教程 —— 技术概念的递进组织参考