第二章 类型
现在基础知识已经讲完了,我们来看看 Rust 的类型系统。我们将跳过《Rust 编程语言》中涉及的基础知识,转而深入研究不同类型在内存中的布局、特质和特质约束的来龙去脉、存在性类型以及跨箱体边界使用类型的规则。
内存中的类型
每个 Rust 值都有一个类型。在 Rust 中,类型有很多作用,我们将在本章中看到,但它们最基本的作用之一是告诉你如何解释内存的位。例如,0b10111101(用十六进制符号写成 0xBD)的比特序列本身并不意味着什么,直到你给它指定一个类型。 当解释为 u8 类型时,这个比特序列就是数字 189。当在 i8 类型下解释时,它是-67。当你定义自己的类型时,编译器的工作是确定定义类型的每个部分在该类型的内存表示中的位置。你的结构体的每个字段在比特序列中出现在哪里?你的枚举的判别式存储在哪里?当你开始编写更高级的 Rust 代码时,了解这个过程是很重要的,因为这些细节会影响你的代码的正确性和性能。
对齐
在我们讨论如何确定一个类型的内存表示之前,我们首先需要讨论对齐的概念,它决定了一个类型的字节可以存储在哪里。一旦一个类型的表示被确定,你可能会认为你可以在任何一个任意的内存位置,把存储在那里的字节解释为该类型。虽然在理论上是这样的,但在实践中,硬件也限制了一个特定类型的位置。这方面最明显的例子是,指针指向字节 (bytes),而不是比特 (bits)。如果你把一个 T 类型的值放在计算机内存的第 4 位开始,你将没有办法参考它的位置;你可以创建一个指针,只指向字节 0 或字节 1(第 8 位)。由于这个原因,所有的值,无论其类型如何,都必须从一个字节边界开始。我们说,所有的值都必须至少是字节对齐的--它们必须被放在一个 8 位 (bits) 的倍数的地址上。
有些数值有更严格的对齐规则,而不仅仅是按字节对齐。在 CPU 和内存系统中,内存经常以大于一个字节的块进行访问。例如,在一个 64 位的 CPU 上,大多数数值都是以 8 个字节(64 位)为单位进行访问的,每个操作都是从一个 8 字节对齐的地址开始。这被称为 CPU 的字大小。然后,CPU 使用一些聪明的方法来处理较小的值,或跨越这些块边界的值的读写。
在可能的情况下,你要确保硬件能够以其 "原生" 对齐方式运行。要知道为什么,考虑一下如果你试图读取一个从 8 字节块中间开始的 i64(也就是说,它的指针不是 8 字节对齐的)会发生什么。硬件将不得不进行两次读取--一次是从第一块的后半部分读取 i64 的开始,另一次是从第二块的前半部分读取 i64 的其余部分,然后将结果拼接在一起。这不是很有效率。由于该操作分散在对底层内存的多次访问中,如果你正在读取的内存被不同的线程同时写入,你也可能会得到奇怪的结果。你可能会在其他线程的写操作发生之前读到前 4 个字节,而在之后读到后 4 个字节,从而导致一个损坏的值。
对未对齐的数据进行的操作被称为错位访问,会导致糟糕的性能和糟糕的并发性问题。出于这个原因,许多 CPU 操作要求或强烈希望其参数是自然对齐的。一个自然对齐的值是指其对齐方式与它的大小相匹配。因此,例如,对于一个 8 字节的负载,提供的地址需要 8 字节对齐。
由于对齐的访问通常更快,并提供更强的一致性语义,编译器试图尽可能地利用它们。它通过给每个类型一个基于它所包含的类型计算出来的对齐方式来实现这一点。内置值通常是根据其大小来对齐的,所以 u8 是字节对齐的,u16 是 2 字节对齐的,u32 是 4 字节对齐的,u64 是 8 字节对齐的。复杂类型--包含其他类型的类型--通常被分配为它们所包含的任何类型的最大对齐方式。例如,一个包含 u8、u16 和 u32 的类型会因为 u32 而被 4 字节对齐。
布局
既然您了解了对齐,我们就可以研究编译器如何决定类型的内存表示,即布局。默认情况下,您很快就会看到,Rust 编译器对如何布局类型提供了很少的保证,这是理解基本原理的一个很差的起点。幸运的是,Rust 提供了一个 repr 属性,您可以将其添加到类型定义中,以请求该类型在内存中的特定表示。最常见的是 repr(C)。顾名思义,它布局类型的方式与 C 或 C++ 编译器布局相同类型的方式兼容。这对于编写使用外部函数接口(我们将在第 11 章中讨论)与其他语言进行接口的 Rust 代码很有帮助,因为 Rust 将生成与其他语言编译器的期望相匹配的布局。由于 C 布局是可预测的,不受更改的影响,因此 repr(C) 在不安全的上下文中也很有用,如果您正在使用到类型的原始指针,或者您需要在两个具有相同字段的不同类型之间进行强制转换。当然,这对于我们开始布局算法的第一步来说是完美的。
注意:另一个有用的表示是 repr(transparent),它只能用于具有单个字段的类型,它保证了外部类型的布局与内部类型的布局完全相同。这在与 newtype 模式结合使用时很方便,在 newtype 模式中,您可能想对某个结构 A 和结构 NewA(A) 的内存表示进行操作,就好像它们是相同的一样。如果没有 repr(transparent),Rust 编译器就不能保证它们拥有相同的布局。
那么,让我们看看编译器会如何用 repr(C) 来布局一个特定的类型:清单 2-1 中的 Foo 类型。你认为编译器会如何在内存中布局这个类型?
#![allow(unused)] fn main() { #[repr(C)] struct Foo { tiny: bool, normal: u32, small: u8, long: u64, short: u16, } // 清单 2-1: 对齐影响布局。 }
首先,编译器会看到 tiny 字段,它的逻辑大小是 1 位 (true 或 false)。但由于 CPU 和内存以字节为单位进行操作,因此在内存表示中,tiny 是 1 个字节。接下来,normal 是一个 4 字节类型,所以我们希望它是 4 字节对齐的。但是即使 Foo 是对齐的,我们分配给 tiny 的 1 个字节将会使 normal 错过它的对齐。为了纠正这个问题,编译器插入 3 个字节的填充字节,这些字节的不确定值在用户代码中被忽略,并插入到内存中 tiny 和 normal 之间的表示中。没有值进入填充,但它确实占用空间。
对于下一个字段 small,对齐方式很简单:它是一个 1 字节的值,当前在结构中的字节偏移量是 1+3+4=8。 这已经是字节对齐的,所以 small 可以紧随普通字段。但对于 long,我们又遇到了一个问题。我们现在是 1+3+4+1=9 字节进入 Foo。如果 Foo 是对齐的,那么 long 就不是我们想要的 8 字节对齐,所以我们必须再插入 7 字节的填充来使 long 再次对齐。这也方便了我们确保最后一个字段 short 所需的 2 字节对齐,使总数达到 26 字节。现在我们已经浏览了所有的字段,我们还需要确定 Foo 本身的对齐方式。这里的规则是使用 Foo 任何一个字段的最大对齐方式,由于 long 的原因,它将是 8 字节。因此,为了确保 Foo 在放入数组时保持对齐,编译器添加了最后 6 个字节的填充,使 Foo 的大小是其 32 字节对齐的倍数。
现在我们准备摆脱 C 语言的传统,考虑一下如果我们不使用清单 2-1 中的 repr(C),布局会发生什么变化。C 表示法的主要限制之一是它要求我们以原始结构定义中的相同顺序放置所有字段。默认的 Rust 表示法 repr(Rust) 消除了这一限制,以及其他一些较小的限制,例如对碰巧有相同字段的类型进行确定的字段排序。也就是说,在使用默认的 Rust 布局时,即使两个不同的类型共享所有相同的字段、相同的类型、相同的顺序,也不能保证它们的布局是一样的。
由于我们现在可以对字段进行重新排序,我们可以按照大小递减的顺序来放置它们。这意味着我们不再需要 Foo 字段之间的填充;字段本身被用来实现必要的对齐!Foo 现在只是其字段的大小:只有 16 个字节。这就是为什么 Rust 默认情况下不对一个类型在内存中的布局做很多保证的原因之一:通过给编译器更多的余地来重新排列,我们可以产生更有效的代码。
事实证明,还有第三种方法来布局一个类型,那就是告诉编译器,我们不希望在字段之间有任何填充。这样做,我们就表示我们愿意接受使用错位访问的性能冲击。最常见的使用情况是,当每一个额外的字节的内存的影响是可以感觉到的,比如你有很多类型的实例,如果你有非常有限的内存,或者如果你通过一个低带宽的媒介(如网络连接)发送内存中的表示。为了选择这种行为,你可以用 #[repr(packed)] 来注释你的类型。请记住,这可能会导致更慢的代码,在极端情况下,如果你试图执行 CPU 只支持对齐参数的操作,这可能会导致你的程序崩溃。
有时,你想给一个特定的字段或类型一个比它技术上要求的更大的对齐方式。你可以使用属性 #[repr(align(n))] 来做到这一点。这方面的一个常见的用例是确保在内存中连续存储的不同数值(比如在一个数组中)最终出现在 CPU 的不同缓存行中。这样,你就可以避免错误的共享,因为错误的共享会导致并发程序的巨大性能下降。当两个不同的 CPU 访问不同的值,而这些值恰好共享一个缓存线时,就会发生虚假共享;虽然理论上它们可以并行操作,但它们最终都会争相更新缓存中的同一个条目。我们将在第 10 章中更详细地讨论并发性问题。
复合类型
你可能对编译器如何在内存中表示其他 Rust 类型感到好奇。这里有一个快速参考:
元组 表示为与元组值相同类型的字段,顺序相同。 数组 表示为所含类型的连续序列,元素之间没有填充物。 联合 布局是为每个变体独立选择的。 对齐是所有变体的最大值。 枚举 与联合相同,但有一个额外的隐藏共享字段,存储枚举变体判别符。判别值是代码用来确定一个给定值持有哪一个枚举变体的值。判别字段的大小取决于变体的数量。
动态大小的类型和宽指针
你可能在 Rust 文档的各种奇怪的角落和错误信息中遇到过 Sized 这个标记性特质。通常情况下,它的出现是因为编译器希望你提供一个 Sized 的类型,但你(显然)没有。Rust 中的大多数类型都自动实现了 Sized,也就是说,它们有一个在编译时就知道的大小,但有两种常见的类型却没有:Trait 对象和切片 (slices)。例如,如果你有一个 dyn Iterator 或者一个 [u8],这些都没有一个明确的大小。它们的大小取决于一些只有在程序运行时才知道的信息,而不是在编译时,这就是为什么它们被称为动态大小的类型(DSTs)。没有人提前知道你的函数收到的 dyn Iterator 是这个 200 字节的结构还是那个 8 字节的结构。这就出现了一个问题:编译器往往必须知道某样东西的大小,以便产生有效的代码,例如为一个类型为(i32,dyn Iterator, [u8], i32)的元组分配多少空间,或者如果你的代码试图访问第四个字段,应该使用什么偏移。但是如果类型不是 Sized,这些信息就无法使用。
编译器几乎在任何地方都要求类型是大小适中的。结构字段、函数参数、返回值、变量类型和数组类型都必须是 Sized 的。这个限制是如此的普遍,以至于你写的每一个类型绑定都包括T:Sized,除非你明确地用 T:?Sized(? 的意思是 "可能不是")来选择不使用它。但如果你有一个 DST 并想用它做一些事情,比如你真的想让你的函数接受一个 trait 对象或一个 slice 作为参数,这就很无助了。
弥合非固定大小类型和固定大小类型之间的差距的方法是将非固定大小类型放在宽指针(也被称为胖指针)后面。一个宽指针就像一个正常的指针,但它包括一个额外的字大小的字段,它提供了编译器需要的关于该指针的额外信息,以生成合理的代码来处理该指针。当你获取一个对 DST 的引用时,编译器会自动为你构造一个宽指针。对于一个切片,额外的信息只是切片的长度。对于 trait 对象来说,我们稍后会讨论这个问题。最重要的是,这个宽指针是有尺寸的。具体来说,它是 usize(目标平台上一个字的大小)的两倍:一个 usize 用于保存指针,一个 usize 用于保存 "完成 "类型所需的额外信息。
注:Box 和 Arc 也支持存储宽指针,这就是为什么它们都支持
T:?Sized。
Trait 和 Trait 约束
特质是 Rust 类型系统的一个关键部分--它们是一种胶水,允许类型之间相互操作,尽管它们在定义时并不了解对方的情况。Rust 编程语言对如何定义和使用特质做了很好的介绍,所以我在这里就不做介绍了。取而代之的是,我们要看看 traits 的一些技术方面:它们是如何实现的,你必须遵守的限制,以及 traits 的一些更深奥的用途。
编译和分发
到目前为止,你可能已经在 Rust 中写了相当数量的泛型代码。你已经在类型和方法上使用了泛型类型参数,甚至可能在这里和那里使用了一些 trait 约束。但是你有没有想过,当你编译泛型代码时,它究竟会发生什么,或者当你在 dyn Trait 上调用一个 trait 方法时,会发生什么?
当你写一个在 T 上泛型的类型或函数时,你实际上是在告诉编译器为每个类型 T 制作一个该类型或函数的副本。当你构造一个 Vec<i32> 或 HashMap<String, bool> 时,编译器基本上是复制粘贴泛型类型和它的所有实现块,并将每个泛型参数的所有实例替换为你提供的具体类型。它制作了一个 Vec 类型的完整副本,每个 T 都被替换为 i32,而 HashMap 类型的完整副本,每个 K 都被替换为 String,每个 V 都被替换为 bool。
注意:在现实中,编译器实际上并没有进行完全的复制。它只复制你使用的部分代码,所以如果你从未在
Vec<i32>上调用find,find的代码就不会被复制和编译。
这一点也适用于泛型函数。考虑清单 2-2 中的代码,它显示了一个泛型方法。
#![allow(unused)] fn main() { impl String { pub fn contains(&self, p: impl Pattern) -> bool { p.is_contained_in(self) } } // 清单 2-2:使用静态分发的泛型方法 }
这个方法的副本是为每个不同的模式类型制作的(记得 impl Trait 是 <T: Trait> 的简写)。我们需要为每个 impl Pattern 类型提供一个不同的函数体副本,因为我们需要知道 is_contained_in 函数的地址来调用它。CPU 需要被告知跳转到哪里并继续执行。对于任何给定的模式,编译器知道该地址是该模式类型实现该 trait 方法的地方的地址。但是没有一个地址可以用于任何类型,所以我们需要为每个类型准备一个副本,每个副本都有自己的地址可以跳转。 这被称为静态分发,因为对于任何给定的方法副本,我们 "分发到" 的地址是静态已知的。
注意:你可能已经注意到,"静态" 这个词在这里有点超载。静态通常是指任何在编译时已知的东西,或者可以被当作已知的东西来对待,因为它可以被写入静态内存,正如我们在第 1 章中讨论的。
这个从一个泛型到许多非泛型的过程被称为单态化,这也是泛型 Rust 代码通常和非泛型代码表现一样好的部分原因。 当编译器开始优化你的代码时,就好像根本没有泛型的存在一样!每个实例都被单独优化,并使用所有已知的类型。每个实例都是单独优化的,并且所有的类型都是已知的。因此,代码的效率就像直接调用传入的模式的 is_contained_in 方法一样,没有任何特质存在。编译器对所涉及的类型有充分的了解,如果愿意,甚至可以内联 is_contained_in 的实现。
单态化也是有代价的:所有这些类型的实例化都需要单独编译,如果编译器不能将它们优化掉,就会增加编译时间。每个单态化的函数也会产生自己的机器代码块,这可能会使你的程序变得更大。而且,由于泛型方法的不同实例之间不共享指令,CPU 的指令缓存也是无效的,因为它现在需要保存有效相同指令的多个副本。
非泛型内部函数
通常情况下,泛型方法中的大部分代码是不依赖于类型的。例如,考虑一下
HashMap::insert的实现。计算所提供键的哈希值的代码取决于映射的键类型,但是遍历映射的桶以找到插入点的代码可能不需要。在这种情况下,跨单一化共享方法的非通用部分生成的机器码会更有效,并且只在实际需要的地方生成不同的副本。在这种情况下,你可以使用一种模式,即在执行共享操作的泛型方法中声明一个非泛型的辅助函数。这样,编译器就只留下与类型相关的代码来为你复制粘贴,同时允许辅助函数被共享。
把函数变成内部函数还有一个好处,就是你不会用一个单一目的的函数来污染你的模块。你可以在方法之外声明这样一个辅助函数;只是要注意不要让它成为泛型植入块下的方法,因为那样它仍然会被单态化。
静态分发的替代方法是动态分发,它使代码能够在不知道是什么类型的情况下调用一个泛型类型的 trait 方法。我在前面说过,我们需要清单 2-2 中方法的多个实例的原因是,否则你的程序就不知道要跳转到什么地址,以便在给定的模式上调用特质方法 is_contained_in。那么,通过动态分发,调用者会简单地告诉你。如果你用 &dyn Pattern 代替 impl Pattern,你就告诉调用者他们必须为这个参数提供两个信息:模式的地址和 is_contained_in 方法的地址。实际上,调用者给了我们一个指针,指向一个叫做虚拟方法表(vtable)的内存块,这个虚拟方法表保存了有关类型的所有 trait 方法的实现地址,其中一个就是 is_contained_in。当方法中的代码想要调用所提供的模式的特质方法时,它会在 vtable 中查找该模式的 is_contained_in 的实现地址,然后调用该地址的函数。这使得我们可以使用相同的函数体,无论调用者想使用什么类型。
注意:每个
vtable都包含了关于具体类型的布局和对齐方式的信息,因为这些信息在使用一个类型时总是需要的。如果你想看一个显式vtable的例子,看看std::task::RawWakerVTable类型。
你会注意到,当我们选择使用 dyn 关键字进行动态分发时,我们必须在它的前面放一个 &。原因是我们在编译时不再知道调用者传入的模式类型的大小,所以我们不知道要为它预留多少空间。换句话说,dyn Trait 是 !Sized,其中的 ! 表示不。为了使它有大小,以便我们可以把它作为一个参数,我们把它放在一个指针(我们知道它的大小)后面。由于我们还需要传递方法地址表,这个指针变成了一个广义的指针,其中额外的字是指向 vtable 的指针。你可以使用任何能够容纳宽指针的类型进行动态分发,比如 &mut、 Box 和 Arc。清单 2-3 显示了清单 2-2 的动态分发等价物。
#![allow(unused)] fn main() { impl String { pub fn contains(&self, p: &dyn Pattern) -> bool { p.is_contained_in(&*self) } } // 清单 2-3: 使用动态分发的泛型方法 }
实现特质的类型和其 vtable 的组合被称为特质对象。大多数特质可以变成特质对象,但不是全部。例如,Clone trait,其 clone 方法返回 自身,不能被转化为特质对象。如果我们接受一个 dyn Clone trait 对象,然后对它调用 clone,编译器将不知道要返回什么类型。或者,考虑一下标准库中的 Extend trait,它有一个方法 extend,在所提供的迭代器的类型上是通用的(所以它可能有很多实例)。如果你要调用一个接受动态 Extend 的方法,那么就没有一个单一的地址可以放在 trait 对象的 vtable 中;对于 extend 可能被调用的每种类型,都必须有一个条目。这些都是特性的例子,它们不是对象安全的,因此不能被变成特性对象。为了实现对象安全,特质的任何方法都不能是泛型的或使用Self 类型。此外,特质不能有任何静态方法(也就是说,其第一个参数不解引用到 Self),因为不可能知道要调用哪个方法的实例。例如,不清楚 FromIterator::from_iter(&[0]) 应该执行什么代码。
在阅读关于特质对象的文章时,你可能会看到提到特质绑定 Self: Sized。这样的约束意味着 Self 不会通过 trait 对象被使用(因为它将是 !Sized)。你可以把这个约束放在特质上,要求特质永远不使用动态分发,或者你可以把它放在一个特定的方法上,使该方法在通过特质对象访问特质时不可用。当检查一个特质是否是对象安全的时候,具有 where Self: Sized 绑定的方法被豁免。
动态分发可以缩短编译时间,因为它不再需要编译多个类型和方法的副本,而且可以提高 CPU 指令缓存的效率。然而,它也阻止了编译器对所使用的特定类型进行优化。有了动态分发,编译器对清单 2-2 中的 find 所能做的就是通过 vtable 插入对函数的调用--它不能再执行任何额外的优化,因为它不知道在这个函数调用的另一边会有什么代码。 此外,对 trait 对象的每个方法调用都需要在 vtable 中进行查找,这比直接调用方法增加了少量的开销。
当你要在静态分发和动态分发之间做出选择时,很少有明确的正确答案。不过,广义上讲,你想在你的库中使用静态分发,在你的二进制文件中使用动态分发。在库中,你想让你的用户来决定哪种分发最适合他们,因为你不知道他们的需求是什么。如果你使用动态分发,他们也会被迫这样做,而如果你使用静态分发,他们可以选择是否使用动态分发。另一方面,在二进制文件中,你正在编写最终的代码,所以除了你正在编写的代码的需求外,没有其他需求需要考虑。 动态分发通常允许你编写更干净的代码,省去泛型参数,并能更快地编译,所有这些都是以(通常)边际性能为代价,所以它通常是二进制文件的更好选择。
泛型 Traits
Rust 特质可以通过两种方式之一称为泛型:使用泛型类型参数,如 trait Foo<T>,或者使用关联类型,如 trait Foo { type Bar; }。这两者之间的区别并不明显,但幸运的是,经验法则非常简单:如果你希望一个给定的类型只有一个特质的实现,那么就使用关联类型,否则就使用通用类型参数。
这样做的理由是,关联类型通常更容易操作,但不允许多种实现。所以,更简单地说,我们的建议是,只要你能使用关联类型,就使用关联类型。
有了泛型特质,用户必须始终指定所有的泛型参数,并重复这些参数的任何约束。这很快就会变得混乱和难以维护。如果你给一个特质增加了一个泛型参数,该特质的所有用户也必须更新以反映这一变化。而且,由于一个特质的多个实现可能存在于一个给定的类型中,编译器可能很难确定你想使用特质的哪个实例,从而导致像 FromIterator::<u32>::from_iter 这样可怕的歧义函数调用。 但好处是,你可以为同一类型多次实现特质--例如,你可以针对你的类型的多个右手边类型实现 PartialEq,或者你可以同时实现 FromIterator<T>和 FromIterator<&T> where T: Clone,正是因为通用特质所提供的灵活性。
另一方面,对于关联类型,编译器只需要知道实现特质的类型,而所有的关联类型都是如此(因为只有一个实现)。这意味着约束可以全部存在于特质本身,不需要在使用时重复。反过来,这允许特质在不影响用户的情况下增加更多的关联类型。因为类型决定了特质的所有关联类型,所以你永远不需要用上一段中显示的统一函数调用语法来消除歧义。然而,你不能针对多个目标类型实现 Deref,也不能用多个不同的 Item 类型实现 Iterator。
连贯性和孤儿规则
Rust 有一些相当严格的规则,规定你可以在哪里实现特性,以及你可以在哪些类型上实现它们。这些规则的存在是为了保护一致性属性:对于任何给定的类型和方法,只有一个正确的选择,那就是对该类型使用该方法的实现。为了理解这一点的重要性,考虑一下如果我可以为标准库中的 bool 类型编写自己的 Display 特性的实现会发生什么。现在,对于任何试图打印一个 bool 值并包括我的 crate 的代码,编译器将不知道是选择我写的实现还是标准库的实现。这两种选择都不正确,也不比其他选择好,而且编译器显然不能随机选择。如果完全不涉及标准库,但我们有两个相互依赖的包 (crate),而且它们都实现了某个共享类型的特质,也会出现同样的问题。连贯性属性保证了编译器永远不会在这些情况下结束,也永远不需要做出这些选择:总会有一个明显的选择。
维护一致性的一个简单方法是确保只有定义特质的包 (crate) 可以编写该特质的实现;如果没有其他人可以实现该特质,那么其他地方就不能有冲突的实现。然而,这在实践中限制性太强,而且基本上会使特质失去作用,因为没有办法为你自己的类型实现诸如 std::fmt::Debug 和 serde::Serialize 这样的特质,除非你把自己的类型包含在定义框中。相反的极端做法是,你可以只为你自己的类型实现特性,这解决了这个问题,但却带来了另一个问题:一个定义了特性的包现在不能为标准库或其他流行的包中的类型提供该特性的实现 理想情况下,我们希望找到一些规则来平衡下游包为自己的类型实现上游特质的愿望和上游包能够在不破坏下游代码的情况下增加自己的特质实现的愿望。
注意:上游指的是你的代码所依赖的东西,下游指的是依赖你的代码的东西。 通常,这些术语是在 crate 依赖关系的直接意义上使用的,但它们也可以用来指代码的权威分叉--如果你做一个 Rust 编译器的分叉,官方 Rust 编译器就是你的 "上游"。
在 Rust 中,建立这种平衡的规则是孤儿规则。 简单地说,孤儿规则说你可以为一个类型实现一个特质,但该特质或类型必须是你的包的本地的。所以,你可以为你自己的类型实现 Debug,也可以为 bool 实现 MyNeatTrait,但你不能为 bool 实现 Debug。如果你尝试,你的代码将无法编译,而且编译器会告诉你有冲突的实现。
这让你走得很远;它允许你为第三方类型实现你自己的特质,并为你自己的类型实现第三方特质。然而,孤儿规则并不是故事的结束。它还有一些额外的影响、注意事项和例外情况,你应该注意一下。
覆盖实现(Blanket Implementations)
孤规则允许你在一系列类型上实现特质,代码如 impl<T> MyTrait for T where T:,等等。这是一个覆盖的实现,它不局限于一个特定的类型,而是适用于广泛的类型。只有定义了一个 trait 的包被允许编写一个覆盖实现,并且添加一个覆盖实现到一个已经存在的 trait 被认为是一个破坏性的改变。如果不是的话,下游包含 impl MyTrait for Foo 的包可能会突然停止编译,因为你更新了定义 MyTrait 的包,出现了一个关于冲突实现的错误。
基本类型
有些类型是如此重要,以至于有必要允许任何人在其上实现特性,即使这似乎违反了孤儿规则。这些类型被标记为 #[fundamental] 属性,目前包括 &, &mut, 和 Box。为了孤儿规则的目的,基本类型可能不存在--它们在孤儿规则被检查之前就被有效地删除了,以便允许你,例如,为 &MyType 实现 IntoIterator。如果只有孤儿规则,这个实现将不被允许,因为它为一个外来类型实现了一个外来特性-- IntoIterator 和 & 都来自标准库。在一个基本类型上添加一个覆盖的实现也被认为是一个破坏性的改变。
覆盖实现 (Covered Implementations)
在一些有限的情况下,我们想允许为一个外来类型实现一个外来特性,而孤儿规则通常不允许这样做。最简单的例子是当你想编写类似 impl From<MyType> for Vec<i32> 的东西。在这里,From 特质是外来的,Vec 类型也是,但没有违反一致性的危险。这是因为冲突的实现只能通过标准库中的覆盖实现来添加(标准库不能以其他方式命名 MyType),这无论如何是一个破坏性的改变。
为了允许这些类型的实现,孤规则包含了一个很小的豁免,允许在非常特定的一组环境下为外部类型实现外部特质。具体来说,给定 impl<P1..=Pn> ForeignTrait<T1..=Tn> for T0 是允许的,只有当至少一个 Ti 是本地类型,并且在第一个这样的 Ti 之前没有 T 是泛型类型 P1..=Pn。泛型类型参数 (Ps) 允许出现在 T0..Ti 只要它们被某种中间类型所覆盖。如果 T 作为其他类型的类型参数出现(比如 Vec<T>),则会覆盖它,但如果它独立存在(只是 T),或者只是出现在基本类型(比如 &T ) 后面,则不会覆盖它。因此,清单 2-4 中的所有实现都是有效的。
#![allow(unused)] fn main() { impl<T> From<T> for MyType impl<T> From<T> for MyType<T> impl<T> From<MyType> for Vec<T> impl<T> ForeignTrait<MyType, T> for Vec<T> // 清单 2-4: 外部类型的外部特质的有效实现 }
但是,清单 2-5 中的实现是无效的。
#![allow(unused)] fn main() { impl<T> ForeignTrait for T impl<T> From<T> for T impl<T> From<Vec<T>> for T impl<T> From<MyType<T>> for T impl<T> From<T> for Vec<T> impl<T> ForeignTrait<T, MyType> for Vec<T> // 清单 2-5: 外部类型的外部特质的无效实现 }
对孤儿规则的这种放宽使得当你为现有特质添加新的实现时构成破坏性改变的规则变得复杂。特别是,为现有特质添加新的实现,只有当它至少包含一个新的本地类型,并且这个新的本地类型满足前面描述的豁免规则时,才是非破坏性的。添加任何其他新的实现都是一种破坏性的改变。
注意:
impl<T> ForeignTrait<LocalType, T> for ForeignType是有效的,但是impl<T> ForeignTrait<T, LocalType> for ForeignType是无效的!这看起来很随意,但是如果没有这个规则,你可以为写impl<T> ForeignTrait<T, LocalType> for ForeignType,而另一个包可以写impl<T> ForeignTrait<TheirType, T> for ForeignType,只有当这两个包被放在一起时才会产生冲突。 孤儿规则没有完全禁止这种模式,而是要求你的本地类型在类型参数之前,这打破了联系,确保如果两个包在孤立情况下坚持一致性,它们在合并时也坚持一致性。
Trait 约束
标准库中有很多特质约束,无论是 HashMap 中的键必须实现 Hash + Eq,还是 Thread::Spawn 的函数必须是 FnOnce + Send + 'static。当你自己写泛型代码时,几乎肯定会包括特质约束,因为否则你的代码就不能对它所泛型的类型做什么。当你写出更复杂的泛型实现时,你会发现你也需要从你的特质约束中获得更多的保真度,所以让我们看看实现这一目的的一些方法。
首先,特质约束不一定是 T: Trait 的形式,其中 T 是你的实现或类型的泛型的一些类型。约束可以是任意的类型限制,甚至不需要包括泛型参数、参数类型或本地类型。你可以写一个 trait 约束,比如说 where String: Clone,尽管 String: Clone 总是真的,并且不包含本地类型。你也可以写 where io::Error: From<MyError<T>>;你的泛型类型参数不需要只出现在左手边。这不仅允许你表达更复杂的约束,而且可以使你避免不必要地重复约束。例如,如果你的方法要构造一个 HashMap<K, V, S>,它的键是一些泛型 T,它的值是一个 usize,与其把约束写成 where T: Hash + Eq, S: BuildHasher + Default,你可以写成 where HashMap<T, usize, S>: FromIterator。这样就省去了查找你最终使用的方法的确切约束要求,并且更清楚地传达了你的代码的真正要求。正如你所看到的,如果你想调用的底层特质方法的约束很复杂,它也能大大降低你的约束的复杂性。
DERIVE TRAIT 虽然
#[derive(Trait)]非常方便,但在 trait 约束的上下文中,你应该注意到它经常被实现的一个微妙之处。许多#[derive(Trait)]的扩展被分解为impl Trait for Foo<T> where T: Trait。这通常是你想要的,但并不总是如此。例如,考虑一下如果我们试图以这种方式为Foo<T>派生Clone,而Foo包含一个Arc<T>,会发生什么。不管T是否实现了Clone,Arc都实现了Clone,但是由于派生的约束,Foo只有在T实现了Clone时才会实现!这通常不是一个太大的问题,但是它确实在不需要的地方增加了一个约束。如果我们把这个类型重命名为Shared,问题可能会变得更清楚一些。想象一下,当编译器告诉他们不能克隆Shared<NotClone>时,拥有Shared<NotClone>的用户将是多么的困惑啊!在写这篇文章的时候,这就是标准库提供的#[derive(Clone)]的工作方式,尽管这在将来可能会改变。
有时候,你需要对泛型类型的关联类型进行约束。例如,考虑迭代器方法 flatten,它接受一个迭代器,该迭代器生成的项依次实现 iterator,并生成这些内部迭代器的项的迭代器。它产生的类型 Flatten 是 I 上的泛型,这是外部迭代器的类型。Flatten 实现 Iterator,如果我实现 Iterator,我自己产生的项目实现 IntoIterator。为了使您能够像这样编写约束,Rust 允许您使用 type::AssocType 语法引用类型的关联类型。例如,我们可以使用 I::Item 来引用 I 的 Item 类型。如果一个类型有多个同名的关联类型,比如提供关联类型的 trait 本身就是泛型(因此有很多实现),你可以用语法 <Type as Trait>::AssocType 来消除歧义。使用这个方法,你不仅可以为外层迭代器的类型写边界,还可以为该外层迭代器的项目类型写边界。
在广泛使用泛型的代码中,您可能会发现需要编写一个讨论对类型的引用的约束。这通常没有问题,因为您可能还会有一个泛型的生存期参数,可以将其用作这些引用的生存期。然而,在某些情况下,您希望约束说明此引用在任何生命周期实现此 trait。这种类型的约束被称为高阶生存期的 trait 约束,它在与 Fn 特质的联系中特别有用。例如,假设你想成为一个泛型函数,它接受一个对 T 的引用,并返回一个对 T 内部的引用,如果你写 F: Fn(&T) -> &U,您需要为这些引用提供一个生存期,但是您真正想说的是任何生存期,只要输出与输入相同。使用高阶的生存期,你可以写 F: for<'a> Fn(&'a T) -> &'a U 表示在任何生存期中,这个约束都必须成立。Rust 编译器足够聪明,当你用这样的引用编写 Fn 约束时,它会自动添加 for,这涵盖了此特性的大部分用例。在编写本文时,这种显式形式很少被需要,因此标准库只在三个地方使用它,但它确实存在,因此值得了解。
为了把所有这些结合起来,考虑清单 2-6 中的代码,它可以用来为任何可以被迭代的类型实现 Debug,并且其元素是 Debug。
#![allow(unused)] fn main() { impl Debug for AnyIterable where for<'a> &'a Self: IntoIterator, for<'a> <&'a Self as IntoIterator>::Item: Debug { fn fmt(&self, f: &mut Formatter) -> Result<(), Error> { f.debug_list().entries(self).finish() } } // 清单 2-6:对任何可迭代的集合来说,Debug 的实现都过于通用。 }
你可以把这个实现复制粘贴到几乎所有的集合类型上,它就会 "正常工作"。当然,你可能想要一个更聪明的调试实现,但这很好地说明了特质约束的力量。
标记 Traits
通常,我们使用特质来表示多种类型可以支持的功能;Hash 类型可以通过调用 hash 进行哈希处理,Clone 类型可以通过调用 clone 进行克隆,Debug 类型可以通过调用 fmt 进行格式化。但并不是所有的特质都是这样起作用的。有些特质(称为标记特质)指示的是实现类型的属性。标记特质没有方法或相关的类型,只是用来告诉你某一特定类型可以或不能以某种方式使用。例如,如果一个类型实现了 Send 标记特质,那么跨线程边界发送是安全的。如果它没有实现这个标记符特质,发送它就不安全。没有与此行为相关的方法;这只是一个关于类型的事实。在 std::marker 模块中,标准库有许多这样的标记,包括 Send, Sync, Copy, Sized, 和 Unpin。其中大多数(除了Copy)也是自动特质;编译器会自动为类型实现它们,除非类型包含没有实现标记特质的内容。
标记特质在 Rust 中有一个重要的作用:它允许你写出捕获语义要求的约束,而这些要求在代码中没有直接表达。在代码中没有调用 send,要求一个类型是 Send。相反,代码假设给定的类型可以在一个单独的线程中使用,如果没有标记特质,编译器将没有办法检查这个假设。这就需要程序员记住这个假设并仔细阅读代码,我们都知道这不是我们想要依赖的东西。这条路充满了数据竞争、分离故障和其他运行时问题。
与标记特质类似的是标记类型。这些是单元类型(如 struct MyMarker;),不持有数据,也没有方法。标记类型对于,嗯,标记一个类型处于一个特定的状态很有用。当你想让用户不可能滥用一个 API 时,它们就会派上用场。例如,考虑一个像 SshConnection 这样的类型,它可能已经被验证,也可能还没有被验证。你可以给 SshConnection 添加一个通用类型参数,然后创建两个标记类型。未认证的和已认证的。当用户第一次连接时,他们得到 SshConnection<Unauthenticated>。在其 impl 块中,你只提供了一个方法:connect。connect 方法返回一个 SshConnection<Authenticated>,只有在这个 impl 块中,你才提供其余的方法来运行命令等。 我们将在第 3 章进一步研究这个模式。
存在类型 (Existential Types)
在 Rust 中,你很少需要指定你在函数主体中声明的变量的类型,或者你调用的方法的泛型参数的类型。这是因为类型推断的存在,编译器根据类型出现在代码中的评估结果决定使用什么类型。编译器通常只对变量和闭包的参数(和返回类型)进行类型推断;像函数、类型、特质和特质实现块这样的顶层定义都需要你明确命名所有的类型。 这有几个原因,但主要的原因是,当你至少有一些已知的点来开始推断时,类型推断就容易多了。然而,要完全命名一个类型并不总是容易的,甚至是不可能的。例如,如果你从一个函数中返回一个闭包,或者从一个特质方法中返回一个异步块,它的类型并没有一个你可以在代码中输入的名字。
为了处理这样的情况,Rust 支持存在性(existential)类型。 你可能已经看到了存在性类型的作用。所有标记为 async fn 或者返回类型为 impl Trait 的函数都有一个存在性的返回类型:签名中没有给出返回值的真实类型,只是提示函数返回的某个类型实现了调用者可以依赖的一组特性。更重要的是,调用者只能依赖实现这些特质的返回类型,而不能依赖其他。
注意:从技术上讲,调用者只依赖返回类型而不依赖其他,这并不是严格意义上的。编译器也会通过返回位置的
impl Trait来传播Send和Sync等自动特性。我们将在下一章中进一步研究这个问题。
这种行为就是存在性类型的名称:我们断言存在某种与签名相匹配的具体类型,而我们让编译器去寻找这种类型。编译器通常会通过在函数主体上应用类型推理来找出这个类型。
并非所有 impl Trait 的实例都使用存在性类型。如果你在一个函数的参数位置使用 impl Trait,它实际上只是该函数的一个未命名的泛型参数的缩写。例如,fn foo(s: impl ToString) 大多只是 fn foo<S: ToString>(s: S)。当你实现有相关类型的特质时,存在性类型就特别有用了。例如,设想你正在实现 IntoIterator trait。它有一个相关的类型 IntoIter,持有相关类型可以变成的迭代器的类型。对于存在性类型,你不需要定义一个单独的迭代器类型来用于 IntoIter。相反,你可以给出关联类型为 impl Iterator<Item = Self::Item>,并且只需在 fn into_iter(self) 里面写一个表达式,评估为一个 Iterator,比如通过在一些现有的迭代器类型上使用 maps 和 filters。
存在性类型还提供了一个不仅仅是方便的功能:它们允许你进行零成本的类型清除。你可以使用存在类型来隐藏底层的具体类型,而不是仅仅因为它们出现在某个公共签名中就导出辅助类型--迭代器和 future 就是这种常见的例子。 你的接口的用户只能看到相关类型所实现的特质,而具体类型则作为一个实现细节留下。这不仅简化了接口,而且还使你能够随心所欲地改变实现,而不会在将来破坏下游的代码。
总结
本章对 Rust 的类型系统进行了全面的回顾。我们既看了编译器如何在内存中表现类型,又看了它如何对类型本身进行推理。这是编写不安全代码、复杂应用接口和异步代码的重要背景材料。 你还会发现,本章中的许多类型推理在你如何设计 Rust 代码接口方面发挥了作用,我们将在下一章介绍。