Programming Rust Fast, Safe Systems Development(译) 特征和泛型(第十一章)

[A] computer scientist tends to be able to deal with nonuniform structures—case 1, case 2,
case 3—while a mathematician will tend to want one unifying axiom that governs an entire
system.
— Donald Knuth

编程的一个重大发现是,编写可以处理许多不同类型的值的代码是可能的,甚至是尚未发明的类型。这是两个例子:

•Vec 是通用的:您可以创建任何类型值的向量,包括程序中定义的Vec作者从未预料到的类型。

•许多东西都有.write()方法,包括Files和TcpStreams。您的代码可以通过引用,任何编写器以及向其发送数据来获取编写器。您的代码不必关心它是什么类型的编写器。之后,如果有人添加了一种新类型的编写器,您的代码就会支持它。

当然,这种能力对Rust来说并不新鲜。它被称为多态,它是20世纪70年代热门的新编程语言技术。到目前为止,它实际上是普遍的。 Rust支持多态性和两个相关的特征:特征和泛型。许多程序员都熟悉这些概念,但Rust采用了​​一种受Haskell类型类启发的新方法。

特征是Rust对接口或抽象基类的看法。起初,它们看起来就像Java或C#中的接口。写入字节的特性称为std :: io :: Write,它在标准库中的定义如下所示:

trait Write {
 fn write(&mut self, buf: &[u8]) -> Result<usize>;
 fn flush(&mut self) -> Result<()>;
 fn write_all(&mut self, buf: &[u8]) -> Result<()> { ... }
 .

这个特性提供了几种方法;我们只展示了前三个。标准类型File和TcpStream都实现了std :: io :: Write。 Vec也是如此。这三种类型都提供名为.write(),. flush()等的方法。使用编写器而不关心其类型的代码如下所示:

use std::io::Write;
fn say_hello(out: &mut Write) -> std::io::Result<()> {
 out.write_all(b"hello world\n")?;
 out.flush()
}

out的类型是&mut Write,意思是“对实现Write特征的任何值的可变引用”。

use std::fs::File;
let mut local_file = File::create("hello.txt")?;
say_hello(&mut local_file)?; // works
let mut bytes = vec![];
say_hello(&mut bytes)?; // also works
assert_eq!(bytes, b"hello world\n");

本章首先介绍如何使用特征,如何使用特征以及如何定义特征。但到目前为止,我们所暗示的特征还有很多。我们将使用它们为现有类型添加扩展方法,甚至可以构建类型为str和bool。我们将解释为什么向类型添加特征不需要额外的内存以及如何在没有虚拟方法调用开销的情况下使用特征。我们将看到内置特征是Rust为操作符重载和其他功能提供的语言的钩子。我们将介绍Self类型,相关方法和相关类型,从Haskell中提取的三个特性Rust,它可以优雅地解决其他语言通过变通方法和黑客攻击解决的问题。

泛型是Rust中的另一种多态性。与C ++模板一样,通用函数或类型可以与许多不同类型的值一起使用。

/// Given two values, pick whichever one is less.
fn min<T: Ord>(value1: T, value2: T) -> T {
 if value1 <= value2 {
 value1
 } else {
 value2
 }
}

此函数中的<T:Ord>表示min可以与任何实现Ord特征的类型T的参数一起使用,即任何有序类型。编译器为您实际使用的每种类型T生成自定义机器代码。

仿制药和特征密切相关。 Rust让我们在使用<=运算符比较两个类型为T的值之前,先声明T:Ord要求(称为绑定)。所以我们还将讨论&mut Write和<T:Write>如何相似,他们是如何不同的,以及如何在这两种使用特征的方式之间做出选择。

使用特征

特征是任何给定类型可能支持或不支持的特征。大多数情况下,特质代表一种能力:类型可以做的事情。

•实现std :: io :: Write的值可以写出字节。

•实现std :: iter :: Iterator的值可以生成一系列值。

•实现std :: clone :: Clone的值可以在内存中创建自身的克隆。

•可以使用带有{:?}格式说明符的println!()打印实现std :: fmt :: Debug的值。

这些特性都是Rust标准库的一部分,许多标准类型都实现了它们。

•std :: fs :: File实现Write特性;它将字节写入本地文件。 std :: net :: TcpStream写入网络连接。 Vec 也实现了Write。对字节向量的每个.write()调用都会将一些数据附加到末尾。

•Range (类型0…10)实现Iterator特征,与切片,哈希表等相关联的一些迭代器类型也是如此。

•大多数标准库类型都实现了克隆。例外主要是像TcpStream这样的类型,它们不仅仅代表内存中的数据。
•同样,大多数标准库类型都支持Debug。

关于特质方法有一个不寻常的规则:特质本身必须在范围内。否则,它的所有方法都被隐藏。

let mut buf: Vec<u8> = vec![];
buf.write_all(b"hello")?; // error: no method named `write_all`

在这种情况下,编译器会打印一条友好的错误消息,建议添加使用std :: io :: Write;确实解决了这个问题:

use std::io::Write;
let mut buf: Vec<u8> = vec![];
buf.write_all(b"hello")?; // ok

Rust有这个规则,因为正如我们将在本章后面看到的那样,你可以使用traits将新方法添加到任何典型的标准库类型,如u32和str。第三方包装箱可以做同样的事情。显然,这可能会导致命名冲突!但是由于Rust让你导入你计划使用的特性,板条箱可以自由地利用这个超级大国,而且在实践中很少发生冲突。

Clone和Iterator方法在没有任何特殊导入的情况下工作的原因是它们默认情况下始终在范围内:它们是标准前奏的一部分,Rust自动导入每个模块的名称。事实上,前奏主要是精心挑选的特征选择。我们将在第13章中介绍其中的许多内容。

C ++和C#程序员已经注意到trait方法就像虚方法。尽管如此,上面显示的呼叫速度很快,与任何其他方法调用一样快。简单地说,这里没有多态性。很明显,buf是一个向量,而不是文件或网络连接。编译器可以发出对Vec :: write()的简单调用。它甚至可以内联该方法。 (C ++和C#通常都会这样做,虽然子类化的可能性有时会排除这种情况。)只有通过&mut写入调用才会产生虚拟方法调用的开销。

Trait对象

在Rust中使用traits编写多态代码有两种方法:trait对象和泛型。我们首先介绍特征对象,然后在下一节中转向泛型。

Rust不允许Write类型的变量:

use std::io::Write;
let mut buf: Vec<u8> = vec![];
let writer: Write = buf; // error: `Write` does not have a constant size

变量的大小必须在编译时知道,实现Write的类型可以是任何大小。如果您来自C#或Java,这可能会令人惊讶,但原因很简单。在Java中,OutputStream类型的变量(类似于std :: io :: Write的Java标准接口)是对任何实现OutputStream的对象的引用。事实上它是一个参考不言而喻。它与C#和大多数其他语言中的接口相同。

我们在Rust中想要的是同样的东西,但在Rust中,引用是显式的:

let mut buf: Vec<u8> = vec![];
let writer: &mut Write = &mut buf; // ok

对特征类型的引用(如writer)称为特征对象。与任何其他引用一样,特征对象指向某个值,它具有生命周期,并且可以是mut或shared。

使特征对象与众不同的是,Rust通常在编译时不知道所指对象的类型。因此,特征对象包括关于指示对象类型的一些额外信息。这完全是Rust在幕后使用的:当你调用writer.write(data)时,Rust需要类型信息来动态调用正确的write方法,具体取决于* writer的类型。您无法直接查询类型信息,Rust不支持从特征对象&mut转发返回到Vec 这样的具体类型。

特质对象布局

在内存中,特征对象是一个胖指针,由指向值的指针和指向表示该值类型的表的指针组成。因此,每个特征对象占用两个机器字,如图11-1所示
在这里插入图片描述

C ++也有这种运行时类型信息。它被称为虚拟表或vtable。在Rust中,就像在C ++中一样,vtable在编译时生成一次,并由相同类型的所有对象共享。图11-1中以深灰色显示的所有内容(包括vtable)都是Rust的私有实现细节。同样,这些不是您可以直接访问的字段和数据结构。相反,当您调用特征对象的方法时,该语言会自动使用vtable,以确定要调用的实现。

经验丰富的C ++程序员会注意到Rust和C ++使用的内存有点不同。在C ++中,vtable指针或vptr存储为结构的一部分。 Rust使用胖指针代替。结构本身只包含其字段。这样,一个struct可以实现几十个特性而不包含几十个vptrs。甚至类似i32的类型,如果不足以容纳vptr,也可以实现特性。

Rust会在需要时自动将普通引用转换为特征对象。这就是为什么我们能够在这个例子中将&mut local_file传递给say_hello:

let mut local_file = File::create("hello.txt")?;
say_hello(&mut local_file)?;

&mut local_file的类型是&mut文件,say_hello的参数类型是&mut Write。由于File是一种编写器,Rust允许这样做,自动将普通引用转换为特征对象。

同样,Rust会很乐意将Box 转换为Box ,这是一个拥有堆中writer的值:

Box ,如&mut Write,是一个胖指针:它包含编写器本身的地址和vtable的地址。其他指针类型也是如此,例如Rc 。

这种转换是创建特征对象的唯一方法。计算机实际上在这里做的很简单。在转换发生的时刻,Rust知道指示对象的真实类型(在本例中为File),因此它只是添加了相应vtable的地址,将常规指针转换为胖指针。

通用函数

在本章的开头,我们展示了一个将trait对象作为参数的say_hello()函数。让我们将该函数重写为泛型函数:

fn say_hello<W: Write>(out: &mut W) -> std::io::Result<()> {
 out.write_all(b"hello world\n")?;
 out.flush()
}

只有类型签名发生了变化:

fn say_hello(out: &mut Write) // plain function
fn say_hello<W: Write>(out: &mut W) // generic function

短语<W:Write>是使函数通用的原因。这是一个类型参数。

这意味着在整个函数体中,W代表实现Write特征的某种类型。按照惯例,类型参数通常是单个大写字母。

say_hello(&mut local_file)?; // calls say_hello::<File>
say_hello(&mut bytes)?; // calls say_hello::<Vec<u8>>

W代表哪种类型取决于泛型函数的使用方式:

say_hello(&mut local_file)?; // calls say_hello::<File>
say_hello(&mut bytes)?; // calls say_hello::<Vec<u8>>

当你将local_file传递给通用的say_hello()函数时,你正在调用say_hello :: ()。 Rust为此函数生成调用File :: write_all()和File :: flush()的机器代码。当你传递&mut字节时,你正在调用say_hello :: <Vec >()。 Rust为此版本的函数生成单独的机器代码,调用相应的Vec 方法。在这两种情况下,Rust都会根据参数的类型推断出类型W.你总是可以拼写出类型参数:

say_hello::<File>(&mut local_file)?;

但它很少需要,因为Rust通常可以通过查看参数来推断出类型参数。这里,say_hello泛型函数需要一个&mut W参数,我们将它传递给一个&mut文件,因此Rust推断出W = File。如果您正在调用的泛型函数没有任何提供有用线索的参数,则可能需要拼写出来:

// calling a generic method collect<C>() that takes no arguments
let v1 = (0 .. 1000).collect(); // error: can't infer type
let v2 = (0 .. 1000).collect::<Vec<i32>>(); // ok

有时我们需要来自类型参数的多种能力。例如,如果我们想要打印出矢量中前10个最常见的值,我们需要这些值可打印:

use std::fmt::Debug;
fn top_ten<T: Debug>(values: &Vec<T>) { ... }

但这还不够好。我们如何计划确定哪些值最常见?通常的方法是将值用作哈希表中的键。这意味着值需要支持Hash和Eq操作。 T上的边界必须包括这些以及Debug。其语法使用+号:

fn top_ten<T: Debug + Hash + Eq>(values: &Vec<T>) { ... }

有些类型实现Debug,有些实现Hash,有些支持Eq;还有一些,比如u32和String,实现了这三个,如图11-2所示。

在这里插入图片描述

类型参数也可以根本没有边界,但如果没有为它指定任何边界,则无法对值进行多少操作。你可以移动它。你可以把它放在一个盒子或矢量中。就是这样。

通用函数可以有多个类型参数:

/// Run a query on a large, partitioned data set.
/// See <http://research.google.com/archive/mapreduce.html>.
fn run_query<M: Mapper + Serialize, R: Reducer + Serialize>(
 data: &DataSet, map: M, reduce: R) -> Results
{ ... }

正如这个例子所示,边界可能会很长,以至于眼睛很难看。 Rust使用关键字where提供了另一种语法:

fn run_query<M, R>(data: &DataSet, map: M, reduce: R) -> Results
 where M: Mapper + Serialize,
 R: Reducer + Serialize
{ ... }

类型参数M和R仍然在前面声明,但是边界被移动到单独的行。在通用结构,枚举,类型别名和方法上也允许使用这种where子句 - 允许任何边界。

当然,where子句的替代方法是保持简单:找到一种编写程序的方法,而不是非常集中地使用泛型。第105页上的“将引用作为参数接收”介绍了生命周期参数的语法。通用函数可以包含生命周期参数和类型参数。终身参数首先出现。

/// Return a reference to the point in `candidates` that's
/// closest to the `target` point.
fn nearest<'t, 'c, P>(target: &'t P, candidates: &'c [P]) -> &'c P
 where P: MeasureDistance
{
 ...
}

这个函数有两个参数,target和candidate。两者都是引用,我们给它们不同的生命周期’和’c(如第111页的“不同的生命周期参数”中所述)。此外,该函数适用于实现MeasureDistance特性的任何类型P,因此我们可以在一个程序中使用Point2d值而在另一个程序中使用Point3d值。

生命周期永远不会对机器代码产生任何影响。两次使用相同类型P调用nearest(),但生命周期不同,将调用相同的编译函数。只有不同的类型才会导致Rust编译泛型函数的多个副本。

当然,函数不是Rust中唯一的通用代码。

•我们已经在第202页的“Generic Structs”和第218页的“Generic Enums”中介绍了泛型类型。

•单个方法可以是通用的,即使它定义的类型不是通用的:

neric:
impl PancakeStack {
 fn push<T: Topping>(&mut self, goop: T) -> PancakeResult<()> {
 ...
 }
}

•类型别名也可以是通用的:输入PancakeResult = Result <T,PancakeError>;

•我们将在本章后面介绍一般特征。此section-bounds中引入的所有功能,其中包含子句,生命周期参数等等,可用于所有通用项,而不仅仅是函数。

使用哪种

是否使用特征对象或通用代码的选择是微妙的。由于这两个特征都基于特征,因此它们有很多共同之处。

无论何时需要混合类型的值集合,特征对象都是正确的选择。制作通用沙拉在技术上是可行的:

trait Vegetable {
 ...
}
struct Salad<V: Vegetable> {
 veggies: Vec<V>
}

但这是一个相当严厉的设计。每种这样的沙拉完全由单一类型的蔬菜组成。并非所有人都因此而被淘汰。你的一位作者曾经花了14美元购买了Salad 并且从未完全体验过这种体验。

我们怎样才能建立更好的沙拉?由于蔬菜价值可以是所有不同的大小,我们不能要求Rust为Vec <蔬菜>:

struct Salad {
 veggies: Vec<Vegetable> // error: `Vegetable` does not have
 // a constant size
}

Trait对象是解决方案:

struct Salad {
 veggies: Vec<Box<Vegetable>>
}

每个Box 可以拥有任何类型的蔬菜,但盒子本身有一个恒定的大小 - 两个适合存储在向量中的指针。除了在一个人的食物中有盒子的不幸的混合比喻,这正是所要求的,它也可以用于绘图应用程序中的形状,游戏中的怪物,网络路由器中的可插拔路由算法等等。上。

使用特征对象的另一个可能原因是减少编译代码的总量。 Rust可能需要多次编译泛型函数,对于它使用的每种类型都要编译一次。这可能会使二进制文件变大,这种现象在C ++圈子中称为代码膨胀。这些天,记忆力很丰富,我们大多数人都有忽视代码大小的奢侈品;但是存在受限制的环境。在涉及沙拉或微控制器的情况之外,仿制药比特征对象具有两个重要的优点,结果是在Rust中,仿制药是更常见的选择。

第一个优点是速度。每次Rust编译器为泛型函数生成机器代码时,它都知道它正在使用哪种类型,因此它在那时知道要调用哪种写入方法。不需要动态调度。引言中显示的通用min()函数与我们编写单独的函数min_u8,min_i64,min_string等一样快。编译器可以像任何其他函数一样内联它,因此在发布版本中,对min :: 的调用可能只是两个或三个指令。具有常量参数的调用(如min(5,3))将更快:Rust可以在编译时对其进行评估,因此根本没有运行时成本。或者考虑这个通用函数调用:

let mut sink = std::io::sink();
say_hello(&mut sink)?;

std :: io :: sink()返回一个Sink类型的编写器,它静静地丢弃写入它的所有字节。

当Rust为此生成机器代码时,它可以发出调用Sink :: write_all的代码,检查错误,然后调用Sink :: flush。这就是泛型函数的主体所要做的。或者,Rust可以查看这些方法并实现以下内容:

•Sink :: write_all()什么都不做。

•Sink :: flush()什么都不做。

•两种方法都不会返回错误。

简而言之,Rust拥有完全优化此功能所需的所有信息。

将其与特征对象的行为进行比较。 Rust永远不知道特征对象在运行时指向什么类型的值。因此,即使您传递了一个Sink,调用虚方法和检查错误的开销仍然适用。

泛型的第二个优点是并非每个特征都可以支持特征对象。 Traits支持几种功能,例如静态方法,只适用于泛型:它们完全排除特征对象。我们将在他们面前指出这些功能。

定义和实现特征

定义特征很简单。给它命名并列出特征方法的类型签名。如果我们正在编写游戏,我们可能会有这样的特征:

/// A trait for characters, items, and scenery -
/// anything in the game world that's visible on screen.
trait Visible {
 /// Render this object on the given canvas.
 fn draw(&self, canvas: &mut Canvas);
 /// Return true if clicking at (x, y) should
 /// select this object.
 fn hit_test(&self, x: i32, y: i32) -> bool;
}

要实现特征,请使用语法impl TraitName for Type:

impl Visible for Broom {
 fn draw(&self, canvas: &mut Canvas) {
 for y in self.y - self.height - 1 .. self.y {
 canvas.write_at(self.x, y, '|');
 }
 canvas.write_at(self.x, self.y, 'M');
 }
 fn hit_test(&self, x: i32, y: i32) -> bool {
 self.x == x
 && self.y - self.height - 1 <= y
 && y <= self.y
 }
}

请注意,此impl包含每个方法的实现 可见的 trait,没有别的。 trait impl中定义的所有内容实际上必须是 trait 的 trai t;如果我们想添加一个辅助方法来支持Broom :: draw(),我们必须在一个单独的impl块中定义它:

impl Broom {
 /// Helper function used by Broom::draw() below.
 fn broomstick_range(&self) -> Range<i32> {
 self.y - self.height - 1 .. self.y
 }
}
impl Visible for Broom {
 fn draw(&self, canvas: &mut Canvas) {
 for y in self.broomstick_range() {
 ...
 }
 ...
 }
 ...
}

默认方法

我们之前讨论过的Sink编写器类型可以用几行代码实现。首先,我们定义类型:

/// A Writer that ignores whatever data you write to it.
pub struct Sink;

Sink是一个空结构,因为我们不需要在其中存储任何数据。接下来,我们为Sink提供Write特性的实现:

use std::io::{Write, Result};
impl Write for Sink {
 fn write(&mut self, buf: &[u8]) -> Result<usize> {
 // Claim to have successfully written the whole buffer.
 Ok(buf.len())
 }
 fn flush(&mut self) -> Result<()> {
 Ok(())
 }
}

到目前为止,这非常像可见特征。但我们也看到Write trait有一个write_all方法:

out.write_all(b"hello world\n")?;

为什么Rust在不定义此方法的情况下让我们为Sink写入?答案是标准库的Write特性定义包含write_all的默认实现:

trait Write {
 fn write(&mut self, buf: &[u8]) -> Result<usize>;
 fn flush(&mut self) -> Result<()>;
 fn write_all(&mut self, buf: &[u8]) -> Result<()> {
 let mut bytes_written = 0;
 while bytes_written < buf.len() {
 bytes_written += self.write(&buf[bytes_written..])?;
 }
 Ok(())
 }
 ...
}

write和flush方法是每个writer必须实现的基本方法。编写器也可以实现write_all,但如果没有,将使用上面显示的默认实现。

您自己的特征可以包括使用相同语法的默认实现。

标准库中最常用的默认方法是Iterator trait,它有一个必需的方法(.next())和几十个默认方法。

第15章解释了原因。

特征和其他人的类型

Rust允许您在任何类型上实现任何特征,只要在当前包中引入特征或类型即可。

这意味着只要您想要将方法添加到任何类型,您就可以使用特征来执行此操作:

trait IsEmoji {
 fn is_emoji(&self) -> bool;
}
/// Implement IsEmoji for the built-in character type.
impl IsEmoji for char {
 fn is_emoji(&self) -> bool {
 ...
 }
}
assert_eq!('$'.is_emoji(), false);

与任何其他特征方法一样,只有当IsEmoji在范围内时,这个新的is_emoji方法才可见。

此特定特征的唯一目的是向现有类型char添加方法。

这被称为扩展特征。当然,您也可以通过为str {…}编写impl IsEmoji来将此特性添加到类型中,依此类推。

您甚至可以使用通用impl块一次性向一系列类型添加扩展特征。以下扩展特征为所有Rust编写器添加了一个方法:

use std::io::{self, Write};
/// Trait for values to which you can send HTML.
trait WriteHtml {
 fn write_html(&mut self, &HtmlDocument) -> io::Result<()>;
}
/// You can write HTML to any std::io writer.
impl<W: Write> WriteHtml for W {
 fn write_html(&mut self, html: &HtmlDocument) -> io::Result<()> {
 ...
 }
}

用于W的行impl <W:Write> WriteHtml意味着“对于实现Write的每种类型W,这里是W的WriteHtml实现”。

serde库提供了一个很好的例子,说明在标准类型上实现用户定义的特征是多么有用。 serde是一个序列化库。也就是说,您可以使用它将Rust数据结构写入磁盘并在以后重新加载它们。该库定义了一个特性Serialize,它是为库支持的每种数据类型实现的。所以在serde源代码中,有通过所有标准数据结构(如Vec和HashMap)为bool,i8,i16,i32,数组和元组类型实现Serialize的代码。

所有这一切的结果是serde为所有这些类型添加了一个.serialize()方法。它可以像这样使用:

use serde::Serialize;
use serde_json;
pub fn save_configuration(config: &HashMap<String, String>)
 -> std::io::Result<()>
{
 // Create a JSON serializer to write the data to a file.
 let writer = File::create(config_filename())?;
 let mut serializer = serde_json::Serializer::new(writer);
 // The serde `.serialize()` method does the rest.
 config.serialize(&mut serializer)?;
Ok(())
}
 

我们之前说过,当你实现一个特征时,特征或类型必须是当前包中的新特征。这称为一致性规则。它有助于Rust确保特征实现是唯一的。您的代码不能为u8写入Write,因为Write和u8都是在标准库中定义的。如果Rust让crate这样做,那么在不同的crates中可能有多个Write for u8的实现,而Rust没有合理的方法来决定用于给定方法调用的实现。

(C ++有一个类似的唯一性限制:一个定义规则。在典型的C ++方式中,它不是由编译器强制执行的,除非在最简单的情况下,如果你破坏它就会得到未定义的行为。)

Self in Traits

特征可以使用关键字Self作为类型。例如,标准克隆特征看起来像这样(略微简化):

 fn clone(&self) -> Self;
 ...
}

在这里使用Self作为返回类型意味着x.clone()的类型与x的类型相同,无论它是什么。如果x是String,则x.clone()的类型是String-not Clone或任何其他可克隆类型。同样,如果我们定义这个特征:

pub trait Spliceable {
 fn splice(&self, other: &Self) -> Self;
}

有两个实现:

impl Spliceable for CherryTree {
 fn splice(&self, other: &Self) -> Self {
 ...
 }
}
impl Spliceable for Mammoth {
 fn splice(&self, other: &Self) -> Self {
 ...
 }
}

然后在第一个impl中,Self只是CherryTree的别名,而在第二个中,它是Mammoth的别名。这意味着我们可以将两棵樱桃树或两只猛犸象拼接在一起,而不是我们可以创造一个猛犸樱桃混合体。自我的类型和其他类型必须匹配。使用Self类型的特征与特征对象不兼容:

// error: the trait `Spliceable` cannot be made into an object
fn splice_anything(left: &Spliceable, right: &Spliceable) {
 let combo = left.splice(right);
 ...
}

当我们深入研究特征的高级特征时,我们会一次又一次地看到这个原因。 Rust拒绝此代码,因为它无法键入 - 检查调用left.splice(右)。特征对象的重点是直到运行时才知道该类型。如果需要,Rust在编译时无法知道左侧和右侧是否为同一类型。

Trait对象实际上是针对最简单的特性,可以使用Java中的接口或C ++中的抽象基类实现的类型。特征的更高级特征是有用的,但它们不能与特征对象共存,因为对于特征对象,您将丢失Rust需要对类型进行类型检查的类型信息。
现在,如果我们想要基因不可能的剪接,我们本可以设计出一种特质友好的特征:

// error: the trait `Spliceable` cannot be made into an object
fn splice_anything(left: &Spliceable, right: &Spliceable) {
 let combo = left.splice(right);
 ...
}

此特征与特征对象兼容。类型检查对此.splice()方法的调用没有问题,因为只要两个类型都是MegaSpliceable,就不需要参数other的类型来匹配self的类型。

Subtraits

我们可以声明特征是另一个特征的扩展:

/// Someone in the game world, either the player or some other
/// pixie, gargoyle, squirrel, ogre, etc.
trait Creature: Visible {
 fn position(&self) -> (i32, i32);
 fn facing(&self) -> Direction;
 ...
}

短语trait生物:可见意味着所有trait都是可见的。实现Creature的每个类型也必须实现Visible特征:

impl Visible for Broom {
 ...
}
impl Creature for Broom {
 ...
}

我们可以按任意顺序实现这两个特性,但是在没有实现Visible的情况下为类型实现Creature是错误的。

子trait类似于Java或C#中的子接口。它们是一种描述特征的方法,该特征通过更多方法扩展现有特征。在此示例中,与Creatures一起使用的所有代码也可以使用Visible特征中的方法。

静态方法

在大多数面向对象语言中,接口不能包含静态方法或构造函数。但是,Rust特征可以包括静态方法和构造函数,这里是如何:

trait StringSet {
 /// Return a new empty set.
 fn new() -> Self;
 /// Return a set that contains all the strings in `strings`.
 fn from_slice(strings: &[&str]) -> Self;
 /// Find out if this set contains a particular `value`.
 fn contains(&self, string: &str) -> bool;
 /// Add a string to this set.
 fn add(&mut self, string: &str);
}

实现StringSet特征的每个类型都必须实现这四个相关的函数。前两个,new()和from_slice(),不采用自我参数。他们担任建设者。

在非泛型代码中,可以使用:: syntax调用这些函数,就像任何其他静态方法一样:

// Create sets of two hypothetical types that impl StringSet:
let set1 = SortedStringSet::new();
let set2 = HashedStringSet::new();

在通用代码中,它是相同的,除了类型通常是类型变量,如在此处显示的对S :: new()的调用中:

/// Return the set of words in `document` that aren't in `wordlist`.
fn unknown_words<S: StringSet>(document: &Vec<String>, wordlist: &S) -> S {
 let mut unknowns = S::new();
 for word in document {
 if !wordlist.contains(word) {
unknowns.add(word);
 }
 }
 unknowns
}

与Java和C#接口一样,特征对象不支持静态方法。如果要使用&StringSet特征对象,则必须更改特征,将Self:Sized的边界添加到每个静态方法:

trait StringSet {
 fn new() -> Self
 where Self: Sized;
 fn from_slice(strings: &[&str]) -> Self
 where Self: Sized;
 fn contains(&self, string: &str) -> bool;
 fn add(&mut self, string: &str);
}

这个绑定告诉Rust,特征对象可以免于支持这种方法。然后允许StringSet特征对象;它们仍然不支持这两种静态方法,但您可以创建它们并使用它们来调用.contains()和.add()。同样的技巧适用于与特征对象不兼容的任何其他方法。 (我们将放弃对其工作原理进行相当繁琐的技术解释,但第13章将介绍大小特征。)

Fully Qualified Method Calls

方法只是一种特殊的功能。这两个电话是等价的:

"hello".to_string()
str::to_string("hello")

第二种形式看起来与静态方法调用完全相同。即使to_string方法采用self参数,这也可以工作。只需将self作为函数的第一个参数。由于to_string是标准ToString特征的一种方法,因此您可以使用另外两种形式:

ToString::to_string("hello")
<str as ToString>::to_string("hello")

所有这四个方法调用都完全相同。通常,您只需编写value.method()。其他形式是合格的方法调用。它们指定与方法关联的类型或特征。带尖括号的最后一个表单指定了两个:完全限定的方法调用。

当你写“hello”.to_string()时,使用。运算符,你没有确切地说你正在调用哪个to_string方法。 Rust有一个方法查找算法可以根据类型,deref强制等来计算出来。通过完全合格的调用,您可以准确地说出您的意思,在一些奇怪的情况下可以提供帮助:

•当两种方法具有相同名称时。经典的hokey示例是Outlaw,它有两个来自两个不同特征的.draw()方法,一个用于在屏幕上绘制,另一个用于与法律交互:

outlaw.draw(); // error: draw on screen or draw pistol?
Visible::draw(&outlaw); // ok: draw on screen
HasPistol::draw(&outlaw); // ok: corral

通常你最好只重命名其中一种方法,但有时你不能。

•无法推断自参数的类型时:

let zero = 0; // type unspecified; could be `i8`, `u8`, ...
zero.abs(); // error: method `abs` not found
i64::abs(zero); // ok

•将函数本身用作函数值时:

let words: Vec<String> =
 line.split_whitespace() // iterator produces &str values
 .map(<str as ToString>::to_string) // ok
 .collect();

这里完全限定的 :: to_string只是一种命名我们想要传递给.map()的特定函数的方法。

•在宏中调用特征方法时。我们将在第20章解释。

完全限定语法也适用于静态方法。在上一节中,我们编写了S :: new()来在泛型函数中创建一个新集合。我们也可以编写StringSet :: new()或 :: new()。

定义类型之间关系的Traits

到目前为止,我们所看到的每个特征都是独立的:特征是一组类型可以实现的方法。特征也可用于有多种类型必须协同工作的情况。他们可以描述类型之间的关系。

•std :: iter :: Iterator特征将每个迭代器类型与其生成的值类型相关联。

•std :: ops :: Mul特征与可以相乘的类型相关。在表达式a * b中,值a和b可以是相同类型,也可以是不同类型。

•rand crate包括随机数生成器的特征(rand :: Rng)和可以随机生成的类型的特征(rand :: Rand)。特征本身确切地定义了这些类型如何协同工作。

您不需要每天都创建这样的特征,但是您会在整个标准库和第三方包装箱中遇到它们。在本节中,我们将展示如何实现这些示例,并根据需要选择相关的Rust语言功能。这里的关键技能是能够读取特征和方法性质,并弄清楚他们对所涉及的类型的看法。

关联类型(或迭代器如何工作)

我们将从迭代器开始。到目前为止,每种面向对象的语言都对迭代器具有某种内置支持,迭代器代表遍历某些值序列的对象。
Rust有一个标准的Iterator特性,定义如下

pub trait Iterator {
 type Item;
 fn next(&mut self) -> Option<Self::Item>;
 ...
}

此特征的第一个特征,类型Item;,是一个关联类型。实现Iterator的每种类型都必须指定它生成的项目类型。

第二个特性next()方法在其返回值中使用关联的类型。 next()返回一个Option :Some(item),序列中的下一个值,或者没有更多值要访问时的None。该类型被写为Self :: Item,而不仅仅是plain Item,因为Item是每种迭代器的一个特性,而不是一个独立的类型。与往常一样,self和Self类型在代码中明确显示其所使用的字段,方法等。以下是为类型实现Iterator的样子:

// (code from the std::env standard library module)
impl Iterator for Args {
 type Item = String;
 fn next(&mut self) -> Option<String> { }
 ...
}

std :: env :: Args是标准库函数std :: env :: args()返回的迭代器类型,我们在第2章中使用它来访问命令行参数。它产生String值,因此impl声明类型为Item = String;。

通用代码可以使用关联类型:

/// Loop over an iterator, storing the values in a new vector.
fn collect_into_vector<I: Iterator>(iter: I) -> Vec<I::Item> {
 let mut results = Vec::new();
 for value in iter {
 results.push(value);
 }
 results
}

在这个函数的主体内部,Rust为我们推断出了值的类型,这很好;但是我们必须拼出collect_into_vector的返回类型,而Item关联类型是唯一的方法。 (Vec 将是完全错误的:我们将声称返回迭代器的向量!)

前面的示例不是您自己编写的代码,因为在阅读第15章之后,您将知道迭代器已经有一个标准方法来执行此操作:iter.collect()。因此,在继续之前,让我们再看一个例子。

/// Print out all the values produced by an iterator
fn dump<I>(iter: I)
 where I: Iterator
{
 for (index, value) in iter.enumerate() {
 println!("{}: {:?}", index, value); // error
 }
}

这几乎可行。只有一个问题:值可能不是可打印的类型。

error[E0277]: the trait bound `<I as std::iter::Iterator>::Item:
 std::fmt::Debug` is not satisfied
 --> traits_dump.rs:10:37
 |
10 | println!("{}: {:?}", index, value); // error
 | ^^^^^ the trait `std::fmt::Debug`
 | is not implemented for
 | `<I as std::iter::Iterator>::Item`
 |
 = help: consider adding a
 `where <I as std::iter::Iterator>::Item: std::fmt::Debug` bound
 = note: required by `std::fmt::Debug::fmt`

Rust使用语法 :: Item略微混淆了错误消息,这是一种说明I :: Item的长,最明确的方式。这是有效的Rust语法,但您很少需要以这种方式编写类型。

错误消息的要点是要使这个泛型函数编译,我们必须确保I :: Item实现Debug特性,这是用{:?}格式化值的特征。我们可以通过在I :: Item上放置一个绑定来做到这一点:

use std::fmt::Debug;
fn dump<I>(iter: I)
 where I: Iterator, I::Item: Debug
{
 ...
}

或者,我们可以写,“我必须是字符串值的迭代器”:

fn dump<I>(iter: I)
 where I: Iterator<Item=String>
{
 ...
}

Iterator <Item = String>本身就是一个特征。如果您将Iterator视为所有迭代器类型的集合,那么Iterator <Item = String>是Iterator的一个子集:生成字符串的迭代器类型集。可以在任何可以使用特征名称的地方使用此语法,包括特征对象类型:

fn dump(iter: &mut Iterator<Item=String>) {
 for (index, s) in iter.enumerate() {
 println!("{}: {:?}", index, s);
 }
}

具有相关类型的特征(如Iterator)与特征方法兼容,但仅限于拼写出所有关联类型,如此处所示。否则,s的类型可能是任何东西,并且Rust再也无法键入 - 检查此代码。我们已经展示了很多涉及迭代器的例子。很难不;它们是迄今为止关联类型最突出的用途。但是,当特征需要涵盖的不仅仅是方法时,相关类型通常是有用的。

•在线程池库中,表示工作单元的任务特征可以具有关联的输出类型。

•表示搜索字符串的方式的Pattern特征可以具有关联的Match类型,表示通过将模式与字符串匹配而收集的所有信息

trait Pattern {
 type Match;
 fn search(&self, string: &str) -> Option<Self::Match>;
}
/// You can search a string for a particular character.
impl Pattern for char {
 /// A "match" is just the location where the
 /// character was found.
 type Match = usize;
 fn search(&self, string: &str) -> Option<usize> {
 ...
 }
}

如果您熟悉正则表达式,很容易看出impl Pattern for RegExp将如何具有更精细的匹配类型,可能是包含匹配的开始和长度,括号组匹配的位置等的结构,等等。

•用于处理关系数据库的库可能具有数据库连接特征,其中相关类型表示事务,游标,预准备语句等。

关联类型适用于每个实现具有一种特定相关类型的情况:每种类型的任务产生特定类型的输出;每种类型的Pattern都会查找特定类型的Match。但是,正如我们所看到的,类型之间的某些关系并不像这样。

通用特征(或运算符重载如何工作)

Rust中的乘法使用这个特性:

/// std::ops::Mul, the trait for types that support `*`.
pub trait Mul<RHS> {
 /// The resulting type after applying the `*` operator
 type Output;
 /// The method for the `*` operator
 fn mul(self, rhs: RHS) -> Self::Output;
}

Mul是一种通用特征。类型参数RHS是右侧的缩写。

这里的type参数意味着它在结构或函数上的含义相同:

Mul是一个通用特征,它的实例Mul ,Mul ,Mul 等都是不同的特征,就像min 和min :: 是不同的函数而Vec < i32>和Vec 是不同的类型。

单一类型 - 比如WindowSize–可以实现Mul 和Mul 等等。然后,您可以将WindowSize乘以许多其他类型。

每个实现都有自己的关联输出类型。上面显示的特征缺少一个小细节。真正的Mul特征看起来像这样:

pub trait Mul<RHS=Self> {
 ...
}

语法RHS = Self表示RHS默认为Self。如果我为复合物编写impl Mul,而没有指定Mul的类型参数,则意味着impl Mul for Complex。在一个界限中,如果我写T:Mul,那意味着T:Mul 。

在Rust中,表达式lhs * rhs是Mul :: mul(lhs,rhs)的简写。因此,在Rust中重载*运算符就像实现Mul特征一样简单。我们将在下一章中展示示例。

好友特征(或rand :: random()如何工作)

还有一种方法可以使用特征来表达类型之间的关系。这种方式可能是最简单的一种,因为您不必学习任何新的语言功能来理解它:我们称之为伙伴特征的只是旨在协同工作的特征。

在rand crate里面有一个很好的例子,一个用于生成随机数的流行箱子。 rand的主要特性是random()函数,它返回一个随机值:

use rand::random;
let x = random();

如果Rust无法推断随机值的类型(通常是这种情况),则必须指定它:

let x = random::<f64>(); // a number, 0.0 <= x < 1.0
let b = random::<bool>(); // true or false

对于许多程序而言,这一个通用功能就是您所需要的。但是rand crate还提供了几种不同但可互操作的随机数生成器。库中的所有随机数生成器都实现了一个共同的特征:

/// A random number generator.
pub trait Rng {
 fn next_u32(&mut self) -> u32;
 ...
}

Rng只是一个可以根据需要吐出整数的值。 rand库提供了一些不同的实现,包括XorShiftRng(快速伪随机数生成器)和OsRng(慢得多,但真正不可预测,用于加密)。

伙伴特质叫兰德:

/// A type that can be randomly generated using an `Rng`.
pub trait Rand: Sized {
 fn rand<R: Rng>(rng: &mut R) -> Self;
}

像f64和bool这样的类型实现了这个特性。将任意随机数生成器传递给它们的:: rand()方法,并返回一个随机值:

let x = f64::rand(rng);
let b = bool::rand(rng);

实际上,random()只不过是一个瘦包装器,它将全局分配的Rng传递给这个rand方法。实现它的一种方法是这样的:

pub fn random<T: Rand>() -> T {
 T::rand(&mut global_rng())
}

当您看到使用其他特征作为边界的特征时,Rand :: rand()使用Rng的方式,您知道这两个特征是混合匹配:任何Rng都可以生成每个Rand类型的值。由于所涉及的方法是通用的,因此Rust为您的程序实际使用的Rng和Rand的每个组合生成优化的机器代码。

这两个特征也可以解决问题。无论你是为你的Monster类型实现Rand还是实现一个非常快速但不那么随机的Rng,你都不必为这两段代码做一些特殊的工作,如图11所示。 -3。

在这里插入图片描述

标准库对计算哈希码的支持提供了另一个伙伴特征的例子。实现Hash的类型是可清除的,因此它们可以用作哈希表键。实现Hasher的类型是散列算法。这两者是相互关联的

与Rand和Rng一样:Hash有一个泛型方法Hash :: hash(),它接受任何类型的Hasher作为参数。

另一个例子是serde库的序列化特征,你可以在第247页的“特征和其他人的类型”中看到它。它有一个我们没有谈到的伙伴特征:Serializer特征,它代表输出格式。 serde支持可插拔的序列化格式。有JSON,YAML的串行器实现,称为CBOR的二进制格式,等等。由于两个特征之间的密切关系,每种格式都自动支持每种可序列化类型。

在最后三节中,我们展示了三种特征可以描述类型之间关系的方法。所有这些也可以被视为避免虚拟方法开销和向下转换的方法,因为它们允许Rust在编译时知道更多具体类型。

逆向工程界限

当没有单一的特性可以完成你需要的每件事时,编写通用代码可能是一个真正的困难。假设我们编写了这个非泛型函数来进行一些计算:

fn dot(v1: &[i64], v2: &[i64]) -> i64 {
 let mut total = 0;
 for i in 0 .. v1.len() {
 total = total + v1[i] * v2[i];
 }
 total
}

现在我们想要使用具有浮点值的相同代码。我们可能会尝试这样的事情:

fn dot<N>(v1: &[N], v2: &[N]) -> N {
 let mut total: N = 0;
 for i in 0 .. v1.len() {
 total = total + v1[i] * v2[i];
 }
 total
}

没有这样的运气:Rust抱怨使用+和以及类型0.我们可以要求N是一个支持+和的类型,使用Add和Mul特征。但是,我们对0的使用需要改变,因为0在Rust中总是一个整数;相应的浮点值为0.0。幸运的是,对于具有默认值的类型,存在标准的默认特征。对于数字类型,默认值始终为0。

use std::ops::{Add, Mul};
fn dot<N: Add + Mul + Default>(v1: &[N], v2: &[N]) -> N {
 let mut total = N::default();
 for i in 0 .. v1.len() {
 total = total + v1[i] * v2[i];
 }
 total
}

这更接近,但仍然不太有效:

error[E0308]: mismatched types
 --> traits_generic_dot_2.rs:11:25
 |
11 | total = total + v1[i] * v2[i];
 | ^^^^^^^^^^^^^ expected type parameter, found associated type
 |
 = note: expected type `N`
 found type `<N as std::ops::Mul>::Output`

我们的新代码假设将N类型的两个值相乘会产生另一个类型为N的值。这不一定是这种情况。您可以重载乘法运算符以返回您想要的任何类型。我们需要以某种方式告诉Rust这个泛型函数只适用于具有正常乘法风格的类型,其中乘以N * N返回N.我们通过用Mul <Output = N>替换Mul来实现这一点,并且对于Add来说相同:

fn dot<N: Add<Output=N> + Mul<Output=N> + Default>(v1: &[N], v2: &[N]) -> N
{
 ...
}

此时,边界开始堆积,使代码难以阅读。让我们将边界移动到where子句:

fn dot<N>(v1: &[N], v2: &[N]) -> N
 where N: Add<Output=N> + Mul<Output=N> + Default
{
 ...
}

大。但是Rust仍然抱怨这行代码:

error[E0508]: cannot move out of type `[N]`, a non-copy array
 --> traits_generic_dot_3.rs:7:25
 |
7 | total = total + v1[i] * v2[i];
 | ^^^^^ cannot move out of here

这个可能是一个真正的难题,即使现在我们已经熟悉了这个术语。是的,将值v1 [i]移出切片是违法的。但数字是可复制的。所以有什么问题?

答案是Rust不知道v1 [i]是一个数字。实际上,它不是 - 类型N可以是满足我们给出的界限的任何类型。如果我们也希望N是可复制类型,我们必须这样说:

where N: Add<Output=N> + Mul<Output=N> + Default + Copy

有了这个,代码编译并运行。最终代码如下所示:

use std::ops::{Add, Mul};
fn dot<N>(v1: &[N], v2: &[N]) -> N
 where N: Add<Output=N> + Mul<Output=N> + Default + Copy
{
 let mut total = N::default();
 for i in 0 .. v1.len() {
 total = total + v1[i] * v2[i];
 }
 total
}
#[test]
fn test_dot() {
 assert_eq!(dot(&[1, 2, 3, 4], &[1, 1, 1, 1]), 10);
 assert_eq!(dot(&[53.0, 7.0], &[1.0, 5.0]), 88.0);
}

Rust偶尔会发生这种情况:有一段时间与编译器激烈争论,最后代码看起来相当不错,就像编写代码一样轻松,运行得很漂亮。
我们在这里做的是对N的边界进行逆向工程,使用编译器来指导和检查我们的工作。它有点痛苦的原因是标准库中没有一个包含我们想要使用的所有运算符和方法的Number特征。碰巧的是,有一个名为num的流行开源包,它定义了这样的特性!如果我们知道,我们可以为我们的Cargo.toml添加num并写入:

use num::Num;
fn dot<N: Num + Copy>(v1: &[N], v2: &[N]) -> N {
 let mut total = N::zero();
 for i in 0 .. v1.len() {
 total = total + v1[i] * v2[i];
 }
 total
}

就像在面向对象的编程中一样,正确的界面使得一切都很好,在通用编程中,正确的特性使一切变得美观。

结论

特征是Rust的主要组织特征之一,并且有充分的理由。设计一个程序或库没有比一个好的界面更好的了。

本章是语法,规则和解释的暴风雪。现在我们已经奠定了基础,我们可以开始讨论Rust代码中使用traits和泛型的许多方法。事实是,我们只是开始划伤表面。接下来的两章涵盖了标准库提供的共同特征。即将到来的章节涵盖了闭包,迭代器,输入/输出和并发。特征和泛型在所有这些主题中发挥着核心作用。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值