课程概览 · 第 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");
// 只有 labels 从 old 移走,old 不能再整体使用。
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,
}

自测

  1. “构造器是唯一构造路径”在哪个模块边界内并不成立?pub state 受到标题私有性的保护吗?
  2. rename(" ") 失败后,原标题应该是什么?代码怎样保证这一点?
  3. field_flow.rs 更新后,old.title 为什么仍可读,old 为什么不能整体使用?
  4. &self、&mut self、self 对调用者分别要求什么?
  5. 小值建议与 Copy 的语言限制有何区别?含 f64 为什么不能派生 Eq?

参考答案

  1. 定义模块内部仍可直接造值;模块外不能访问私有字段。公开状态可直接替换,不受标题校验保护。
  2. 仍是原标题;先检查非空,成功才赋值。
  3. 显式替换的标题没有从旧值移走;未指定的 labels 已移动,旧值因此不完整。
  4. 共享读取、独占修改、交出所有权;非 Copy 的按值接收者会移动原值。
  5. 语言要求字段可复制且类型不实现 Drop,没有统一大小上限;NaN 不满足 Eq 的相等关系。