返回 Rust
Rust 2026-05-19

第5章:使用结构体组织相关联的数据

第5章:使用结构体组织相关联的数据

一、章节目标

本章的核心任务,是学习如何使用 struct 将彼此相关的数据组织成一个有意义的整体,并进一步为这种数据类型定义与之配套的行为。

这一章实际上回答了三个问题:

  1. 当多个数据在语义上属于同一个对象时,为什么不应继续把它们拆散存放。
  2. 如何定义、实例化和更新结构体。
  3. 如何通过 impl 和方法语法,让“数据”和“操作这些数据的逻辑”自然地放在一起。

从语言设计角度看,结构体是 Rust 中创建自定义类型的重要基础。它让程序从“只是在处理一些值”,提升到“在处理领域中的对象”。

二、结构体的本质与作用

结构体(struct)是一种自定义数据类型,用来把多个相关联的值打包为一个整体。

它和元组有相似之处:

  • 都可以把多个值组合在一起。
  • 各部分的数据类型可以不同。

但结构体比元组更进一步,因为它的每个组成部分都有名字,也就是字段(field)。

这带来三个直接好处:

  1. 语义更清晰:字段名可以表达数据的真实含义。
  2. 访问更直观:通过字段名访问,而不是通过位置索引访问。
  3. 维护更容易:不必依赖字段顺序去理解代码。

所以,元组适合“临时组合几个值”,而结构体适合“表达一个真实对象”。

三、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 中取得。

需要注意的规则:

  1. ..user1 必须写在最后。
  2. 它本质上仍然遵守所有权和移动规则。

8. 更新语法与所有权的关系

结构体更新语法并不是“拷贝一份所有内容”,而是按字段进行移动或复制

例如:

  • username: String 是拥有所有权的数据,不实现 Copy
  • 如果 user2 复用了 user1.username,那么这个字段会被移动;
  • 移动后,user1 就不能再像原来那样完整使用了。

但如果复用的字段是:

  • active: bool
  • sign_in_count: u64

这类实现了 Copy 的类型,那么它们会被复制而不是移动。

因此,更新语法的关键不是“像不像面向对象语言里的复制对象”,而是:它仍严格服从 Rust 的所有权模型。

9. 元组结构体

Rust 还支持元组结构体(tuple struct):

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

它有两个特征:

  1. 有结构体名,因此它是一个新类型。
  2. 字段没有名字,只按位置存储。

这类结构体适用于:

  • 想保留“多个值打包在一起”的形式;
  • 又希望这个组合拥有独立类型身份。

例如:

  • 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
}

这段代码能算对面积,但有明显问题:

  • widthheight 在语义上属于同一个长方形;
  • 代码却把它们当成两个彼此独立的值传来传去;
  • area(width, height) 并没有清楚表达“这是某个矩形的两个属性”。

换句话说:程序算对了,但模型表达得不够好。

2. 第二版:使用元组

接着会把宽和高打包成元组,例如:

let rect1 = (30, 50);

这样比独立变量前进了一步,因为:

  • 宽和高确实被组合在一起了;
  • 我们知道它们属于同一个对象。

但元组仍有局限:

  • rect1.0rect1.1 缺乏语义;
  • 读代码的人必须记忆“第 0 个是宽,第 1 个是高”;
  • 一旦字段变多,含义更难维护。

所以元组虽然表达了“这些值相关”,却没表达清楚“它们各自是什么”。

3. 第三版:使用结构体

最终重构成结构体:

struct Rectangle {
    width: u32,
    height: u32,
}

然后使用:

let rect1 = Rectangle {
    width: 30,
    height: 50,
};

这时程序模型明显更强了:

  • Rectangle 表示“长方形”这个对象;
  • widthheight 明确表示对象的属性;
  • 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 例子表面上是在讲“面积怎么计算”,其实真正想说明的是:

  1. 程序不仅要能运行正确。
  2. 程序还要让数据之间的关系表达得正确。
  3. 结构体让这种表达更自然、更清晰、更容易维护。

所以这节的重点不是公式 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

&selfself: &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 关键字。

也就是说,你完全可以根据语义定义更合适的关联函数名,比如:

  • square
  • from_parts
  • with_capacity
  • empty

因此,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. 现阶段优先使用拥有所有权的字段类型

如果你刚学到这里,结构体字段优先用 StringVec<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 里怎么写结构体”,而是:当多个数据在概念上属于同一个对象时,应该把它们提升为一个明确的类型,并把与之相关的行为一并组织进去。