第5章:使用结构体组织相关联的数据
第5章:使用结构体组织相关联的数据
一、章节目标
本章的核心任务,是学习如何使用 struct 将彼此相关的数据组织成一个有意义的整体,并进一步为这种数据类型定义与之配套的行为。
这一章实际上回答了三个问题:
- 当多个数据在语义上属于同一个对象时,为什么不应继续把它们拆散存放。
- 如何定义、实例化和更新结构体。
- 如何通过
impl和方法语法,让“数据”和“操作这些数据的逻辑”自然地放在一起。
从语言设计角度看,结构体是 Rust 中创建自定义类型的重要基础。它让程序从“只是在处理一些值”,提升到“在处理领域中的对象”。
二、结构体的本质与作用
结构体(struct)是一种自定义数据类型,用来把多个相关联的值打包为一个整体。
它和元组有相似之处:
- 都可以把多个值组合在一起。
- 各部分的数据类型可以不同。
但结构体比元组更进一步,因为它的每个组成部分都有名字,也就是字段(field)。
这带来三个直接好处:
- 语义更清晰:字段名可以表达数据的真实含义。
- 访问更直观:通过字段名访问,而不是通过位置索引访问。
- 维护更容易:不必依赖字段顺序去理解代码。
所以,元组适合“临时组合几个值”,而结构体适合“表达一个真实对象”。
三、5.1 结构体的定义和实例化
1. 定义结构体
定义结构体使用 struct 关键字:
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
这里的 User 是结构体名,后面的大括号中定义了若干字段。每个字段都由“字段名: 字段类型”组成。
理解这个定义时,要抓住一点:
User不再只是几个零散变量的集合;- 它已经成为一个新的类型;
- 这个类型专门用来描述“用户账号信息”。
这就是结构体的根本价值:把程序中的概念变成类型。
2. 实例化结构体
结构体定义好之后,可以通过“字段名: 值”的形式创建实例:
fn main() {
let user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("someone@example.com"),
sign_in_count: 1,
};
}
要点如下:
- 实例化时必须为每个字段提供值。
- 字段顺序不必与定义顺序完全一致。
- 结构体定义像模板,实例则是这个模板填入具体数据后的具体对象。
3. 访问结构体字段
使用点号访问字段:
user1.email
点号访问的意义非常直接:
user1是一个用户对象;email是这个对象的邮箱字段;user1.email就是在取“这个用户的邮箱”。
这种写法比元组下标访问更自然,因为它表达的是“属性”,而不是“位置”。
4. 修改结构体字段
如果要修改字段,对应的结构体实例必须是可变的:
fn main() {
let mut user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("someone@example.com"),
sign_in_count: 1,
};
user1.email = String::from("anotheremail@example.com");
}
这里要特别注意:
- Rust 规定的是“整个实例是否可变”,而不是“某个单独字段是否可变”。
- 也就是说,不能只把某个字段标记为可变。
这体现了 Rust 对状态变化的严格控制:可变性是对象级别的,而不是随意散落在局部字段上的。
5. 从函数返回结构体实例
结构体实例可以作为函数返回值:
fn build_user(email: String, username: String) -> User {
User {
active: true,
username: username,
email: email,
sign_in_count: 1,
}
}
这说明结构体不仅能保存数据,也很适合作为函数的输出结果。
在工程中,这种写法很常见:
- 函数负责组装对象;
- 调用者直接得到一个完整、清晰、可用的结构体值。
6. 字段初始化简写语法
当函数参数名和结构体字段名相同时,可以使用简写:
fn build_user(email: String, username: String) -> User {
User {
active: true,
username,
email,
sign_in_count: 1,
}
}
它等价于:
username: username,
email: email,
这种简写的本质是:
- 左边字段名和右边变量名相同;
- Rust 允许省掉重复部分。
它带来的好处不是“少写几个字符”这么简单,而是让代码更聚焦于“结构体的构造逻辑”,而不是机械重复。
7. 结构体更新语法
当要基于一个旧实例创建一个新实例,只修改少数字段时,可以使用结构体更新语法:
let user2 = User {
email: String::from("another@example.com"),
..user1
};
这表示:
email使用新的值;- 其他未显式写出的字段,都从
user1中取得。
需要注意的规则:
..user1必须写在最后。- 它本质上仍然遵守所有权和移动规则。
8. 更新语法与所有权的关系
结构体更新语法并不是“拷贝一份所有内容”,而是按字段进行移动或复制。
例如:
username: String是拥有所有权的数据,不实现Copy;- 如果
user2复用了user1.username,那么这个字段会被移动; - 移动后,
user1就不能再像原来那样完整使用了。
但如果复用的字段是:
active: boolsign_in_count: u64
这类实现了 Copy 的类型,那么它们会被复制而不是移动。
因此,更新语法的关键不是“像不像面向对象语言里的复制对象”,而是:它仍严格服从 Rust 的所有权模型。
9. 元组结构体
Rust 还支持元组结构体(tuple struct):
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
它有两个特征:
- 有结构体名,因此它是一个新类型。
- 字段没有名字,只按位置存储。
这类结构体适用于:
- 想保留“多个值打包在一起”的形式;
- 又希望这个组合拥有独立类型身份。
例如:
Color(0, 0, 0)和Point(0, 0, 0)虽然底层都像三个i32,但语义不同,类型也不同。
所以元组结构体的价值是:保留简洁,同时建立类型区分。
10. 类单元结构体
还可以定义没有任何字段的结构体:
struct AlwaysEqual;
这种结构体称为类单元结构体(unit-like struct)。
它的特点是:
- 不存储数据;
- 但仍然是一个独立类型。
适用场景是:
- 你需要一个类型来表达某种概念;
- 但当前不需要实际存储数据;
- 以后可能通过 trait 或方法为它定义行为。
也就是说,这种结构体强调的是类型身份,而不是字段内容。
11. 结构体数据的所有权
书中在 User 中使用的是 String,而不是 &str,这是有意设计。
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
这样做的意义是:
- 结构体拥有自己的数据;
- 只要结构体本身有效,内部数据就有效;
- 不需要额外追踪“这些字符串引用的是谁的数据”。
这是一种非常重要的建模思想:优先让结构体拥有自己的数据,减少引用关系带来的复杂性。
12. 在结构体中存引用需要生命周期
如果把字段写成引用,例如:
struct User {
active: bool,
username: &str,
email: &str,
sign_in_count: u64,
}
编译器会报错,因为需要生命周期标注。
原因是:
- 结构体中如果保存引用,编译器必须知道这些引用至少能活多久;
- 否则结构体可能还存在,但它引用的数据已经失效。
因此,现阶段应该记住结论:
- 结构体存拥有所有权的数据最省事。
- 结构体存引用是可以的,但必须配合生命周期。
四、5.2 结构体示例程序:用 Rectangle 重构面积计算
这一节通过“计算长方形面积”的程序,展示为什么结构体是更好的建模方式。
1. 第一版:使用独立变量
最开始的写法是:
fn main() {
let width1 = 30;
let height1 = 50;
println!(
"The area of the rectangle is {} square pixels.",
area(width1, height1)
);
}
fn area(width: u32, height: u32) -> u32 {
width * height
}
这段代码能算对面积,但有明显问题:
width和height在语义上属于同一个长方形;- 代码却把它们当成两个彼此独立的值传来传去;
area(width, height)并没有清楚表达“这是某个矩形的两个属性”。
换句话说:程序算对了,但模型表达得不够好。
2. 第二版:使用元组
接着会把宽和高打包成元组,例如:
let rect1 = (30, 50);
这样比独立变量前进了一步,因为:
- 宽和高确实被组合在一起了;
- 我们知道它们属于同一个对象。
但元组仍有局限:
rect1.0和rect1.1缺乏语义;- 读代码的人必须记忆“第 0 个是宽,第 1 个是高”;
- 一旦字段变多,含义更难维护。
所以元组虽然表达了“这些值相关”,却没表达清楚“它们各自是什么”。
3. 第三版:使用结构体
最终重构成结构体:
struct Rectangle {
width: u32,
height: u32,
}
然后使用:
let rect1 = Rectangle {
width: 30,
height: 50,
};
这时程序模型明显更强了:
Rectangle表示“长方形”这个对象;width和height明确表示对象的属性;area处理的不再是两个零散数字,而是一个结构清晰的对象。
这就是结构体最典型的使用场景:当数据天然属于同一个概念时,用结构体把概念建出来。
4. 使用 #[derive(Debug)] 打印结构体
结构体默认不能直接用 println!("{:?}") 打印调试信息,需要先为它派生 Debug:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
之后可以这样打印:
println!("rect1 is {:?}", rect1);
println!("rect1 is {:#?}", rect1);
其中:
{:?}是普通调试输出;{:#?}是更适合阅读的美化输出。
这说明 Rust 不只是让我们定义数据结构,还提供了方便的方式去观察结构体的内部状态。
5. dbg! 宏的作用
除了 println!,本节还介绍了 dbg! 宏。
dbg! 的特点:
- 会输出表达式的值;
- 会带上源代码位置等调试信息;
- 会返回这个表达式的所有权。
因此它非常适合快速调试中间值。
但要注意:
dbg!(rect1)会拿走rect1的所有权;- 如果后面还要继续使用
rect1,应写成dbg!(&rect1)。
这个细节再次体现了 Rust 的一贯风格:即使是调试工具,也不会绕开所有权规则。
6. 这一节真正想说明什么
Rectangle 例子表面上是在讲“面积怎么计算”,其实真正想说明的是:
- 程序不仅要能运行正确。
- 程序还要让数据之间的关系表达得正确。
- 结构体让这种表达更自然、更清晰、更容易维护。
所以这节的重点不是公式 width * height,而是:
好的数据建模,会让代码的含义更接近问题本身。
五、5.3 方法语法
1. 为什么需要方法
前面我们用函数来计算面积,例如:
fn area(rectangle: &Rectangle) -> u32 {
rectangle.width * rectangle.height
}
这已经比传两个独立参数更好了,因为它至少把参数提升成了一个 Rectangle。
但还可以继续改进。
从语义上说:
- “计算面积”不是一个与
Rectangle毫无关系的普通函数; - 它其实就是“长方形会做的事情”之一。
因此,更自然的做法是把它定义为方法。
2. 定义方法:impl 块
方法定义在 impl 块中:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
这里:
impl Rectangle表示“下面这些函数与Rectangle类型相关”;- 写在
impl块里的函数,就是与该类型关联的函数; - 其中第一个参数是
self相关形式的,就是方法。
3. 方法与函数的区别
方法和普通函数都能完成逻辑,但它们的组织方式不同。
普通函数:
- 独立存在;
- 参数里显式写出要操作的对象。
方法:
- 放在类型的上下文中;
- 第一个参数总是
self; - 表示“这是这个类型本身的行为”。
因此方法最大的意义不是少写几个字,而是:
把数据和行为组织到一起。
这会让 API 更清晰,也更符合直觉。
4. &self 的含义
在方法签名中:
fn area(&self) -> u32
&self 是 self: &Self 的简写。
含义是:
self表示调用该方法的实例;Self是当前impl对应类型的别名,这里就是Rectangle;&self表示“不可变借用这个实例”。
因此,area(&self) 的真实含义就是:
- 这个方法读取一个
Rectangle; - 但不获取所有权;
- 也不修改它。
5. self 的三种常见形式
方法第一个参数常见有三种:
(1)&self
不可变借用实例。
适合:
- 只读数据;
- 不修改实例;
- 不拿走所有权。
(2)&mut self
可变借用实例。
适合:
- 方法内部需要修改实例状态。
(3)self
获取实例所有权。
适合:
- 调用后原实例不应再使用;
- 方法需要消耗当前对象,或者将其转换成别的值。
其中最常用的是 &self 和 &mut self。
6. 调用方法
定义了方法后,可以这样调用:
rect1.area()
这就是方法语法(method syntax):
- 先写实例;
- 再写点号;
- 再写方法名。
相较于普通函数调用:
area(&rect1)
方法调用:
rect1.area()
更符合“对象调用自身行为”的直觉。
7. Rust 的自动引用与解引用
调用方法时,Rust 会自动帮你补齐必要的引用、可变引用或解引用。
这意味着:
- 如果方法签名需要
&self,你通常可以直接写rect1.area(); - 不必手动写成
(&rect1).area()。
这种自动处理只在方法调用语法中成立,它提升了可读性,也让 API 使用更自然。
8. 带更多参数的方法
方法除了 self 以外,也可以有其他参数。
例如矩形之间比较大小:
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
这个方法表达的是:
- 当前矩形是否能容纳另一个矩形。
调用时:
rect1.can_hold(&rect2)
这个例子非常重要,因为它说明方法不只是“读取自身属性”,还可以表达“当前对象与另一个对象的关系”。
9. 方法名可以与字段名相同
Rust 允许方法名和字段名同名,例如可以同时存在:
- 字段
width - 方法
width()
二者通过语法区分:
rect.width表示字段;rect.width()表示方法。
这使得 API 设计更灵活,例如:
- 字段表示原始数据;
- 同名方法表示某种封装后的含义判断。
10. 关联函数
并不是写在 impl 中的所有函数都必须以 self 开头。
如果函数没有 self 参数,它就不是方法,而是关联函数(associated function)。
例如:
impl Rectangle {
fn square(size: u32) -> Self {
Self {
width: size,
height: size,
}
}
}
这里的 square:
- 不依赖某个已存在的实例;
- 它的作用是构造一个新的
Rectangle; - 因此更像“类型上的函数”,而不是“实例上的行为”。
调用方式是:
let sq = Rectangle::square(3);
注意不是 sq.square(),而是 Rectangle::square(3)。
11. Self 的含义
在关联函数中:
fn square(size: u32) -> Self
Self 表示当前 impl 对应的类型,也就是 Rectangle。
它的好处是:
- 代码更简洁;
- 当类型名较长时更方便;
- 体现“这是当前类型自己的返回值”。
因此:
Self {
width: size,
height: size,
}
本质上就是:
Rectangle {
width: size,
height: size,
}
12. new 不是关键字
书中强调了一点:
- 很多语言中习惯用
new作为构造函数名字; - Rust 里也常这么做;
- 但
new并不是 Rust 关键字。
也就是说,你完全可以根据语义定义更合适的关联函数名,比如:
squarefrom_partswith_capacityempty
因此,Rust 更强调语义合适,而不是机械套用固定命名。
13. 多个 impl 块
一个结构体可以拥有多个 impl 块:
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
这在语法上完全合法。
虽然在当前这个简单例子里没必要拆开,但它说明:
- 类型的行为定义可以按逻辑分块组织;
- 后续学习泛型与 trait 时,这一点会变得更加实用。
六、本章核心思想总结
第 5 章最核心的内容可以概括为以下几点。
1. 结构体是“带名字的数据组合”
与元组相比,结构体最大的优势是字段有名字,因此更能表达真实语义。
2. 结构体是自定义类型的重要基础
通过结构体,你可以把“领域中的概念”直接建模成程序中的类型。
3. 所有权依然贯穿结构体设计
字段是拥有所有权的数据,还是引用别人的数据,会直接影响结构体能否安全存在,以及是否需要生命周期。
4. 结构体更新语法并不会绕过所有权规则
..旧实例 看起来像“复用旧对象”,但本质仍然是按字段进行移动或复制。
5. 方法让数据和行为聚合在一起
把与某个类型相关的逻辑放进 impl,比把一堆普通函数散在外面更清晰、更易维护。
6. 关联函数让类型本身也能拥有“构造行为”
不是所有相关函数都必须作用于实例;有些函数天然适合写成 Type::function() 的形式。
七、易错点与学习建议
1. 不要把结构体只理解成“高级元组”
结构体的重点不是“能装多个值”,而是“能表达一个有意义的对象”。
2. 注意实例可变性是整体的
不能只把某个字段设成可变;要改字段,就要让整个实例是 mut。
3. 牢记更新语法中的移动语义
..user1 并不意味着 user1 一定还能继续完整使用。要根据字段类型判断是否发生移动。
4. 现阶段优先使用拥有所有权的字段类型
如果你刚学到这里,结构体字段优先用 String、Vec<T> 这类拥有所有权的类型,不要急着在结构体里放引用。
5. 方法的意义首先是“组织代码”
方法不仅仅是调用形式从 area(&rect) 变成 rect.area(),更重要的是它体现“这是这个类型自己的行为”。
6. 关联函数和方法不要混淆
区分标准很简单:
- 有
self参数的是方法; - 没有
self参数的是关联函数。
八、关键代码模式速记
1. 定义结构体
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
2. 创建实例
let user1 = User {
active: true,
username: String::from("name"),
email: String::from("a@b.com"),
sign_in_count: 1,
};
3. 修改字段
let mut user1 = User { /* ... */ };
user1.email = String::from("new@b.com");
4. 字段初始化简写
fn build_user(email: String, username: String) -> User {
User {
active: true,
username,
email,
sign_in_count: 1,
}
}
5. 结构体更新语法
let user2 = User {
email: String::from("another@example.com"),
..user1
};
6. 定义方法
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
7. 定义带额外参数的方法
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
8. 定义关联函数
impl Rectangle {
fn square(size: u32) -> Self {
Self {
width: size,
height: size,
}
}
}
9. 调试打印结构体
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
println!("{:?}", rect1);
println!("{:#?}", rect1);
dbg!(&rect1);
九、一句话总括本章
第 5 章真正教会你的,不只是“Rust 里怎么写结构体”,而是:当多个数据在概念上属于同一个对象时,应该把它们提升为一个明确的类型,并把与之相关的行为一并组织进去。