方法与关联函数:impl 块

struct 只有数据,行为挂在哪?答案在 impl 块。impl 把函数绑定到类型上:以 self 为第一个参数的是方法,用 :: 调用的是关联函数(含构造器)。这篇讲清两类函数的区别、self 三种形式的取舍、以及签名自动转换的设计直觉。读完你能给任何 struct 配上一套顺手的方法。

一、方法与关联函数的分野

关联函数不接收 self,类似其他语言的静态方法或类方法;方法接收 self、&self 或 &mut self,通过点语法调用。最熟悉的关联函数是 String::from,最熟悉的方法是 String::len。

#[derive(Debug)]
struct Rect { w: f64, h: f64 }

impl Rect {
    // 关联函数:构造器,不接收 self
    fn new(w: f64, h: f64) -> Rect {
        Rect { w, h }
    }

    // 方法:只读借用 self
    fn area(&self) -> f64 {
        self.w * self.h
    }

    // 方法:可变借用 self
    fn scale(&mut self, f: f64) {
        self.w *= f;
        self.h *= f;
    }
}

调用方式也不同:关联函数 Rect::new(1.0, 2.0),方法 r.area()。点语法背后有自动引用机制:编译器根据方法签名自动补上 &、&mut 或解引用,因此写 r.area() 而非 (&r).area(),Rust 替你完成了这层繁琐。

这个自动补引用的规则还能穿透多层解引用:给 Box<Rect> 调 area() 也能直达,因为方法查找会沿解引用链逐层搜索。理解了这一点,包了一层智能指针的类型用起来就不再别扭,库设计者也能放心地把方法定义在原始类型上而非每个包装类型各写一份。

二、self 三种形式的取舍

self 形式决定方法对实例的权限,也决定了调用者之后还能不能用这个实例。选择标准很直接:能只读就只读,必要时才可变,需要消费所有权时才用 self。

形式 拥有的权限 调用后实例状态 典型用途
&self 只读 不变 查询、计算派生值
&mut self 读写 仍然可用 修改内部状态
self 完全所有权 已被消费 转换为别的类型

错误形态也很有教育意义:给只需要读的方法写成 &mut self,调用端持有多个引用时会报 cannot borrow as mutable,属于过度索权;反过来真正需要修改的方法写成 &self 则在方法体内报 cannot assign to。

impl Rect {
    // 消费 self 的 Builder 风格转换
    fn into_square(self) -> Rect {
        let side = (self.w + self.h) / 2.0;
        Rect { w: side, h: side }
    }
}

fn main() {
    let mut r = Rect::new(3.0, 4.0);
    println!("{}", r.area());
    r.scale(2.0);       // 借用,r 仍可用
    let s = r.into_square(); // 消费,r 之后不能再用
}

三、多个 impl 块与扩展

同一类型允许写任意多个 impl 块,这在给类型分门别类组织方法时很有用。更有威力的是泛型 impl:给特定条件下的类型加方法。标准库的 Option::unwrap 等大量方法都是这种条件实现。

impl<T: ToString> Vec<T> {
    fn join_str(&self, sep: &str) -> String {
        let mut out = String::new();
        for (i, item) in self.iter().enumerate() {
            if i > 0 { out.push_str(sep); }
            out.push_str(&item.to_string());
        }
        out
    }
}

这段代码给所有元素实现了 ToString 的 Vec 加了一个方法(演示语法,生产环境用标准库的 join)。trait 约束写在 impl 关键字之后、类型之前,编译器据此决定方法对哪些实例可见。

顺带厘清一个疑惑:多个 impl 块之间不能有同名方法,重复定义会直接编译失败,不像某些语言允许重载。要按类型分派同名行为得走 trait 的路线,那是后续章节的内容。另外泛型 impl 的约束可以比 struct 定义时更窄,这正是「给特定子集加方法」能力如此重要的原因:类型本身保持通用,方法按需附加。

四、关联常量与构造器约定

impl 块里还能定义关联常量与关联类型(后者主要用于 trait)。关联常量给类型提供命名字面量,调用写 Rect::UNIT:

impl Rect {
    const UNIT: Rect = Rect { w: 1.0, h: 1.0 };

    fn unit() -> Rect { Rect::UNIT }
}

命名约定上,构造器方法通常叫 new,但不强制;当有多个不同含义的构造途径时,起有意义的名字比挤在 new 里更好,比如 from_csv、with_defaults。标准库自身也这样:String::from、String::with_capacity 各表其意。

五、方法签名与错误处理

方法返回值的设计和普通函数一致:可能失败返回 Result,可能缺失返回 Option,需要可变状态时返回 &mut self 链式调用的习惯写法。impl 块不改变错误处理规则,只是让函数有了一个固定的接收者参数。

一个易踩的坑:方法内部的 ? 同样要求函数签名带 Result,impl 块不会自动给你兜底。另一个细节是 self 参数本身也参与生命周期标注,默认签名里 &self 的生命周期与返回的引用绑定,想在方法里返回 self 内部数据的引用时这层关系自动成立,无需手动写。

再补一条 Builder 模式的直觉:当 struct 字段多、其中多数有默认值、少数必须给定时,与其写一长串构造参数,不如写一组返回 self 的链式方法。每个 with_x 方法消费并返回 self,调用端读起来就是把配置一项项叠上去。这与表达式语义天然吻合,也是 Rust 里少见的「消费换简洁」被广泛接受的场景。

六、速查与心法

把本篇压缩成几条口诀:构造器用关联函数、行为用方法;只读 &self、修改 &mut self、变身用 self;多个 impl 块按主题分;一看到点语法就想到编译器在补引用与解引用。再附一张常见标准库方法的签名对照,读源码时可以快速定位其权限意图。

标准库方法 签名类别 权限解读
String::len &self 只读查询
String::push_str &mut self 原地修改
Vec::into_iter self 消费所有权
String::from 关联函数 不涉及实例
Option::unwrap self 消费并取值
HashMap::get &self 只读,返回 Option

想系统练习方法设计,可以仿造 Tauri 桌面开发指南 里的状态管理结构体,给同一个类型写读写与转换三类方法。写命令行工具时参数解析对象也是很好的练习场,把解析、校验、取值各拆成一个方法,代码立刻有了层次感。

延伸阅读

  • 下一篇「枚举与 Option」把可能性写进类型,与方法结合后能表达更丰富的领域逻辑。
  • trait 一章会展开方法的多态形态,本篇是它的前置知识,建议先写过十几个方法再进入。
  • 正则表达式精进 示例中的解析器结构体方法分层清晰,适合作为中量级方法组织的参照,重点观察它如何把解析步骤拆成一串只读方法。