结构体与方法
本章是「用 Rust 建模」的起点:把现实世界的名词变成
struct,把「只能是其中一种情况」变成enum, 把行为挂在数据上用impl,把「具备某种能力」抽象成trait。 前置知识:所有权与借用 (move / 借用必须已经理解,本章到处在用)、 变量与流程控制 (标量、复合类型、match的语法基础)。
本章目标
- 能写出具名字段结构体、元组结构体、单元结构体,并说清
..base更新语法为什么会 move 字段。 - 能根据「方法要不要拿走所有权」在
self/&self/&mut self/self: Box<Self>之间做出选择。 - 能解释
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]各自生成了什么,以及为什么Copy不能和Drop共存。 - 能用带数据的
enum替代「一堆bool标志位」和「字符串状态机」,并用match让编译器帮你查漏。 - 能定义 trait、写默认方法、用
impl Trait与T: Trait,并知道两者何时该选哪个。 - 能区分关联类型与泛型参数、静态分发与动态分发,并说清
dyn Trait为什么必须放在指针后面。 - 能看懂并自己修掉
E0599/E0117/E0277/E0046/E0038这几类高频错误。
结构体的三种形态
Rust 的 struct(结构体)只有一种语义:把若干值打包成一个值。但语法上有三种形态, 分别解决「字段要不要名字」「要不要只是给已有类型起个别名」「要不要有数据」三个问题。
具名字段结构体(named-field struct)
rust
#[derive(Debug)]
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
fn main() {
// 字段顺序可以与定义顺序不同,Rust 不依赖顺序
let user = User {
active: true,
username: String::from("ana"),
email: String::from("ana@example.com"),
sign_in_count: 1,
};
println!("{} {}", user.username, user.active);
// 可变性属于「绑定」而不是「字段」:整个结构体要么全可变,要么全不可变
let mut user2 = User { active: false, ..user };
user2.sign_in_count += 1;
println!("{user2:?}");
}🧠 原理:Rust 没有「某个字段可变、另一个字段不可变」的写法。
let mut user2让整个结构体 包括所有字段都可变;let user则连user.sign_in_count += 1都不允许。 这是为了让借用检查器只需跟踪「一个值」的可变借用,而不必跟踪到字段粒度。
字段初始化简写(field init shorthand)
当变量名和字段名相同时,可以省略 字段名::
rust
#[derive(Debug)]
struct User { username: String, active: bool }
fn build(username: String, active: bool) -> User {
User { username, active } // 等价于 User { username: username, active: active }
}
fn main() {
println!("{:?}", build(String::from("bo"), true));
}💡 对照:JavaScript 的
{ username, active }、Python 的User(username=username, active=active)里只有 JS 有这个简写。Rust 借用了 JS 的写法,但要求类型完全匹配,不会做隐式转换。
元组结构体(tuple struct)与单元结构体(unit struct)
rust
#[derive(Debug, PartialEq)]
struct Meters(f64); // 元组结构体:有类型名,字段没有名字,用 .0 .1 访问
#[derive(Debug, PartialEq)]
struct Point3(f64, f64, f64);
#[derive(Debug)]
struct Marker; // 单元结构体:不携带任何数据,只作为类型存在
fn main() {
let d = Meters(12.5);
println!("{}", d.0); // 访问靠位置下标
println!("{:?} {:?}", Point3(1.0, 2.0, 3.0), Marker);
// 元组结构体的最大价值:给同一底层类型加上不同的“含义”,防止参数写反
let a = Meters(1.0);
let b = Meters(2.0);
assert!(a != b);
}🧠 原理:
struct Meters(f64)与struct Seconds(f64)在类型系统里是两个完全不同的类型, 不能互相赋值,也不能相加。这叫 newtype(新类型)模式,是零成本的:编译后就是裸f64。
🚀 进阶:单元结构体常用来实现 trait 而不需要携带状态,例如手写一个「总是返回某个常量」的类型, 或者作为标记类型(marker type)参与泛型。它的大小是 0 字节。
结构体整体 move、..base 更新语法与部分移动
结构体默认是「整体 move」的
Rust 的结构体没有默认拷贝语义。赋值、传参、放进 Vec,默认都是移动(move):
rust
#[derive(Debug)]
struct Config { name: String, retries: u32 }
fn main() {
let a = Config { name: String::from("svc"), retries: 3 };
let b = a; // a 被整体 move 到 b
println!("{b:?}");
// println!("{a:?}"); // 取消注释会报 E0382: borrow of moved value: `a`
}💡 对照:Java / Python / C# 里
let b = a只是复制引用,两个名字指向同一对象; C++ 里默认复制整个对象(值语义,可能很贵)。Rust 选了第三条路:默认转移所有权, 让「谁负责释放」永远只有一个答案。想复制内容就显式.clone()。
结构体更新语法 ..base
rust
#[derive(Debug)]
struct Order {
id: u64,
note: String,
qty: u32,
}
fn main() {
let base = Order { id: 1, note: String::from("first"), qty: 2 };
// ..base 表示“其余字段从 base 里取”,注意 note 是 String,会被 move
let order2 = Order { qty: 5, ..base };
println!("{order2:?}");
println!("{}", base.id); // OK:u64 是 Copy,..base 只是复制了它
// println!("{}", base.note); // 错误:base.note 已经被 move 走了
}实际编译器给出的错误(节选):
text
error[E0382]: borrow of moved value: `base.note`
--> src/main.rs:9:22
|
6 | let order2 = Order { qty: 5, ..base };
| ------------------------ value moved here
...
9 | println!("{:?}", base.note);
| ^^^^^^^^^ value borrowed here after move
|
= note: move occurs because `base.note` has type `String`,
which does not implement the `Copy` trait要点:
..base不是「浅拷贝剩余字段」,而是「把base里还没被显式指定的字段逐个搬过来」。- 搬
String/Vec<T>这种非Copy字段就是 move,之后base.note不可再用。 base整体也不可再用(已被部分移动),但仍然可用的字段(如Copy的base.id)可以继续读。- 如果希望
base之后还能用,把非Copy字段显式写出来并.clone():
rust
#[derive(Debug, Clone)]
struct Order { id: u64, note: String, qty: u32 }
fn main() {
let base = Order { id: 1, note: String::from("first"), qty: 2 };
let order2 = Order { qty: 5, note: base.note.clone(), ..base };
println!("{order2:?} {base:?}"); // 两者都可用
}⚠️ 陷阱:
..base只能出现在最后,且不能和#[derive(Copy)]混用出「需要一部分 move、 一部分 Copy」的矛盾情况。若结构体实现了Copy,..base就变成纯复制,base依然可用。
🧠 原理:部分移动(partial move)之后,编译器把
base标记为「部分有效」。你能读没被移动的字段、 能实现Drop的结构体则完全不能部分移动(因为析构函数需要拿到完整的值)。
impl 块与方法
四种 self 的语义与选择指南
方法是第一个参数叫 self 的关联函数。选哪种 self,就是在选「这个操作对所有权做了什么」:
| 写法 | 类型 | 能否读字段 | 能否改字段 | 能否消耗(move)字段 | 调用后调用者还能用吗 | 典型场景 |
|---|---|---|---|---|---|---|
self | Self | 能 | 能 | 能 | 不能(已 move) | 转换、消耗式构建器、into_* |
&self | &Self | 能 | 不能 | 不能 | 能 | 查询、计算、len()、is_empty() |
&mut self | &mut Self | 能 | 能 | 不能(可换出) | 能(需可变借用) | 修改状态、push()、update() |
self: Box<Self> | Box<Self> | 能 | 能 | 能 | 不能(Box 被消耗) | 消费堆上对象并换类型,如 Box<dyn Error> 转换 |
self: Rc<Self> / Arc<Self> | 智能指针 | 能 | 视内部可变性 | 能(若引用计数归 1) | 视情况 | 共享所有权下的消耗式操作 |
rust
#[derive(Debug, Clone)]
struct Cart { items: Vec<String> }
impl Cart {
// 只读:不改变调用者,可以被反复调用
fn total_items(&self) -> usize { self.items.len() }
// 可变借用:改状态,但不吃掉调用者
fn add(&mut self, item: &str) { self.items.push(item.to_string()); }
// 拿走所有权:把 Cart 变成 Vec<String>,之后原 Cart 不能再用
fn into_items(self) -> Vec<String> { self.items }
}
fn main() {
let mut c = Cart { items: vec![] };
c.add("apple");
c.add("pear");
println!("{}", c.total_items());
let items = c.into_items(); // c 在此被 move
println!("{items:?}");
// println!("{}", c.total_items()); // 错误:c 已被 move
}🧠 原理:
&self是self: &Self的语法糖,&mut self是self: &mut Self。 方法调用时 Rust 会自动加&/&mut/*(autoref / autoderef), 所以你写c.total_items()而不是(&c).total_items()。
⚠️ 陷阱:在
&mut self方法里想拿出字段又不想让结构体失效,用std::mem::take/std::mem::replace(详见 集合与迭代器):let old = std::mem::take(&mut self.items);它会留下Vec::new()。
为什么 Rust 没有构造函数语法
Rust 没有 new 关键字,也没有和类同名的构造函数。约定俗成用关联函数(associated function, 即没有 self 参数的 fn)来造值,名字通常叫 new:
rust
#[derive(Debug)]
pub struct Temperature { celsius: f64 }
impl Temperature {
// 关联函数:用 Self:: 作为返回类型,改名也不怕
pub fn new(celsius: f64) -> Self {
Self { celsius }
}
// 可以有多个“构造器”,名字各自表意
pub fn freezing() -> Self { Self { celsius: 0.0 } }
// 失败可能时返回 Result,而不是 panic
pub fn from_kelvin(k: f64) -> Result<Self, String> {
if k < 0.0 { return Err(format!("开尔文温度不能为负: {k}")); }
Ok(Self { celsius: k - 273.15 })
}
pub fn celsius(&self) -> f64 { self.celsius }
}
fn main() {
println!("{}", Temperature::new(25.0).celsius());
println!("{}", Temperature::freezing().celsius());
println!("{:?}", Temperature::from_kelvin(-1.0));
}💡 对照:Python 的
__init__、Java 的构造器都是「语言级特殊语法」,且必然返回新对象; Rust 的new只是普通函数。好处是:可以返回Result/Option、可以返回单例、可以返回Box<Self>,甚至可以在失败时返回一个代理类型(typestate 模式),表达能力更强。
多个 impl 块与方法/字段同名
rust
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
struct Point { x: i32, y: i32 }
// 第一个 impl 块:基础操作
impl Point {
fn new(x: i32, y: i32) -> Self { Self { x, y } }
fn x(&self) -> i32 { self.x } // 方法名与字段名相同是合法的
fn manhattan(&self) -> i32 { self.x.abs() + self.y.abs() }
}
// 第二个 impl 块:另一种“逻辑分组”,编译器视作同一个类型的方法集合
impl Point {
fn origin() -> Self { Self::new(0, 0) }
}
// 第三个 impl 块:为其他 trait 实现(本章「枚举 + `match` 实战」详述)
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
fn main() {
let p = Point::new(3, -4);
println!("{} {:?}", p, p.x()); // p.x 取字段,p.x() 调方法,靠括号区分
println!("{}", Point::origin());
println!("{:?}", Point::default());
}🧠 原理:
p.x是字段访问,p.x()是方法调用 —— 二者在语法上完全不同,不会冲突。 多个impl块完全等价于把它们拼在一起;分块只是为了可读性,以及给每个块加不同的#[cfg(...)]/ 泛型参数。
⚠️ 陷阱:固有方法(inherent method)优先级高于 trait 方法。如果类型自己有一个
len(),同时它实现的某个 trait 也有len(),那么v.len()一定调用固有方法, trait 方法只能写成Trait::len(&v)。
#[derive(...)]:编译器替你写代码
#[derive] 是过程宏,在编译期为结构体/枚举生成 trait 实现。它只能做「机械的、逐字段的」工作。
rust
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] // 故意漏掉 Copy
struct UserId(u64);
fn main() {
let a = UserId(7);
let b = a; // Clone 不等于 Copy:这里是 move
println!("{:?}", a); // E0382: borrow of moved value: `a`
println!("{b:?}");
println!("{}", a == b);
println!("{:?}", UserId::default()); // Default -> UserId(0)
}⚠️ 修正:上面这段代码不能编译(
E0382),let b = a;之后a已被 move。 正确的是加上Copy(因为u64是Copy):#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] struct UserId(u64);这里刻意保留这个错误,提醒你:Clone不会自动让你「赋值后原值还能用」,那需要Copy。
| derive | 生成什么 | 何时该加 | 何时不能加 |
|---|---|---|---|
Debug | impl fmt::Debug,支持 {:?} / {:#?} | 几乎总是加;调试、测试、assert_eq! 都需要 | 所有字段类型都实现了 Debug 才能 derive |
Clone | impl Clone,.clone() 逐字段深拷贝 | 需要「复制一份继续用」时 | 有字段类型不实现 Clone;有独特资源语义时手写 |
Copy | impl Copy(并要求同时有 Clone) | 小的、纯数据的类型(坐标、ID、颜色码) | 字段含 String/Vec/Box/任何 Drop 类型;类型自身实现了 Drop(二者互斥) |
PartialEq | impl PartialEq,== / != | 需要比较、测试断言、放进 HashSet | 有字段类型不实现 PartialEq |
Eq | impl Eq(标记 trait,无方法) | 需要自反/传递的严格相等:用作 HashMap 键、放进 BTreeMap | 字段含 f32/f64(NaN ≠ NaN);含不实现 Eq 的类型 |
Hash | impl Hash | 类型要作 HashMap / HashSet 的键 | 字段不实现 Hash;Eq 与 Hash 必须一致,不一致会「查不到键」 |
Default | impl Default::default(),各字段取各自默认值 | 配置结构体、..Default::default() | 枚举不能 derive Default(编译器不知道选哪个变体) |
Copy 与 Drop 互斥
rust
struct Guard(String); // 假设要自己管理资源
impl Drop for Guard {
fn drop(&mut self) { println!("释放 {}", self.0); }
}
// #[derive(Copy, Clone)] // 取消注释会报 E0184: the trait `Copy` cannot be
// implemented for a type that also implements `Drop`原因很直接:Copy 意味着「赋值后得到两个独立可用的副本」,那析构函数就会跑两次, 同一个资源被释放两次。编译器在类型层面禁止这种可能性,这是 Rust 不用 GC 也能内存安全的核心手段之一。
Eq 与浮点字段
rust
#[derive(PartialEq)]
struct Vec2 { x: f64, y: f64 }
// #[derive(Eq)] // 错误:the trait bound `f64: Eq` is not satisfiedf64 只实现 PartialEq(因为 NaN != NaN,相等关系不满足自反性),所以含浮点字段的类型 最多只能 PartialEq。要拿它当 HashMap 键,得先用整数或定点数表示,或者包一层自定义 Eq (此时必须自己保证「没有 NaN」这个不变量)。
Default 与枚举
rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Level { Low, Mid, High }
// 枚举不能 #[derive(Default)],必须手写 impl 指定哪个变体是默认值
impl Default for Level {
fn default() -> Self { Level::Mid }
}
#[derive(Debug, Default)]
struct Config { level: Level, verbose: bool, name: String }
fn main() {
println!("{:?}", Config::default());
// 输出:Config { level: Mid, verbose: false, name: "" }
}🚀 进阶:Rust 1.62 起,若枚举带
#[default]属性标注某个单元变体,就可以 deriveDefault:#[derive(Default)] enum Level { Low, #[default] Mid, High }。最低稳定版本 1.62。
Debug 与 Display
三种打印格式
rust
#[derive(Debug)]
struct Meters(u32);
#[derive(Debug)]
struct Trip { from: String, to: String, dist: Meters }
fn main() {
let t = Trip { from: "北京".into(), to: "上海".into(), dist: Meters(1200) };
println!("{:?}", t); // 单行紧凑
println!("{:#?}", t); // 多行缩进(# = pretty)
}输出:
textTrip { from: "北京", to: "上海", dist: Meters(1200) } Trip { from: "北京", to: "上海", dist: Meters( 1200, ), }
| 格式 | 依赖的 trait | 谁实现 | 用途 |
|---|---|---|---|
{:?} | Debug | 可 derive | 调试输出、assert_eq!、日志 |
{:#?} | Debug | 可 derive | 多行美化调试,看嵌套结构 |
{} | Display | 不可 derive,必须手写 | 面向用户的输出、to_string() |
{:>8} {:.2} {:x} 等 | Display(格式说明符基于它) | 手写 | 对齐、精度、进制 |
为什么 Display 不能 derive
Debug 的输出格式是约定的、机械的(类型名 { 字段: 值 }),编译器能猜。 Display 的输出是给人看的:Meters(1200) 应该显示为 1200 m 还是 1.2 km? 编译器无从得知,所以必须由你决定。
rust
use std::fmt;
struct Meters(f64);
impl fmt::Display for Meters {
// 参数是 &mut Formatter(带格式说明符的“输出目标”),返回 fmt::Result
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
v if v >= 1000.0 => write!(f, "{:.2} km", v / 1000.0),
v => write!(f, "{v:.0} m"),
}
}
}
fn main() {
println!("{}", Meters(320.0)); // 320 m
println!("{}", Meters(1500.0)); // 1.50 km
println!("{}", Meters(1500.0).to_string()); // 1.50 km,Display 免费送 to_string()
}三个必须记住的细节:
use std::fmt;后写impl fmt::Display for T(或impl std::fmt::Display for T)。- 函数签名固定为
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result,Formatter<'_>的生命周期用'_让编译器推断,不要写具体名字。 - 结尾必须是
Ok(())或write!(...)的返回值。write!返回的正是fmt::Result, 所以「以write!结尾不加分号」是最常见的写法;如果中间有多个write!,则每个都要加?:
rust
use std::fmt;
struct Pair { left: i32, right: i32 }
impl fmt::Display for Pair {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[")?; // ? 在这里返回的是 Result,不是 panic
write!(f, "{}, {}", self.left, self.right)?;
write!(f, "]")
}
}
fn main() { println!("{}", Pair { left: 1, right: 2 }); } // [1, 2]⚠️ 陷阱:
Display一旦实现,标准库就自动提供.to_string()(因为有个 blanket implimpl<T: Display + ?Sized> ToString for T)。所以不要自己再写to_string,会和标准库冲突。
延伸阅读
- 同一概念的第二种讲法(官方书中文版、Rust 圣经的逐章映射),见 附录 E · 对照阅读与组合学习法。
- 官方文档、中文资料、书单与工具的完整索引,见 附录 D · 学习资源与文档索引。