课程概览 · 第 7 章

上一篇:所有权、借用与生命周期:用数据流理解编译器

下一篇:枚举:把状态、缺失和失败建模为类型

结构体(struct)把一组相关的数据放进一个有名字、有类型的整体。配合 impl,它还能定义行为;配合私有字段和构造器,它还能保证值一创建就是合法的。第 6 章你已经看到了"字段被移动"的后果,本章把这些工具组装成一个能自我保护的领域模型。

日常 Rust 代码的主线很简单:用命名结构体表达数据,用方法表达操作,用所有权表达数据如何流动。元组结构体、Builder 和内存布局控制都重要,但应在确实需要时引入。

结构体通过构造器维护不变量、私有字段封装状态,以及更新和解构时字段所有权的流向
图:类型先把值创建为合法状态,再以方法维持不变量;更新和解构时仍需逐字段检查所有权。

学习目标与默认选择

学完本章你应当能够:

  • 在命名结构体、元组结构体(newtype)、单元结构体之间做出正确选择。
  • 用私有字段 + 校验构造器维护不变式:TaskId::new(0) 返回 Err,空标题、空 label 无法进入 Task
  • 说明 &self / &mut self / self 三种接收者对调用者的含义。
  • 按需 derive Debug / Clone / Copy / PartialEq / Eq / Hash / Default
  • 在字面量、校验构造器、Builder 之间按字段数量与校验需求做决策。

默认选择:字段少、全部必填、无复杂校验时直接用结构体字面量;需要校验或隐藏内部表示时用 new / from_* 构造器;可选字段多、最后统一校验才上 Builder。repr 相关属性只在 FFI 边界考虑。

核心模型:被校验的 TaskIdTask 聚合

本章所有示例围绕同一个领域模型展开。先看主角:一个拒绝 0 的 TaskId newtype,和一个只接受非空标题与非空 label 的 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
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TaskId(u64);

#[derive(Debug, PartialEq, Eq)]
pub enum TaskIdError {
Zero,
}

impl TaskId {
/// 构造一个任务 ID。0 永远不合法。
pub fn new(value: u64) -> Result<TaskId, TaskIdError> {
if value == 0 {
Err(TaskIdError::Zero)
} else {
Ok(TaskId(value))
}
}

pub fn value(self) -> u64 {
self.0
}
}

fn main() {
let ok = TaskId::new(42);
let rejected = TaskId::new(0);

assert_eq!(ok.unwrap().value(), 42);
// 关键不变式:0 被拒绝,且错误类型有名字,可被调用方匹配
assert_eq!(rejected, Err(TaskIdError::Zero));
}

字段 TaskId(u64) 保持私有:模块外只能通过 TaskId::new 创建值,不可能直接构造 TaskId(0)。错误用命名枚举 TaskIdError 而不是字符串,调用方能用 match 精确处理(第 9 章),错误内容也不会拼错。

命名结构体:Task 聚合与不变式

Task 是一个聚合(aggregate):它把标识、标题、状态、标签放进一个整体,并用方法保证每条修改都合法。

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
// 本例独立完整:TaskId 在示例内部重新定义(见上一节的完整版)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TaskId(u64);

impl TaskId {
pub fn new(value: u64) -> Result<TaskId, String> {
if value == 0 {
Err("TaskId 不能为 0".to_string())
} else {
Ok(TaskId(value))
}
}
}

pub struct Task {
pub id: TaskId,
title: String, // 私有:非空不变式由方法维护
pub state: TaskState,
labels: Vec<String>, // 私有:每个元素非空
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskState {
Todo,
InProgress { started_at: u64 },
Done { finished_at: u64 },
Cancelled { reason: String },
}

#[derive(Debug, PartialEq, Eq)]
pub enum TaskError {
EmptyTitle,
EmptyLabel,
DuplicateLabel(String),
}

impl Task {
/// 唯一的入口:创建时校验全部不变式。
pub fn new(id: TaskId, title: &str, labels: &[&str]) -> Result<Task, TaskError> {
if title.trim().is_empty() {
return Err(TaskError::EmptyTitle);
}
let mut validated = Vec::new();
for label in labels {
if label.trim().is_empty() {
return Err(TaskError::EmptyLabel);
}
let owned = (*label).to_string();
if validated.contains(&owned) {
return Err(TaskError::DuplicateLabel(owned));
}
validated.push(owned);
}
Ok(Task {
id,
title: title.trim().to_string(),
state: TaskState::Todo,
labels: validated,
})
}

/// 重命名:拒绝空标题,成功后原值仍是合法 Task。
pub fn rename(&mut self, new_title: &str) -> Result<(), TaskError> {
if new_title.trim().is_empty() {
return Err(TaskError::EmptyTitle);
}
self.title = new_title.trim().to_string();
Ok(())
}

/// 新增标签:拒绝空标签与重复标签。
pub fn add_label(&mut self, label: &str) -> Result<(), TaskError> {
let owned = label.trim().to_string();
if owned.is_empty() {
return Err(TaskError::EmptyLabel);
}
if self.labels.contains(&owned) {
return Err(TaskError::DuplicateLabel(owned));
}
self.labels.push(owned);
Ok(())
}

/// 只读访问器:只暴露真正需要的视图。
pub fn title(&self) -> &str {
&self.title
}

pub fn labels(&self) -> &[String] {
&self.labels
}
}

fn main() {
let id = TaskId::new(7).unwrap();
let mut task = Task::new(id, "ship release notes", &["docs", "release"]).unwrap();

// 不变式被维持:空标题和空 label 都进不来
assert_eq!(task.rename(" "), Err(TaskError::EmptyTitle));
assert_eq!(task.add_label(" "), Err(TaskError::EmptyLabel));
assert_eq!(task.add_label("docs"), Err(TaskError::DuplicateLabel("docs".to_string())));

// 合法操作成功,原值继续可用
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", "release", "urgent"]);

// 构造入口同样拒绝空标题
assert_eq!(
Task::new(id, " ", &[]).map(|_| ()), // 只比较错误分支
Err(TaskError::EmptyTitle)
);
}

这一段集中体现了本章的主题:

  • 不变式集中在构造器Task::new 是唯一能造出 Task 的路径(字段私有),所以"title 非空、labels 元素非空"在创建时刻就成立。
  • 每条修改方法重新校验rename / add_label 返回 Result,非法输入可观测地失败,而不是被静默忽略。
  • 读操作返回借用title() 返回 &strlabels() 返回 &[String],不复制数据、不拿走所有权。

TaskState 的状态转移方法在第 8 章展开,这里只把它作为 Task 的一个字段。)

字段初始化简写与 ..old 更新语法

局部变量与字段同名时可以省略重复部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}

fn origin_with_offset(y: i32) -> Point {
let x = 0;
Point { x, y } // 等价于 x: x, y: y
}

fn main() {
let p = origin_with_offset(5);
assert_eq!((p.x, p.y), (0, 5));
}

更新语法 ..old 从旧值补齐未写出的字段,但未写出的非 Copy 字段会被移动

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
#[derive(Debug)]
struct Task {
id: u64,
title: String,
labels: Vec<String>,
}

fn main() {
let old = Task {
id: 1,
title: String::from("ship release notes"),
labels: vec![String::from("docs")],
};

let reopened = Task {
title: String::from("ship v2 release notes"),
..old
};

assert_eq!(reopened.id, 1); // u64 是 Copy:复制
assert_eq!(reopened.labels.len(), 1); // Vec<String>:移动
// old 发生了部分移动(title、labels 已不可用),不能再整体使用:
// println!("{old:?}"); // 无法编译:borrow of partially moved value
assert_eq!(old.id, 1); // 未移动的 Copy 字段仍可读
}

这就是第 6 章部分移动的落点:..old 不是复制。需要两份独立数据时显式 clone(),让成本一眼可见;不要为了消除报错而无意识地到处 clone。

解构:取字段,还是借字段

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
#[derive(Debug)]
struct Task {
id: u64,
title: String,
labels: Vec<String>,
}

fn main() {
let task = Task {
id: 7,
title: String::from("ship release notes"),
labels: vec![String::from("docs")],
};

// 按值解构:title、labels 被移动进新变量
let Task { id, title, .. } = task;
assert_eq!(id, 7);
assert_eq!(title, "ship release notes");

// 按引用解构:只借用,原值保持完整
let task2 = Task {
id: 8,
title: String::from("write postmortem"),
labels: vec![],
};
let Task { id: id2, title: title2, .. } = &task2;
assert_eq!(*id2, 8);
assert_eq!(title2, "write postmortem");
assert_eq!(task2.labels.len(), 0); // task2 仍完整可用
}

按值解构把非 Copy 字段移动出去(task 之后不完整);按引用解构得到 &String&u64,原值原封不动。这正是"取得值"和"借来查看"的区别。

元组结构体、newtype 与单元结构体

元组结构体有名字、字段无名,通过 .0 访问。最重要的用途是 newtype:给同一底层类型赋予不同语义,把"别传错参数"的人为约定变成编译器保证。

1
2
3
4
5
6
7
8
9
struct UserId(u64);
struct OrderId(u64);

fn find_user(_id: UserId) {}

fn main() {
find_user(UserId(42));
// find_user(OrderId(42)); // 编译错误:expected UserId, found OrderId
}

本章开头的 TaskId 是更完整的 newtype:不仅区分类型,还封装了"非零"不变式。实践建议:newtype 只实现真正有意义的方法,通常不要实现 DerefTaskIdu64 不是同一概念,暴露底层类型的全部方法会破坏这层语义边界。

单元结构体(如 struct JsonFormat;)没有字段,主要用作类型标记或 trait 实现载体;日常业务代码较少直接使用,知道它存在即可。

字段的可变性与可见性

可变性属于绑定,不属于单个字段:

1
2
3
4
5
6
7
8
9
10
11
12
#[derive(Debug)]
struct Point { x: i32, y: i32 }

fn main() {
let mut p = Point { x: 1, y: 2 };
p.x = 10; // p 是 mut 绑定:所有字段都可改
assert_eq!(p.x, 10);

let q = Point { x: 1, y: 2 };
// q.x = 10; // 错误:q 不是 mut
assert_eq!(q.x, 1);
}

字段默认对定义它的模块私有,pub struct 不代表字段自动公开。上面的 Task 正是靠这一点守住不变式:模块外代码只能调用 Task::new / rename / add_label,不能绕过校验直接改 title。规则很简单:

  • 随便改也不会破坏规则的字段(如 idstate)可以公开。
  • 改了会破坏不变式的字段保持私有,配上有业务含义的方法。

不要机械地为每个字段生成 getter/setter;公开真正有意义的操作即可。如果只有少数内部状态需要在共享引用下改变,应明确使用 CellRefCell 或锁等内部可变性工具(第 15 章),而不是把它当默认设计。

impl:把行为放在类型旁边

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
struct Rectangle {
width: u32,
height: u32,
}

impl Rectangle {
// 关联函数:没有 self,通过 Rectangle::square 调用
fn square(size: u32) -> Self {
Self { width: size, height: size }
}

// 只读:不获取所有权
fn area(&self) -> u32 {
self.width * self.height
}

// 修改自身:调用者必须持有 mut 绑定
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
rect.scale(2); // 自动借用为 &mut rect
assert_eq!(rect.area(), 36);
let square = rect.into_square(); // rect 被移动
assert_eq!(square.area(), 36);
}
接收者用途调用后原值
&self查询、计算、格式化仍可用
&mut self修改状态仍可用
self转换、消费、拆解被移动

实践中先选 &self,它对调用者限制最少。只有语义就是修改时才用 &mut self;名称为 into_* 的方法通常取 self,明确告诉调用者会发生所有权转移。new 不是关键字,只是常见的关联函数命名:无失败构造返回 Self,需要校验时返回 Result<Self, E>(如 TaskId::newTask::new);多种自然构造方式用 from_partswith_capacity 等语义化名称。

derive:按需获得通用能力

derive用途何时添加
Debug{:?} 调试输出几乎总应添加
Clone显式 .clone()确实需要独立副本
Copy赋值/传参隐式复制小型、纯值类型
PartialEqEq==!=测试或业务需判等
Hash哈希集合的键通常与 Eq 同用
DefaultT::default()存在合理默认状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::collections::HashSet;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Tag {
name: String,
}

fn main() {
let a = Tag { name: String::from("docs") };
let b = a.clone(); // 显式深复制:值相等的两份数据
assert_eq!(a, b); // PartialEq
assert_ne!(a, Tag { name: String::from("release") });

// Hash + Eq:HashSet 按值去重,克隆出的相等值不会重复入库
let mut seen = HashSet::new();
assert!(seen.insert(a)); // 首次插入成功
assert!(!seen.insert(b)); // 相等值:去重
assert!(seen.insert(Tag { name: String::from("release") }));
assert_eq!(seen.len(), 2);
}

Clone 是显式复制,可能会复制堆数据;Copy 是隐式复制,只适合 TaskIdPoint { x: i32, y: i32 } 这类小值。StringVec<T>Box<T> 都不是 Copy,含它们的结构体(如 Task)不能 derive Copy。含 f64 的类型可以 derive PartialEq 但不能 derive EqNaN != NaN,不满足"值一定等于自身"的要求。

Default 与部分配置

#[derive(Default)] 让字段分别取默认值(整数为 0、布尔为 false、字符串为空)。对业务配置,通常应手写有意义的默认值:

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
#[derive(Debug)]
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);
}

如果"默认值"本身没有意义(TaskId 没有–0 不合法),或者每次构造都必须校验(Task),就不要实现 Default,用构造器或 Builder。

字面量、构造器和 Builder 怎么选

情况推荐
字段少、全部必填、无复杂校验结构体字面量
需要校验或隐藏内部表示new / from_* 构造器
可选字段多,最后要统一校验Builder

Builder 解决的是"大量可选参数",不是每个类型都必须拥有的样板:

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
#[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: impl Into<String>) -> Self {
self.owner = Some(owner.into());
self
}

fn label(mut self, label: impl Into<String>) -> Self {
self.labels.push(label.into());
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,
TaskFilter {
owner: String::from("alice"),
labels: vec![String::from("docs")],
limit: 20,
}
);

// 统一校验集中在 build:缺 owner 可观测地失败
let missing = TaskFilterBuilder::default().build();
assert_eq!(missing, Err("owner is required"));
}

设置器返回 Self 形成链式调用;可选值和校验集中在 build。只有两三个字段时,直接使用字面量通常更清晰。

组合优于继承

Rust 没有类继承。复用数据时,把另一个结构体作为字段;复用行为时,使用 trait(第 10 章)。

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
#[derive(Debug)]
struct AuditInfo {
created_by: String,
created_at: u64,
}

#[derive(Debug)]
struct Task {
id: u64,
title: String,
audit: AuditInfo, // has-a:Task 拥有一个 AuditInfo
}

impl Task {
fn created_by(&self) -> &str {
&self.audit.created_by // 显式委托需要的能力
}
}

fn main() {
let task = Task {
id: 7,
title: String::from("ship release notes"),
audit: AuditInfo {
created_by: String::from("alice"),
created_at: 1723000000,
},
};
assert_eq!(task.created_by(), "alice");
}

Task 拥有一个 AuditInfo,是"has-a"关系。它不会自动暴露 AuditInfo 的全部能力;需要什么能力就显式委托什么能力,数据和行为的来源都清楚。TaskIdTask 也是组合:聚合持有被校验的标识,而不是裸 u64

内存布局:ABI 边界,不是性能开关

绝大多数业务结构体不需要考虑布局。默认 #[repr(Rust)] 下,字段的具体偏移和排列不是程序契约:不要按源码字段顺序推断内存顺序,也不要把普通结构体直接当成网络包或文件格式。

只有与 C 交互(FFI 边界)时才使用 #[repr(C)]

1
2
3
4
5
6
7
8
9
10
11
#[repr(C)]
struct CHeader {
magic: u32,
version: u16,
flags: u16,
}

fn main() {
let header = CHeader { magic: 0x544b, version: 1, flags: 0 };
println!("{:?}", header.magic);
}

#[repr(C)] 用于遵循目标平台的 C ABI,不是性能开关,也不会自动解决 FFI 中的指针、所有权和字符串问题(第 17 章展开)。单字段 newtype(如 TaskId)若确实需要透明 ABI,应显式标注 #[repr(transparent)]–它保证 newtype 与底层类型布局一致;同样是 ABI 承诺,不是优化建议。#[repr(packed)] 会带来未对齐访问风险,普通业务类型不要使用。

为什么可行

本章的种种规则共享同一个机制:Rust 没有绕过类型系统的运行时后门

  • 不变式能守住,是因为字段私有 + 构造器是唯一的造值路径:编译器保证模块外无法构造 TaskId(0) 或空标题的 Task,规则不需要靠运行时检查兜底。
  • 所有权流向可预测,是因为移动/复制是类型系统语义而非库约定:..old 移动非 Copy 字段、self 接收者消耗原值,都是编译器强制执行的行为,读签名就能推理。
  • derive 可靠,是因为 DebugClonePartialEq 等只是编译器按字段生成的普通实现,语义与手写一致;不 derive 就没有对应能力,不会暗中生效。
  • 布局默认不承诺,是因为 repr(Rust) 保留重排字段的自由(对齐、填充优化);只有显式 repr 才把布局变成契约。

常见误区

  1. ..old 当复制。Copy 字段会移动;需要两份数据时显式 clone。
  2. 公有字段 + 手工校验。 字段公开后任何代码都能绕过校验;不变式靠"私有字段 + 构造器 + 方法"守住。
  3. 只读却传入 Task 函数不需要拥有值时,参数写 &Task
  4. 为了方便 derive Copy 它是隐式复制的语义承诺,不是解决所有权错误的工具;含 String/Vec 的类型根本无法 derive。
  5. 给每个字段写 getter/setter。 公开真正有意义的业务操作(renameadd_label)即可。
  6. Display 调试。 调试优先 #[derive(Debug)]{:?}Display 只服务于面向用户的自然文本。
  7. 过早使用 Builder 或 repr 字面量与普通结构体往往已经是最清晰的方案;repr 只属于 FFI 边界。

自测

  1. 为什么 TaskId 的字段要私有?如果公有,TaskId::new 的校验还有什么意义?
    答案方向: 字段公有时模块外可直接 TaskId(0) 绕过校验,"非零"不变式失效;私有 + new 才能让所有值都经过校验。
  2. ..old 更新语法之后,old 还能整体使用吗?
    答案方向: 不能;未写出的非 Copy 字段被移动,old 处于部分移动状态,只有未被移动的 Copy 字段可读。
  3. rename 为什么返回 Result<(), TaskError> 而不是直接忽略空标题?
    答案方向: 非法输入必须可观测;静默忽略会让调用方以为改名成功。返回命名错误让上层能用 match 分派处理。
  4. 一个含 f64 字段的结构体能 derive Eq 吗?为什么?
    答案方向: 不能;NaN != NaN 违反 Eq 要求的自反性,只能 derive PartialEq
  5. 什么时候才考虑 #[repr(C)]
    答案方向: 仅在与 C 交互的 ABI 边界;它是布局承诺,不是性能建议,业务结构体用默认 repr(Rust)

下一篇让状态本身成为类型:枚举:把状态、缺失和失败建模为类型