课程概览 · 第 7 章
上一篇:所有权、借用与生命周期:用数据流理解编译器
下一篇:枚举:把状态、缺失和失败建模为类型
结构体把相关字段组织成有名字的整体,方法为读取、修改和消费值建立接口。真正需要保护业务约束时,私有字段挡住模块外的直接改写,构造器和修改方法执行校验;类型系统保护访问边界,不会自动证明校验逻辑正确。
图:定义模块维护校验规则,模块外通过接口访问私有字段;更新和解构仍遵循所有权规则。前置知识与学习目标
需要掌握第 6 章的移动与借用、Result 和 ?。本章任务模型独立,状态转移在第 8 章单独演示。学完后应能:
- 用命名结构体组织字段,用 newtype 区分领域含义。
- 通过私有字段、校验构造器和修改方法维持明确的不变式。
- 区分
&self、&mut self、self 接收者和字段解构的所有权。 - 按语义选择
derive、Default,按需求引入 Builder。
所有源码仅依赖标准库、Edition 2024。独立示例各自是一份完整 .rs 文件;代码片段的定义和替换位置在块前说明。
命名结构体与 newtype
命名结构体适合字段含义明确的业务数据;元组结构体有类型名、字段无名,通过 .0 访问。newtype 用元组结构体给同一种底层表示不同的意义,例如 TaskId(u64) 不再能误当作任意 u64。单元结构体没有字段,适合作为格式或策略标记。
独立示例(标准库;完整文件 named_struct.rs):字段初始化简写和一个最小 newtype。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| struct Point { x: i32, y: i32 } struct UserId(u64);
fn origin_with_offset(y: i32) -> Point { let x = 0; Point { x, y } }
fn user_number(id: UserId) -> u64 { id.0 }
fn main() { let point = origin_with_offset(5); assert_eq!((point.x, point.y), (0, 5)); assert_eq!(user_number(UserId(42)), 42); }
|
另定义 OrderId(u64) 后,不能把它传给 user_number;这层区分在编译期成立。newtype 不必实现 Deref,否则可能意外暴露底层类型的全部操作。new 不是关键字,只是构造器惯例;无失败构造可以返回 Self,需要校验则返回 Result<Self, E>。
私有字段与校验构造器
下面只保护 ID 非零、标题非空、标签非空且不重复。标题和标签会先 trim。领域驱动设计中可以把这样的整体称作一次“聚合”,但理解代码不依赖这套术语。
独立示例(标准库;完整文件 validated_task.rs):包含 domain 模块全部定义与模块外 main,不沿用第 6 章的 Task。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114
| mod domain { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct TaskId(u64);
#[derive(Debug, PartialEq, Eq)] pub enum TaskIdError { Zero }
impl TaskId { pub fn new(value: u64) -> Result<Self, TaskIdError> { match value { 0 => Err(TaskIdError::Zero), value => Ok(Self(value)), } } pub fn value(self) -> u64 { self.0 } }
#[derive(Debug, Clone, PartialEq, Eq)] pub enum TaskState { Todo, InProgress { started_at: u64 }, Done { finished_at: u64 }, Cancelled { reason: String }, }
#[derive(Debug)] pub struct Task { pub id: TaskId, title: String, pub state: TaskState, labels: Vec<String>, }
#[derive(Debug, PartialEq, Eq)] pub enum TaskError { EmptyTitle, EmptyLabel, DuplicateLabel(String), }
impl Task { pub fn new( id: TaskId, title: &str, labels: &[&str], ) -> Result<Self, TaskError> { let mut task = Self { id, title: String::new(), state: TaskState::Todo, labels: Vec::new(), }; task.rename(title)?; for label in labels { task.add_label(label)?; } Ok(task) }
pub fn rename(&mut self, title: &str) -> Result<(), TaskError> { let title = title.trim(); if title.is_empty() { return Err(TaskError::EmptyTitle); } self.title = title.to_string(); Ok(()) }
pub fn add_label(&mut self, label: &str) -> Result<(), TaskError> { let label = label.trim().to_string(); if label.is_empty() { return Err(TaskError::EmptyLabel); } if self.labels.contains(&label) { return Err(TaskError::DuplicateLabel(label)); } self.labels.push(label); Ok(()) }
pub fn title(&self) -> &str { &self.title } pub fn labels(&self) -> &[String] { &self.labels } } }
use domain::{Task, TaskError, TaskId, TaskIdError, TaskState};
fn main() { assert_eq!(TaskId::new(0), Err(TaskIdError::Zero)); let id = TaskId::new(7).unwrap(); assert_eq!(id.value(), 7); let mut task = Task::new(id, " ship release notes ", &["docs"]) .unwrap(); assert_eq!(task.id, id); assert_eq!(task.state, TaskState::Todo); assert_eq!(task.rename(" "), Err(TaskError::EmptyTitle)); assert_eq!(task.title(), "ship release notes"); assert_eq!(task.add_label(" "), Err(TaskError::EmptyLabel)); assert_eq!( task.add_label("docs"), Err(TaskError::DuplicateLabel(String::from("docs"))) ); assert_eq!(task.labels(), ["docs"]); task.rename("ship v2 release notes").unwrap(); task.add_label("urgent").unwrap(); assert_eq!(task.title(), "ship v2 release notes"); assert_eq!(task.labels(), ["docs", "urgent"]); assert!(matches!( Task::new(id, " ", &[]), Err(TaskError::EmptyTitle) )); assert!(matches!( Task::new(id, "valid", &[" "]), Err(TaskError::EmptyLabel) )); }
|
“只能通过构造器”仅针对定义模块外。domain 内仍可直接构造 TaskId(0) 或空标题,因此模块实现和测试要共同维护规则。Task::new 内先建立暂时未校验的局部值,只有校验成功才把它交出去;错误路径的局部值自动清理。
当前 pub id、pub state 仍可被模块外替换,它们没有受到标题和标签私有性的保护。这不是完整状态机封装:如果需要禁止任意状态赋值,就应进一步隐藏状态并提供转移 API。本章不把私有标题误称为对所有业务约束的证明。
故意失败:编译错误(标准库;以 validated_task.rs 为基础,只用以下 main 替换原 main,保留 domain 与 use):模块外直接构造私有字段。
1 2 3 4 5 6 7 8 9 10
| fn main() { let id = TaskId::new(7).unwrap(); let task = Task { id, title: String::new(), state: TaskState::Todo, labels: Vec::new(), }; println!("{task:?}"); }
|
这里失败是 title 和 labels 的字段可见性,不是空字符串自动触发编译期校验。第 14 章将进一步解释模块边界。
修改方法:失败不能污染原值
rename 先检查、后赋值,空标题失败时原标题不变;add_label 只在全部检查通过后 push。这让调用者能够处理错误并继续使用原对象。不要通过公开字段或返回 &mut String 绕开这些检查,也不必机械生成每个字段的 setter。
可变性属于绑定,不属于单个字段:let mut task 才能调用 &mut self 方法,但即使绑定可变,模块外仍不能访问私有字段。title() 和 labels() 返回借用视图,不复制也不拿走内部数据。
接收者:读取、修改和消费
独立示例(标准库;完整文件 receivers.rs):关联函数和三种接收者。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| struct Rectangle { width: u32, height: u32 }
impl Rectangle { fn square(size: u32) -> Self { Self { width: size, height: size } } fn area(&self) -> u32 { self.width * self.height } fn scale(&mut self, factor: u32) { self.width *= factor; self.height *= factor; } fn into_square(self) -> Self { Self::square(self.width.max(self.height)) } }
fn main() { let mut rect = Rectangle::square(3); assert_eq!(rect.area(), 9); rect.scale(2); assert_eq!(rect.area(), 36); let square = rect.into_square(); assert_eq!(square.area(), 36); }
|
| 接收者 | 含义 | 调用后原值 |
|---|
&self | 共享读取 | 仍可用 |
&mut self | 独占修改 | 仍可用 |
self | 获取所有权 | 非 Copy 值被移动 |
方法调用会按需要自动借用。优先选择语义准确的接收者,而不是为了方便统一取 self;into_* 通常明确表示消费式转换。这个几何例仅演示接收者,未建立任意大整数输入都不会溢出的保证。
解构和更新语法
按值解构可能移动非 Copy 字段;按引用解构得到字段引用。更新语法 ..old 补齐未指定字段,非 Copy 字段会移动,而显式被替换的旧字段不会因此移动。
独立示例(标准库;完整文件 field_flow.rs):最小 Task 与校验模型不同,专门观察字段流向。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| struct Task { id: u64, title: String, labels: Vec<String> }
fn main() { let old = Task { id: 1, title: String::from("old title"), labels: vec![String::from("docs")], }; let reopened = Task { title: String::from("new title"), ..old }; assert_eq!(old.id, 1); assert_eq!(old.title, "old title"); let Task { id, title, labels } = &reopened; assert_eq!(*id, 1); assert_eq!(title, "new title"); assert_eq!(labels.len(), 1); let Task { title, .. } = reopened; assert_eq!(title, "new title"); assert_eq!(reopened.id, 1); }
|
更新语法不是深复制。需要保留两个完整对象时才选择克隆,并先确认克隆的含义与成本;具有 Drop 的结构体不能这样直接移出字段。
derive 与有意义的 Default
| 能力 | 适用语义 |
|---|
Debug | 开发者调试表示,不是面向用户的稳定格式 |
Clone | 显式克隆,可能复制数据或共享资源 |
Copy | 隐式复制的承诺;字段都必须支持 Copy |
PartialEq、Eq | 按字段比较;Eq 还要求完整的相等关系 |
Hash | 哈希键,需与相等关系一致 |
Default | 存在有意义的默认值 |
独立示例(标准库;完整文件 derive_tag.rs):派生判等和哈希用于去重。
1 2 3 4 5 6 7 8 9 10 11 12
| use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq, Hash)] struct Tag { name: String }
fn main() { let tag = Tag { name: String::from("docs") }; let mut seen = HashSet::new(); assert!(seen.insert(tag.clone())); assert!(!seen.insert(tag)); assert_eq!(seen.len(), 1); }
|
含 String、Vec、Box 的类型不能派生 Copy;“只给小值实现”是接口成本建议,语言没有统一大小上限。含 f64 可以派生 PartialEq,但不能派生 Eq,因为 NaN != NaN。derive 是按字段生成实现,不会理解标题必须非空。
独立示例(标准库;完整文件 default_config.rs):手写业务默认值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| struct ServerConfig { host: String, port: u16, max_connections: usize, }
impl Default for ServerConfig { fn default() -> Self { Self { host: String::from("127.0.0.1"), port: 8080, max_connections: 100, } } }
fn main() { let config = ServerConfig { port: 3000, ..Default::default() }; assert_eq!(config.host, "127.0.0.1"); assert_eq!(config.port, 3000); assert_eq!(config.max_connections, 100); }
|
derive(Default) 取各字段的默认值,不等于业务上的合法默认状态。这里 TaskId 的零值非法、Task 必须校验,因此不提供随手构造的 Default。
深入阅读
Builder:集中处理可选参数
字段少且无校验时用字面量;需要校验用构造器;可选参数多、需要最后统一校验时才考虑 Builder。设置方法返回 Self 以便链式调用,build 消费构建器并报告失败。
独立示例(标准库;完整文件 builder.rs):保留 owner 与标签校验、默认 limit。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| #[derive(Debug, PartialEq, Eq)] struct TaskFilter { owner: String, labels: Vec<String>, limit: usize, }
#[derive(Default)] struct TaskFilterBuilder { owner: Option<String>, labels: Vec<String>, limit: Option<usize>, }
impl TaskFilterBuilder { fn owner(mut self, owner: &str) -> Self { self.owner = Some(owner.to_string()); self } fn label(mut self, label: &str) -> Self { self.labels.push(label.to_string()); self } fn limit(mut self, limit: usize) -> Self { self.limit = Some(limit); self } fn build(self) -> Result<TaskFilter, &'static str> { let owner = self.owner.ok_or("owner is required")?; if owner.trim().is_empty() { return Err("owner must not be empty"); } if self.labels.iter().any(|label| label.trim().is_empty()) { return Err("labels must not be empty"); } Ok(TaskFilter { owner, labels: self.labels, limit: self.limit.unwrap_or(20), }) } }
fn main() { let filter = TaskFilterBuilder::default() .owner("alice").label("docs").build().unwrap(); assert_eq!(filter.owner, "alice"); assert_eq!(filter.labels, ["docs"]); assert_eq!(filter.limit, 20); let limited = TaskFilterBuilder::default() .owner("alice").limit(5).build().unwrap(); assert_eq!(limited.limit, 5); assert_eq!( TaskFilterBuilder::default().build(), Err("owner is required") ); }
|
组合与显式委托
Rust 不提供类继承。复用数据时持有另一个结构体,复用行为时使用第 10 章的 trait;持有字段不代表自动公开它的全部能力。
独立示例(标准库;完整文件 composition.rs):只委托需要的查询。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| struct AuditInfo { created_by: String, created_at: u64 } struct Task { title: String, audit: AuditInfo }
impl Task { fn created_by(&self) -> &str { &self.audit.created_by } }
fn main() { let task = Task { title: String::from("ship release notes"), audit: AuditInfo { created_by: String::from("alice"), created_at: 100, }, }; assert_eq!(task.created_by(), "alice"); assert_eq!(task.audit.created_at, 100); assert_eq!(task.title, "ship release notes"); }
|
布局是边界合同,不是性能开关
默认 repr(Rust) 不承诺字段顺序或偏移,不要据此定义网络包或文件格式。repr(C) 遵循目标平台的 C 布局规则,但不会自动解决字符串、指针、所有权和内部字段的 FFI 安全性;repr(transparent) 可为符合条件的 newtype 承诺透明布局。布局也可能用于其他明确的底层互操作场景,不是普通业务类型的默认选择。repr(packed) 带来未对齐访问风险,详见第 19 章。
代码片段(标准库;独立类型声明,放入 layout.rs 或需要此 FFI 类型的模块;无入口,不依赖前文定义):
1 2 3 4 5 6
| #[repr(C)] struct CHeader { magic: u32, version: u16, flags: u16, }
|
自测
- “构造器是唯一构造路径”在哪个模块边界内并不成立?
pub state 受到标题私有性的保护吗? rename(" ") 失败后,原标题应该是什么?代码怎样保证这一点?field_flow.rs 更新后,old.title 为什么仍可读,old 为什么不能整体使用?&self、&mut self、self 对调用者分别要求什么?- 小值建议与
Copy 的语言限制有何区别?含 f64 为什么不能派生 Eq?
参考答案
- 定义模块内部仍可直接造值;模块外不能访问私有字段。公开状态可直接替换,不受标题校验保护。
- 仍是原标题;先检查非空,成功才赋值。
- 显式替换的标题没有从旧值移走;未指定的
labels 已移动,旧值因此不完整。 - 共享读取、独占修改、交出所有权;非
Copy 的按值接收者会移动原值。 - 语言要求字段可复制且类型不实现
Drop,没有统一大小上限;NaN 不满足 Eq 的相等关系。