trait:接口、约束、多态,一个词
如果说枚举是「一个值可以是几种情况之一」,trait 就是「几种类型可以共享同一套行为」。它像接口,但更强:能为你不拥有的类型实现、能当泛型的约束、能重载运算符、能被自动 derive。Rust 的抽象几乎全建在 trait 上 —— 吃透它,后面的路一马平川。
定义一套行为,让不同类型去实现
trait 声明「实现我的类型,得会做这些事」。然后你为各种类型分别实现它:
trait Summary {
fn summarize(&self) -> String; // 要求:得会「概括自己」
fn preview(&self) -> String { // 默认实现:不写就用这个
format!("{}...", &self.summarize()[..3])
}
}
struct Article { title: String, body: String }
struct Tweet { user: String, text: String }
impl Summary for Article {
fn summarize(&self) -> String { self.title.clone() }
}
impl Summary for Tweet {
fn summarize(&self) -> String { format!("@{}: {}", self.user, self.text) }
}
到这里,它看着就是个接口。但 trait 有几处「比接口多的东西」,正是它的价值所在。
比接口多的第一样:能为「别人的类型」实现
你可以为你没定义的类型实现你的 trait —— 包括标准库的 i32、String、Vec。这在 Java/Kotlin 里做不到(你没法给 Integer 加实现某个接口的方法):
impl Summary for i32 {
fn summarize(&self) -> String { format!("一个整数:{}", self) }
}
// 现在 42.summarize() 就能调了
这带来一种优雅的扩展能力 —— 你能给现成类型「加行为」,而不用继承、不用包装。(有一条限制叫「孤儿规则」:trait 和类型,至少有一个得是你自己定义的,防止两个库给同一类型加冲突的实现。)
比接口多的第二样:当泛型的约束
trait 最重要的用途,其实是约束泛型 —— 「我这个函数能接受任何类型,只要它会 summarize」:
fn notify<T: Summary>(item: &T) { // T 必须实现 Summary
println!("新内容:{}", item.summarize());
}
// 或等价的、更常见的写法:
fn notify(item: &impl Summary) { /* ... */ }
T: Summary 读作「T 受 Summary 约束」。这行约束干了两件事:一是放行 —— 任何实现了 Summary 的类型都能传进来;二是保证 —— 函数体里可以放心调用 item.summarize(),因为约束担保了它一定有。泛型 + trait 约束,是 Rust 复用代码的主力方式,下一章细讲它的性能。
两种多态:静态分发 vs 动态分发
「对不同类型调用同一个方法名」这件事(多态),Rust 给了你两条路,而且让你自己选成本 —— 这是它和有 GC 的语言很不一样的地方。
静态分发 impl Trait / 泛型 | 动态分发 dyn Trait | |
|---|---|---|
| 怎么实现 | 编译期为每个具体类型生成一份代码(单态化) | 运行时查虚表(vtable)找方法 |
| 调用成本 | 零:直接调用,还能内联 | 一次指针跳转(和 Java 接口调用一样) |
| 能不能装一堆不同类型 | 不能(每个 T 是固定的一种) | 能:Vec<Box<dyn Summary>> 混装 |
| 代价 | 代码膨胀、编译变慢 | 有运行时开销、丢了内联 |
// 静态:编译期定死是哪种类型,零成本
fn notify(item: &impl Summary) { /* ... */ }
// 动态:一个列表里混装不同类型,运行时决定调谁
let feed: Vec<Box<dyn Summary>> = vec![
Box::new(Article { /* ... */ }),
Box::new(Tweet { /* ... */ }),
];
for item in &feed {
println!("{}", item.summarize()); // 运行时查虚表
}
什么时候用哪个?默认用静态(泛型 / impl Trait),要零成本;当你需要把不同类型放进同一个集合、或返回类型在运行时才定,才用 dyn。关键是:这个「要不要付运行时成本」的决定,Rust 让你看得见、自己拍板,而不是像 Java 那样默认所有接口调用都走虚表。
你天天在用的那些 trait
标准库里一堆核心 trait,你几乎每天都碰,认识它们能省很多事:
| trait | 给你什么能力 |
|---|---|
Debug / Display | 能被 {:?} / {} 打印 |
Clone | 能 .clone() 深拷贝 |
PartialEq / Eq | 能用 == 比较 |
PartialOrd / Ord | 能比大小、能排序 |
Iterator | 能被 for 遍历、能用 .map().filter() 那套 |
From / Into | 类型间转换(上一章 ? 自动转错误就靠它) |
最爽的是:这些常见 trait,大多能一行 derive 自动实现,不用你手写:
#[derive(Debug, Clone, PartialEq)] // 编译器替你把这三个都实现了
struct Point { x: i32, y: i32 }
let a = Point { x: 1, y: 2 };
let b = a.clone(); // Clone 有了
println!("{:?}", b); // Debug 有了 → Point { x: 1, y: 2 }
assert!(a == b); // PartialEq 有了
还有一个隐藏福利:运算符也是 trait。a + b 其实是调 Add trait 的方法 —— 所以你想让自己的类型支持 +,实现 Add 就行。运算符重载在 Rust 里不是特殊语法,就是「实现某个约定好的 trait」。
回流
Java / Kotlin 的 interface:最接近,但差三点 —— 你没法为已有类型(如 String)追加实现接口;接口调用永远是动态分发(走虚表),没有零成本的静态版本;也没有 derive 这种自动实现。Kotlin 的扩展函数能给现有类型「加方法」,方向对了,但扩展函数不是多态的(静态解析、不能当约束)。Rust 的 trait 把「接口 + 扩展函数 + 泛型约束」合成了一个概念。
Haskell 的 typeclass:trait 的真正祖先。「为类型定义一组行为、能后加、能当约束、编译期解析」这套,Haskell 的 typeclass 早就有了。如果你好奇这个思想的源头(以及 From/Into、Iterator 这些设计从哪来),它在那条河的上游。
Go 的 interface:Go 接口是「隐式实现 + 永远动态分发」。Rust 是「显式 impl + 你选静态还是动态」。Go 的鸭子类型更松,Rust 的更显式、也更快(默认静态)。
这一章的一句话
trait 定义共享行为,一个词兼任接口、泛型约束、运算符重载、扩展方法;它能为别人的类型实现、能自动 derive,还让你在「零成本的静态分发」和「灵活的动态分发」之间自己选成本。
上面反复提到,泛型 + trait 约束会「为每个类型生成一份代码」,还说这叫「零成本」。这是 Rust 性能的关键一环,下一章我们把这台「代码印刷机」拆开看看 —— 一个泛型函数,到底被印成了几份。