每个从面向对象语言过来的人,硬盘里都躺着一棵想烧掉的继承树。Person 派生 Employee,Employee 派生 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 }
编译器按字段机械地为你生成实现。Debug、Clone、PartialEq、Eq、Hash、Default——这些“没有惊喜、只有体力劳动”的 trait,标准做法就是 derive 出来,把手写留给真正需要判断力的那些。
每天打交道的六个标准 trait
标准库里最常用的 trait 一只手数得过来,而它们共同构成“惯用 Rust”的底层语法:
DebugvsDisplay——两副面孔的“转成字符串”。Debug给程序员看({:?},日志和断言里),原则上一律 derive;Display给用户看({}),必须手写——什么算“好看的展示”是判断题,机器替你答不了。实现了Display,ToString自动到账,这就是 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]
两个性质让它不止是语法糖:
- 惰性。
filter、map、take这些适配器什么都不做——不分配、不迭代,只搭出一张计划。直到collect(或sum、for循环)这样的消费者开始拉动,值才一个一个流过整条管道。没有中间集合,没有浪费的遍历。 - 零成本。 整条链编译后就是你手写循环会得到的那段汇编——这是“zero-cost abstraction”承诺最直白的兑现。写更声明式的代码,不付运行时的钱。
而合同是对称的:给你自己的类型实现 next(),你的类型立刻接入这七十多个适配器——map、filter、zip、fold、collect 全部免费。在继承的世界里,这意味着你要继承某个巨型的 AbstractCollection;在 trait 的世界里,你只签了一行义务。
练习,以及第 6 篇
- 给第 4 篇的
Connection实现Display:Connected打印会话标识,Failed打印原因。然后 deriveDebug,分别用{}和{:?}打印同一个值——体会“给用户的”和“给你的”两副面孔各自该长什么样。 - 写
trait Area { fn area(&self) -> f64; },为Circle和Rect实现它,再写一个fn total_area(shapes: &[impl Area]) -> f64。然后把impl Area换成第 7 篇会细讲的完整泛型写法<T: Area>——确认两者编译后行为一致,把这个等价记进肌肉记忆。 - 不用 for 循环,只用迭代器适配器解决:给定一个日志行向量,统计其中包含
"ERROR"的行的字节总数。然后尝试给“每三行一组”的需求写代码——提示:chunks不是Iterator的适配器,而是切片的,去文档里找到它,这是学会“答案住在文档里”的一天。
第 6 篇回到所有权,但这次带着逃生舱:Box、Rc、RefCell——智能指针如何把借用检查从编译期挪到运行期、这笔交易什么时候值得做,以及“单一所有权”规则真正的弹性边界在哪里。学完它,你将知道借用检查器的每一条“不”背后,都留了一扇明码标价的门。