跳转到内容

5.2 结构体(struct):所有权单元与部分移动

上一节我们看到,元组 (i32, bool) 可以把几个值临时打包在一起。但元组有两个短板:一是字段没有名字,写 user.0、user.1 很快就没人记得代表什么;二是任何两个 (i32, i32) 在编译器眼里都是同一个类型,无法区分「二维坐标」和「宽高尺寸」。

要在工程中对业务数据建模,我们需要用 struct(结构体)来定义全新的类型。


每写下一个 struct 定义,你就向编译器注册了一个独立的编译期类型实体。Rust 的结构体共有三种语法形态:

给每个字段明确起名并标注类型:

struct User {
name: String,
age: u64,
active: bool,
}
在 Rust Playground 中运行

创建实例时,必须用 User { 字段名: 值, ... } 显式初始化每一个字段。字段的书写顺序可以任意调换,但绝不允许漏掉任何一个字段——Rust 里不存在「未初始化的默认 null 字段」。

只给结构体起类型名,里面的字段不写名字:

struct Color(u8, u8, u8);
struct Point(u8, u8, u8);
在 Rust Playground 中运行

它的取值方式和普通元组一样(用 red.0、red.1),但它是一个全新的类型。哪怕 Color 和 Point 里面装的都是三个 u8,编译器也会把它们当成两个完全不相干的类型,防止你在传参时把「颜色」误传给「坐标」。

连圆括号和字段都不写,只有一个类型名加分号:

它和上一节学的单元类型 () 一样,在运行时占 0 个字节(零大小类型 ZST),后续常用于在不占内存的前提下挂载特定的行为契约(Trait)。


在创建具名字段结构体时,Rust 提供了两个省去重复代码的语法糖:

当你的变量名(或函数参数名)刚好和结构体字段同名时,不需要重复写 name: name,直接写一次名字即可:

fn build_user(name: String, age: u64) -> User {
User {
name, // 等价于 name: name
age, // 等价于 age: age
active: true,
}
}
在 Rust Playground 中运行

当你想基于已有的实例 u1 创建一个新实例 u2,只改动其中少数几个字段时,可以在最后写上 ..u1:

let u2 = User {
name: String::from("Bob"),
..u1 // 其余未列出的字段(age、active)直接从 u1 拿过来
};
在 Rust Playground 中运行

要注意的是,..u1 在底层其实就是对剩余字段逐一执行 = 赋值。

在上面的写法中,从 u1 拿过来的只有实现了 Copy 的 age(u64)和 active(bool),所以 u1 依然完好可用;但如果 ..u1 把 u1 里未实现 Copy 的 String 字段搬给了 u2,那么 u1 对应的字段就会发生移动(Move)而失效。


物理表示:扁平内联,没有对象头

Section titled “物理表示:扁平内联,没有对象头”

运行下面这段完整代码,看看这三种结构体在内存里分别占多少字节:

源文件:struct-basics.rs
use std::mem::size_of;
struct User {
name: String,
age: u64,
active: bool,
}
struct Color(u8, u8, u8);
struct AlwaysEqual;
fn build_user(name: String, age: u64) -> User {
User {
name, // field init shorthand for `name: name`
age,
active: true,
}
}
fn main() {
let u1 = build_user(String::from("Alice"), 28);
// Struct update syntax `..u1` moves or copies remaining fields from `u1`.
let u2 = User {
name: String::from("Bob"),
..u1
};
let red = Color(255, 0, 0);
let _marker = AlwaysEqual;
println!("u2: {} (age {}, active {})", u2.name, u2.age, u2.active);
println!("rgb = ({}, {}, {})", red.0, red.1, red.2);
println!(
"sizes: User = {}B, Color = {}B, AlwaysEqual = {}B",
size_of::<User>(),
size_of::<Color>(),
size_of::<AlwaysEqual>()
);
}
在 Rust Playground 中运行

运行输出:

运行输出
u2: Bob (age 28, active true)
rgb = (255, 0, 0)
sizes: User = 40B, Color = 3B, AlwaysEqual = 0B

看最后一行输出:User 占 40B(String 的 24 字节控制头 + u64 的 8 字节 + bool 的 1 字节 + 7 字节内存对齐填充),Color 占 3B,AlwaysEqual 占 0B。

这与 Java 或 Python 的对象有着根本区别:

  • 在 Java 中,User 对象本身在堆上有对象头(12~16 字节),里面的 String name 又是一个指向另一块堆内存的引用;
  • 在 Rust 中,struct 是纯粹的扁平内联(Flat Inline):User 的 40 个字节直接紧挨着排在当前栈帧上,没有任何隐藏的对象头。

为什么自定义类型默认走移动(Move)?

Section titled “为什么自定义类型默认走移动(Move)?”

现在看一个容易让人意外的现象。我们定义一个只包含两个 i32 整数的 Point:

源文件:point-move.rs
//! Two `i32` fields, no heap. Without a `Copy` declaration the struct still moves.
//! Uncomment the last line to get error[E0382]: borrow of moved value: `a`.
struct Point {
x: i32,
y: i32,
}
fn main() {
let a = Point { x: 1, y: 2 };
let b = a;
println!("{} {}", b.x, b.y);
// println!("{}", a.x);
}
在 Rust Playground 中运行

Point 里面装的只是两个 i32(共 8 字节,全在栈上,没有半点堆内存)。可一旦取消最后一行 // println!("{}", a.x); 的注释,编译器居然报出了 error[E0382](移动后使用)。

为什么同样是两个 i32,元组 (i32, i32) 和数组 [i32; 2] 自动实现了 Copy,而我们自己定义的 struct Point 却默认走移动(Move)?

因为对自定义类型而言,默认不实现 Copy 是最安全的接口承诺。

试想,如果编译器因为 Point 目前只有两个 i32 就自动替它开启 Copy,那将来某天你在升级代码时往 Point 里加了一个 String 标签字段(导致它不再满足按位复制条件),全项目所有写过 let b = a; 的地方就会在一夜之间全部编译失败。

因此 Rust 规定:自定义类型默认一律走移动语义。只有当你确认这个结构体很小、所有字段都支持 Copy,且愿意对外承诺它具备按位复制能力时,才通过属性标注 #[derive(Clone, Copy)] 显式开启:

源文件:point-copy.rs
//! The same struct with `Copy` declared. Assignment now copies.
#[derive(Clone, Copy)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let a = Point { x: 1, y: 2 };
let b = a;
println!("{} {}", a.x, b.y); // both bindings are usable
}
在 Rust Playground 中运行

加上这行标注后,let b = a; 就会按位复制栈上的 8 个字节,a 和 b 都能继续使用。


如果一个结构体里既有 String 字段,又有 u32 字段,我们不移动整个结构体,只把里面的 String 字段单独赋给另一个变量,会发生什么?

在第 4 章我们见过,数组 [String; 2] 和动态数组 Vec<String> 都严禁写 let s = names[0]; 移出单个元素(因为下标 i 可能是运行时算出来的变量,编译器无法在编译期追踪第几个格子空了)。

但结构体的字段名(如 user.name)在编译期是 100% 固定的。运行下面这段代码看看编译器的处理方式:

源文件:partial-move.rs
//! Moving one field out leaves the other fields usable.
//! Uncomment either commented `println!` line to trigger error[E0382].
#[derive(Debug)]
struct User {
name: String,
age: u32,
}
fn main() {
let user = User {
name: String::from("Ferris"),
age: 7,
};
let name = user.name; // the String moves out to `name`
println!("moved name: {name}, remaining age: {}", user.age);
// println!("cannot read moved field: {}", user.name);
// println!("cannot use whole struct: {:?}", user);
}
在 Rust Playground 中运行

运行输出:

运行输出
moved name: Ferris, remaining age: 7

注意第 16~17 行发生了两件事:

  1. let name = user.name; 把字段 user.name(String)的所有权单独移交给了新变量 name;
  2. 紧接着下一行,我们依然可以合法地读取未受影响的 user.age(输出 7)。

但如果取消最后两行中任意一行的注释,编译器就会报 error[E0382]:

编译器诊断摘要:取消 partial-move.rs 最后两行注释后
error[E0382]: borrow of moved value: `user.name`
--> types/structs/src/bin/partial-move.rs:19:44
|
16 | let name = user.name; // the String moves out to `name`
| --------- value moved here
...
19 | println!("cannot read moved field: {}", user.name);
| ^^^^^^^^^ value borrowed here after move
error[E0382]: borrow of partially moved value: `user`
--> types/structs/src/bin/partial-move.rs:20:45
|
16 | let name = user.name; // the String moves out to `name`
| --------- value partially moved here
...
20 | println!("cannot use whole struct: {:?}", user);
| ^^^^ value borrowed here after partial move
执行 let name = user.name; 之后,user.name 变成虚线空位,再读会报 E0382;user.age 仍是 7,可以继续用。user 整体已经残缺,不能再当作完整的 User。堆上 6 字节的 Ferris 改由 name 负责释放;user 离开作用域时,只清理还留在里面的字段。
移出 user.name 之后的栈与堆左侧是栈帧 main。绑定 user: User 标成整体已残缺,不能再当作完整的 User 使用(E0382)。里面 user.name 是虚线空位,标明已移出;user.age 是实线槽,里面仍是 7: u32。结构体下方,name: String 持有 ptr、len 6 和 cap 6。一条标着移动所有权的实线箭头从 user.name 向下指向 name。右侧堆上只有一份缓冲区 Ferris,6 字节。实线箭头从 name 指向这块缓冲区,释放责任已经交给 name。user 离开作用域时,只清理还留在里面的字段,例如 age。let name = user.name;栈帧 mainuser: User整体已残缺 · 不可整体使用 E0382user.name已移出(禁止再读 E0382)user.age7: u32未移动(仍可正常访问)移动所有权name: Stringptr | len: 6 | cap: 6堆释放责任已移交给 nameFerris6 字节

这就是结构体与元组的部分移动(Partial Move)机制。当一个字段被移走后,编译器的状态追踪会精确到字段级别:

访问目标 是否允许 原因
已被移走的字段(user.name) 禁止 所有权已移交给 name,原字段槽位已失效。
结构体整体(user) 禁止 内部已经缺失了 name 字段,不能再把 user 当作完整的 User 整体传参或打印。
其余未移动的字段(user.age) 允许 .age 字段完好无损,仍可单独读写;当 user 离开作用域时,编译器也只会清理那些尚未被移走的剩余字段。

  1. 三种形态与扁平布局:struct 分为具名字段结构体、元组结构体和占 0 字节的单元结构体;字段直接扁平内联在内存中,没有对象头,可变性由 let mut 绑定统一控制;
  2. 默认移动,显式 Copy:自定义结构体默认一律走移动语义,需显式加上 #[derive(Clone, Copy)] 才会按位复制;
  3. 字段级部分移动:移出结构体中的单个非 Copy 字段后,该字段与结构体整体失效,但其余未移动的字段仍可独立访问。
  1. 对比机制:为什么数组 let names: [String; 2] 写 let s = names[0]; 会直接编译报错(E0508),而结构体写 let s = user.name; 却允许发生部分移动?
  2. 独立建模与诊断:假设你要定义一个表示「窗口尺寸」的结构体 Size(包含 width: u32 和 height: u32 两个字段),并希望在代码里执行 let s2 = s1; 后,s1 和 s2 都能继续使用;同时还要定义一个表示「网络请求」的结构体 Request(包含 url: String 和 timeout_ms: u32)。
    • 请写出 Size 和 Request 的 struct 定义(考虑哪个应该加 #[derive(Clone, Copy)]);
    • 如果有人写了 let req: Request = ...; let target = req.url; println!("{:?}", req);,编译器会报什么错?如果把最后一行改成 println!("{}", req.timeout_ms); 呢?
点击核对参考答案
  1. 数组下标 vs 结构体字段: 数组下标 arr[i] 允许使用运行时变量 i,编译器在编译期无法静态确定哪一个槽位被移空了,离开作用域时无法生成确定的释放代码,因此一律禁止按索引移出;而结构体字段 .name(以及元组下标 .0)在语法上只能是编译期固定的名字,编译器能精确记录每个字段的存活状态,在作用域结束时只清理未移出的字段。
  2. 建模与部分移动诊断:
    • 结构体定义:
      #[derive(Clone, Copy)]
      struct Size {
      width: u32,
      height: u32,
      }
      struct Request {
      url: String,
      timeout_ms: u32,
      }
      在 Rust Playground 中运行
      Size 只包含两个 u32(共 8 字节,无堆资源),加上 #[derive(Clone, Copy)] 后即可在 let s2 = s1; 时按位复制;而 Request 含有 String 字段,不能实现 Copy。
    • 执行 let target = req.url; 后,req.url 发生部分移动。此时再写 println!("{:?}", req); 试图使用结构体整体,编译器会报 error[E0382]: borrow of partially moved value: req;如果改为只打印未移动的 u32 字段 println!("{}", req.timeout_ms);,则完全合法,可以通过编译。

我们已经能用 struct 把数据字段打包成自定义类型了。下一节我们看怎样通过 impl 给类型挂上方法,并拆解方法接收者三件套(self / &self / &mut self)。

继续:方法与接收者三件套 →