Rust中的Trait:定义共享行为
1. 概述
在Rust中,trait是一种描述多种数据类型共有行为的方式。它类似于Java中的接口,但具有额外的功能:默认实现、泛型的trait约束,以及在静态分发和动态分发之间的选择。
读完本文后,你将学会如何定义trait、为struct实现它、将trait用作参数和返回值、使用dyn Trait创建异构对象列表。你还会理解为什么编译器有时会拒绝你的代码。
所有示例都能在Rust稳定版(2021版本及以后)上运行,不需要外部库。
2. 定义和实现一个trait
假设一个销售系统有两种类型Order和Customer。我们希望两者都能够将自己总结成一个简短的字符串。
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct Order {
pub id: u32,
pub total: u64, // 单位:元
}
pub struct Customer {
pub name: String,
pub email: String,
}
impl Summary for Order {
fn summarize(&self) -> String {
format!("订单 #{} - {} 元", self.id, self.total)
}
}
impl Summary for Customer {
fn summarize(&self) -> String {
format!("客户 {} <{}>", self.name, self.email)
}
}
fn main() {
let order = Order { id: 42, total: 350_000 };
let customer = Customer {
name: "An".to_string(),
email: "an@example.com".to_string(),
};
println!("{}", order.summarize());
println!("{}", customer.summarize());
}
解释:
pub trait Summary声明了一个只有方法签名(没有函数体)的trait。impl Summary for Order为Order提供具体的实现。每种类型必须实现trait中所有没有函数体的方法。&self表示该方法借用对象,不获取所有权。
输出:
订单 #42 - 350000 元
客户 An <an@example.com>
注意: 要在其他模块中调用
order.summarize(),traitSummary必须在作用域内(use crate::Summary;)。这是一个很常见的错误。编译器会报错“no method namedsummarizefound”,即使你已经正确实现了。
3. 默认实现
Trait可以提供默认的函数体。实现trait的类型可以使用默认版本,或者覆盖它。
pub trait Summary {
fn title(&self) -> String;
fn summarize(&self) -> String {
format!("[摘要] {}", self.title())
}
}
impl Summary for Order {
fn title(&self) -> String {
format!("订单 #{}", self.id)
}
// 覆盖默认实现
fn summarize(&self) -> String {
format!("{} - {} 元", self.title(), self.total)
}
}
impl Summary for Customer {
fn title(&self) -> String {
format!("客户 {}", self.name)
}
// 不覆盖summarize -> 使用默认实现
}
再次运行第2节的main,输出为:
订单 #42 - 350000 元
[摘要] 客户 An
值得注意的是,默认方法summarize可以调用title(),即使title()在trait中没有函数体。这是一个很有用的模式。你只需要让具体类型提供最小部分(title),而trait会在其基础上构建其余部分。
从现在开始,后续示例将使用这个版本的trait。
4. 将Trait作为参数
4.1. impl Trait 语法
编写一个接受“任何实现了Summary的东西”的最简单方式:
fn print_summary(item: &impl Summary) {
println!("通知:{}", item.summarize());
}
fn main() {
let order = Order { id: 7, total: 120_000 };
print_summary(&order);
}
通知:订单 #7 - 120000 元
4.2. Trait约束
&impl Summary 实际上是带trait约束的泛型的简写:
fn print_summary<T: Summary>(item: &T) {
println!("通知:{}", item.summarize());
}
当有多个参数时,两种写法有区别:
// a和b可以是两种不同的类型
fn compare_loose(a: &impl Summary, b: &impl Summary) { /* ... */ }
// a和b必须是同一种类型T
fn compare_strict<T: Summary>(a: &T, b: &T) { /* ... */ }
调用compare_strict(&order, &customer)会产生编译错误,因为Order和Customer不是同一种类型。
4.3. 多个约束和where子句
当需要多个trait时,用+连接。当列表很长时,改用where会更易读:
use std::fmt::{Debug, Display};
fn log_item<T>(item: &T)
where
T: Summary + Display + Debug,
{
println!("{item} | {item:?} | {}", item.summarize());
}
这个函数只接受实现了所有三个trait的类型。缺少一个trait,编译器会在函数调用处立即报错,不会等到运行时。
Rust中的泛型使用单态化。编译器会为每个具体类型生成一份log_item的副本。因此调用没有运行时开销(静态分发)。作为代价,二进制文件可能会更大。
5. 返回impl Trait
你也可以返回“某个实现了Summary的类型”而不需要指定确切的类型名:
fn latest_order() -> impl Summary {
Order { id: 100, total: 999_000 }
}
fn main() {
println!("{}", latest_order().summarize());
}
订单 #100 - 999000 元
这种方法特别适合闭包和迭代器。它们真实的类型往往非常长或者根本无法写出来。
注意: 返回位置的
impl Trait仍然是单一的具体类型。下面这段代码无法编译:fn pick(vip: bool) -> impl Summary { if vip { Customer { name: "An".into(), email: "an@example.com".into() } } else { Order { id: 1, total: 0 } // 错误:`if` 和 `else` 有不兼容的类型 } }如果想返回不同类型,需要用
Box<dyn Summary>(见第6节)。
6. 使用dyn Trait的Trait对象
泛型要求类型在编译时已知。有时你需要一个列表既包含Order又包含Customer。此时应该使用trait对象:
fn main() {
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Order { id: 1, total: 50_000 }),
Box::new(Customer {
name: "Bình".to_string(),
email: "binh@example.com".to_string(),
}),
];
for item in &items {
println!("{}", item.summarize());
}
}
订单 #1 - 50000 元
[摘要] 客户 Bình
解释:
dyn Summary是一个编译时大小不确定的类型。因此它必须位于指针后面,如Box、&或Rc。- 每个trait对象由两部分组成:指向数据的指针和指向虚函数表的指针。调用
summarize()通过虚函数表在运行时进行查找(动态分发)。 - 第5节的
pick函数可以通过改变返回类型为Box<dyn Summary>并用Box::new(...)包装每个分支来修复。
快速比较:
| 方面 | 泛型 / impl Trait |
dyn Trait |
|---|---|---|
| 分发 | 静态,可以内联 | 动态通过虚函数表 |
| 混合类型列表 | 否 | 是 |
| 二进制大小 | 更大 | 更小 |
注意: 不是所有trait都能用
dyn。一个trait必须是动态兼容的(之前被称为对象安全)。如果trait有泛型方法(如fn send<T>(&self, t: T)),则不是动态兼容的。如果trait有返回Self的方法但不带where Self: Sized,也会失去这个属性。原因是虚函数表无法包含无限多个泛型方法的版本,编译器也不知道真实的Self是什么。
7. Derive和标准库Trait
标准库有许多常见的trait。其中一些trait可以通过#[derive]自动生成:
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct Order {
pub id: u32,
pub total: u64,
}
impl fmt::Display for Order {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Order#{}", self.id)
}
}
fn main() {
let a = Order { id: 5, total: 10_000 };
let b = a.clone();
println!("{a}"); // Display
println!("{a:?}"); // Debug
println!("{}", a == b); // PartialEq
println!("{}", a.to_string().len());
}
Order#5
Order { id: 5, total: 10000 }
true
7
我们没有实现ToString却能调用a.to_string()。这是因为标准库中有覆盖性实现:
impl<T: Display + ?Sized> ToString for T { /* ... */ }
这意味着任何实现了Display的类型都自动拥有ToString。你也可以为自己的trait编写覆盖性实现,例如impl<T: Display> Summary for T。不过要小心,因为它会与其他具体实现相冲突。
8. Orphan规则和newtype模式
Rust有orphan规则。你只能在trait或类型在你的crate中定义时才能写impl Trait for Type。这条规则防止两个crate以不同方式为同一类型实现同一trait。
下面的例子被拒绝,因为Display和Vec都来自标准库:
// 错误E0117:只有定义在当前crate中的trait可以为定义在外部crate中的类型实现
impl std::fmt::Display for Vec<Order> { /* ... */ }
解决方案是newtype模式。你用自己的struct包装外部类型:
pub struct Cart(pub Vec<Order>);
impl fmt::Display for Cart {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let total: u64 = self.0.iter().map(|o| o.total).sum();
write!(f, "购物车:{} 个订单,共 {} 元", self.0.len(), total)
}
}
fn main() {
let cart = Cart(vec![
Order { id: 1, total: 100_000 },
Order { id: 2, total: 250_000 },
]);
println!("{cart}");
}
购物车:2 个订单,共 350000 元
Cart是本地类型,所以可以实现Display。Newtype在运行时没有开销:Cart的内存布局与Vec<Order>相同。
9. 为Trait编写测试
Rust中的测试位于同一文件中,在带有#[cfg(test)]的模块内。我们测试覆盖实现和默认实现,以及通过trait对象调用的情况:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn order_overrides_default_summary() {
let order = Order { id: 42, total: 350_000 };
assert_eq!(order.summarize(), "订单 #42 - 350000 元");
}
#[test]
fn customer_uses_default_summary() {
let customer = Customer {
name: "An".to_string(),
email: "an@example.com".to_string(),
};
assert_eq!(customer.summarize(), "[摘要] 客户 An");
}
#[test]
fn trait_objects_dispatch_to_correct_impl() {
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Order { id: 1, total: 0 }),
Box::new(Customer { name: "Bình".into(), email: "b@x.vn".into() }),
];
let result: Vec<String> = items.iter().map(|i| i.summarize()).collect();
assert_eq!(result, vec!["订单 #1 - 0 元", "[摘要] 客户 Bình"]);
}
}
运行测试:
cargo test
running 3 tests
test tests::customer_uses_default_summary ... ok
test tests::order_overrides_default_summary ... ok
test tests::trait_objects_dispatch_to_correct_impl ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
第三个测试很重要。它确认当通过虚函数表时,每个元素仍然调用其真实类型的实现。
10. 结论
Trait是Rust中共享行为的基础。关键要点:
- 使用
trait声明行为,用impl Trait for Type来实现。默认方法帮助减少重复代码。 - 当类型在编译时已知时,使用
impl Trait或trait约束(T: Trait、where)。这种方式进行静态分发,没有运行时开销。 - 当需要存储或返回不同类型时,使用
Box<dyn Trait>。记住需要满足动态兼容性要求。 - 在调用trait方法前,记得用
use将trait导入作用域。 - 遇到orphan规则时,使用newtype模式。
什么时候不应该创建trait?如果行为只有一个实现且测试时不需要替换,在struct上使用普通方法就足够了。只有当真正有多种类型共享一个行为,或者需要抽象来解耦系统的不同部分时,trait才值得加入。