简介
欢迎来到「特性:定义共享行为」。本实验是 《Rust 程序设计语言》 的一部分。你可以在 LabEx 中练习 Rust 技能。
在本实验中,我们将探索特性,它是一种在类型中定义共享行为以及为泛型类型指定特性边界的方式。
This tutorial is from open-source community. Access the source code
💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版
欢迎来到「特性:定义共享行为」。本实验是 《Rust 程序设计语言》 的一部分。你可以在 LabEx 中练习 Rust 技能。
在本实验中,我们将探索特性,它是一种在类型中定义共享行为以及为泛型类型指定特性边界的方式。
特性定义了特定类型所具有的功能,并且可以与其他类型共享。我们可以使用特性以抽象的方式定义共享行为。我们可以使用特性边界来指定泛型类型可以是具有某些行为的任何类型。
注意:特性类似于其他语言中通常称为「接口」的功能,不过存在一些差异。
一个类型的行为由我们可以在该类型上调用的方法组成。如果我们可以在所有这些类型上调用相同的方法,那么不同的类型就共享相同的行为。特性定义是一种将方法签名组合在一起的方式,用于定义实现某个目的所需的一组行为。
例如,假设我们有多个结构体,它们保存着各种类型和数量的文本:一个 NewsArticle
结构体,它保存着存储在特定位置的新闻报道;还有一个 Tweet
,它最多可以有 280 个字符,以及表示它是新推文、转发还是对另一条推文的回复的元数据。
我们想要创建一个名为 aggregator
的媒体聚合器库箱,它可以显示可能存储在 NewsArticle
或 Tweet
实例中的数据的摘要。为此,我们需要每个类型的摘要,并通过在实例上调用 summarize
方法来请求该摘要。清单 10-12 展示了一个公共的 Summary
特性的定义,该特性表达了这种行为。
文件名:src/lib.rs
pub trait Summary {
fn summarize(&self) -> String;
}
清单 10-12:一个由 summarize
方法提供的行为所组成的 Summary
特性
在这里,我们使用 trait
关键字声明一个特性,然后是特性的名称,在这种情况下是 Summary
。我们还将该特性声明为 pub
,这样依赖此箱的其他箱也可以使用这个特性,我们将在一些示例中看到这一点。在花括号内,我们声明了方法签名,这些签名描述了实现此特性的类型的行为,在这种情况下是 fn summarize(&self) -> String
。
在方法签名之后,我们没有在花括号内提供实现,而是使用了分号。每个实现此特性的类型都必须为该方法的主体提供自己的自定义行为。编译器将强制要求任何具有 Summary
特性的类型都必须精确地定义具有此签名的 summarize
方法。
一个特性的主体中可以有多个方法:方法签名每行列出一个,每行以分号结尾。
既然我们已经定义了 Summary
特性的方法所需的签名,那么我们就可以在媒体聚合器中的类型上实现它。清单 10-13 展示了在 NewsArticle
结构体上对 Summary
特性的实现,它使用标题、作者和位置来创建 summarize
的返回值。对于 Tweet
结构体,我们将 summarize
定义为用户名加上推文的完整文本,假设推文内容已经限制在 280 个字符以内。
文件名:src/lib.rs
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)
}
}
清单 10-13:在 NewsArticle
和 Tweet
类型上实现 Summary
特性
在类型上实现特性类似于实现常规方法。不同之处在于,在 impl
之后,我们写上想要实现的特性名称,然后使用 for
关键字,接着指定想要为其实现该特性的类型名称。在 impl
块内,我们放入特性定义中定义的方法签名。我们不是在每个签名后加分号,而是使用花括号,并在方法体中填充我们希望该特性的方法针对特定类型所具有的具体行为。
现在库已经在 NewsArticle
和 Tweet
上实现了 Summary
特性,该箱的用户可以像调用常规方法一样在 NewsArticle
和 Tweet
的实例上调用特性方法。唯一的区别是用户必须将特性以及类型引入作用域。下面是一个二进制箱如何使用我们的 aggregator
库箱的示例:
use aggregator::{Summary, Tweet};
fn main() {
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
。
其他依赖 aggregator
箱的箱也可以将 Summary
特性引入作用域,以便在它们自己的类型上实现 Summary
。需要注意的一个限制是,只有当特性或类型,或者两者都在我们的箱内时,我们才能在一个类型上实现一个特性。例如,作为我们 aggregator
箱功能的一部分,我们可以在像 Tweet
这样的自定义类型上实现标准库特性,如 Display
,因为类型 Tweet
在我们的 aggregator
箱内。我们也可以在我们的 aggregator
箱内对 Vec<T>
实现 Summary
,因为特性 Summary
在我们的 aggregator
箱内。
但是我们不能在外部类型上实现外部特性。例如,我们不能在我们的 aggregator
箱内对 Vec<T>
实现 Display
特性,因为 Display
和 Vec<T>
都在标准库中定义,并且不在我们的 aggregator
箱内。这个限制是一个称为「一致性」属性的一部分,更具体地说是「孤儿规则」,之所以这样命名是因为父类型不存在。这个规则确保其他人的代码不会破坏你的代码,反之亦然。没有这个规则,两个箱可能会为同一类型实现相同的特性,而 Rust 就不知道该使用哪个实现了。
有时,为特性中的某些或所有方法提供默认行为是很有用的,而不是要求每个类型都为所有方法提供实现。然后,当我们在特定类型上实现该特性时,可以保留或覆盖每个方法的默认行为。
在清单 10-14 中,我们为 Summary
特性的 summarize
方法指定了一个默认字符串,而不是像清单 10-12 那样只定义方法签名。
文件名:src/lib.rs
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
清单 10-14:定义一个带有 summarize
方法默认实现的 Summary
特性
为了使用默认实现来总结 NewsArticle
的实例,我们指定一个空的 impl
块 impl Summary for NewsArticle {}
。
即使我们不再直接在 NewsArticle
上定义 summarize
方法,但我们已经提供了默认实现,并指定 NewsArticle
实现了 Summary
特性。因此,我们仍然可以在 NewsArticle
的实例上调用 summarize
方法,如下所示:
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());
这段代码打印出 New article available! (Read more...)
。
创建默认实现并不需要我们对清单 10-13 中 Tweet
上的 Summary
实现做任何更改。原因是覆盖默认实现的语法与实现没有默认实现的特性方法的语法相同。
默认实现可以调用同一特性中的其他方法,即使这些其他方法没有默认实现。通过这种方式,一个特性可以提供很多有用的功能,并且只要求实现者指定其中的一小部分。例如,我们可以将 Summary
特性定义为有一个需要实现的 summarize_author
方法,然后定义一个 summarize
方法,其默认实现调用 summarize_author
方法:
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)
}
}
在我们定义了 summarize_author
之后,我们就可以在 Tweet
结构体的实例上调用 summarize
,并且 summarize
的默认实现会调用我们提供的 summarize_author
的定义。因为我们已经实现了 summarize_author
,Summary
特性为我们提供了 summarize
方法的行为,而无需我们编写更多代码。如下所示:
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: (Read more from @horse_ebooks...)
。
请注意,在同一方法的覆盖实现中不可能调用默认实现。
既然你已经知道如何定义和实现特性,我们可以探讨如何使用特性来定义接受多种不同类型的函数。我们将使用在清单 10-13 中为 NewsArticle
和 Tweet
类型实现的 Summary
特性,来定义一个 notify
函数,该函数会在其 item
参数(它是实现了 Summary
特性的某种类型)上调用 summarize
方法。为此,我们使用 impl Trait
语法,如下所示:
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
我们不是为 item
参数指定具体的类型,而是指定 impl
关键字和特性名称。这个参数接受任何实现了指定特性的类型。在 notify
的函数体中,我们可以对 item
调用来自 Summary
特性的任何方法,比如 summarize
。我们可以调用 notify
并传入任何 NewsArticle
或 Tweet
的实例。用任何其他类型(比如 String
或 i32
)调用该函数的代码将无法编译,因为那些类型没有实现 Summary
。
impl Trait
语法在简单的情况下可以正常工作,但实际上它是一种更冗长形式的语法糖,这种冗长形式被称为「特性约束」;它看起来像这样:
pub fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
这种更冗长的形式与上一节中的示例等效,但更啰嗦。我们在冒号后和尖括号内,与泛型类型参数的声明一起放置特性约束。
impl Trait
语法很方便,在简单的情况下能使代码更简洁,而完整的特性约束语法在其他情况下可以表达更复杂的情况。例如,我们可以有两个实现了 Summary
的参数。使用 impl Trait
语法时看起来像这样:
pub fn notify(item1: &impl Summary, item2: &impl Summary) {
如果我们希望这个函数允许 item1
和 item2
具有不同的类型(只要这两种类型都实现了 Summary
),那么使用 impl Trait
是合适的。然而,如果我们想强制两个参数具有相同的类型,那么我们必须使用特性约束,如下所示:
pub fn notify<T: Summary>(item1: &T, item2: &T) {
指定为 item1
和 item2
参数类型的泛型类型 T
对函数进行了约束,使得作为 item1
和 item2
参数传递的值的具体类型必须相同。
我们还可以指定多个特性约束。假设我们希望 notify
对 item
既使用 summarize
方法,又使用显示格式化功能:我们在 notify
定义中指定 item
必须同时实现 Display
和 Summary
。我们可以使用 +
语法来做到这一点:
pub fn notify(item: &(impl Summary + Display)) {
+
语法在泛型类型的特性约束中同样有效:
pub fn notify<T: Summary + Display>(item: &T) {
指定了这两个特性约束后,notify
的函数体就可以调用 summarize
并使用 {}
来格式化 item
。
使用过多的特性约束有其缺点。每个泛型都有自己的特性约束,所以带有多个泛型类型参数的函数在函数名和参数列表之间可能包含大量的特性约束信息,这使得函数签名难以阅读。出于这个原因,Rust 有一种替代语法,用于在函数签名后的 where
子句中指定特性约束。所以,不用这样写:
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,
{
这个函数的签名没那么杂乱:函数名、参数列表和返回类型靠得很近,类似于一个没有大量特性约束的函数。
我们还可以在返回位置使用 impl 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
特性的类型,而无需指定具体类型。在这种情况下,returns_summarizable
返回一个 Tweet
,但调用此函数的代码无需知道这一点。
仅通过特性来指定返回类型的能力在闭包和迭代器的上下文中特别有用,我们将在第 13 章中介绍。闭包和迭代器创建的类型只有编译器知道,或者是非常长而难以指定的类型。impl Trait
语法让你可以简洁地指定一个函数返回某种实现了 Iterator
特性的类型,而无需写出很长的类型。
然而,只有在返回单个类型时才能使用 impl Trait
。例如,这段返回 NewsArticle
或 Tweet
且返回类型指定为 impl Summary
的代码无法正常工作:
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,
}
}
}
由于编译器对 impl Trait
语法的实现限制,不允许返回 NewsArticle
或 Tweet
中的任意一个。我们将在“使用允许不同类型值的特性对象”中介绍如何编写具有这种行为的函数。
通过在使用泛型类型参数的 impl
块中使用特性约束,我们可以为实现了指定特性的类型有条件地实现方法。例如,清单 10-15 中的 Pair<T>
类型总是实现 new
函数来返回 Pair<T>
的新实例(从“定义方法”中回忆起,Self
是 impl
块类型的类型别名,在这种情况下是 Pair<T>
)。但是在下一个 impl
块中,只有当 Pair<T>
的内部类型 T
实现了用于比较的 PartialOrd
特性和用于打印的 Display
特性时,Pair<T>
才实现 cmp_display
方法。
文件名:src/lib.rs
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);
}
}
}
清单 10-15:根据特性约束有条件地在泛型类型上实现方法
我们还可以为实现了另一个特性的任何类型有条件地实现一个特性。在满足特性约束的任何类型上对特性的实现被称为覆盖实现,并且在 Rust 标准库中被广泛使用。例如,标准库为任何实现了 Display
特性的类型实现了 ToString
特性。标准库中的 impl
块看起来类似于以下代码:
impl<T: Display> ToString for T {
--snip--
}
因为标准库有这个覆盖实现,所以我们可以在任何实现了 Display
特性的类型上调用由 ToString
特性定义的 to_string
方法。例如,因为整数实现了 Display
,所以我们可以像这样将整数转换为它们相应的 String
值:
let s = 3.to_string();
覆盖实现在特性文档的“实现者”部分中显示。
特性和特性约束使我们能够编写使用泛型类型参数的代码,以减少重复,同时向编译器指定我们希望泛型类型具有特定的行为。然后编译器可以使用特性约束信息来检查与我们的代码一起使用的所有具体类型是否提供了正确的行为。在动态类型语言中,如果我们在未定义该方法的类型上调用方法,我们会在运行时得到一个错误。但是 Rust 将这些错误转移到编译时,所以我们被迫在代码甚至能够运行之前修复问题。此外,我们不必编写在运行时检查行为的代码,因为我们已经在编译时进行了检查。这样做在不放弃泛型灵活性的情况下提高了性能。
恭喜你!你已经完成了“特性:定义共享行为”实验。你可以在 LabEx 中练习更多实验来提升你的技能。