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 named summarize found”,即使你已经正确实现了。

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才值得加入。

rusttraitgenericspolymorphism