第五章 数据类型与结构体:把零散的数据,焊成一个有意义的整体
你有没有过这种经历:去政务大厅办一件事,窗口要你填姓名、身份证、手机号、住址——四个字段分散在四张纸上,稍微手一抖,姓名和身份证就对不上号了。程序里也一样:如果 name、id、phone、addr 是四个各管各的变量,过不了几天你就会写出 print(name, id) 却把别人的 id 配上去的 bug。Rust 的解法很朴素:把这些”本该是一伙的”数据,用结构体(struct)焊成一个整体,从此它们必须整体出现、整体传递。
墨叔先抛结论:Rust 的数据是”值语义”的——你赋值、传参,搬的是整个值的本身(或按所有权规则 move),而不是一个飘忽的引用。struct/enum/tuple 是组织这些值的容器,而 impl 把”能对这个容器做什么”和容器本身绑在一起,形成面向对象那味儿、却又更干净的东西。 这一章我们把”怎么组织数据”和”怎么给数据配行为”讲透。
一、struct:把相关的字段打包成一个有意义的整体
结构体就是一张”自定义表格”。你声明一次”一个人有哪些字段”,以后每次创建都是一张填好的表。
struct 用户 {
姓名: String,
年龄: u32,
是否会员: bool,
}
let 小墨 = 用户 {
姓名: String::from("墨叔"),
年龄: 42,
是否会员: true,
};
// 访问字段用点号,干净利落
println!("{} 今年 {} 岁", 小墨.姓名, 小墨.年龄);注意看这里的所有权味道:小墨 是一个值,它”拥有”自己字段里的数据(包括 姓名 那串 String 的堆内存)。当你把 小墨 move 给别人,整张表连同姓名那块堆内存一起过户。这就是值语义——结构体是”一整块有意义的数据”,不是一个装着引用、各部分各过各日的拼盘。
字段多的时候,Rust 有个贴心语法:用变量名和字段名相同时,可以只写名字(姓名, 等于 姓名: 姓名,)。这叫”字段初始化简写”,少敲不少字。还有”结构体更新语法” ..other,用别人的字段填自己的空缺,但记住它底层可能是 move——被搬的字段照样按所有权规则走。
如果你写过 JavaScript 或 Python,这里的心智要重装:你过去觉得”对象”就是个可以随时增删字段的字典,今天挂个 a 明天挂个 b。Rust 的 struct 是定型且不可中途加字段的——声明时定死有哪些字段,之后每个实例都长一样。这不是死板,是”形状在编译期确定”带来的好处:编译器能精确知道一个 用户 占多少字节、字段在内存哪偏移,访问零成本、还能做边界检查外的静态保证。你失去了”随便加字段”的随意,换来的是”数据结构永不意外变形”的确定性。
二、tuple 与具名结构体:轻量组合 vs 自解释组合
除了命名字段的结构体,还有两种轻量组合。
元组(tuple)是一组按顺序的值,用括号抱团,靠位置(.0、.1)访问:
let 坐标 = (10, 20); // (i32, i32)
let (x, y) = 坐标; // 解构,一次性拆出元组适合”临时把两三个值捆一起、且含义靠上下文就够清楚”的场景,比如函数返回”成功标志 + 消息”。但字段一多、或者含义不显然,光靠 .2、.3 可读性就崩了——这时候就该用命名字段的 struct。Rust 还有”元组结构体”(struct 颜色(i32, i32, i32);),它既有名字又靠位置访问,常见于”新类型(newtype)“模式,用来给一个已有类型包一层语义外壳(比如 struct 米(i32) 和 struct 秒(i32) 虽都是 i32,却不能被混用)。
选择口诀:临时捆几下、含义自明 → 元组;要长期传递、字段要有名字 → 结构体;想给老类型套新语义 → 元组结构体/newtype。没有银弹,看”这堆数据要被传来传去多少次、含义靠不靠得住”。
三、impl:把”行为”焊到”数据”上
光有数据不够,你还得能对它做事。Rust 用 impl 块给结构体(或枚举)添加方法。方法和函数的区别,在于方法第一个参数是 self(或 &self、&mut self),表示”这个方法作用在谁身上”。
impl 用户 {
// 关联函数:没有 self,常用作构造函数
fn new(姓名: &str, 年龄: u32) -> 用户 {
用户 {
姓名: String::from(姓名),
年龄,
是否会员: false,
}
}
// 方法:带 &self,作用在实例上
fn 介绍(&self) -> String {
format!("我是{},{}岁", self.姓名, self.年龄)
}
// 方法:带 &mut self,能改自己
fn 升级会员(&mut self) {
self.是否会员 = true;
}
}
let mut u = 用户::new("阿强", 30); // 注意 ::new,这是关联函数
println!("{}", u.介绍());
u.升级会员();这里有两个容易混的点,墨叔点醒你:
关联函数 vs 方法。 不带 self 的(如 new),叫关联函数,调用时用 类型名::函数名(双冒号),它不作用于某个具体实例——典型用途就是当”构造函数”(new 只是约定俗成的名字,你爱叫 create 也行,Rust 没有 new 关键字)。带 self 的才叫方法,调用时用 实例.方法名(点号),self 自动被填成”这个实例”。一句话区分:要不要先有个实例才能调?要 → 方法(有 self);不要、直接靠类型就能调 → 关联函数(无 self)。
self 的三种形态就是借用的三种形态。 self(拿所有权)、&self(借来读)、&mut self(借来改)——你一眼认出,这恰好是第三章讲的 move / 不可变借用 / 可变借用三档!所以”方法怎么声明”,本质是”这个方法对调用方的数据,是过户、只读、还是能改”。介绍 只需读,用 &self;升级会员 要改,用 &mut self。编译器据此继续用借用规则卡你:你拿着 &self 时绝不能在方法里改字段,因为它只是借阅证。
四、值语义:Rust 赋值搬的是”整个值”
我们再深挖一个底层理念——值语义(value semantics)。这是 Rust 和很多语言的根本分野之一。
在”引用语义”的语言里(比如 Java 的对象赋值、a = b 后 a、b 指向同一块堆),你改 a 的字段,b 也跟着变,因为俩变量共享同一个对象。这种”改一个动一片”的特性,是无数并发 bug 和”我明明没动它怎么变了”的源头。
Rust 默认是值语义:赋值和传参,搬的是值本身(按所有权规则,可能是 move 整块、或 Copy 按位拷)。let b = a; 之后,b 是一个独立的值,改 b 绝不影响 a(除非 a 被 move 走了、本就不该再碰)。这带来一个巨大好处:可预测性——你手里这个值,就是它自己,不跟别处的变量暗中牵连。
let a = 用户 { 姓名: String::from("甲"), 年龄: 20, 是否会员: false };
let b = a; // move:a 已经失效,b 是独立的主人
// 此时不存在"a 和 b 共享同一份"的情况,从语言层面杜绝隐式牵连结合第二章:因为 用户 含 String(碰了堆),所以它不是 Copy,赋值即 move。如果你想两份独立副本,显式 .clone()。Rust 不替你偷偷拷贝,也不替你偷偷共享——它把”搬值还是借值”的抉择,明晃晃摆在你面前。值语义 + 所有权,是”数据不被偷偷改动”的双保险。
五、内存对齐与 padding:点一下,不深挖
顺带提一句底层,点到即止,免得你觉得墨叔藏着。CPU 读内存不是任意位置随手抓的,它喜欢按”字长边界”对齐——比如一个 u32 最好落在 4 的倍数地址上,读起来最快。于是结构体里字段的排列,编译器会自动插入”填充字节(padding)“来满足对齐。
struct 例子 {
a: u8, // 1 字节
// ← 这里编译器可能插 3 字节 padding,让下面的 u32 对齐到 4 的倍数
b: u32, // 4 字节
c: u8, // 1 字节
// ← 末尾可能再补 3 字节,让整个结构体大小是 4 的倍数
}结果 例子 占的字节数,往往大于 1+4+1=6,而是 12 左右——多出来的就是 padding。这个知识干嘛用?调结构体字段顺序(把大的、对齐要求高的放一起)可以省内存,写要和 C 交互的 FFI 时更得在意对齐。但日常业务代码,编译器替你管好了,你不用天天惦记。墨叔只点这一下:知道”Rust 的值在内存里是实打实排布的、有对齐代价”这件事,有助于你理解为什么它是系统语言、为什么零成本抽象成立。深挖留到写 unsafe/FFI 那章。
所以总结这一章的因果链:struct/enum/tuple 解决”怎么把数据组织成有意义的整体”;impl 把行为绑到数据上,方法的 self 三态恰是借用三档;值语义保证”我手里的就是我的、不被暗中牵连”;对齐 padding 是底层排布的代价,编译器多数时候替你扛。理解了”数据怎么组织、行为怎么绑定、值怎么搬”,你才算有了写真实程序的骨架。
六、方法的接收者三态,是借用三档的影子
回看 self / &self / &mut self,你有没有觉得眼熟?没错,它就是第二章借用规则在”方法调用”上的投射:self 拿所有权(调用后原值没了)、&self 借不可变、&mut self 借可变。所以”一个方法该用哪种接收者”,答案不在语法,在你这个方法要不要动数据、要不要消耗它。把这个和借用规则连起来想,Rust 的很多设计就不再是孤立的规则,而是一张自洽的网——你改一个字段得用 &mut self,编译器才放你进方法体去改;你只读数用 &self,它就保证你碰不动。设计意图一以贯之。
【思考题】
-
墨叔说 Rust 是”值语义”,而 Java 的对象是”引用语义”。请你推一个具体场景:假设在引用语义语言里,你写
b = a后改了b.字段,a也跟着变,导致一个你”从来没碰过 a”的函数出了错。请你设计这样一个 bug,并说明 Rust 的值语义 + move 是怎么从根上让它不发生的。不要只说”Rust 更安全”。 -
new是关联函数(无 self),介绍是方法(有&self)。请你讲清:为什么 Rust 不把new设计成”先有实例才能调的方法”?换句话说,构造一个对象之前,还没有实例,那self该填谁?由此说明关联函数存在的必要性。再举一个除了new之外、你觉着”不适合做成方法、适合做关联函数”的例子。 -
方法声明里
&self、&mut self、self三档,墨叔说”就是借用的三档”。请你把第四章(生命周期)拉进来想:当&self背后引用的数据有生命周期时,方法的返回引用要不要也标生命周期?什么情况下编译器能省略、什么情况下逼你手写?用impl 用户 { fn 最长名字(&self, 其他: &用户) -> &str }这种形状推一下。
【参考答案】
-
场景设计:在引用语义语言里,写个
配置 config = 读取配置();然后配置副本 = config;(副本和config指向同一对象)。你在某个工具函数里为了”临时显示”改了副本.超时 = 9999,本意只动副本。结果主流程里config.超时也变成了 9999——因为俩变量共享同一块对象。主流程从没被你改过,却用了错误的超时,去连数据库直接超时雪崩。这就是”改一个动一片”的经典事故。Rust 里:配置副本 = 配置;是 move,原配置立刻失效,你根本不可能”既留着 config 又改副本”——它们不是两份共享,而是所有权过户给了副本,原主不再合法存在,编译器禁止你再用配置。想要独立两份?必须显式.clone(),那一刻你清醒地知道”我在花钱复制”。于是”无意中共享导致暗改”从语言层面被结构消除:要么唯一主人(改了就是改那份唯一的),要么你显式复制(两份独立、各改各的)。不可能出现”以为只改副本、其实动了原主”的中间态。 -
new是造物的起点:在调用用户::new(...)之前,内存里根本还没有一个用户实例存在,那self该绑定到哪个对象?没有对象,就没有self可填。所以”创建实例”这个动作,逻辑上必须先于”作用于实例的方法”,它只能是关联函数——靠类型名直接调,不依赖任何已有实例。这也解释了为什么 Rust 没有构造函数关键字:因为构造只是个普通关联函数,你爱返回什么、怎么拼字段都自由。另一个适合做关联函数的例子是”类型级的工厂/转换”,比如颜色::从_rgb(r, g, b)——它从三个原始分量合成一个颜色,合成前也没有颜色实例,所以是关联函数而非方法;再比如i32::from_str("123")这种”从别的类型解析出我”,也是关联函数,因为调用时”我”还不存在。判断标准一句话:动作的接收者(self)在调之前就已经存在 → 方法;动作本身就是”把接收者造出来/从别处变出来” → 关联函数。 -
把生命周期拉进来:方法
fn 最长名字(&self, 其他: &用户) -> &str返回的是个字符串切片引用。编译器要问”返回的&str借自谁”。这里有两个输入引用:&self和其他: &用户。返回借自哪个不确定(方法里可能返回self.姓名也可能返回其他.姓名)。按第四章省略规则:有self时规则二说”默认绑到 self”,但本例返回值可能借自其他,规则二不够用;而来源有二义,省略规则第三条也管不了,于是编译器逼你手写生命周期:fn 最长名字<'a>(&self, 其他: &'a 用户) -> &'a str——这里故意只给其他标'a、self用另一条(或也用'a),表达”返回的引用跟其他绑同一约束”。如果方法永远只返回self的字段(来源唯一是 self),那规则二自动省略,你不用写。所以判断跟函数一致:返回引用且借自唯一确定(仅 self)→ 省略;借自有二义(self 和其他都可能)→ 逼你手写。生命周期在方法上和自由函数上规则同源,只是&self常常让规则二替你省了多数情况。
小结
struct/enum/tuple 把零散数据焊成有意义的整体,impl 用 self 三态(恰是借用三档)把行为绑到数据上;Rust 的值语义保证”我手里的就是我的、不被暗中牵连”,对齐 padding 是底层排布代价、编译器多数替你扛——组织的确定性,是一切可靠程序的骨架。