定义 struct 与实例化
基础类型表达单个值,真实世界的数据总是成组出现:一个用户有姓名、邮箱、积分。struct 把这些字段捆成一个类型,编译器随之获得检查每个字段用法的能力。这篇讲 struct 的三种形态、实例化的简写语法、以及字段可见性带来的封装边界。读完你就能把任何 JSON 形状的数据翻译成 Rust 类型。
一、struct 的三种形态
命名结构体最常见,每个字段都有名字;元组结构体字段无名只有顺序,适合轻量包装;单元结构体没有任何字段,常用于给 trait 实现占位。
// 命名结构体
struct User {
name: String,
email: String,
points: u64,
active: bool,
}
// 元组结构体:两种同样重量的类型不会被混用
struct UserId(u64);
struct Points(u64);
// 单元结构体
struct Marker;
注意 UserId 与 Points 内部都是 u64,但编译器视它们为不同类型,接收 UserId 的函数不会误收 Points。这就是「newtype 模式」,零运行时成本加了类型安全。
三种形态的选择有一条经验法则:字段多于两个且语义各异时用命名结构体,名字本身就是文档;只是给单个值加类型标签时用元组结构体;需要一处挂 trait 实现又没有数据时用单元结构体。元组结构体字段访问写 .0,一旦字段超过三个就该换命名结构体,否则读代码的人全靠数下标猜含义。newtype 还有一个额外好处:可以直接给 UserId 挂方法与 trait 实现,不会污染原始 u64 的方法空间,领域逻辑因此有了落脚点。
二、实例化与字段简写
实例化时所有字段必须赋值,不存在部分初始化。变量名与字段名相同时可简写,这在构造函数里特别省事:
fn build(name: String, email: String) -> User {
User {
name, // 等价于 name: name
email,
points: 0, // 默认值在这里给
active: true,
}
}
fn main() {
let u = build(String::from("林"), String::from("a@b.cn"));
println!("{} 积分 {}", u.name, u.points); // 读字段用点
}
更新语法 ..other 用一个旧实例填充其余字段,修改个别字段时很方便。注意它会移动被复制来源的字段,来源结构体的 String 字段被移走后就不能再用了。
fn reset_email(u: User) -> User {
User {
email: String::new(),
..u // 其余三个字段来自 u
}
}
这段代码里 name 等字段被移动进新实例。如果只想借用而非移动,就得声明 struct 持有引用并标注生命周期,那是另一套设计——通常更好的办法是让数据自身拥有内容,只在处理窗口内传切片。
三、可变性与字段级访问
struct 实例整体可变或整体不可变,没有字段级 mut。要让单个字段可变,常见手段是用内部可变性(Cell/RefCell)或把该字段拆成独立生命周期的成员。这条限制看似别扭,实则简化了推理:一个 struct 可变,则所有字段都可能被改。
let mut u = build(String::from("林"), String::from("a@b.cn"));
u.points += 10; // 实例是 mut,字段才能改
// u.email.clear(); // 若 u 未声明 mut,这一行编译失败
从其他语言过来的读者常把这当成缺陷,但它是对借用检查器的直接支持:只要实例不可变,编译器就能保证没有指向其内部的可变引用,别名分析因此精确到字段级。愿意支付一点写法上的约束,换来的是编译期对并发共享与别名混乱的排除,这笔账不亏。
四、派生 trait 与打印
裸 struct 不能直接 println 打印,需要派生 Debug。日常开发派生四件套是 Debug、Clone、PartialEq,序列化场景再加 serde 的 Serialize 与 Deserialize。
| 派生 trait | 提供能力 | 典型用途 |
|---|---|---|
| Debug | 调试格式化打印 | 日志与临时排查 |
| Clone | 显式深复制 | 需要副本时调用 clone |
| PartialEq/ Eq | 值相等比较 | 测试断言 |
| Default | 全字段默认值 | 配置初始化 |
| Copy | 栈上整值复制 | 仅适用于无堆字段 |
派生用属性写法即可:#[derive(Debug, Clone, PartialEq)] 放在 struct 定义上方,编译器自动生成实现。想自定义相等规则时手写 PartialEq 也可以,但派生能覆盖九成场景。
派生背后还有一个细节值得知道:属性是编译期的代码生成,不是运行时反射。因此派生的实现可以内联优化,与手写版本性能无差,不必为了「避免开销」去手写。同样的道理也适用于 serde 的派生——它在编译期展开成逐字段的序列化代码。
| 常见组合 | 适用场景 |
|---|---|
| Debug 单独派生 | 只为调试打印 |
| Debug + Clone | 临时DTO、测试夹具 |
| Debug + Clone + PartialEq | 需要比较的业务模型 |
| 全套加 serde | 与外部格式交换数据 |
| 手写 Display | 面向用户的格式化输出 |
五、可见性与封装
字段默认私有,加上 pub 才对外暴露。库设计的一条经验:字段尽量私有,公开方法提供受控访问;一旦字段公开,外部代码任意修改,后续重构就被锁死。可见性以 crate 为界,同 crate 内默认可见,跨 crate 才需 pub。
pub struct Account {
balance: u64, // 私有:外部不能直接读
pub id: UserId, // 公开:标识可以暴露
}
impl Account {
pub fn balance(&self) -> u64 { self.balance } // 只读访问器
}
结构体定义与字段可见性独立:struct 自身 pub 但字段私有是完全合法的组合,代表「类型公开、内部不公开」。
一个常见疑问:getter 方法是否值得为每个私有字段写一个?答案是看使用频率,外部反复要读的字段值得一个 fn field(&self) -> &T,从来没人读的字段保持私有即可。getter 与直接公开字段的差别在于:方法签名是稳定接口,日后可以换内部存储(比如换成计算值)而不破坏调用方。这一点在库演进时价值最大,应用内部代码则可以宽松些。
六、内存布局与速度
struct 字段按声明顺序排列,编译器会按需要对齐填充。对缓存敏感的热路径,重排字段(大的放前面或用 repr(C))能改善局部性,但这属于优化末节,profile 显示为热点再说。绝大多数业务代码无需关心布局,Rust 的默认安排已经足够好。
一个新手常问的问题:struct 能不能递归自引用,比如「节点里放一个同类型节点」?不能直接放,因为编译器算不出无限递归类型的大小。标准答案是三个里选一:用 Box 把递归字段放到堆上;用容器间接表达(children: Vec<Node>);或者换成枚举加指针的组合。数据结构教科书里的链表树在 Rust 里几乎都得过这一关,所有权一章的移动语义在这里第一次显示了威力。
做前后端数据传递时 struct 与 JSON 的映射是高频需求,Tauri 桌面开发指南 里的命令参数定义演示了同一 struct 在 Rust 与前端两边共享的写法,序列化派生是其中承上启下的枢纽。
延伸阅读
- 下一篇「方法与关联函数」给 struct 挂上行为,两者一起才构成完整的面向数据设计。
- 结构体与 serde 的完整配合留在文件 IO 与序列化一章,那里有 Derive 属性逐项说明。
- 写扩展程序时配置对象也用 struct 表达,浏览器扩展开发指南 的存储一章有跨语言结构对照的例子。
