📚 Rust 课程系列

  1. 课程概览
  2. 基础语法(一):变量、数据类型与字符串
  3. 切片(Slice):序列的借用视图
  4. 基础语法(二):运算符、表达式与控制流
  5. 函数与输入输出
  6. 所有权、借用与生命周期
  7. 结构体(本文)
  8. 枚举
  9. 模式匹配
  10. 类型系统:泛型、trait 与多态
  11. 集合与容器
  12. 错误处理与 Panic 恢复
  13. 模块、属性与宏
  14. 智能指针、迭代器与闭包
  15. 并发与异步编程
  16. Unsafe Rust 与常用 trait 详解
  17. 工具链、Cargo 与外部 crate
  18. 最佳实践、性能与调试

结构体是 Rust 组织数据的基本手段:把若干相关字段聚合成有名字、有类型的整体,让编译期就能检查「字段是否齐全、是否被错误复用」。相比 C 的结构体,Rust 结构体不仅承载数据,还通过 impl 块承载行为、通过 derive 宏批量获得常用能力、通过字段私有性天然支持封装——是一个「数据 + 行为 + 不变式」的完整单元。

本章与《枚举》《模式匹配》同属「自定义类型」主题:结构体「并列地组合数据」,枚举「互斥地表达可能」,模式匹配则是处理枚举的惯用手段。

三种结构体形式

Rust 提供三种结构体形式,语法、字段访问方式和典型用途各异。选哪种,取决于「字段需不需要名字」「需不需要携带数据」「是否只是为了实现 trait」。

形式语法字段访问典型用途
命名结构体struct Name { field: T }x.field数据记录、业务实体、配置
元组结构体struct Name(T1, T2)x.0 / x.1newtype 包装、坐标、颜色
单元结构体struct Name;无字段类型标记、trait 实现载体

命名结构体

命名结构体最常用:每个字段都有名字,构造与访问都通过字段名。字段名让代码自带文档–读 Rectangle { width, height } 立刻知道两个 u32 各代表什么,而 (u32, u32) 则要靠注释或上下文。

1
2
3
4
5
6
7
8
9
10
11
// 命名结构体:字段有名字,可单独访问与构造
struct Rectangle {
width: u32,
height: u32,
}

let rect = Rectangle {
width: 30,
height: 50,
};
println!("宽 = {}", rect.width); // 通过字段名访问

命名结构体适合字段较多、语义需名字承载的场景。字段超过 3-4 个,或类型相同容易混淆(多个 String、多个 u32)时,命名结构体几乎是唯一合理选择–元组结构体靠 .0.1.2 计数,可读性会迅速崩坏。

元组结构体

元组结构体有名字但字段无名,只能按位置 .0.1 访问。核心价值是给已有类型套上「新名字」,让编译器区分「底层相同但语义不同」的值:

1
2
3
4
5
6
7
8
9
// 元组结构体:字段按位置访问,类型有独立名字
struct Color(u8, u8, u8); // RGB
struct Point(f64, f64); // 经纬度

let orange = Color(255, 165, 0);
println!("R = {}", orange.0); // 按位置访问

let shanghai = Point(31.23, 121.47);
println!("lat = {}", shanghai.0);

🔄 对比:元组结构体介于「带名结构体」和「裸元组」之间。裸元组 (u8, u8, u8) 无类型名,两个三元组可互相赋值,类型系统无法区分;元组结构体 Color(u8, u8, u8) 有独立类型名,ColorPoint 即便底层都是三个 u8 也不能互相赋值。这是「用类型名换类型安全」的关键一步。

元组结构体最常见的用法是单字段形式,即 newtype 模式(详见下节)。多字段形式适合「字段顺序天然有含义且数量很少」的场景,如坐标、颜色、复数–它们本就按分量顺序书写,用 .0/.1 访问反而更贴切。

单元结构体

单元结构体不携带数据,定义时连字段列表都没有,像一个「空的标记」:

1
2
3
4
5
// 单元结构体:不占内存,仅作为类型标记存在
struct AlwaysEqual;
struct RouterMarker;

let _unit = AlwaysEqual; // 实例化不写括号

它看起来「什么都没有」,但在两个场景下不可或缺:

  1. 作为 trait 实现的载体。有时想为某类型实现 trait 以获得行为,但该类型本身不需要状态。例如框架的路由标记、状态机的「空状态」、PhantomData<T> 的底层(它本身是单元结构体,在类型层面「假装」持有 T 而不占运行时空间)。
  2. 作为类型级的「事件」或「信号」。泛型代码里常作「类型参数的占位」,编译期参与类型计算,运行期零开销。
1
2
3
4
5
6
7
8
9
10
use std::marker::PhantomData;

// 单元结构体作为类型标记:参与类型计算,运行期零开销
struct HasLength; // 标记 trait:表示集合有长度
struct NoLength;

struct MyContainer<T, Marker> {
data: Vec<T>,
_marker: PhantomData<Marker>, // 零大小,仅占类型位置
}

三种形式的取舍

三种形式并非语法糖等价。下面的决策流程可快速判断取舍:

flowchart LR
  A["携带数据?"] -->|否| B["单元结构体"]
  A -->|是| C["字段需名字?"]
  C -->|是| D["命名结构体"]
  C -->|否| E["元组结构体"]

💡 提示:常见误区是「元组结构体比命名结构体轻量」。实际上三者编译后的内存布局都由字段决定,元组结构体不省内存也不更快。选择形式的唯一依据是可读性与语义清晰度,非性能。

newtype 模式深入

元组结构体最重要的用途是 newtype 模式:用新名字包装一个已有类型。它零开销(编译后就是被包装类型本身),却带来类型安全与封装两大收益。

零开销区分同底层不同语义

最直接的收益是让编译器区分「底层相同、语义不同」的值。两个函数都接收 f64,一个表示距离、一个表示时间,传反位置时编译器毫无办法:

1
2
3
4
5
6
7
// 不用 newtype:两个 f64 无法区分,传反位置编译器不报错
fn speed(distance: f64, time: f64) -> f64 {
distance / time
}

// speed(10.0, 100.0) 和 speed(100.0, 10.0) 都能编译
// 但只有一个是你要的,错的那次静默地给出错误结果

用 newtype 给每个语义一个独立类型,传反位置就会直接编译失败:

1
2
3
4
5
6
7
8
9
10
// newtype:每个语义一个独立类型,传错位置编译失败
struct Meters(f64);
struct Seconds(f64);

fn speed(d: Meters, t: Seconds) -> f64 {
d.0 / t.0
}

let v = speed(Meters(100.0), Seconds(10.0)); // OK
// speed(Seconds(10.0), Meters(100.0)); // 编译错误:类型不匹配

MetersSeconds 运行期就是两个 f64,无额外开销;编译期则是两个不同类型,编译器拒绝任何混淆。

NASA 火星气候轨道器案例

单位混淆不是学术演练,它炸过真实的航天器。1998 年发射的火星气候轨道器(Mars Climate Orbiter)1999 年 9 月抵达火星时因地面软件单位不匹配而解体:洛克希德·马丁用磅力秒(pound-force·seconds)计算推进器冲量,NASA 喷气推进实验室用牛顿秒(newton·seconds),1 磅力秒 ≈ 4.45 牛顿秒。两套数字在各自系统里都「正确」,却因底层都是浮点数、无类型区分被静默混用,导致轨道插入时探测器过低进入火星大气层烧毁。

如果当时有 newtype 模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 用 newtype 区分单位,NASA 悲剧本可避免
struct PoundForceSeconds(f64);
struct NewtonSeconds(f64);

impl PoundForceSeconds {
fn to_newton(self) -> NewtonSeconds {
NewtonSeconds(self.0 * 4.44822162)
}
}

fn apply_impulse(impulse: NewtonSeconds) { /* ... */ }

let raw = PoundForceSeconds(100.0);
apply_impulse(raw.to_newton()); // 显式转换
// apply_impulse(raw); // 编译错误:类型不匹配

混用直接编译失败,唯一能跨越的方式是显式调用 to_newton–每次转换都是一次「我确认要换单位」的人工确认。这正是 newtype 的精髓:把「容易出错的人肉记忆」变成「编译器强制的类型契约」

🔬 进阶:newtype 的零开销有保证。Rust 承诺单字段元组结构体内存布局与被包装类型完全一致(repr(Rust) 下),即 size_of::<Meters>() == size_of::<f64>(),且无任何间接寻址。所以 newtype 既安全又免费。

封装不变式

newtype 的第二个收益是封装不变式:字段设私有,只暴露受控的构造器与方法,保证「该类型的每个实例都满足某条规则」。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// newtype 封装不变式:端口必须在 1..=65535
pub struct Port(u16);

impl Port {
/// 构造器:校验后才能创建,非法值直接报错
pub fn new(value: u16) -> Result<Self, String> {
if value == 0 {
Err("端口不能为 0".into())
} else {
Ok(Port(value))
}
}

/// 取值:通过方法访问私有字段
pub fn value(&self) -> u16 {
self.0
}
}
// 字段 .0 是私有的,外部无法绕过校验直接 Port(0)

字段私有、只暴露 new/value,于是「端口永不为 0」这条不变式由类型系统强制保证–不存在「忘了校验」的实例。这种「构造即合法」的设计是 Rust 高可靠代码的基石。

与 Deref 的取舍

newtype 包装后,原类型方法不会自动可用:Meters(1.0) 不能直接调 f64sqrt,得写 m.0.sqrt()。许多人图省事给 newtype 实现 Deref,让 m.sqrt() 自动解引用到 f64

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::ops::Deref;

struct MyString(String);

impl Deref for MyString {
type Target = String;
fn deref(&self) -> &String {
&self.0
}
}

let s = MyString(String::from("hello"));
// 借助 Deref,可直接调用 String 的方法
println!("len = {}", s.len());
println!("upper = {}", s.to_uppercase());

这很方便,但 Deref 是为智能指针设计的,不是为 newtype 设计的。无脑 impl Deref 带来两个问题:

  1. 破坏封装Deref 会把目标类型的所有 pub 方法暴露给你的 newtype。若封装 Port(u16) 是为限制接口,impl Deref for Port 会把 u16 的全部方法(wrapping_addto_le_bytescount_ones……)都泄露出去,封装形同虚设。
  2. 语义混乱Deref 表达的是「智能指针指向目标,二者逻辑上是一回事」。MyStringString 也许算一回事,但 Metersf64 显然不是–f64 能做 sinlogMeters 取正弦毫无意义。Deref 让这些无意义方法也变得可调用,语义边界被打破。

判断准则很简单:

当 newtype 与底层类型「语义上是同一个东西,只是换个名字或加层校验」,且你愿意暴露底层全部接口时,impl Deref 合理(典型如 MyString(String))。
当 newtype 是「有自己语义边界的新类型」,底层只是恰好复用了表示时,不要 impl Deref,只显式实现真正需要的方法(典型如 Meters(f64)Port(u16))。

flowchart LR
  A["智能指针式容器?"] -->|是| B["impl Deref 合理"]
  A -->|否| C["愿暴露全部方法?"]
  C -->|是| D["impl Deref 可接受"]
  C -->|否| E["不要 impl Deref"]

⚠️ 注意Deref 还有「强制解引用」副作用–它会让 &MyString 自动转成 &String,在函数传参时静默发生。若 newtype 是为「绝不与底层类型互通」,这种静默转换正是要避免的。社区共识:Deref 留给智能指针(BoxRcArcStringVec),newtype 优先显式方法。

构造与初始化

字段初始化简写

当局部变量与字段同名时,可省略重复的名字只写一次字段名。这是纯语法糖,编译后与 name: name 完全一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct User {
username: String,
email: String,
active: bool,
}

// 字段初始化简写:变量名与字段名相同时省略
fn build_user(username: String, email: String) -> User {
User {
username, // 等价于 username: username
email, // 等价于 email: email
active: true,
}
}

简写在构造函数里尤为常见:参数名通常与字段名一致,简写能消除重复、减少打字错误。

结构体更新语法

基于已有实例创建新实例时,未显式指定的字段可用 ..old 从旧实例「搬」过来,避免重写所有字段:

1
2
3
4
5
6
7
8
9
10
let user1 = build_user(
String::from("alice"),
String::from("a@example.com"),
);

// 更新语法:只改 email,其余字段从 user1 搬来
let user2 = User {
email: String::from("b@example.com"),
..user1
};

..user1 表示「其余字段全部从 user1 取」,与字段简写搭配可简洁地「基于旧值派生新值」。

更新语法的移动语义细节

..user1 不是「复制」,而是逐字段移动或复制:对每个未显式列出的字段,Copy 类型按位复制,否则移动。这条规则带来一个常被忽视的后果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct User {
username: String, // 非 Copy
email: String, // 非 Copy
active: bool, // Copy
sign_in_count: u64, // Copy
}

let user1 = User {
username: String::from("alice"),
email: String::from("a@example.com"),
active: true,
sign_in_count: 1,
};

let user2 = User {
email: String::from("b@example.com"),
..user1 // username 移动,active/sign_in_count 复制
};

执行后,user1 处于部分移动状态:username 被移走(不可再用),email 未被 ..user1 触及(显式指定,用新值)仍可访问,activesign_in_countCopy,复制后原值仍可用。但 user1 整体不能再作为完整值使用:

1
2
3
4
// println!("{:?}", user1);      // 错误:user1 已部分移动
// drop(user1); // 错误:不能整体使用
println!("{}", user1.email); // OK:email 未被搬走
println!("{}", user1.active); // OK:bool 是 Copy

⚠️ 注意:常见误区是「只要用了 ..user1user1 就一定废了」。是否废取决于有没有非 Copy 字段被搬走。若所有未列出字段都是 Copy(如 boolu32f64),user1 仍完整可用;只要任一非 Copy 字段被搬走,user1 就进入部分移动状态。编译器会逐字段检查;理解这条规则能解释编译器报的某些「奇怪」错误。

一个全部 Copy 字段的例子:

1
2
3
4
5
6
struct Pixel { r: u8, g: u8, b: u8 }  // 全部 Copy

let p1 = Pixel { r: 10, g: 20, b: 30 };
let p2 = Pixel { b: 99, ..p1 }; // r/g 复制
// p1 仍完整可用,因为搬走的都是 Copy 字段
println!("p1 = {} {} {}", p1.r, p1.g, p1.b);

结构体字面量作为表达式

结构体字面量是表达式,可出现在任何表达式位置–直接调用方法、作函数参数、嵌入更大的字面量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 字面量直接调用方法
let area = Rectangle { width: 3, height: 4 }.area();

// 作为函数参数
fn is_square(r: Rectangle) -> bool {
r.width == r.height
}
println!("{}", is_square(Rectangle { width: 5, height: 5 }));

// 嵌入更大的结构
struct Window {
rect: Rectangle,
title: String,
}
let win = Window {
rect: Rectangle { width: 800, height: 600 },
title: String::from("主窗口"),
};

💡 提示:「字面量即表达式」让 Rust 结构体构造非常灵活,不像某些语言需要专门的 new 关键字或工厂方法。但含非 Copy 字段的字面量若只构造一次,构造完即移动,不会有多余拷贝。

字段访问、可变性与部分移动

整体可变,无字段级 mut

Rust 的可变性是结构体级别的:一个绑定要么 mut(所有字段都可改),要么不可变(所有字段都不可改)。不存在「只让某字段可变」的语法:

1
2
3
4
5
6
7
8
struct Point { x: i32, y: i32 }

let mut p = Point { x: 1, y: 2 };
p.x = 10; // OK:p 是 mut
p.y = 20; // OK

let q = Point { x: 1, y: 2 };
// q.x = 10; // 编译错误:q 不可变,所有字段都不可改

🔄 对比:C++ 允许 mutable 字段,C 允许 const 结构体里某字段单独非 const,Rust 不提供这种细粒度控制。这是有意的简化:字段级可变会让「这个值能不能改」依赖每个字段的声明,难以一眼看清。Rust 的哲学是「要么全可变,要么全不可变」,把可变性变成绑定级别的属性。

需要「部分可变」时,有两个惯用法:

  1. 拆分结构体:把需独立可变的部分拆成独立结构体,分别持有 mut 绑定。
  2. 内部可变性:用 Cell/RefCell 让不可变结构体内部某些字段可变(详见《智能指针、迭代器与闭包》)。
1
2
3
4
5
6
7
use std::cell::Cell;

// 内部可变性:不可变绑定也能改 Cell 字段
struct Counter { count: Cell<u32> }

let c = Counter { count: Cell::new(0) }; // c 本身不可变
c.count.set(c.count.get() + 1); // 但 Cell 字段可改

部分移动

字段可单独 move 出来,这叫部分移动。移动后被移出的字段不可再用,其他字段仍可用;结构体整体不能再作为完整值使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Person {
name: String, // 非 Copy
age: u32, // Copy
}

let p = Person {
name: String::from("Alice"),
age: 30,
};
let name = p.name; // name 字段被 move 出去
// println!("{}", p.name); // 错误:字段已被移动
println!("{}", p.age); // OK:age 是 Copy,未受影响
// drop(p); // 错误:p 已部分移动,不能整体使用

部分移动适合「只想取出某字段、其余丢弃」,省去先 clone 再丢的浪费。Copy 字段不受影响(如 p.age 仍可读)。

💡 提示:若整个结构体实现了 Copy(所有字段都是 Copy 时可 #[derive(Copy, Clone)]),赋值和传参都按位复制,不存在移动问题。但 StringVec 等持有堆内存的类型无法 Copy,含这些字段的结构体也不能 derive Copy。能否 Copy 的判定见 derive 宏一节。

私有字段与封装

结构体字段默认对定义它的模块可见,跨模块访问需显式 pub。这让结构体天然支持封装:内部表示可随意改,只暴露必要的访问器:

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
mod config {
pub struct Config {
pub port: u16, // 公开字段,外部可读写
secret_key: String, // 私有字段,外部只能通过方法访问
}

impl Config {
pub fn new(port: u16, key: String) -> Self {
Self { port, secret_key: key }
}

// 受控读取:不暴露原始 key,只回答是否为空
pub fn has_key(&self) -> bool {
!self.secret_key.is_empty()
}

// 受控修改:内部仍可直接改私有字段
pub fn rotate_key(&mut self, new_key: String) {
self.secret_key = new_key;
}
}
}

use config::Config;
let mut c = Config::new(8080, String::from("s3cr3t"));
println!("port = {}", c.port); // OK:port 是 pub
// println!("{}", c.secret_key); // 错误:secret_key 私有
c.rotate_key(String::from("new")); // 通过方法修改

🔬 进阶:Rust 的可见性是「按模块」而非「按类」的。同一模块内所有代码都能访问彼此的私有字段,不论是否「同一个结构体的方法」。这与 Java/C++ 的 private(类内可见)不同,更接近 Python 的「约定式私有」(但 Rust 真的强制)。需更细粒度时,把类型放进子模块,用 pub(crate)pub(super) 等限定可见范围。

方法与关联函数

impl 块与三种接收者

结构体的行为定义在 impl 块里。方法的第一个参数决定如何接收 self,共三种:

接收者形式语义能否修改
不可变借用&self只读访问,不获取所有权
可变借用&mut self可读写,不获取所有权
获取所有权self消耗实例,调用后不可再用是(且销毁)
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
struct Rectangle {
width: u32,
height: u32,
}

impl Rectangle {
// &self:只读,最常用
fn area(&self) -> u32 {
self.width * self.height
}

// &mut self:需要修改
fn scale(&mut self, factor: u32) {
self.width *= factor;
self.height *= factor;
}

// self:消耗实例,转换成别的值
fn into_square(self) -> Rectangle {
let side = self.width.max(self.height);
Rectangle { width: side, height: side }
}
}

let mut r = Rectangle { width: 3, height: 4 };
println!("area = {}", r.area()); // &self
r.scale(2); // &mut self
let s = r.into_square(); // self,r 此后不可用

选择接收者的原则:

  • 默认 &self:只读操作最常见,也最不限制调用者。
  • 需改字段时 &mut self:调用者必须持有 mut 绑定。
  • 要把实例「转换并消耗」时 self:常见于 into_* 方法、链式终结方法。调用后实例不可再用,所有权转移到方法里。
flowchart LR
  A["需消耗实例转换?"] -->|是| B["self"]
  A -->|否| C["需修改字段?"]
  C -->|是| D["&mut self"]
  C -->|否| E["&self"]

多个 impl 块自动合并

一个类型可写多个 impl 块,Rust 会自动合并。这在按 trait 分组、按功能分文件时很有用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}

// 另一个 impl 块,与上面的自动合并
impl Rectangle {
fn perimeter(&self) -> u32 {
2 * (self.width + self.height)
}
}

// 方法调用无区别,仿佛写在同一个块里
let r = Rectangle { width: 3, height: 4 };
println!("{}", r.area());
println!("{}", r.perimeter());

关联函数:构造器约定

不带 self 参数的函数叫关联函数(associated function),相当于其他语言的「静态方法」。最常见的用途是构造器,约定俗成命名为 new

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
impl Rectangle {
// 关联函数:不取 self,常作构造器
fn square(size: u32) -> Self {
Self { width: size, height: size }
}

// 带校验的构造器:返回 Result
fn new(width: u32, height: u32) -> Result<Self, String> {
if width == 0 || height == 0 {
Err("宽高不能为 0".into())
} else {
Ok(Self { width, height })
}
}
}

// 关联函数通过 Type::method() 调用
let sq = Rectangle::square(10);
let r = Rectangle::new(3, 4).unwrap();

Selfimpl 块内是「当前类型」的别名,等价于 Rectangle。关联函数通过 Type::name() 调用,如 String::fromVec::new

🔬 进阶new 只是约定,不是关键字,也不特殊。它不负责分配(Rust 没有专门的 new 运算符),就是一个普通关联函数。许多类型有多个构造器,按语义命名:Vec::with_capacityString::from_utf8OsString::from_string 等。new 通常留给「最常用、无失败」的构造路径。

方法调用链与自动引用

方法调用 r.area() 会根据接收者类型自动添加引用或解引用,无需手写 (&r).area()(*r).area()。这套机制叫「自动引用/解引用」(auto-ref/deref):

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
let r = Rectangle { width: 3, height: 4 };
// area 接收 &self,r 自动借用为 &r
r.area();

let mut r2 = Rectangle { width: 3, height: 4 };
// scale 接收 &mut self,r2 自动可变借用
r2.scale(2);

// 链式调用:每个方法返回新值或 Self,可连写
struct Builder { parts: Vec<String> }
impl Builder {
fn add(mut self, s: &str) -> Self {
self.parts.push(s.to_string());
self
}
fn build(self) -> String {
self.parts.join(",")
}
}

let s = Builder { parts: vec![] }
.add("a")
.add("b")
.add("c")
.build();
println!("{}", s); // a,b,c

自动引用的规则:编译器查找方法时,会依次尝试 r.method()(&r).method()(&mut r).method()(*r).method() 等形式,找到匹配的就用。这就是为什么 String 能调用 str 的方法、Box<Rectangle> 能调用 Rectangle 的方法–Deref 配合自动引用让方法查找「穿透」多层包装。

derive 宏详解

Rust 的结构体默认几乎「什么都不会」:不能打印、不能比较、不能复制、不能哈希。这些能力都由对应 trait 提供,#[derive(...)] 宏能自动生成其中常用的几个实现。

逐个 trait 详解

derive生成的 trait作用典型场景
DebugDebug{:?} / {:#?} 调试输出几乎总是需要
CloneClone显式深拷贝(.clone()需要复制含堆数据时
CopyCopy赋值/传参按位复制(隐式)全字段 Copy 且无 Drop
PartialEqPartialEq== / != 相等比较需要判等
EqEq自反相等(a == aPartialEq 的强化
HashHash可作为 HashMap/HashSet 的键需要哈希
DefaultDefault提供默认值部分更新、兜底
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
struct Point {
x: i32,
y: i32,
}

// Debug
println!("{:?}", Point { x: 1, y: 2 });
// Copy:赋值即复制,原值仍可用
let p1 = Point { x: 1, y: 2 };
let p2 = p1; // 复制,不是移动
println!("{:?}", p1); // OK,p1 仍可用
// PartialEq / Eq
assert_eq!(p1, p2);
// Hash:可作 HashMap 键
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(p1);
// Default
let d = Point::default(); // x=0, y=0

Debug:生成 Debug trait 实现,让结构体可用 {:?}(单行)或 {:#?}(多行美化)打印。几乎所有结构体都该 derive Debug,调试和日志离不开它。derive 要求所有字段实现 Debug

Clone:生成 Clone 实现,提供 .clone() 做显式深拷贝。含堆数据的字段(StringVec),clone 会复制堆内容;Copy 字段则按位复制。derive 要求所有字段 Clone

Copy:标记 trait,表示赋值和传参是「按位复制」而非「移动」。derive Copy 的前提是所有字段都是 Copy,且类型没有实现 Drop。derive Copy 后,赋值 let p2 = p1p1 仍可用–这正是「复制」与「移动」的区别。

PartialEq:生成 == / !=,逐字段比较。derive 要求所有字段 PartialEq

EqPartialEq 的标记强化,承诺自反性(a == a 恒成立)。浮点数 f64NaN != NaN 不满足自反性,只能 PartialEq 不能 Eq,所以含浮点字段的结构体也不能 derive Eq

Hash:生成 Hash,让结构体能放进 HashMap/HashSet。derive 要求所有字段 Hash,且通常与 Eq 配套(HashMap 要求键 Eq + Hash)。

Default:生成 Default::default(),对每个字段调用其 Default。整型默认 0、bool 默认 falseString 默认空串、Option 默认 None

Copy 的前提条件与代价

Copy 是最容易踩坑的 derive。它的前提是:

  1. 所有字段都是 CopyStringVecBox 等持有堆所有权的类型不是 Copy(它们实现了 Drop,复制会导致双重释放),所以含这些字段的结构体无法 derive Copy
  2. 类型没有手动实现 DropDrop 表示「需自定义析构」,与「按位复制即完整」矛盾–复制后两个副本都析构,谁来释放堆?所以 CopyDrop 互斥。
1
2
3
4
5
6
7
8
9
10
// 可以 Copy:所有字段都是 Copy
#[derive(Copy, Clone)]
struct Point { x: i32, y: i32 }

// 不能 Copy:含 String(非 Copy)
#[derive(Clone)] // 只能 Clone,不能 Copy
struct User {
name: String, // String 持有堆内存,非 Copy
age: u32,
}

⚠️ 注意Copy 必须搭配 CloneCopy: Clone 是 supertrait)。derive Copy 时通常写成 #[derive(Copy, Clone)]。单独 derive Copy 会报错。

Copy 的代价是「每次赋值/传参都复制」。对小类型(几十字节以内)几乎无感,但对大结构体(如含数 KB 数组)开销明显–每次传参都复制一份,且隐式难以察觉。所以 Copy 适合「小而值语义」的类型(坐标、颜色、标识符、小配置),不适合大结构体–后者用引用或 Clone 显式控制。

🔄 对比:C++ 里拷贝构造函数是默认行为,且可隐式触发(传值、返回),大对象的隐式拷贝是经典性能陷阱。Rust 反过来:默认是移动(零成本),只有显式 derive Copy 的小类型才复制。这把「复制」从默认行为降级为需主动声明的特性,避免了 C++ 式的隐式大对象拷贝。

derive 的局限

derive 生成的实现是「机械」的:PartialEq 逐字段比较、Debug 逐字段打印、Hash 逐字段哈希。当类型语义不等于字段之和时,需手写实现。例如「两个 Point 即使字段不同也算相等」(极坐标与直角坐标),就得手写 PartialEq

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct PolarPoint {
r: f64,
theta: f64,
}

impl std::cmp::PartialEq for PolarPoint {
// 手写:theta 差 2π 仍算相等
fn eq(&self, other: &Self) -> bool {
const TAU: f64 = std::f64::consts::TAU;
(self.r - other.r).abs() < 1e-9
&& (self.theta - other.theta).rem_euclid(TAU)
< 1e-9
}
}

Default trait 与部分更新惯用法

Default 提供类型的「自然默认值」。derive Default 会对每个字段调用其 Default,得到「全默认」实例。它的价值不在于「经常需要全默认值」,而在于配合结构体更新语法实现部分更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[derive(Debug, Default)]
struct ServerConfig {
host: String, // 默认 ""(空串)
port: u16, // 默认 0
max_conn: usize, // 默认 0
timeout_secs: u64, // 默认 0
debug: bool, // 默认 false
}

// 部分更新:只改关心的字段,其余用默认值
let cfg = ServerConfig {
host: String::from("0.0.0.0"),
port: 8080,
..Default::default() // 其余字段用默认值
};
println!("{:#?}", cfg);

..Default::default() 是 Rust 里「部分初始化」的标准惯用法。它比「全字段写出默认值」简洁,又比「构造器一堆可选参数」类型安全–只写想改的字段,编译器保证其余字段有值。

手写 Default 时,应让默认值「安全且合理」:

1
2
3
4
5
6
7
8
9
10
11
12
impl Default for ServerConfig {
fn default() -> Self {
// 给出合理的业务默认值,而非 0/空
Self {
host: String::from("127.0.0.1"),
port: 8080,
max_conn: 100,
timeout_secs: 30,
debug: false,
}
}
}

💡 提示Default 还常用于测试夹具(生成「一个能用的实例」)、序列化兜底(缺字段时用默认)、以及 unwrap_or_defaultOptionNone 时取默认)。给业务结构体实现合理的 Default,是提升代码简洁度的低成本投入。

打印与格式化

Debug:{:?} 与 {:#?}

Debug 是结构体最常 derive 的 trait,让结构体可用 {:?}(单行紧凑)或 {:#?}(多行美化)输出:

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

let p = Point { x: 1, y: 2 };
println!("{:?}", p); // Point { x: 1, y: 2 }
println!("{:#?}", p); // 多行美化:
// Point {
// x: 1,
// y: 2,
// }

{:?} 适合日志一行输出,{:#?} 适合人眼阅读复杂结构。derive Debug 要求所有字段 Debug,标准库类型基本都满足。

Display 的取舍

Rust 还有 Display trait,对应 {} 格式化,语义是「面向用户的友好输出」。但 Display 不能 derive,必须手写,且通常只对「有自然文本表示」的类型有意义(如 StringIpAddr)。对结构体而言,derive Debug 用于调试,Display 用于对外展示,二者分工不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::fmt;

struct Money {
amount: i64,
currency: String,
}

// Display:面向用户的展示,需手写
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.amount, self.currency)
}
}

let m = Money { amount: 9900, currency: String::from("CNY") };
println!("{}", m); // 9900 CNY(Display)
println!("{:?}", m); // 缺 Debug 会报错,需额外 derive

🔄 对比:Python 的 __str__(用户友好)与 __repr__(调试)对应 Rust 的 DisplayDebug。区别是 Python 都能轻松写,Rust 的 Display 必须手写且只对「有文本表示」的类型有意义–大多数结构体只 derive Debug 就够,Display 留给真正需要对外展示的类型。

手动实现 Debug

有时 derive 的 Debug 输出太冗长(如大数组字段),或想隐藏敏感字段(密码、密钥),就需手写 Debug

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::fmt;

struct Credentials {
username: String,
password: String, // 不想被打印
}

impl fmt::Debug for Credentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Credentials")
.field("username", &self.username)
.field("password", &"<redacted>") // 隐藏真实值
.finish()
}
}

let c = Credentials {
username: String::from("alice"),
password: String::from("s3cr3t"),
};
println!("{:?}", c);
// Credentials { username: "alice", password: "<redacted>" }

Formatter 提供 debug_structdebug_tupledebug_list 等辅助方法,让手写 Debug 不必逐字节拼字符串。隐藏敏感字段是安全代码的常见要求–日志里泄露密钥是真实事故来源,手写 Debug 是防御手段之一。

⚠️ 注意:含引用字段的结构体若想 derive Debug,需给生命周期参数加约束 #[derive(Debug)] struct Ref<'a, T: Debug>(&'a T)。这是「trait 约束写在类型定义」的常见用法,详见《所有权、借用与生命周期》《类型系统》

内存布局

结构体在内存里如何摆放,直接关系性能与 ABI 兼容。Rust 给了从「让编译器自由优化」到「逐字节精确控制」的多个层次。

repr(Rust):字段重排与对齐填充

默认 repr(Rust) 下,编译器可自由重排字段以最小化对齐填充。对齐(alignment)是「数据存放地址必须是某数倍数」的硬件要求:u32 对齐 4、u64 对齐 8、u8 对齐 1。字段顺序不佳会留下「填充」(padding)浪费空间:

1
2
3
4
5
6
7
8
9
10
use std::mem::size_of;

struct Foo {
a: u8, // 1 字节,对齐 1
b: u32, // 4 字节,对齐 4
c: u8, // 1 字节,对齐 1
}
// 源码顺序:a(1) + 填充(3) + b(4) + c(1) + 填充(3) = 12 字节
// 但编译器重排为:b(4) + a(1) + c(1) + 填充(2) = 8 字节
assert_eq!(size_of::<Foo>(), 8); // 实际是 8,不是 12

重排前后的字节布局对比如下(假设起始地址 0,结构体对齐为 4):

1
2
3
4
5
6
7
8
9
10
11
12
13
源码顺序(若不重排):
偏移 0 1 2 3 4 5 6 7 8 9 10 11
+----+----+----+----+----+----+----+----+----+----+----+----+
| a |<-- pad 3B -->| b (u32) | c |<-- pad 3B -->|
+----+----+----+----+----+----+----+----+----+----+----+----+
总计 12 字节,5 字节填充浪费

repr(Rust) 重排后(b 提前,a/c 相邻):
偏移 0 1 2 3 4 5 6 7
+----+----+----+----+----+----+----+----+
| b (u32) | a | c |<- pad 2B->|
+----+----+----+----+----+----+----+----+
总计 8 字节,仅 2 字节填充

⚠️ 注意:字段重排是合法优化,不要依赖字段的内存顺序或字段间地址差。需固定布局时(FFI、网络协议、二进制格式解析)必须用 #[repr(C)]。在 safe Rust 里你也无法取字段地址做这种依赖(取引用得到的是字段地址,但偏移不保证稳定)。

#[repr©]:固定布局

#[repr(C)] 强制按声明顺序摆放字段,遵循 C 的对齐规则。用于需「跨语言 ABI 一致」或「二进制格式可预测」的场景:

1
2
3
4
5
6
7
8
9
10
11
use std::mem::{size_of, align_of};

#[repr(C)]
struct Header {
magic: u32, // 4 字节
version: u16, // 2 字节
flags: u16, // 2 字节
}
// 顺序固定:magic(4) + version(2) + flags(2) = 8 字节
assert_eq!(size_of::<Header>(), 8);
assert_eq!(align_of::<Header>(), 4); // 对齐随最大字段 u32

#[repr(C)] 不改变字段对齐,只固定顺序。它最适合:

  1. FFI:与 C 代码交互时,C 端按 struct { u32; u16; u16; } 摆放,Rust 端用 #[repr(C)] 保证一致。
  2. 二进制解析:解析文件头、网络包时,按字节布局精确匹配协议。

#[repr(packed)]:慎用

#[repr(packed)] 完全去掉对齐填充,字段紧挨着摆放,最省空间,但代价是未对齐访问

1
2
3
4
5
6
#[repr(packed)]
struct Packed {
a: u8,
b: u32, // 紧跟 a,地址非 4 对齐
}
assert_eq!(size_of::<Packed>(), 5); // 无填充

⚠️ 注意#[repr(packed)] 危险在于取字段引用。&packed.b 得到未对齐引用,而 &T 要求 T 对齐–safe Rust 里取 packed 字段引用会被编译器拒绝,必须 unsafe

1
2
3
4
5
6
7
8
9
10
#[repr(packed)]
struct Packed { a: u8, b: u32 }

let p = Packed { a: 1, b: 0x04030201 };
// let r = &p.b; // 编译错误:packed 字段引用可能未对齐
let val = unsafe {
// 必须用 unsafe 读,可能触发未对齐访问
p.b.read_unaligned()
};
println!("{}", val);

未对齐访问在某些平台(ARM、MIPS)是 UB,在 x86 上虽能跑但慢一倍。所以 #[repr(packed)]仅用于必须匹配的硬件/协议布局,且读写都用 read_unaligned/write_unaligned,绝不在 packed 结构体上取普通引用。

零大小类型 ZST

零大小类型(Zero-Sized Type)是 size_of::<T>() == 0 的类型。单元结构体就是 ZST,此外 ()[T; 0]PhantomData<T> 都是:

1
2
3
4
5
6
7
use std::mem::size_of;

struct Empty; // 单元结构体
assert_eq!(size_of::<Empty>(), 0);
assert_eq!(size_of::<()>(), 0);
assert_eq!(size_of::<[i32; 0]>(), 0);
assert_eq!(size_of::<std::marker::PhantomData<u64>>(), 0);

ZST 的特殊之处:

  1. 不占内存。一个 Vec<Empty> 装 100 万个单元结构体,实际只占几个字节元数据,元素本身零开销。
  2. 复制和移动无开销。零字节复制就是「什么都不做」。
  3. 构造无开销。创建 ZST 值不分配、不初始化。

💡 提示:ZST 在泛型代码里很有用。例如 HashMap<K, V>V = () 时退化为 HashSet<K>,复用全部实现而零额外开销。PhantomData<T> 用 ZST 在类型层面「标记」一个类型参数,运行期不占空间。理解 ZST 能解释很多「为什么这看起来有开销却免费」的现象。

用 size_of / align_of 验证

标准库提供 size_of::<T>()align_of::<T>() 验证布局,调试时还可用 cargo rustc -- -Zprint-type-sizes(nightly)打印每个类型的确切字节数与字段偏移。下面这段代码验证常见结构的布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::mem::{size_of, align_of};

struct Mixed {
a: u8,
b: u64,
c: u8,
}
// repr(Rust) 重排后约 16 字节(u64 对齐 8)
println!("Mixed: size={}, align={}",
size_of::<Mixed>(), align_of::<Mixed>());

#[repr(C)]
struct MixedC {
a: u8,
b: u64,
c: u8,
}
// C 顺序:a(1) + pad(7) + b(8) + c(1) + pad(7) = 24
println!("MixedC: size={}, align={}",
size_of::<MixedC>(), align_of::<MixedC>());

🔬 进阶:想亲眼看到字节的原始内容,可用 unsafe 把值转成字节切片。但这是诊断手段而非生产代码,且要注意 repr(Rust) 的布局不稳定,跨编译版本可能变化:

1
2
3
4
5
6
7
8
9
fn dump_bytes<T>(val: &T) {
let bytes = unsafe {
std::slice::from_raw_parts(
val as *const T as *const u8,
std::mem::size_of::<T>(),
)
};
println!("{:02x?}", bytes);
}

组合优于继承

Rust 没有继承(inheritance),既无数据继承也无实现继承。复用数据靠组合(composition):把其他结构体作为字段嵌套进来;复用行为靠 trait(见《类型系统》)。

has-a 嵌套

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct Engine {
horsepower: u32,
}

struct Gps {
lat: f64,
lng: f64,
}

struct Car {
engine: Engine, // 组合:Car "has-a" Engine
gps: Gps,
model: String,
}

let car = Car {
engine: Engine { horsepower: 300 },
gps: Gps { lat: 31.23, lng: 121.47 },
model: String::from("Model S"),
};
// 通过字段链访问嵌套结构
println!("{} 匹马力", car.engine.horsepower);

组合是显式的:Car 想用 Engine 的什么就写 car.engine.xxx(),不会出现「调一个方法不知道它来自哪层父类」的迷雾。

与 trait 配合复用行为

继承同时耦合了「数据复用、行为复用、类型层级」三件事,Rust 把它们拆开:数据用嵌套字段,行为用 trait,类型层级用 trait bound。需复用行为时,定义 trait 并为类型实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
trait Describable {
fn describe(&self) -> String;
}

impl Describable for Engine {
fn describe(&self) -> String {
format!("{} 匹马力引擎", self.horsepower)
}
}

impl Describable for Car {
fn describe(&self) -> String {
// 显式委托,而非隐式继承
format!("{}(搭载 {})", self.model, self.engine.describe())
}
}

println!("{}", car.describe());

🔄 对比:面对「Cat 是一种 Animal」的继承冲动,Rust 的答案是拆问题–共享数据用嵌套字段,共享行为用 trait。这规避了 C++ 菱形继承(多继承下基类重复)和 Java 深层继承链(Object -> Animal -> Mammal -> Cat -> …,方法来源难追踪)的问题。Go 的「嵌入字段」机制与 Rust 组合思路相近,也是把「is-a」改写成「has-a + 委托」。

DTO 与业务实体分离

组合哲学下,一个常见实践是「接口层 DTO 与内部业务实体分离」:DTO(Data Transfer Object)是轻量结构体,只含序列化所需字段;业务实体是富类型,含不变式与方法。两者通过显式转换沟通:

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
// DTO:接口层,含序列化字段,无业务逻辑
#[derive(Debug, serde::Serialize)]
struct UserDto {
id: u64,
name: String,
email: String,
}

// 业务实体:含不变式与方法,不直接暴露
struct User {
id: UserId, // newtype 封装
name: UserName, // newtype 封装,含校验
email: Email, // newtype 封装,含格式校验
}

impl User {
// 从 DTO 构造业务实体:校验在此发生
fn from_dto(dto: UserDto) -> Result<Self, String> {
Ok(Self {
id: UserId::new(dto.id)?,
name: UserName::new(dto.name)?,
email: Email::new(dto.email)?,
})
}

// 转回 DTO:仅暴露安全字段
fn to_dto(&self) -> UserDto {
UserDto {
id: self.id.value(),
name: self.name.value(),
email: self.email.value(),
}
}
}

💡 提示:DTO 与实体分离的好处是「内部表示可自由演进,外部接口稳定」。业务实体的字段可重命名、改类型、加不变式,只要 to_dto/from_dto 维持 DTO 形状,外部 API 就不受影响。反之,把内部实体直接序列化暴露,会让内部变更变成破坏性 API 变更。

Builder 模式

当结构体的可选字段很多、构造需校验、或想支持链式调用时,Builder 模式是惯用法。它把「构造」从一次性字面量拆成「逐步设置 + 终结校验」两步,每步返回 builder 自身以支持链式调用。

完整的 Builder 实现(含 build 校验):

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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub max_connections: usize,
pub timeout_secs: u64,
pub debug: bool,
}

#[derive(Debug)]
pub enum ConfigError {
InvalidPort(String),
InvalidTimeout(String),
InvalidMaxConnections(String),
}

impl std::fmt::Display for ConfigError {
fn fmt(
&self,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
match self {
ConfigError::InvalidPort(msg) => {
write!(f, "无效端口: {msg}")
}
ConfigError::InvalidTimeout(msg) => {
write!(f, "无效超时: {msg}")
}
ConfigError::InvalidMaxConnections(msg) => {
write!(f, "无效最大连接数: {msg}")
}
}
}
}

impl std::error::Error for ConfigError {}

// Builder:所有字段用 Option,默认 None
#[derive(Default)]
pub struct ServerConfigBuilder {
host: Option<String>,
port: Option<u16>,
max_connections: Option<usize>,
timeout_secs: Option<u64>,
debug: Option<bool>,
}

impl ServerConfigBuilder {
pub fn new() -> Self {
Self::default()
}

// 每个设置器消耗 self 并返回,支持链式
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = Some(host.into());
self
}

pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}

pub fn max_connections(mut self, n: usize) -> Self {
self.max_connections = Some(n);
self
}

pub fn timeout_secs(mut self, secs: u64) -> Self {
self.timeout_secs = Some(secs);
self
}

pub fn debug(mut self, on: bool) -> Self {
self.debug = Some(on);
self
}

// build:校验并构造,失败返回 Err
pub fn build(self) -> Result<ServerConfig, ConfigError> {
let host = self
.host
.unwrap_or_else(|| "127.0.0.1".to_string());
let port = self.port.unwrap_or(8080);
if port == 0 {
return Err(ConfigError::InvalidPort(
"端口不能为 0".into(),
));
}
let max_connections =
self.max_connections.unwrap_or(100);
if max_connections == 0 {
return Err(ConfigError::InvalidMaxConnections(
"最大连接数不能为 0".into(),
));
}
let timeout_secs = self.timeout_secs.unwrap_or(30);
if timeout_secs == 0 {
return Err(ConfigError::InvalidTimeout(
"超时不能为 0".into(),
));
}
let debug = self.debug.unwrap_or(false);
Ok(ServerConfig {
host,
port,
max_connections,
timeout_secs,
debug,
})
}
}

fn main() {
// 链式构造:只设关心的字段,其余用默认
let cfg = ServerConfigBuilder::new()
.host("0.0.0.0")
.port(3000)
.max_connections(500)
.timeout_secs(60)
.debug(true)
.build();
println!("{:#?}", cfg);

// 校验在 build 时触发
let bad = ServerConfigBuilder::new()
.port(0)
.build();
println!("{:?}", bad); // Err(InvalidPort(...))
}

Builder 的关键设计点:

  1. Builder 字段全 Option,初始为 None,表示「未设置」。
  2. 设置器 mut self -> Self,消耗并返回 builder,支持链式(b.host(..).port(..))。
  3. build 做校验,把 Option 解开为最终值,任一校验失败返回 Err,全部通过才构造 ServerConfig
  4. 目标结构体只在 build 里构造,外部无法绕过校验直接 ServerConfig { .. }(把字段设为私有即可,这里为演示用 pub)。

💡 提示:当字段很多、有校验、或构造逻辑复杂时用 Builder;字段少且无需校验时,直接字面量 + ..Default::default() 更简洁。Builder 不是银弹,是「构造复杂度足够高」时的工具。社区有 derive_buildertyped-builder 等 crate 自动生成 Builder,减少样板代码。

⚠️ 注意:上面的 Builder 是「消耗式」的(每个设置器 mut self),调用一次后原 builder 不可再用。需「可复用 builder」时,设置器改为 &mut self -> &mut Selfbuild 改为 &self -> Result,但链式调用要写 &mut 借用,语法略繁。两种风格各有取舍,按需选择。

局部 struct

结构体可定义在函数体内,作用域仅限该函数。这适合「用完即弃」的临时分组–解析中间结果、测试夹具、临时聚合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fn parse_config(text: &str) -> (bool, u32) {
#[derive(Debug)]
struct Entry { // 局部 struct,仅在函数内可见
key: String,
value: u32,
}

impl Entry { // impl 块同样可写在函数内
fn is_enabled(&self) -> bool {
self.key == "enabled"
}
}

let entry = Entry {
key: String::from("enabled"),
value: 42,
};
(entry.is_enabled(), entry.value)
}

局部 struct 的特点:

  1. 作用域限于函数Entry 在函数外无法命名,不能作返回类型或参数类型。临时类型需跨函数传递时,必须提升到模块级别。
  2. 可 derive、可 impl、可实现 trait。局部 struct 与模块级 struct 能力无差别,只是可见范围不同。
  3. 不捕获环境变量。与闭包不同,局部 struct 不会「捕获」外层变量,只能通过字段显式传入。
  4. 不能捕获外层泛型参数fn f<T>() { struct S(T); } 非法–局部 struct 不能隐式使用外层泛型参数,需显式声明为泛型:struct S<T>(T)
1
2
3
4
5
6
7
8
9
10
11
// 错误:局部 struct 不能捕获外层泛型参数
// fn f<T>() {
// struct S(T); // 非法:T 是外层的
// }

// 正确:局部 struct 自己声明泛型参数
fn f<T: Default>() {
struct S<T>(T); // 这里的 T 是 S 自己的,遮蔽外层
let s = S(T::default());
let _ = s;
}

💡 提示:局部 struct 在测试里特别有用–为单个测试构造「小型夹具类型」,用完即弃,不污染模块命名空间。也在解析器、状态机内部表达「这一步的中间结构」,让逻辑比裸用元组清晰。

常见模式速查表与陷阱小结

常见模式速查

模式做法收益
newtypestruct UserId(u64)零开销区分同底层不同语义
封装不变式字段私有 + pub 构造器非法状态无法从外部构造
构造器约定impl T { fn new(..) -> Self }集中校验,保证实例合法
Builder链式 Config::new().x(..).build()可选参数多时的可读构造
部分更新{ x, ..Default::default() }只写关心的字段
#[derive]Debug, Clone, PartialEq一行获得常用行为
DTO 分离接口层 DTO 与内部实体分开避免暴露内部可变状态
类型标记单元结构体 + PhantomData编译期类型计算,运行期零开销
委托方法car.engine.xxx() 显式调用组合代替继承,行为来源清晰

常见陷阱

  1. 更新语法搬空实例..user1 会移动非 Copy 字段,导致 user1 部分移动后整体不可再用。误以为「只是复制」会在后续使用 user1 时遇编译错误。记住:只有全 Copy 字段时 user1 才完整存活。

  2. 无脑 impl Deref。给 newtype impl Deref 会暴露底层全部方法,破坏封装、混淆语义。Deref 留给智能指针,newtype 优先显式方法。

  3. packed 取引用#[repr(packed)] 结构体取字段引用是未对齐访问,safe Rust 会拒绝,unsafe 下可能 UB。packed 仅用于匹配硬件/协议布局,读写用 read_unaligned

  4. 依赖字段内存顺序repr(Rust) 下编译器会重排字段,不要假设字段顺序或偏移。需固定布局用 #[repr(C)]

  5. 大类型 derive CopyCopy 让赋值隐式复制,大结构体每次传参都拷贝。Copy 只适合小而值语义的类型,大对象用引用或显式 Clone

  6. 含 String 却想 derive CopyString 持有堆内存,非 Copy,含它的结构体无法 derive Copy。需「复制」就 derive Clone 显式 .clone()

  7. 字段级 mut 期待。Rust 不支持「只让某字段可变」,要么整个 mut,要么用 Cell/RefCell 内部可变性。

  8. 局部 struct 捕获泛型fn f<T>() { struct S(T); } 非法,局部 struct 不能隐式用外层泛型参数,需显式 struct S<T>(T)

  9. Display 当 Debug 用Display{})不能 derive,且只对「有文本表示」的类型有意义。调试输出用 Debug{:?}),不要为每个结构体硬写 Display

  10. unwrap 当默认。Builder 的 build 应返回 Result,而非 unwrap。把校验失败变成 panic 会把「可恢复错误」退化成「运行时崩溃」。

实战示例:HTTP 服务器配置

把本章的 newtype、封装不变式、Builder、组合、derive 串起来,写一个完整的 HTTP 服务器配置模块。这个例子综合体现「构造即合法」「部分更新」「链式构造」「DTO 分离」。

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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
use std::fmt;

// === newtype:每个概念一个类型,封装不变式 ===

pub struct Port(u16);
impl Port {
pub fn new(value: u16) -> Result<Self, String> {
if value < 80 {
Err("端口必须 >= 80".into())
} else {
Ok(Port(value))
}
}
pub fn value(&self) -> u16 {
self.0
}
}

pub struct Host(String);
impl Host {
pub fn new(s: impl Into<String>) -> Result<Self, String> {
let s = s.into();
if s.is_empty() {
Err("host 不能为空".into())
} else {
Ok(Host(s))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}

// === 组合:嵌套结构体复用数据 ===

pub struct ThreadPool {
workers: usize,
queue_size: usize,
}

impl ThreadPool {
fn new(workers: usize, queue_size: usize) -> Self {
Self { workers, queue_size }
}
fn describe(&self) -> String {
format!("{} workers / queue {}", self.workers, self.queue_size)
}
}

// === 业务实体:字段私有,只能通过 Builder 构造 ===

pub struct HttpServerConfig {
host: Host,
port: Port,
pool: ThreadPool,
debug: bool,
max_body_bytes: u64,
}

impl HttpServerConfig {
pub fn listen_addr(&self) -> String {
format!("{}:{}", self.host.as_str(), self.port.value())
}
pub fn pool_info(&self) -> String {
self.pool.describe()
}
pub fn is_debug(&self) -> bool {
self.debug
}
pub fn max_body(&self) -> u64 {
self.max_body_bytes
}
}

// === Builder:链式构造 + build 校验 ===

#[derive(Default)]
pub struct HttpServerConfigBuilder {
host: Option<String>,
port: Option<u16>,
workers: Option<usize>,
queue_size: Option<usize>,
debug: Option<bool>,
max_body_bytes: Option<u64>,
}

impl HttpServerConfigBuilder {
pub fn new() -> Self {
Self::default()
}

pub fn host(mut self, h: impl Into<String>) -> Self {
self.host = Some(h.into());
self
}
pub fn port(mut self, p: u16) -> Self {
self.port = Some(p);
self
}
pub fn workers(mut self, n: usize) -> Self {
self.workers = Some(n);
self
}
pub fn queue(mut self, n: usize) -> Self {
self.queue_size = Some(n);
self
}
pub fn debug(mut self, on: bool) -> Self {
self.debug = Some(on);
self
}
pub fn max_body(mut self, bytes: u64) -> Self {
self.max_body_bytes = Some(bytes);
self
}

pub fn build(self) -> Result<HttpServerConfig, String> {
let host = match self.host {
Some(h) => Host::new(h)?,
None => Host::new("0.0.0.0")?,
};
let port = match self.port {
Some(p) => Port::new(p)?,
None => Port::new(8080)?,
};
let workers = self.workers.unwrap_or(4);
if workers == 0 {
return Err("workers 不能为 0".into());
}
let queue_size = self.queue_size.unwrap_or(100);
let pool = ThreadPool::new(workers, queue_size);
let debug = self.debug.unwrap_or(false);
let max_body = self.max_body_bytes.unwrap_or(1024 * 1024);
Ok(HttpServerConfig {
host,
port,
pool,
debug,
max_body_bytes: max_body,
})
}
}

// === DTO:对外暴露的轻量结构,不含不变式 ===

#[derive(Debug, serde::Serialize)]
pub struct HttpServerConfigDto {
host: String,
port: u16,
workers: usize,
queue_size: usize,
debug: bool,
max_body_bytes: u64,
}

impl HttpServerConfig {
pub fn to_dto(&self) -> HttpServerConfigDto {
HttpServerConfigDto {
host: self.host.as_str().to_string(),
port: self.port.value(),
workers: self.pool.workers,
queue_size: self.pool.queue_size,
debug: self.debug,
max_body_bytes: self.max_body_bytes,
}
}
}

impl fmt::Debug for HttpServerConfig {
// 手写 Debug,隐藏内部结构细节
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HttpServerConfig")
.field("listen", &self.listen_addr())
.field("pool", &self.pool_info())
.field("debug", &self.debug)
.finish()
}
}

fn main() {
let cfg = HttpServerConfigBuilder::new()
.host("127.0.0.1")
.port(3000)
.workers(8)
.queue(200)
.max_body(2 * 1024 * 1024)
.debug(true)
.build();

match cfg {
Ok(c) => {
println!("{:?}", c);
println!("DTO: {:?}", c.to_dto());
}
Err(e) => println!("构造失败: {e}"),
}

// 校验在 build 时触发
let bad = HttpServerConfigBuilder::new()
.port(22) // < 80,非法
.build();
println!("{:?}", bad); // Err("端口必须 >= 80")
}

这个例子串起了本章的核心:

  • newtypePortHost)封装不变式,构造即合法,外部无法绕过校验。
  • 组合HttpServerConfigHost/Port/ThreadPool)复用数据,行为来源清晰。
  • Builder 把复杂构造拆成链式设置 + 终结校验,可选字段用默认值兜底。
  • DTO 分离 让对外接口稳定,内部实体可自由演进。
  • 手写 Debug 控制输出,隐藏内部细节。

结构体是 Rust 表达「带不变式的数据 + 行为」的核心载体。下一章《枚举》讨论「互斥地表达可能」,与本章的「并列地组合数据」共同构成 Rust 自定义类型的两大支柱。