写点什么

Rust 从 0 到 1- 泛型 -trait

用户头像
关注
发布于: 2021 年 06 月 02 日
Rust从0到1-泛型-trait

trait 告诉 Rust 编译器某些或全部类型拥有相同的功能(如果是某个类型特有的,那么定义为这个类型相关的方法或函数就好了)。借助 trait 我们可以以一种抽象的方式定义类型相同的功能,同时我们可以使用 trait bounds 约束泛型是拥有特定功能的类型(有点类似面向对象中的接口的概念)。

定义 trait

一个类型的行为由其可供调用的方法和函数构成。如果不同类型可以调用的相同的方法,我们把它们称作这些类型的共享行为。trait 就是用来定义这些共享行为的方法。譬如,有两个存放了不同类型和属性文本的结构体:NewsArticle 用于存放世界各地的新闻故事, Tweet 最多只能存放 280 个字符的内容,以及是新的推文、还是转发或回复这样的信息。

假设,我们想要创建一个实现多媒体内容聚合的库,可以显示储存在 NewsArticle 或 Tweet 中的内容的概要。因此,我们需要可以通过一个 summarize 方法就可以分别获取这两个不同结构体实例的内容概要。下面是 Summary trait 定义的示例:

pub trait Summary {    fn summarize(&self) -> String;}
复制代码

上例中,我们使用 trait 关键字来声明一个 trait,后面是 trait 的名字 Summary,实现其所需要的方法是 fn summarize(&self) -> String。所有实现这个 trait 的类型都需要实现其自己的方法或使用默认方法,编译器也会确保任何实现 Summary trait 的类型都拥有与其定义完全一致的 summarize 方法。

注意,trait 可以包含有多个方法。

实现 trait

我们已经定义了 Summary trait,下面我们在类型 NewsArticle 和 Tweet  中实现它。譬如,NewsArticle 使用标题、作者和创建的位置作为 summarize 的返回值, Tweet 使用用户名后跟推文的全部内容作为返回值:

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) }}
复制代码

实现 trait 和实现类型的方法很相似,区别仅在于 impl 关键字之后增加了 trait 的名称,另外需要注意的就是在 impl 块中方法的实现,需要和 trait 定义中保持一致。在方法的调用上,trait 和普通的方法没有区别:

let tweet = Tweet {    username: String::from("horse_ebooks"),    content: String::from(        "of course, as you probably already know, people",    ),    reply: false,    retweet: false,};println!("1 new tweet: {}", tweet.summarize());
复制代码

上面的例子最终会打印出 1 new tweet: horse_ebooks: of course, as you probably already know, people。

另外,需要注意的是前面的例子中我们是在相同的 lib.rs 里定义了 Summary trait 和 NewsArticle、Tweet 两个类型,因此他们位于同一作用域。假设这个 lib.rs 属于 aggregator crate,如果外部代码想使用我们的 crate 为其自己的库中的结构体实现 Summary trait。首先他们需要通过 use aggregator::Summary; 将 trait 引入作用域,并且 Summary 还必须是公有的。

实现 trait 还有一个需要注意的限制,即至少要满足 trait 位于 crate 的本地作用域或者要实现 trait 的类型位于 crate 的本地作用域其中一个条件时,才能为该类型实现 trait。例如,我们可以为 aggregator crate 中的 Tweet 类型实现标准库中的 Display trait,因为 Tweet 类型位于 aggregator crate 本地作用域。同样,我们也可以在 aggregator crate 中为 Vec<T> 类型实现 Summary,因为 Summary trait 位于 aggregator crate 本地作用域。但是我们不能在 aggregator crate 中为 Vec<T> 实现 Display trait,因为 Display 和 Vec<T> 都定义于标准库中,它们并不在 aggregator crate 本地作用域。这个限制被称为程序的相干性(coherence),或者更确切的说是孤儿规则(orphan rule,之所以叫这个名字,是因为其父类型不存在)。这条规则确保了其他人编写的代码不会破坏我们的代码,反之亦然。如果没有这条规则的话,两个 crate 可以对相同类型实现相同的 trait,Rust 将无法知道应该使用哪一个。

默认实现

为 trait 中的某些或全部方法提供默认的实现在有些时候是很有用的,可以减少我们的编码,同时,当某个类型在实现 trait 时还可以选择重载方法的默认实现,保留了灵活性:

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

如果在 NewsArticle 中我们想直接使用默认实现,则可以通过 {} 指定一个空的 impl 块。这样我们就可以调用 NewsArticle 实例的 summarize 方法了,它会执行 Summary trait 中的默认实现:

impl Summary for NewsArticle {};
let article = NewsArticle { headline: String::from("Penguins win the Stanley Cup Championship!"), location: String::from("Pittsburgh, PA, USA"), author: String::from("Iceburgh"), content: String::from( "The Pittsburgh Penguins once again are the best \ hockey team in the NHL.", ),};
println!("New article available! {}", article.summarize());
复制代码

为 summarize 提供默认实现并不需要对 Tweet 的 Summary 实现做任何修改,因为重载默认实现的语法与实现没有默认实现的 trait 语法完全一样。

在 trait 的定义中,方法的默认实现可以调用当前 trait 中定义的其他方法,即使这个方法没有提供默认实现。这为 trait 提供了更多灵活性,譬如,我们可以在 Summary trait 中增加一个 summarize_author 方法的定义,并在 summarize 方法的默认实现中调用它:

pub trait Summary {    fn summarize_author(&self) -> String;
fn summarize(&self) -> String { format!("(Read more from {}...)", self.summarize_author()) }}
复制代码

如果要使用上面例子中的 Summary,我们需要在其实现中实现 summarize_author 方法:

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

这样我们就可以调用 Tweet 结构体实例的 summarize 方法了,summarize 的默认实现会调用 Tweet 实现的 summarize_author 方法:

let tweet = Tweet {    username: String::from("horse_ebooks"),    content: String::from("of course, as you probably already know, people"),    reply: false,    retweet: false,};
println!("1 new tweet: {}", tweet.summarize());
复制代码

另外需要注意的是,我们无法在重载的方法中调用这个方法的默认实现。

trait 作为参数

我们现在对 trait 的定义及其实现已经有了初步的了解,下面我们看看如何利用 trait 来接受不同类型的参数。譬如,定义一个函数 notify 来调用其参数 item 上的 summarize 方法,该参数的类型是实现了 Summary trait 的 NewsArticle、Tweet 或任何其它类型:

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

对于参数 item ,我们使用 impl 关键字和 trait 名称指定其类型为实现了 Summary trait 的类型,而不是某个具体的类型。从而在 notify 函数体中,我们可以调用任何属于 Summary trait 的方法,比如 summarize。我们可以传递 NewsArticle、 Tweet 或任何实现了 Summary trait 的类型的实例做为 notify 的参数,其它类型则无法编译通过。

Trait Bound 语法

上面例子中 impl + trait 名称的写法可以看做是 trait bound 的语法糖,参考下面的例子:

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

impl + trait 名称的语法很方便,适用于参数比较简单的场景。trait bound 则适用于更复杂的场景。譬如,当 notify 有 2 个参数,并且都是实现了 Summary trait 的类型,使用 impl + trait 名称的定义如下:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {
复制代码

在上面的定义中 item1 和 item2 的具体类型可以是不同的,也可以是相同的,没有限制。如果我们希望限制它们是相同类型,使用 impl + trait 名称就无法做到了,但是 trait bound 可以:

pub fn notify<T: Summary>(item1: T, item2: T) {
复制代码
指定多个 trait bound

我们还可以为参数指定多个 trait bound,即类型的定义里需要实现相应的多个 trait,假设我们指定了两个类型,Display 和 Summary:

pub fn notify(item: &(impl Summary + Display)) { // --snip--}
pub fn notify<T: Summary + Display>(item: &T) { // --snip--}
复制代码

上面两种写法都可以,区别前面已经做过介绍。

通过 where 提高可读性

在 trait bound 比较多的场景下,每个泛型有其自己的 trait bound,上面两种写法都会变得难以阅读。为此,Rust 提供了 where 从句来指定 trait bound 的语法。先看下原始的写法:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
复制代码

使用 where 的写法:

fn some_function<T, U>(t: &T, u: &U) -> i32    where T: Display + Clone,          U: Clone + Debug{  // --snip--}
复制代码

是不是看上去清晰了很多;)。

trait 作为返回类型

我们还可以使用 impl + 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,    }}
复制代码

通过 impl Summary 我们指定了 returns_summarizable 函数返回某个实现了 Summary trait 的类型,在这个例子中即是 Tweet。这个在闭包和迭代器场景十分有用,我们在后面章节将会介绍。但是,上面那的例子只适用于返回单一类型的场景,假如上面的例子的返回值类型可能是 NewsArticle 或 Tweet 就会报错:

fn returns_summarizable(switch: bool) -> impl Summary {    if switch {        NewsArticle {            headline: String::from(                "Penguins win the Stanley Cup Championship!",            ),            location: String::from("Pittsburgh, PA, USA"),            author: String::from("Iceburgh"),            content: String::from(                "The Pittsburgh Penguins once again are the best \                 hockey team in the NHL.",            ),        }    } else {        Tweet {            username: String::from("horse_ebooks"),            content: String::from(                "of course, as you probably already know, people",            ),            reply: false,            retweet: false,        }    }
复制代码

上面的例子是无法编译通过的,后面章节我们会介绍如何解决这个问题。

 修复 largest 函数

让我们利用刚刚学到的关于 trait 的知识来修复前面报错的 largest 函数!先来回顾一下编译 largest 函数时出现的错误:

$ cargo run   Compiling chapter10 v0.1.0 (file:///projects/generic)error[E0369]: binary operation `>` cannot be applied to type `&T` --> src/main.rs:5:17  |5 |         if item > largest {  |            ---- ^ ------- &T  |            |  |            &T  |help: consider restricting type parameter `T`  |1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {  |             ^^^^^^^^^^^^^^^^^^^^^^
error: aborting due to previous error
For more information about this error, try `rustc --explain E0369`.error: could not compile `generic`
To learn more, run the command again with --verbose.
复制代码

在这里我们使用了 > 运算符比较两个实例的值。这个运算符被定义为标准库中 trait std::cmp::PartialOrd 的一个默认方法。所以我们需要在参数 T 的中指定 trait bound,即 PartialOrd,这样 largest 函数的参数就被限制为可以比较大小的类型(因为 PartialOrd 位于 prelude 中所以并不需要手动将其引入):

fn largest<T: PartialOrd>(list: &[T]) -> T {
复制代码

再次编译代码的话,会出现类似下面的错误(和之前不同了):

$ cargo run   Compiling chapter10 v0.1.0 (file:///projects/generic)error[E0508]: cannot move out of type `[T]`, a non-copy slice --> src/main.rs:2:23  |2 |     let mut largest = list[0];  |                       ^^^^^^^  |                       |  |                       cannot move out of here  |                       move occurs because `list[_]` has type `T`, which does not implement the `Copy` trait  |                       help: consider borrowing here: `&list[0]`
error[E0507]: cannot move out of a shared reference --> src/main.rs:4:18 |4 | for &item in list { | ----- ^^^^ | || | |data moved here | |move occurs because `item` has type `T`, which does not implement the `Copy` trait | help: consider removing the `&`: `item`
error: aborting due to 2 previous errors
Some errors have detailed explanations: E0507, E0508.For more information about an error, try `rustc --explain E0507`.error: could not compile `generic`
To learn more, run the command again with --verbose.
复制代码

错误的主要原因是 cannot move out of type [T], a non-copy slice,这是什么导致的呢?我们前面介绍过像 i32 和 char 这样的基本类型是已知大小的并储存在栈上,所以他们默认实现了 Copy trait。但是,当我们将 largest 函数的参数改为泛型后,那么有可能传入的参数是没有实现 Copy trait 的类型,这就会导致上面的错误。

那么,怎么办呢?我们可以在参数的 trait bounds 中增加 Copy trait 约束,限制参数为实现了 Copy 和 PartialOrd trait 的类型:

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {    let mut largest = list[0];
for &item in list { if item > largest { largest = item; } }
largest}
fn main() { let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list); println!("The largest number is {}", result);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list); println!("The largest char is {}", result);}
复制代码

我们还可以在参数的 trait bounds 中指定 Clone 而不是 Copy。并克隆切片的每一个值使得 largest 函数拥有其所有权来解决。但是使用 clone 函数意味着会潜在的分配更多堆空间,而堆分配在数据量比较大时可能会造成性能问题。

另一种还有一种解决方式,就是将 largest 函数返回值从 T 改为 &T 并修改其实现,使其能够返回最大值的引用,这样我们就不需要指定 Clone 或 Copy trait,也不会需要额外的分配更多堆空间。大家可以自己动手试试;)。

在方法中指定 trait 约束

利用 trait 我们可以为方法指定约束,即在结构体中我们可以约束只有字段为实现了某些 tait 的类型时才可以调用某个方法。参考下面的例子:

use std::fmt::Display;
struct Pair<T> { x: T, y: T,}
impl<T> Pair<T> { fn new(x: T, y: T) -> Self { Self { x, y } }}
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 的实现上,这类 trait 被成为 blanket implementations。譬如,标准库为任何实现了 Display trait 的类型实现了 ToString trait。看起来类似下面这样:

impl<T: Display> ToString for T {    // --snip--}
复制代码

这样我们就可以对任何实现了 Display trait 的类型调用 ToString trait 定义的 to_string 方法。譬如,我们可以对整型调用 to_string 方法转为 String:

let s = 3.to_string();
复制代码

Blanket implementation 会在 trait 文档的 “Implementers” 部分进行说明。

trait 和 trait bound 让我们可以在使用泛型参数来减少重复的同时,还可以对泛型所需具备的行为进行进一步约束。在动态类型语言中,如果我们调用了一个并没有实现的方法,会在运行时出现错误。而 Rust 会在编译时发现这些错误,让我们在代码能够运行之前就需要修复错误;并且在获得灵活性的同时也具备更好的运行时性能。

发布于: 2021 年 06 月 02 日阅读数: 15
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-泛型-trait