定义 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 表达,浏览器扩展开发指南 的存储一章有跨语言结构对照的例子。