计算机科学家倾向于处理非统一性结构(情形 1、情形 2、情形 3),而数学家则倾向于找一个统一的公理来管理整个体系。 ——高德纳

编程领域的伟大发现之一,就是可以编写能对许多不同类型(甚至是尚未发明的类型)的值进行操作的代码。下面是两个例子。 Vec 是泛型的:你可以创建任意类型值的向量,包括在你的 程序中定义的连 Vec 的作者都不曾设想的类型。 许多类型有 .write() 方法,包括 File 和 TcpStream。你的代码可以通过引用来获取任意写入器,并向它发送数据。你的代码不必关心写入器的类型。以后,如果有人添加了一种新型写入器,你的代码也能够直接支持。 当然,这种能力对 Rust 来说并不是什么新鲜事。这就是所谓的多态性,在 20 世纪 70 年代,它是最新且最热门的编程语言技术。到了现在,多态性实际上已经成了通用技术。Rust 通过两个相关联的特性来支持多态:特型和泛型。许多程序员熟悉这些概念,但 Rust 受到 Haskell 类型类(typeclass)的启发,采用了一种全新的方式。 特型是 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<()> {}
}

此特型还提供了几个方法,我们只展示了前 3 个。 File 和 TcpStream 这两个标准类型以及 Vec 都实现了 std::io::Write。这 3 种类型都提供了 .write()、.flush() 等方法。使用写入器而不关心其具体类型的代码如下所示:

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

out 的类型是 &mut dyn Write,意思是“对实现了 Write 特型的任意值的可变引用”。我们可以将任何此类值的可变引用传给 say_hello:

use std::fs::File;
let mut local_file = File::create("hello.txt")?;
say_hello(&mut local_file)?; // 正常 

let mut bytes = vec![];
say_hello(&mut bytes)?; // 同样正常 assert_eq!(bytes, b"hello world\n");

本章展示特型的用法、工作原理,以及如何定义你自己的特型。其 实,除了前面介绍的用法,特型还有很多其他用法:我们会使用特型为现有类型添加扩展方法,甚至可以在 str、bool 等内置类型上添加;我们会解释为什么向类型添加特型不需要额外的内存,以及如何在不需要虚方法调用开销的情况下使用特型;我们会看到,某些内置特型其实是 Rust 为运算符重载和其他特性提供的语言级钩子;我们会介绍 Self 类型、关联函数和关联类型,这是 Rust 从 Haskell 中借来的三大特性,它们优雅地解决了其他语言中要通过变通和入侵才能解决的问题。 泛型是 Rust 中多态的另一种形式。与 C++ 模板一样,泛型函数或泛型类型可以和不同类型的值一起使用:

/// 给定两个值,找出哪个更小
fn min<T: Ord>(value1: T, value2: T) -> T {
    if value1 <= value2 { value1 } else { value2 }
}

此函数中的 表明 min 函数可以与实现了 Ord 特型的任意类型(任意有序类型)T 的参数一起使用。像这样的要求称为限界,因为它对 T 可能的类型范围做了限制。编译器会针对你实际用到的每种类型 T 生成一份单独的机器码。 泛型和特型紧密相关:泛型函数会在限界中使用特型来阐明它能针对哪些类型的参数进行调用。因此,我们还会讨论 &mut dyn Write 和 的相似之处、不同之处,以及如何在特型的这两种使用方式之间做出选择。

11.1 使用特型

特型是一种语言特性,我们可以说某类型支持或不支持某个特型。大多数情况下,特型代表着一种能力,即一个类型能做什么。 实现了 std::io::Write 的值能写出一些字节。 实现了 std::iter::Iterator 的值能生成一系列值。 实现了 std::clone::Clone 的值能在内存中克隆自身。 实现了 std::fmt::Debug 的值能用带有 {:?} 格式说明符的 println!() 打印出来。 这 4 个特型都是 Rust 标准库的一部分,许多标准类型实现了它们。 std::fs::File 实现了 Write 特型,它能将一些字节写入本地文件。std::net::TcpStream 能写入网络连接。Vec 也实现了 Write,对字节向量的每次 .write() 调用都会将一些数据追加到向量末尾。 Range(表达式 0..10 的类型)实现了 Iterator 特型,一些与切片、哈希表等关联的迭代器类型同样实现了 Iterator 特型。 大多数标准库类型实现了 Clone。没实现 Clone 的主要是一些像 TcpStream 这样的类型,因为它们代表的不仅仅是内存中的数据。 同样,大多数标准库类型支持 Debug。 关于特型方法有一条值得注意的规则:特型本身必须在作用域内。否则,它的所有方法都是不可见的:

let mut buf: Vec<u8> = vec![];
buf.write_all(b"hello")?; // 错误:没有名为`write_all`的方法

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

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

之所以 Rust 会有这条规则,是因为你可以使用特型为任意类型添加新方法——甚至是像 u32 和 str 这样的标准库类型(正如我们将在本章后面看到的那样)。而第三方 crate 也可以这样做。显然,这可能导致命名冲突。但是由于 Rust 会要求你导入自己想用的特型,因此 crate 可以放心地利用这种超能力。只有导入两个特型,才会发生冲突,将具有相同名称的方法添加到同一个类型中。这在实践中非常罕见。(如果确实遇到冲突,你可以使用带完全限定符的方法名明确写出自己想要的内容,本章后面会介绍。) Clone 和 Iterator 的各个方法在没有任何特殊导入的情况下就能工作,因为默认情况下它们始终在作用域中:它们是标准库预导入的一部分,Rust 会把这些名称自动导入每个模块中。事实上,预导入主要就是一些精心挑选的特型。第 13 章会介绍其中的许多内容。 C++ 程序员和 C# 程序员可能已经看出来了,特型方法类似于虚方法。不过,特型方法的调用仍然很快,与任何其他方法调用一样快。一言以蔽之,这里没有多态。很明显,buf 一定是向量,而不可能是文件或网络连接。编译器可以生成对 Vec::write() 的简单调用,甚至可以内联该方法。(C++ 和 C# 通常也会这样做,不过有时会因为子类的缘故而无法做到。)只有通过 &mut dyn Write 调用时才会产生动态派发(也叫虚方法调用)的开销,类型上的 dyn 关键字指出了这一点。dyn Write 叫作特型对象,11.1.1 节会介绍特型对象的一些技术细节,以及它们与泛型函数的比较。

11.1.1 特型对象

在 Rust 中使用特型编写多态代码有两种方法:特型对象和泛型。我们将首先介绍特型对象,然后会在 11.1.2 节转向泛型。 Rust 不允许 dyn Write 类型的变量:

use std::io::Write;

let mut buf: Vec<u8> = vec![];
let writer: dyn Write = buf; // 错误:`Write`的大小不是常量

变量的大小必须是编译期已知的,而那些实现了 Write 的类型可以是任意大小。 如果你是 C# 程序员或 Java 程序员,可能会对此感到惊讶,但原因其实很简单。在 Java 中,OutputStream 类型(类似于 std::io::Write 的 Java 标准接口)的变量其实是对任何实现了 OutputStream 的对象的引用。它本身就是引用,无须显式说明。C# 和大多数其他语言中的接口也一样。 我们在 Rust 中也想这么做,但在 Rust 中,引用是显式的:

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

对特型类型(如 writer)的引用叫作特型对象。与任何其他引用一样,特型对象指向某个值,它具有生命周期,并且可以是可变或共享的。 特型对象的与众不同之处在于,Rust 通常无法在编译期间知道引用目 标的类型。因此,特型对象要包含一些关于引用目标类型的额外信息。这仅供 Rust 自己在幕后使用:当你调用 writer.write(data) 时,Rust 需要使用类型信息来根据 *writer 的具体类型动态调用正确的 write 方法。你不能直接查询这些类型信息,Rust 也不支持从特型对象 &mut dyn Write 向下转型回像 Vec 这样的具体类型。 特型对象的内存布局 在内存中,特型对象是一个胖指针,由指向值的指针和指向表示该值类型的虚表的指针组成。因此,每个特型对象会占用两个机器字,如图 11-1 所示。 C++ 也有这种运行期类型信息,叫作虚表或 vtable。就像在 C++ 中一样,在 Rust 中,虚表只会在编译期生成一次,并由同一类型的所有对象共享。图 11-1 中深色部分展示的所有内容(包括虚表)都是 Rust 的私有实现细节。同样,这些都不是你可以直接访问的字段和数据结构。当你调用特型对象的方法时,该语言会自动使用虚表来确定要调用哪个实现。

image.png

图 11-1:内存中的特型对象 经验丰富的 C++ 程序员可能会注意到 Rust 和 C++ 在内存使用上略有不同。在 C++ 中,虚表指针或 vptr 是作为结构体的一部分存储的,而 Rust 使用的是胖指针方案。结构体本身只包含自己的字段。这样一来,每个结构体就可以实现几十个特型而不必包含几十个 vptr 了。甚至连 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 File,say_hello 的参数类型是 &mut dyn Write。由于 File 也是一种写入器,因此 Rust 允许这样操作,它会自动将普通引用转换为特型对象。 同样,Rust 会愉快地将 Box 转换为 Box,这是一个拥有在堆中分配的写入器的值:

let w: Box<dyn Write> = Box::new(local_file);

和 &mut dyn Write 一样,Box 也是一个胖指针,即包含写入器本身的地址和虚表的地址。其他指针类型(如 Rc)同样如此。 这种转换是创建特型对象的唯一方法。编译器在这里真正做的事非常简单。在发生转换的地方,Rust 知道引用目标的真实类型(在本例中为 File),因此它只要加上适当的虚表的地址,把常规指针变成胖指针就可以了。

11.1.2 泛型函数与类型参数

本章在开头展示过一个以特型对象为参数的 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 dyn Write)     // 普通函数 
 
fn say_hello<W: Write>(out: &mut W)   // 泛型函数

短语 把函数变成了泛型形式。此短语叫作类型参数。这意味着在这个函数的整个函数体中,W 都代表着某种实现了 Write 特型的类型。按照惯例,类型参数通常是单个大写字母。 W 代表哪种类型取决于泛型函数的使用方式:

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

say_hello(&mut bytes)?; // 调用say_hello::<Vec<u8>>

当你将 &mut local_file 传给泛型函数 say_hello() 时,其实调用的是 say_hello::()。Rust 会为此函数生成一份机器码,以调用 File::write_all() 方法和 File::flush() 方法。当你传入 &mut bytes 时,其实是在调用 say_hello:: >()。Rust 会为这个版本的函数生成单独的机器码,以调用相应的 Vec 方法。在这两种情况下,Rust 都会从参数的类型推断出类型 W,这个过程叫作单态化,编译器会自动处理这一切。你总是可以明确写出类型参数:

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

但一般无此必要,因为 Rust 通常可以通过查看参数来推断出类型参数。在这里,say_hello 泛型函数需要一个 &mut W 参数,我们向它传入了一个 &mut File,所以 Rust 断定 W = File。 如果你调用的泛型函数没有任何能提供有用线索的参数,则可能需要把它明确写出来:

// 调用无参数的泛型方法collect<C>()
let v1 = (0..1000).collect(); // 错误:无法推断类型 let v2 = (0 .. 1000).collect::<Vec<i32>>(); // 正确

有时我们需要同一个类型参数的多种能力。如果想打印出向量中前十个 常用的值,那么就要让这些值是可打印的:

use std::fmt::Debug;

fn top_ten<T: Debug>(values: &Vec<T>) {}

但这还不够好。如果我们要确定哪些值是 常用的该怎么办呢?通常 的做法是用这些值作为哈希表中的键。这意味着这些值还要支持 Hash 操作和 Eq 操作。T 的类型限界必须包括这些特型,就像 Debug 一样。这种情况下就要使用 + 号语法:

use std::fmt::Debug;
use std::hash::Hash;

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

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

image.png

图 11-2:把类型集用作特型类型参数也可能完全没有限界,但是如果没有为它指定任何限界,则无法对它做什么。你可以移动它,也可以将它放入一个 Box 或向量中。但也只能做这些了。泛型函数可以有多个类型参数:

/// 在一个大型且分区的数据集上运行查询 
/// 参见<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 子句的 佳方案是保持简单:找到一种无须大量使用泛型就能编写程序的方式。 5.3.2 节中介绍了生命周期参数的语法。泛型函数可以同时具有生命周期参数和类型参数。生命周期参数要排在前面:

/// 返回对`candidates`中 接近`target`的点的引用
fn nearest<'t, 'c, P>(target: &'t P, candidates: &'c [P]) -> &'c P
where
    P: MeasureDistance,
{
}

这个函数有两个参数,即 target 和 candidates。两者都是引用,但我们为它们赋予了不同的生命周期 ’t 和 ‘c(参见 5.3.6 节)。此外,该函数适用于实现了 MeasureDistance 特型的任意类型 P,因此可以在一个程序中将其用于 Point2d 值,而在另一个程序中将其用于 Point3d 值。 生命周期永远不会对机器码产生任何影响。如果对 nearest() 进行的两次调用使用了相同的类型 P 和不同的生命周期,那么就会调用同一个编译结果函数。只有不同的类型才会导致 Rust 编译出泛型函数的多个副本。 除了类型和生命周期,泛型函数也可以接受常量参数,就像 9.9 节中介绍过的 Polynomial 结构体:

fn dot_product<const N: usize>(a: [f64; N], b: [f64; N]) -> f64 {
    let mut sum = 0.;
    for i in 0..N {
        sum += a[i] * b[i];
    }
    sum
}

在这里,短语 指出函数 dot_product 需要一个泛型参数 N,该参数必须是一个 usize。给定了 N,这个函数就会接受两个 [f64; N] 类型的参数,并将其对应元素的乘积相加。N 与普通 usize 参数的区别是,你可以在 dot_product 的签名或函数体的类型中使用它。 与类型参数一样,你既可以显式提供常量参数,也可以让 Rust 推断它们。

// 显式提供`3`作为`N`的值
dot_product::<3>([0.2, 0.4, 0.6], [0., 0., 1.])

// 让Rust推断`N`必然是`2` dot_product([3., 4.], [-5., 1.])

当然,函数并不是 Rust 中唯一的泛型代码。 9.7 节和 10.1.4 节已经介绍过泛型类型。 单独的方法也可以是泛型的,即使它并没有定义在泛型类型上。

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

类型别名也可以是泛型的。

type PancakeResult<T> = Result<T, PancakeError>;

本章在后面还会介绍一些泛型特型。 本节中介绍的特性(限界、where 子句、生命周期参数等)可用于所有泛型语法项,而不仅仅是函数。 11.1.3 使用哪一个 关于是使用特型对象还是泛型代码的选择相当微妙。由于这两个特性都基于特型,因此它们有很多共同点。 当你需要一些混合类型值的集合时,特型对象是正确的选择。制作泛 型沙拉在技术上是可行的:

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

然而,这是一种相当严格的设计:每一样沙拉都要完全由一种蔬菜组 成。但毕竟众口难调。本书的一位作者就曾花 14 美元买过一份 Salad(球形生菜沙拉),那味道真是让人终生难忘。 怎样才能做出更好的沙拉呢?由于 Vegetable 的值可以有各种不同的大小,因此不能要求 Rust 接受 Vec 这种写法:

struct Salad {
    veggies: Vec<dyn Vegetable>, // 错误:`dyn Vegetable`的大小不是常量
}

解决方案是使用特型对象:

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

每个 Box 都可以拥有任意类型的蔬菜,但 Box 本身具有适合存储在向量中的常量大小(两个指针)。虽然把盒子 (Box)放进某个食物里这种隐喻怪怪的,但这正是我们想要的,这种方式同样适用于绘图应用程序中的形状、游戏中的怪物、网络路由器中的可插接路由算法等。 使用特型对象的另一个原因可能是想减少编译后代码的总大小。Rust 可能会不得不多次编译泛型函数,针对用到了它的每种类型各编译一次。而这可能会使二进制文件变大,这种现象在 C++ 圈子里叫作代码膨胀。如今,内存资源充裕,大多数人可以不在乎代码大小,但确实仍然存在着某种受限环境。 除了像制作沙拉这种问题或是低资源环境之类的情况,与特型对象相比,泛型具有 3 个重要优势,因此在 Rust 中,泛型是更常见的选择。 第一个优势是速度。请注意泛型函数签名中缺少 dyn 关键字。因为你要在编译期指定类型,所以无论是显式写出还是通过类型推断,编译器都知道要调用哪个 write 方法。没有使用 dyn 关键字是因为这里不涉及特型对象(因此也不涉及动态派发)。 本章开头展示过的泛型函数 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,调用虚方法和检查错误的开销仍然存在。 泛型的第二个优势在于并不是每个特型都能支持特型对象。特型支持的几个特性(如关联函数)只适用于泛型:它们完全不支持特型对象。我们会在谈及这些特性时指出这一点。泛型的第三个优势是它很容易同时指定具有多个特型的泛型参数限 界,就像我们的 top_ten 函数要求它的 T 参数必须实现 Debug + Hash + Eq 那样。特型对象不能这样做:Rust 不支持像 &mut (dyn Debug + Hash + Eq) 这样的类型。(你可以使用本章稍后定义的子特型来解决这个问题,但有点儿复杂。)

11.2 定义与实现特型

定义特型很简单,给它一个名字并列出特型方法的类型签名即可。如果你正在编写游戏,那么可能会有这样的特型:

/// 角色、道具和风景的特型——游戏世界中可在屏幕上看见的任何东西 trait Visible { 
    /// 在给定的画布上渲染此对象 
    fn draw(&self, canvas: &mut Canvas); 
 
    /// 如果单击(x, y)时应该选中此对象,就返回true 
    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 包含 Visible 特型中每个方法的实现,再无其他。特型的 impl 代码中定义的一切都必须是真正属于此特型的,如果想添加一个辅助方法来支持 Broom::draw(),就必须在单独的 impl 块中定义它:

impl Broom {
    /// 供下面的Broom::draw()使用的辅助函数
    fn broomstick_range(&self) -> Range<i32> {
        self.y - self.height - 1..self.y
    }
}

这些辅助函数可以在特型的各个 impl 块中使用。

impl Visible for Broom {
    fn draw(&self, canvas: &mut Canvas) {
        for y in self.broomstick_range() {}
    }
}

11.2.1 默认方法

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

/// 一个会忽略你写入的任何数据的写入器 pub struct Sink;

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

use std::io::{Result, Write};

impl Write for Sink {
    fn write(&mut self, buf: &[u8]) -> Result<usize> {
        // 声称已成功写入了整个缓冲区
        Ok(buf.len())
    }
    fn flush(&mut self) -> Result<()> {
        Ok(())
    }
}

迄今为止,这种写法和 Visible 特型非常相似。但我们看到 Write 特型中还有一个 write_all 方法:

let mut out = Sink;
out.write_all(b"hello world\n")?;

为什么 Rust 允许在未定义此方法的情况下 impl Write for 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 方法是每个写入器必须实现的基本方法。写入器也可以自行实现 write_all,但如果没实现,就会使用前面展示过的默认实现。 你的自定义特型也可以包含一些使用同样语法的默认实现。 标准库中对默认方法最引人注目的应用场景是 Iterator 特型,它有一个必要方法 (.next()) 和几十个默认方法。第 15 章会解释原因。

11.2.2 特型与其他人的类型

Rust 允许在任意类型上实现任意特型,但特型或类型二者必须至少有一个是在当前 crate 中新建的。这意味着任何时候如果你想为任意类型添加一个方法,都可以使用特型来完成:

trait IsEmoji {
    fn is_emoji(&self) -> bool;
}

/// 为内置的字符类型实现IsEmoji特型
impl IsEmoji for char {
    fn is_emoji(&self) -> bool {}
}
assert_eq!('$'.is_emoji(), false);

与任何其他特型方法一样,只有当 IsEmoji 在作用域内时这个新的 is_emoji 方法才是可见的。 这个特殊特型的唯一目的是向现有类型 char 中添加一个方法。这称为扩展特型。当然,也可以通过 impl IsEmoji for str { … } 等写法将此特型添加到其他类型中。 甚至可以使用一个泛型的 impl 块来一次性向整个类型家族添加扩展特型。这个特型可以在任意类型上实现:

use std::io::{self, Write};

/// 能让你把HTML写入值里的特型
trait WriteHtml {
    fn write_html(&mut self, html: &HtmlDocument) -> io::Result<()>;
}

为所有写入器实现特型会令其成为扩展特型,比如为所有 Rust 写入器添加一个方法:

/// 可以把HTML写入任何一个std::io writer impl<W: Write> WriteHtml for W {     fn write_html(&mut self, html: &HtmlDocument) -> io::Result<()> { 
        ... 
    } 
}

impl WriteHtml for W 这一行的意思是“对于每个实现了 Write 的类型 W,这里有一个适用于 W 的 WriteHtml 实现”。 serde 库提供了一个很好的例子,表明在标准类型上实现用户定义的特型非常有用。serde 是一个序列化库。也就是说,你可以使用 serde 将 Rust 数据结构写入磁盘,稍后再重新加载它们。这个库定义了一个特型 Serialize,它为该库支持的每种数据类型都提供了实现。因此在 serde 源代码中,有一些代码为 bool、i8、i16、 i32、数组类型和元组类型等内置类型,以及像 Vec 和 HashMap 这样的标准数据结构实现了 Serialize。 所有这些工作的成果,就是 serde 为这些类型添加了一个 .serialize() 方法。它可以这样使用:

use serde::Serialize;
use serde_json;

pub fn save_configuration(config: &HashMap<String, String>) -> std::io::Result<()> {
    // 创建一个JSON序列化器以把数据写入文件
    let writer = File::create(config_filename())?;
    let mut serializer = serde_json::Serializer::new(writer);

    // serde的`.serialize()`方法会完成剩下的工作     config.serialize(&mut serializer)?;
    Ok(())
}

我们之前说过,在实现特型时,特型或类型二者必须至少有一个是在当前 crate 中新建的。这叫作孤儿规则。它会帮助 Rust 确保特型的实现是唯一的。你的代码不能写成 impl Write for u8,因为 Write 和 u8 都是在标准库中定义的。如果 Rust 允许 crate 这样做,那么在不同的 crate 中可能会有多个 u8 的 Write 实现,而 Rust 并没有合理的方法来决定把哪个实现用于给定的方法调用。 (C++ 有一个类似的唯一性限制:单一定义规则。在典型的 C++ 流派中,除了一些最简单的情况,编译器不会强制执行此规则。如果你打破了此规则,就会得到未定义行为。)

11.2.3 特型中的 Self

特型可以用关键字 Self 作为类型。例如,标准库的 Clone 特型看起来是这样的(稍作简化):

pub trait Clone {
    fn clone(&self) -> Self;
}

这里以 Self 作为返回类型意味着 x.clone() 的类型与 x 的类型相同,无论 x 是什么。如果 x 是 String,那么 x.clone() 的类型也必须是 String——而不能是 dyn 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 的别名,而在第 二个 impl 中,它是 Mammoth 的别名。这意味着可以将两棵樱桃树或两头猛犸象拼接在一起,但不表示可以创造出猛犸象和樱桃树的混合体。self 的类型和 other 的类型必须匹配。 使用了 Self 类型的特型与特型对象不兼容:

// 错误:特型`Spliceable`不能用作特型对象 
fn splice_anything(left: &dyn Spliceable, right: &dyn Spliceable) 
{ 
    let combo = left.splice(right); 
    // ... }

至于其原因,当我们深入研究特型的高级特性时还会一次又一次看 到。Rust 会拒绝此代码,因为它无法对 left.splice(right) 这个调用进行类型检查。特型对象的全部意义恰恰在于其类型要到运行期才能知道。Rust 在编译期无从了解 left 和 right 是否为同一类型。 特型对象实际上是为最简单的特型类型而设计的,这些类型都可以使用 Java 中的接口或 C++ 中的抽象基类来实现。特型的高级特性很有用,但它们不能与特型对象共存,因为一旦有了特型对象,就会失去 Rust 对你的程序进行类型检查时所必需的类型信息。 现在,如果想要实现这种在遗传意义上不可能的拼接,可以设计一个对特型对象友好的特型:

pub trait MegaSpliceable {
    fn splice(&self, other: &dyn MegaSpliceable) -> Box<dyn MegaSpliceable>;
}

此特型与特型对象兼容。对 .splice() 方法的调用可以通过类型检查,因为参数 other 的类型不需要匹配 self 的类型,只要这两种类型都是 MegaSpliceable 就可以了。

11.2.4 子特型

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

/// 游戏世界中的生物,既可以是玩家,也可以是
/// 其他小精灵、石像鬼、松鼠、食人魔等
trait Creature: Visible {
    fn position(&self) -> (i32, i32);
    fn facing(&self) -> Direction;
}

短语 trait Creature : Visible 表示所有生物都是可见的。每个实现了 Creature 的类型也必须实现 Visible 特型:

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

可以按任意顺序实现这两个特型,但是如果不为类型实现 Visible 只为其实现 Creature 则是错误的。在这里,我们说 Creature 是 Visible 的子特型,而 Visible 是 Creature 的超特型。 子特型与 Java 或 C# 中的子接口类似,因为用户可以假设实现了子特型的任何值也会实现其超特型。但是在 Rust 中,子特型不会继承其超特型的关联项,如果你想调用超特型的方法,那么仍然要保证每个特型都在作用域内。 事实上,Rust 的子特型只是对 Self 类型限界的简写。像下面这样的 Creature 定义与前面的定义完全等效。

trait Creature
where
    Self: Visible,
{
}

11.2.5 类型关联函数

在大多数面向对象语言中,接口不能包含静态方法或构造函数,但特型可以包含类型关联函数,这是 Rust 对静态方法的模拟:

trait StringSet {
    /// 返回一个新建的空集合
    fn new() -> Self;

    /// 返回一个包含`strings`中所有字符串的集合
    fn from_slice(strings: &[&str]) -> Self;

    /// 判断这个集合中是否包含特定的`string`
    fn contains(&self, string: &str) -> bool;

    /// 把一个字符串添加到此集合中
    fn add(&mut self, string: &str);
}

每个实现了 StringSet 特型的类型都必须实现这 4 个关联函数。前两个函数,即 new() 和 from_slice(),不接受 self 参数。它们扮演着构造函数的角色。在非泛型代码中,可以使用 :: 语法调用这些函数,就像调用任何其他类型关联函数一样:

// 创建实现了StringSet的两个假想集合类型:
let set1 = SortedStringSet::new();
let set2 = HashedStringSet::new();

在泛型代码中,也可以使用 :: 语法,不过其类型部分通常是类型变量,如下面对 S::new() 的调用所示:

/// 返回`document`中不存在于`wordlist`中的单词集合
fn unknown_words<S: StringSet>(document: &[String], wordlist: &S) -> S {
    let mut unknowns = S::new();
    for word in document {
        if !wordlist.contains(word) {
            unknowns.add(word);
        }
    }
    unknowns
}

与 Java 接口和 C# 接口一样,特型对象也不支持类型关联函数。如果想使用 &dyn StringSet 特型对象,就必须修改此特型,为每个未通过引用接受 self 参数的关联函数加上类型限界 where 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,特型对象不需要支持特定的关联函数1。通过添加这些限界,就能把 StringSet 作为特型对象使用了。虽然特型对象仍不支持关联函数 new 或 from_slice,但你还是可以创建它们 并用其调用 .contains() 和 .add()。同样的技巧也适用于其他与特型对象不兼容的方法。(我们暂且放弃对“为何这么改就行”的枯燥技术解释,但会在第 13 章介绍一下 Sized 特型,届时你就懂了。)

1

指带有 :Sized 限界的 new 和 from_slice 这两个关联函数。——译者注

11.3 完全限定的方法调用

迄今为止,我们看到的所有调用特型方法的方式都依赖于 Rust 为你补齐了一些缺失的部分。假设你编写了以下内容:

"hello".to_string()

Rust 知道 to_string 指的是 ToString 特型的 to_string 方法(我们称之为 str 类型的实现)。所以这个游戏里有 4 个“玩家”:特型、特型的方法、方法的实现以及调用该实现时传入的值。很高兴我们不必在每次调用方法时都把它们完全写出来。但在某些情况下,你需要一种方式来准确表达你的意思。完全限定的方法调用符合此要求。 首先,要知道方法只是一种特殊的函数。下面两个调用是等效的:

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

第二种形式看起来很像关联函数调用。尽管 to_string 方法需要一 个 self 参数,但是仍然可以像关联函数一样调用。只需将 self 作为此函数的第一个参数传进去即可。 由于 to_string 是标准 ToString 特型的方法之一,因此你还可以使用另外两种形式:

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

所有这 4 种方法调用都会做同样的事情。大多数情况下,只要写 value.method() 就可以了。其他形式都是限定方法调用。它们要指定方法所关联的类型或特型。最后一种带有尖括号的形式,同时指定了两者,这就是完全限定的方法调用。 当你写下 “hello”.to_string() 时,使用的是 . 运算符,你并没有确切说明要调用哪个 to_string 方法。Rust 有一个“方法查找”算法,它可以根据类型、隐式解引用等来解决这个问题。通过完全限定的调用,你可以准确地指出是哪一个方法,这在一些奇怪的情况下会有所帮助。 当两个方法具有相同的名称时。生拼硬凑的经典示例是 Outlaw (亡命之徒),它具有来自不同特型的两个 .draw() 方法,一个用于将其绘制在屏幕上,另一个用于犯罪。

outlaw.draw(); // 错误:画(draw)在屏幕上还是拔出(draw)手枪? 

Visible::draw(&outlaw); // 正确:画在屏幕上 HasPistol::draw(&outlaw);  // 正确:拔出手枪

通常你可以对其中一个方法改名,但有时实在没法改。 当无法推断 self 参数的类型时。

let zero = 0; // 类型未指定:可能为`i8`、`u8`…… 

zero.abs(); // 错误:无法在有歧义的数值类型上调用方法`abs` 
i64::abs(zero); // 正确

将函数本身用作函数类型的值时。

let words: Vec<String> = line
    .split_whitespace() // 迭代器生成&str值
    .map(ToString::to_string) // 正确
    .collect();

在宏中调用特型方法时。第 21 章会对此进行解释。完全限定语法也适用于关联函数。在 11.2.5 节中,我们编写了 S::new() 以在泛型函数中创建一个新集合。也可以写成 StringSet::new() 或 <S as StringSet>::new()

11.4 定义类型之间关系的特型

迄今为止,我们看到的每个特型都是独立的:特型是类型可以实现的一组方法。特型也可以用于多种类型必须协同工作的场景中。它们可以描述多个类型之间的关系。 std::iter::Iterator 特型会为每个迭代器类型与其生成的值的类型建立联系。 std::ops::Mul 特型与可以相乘的类型有关。在表达式 a * b 中,值 a 和 b 可以是相同类型,也可以是不同类型。 rand crate 中包含随机数生成器的特型(rand::Rng)和可被随机生成的类型的特型(rand::Distribution)。这些特型本身就准确地定义了它们是如何协同工作的。 你不需要每天都创建这样的特型,但它们在整个标准库和第三方 crate 中随处可见。本节将展示每一个示例是如何实现的,并根据需要来展开讲解相关的 Rust 语言特性。你需要掌握的关键技能是能够阅读这些特型和方法签名,并理解它们对所涉及的类型意味着什么。

11.4.1 关联类型(或迭代器的工作原理)

接下来我们从迭代器讲起。迄今为止,每种面向对象的语言都内置了某种对迭代器的支持,迭代器是用以遍历某种值序列的对象。 Rust 有一个标准的 Iterator 特型,定义如下:

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

这个特型的第一个特性(type Item;)是一个关联类型。实现了 Iterator 的每种类型都必须指定它所生成的条目的类型。第二个特性(next() 方法)在其返回值中使用了关联类型。 next() 会返回一个 OptionSelf::Item:或者是序列中的下一个值 Some(item),或者当没有更多值可供访问时返回 None。该类 型要写成 Self::Item,而不仅仅是无修饰的 Item,因为这里的 Item 是每个迭代器类型下的一个特性,而不是一个独立的类型。同样,self 和 Self 类型在代码中任何使用了其字段、方法等的地方都要像这样显式写出来。 下面是为一个类型实现 Iterator 的范例:

//(来自标准库中std::env模块的代码)
impl Iterator for Args {
    type Item = String;

    fn next(&mut self) -> Option<String> {}
}

std::env::Args 是我们在第 2 章中用来访问命令行参数的标准库函数 std::env::args() 返回的迭代器类型。它能生成 String 值,因此这个 impl 声明了 type Item = String;。 泛型代码可以使用关联类型:

/// 遍历迭代器,将值存储在新向量中
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 为我们推断出了 value 的类型,这固然不错,但我们还必须明确写出 collect_into_vector 的返回类型,而 Item 关联类型是唯一的途径。(用 Vec 肯定不对,因为那样是在宣告要返回一个由迭代器组成的向量。)前面示例中的代码并不需要你自己编写,因为在阅读第 15 章之后, 你会知道迭代器已经有了一个执行此操作的标准方法: iter.collect()。在继续之前,再来看一个例子:

/// 打印出迭代器生成的所有值
fn dump<I>(iter: I)
where
    I: Iterator,
{
    for (index, value) in iter.enumerate() {
        println!("{}: {:?}", index, value); // 错误 
    }
}

这几乎已经改好了。但还有一个问题:value 不一定是可打印的类型。

error: `<I as Iterator>::Item` doesn't implement `Debug` 
  | 
8 |         println!("{}: {:?}", index, value);   // 错误 
  |                                     ^^^^^ 
  |                          `<I as Iterator>::Item` cannot be formatted 
  |                          using `{:?}` because it doesn't implement `Debug` 
  | 
  = help: the trait `Debug` is not implemented for `<I as 
Iterator>::Item` 
  = note: required by `std::fmt::Debug::fmt` help: consider further restricting the associated type 
  | 
5 |     where I: Iterator, <I as Iterator>::Item: Debug   |                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

由于 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,
{
}

或者,我们可以说“I 必须是针对 String 值的迭代器”:

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

Iterator 本身就是一个特型。如果将 Iterator 视为所有迭代器类型的全集,那么 Iterator 就 是 Iterator 的子集:生成 String 的迭代器类型的集合。此语法可用于任何可以使用特型名称(包括特型对象类型)的地方:

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

具有关联类型的特型(如 Iterator)与特型对象是兼容的,但前提是要把所有关联类型都明确写出来,就像此处所展示的那样。否则, s 的类型可能是任意类型,同样,这会导致 Rust 无法对这段代码进行类型检查。 我们已经展示了很多涉及迭代器的示例。之所以花了这么多篇幅,是因为迭代器是迄今为止使用关联类型的 主要场景。但当特型需要包含的不仅仅是方法的时候,关联类型会很有用。 在线程池库中,Task 特型表示一个工作单元,它可以包含一个关联的 Output 类型。 Pattern 特型表示一种搜索字符串的方式,它可以包含一个关联的 Match 类型,后者表示将模式与字符串匹配后收集到的所有信息。

trait Pattern {
    type Match;

    fn search(&self, string: &str) -> Option<Self::Match>;
}

/// 你可以在字符串中找一个特定的字符
impl Pattern for char {
    /// Match只是找到的字符的位置
    type Match = usize;

    fn search(&self, string: &str) -> Option<usize> {}
}

如果你熟悉正则表达式,那么很容易看出 impl Pattern for RegExp 应该有一个更精细的 Match 类型,它可能是一个结构体,其中包含此匹配的开始位置和长度、圆括号组匹配的位置等。

用于处理关系型数据库的库可能具有 DatabaseConnection 特型,其关联类型表示事务、游标、已准备语句等。 关联类型非常适合每个实现都有一个特定相关类型的情况:每种类型的 Task 都会生成特定类型的 Output,每种类型的 Pattern 都 会寻找特定类型的 Match。然而,如你所见,类型之间的某些关系并不是这样的。

11.4.2 泛型特型(或运算符重载的工作原理)

Rust 中的乘法使用了以下特型:

/// std::ops::Mul,用于标记支持`*`(乘号)的类型的特型
pub trait Mul<RHS> {
    /// 在应用了`*`运算符后的结果类型
    type Output;

    /// 实现`*`运算符的方法
    fn mul(self, rhs: RHS) -> Self::Output;
}

Mul 是一个泛型特型。类型参数 RHS 是右操作数(right-hand side)的缩写。 这里的类型参数与它在结构体或函数上的含义是一样的:Mul 是一个 泛型特型,它的实例 Mul、Mul、Mul 等都是不同的特型,就像 min:: 和 min:: 是不同的函数,Vec 和 Vec 是不同的类型一样。 单一类型(如 WindowSize)可以同时实现 Mul、 Mul,等等。然后你就可以将 WindowSize 乘以许多其他类型。每个实现都有自己关联的 Output 类型。 泛型特型在涉及孤儿规则时会得到特殊豁免:你可以为外部类型实现外部特型,只要特型的类型参数之一是当前 crate 中定义的类型即可。因此,如果你自己定义了 WindowSize,则可以为 f64 实现 Mul,即使你既没有定义 Mul 也没有定义 f64。这些实现甚至可以是泛型的,比如 impl Mul for Vec。这是可行的,因为其他 crate 不可能在任何东西上定义 Mul,因此实现之间不可能出现冲突。(11.2.2 节介绍过孤儿规则。)这就是像 nalgebra 这样的 crate 能为向量定义算术运算的原理。 前面展示的特型缺少一个小细节。真正的 Mul 特型是这样的:

pub trait Mul<RHS = Self> {}

语法 RHS=Self 表示 RHS 默认为 Self。如果我写下 impl Mul for Complex,而不指定 Mul 的类型参数,则表示 impl Mul for Complex。在类型限界中,如果我写下 where T: Mul,则表示 where T: Mul。 在 Rust 中,表达式 lhs * rhs 是 Mul::mul(lhs, rhs) 的简写形式。所以在 Rust 中重载 * 运算符就像实现 Mul 特型一样简单。第 12 章会展示相关示例。

11.4.3 impl Trait

如你所料,由许多泛型类型组合而成的结果可能会极其凌乱。例如,使用标准库的组合器组合上几个迭代器,就会迅速把你的返回类型变成一个“丑八怪”:

use std::iter;
use std::vec::IntoIter;
fn cyclical_zip(
    v: Vec<u8>,
    u: Vec<u8>,
) -> iter::Cycle<iter::Chain<IntoIter<u8>, IntoIter<u8>>> {
    v.into_iter().chain(u.into_iter()).cycle()
}

我们可以很容易地用特型对象替换这个“丑陋的”返回类型:

fn cyclical_zip(v: Vec<u8>, u: Vec<u8>) -> Box<dyn Iterator<Item = u8>> {
    Box::new(v.into_iter().chain(u.into_iter()).cycle())
}

然而,在大多数情况下,如果仅仅是为了避免“丑陋的”类型签名,就要在每次调用这个函数时承受动态派发和不可避免的堆分配开销,可不太划算。 Rust 有一个名为 impl Trait 的特性,该特性正是为应对这种情况而设计的。impl Trait 允许我们“擦除”返回值的类型,仅指定它实现的一个或多个特型,而无须进行动态派发或堆分配:

fn cyclical_zip(v: Vec<u8>, u: Vec<u8>) -> impl Iterator<Item = u8> {
    v.into_iter().chain(u.into_iter()).cycle()
}

现在,cyclical_zip 的签名中再也没有那种带着迭代器组合结构的嵌套类型了,而只是声明它会返回某种 u8 迭代器。返回类型表达了函数的意图,而非实现细节。 这无疑清理了代码并提高了可读性,但 impl Trait 可不止是一个方便的简写形式。使用 impl Trait 意味着你将来可以更改返回的实际类型,只要返回类型仍然会实现 Iterator,调用该函数的任何代码就能继续编译而不会出现问题。这就为库作者提供了很大的灵活性,因为其类型签名中只编码了有意义的功能。 如果库的第一个版本像前面那样使用迭代器的组合器,但后来发现了针对同一过程的更好算法,则库作者可能会改用不同的组合器,甚至会创建一个能实现 Iterator 的自定义类型,但只要当初使用了 impl Trait 来编写签名,库的用户根本不必更改代码就能获得性能改进。 使用 impl Trait 来为面向对象语言中常用的工厂模式模仿出一个静态派发版本可能是个诱人的想法。例如,你可能会想定义如下特型:

trait Shape {
    fn new() -> Self;
    fn area(&self) -> f64;
}

在为几种类型实现了 Shape 之后,你可能希望根据某个运行期的值(比如用户输入的字符串)使用不同的 Shape。但以 impl Shape 作为返回类型并不能实现这一目标:

fn make_shape(shape: &str) -> impl Shape {
    match shape {
        "circle" => Circle::new(),
        "triangle" => Triangle::new(), // 错误:不兼容的类型
        "shape" => Rectangle::new(),
    }
}

从调用者的角度来看,这样的函数没有多大意义。impl Trait 是一种静态派发形式,因此编译器必须在编译期就知道从函数返回的类型,以便在栈上分配正确的空间数量并正确访问该类型的字段和方法。在这里,这个类型可能是 Circle、Triangle 或 Rectangle,它们可能占用不同的空间大小,并且有着不同的 area() 实现。 这里的要点是,Rust 不允许特型方法使用 impl Trait 作为返回值。要支持这个特性,就要对语言的类型系统进行一些改进。在这项工作完成之前,只有自由函数和关联具体类型的函数才能使用 impl Trait 作为返回值。 impl Trait 也可以用在带有泛型参数的函数中。例如,考虑下面这个简单的泛型函数:

fn print<T: Display>(val: T) {
    println!("{}", val);
}

它与使用 impl Trait 的版本完全相同:

fn print(val: impl Display) {
    println!("{}", val);
}

但有一个重要的例外。使用泛型时允许函数的调用者指定泛型参数的类型,比如 print::(42),而如果使用 impl Trait 则不能这样做。 每个 impl Trait 参数都被赋予了自己独有的匿名类型参数,因此,只有在 简单的泛型函数中才能把 impl Trait 参数用作类型,参数的类型之间不能存在关系。

11.4.4 关联常量

与结构体和枚举一样,特型也可以有关联常量。你可以使用与结构体或枚举相同的语法来声明带有关联常量的特型:

trait Greet {
    const GREETING: &'static str = "Hello";
    fn greet(&self) -> String;
}

不过,关联常量在特型中具有特殊的功能。与关联类型和函数一样,你也可以声明它们,但不为其定义值:

trait Float {
    const ZERO: Self;
    const ONE: Self;
}

之后,特型的实现者可以定义这些值:

impl Float for f32 {
    const ZERO: f32 = 0.0;
    const ONE: f32 = 1.0;
}
impl Float for f64 {
    const ZERO: f64 = 0.0;
    const ONE: f64 = 1.0;
}

你可以编写使用这些值的泛型代码:

fn add_one<T: Float + Add<Output = T>>(value: T) -> T {
    value + T::ONE
}

请注意,关联常量不能与特型对象一起使用,因为为了在编译期选择正确的值,编译器会依赖相关实现的类型信息。即使是没有任何行为的简单特型(如 Float),也可以提供有关类型的足够信息,再结合一些运算符,以实现像斐波那契数列这样常见的数学函数:

fn fib<T: Float + Add<Output = T>>(n: usize) -> T {
    match n {
        0 => T::ZERO,
        1 => T::ONE,
        n => fib::<T>(n - 1) + fib::<T>(n - 2),
    }
}

在 11.5 节和 11.6 节中,我们将展示用特型描述类型之间关系的不同方式。所有这些都可以看作避免虚方法开销和向下转换的方法,因为它们允许 Rust 在编译期了解更多的具体类型。

11.5 逆向工程求限界

当没有特型可以满足你的所有需求时,编写泛型代码可能会是一件真正的苦差事。假设我们编写了下面这个非泛型函数来进行一些计算:

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 的类型有问题。 我们可以使用 Add 和 Mul 的特型要求 N 是支持 + 和 * 的类型。但是,对 0 的用法需要改变,因为 0 在 Rust 中始终是一个整数,对应的浮点值为 0.0。幸运的是,对于具有默认值的类型,有一个标准的 Default 特型。对于数值类型,默认值始终为 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: mismatched types 
  | 
5 | fn dot<N: Add + Mul + Default>(v1: &[N], v2: &[N]) -> N { 
  |        - this type parameter 
... 
8 |         total = total + v1[i] * v2[i]; 
  |                         ^^^^^^^^^^^^^ expected type parameter `N`, 
  |                                       found associated type   | 
  = note: expected type parameter `N` 
            found associated type `<N as Mul>::Output` help: consider further restricting this bound 
  | 
5 | fn dot<N: Add + Mul + Default + Mul<Output = N>>(v1: &[N], v2: 
&[N]) -> N { 
  |                               ^^^^^^^^^^^^^^^^^

我们的新代码中假定将两个 N 类型的值相乘会生成另一个 N 类型的值。但事实并非如此。你可以重载乘法运算符以返回想要的任意类型。我们需要以某种方式让 Rust 知道这个泛型函数只适用于那些支持正常乘法规范的类型,其中 N * N 一定会返回 N。错误消息中的建议几乎是正确的:我们可以通过将 Mul 替换为 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: cannot move out of type `[N]`, a non-copy slice 
  | 
8 |         total = total + v1[i] * v2[i]; 
  |                         ^^^^^ 
  |                         | 
  |                         cannot move out of here 
  |                         move occurs because `v1[_]` has type `N`, 
  |                         which does not implement the `Copy` trait

由于我们没有要求 N 是可复制的类型,因此 Rust 将 v1[i] 解释为试图将值从切片中移动出去(Rust 中禁止这样做)。但是我们根本就没想修改切片,只是想将这些值复制出来以便对它们进行操作。幸运的是,Rust 的所有内置数值类型都实现了 Copy,因此可以简单地将其添加到对 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 的流行开源 crate 定义了这样的一个特型。如 果我们能提前知道,就可以将 num 添加到 Cargo.toml 中并这样写:

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 的设计者不让泛型更像 C++ 模板中的“鸭子类型”那样在代码中隐含约束呢? Rust 的这种方式的一个优点是泛型代码的前向兼容性。你可以更改公共泛型函数或方法的实现,只要没有更改签名,对它的用户就没有任何影响。 类型限界的另一个优点是,当遇到编译器错误时,至少编译器可以告诉你问题出在哪里。涉及模板的 C++ 编译器错误消息可能比 Rust 的错误消息要长得多,并且会指向许多不同的代码行,因为编译器无法判断谁应该为此问题负责:是模板,还是其调用者?而调用者也可能是模板,或者调用者模板的模板…… 也许明确写出限界的 重要的优点是限界就这么写在代码和文档中。你可以查看 Rust 中泛型函数的签名,并准确了解它能接受的参数类型。而使用模板则做不到这些。在像 Boost 这样的 C++ 库中完整记录参数类型的工作比我们在这里经历的还要艰巨得多。Boost 开发人员可没有能帮他们检查工作成果的编译器。

11.6 以特型为基础

特型成为 Rust 中最主要的组织特性之一是有充分理由的,因为良好的接口在设计程序或库时尤为重要。 本章是关于语法、规则和解释的“狂风骤雨”。现在我们已经奠定了基础,可以开始讨论在 Rust 代码中使用特型和泛型的多种方式了。事实上,我们才刚刚开始入门。接下来的第 12 章和第 13 章将介绍标准库提供的公共特型。之后的各章将涵盖闭包、迭代器、输入 /输出和并发。特型和泛型在所有这些主题中都扮演着核心角色。