课程概览 · 第 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 边界考虑。
核心模型:被校验的 TaskId 与 Task 聚合 本章所有示例围绕同一个领域模型展开。先看主角:一个拒绝 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 { 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 ); 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 #[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, }) } 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 (); 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() 返回 &str、labels() 返回 &[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 } }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 ); assert_eq! (reopened.labels.len (), 1 ); assert_eq! (old.id, 1 ); }
这就是第 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" )], }; 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 ); }
按值解构把非 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 )); }
本章开头的 TaskId 是更完整的 newtype:不仅区分类型,还封装了"非零"不变式。实践建议:newtype 只实现真正有意义的方法,通常不要 实现 Deref–TaskId 和 u64 不是同一概念,暴露底层类型的全部方法会破坏这层语义边界。
单元结构体(如 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 ; assert_eq! (p.x, 10 ); let q = Point { x: 1 , y: 2 }; assert_eq! (q.x, 1 ); }
字段默认对定义它的模块 私有,pub struct 不代表字段自动公开。上面的 Task 正是靠这一点守住不变式:模块外代码只能调用 Task::new / rename / add_label,不能绕过校验直接改 title。规则很简单:
随便改也不会破坏规则的字段(如 id、state)可以公开。 改了会破坏不变式的字段保持私有,配上有业务含义的方法。 不要机械地为每个字段生成 getter/setter;公开真正有意义的操作即可。如果只有少数内部状态需要在共享引用下改变,应明确使用 Cell、RefCell 或锁等内部可变性工具(第 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 { 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转换、消费、拆解 被移动
实践中先选 &self,它对调用者限制最少。只有语义就是修改时才用 &mut self;名称为 into_* 的方法通常取 self,明确告诉调用者会发生所有权转移。new 不是关键字,只是常见的关联函数命名:无失败构造返回 Self,需要校验时返回 Result<Self, E>(如 TaskId::new、Task::new);多种自然构造方式用 from_parts、with_capacity 等语义化名称。
derive:按需获得通用能力derive 用途 何时添加 Debug{:?} 调试输出几乎总应添加 Clone显式 .clone() 确实需要独立副本 Copy赋值/传参隐式复制 小型、纯值类型 PartialEq、Eq==、!=测试或业务需判等 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); assert_ne! (a, Tag { name: String ::from ("release" ) }); 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 是隐式复制,只适合 TaskId、Point { x: i32, y: i32 } 这类小值。String、Vec<T>、Box<T> 都不是 Copy,含它们的结构体(如 Task)不能 derive Copy。含 f64 的类型可以 derive PartialEq 但不能 derive Eq:NaN != 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 , } ); 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, }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 的全部能力;需要什么能力就显式委托什么能力,数据和行为的来源都清楚。TaskId 与 Task 也是组合:聚合持有被校验的标识,而不是裸 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 可靠 ,是因为 Debug、Clone、PartialEq 等只是编译器按字段生成的普通实现,语义与手写一致;不 derive 就没有对应能力,不会暗中生效。布局默认不承诺 ,是因为 repr(Rust) 保留重排字段的自由(对齐、填充优化);只有显式 repr 才把布局变成契约。常见误区 把 ..old 当复制。 非 Copy 字段会移动;需要两份数据时显式 clone。公有字段 + 手工校验。 字段公开后任何代码都能绕过校验;不变式靠"私有字段 + 构造器 + 方法"守住。只读却传入 Task。 函数不需要拥有值时,参数写 &Task。为了方便 derive Copy。 它是隐式复制的语义承诺,不是解决所有权错误的工具;含 String/Vec 的类型根本无法 derive。给每个字段写 getter/setter。 公开真正有意义的业务操作(rename、add_label)即可。用 Display 调试。 调试优先 #[derive(Debug)] 和 {:?};Display 只服务于面向用户的自然文本。过早使用 Builder 或 repr。 字面量与普通结构体往往已经是最清晰的方案;repr 只属于 FFI 边界。自测 为什么 TaskId 的字段要私有?如果公有,TaskId::new 的校验还有什么意义?答案方向: 字段公有时模块外可直接 TaskId(0) 绕过校验,"非零"不变式失效;私有 + new 才能让所有值都经过校验。 ..old 更新语法之后,old 还能整体使用吗?答案方向: 不能;未写出的非 Copy 字段被移动,old 处于部分移动状态,只有未被移动的 Copy 字段可读。rename 为什么返回 Result<(), TaskError> 而不是直接忽略空标题?答案方向: 非法输入必须可观测;静默忽略会让调用方以为改名成功。返回命名错误让上层能用 match 分派处理。一个含 f64 字段的结构体能 derive Eq 吗?为什么?答案方向: 不能;NaN != NaN 违反 Eq 要求的自反性,只能 derive PartialEq。 什么时候才考虑 #[repr(C)]?答案方向: 仅在与 C 交互的 ABI 边界;它是布局承诺,不是性能建议,业务结构体用默认 repr(Rust)。 下一篇让状态本身成为类型:枚举:把状态、缺失和失败建模为类型 。