Skip to content

结构体与方法

本章是「用 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 TraitT: 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

要点:

  1. ..base 不是「浅拷贝剩余字段」,而是「把 base 里还没被显式指定的字段逐个搬过来」。
  2. String / Vec<T> 这种非 Copy 字段就是 move,之后 base.note 不可再用。
  3. base 整体也不可再用(已被部分移动),但仍然可用的字段(如 Copybase.id)可以继续读。
  4. 如果希望 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)字段调用后调用者还能用吗典型场景
selfSelf不能(已 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
}

🧠 原理&selfself: &Self 的语法糖,&mut selfself: &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(因为 u64Copy): #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] struct UserId(u64); 这里刻意保留这个错误,提醒你:Clone 不会自动让你「赋值后原值还能用」,那需要 Copy

derive生成什么何时该加何时不能
Debugimpl fmt::Debug,支持 {:?} / {:#?}几乎总是加;调试、测试、assert_eq! 都需要所有字段类型都实现了 Debug 才能 derive
Cloneimpl Clone.clone() 逐字段深拷贝需要「复制一份继续用」时有字段类型不实现 Clone;有独特资源语义时手写
Copyimpl Copy(并要求同时有 Clone小的、纯数据的类型(坐标、ID、颜色码)字段含 String/Vec/Box/任何 Drop 类型;类型自身实现了 Drop(二者互斥)
PartialEqimpl PartialEq== / !=需要比较、测试断言、放进 HashSet有字段类型不实现 PartialEq
Eqimpl Eq(标记 trait,无方法)需要自反/传递的严格相等:用作 HashMap 键、放进 BTreeMap字段含 f32/f64(NaN ≠ NaN);含不实现 Eq 的类型
Hashimpl Hash类型要作 HashMap / HashSet 的键字段不实现 HashEqHash 必须一致,不一致会「查不到键」
Defaultimpl Default::default(),各字段取各自默认值配置结构体、..Default::default()枚举不能 derive Default(编译器不知道选哪个变体)

CopyDrop 互斥

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 satisfied

f64 只实现 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] 属性标注某个单元变体,就可以 derive Default#[derive(Default)] enum Level { Low, #[default] Mid, High }。最低稳定版本 1.62。



DebugDisplay

三种打印格式

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)
}

输出:

text
Trip { 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()
}

三个必须记住的细节:

  1. use std::fmt; 后写 impl fmt::Display for T(或 impl std::fmt::Display for T)。
  2. 函数签名固定为 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::ResultFormatter<'_> 的生命周期用 '_ 让编译器推断,不要写具体名字。
  3. 结尾必须是 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 impl impl<T: Display + ?Sized> ToString for T)。所以不要自己再写 to_string,会和标准库冲突。



延伸阅读

内容以 rustc 1.98.1 · Rust 2024 edition 为基准