第五章 数据类型与结构体:把零散的数据,焊成一个有意义的整体

你有没有过这种经历:去政务大厅办一件事,窗口要你填姓名、身份证、手机号、住址——四个字段分散在四张纸上,稍微手一抖,姓名和身份证就对不上号了。程序里也一样:如果 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,它就保证你碰不动。设计意图一以贯之。

【思考题】

  1. 墨叔说 Rust 是”值语义”,而 Java 的对象是”引用语义”。请你推一个具体场景:假设在引用语义语言里,你写 b = a 后改了 b.字段,a 也跟着变,导致一个你”从来没碰过 a”的函数出了错。请你设计这样一个 bug,并说明 Rust 的值语义 + move 是怎么从根上让它不发生的。不要只说”Rust 更安全”。

  2. new 是关联函数(无 self),介绍 是方法(有 &self)。请你讲清:为什么 Rust 不把 new 设计成”先有实例才能调的方法”?换句话说,构造一个对象之前,还没有实例,那 self 该填谁?由此说明关联函数存在的必要性。再举一个除了 new 之外、你觉着”不适合做成方法、适合做关联函数”的例子。

  3. 方法声明里 &self、&mut self、self 三档,墨叔说”就是借用的三档”。请你把第四章(生命周期)拉进来想:当 &self 背后引用的数据有生命周期时,方法的返回引用要不要也标生命周期?什么情况下编译器能省略、什么情况下逼你手写?用 impl 用户 { fn 最长名字(&self, 其他: &用户) -> &str } 这种形状推一下。

【参考答案】

  1. 场景设计:在引用语义语言里,写个 配置 config = 读取配置(); 然后 配置副本 = config;(副本 和 config 指向同一对象)。你在某个工具函数里为了”临时显示”改了 副本.超时 = 9999,本意只动副本。结果主流程里 config.超时 也变成了 9999——因为俩变量共享同一块对象。主流程从没被你改过,却用了错误的超时,去连数据库直接超时雪崩。这就是”改一个动一片”的经典事故。Rust 里:配置副本 = 配置; 是 move,原 配置 立刻失效,你根本不可能”既留着 config 又改副本”——它们不是两份共享,而是所有权过户给了副本,原主不再合法存在,编译器禁止你再用 配置。想要独立两份?必须显式 .clone(),那一刻你清醒地知道”我在花钱复制”。于是”无意中共享导致暗改”从语言层面被结构消除:要么唯一主人(改了就是改那份唯一的),要么你显式复制(两份独立、各改各的)。不可能出现”以为只改副本、其实动了原主”的中间态。

  2. new 是造物的起点:在调用 用户::new(...) 之前,内存里根本还没有一个 用户 实例存在,那 self 该绑定到哪个对象?没有对象,就没有 self 可填。所以”创建实例”这个动作,逻辑上必须先于”作用于实例的方法”,它只能是关联函数——靠类型名直接调,不依赖任何已有实例。这也解释了为什么 Rust 没有构造函数关键字:因为构造只是个普通关联函数,你爱返回什么、怎么拼字段都自由。另一个适合做关联函数的例子是”类型级的工厂/转换”,比如 颜色::从_rgb(r, g, b)——它从三个原始分量合成一个颜色,合成前也没有颜色实例,所以是关联函数而非方法;再比如 i32::from_str("123") 这种”从别的类型解析出我”,也是关联函数,因为调用时”我”还不存在。判断标准一句话:动作的接收者(self)在调之前就已经存在 → 方法;动作本身就是”把接收者造出来/从别处变出来” → 关联函数。

  3. 把生命周期拉进来:方法 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 是底层排布代价、编译器多数替你扛——组织的确定性,是一切可靠程序的骨架。