尽管很多后发的 OOP 语言都引入了 Trait(特质)用来解决传统 OO 抽象中的一些弊病,但是在不同的语言里 Trait 的功能和定位差异还是很大的。以 Scala 为例,它的 Trait 可以有字段,并且主要应用于“混入”(Mixin),也就是给一个类添加某种能力。Scala 允许一个类同时混入多个 Trait,Scala 依靠 Trait 解决了传统多继承(也叫菱形继承,Diamond Problem) 中的一些问题。相比其他语言,Rust 中的 Trait 算是非常与众不同的了,它的语言中地位非常高,通常人们认为,在 Rust 中:

Trait ➪ 定义行为

Struct ➪ 定义数据

因此,Rust 中的 Trait 不允许有字段,但允许为一个类型(Struct / Enum)实现多个 Trait。下面,我们就系统地了解一下 Trait。

1. 定义 Trait

定义一个 trait 并不难,难的是:基于需求分析,你应该抽象出什么样的 trait?它们都分别代表怎样的行为能力?也就是考验你的设计能力。不过,这不是本文讨论的重点,我们还是从基础语法上了解一下怎样定义 trait 吧。看下面的示例:

pub trait Summary {
    fn summarize(&self) -> String;
}

代码定义了一个 Summary trait,要求实现它的类型要有“总结概要”的能力。单就这个示例而言,它这和 Java 中的 Interface 是一样的:仅仅是定义了一种行为接口,但没有实现它。当然,完全可以在定义 trait 时直接实现它的部分或全部方法,这取决于你认为这个方法是否该有一种默认实现。比如,你可以这样去定义:

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

不用担心你提供的默认实现是否能适用所有类型,因为具体类型可以重写这个方法。

2. (为类型)实现 Trait

Trait 定义的是一组行为能力,在 Rust 里,你并不能“实例化”一个 Trait,Trait 必须要被一个类型 (struct / enmu)去实现,然后通过实例化这个类型而存在。也就是:行为(Trait)不能脱离数据载体(Struct / Enum)单独存在。“为一个类型实现某个 Trait”是一种准确的官方描述,它对应着 imple A_TRAIT for A_STRUCT {...} 这种语法。看下面的例子:

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

结构体 NewsArticleTweet 各自实现了 Summary 这个 trait。如果 trait 中给出了默认实现,我们的结构体想直接使用的话,那就在 impl 块中什么都不要写,例如:如果 NewsArticle 想使用前面例子中给出的 summarize 方法的默认实现,应该这样写:

impl Summary for NewsArticle {} // 指定一个空的 impl 块即可。

在设计 trait 时,如果你觉得一个方法的大部分逻辑或逻辑的框架都是一样的,只是有局部的一小部分逻辑会因具体类型不同而不同,那么可以考虑把这部分会变化的逻辑剥离到一个单独的方法中,不去实现它,而把整个逻辑框架实现为一个方法,把变化的部分改为调用那个独立出去的小方法。这其实有点像设计模式:“模板方法”的思路。比如下面的例子,summarize 方法像是一个模板,在输出 summary 时有固定的逻辑,但是 summarize_author 是在每一个具体的 Tweet 实例中会各部相同,在 summarize 方法中无法确定它,于是,我们可以再定义一个 summarize_author 方法,让 Tweet 去实现它,然后由 summarize 调用:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

3. Trait 作为参数

如果你正在编写的程序可以通过抽象类、接口、特质去完成,就尽量使用这些抽象层级更高的实体,而不是使用具体类,这叫“基于抽象编程”。这样做最大的好处是:当需要引入新的类型时,不需要修改现有的代码,也就是 OOP 中开闭原则:对扩展开放,对修改关闭。在 Rust 里,我们也应如此,不过,Trait 不能单独实例化,因此不能单独使用,这也体现在了用它作参数上,那要怎么办呢?我们需要在参数和 Trait 类型中间加一个 impl 关键字。看一下示例代码:假设我们要设计一个 notify 函数,它要获取 NewsArticle 或 Tweet 的 summary 信息,然后处理一下打印出来,这个时候,这个方法的参数就应该声明为 Summary,而不是某一个具体的 Struct!具体应该这样写:

pub fn notify(item: impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

一定要注意:item: impl Summary 的写法,在参数名和参数类型中间需要一个 impl 关键字!

3.1 Trait 约束(Trait Bound)语法

上一个示例中的 item: impl Summary 其实一个更长更标准的语法的“语法糖”,它的标准形式是:

pub fn notify<T: Summary>(item: T) {
    println!("Breaking news! {}", item.summarize());
}

这种形式的名称叫 Trait 约束(Trait Bound),也就是代码中的 <T: Summary>,这个形式也比较好理解:notify 方法声明一个类型参数 T,然后要求 T 必须是实现了 Summary trait 的某种类型,这实际上是通过声明特定的 Trait 类型,收窄了类型 T 的范围,所以叫 Trait 约束。在只有一个参数时,impl Trait<T: Summary> 是等价的,但是当有两个以上参数时,就会一些微妙的差异了:

// item1 和 item2 都必须是实现了 Summary 的类型,但未必是同一种类型
pub fn notify(item1: impl Summary, item2: impl Summary) {...}

// item1 和 item2 必须是实现了 Summary 的类型 且 必须是同一类型
pub fn notify<T: Summary>(item1: T, item2: T) {...}

3.2 通过 + 指定多个 Trait 约束

我们会经常遇到一个类型实现了多种 trait,同样处于“基于抽象编程”的理念,我们还是想在参数里使用它的抽象类型,也就是 trait 作为参数类型,如果在函数里我们要调用它属于不同特质的方法,那就得声明多个 Trait 约束,语法是这样的:

// 简写形式:
pub fn notify(item: impl Summary + Display) {...}
// 标准形式:
pub fn notify<T: Summary + Display>(item: T) {...}

3.2 通过 Where 简化 Trait 约束

当我们有多个类型参数,每个类型参数又有多个 Trait 约束时,方法签名就会变得非常冗长,例如下面这样:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: T, u: U) -> i32 {...}

针对这种情况,Rust 设计了一种 where 字句,把类型参数的 Trait 约束 声明移动到 where 子句里,这会让函数的签名看上去更清晰一些

fn some_function<T, U>(t: T, u: U) -> i32
    where T: Display + Clone, U: Clone + Debug
{...}

4. Trait 作为返回值

Trait 既然可以做参数,那同样可以做返回值,在能返回一个具体类型的情况下争取返回一个 Trait 同样是基于抽象编程的考量。我们看一下示例:

fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("horse_ebooks"),
        content: String::from("of course, as you probably already know, people"),
        reply: false,
        retweet: false,
    }
}

你看,returns_summarizable 方法其实是创建了一个结构体,但是它非要返回一个 trait,它的函数名其实暗示了它的功能,所以返回一个 Summary trait 是恰当的!返回值类型的书写形式和 Trait 约束 的形式是一致的。Trait 作返回值时也可以指定多个 Trait 约束:

fn foo() -> impl Read + Write {
    std::io::Cursor::new(vec![1,2,3])
}

Trait 作为返回值时与 Trait 作为参数时有一个不一样的地方:Trait 作为返回值时只能指代“一个”具体类型,什么意思呢?看下面的代码:

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            ...
        }
    } else {
        Tweet {
            ...
        }
    }
}

上述代码不能编译通过,因为函数可能返回 NewsArticle 和 Tweet 两种类型,尽管它们都实现了 Summary trait,但是,Rust 不允许这种情况发生,也就是:Trait 作为返回值时只能指代“一个”确定的具体类型。

5. 为特定的 “Trait 约束” 实现方法

考虑一下泛型结构体,它的类型参数可能是各种各样的类型,我们希望:当泛型结构体的类型参数是某一种特定的 trait 时,给它实现 trait 规定的方法,如果不是这种特定类型,就没有机会调用这些方法。我们看一下例子比较好理解:

use std::fmt::Display;

// Paire 是一个泛型结构体
struct Pair<T> {
    x: T,
    y: T,
}

// 为 Pair 添加一个 new 方法
impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self {
            x,
            y,
        }
    }
}

// 同样是为 Pair 添加一个方法,但 cmp_display 方法只能应用在
// T 类型是同时实现了 Display + PartialOrd 特质的类型,如果 T 类型没有实现这些特质,
// 则 Pair<T> 的实例上是“见不到”这个 cmp_display 函数的
impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

这种为特定的 trait 约束 实现方法的做法,叫 blanket implementations,这其实是一个很贴切的名字,只要联想到“地毯式轰炸”就是能体会到它的意思,因为,这是一种“为一大类 类型 统一实现某个 trait”的做法,在标准库中被广泛使用。比如:标准库为所有实现了 Display trait 的类型实现了 ToString trait,听上去是一个很宏大的动作,但通过泛型 + Trait 约束,我们只用编写一个方法就能作用到所有目标类型上,它是这个样子的:

impl<T: Display> ToString for T {...}

6. 小结

trait 和 trait 约束 让我们使用泛型类型参数来减少重复,并仍然能够向编译器明确指定泛型类型需要拥有哪些行为。因为我们向编译器提供了 trait 约束 信息,它就可以检查代码中所用到的具体类型是否提供了正确的行为。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐