← Rust:从入门到精通
全栈开发Rust:从入门到精通

Trait:共享行为的合同,而不是家族树

Rust 从入门到精通系列第 5 篇:trait 是什么、孤儿规则为什么保护你、没有继承的世界如何用组合组织代码、derive 一行能买到什么,以及 Debug、Display、From、Clone、Drop、Iterator 这六个你每天都会打交道的标准 trait。

RustTraitsCompositionStandard LibraryIdioms

每个从面向对象语言过来的人,硬盘里都躺着一棵想烧掉的继承树。Person 派生 EmployeeEmployee 派生 Manager——然后有一天你想给基类加个字段,发现它牵动了四十个派生类的构造函数;或者你需要一个“既会序列化又会记日志”的东西,开始纠结它该是谁的孩子。继承逼你把“是什么”和“能做什么”焊死在一棵单根的树上,而真实世界的能力从来不按树形分布。

第 4 篇回答了“数据是什么”;本篇回答“数据能做什么”。Rust 的答案是 trait:一份行为的合同,任何类型都可以签署任意多份,彼此独立,没有父亲,没有钻石问题,没有会被你无意中改坏的基类。从这一篇起,你写的代码会开始“看起来像 Rust”——而这件事的百分之八十,就是 trait。

Trait 是一份合同,不是一棵树

语法朴素得近乎无聊:

trait Summary {
    fn summarize(&self) -> String;           // 必须实现
    fn preview(&self) -> String {            // 默认实现,可覆盖
        format!("{}…", &self.summarize()[..50])
    }
}

impl Summary for Report {
    fn summarize(&self) -> String {
        format!("{} ({} words)", self.title, self.word_count)
    }
}

类型 Report 签署合同 Summary:提供 summarize,免费获得 preview 的默认实现。注意这个动作发生在 impl 块里,而不是类型定义里——行为是后贴的,不是娘胎里带的。这一个区别引出了 trait 最重要的性质:你可以给别人的类型实现你的 trait。给标准库的 Vec<T> 实现你的 Summary?合法。反过来,给你的 Report 实现别人的 trait?也合法。

唯一不能做的是给别人的类型实现别人的 trait——这就是孤儿规则(orphan rule):impl 语句里,trait 和类型至少有一个必须是你 crate 的。这条规则初看像限制,实际是保护:没有它,两个依赖各自给 Vec<String> 实现同一个 trait,你的构建结果将取决于链接顺序——那种 bug 没人想在生产里见。需要绕过时,惯用解法是 newtype 模式:用 struct MyVec(Vec<String>) 包一层,类型就是你的了,规则就满足了。

没有继承的世界,用组合组织代码

继承树真正的病不在“深”,在于它强迫每个类型只能选一个父亲,于是“能做什么”被伪装成“是什么”。Trait 把这个伪装撕掉:能力就是能力,一个类型想要几份签几份。

对照着看两种心智模型的差异:

  • 继承问:“我是谁的孩子?”——答案唯一,改上层动全身,菱形继承让语言设计者头疼了几十年。
  • Trait 问:“我签了哪些合同?”——答案是一份清单,各合同互不知道彼此存在,想加能力就加一个 impl 块。

泛型函数消费这份清单的方式同样直接。fn show(x: &impl Summary) 读作“任何签署了 Summary 的东西”,函数不关心它什么,只关心它什么。这就是为什么 Rust 没有继承却从不想念它:继承试图用一棵树同时解决“复用实现”和“表达接口”两个问题,结果两个都解决得很别扭;trait 只管接口,复用交给组合(把共享逻辑放进被多个类型持有的普通字段和普通函数里),两个问题各自干净。

还有一行代码能买到的免费午餐——#[derive(...)]

#[derive(Debug, Clone, PartialEq)]
struct Report { title: String, word_count: usize }

编译器按字段机械地为你生成实现。DebugClonePartialEqEqHashDefault——这些“没有惊喜、只有体力劳动”的 trait,标准做法就是 derive 出来,把手写留给真正需要判断力的那些。

每天打交道的六个标准 trait

标准库里最常用的 trait 一只手数得过来,而它们共同构成“惯用 Rust”的底层语法:

  • Debug vs Display——两副面孔的“转成字符串”。 Debug 给程序员看({:?},日志和断言里),原则上一律 derive;Display 给用户看({}),必须手写——什么算“好看的展示”是判断题,机器替你答不了。实现了 DisplayToString 自动到账,这就是 trait 体系的复利:签一份合同,解锁一串关联能力。
  • From / Into——有名字的转换。 第 4 篇的 ? 靠它完成错误转换。约定是实现 From,免费获得 Into——String: From<&str> 一签,"hi".into() 就能用。From 承诺转换绝不失败;会失败的转换走 TryFrom
  • Clone——显式的复制。 Rust 里复制永远写出来:没有隐式拷贝构造函数在暗处堆分配。Clone.clone() 成为一句诚实的“这里要付一份所有权的钱”。(Copy 是它的特例:仅用于按位复制就够的小类型,如数字——复制隐式发生,因为零成本。)
  • Drop——确定性的清理。 值离开作用域时自动运行:文件关闭、锁释放、连接归还。这是 RAII 在 Rust 的样子,也是第 2 篇“所有者离开,值被清理”那句话的扩展点。你很少手写它,但理解它解释了为什么 Rust 的资源管理不需要 finally。
  • Iterator——一个方法解锁整个流水线。 这是六个里最赚的合同,值得单独一节。

Iterator:签一个 next(),得到整个生态系统

Iterator trait 的全部义务只有一个方法:fn next(&mut self) -> Option<Self::Item>——要么给出下一个元素,要么说 None。而标准库在它上面建了七十多个适配器。这就是 trait 设计力量的最佳展品:合同极小,复利极大

let squares: Vec<u32> = (1..=100)
    .filter(|n| n % 2 == 0)
    .map(|n| n * n)
    .take(3)
    .collect();          // [4, 16, 36]

两个性质让它不止是语法糖:

  1. 惰性。 filtermaptake 这些适配器什么都不做——不分配、不迭代,只搭出一张计划。直到 collect(或 sumfor 循环)这样的消费者开始拉动,值才一个一个流过整条管道。没有中间集合,没有浪费的遍历。
  2. 零成本。 整条链编译后就是你手写循环会得到的那段汇编——这是“zero-cost abstraction”承诺最直白的兑现。写更声明式的代码,不付运行时的钱。

而合同是对称的:给你自己的类型实现 next(),你的类型立刻接入这七十多个适配器——mapfilterzipfoldcollect 全部免费。在继承的世界里,这意味着你要继承某个巨型的 AbstractCollection;在 trait 的世界里,你只签了一行义务。

练习,以及第 6 篇

  1. 给第 4 篇的 Connection 实现 DisplayConnected 打印会话标识,Failed 打印原因。然后 derive Debug,分别用 {}{:?} 打印同一个值——体会“给用户的”和“给你的”两副面孔各自该长什么样。
  2. trait Area { fn area(&self) -> f64; },为 CircleRect 实现它,再写一个 fn total_area(shapes: &[impl Area]) -> f64。然后把 impl Area 换成第 7 篇会细讲的完整泛型写法 <T: Area>——确认两者编译后行为一致,把这个等价记进肌肉记忆。
  3. 不用 for 循环,只用迭代器适配器解决:给定一个日志行向量,统计其中包含 "ERROR" 的行的字节总数。然后尝试给“每三行一组”的需求写代码——提示:chunks 不是 Iterator 的适配器,而是切片的,去文档里找到它,这是学会“答案住在文档里”的一天。

第 6 篇回到所有权,但这次带着逃生舱:BoxRcRefCell——智能指针如何把借用检查从编译期挪到运行期、这笔交易什么时候值得做,以及“单一所有权”规则真正的弹性边界在哪里。学完它,你将知道借用检查器的每一条“不”背后,都留了一扇明码标价的门。

guest@swangnice:~$