Skip to content

Latest commit

 

History

History
executable file
·
307 lines (175 loc) · 40.8 KB

chapter_2.md

File metadata and controls

executable file
·
307 lines (175 loc) · 40.8 KB

Chapter 2: Types

现在基础知识已经掌握,我们来看看Rust的类型系统。跳过《Rust编程语言》涵盖的基础知识,直接深入研究不同类型在内存中的布局,Trait和Trait Bound的细节,Existential Types,以及在跨crate边界使用类型的规则。

Type In Memory

每个 Rust 值都有一个类型。在本章中,我们将看到类型在 Rust 中有许多用途,但它们最基本的作用之一是告诉您如何解释内存位。例如,位序列 0b10111101(用十六进制表示为 0xBD)本身没有任何含义,直到您分配一个类型。在类型为 u8 的情况下解释,该位序列是数字 189。在类型为i8 的情况下解释,它是 -67。当您定义自己的类型时,编译器的工作是确定定义类型的每个部分在内存表示中的位置。您结构体的每个字段在位序列中出现在哪里?您的枚举磁盘符储存在哪里?在您开始编写更高级的 Rust 代码时,了解此过程的工作方式非常重要,因为这些细节会影响您代码的正确性和性能。

Alignment

在我们讨论类型的内存表示是如何确定之前,我们首先需要讨论对齐方式的概念,它决定了类型的字节可以存储在哪里。一旦确定了类型的表示形式,您可能认为可以采用任意的内存位置,并将存储在那里的字节解释为该类型。虽然从理论上讲这是正确的,但在实践中,硬件也限制了给定类型可以放置的位置。这最明显的例子是指针指向字节而不是位。如果在计算机内存的第4位开始放置T类型的值,则无法引用其位置;只能创建一个指向字节0或字节1(位8)的指针。因此,所有值,无论其类型如何,都必须从字节边界开始。我们说所有的值都必须至少是字节对齐的,它们必须被放置在地址为8位倍数的位置上。

有些值比字节对齐更严格的对齐规则。在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的类型将对齐为4字节,因为其中有u32。

Layout

现在您已经了解了对齐方式,我们可以探讨编译器如何决定类型在内存中的表示,也就是所谓的布局。默认情况下,正如您很快就会看到的,在类型的布局方面,Rust编译器几乎没有提供任何保证,这使得理解底层原理的起点不太好。幸运的是,Rust提供了repr属性,您可以在类型定义中添加这个属性来请求特定的内存表示。您最常见到的一个选项是repr(C)。顾名思义,它以与C或C++编译器相同的方式布局类型。当编写Rust代码与其他语言使用外部函数接口(foreign-function interface,FFI)进行接口时,这一点十分有用,我们将在第11章中讨论,因为Rust将生成与另一种语言编译器的期望相匹配的布局。由于C布局是可预测的且不会发生变化,所以在不安全的上下文中,如果您正在使用指向该类型的原始指针,或者如果您需要在两种不同类型之间进行转换,而您知道它们具有相同的字段,那么repr(C)也是有用的。当然,它也非常适合我们初步学习布局算法。


Note: 另一个有用的表示是repr(transparent),只能用于具有单个字段的类型,并保证外部类型的布局与内部类型完全相同。这在与“newtype”模式结合使用时非常方便,您可能希望像操作一样地操作一些struct Astruct NewA(A)的内存表示为相同。如果没“有repr(transparent),Rust编译器不能保证它们将具有相同的布局。


让我们看看编译器如何使用 repr(C) 来布置列表2-1中的特定类型 Foo。你认为编译器会如何在内存中布置它?

#[repr(C)]
struct Foo {
    tiny: bool,
    normal: u32,
    small: u8,
    long: u64,
    short: u16,
}

清单2-1:对齐方式影响布局。

首先,编译器会看到 tiny 字段,其逻辑大小为1位(真或假)。但由于 CPU 和内存按字节操作,tiny 在内存中的表示形式中会给出1个字节。接下来,normal 是4字节类型,所以我们希望它是4字节对齐的。但即使 Foo 对齐了,我们分配给 tiny 的1个字节会使 normal 没有对齐。为此,编译器在 tiny 和 normal 之间的内存表示中插入了3个填充字节,这些字节具有不确定值,在用户代码中被忽略。没有值进入填充,但它确实占用空间。

下一个字段,small,对齐方式很简单:它是一个 1 字节的值,且结构体中当前的字节偏移量为 1 + 3 + 4 = 8。这已经是按字节对齐的,所以 small 可以直接放在 normal 后面。然而,对于 long 再次出现了问题。我们现在已经进入了 1 + 3 + 4 + 1 = 9 字节的 Foo。如果 Foo 是对齐的,那么 long 就不是我们想要的按 8 字节对齐了,所以我们必须插入另外 7 字节的填充来再次对齐 long。这也方便地确保了我们需要的最后一个字段 short 的 2 字节对齐,将总大小增加到 26 字节。现在我们已经处理完了所有的字段,我们还需要确定 Foo 本身的对齐方式。规则是要使用 Foo 的任何字段的最大对齐方式,这将是 8 字节,因为存在 long。因此,为了确保把 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 上的不同缓存行中。这样,您可以避免虚假共享(false sharing),虚假共享可能会导致并发程序的性能大幅降低。当两个不同的 CPU 访问共享缓存行的不同值时,虚假共享会发生;尽管它们理论上可以并行操作,但它们最终都会争抢更新缓存中同一条目。我们将在第 10 章中更详细地讨论并发。

Complex Types

你可能好奇编译器如何在内存中表示其他Rust类型。这是一个快速参考:

  • 元组:表现为具有与元组值类型和顺序相同的字段的结构体。
  • 数组:表现为连续的序列,序列由包含的类型构成,元素之间没有填充。
  • 联合体:每个变体的布局是独立选择的。对齐方式是所有变体中的最大值。
  • 枚举:与联合体相同,但有一个额外的隐藏共享字段,用于存储枚举的变体区分值。区分值是代码用来确定给定值包含哪种枚举变体的值。区分字段的大小取决于变体的数量。

Dynamically Sized Types and Wide Pointers

你可能在 Rust 文档和错误消息的各种怪异角落中遇到了 Sized 标记特性。通常,它会出现,因为编译器希望你提供一个具有大小的类型,但是你没有(显然)。Rust 中的大多数类型都会自动实现 Sized,也就是说,它们有一个在编译时已知的大小。但有两种常见类型没有:trait 对象和切片。比如,如果你有一个 dyn Iterator 或一个 [u8],它们就没有一个明确定义的大小。它们的大小取决于一些只有在程序运行时而不是在编译时才知道的信息,这就是它们被称为动态大小类型的原因(DST)。没有人知道 dyn Iterator 是这个 200 字节结构体还是那个 8 字节结构体。这会带来一个问题:编译器常常需要知道某些东西的大小才能生成有效的代码,比如要为类型为 (i32, dyn Iterator, [u8], i32) 的元组分配多少空间,或者如果你的代码试图访问第四个字段,应该使用什么偏移量。但如果该类型不是 Sized,则没有可用的信息。

编译器几乎在所有地方要求类型具有大小。结构体字段、函数参数、返回值、变量类型和数组类型都必须具有大小。这种限制非常普遍,以至于你编写的每一个类型约束都包括 T: Sized,除非你使用 T: ?Sized 显式地“取消大小性”。但是如果你有一个 DST 并想要做一些事情,比如如果你真的希望你的函数接受一个 trait 对象或一个切片作为参数,那么这就相当无用了。

缩小unsized 和sized类型之间的差距的方法是将unsized 和sized类型放置在宽指针(也称为胖指针)之后。宽指针就像普通指针一样,但它包括一个额外的字大小字段,为编译器提供与指针一起工作的合理代码所需的额外信息。当您对DST(动态大小类型)进行引用时,编译器会自动为您构造一个宽指针。对于切片,额外的信息就是切片的长度。对于Trait对象——嗯,我们晚点再谈。而且至关重要的是,这个宽指针是Sized的。具体来说,它是usize(目标平台上的字大小)的两倍大小:一个usize用于保存指针,另一个usize用于保存“完整”类型所需的额外信息。


Note: Box和Arc也支持存储宽指针,这就是为什么它们都支持T: ?Sized。


Traits and Trait Bounds

Trait是 Rust 类型系统的关键组成部分——它们是连接类型的粘合剂,即使它们在定义时彼此不知道,也可以相互操作。《Rust 程序设计语言》很好地介绍了如何定义和使用Trait,因此我在这里不再赘述。相反,我们将着眼于一些更技术性的Trait方面:它们如何实现、必须遵守的限制以及一些更为深奥的Trait用途。

Compilation and Dispatch

到现在为止,你可能已经使用 Rust 写了相当数量的通用代码。你在类型和方法中使用了通用类型参数,甚至可能在某些地方使用了一些 trait 约束。但你有没有想过当你编译通用代码时发生了什么,或者在 dyn Trait 上调用 trait 方法时发生了什么?

当你编写的类型或函数是泛型的时候,你实际上是在告诉编译器为每个类型T复制一份该类型或函数的副本。当你构造一个Vec或HashMap<String, bool>时,编译器本质上是将通用类型及其所有实现块复制粘贴,并替换您提供的具体类型的每个泛型参数的所有实例。 它为每个T替换为i32制作了Vec类型的完整副本,为每个K替换为String和每个V替换为bool制作了HashMap类型的完整副本。


Note: 实际上,编译器并不会完全进行复制粘贴。它只复制你使用的代码部分,因此如果你从未在一个 Vec 上调用 find 函数,那么 find 函数的代码就不会被复制和编译。


同样的事情也适用于通用函数。请考虑清单 2-2 中显示的通用方法的代码。

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方法的地方的地址。但是,对于任何类型,我们都没有一个可以使用的地址,因此我们需要为每种类型创建一个副本,每个副本都有自己的跳转地址。这被称为静态分派,因为对于任何给定的方法副本,我们要“调度到”的地址是静态已知的。


Note: 你可能已经注意到,“静态”这个词在这个上下文中有点多余。通常,“静态”用于表示编译时已知或可以被视为已知的任何内容,因为它可以被写入静态存储器中,正如我们在第1章中讨论的那样。


将通用类型转换为多个非通用类型的过程称为单态化,这是 Rust 代码通常表现得和非通用代码一样好的原因之一。在编译器开始优化代码时,就好像没有通用类型一样!每个实例都会单独进行优化,并且知道所有类型。因此,代码的效率就像直接调用所传递模式的 is_contained_in 方法一样高,没有任何特征存在。编译器完全知道涉及的类型,如果愿意,甚至可以内联实现 is_contained_in。

单态化也有其代价:所有那些类型的实例需要单独进行编译,如果编译器不能将它们进行优化,这会增加编译时间。每个单态化函数也会产生其自己的机器代码块,这会使你的程序变得更大。另外,由于指令不能在不同泛型类型方法的实例间共享,CPU的指令缓存也会变得不太有效,因为它现在需要保存多个有效相同指令的副本。


Note: Non-Generic Inner Functions

通常,通用方法中的许多代码并不依赖于类型。例如,考虑HashMap :: insert的实现。计算所提供密钥的散列的代码取决于Map的键类型,但遍历Map桶以找到插入点的代码可能不取决于类型。在这种情况下,跨单态化共享方法的非通用部分生成的机器代码将更有效,并且仅在实际需要时生成不同的副本。

在这种情况下,您可以使用一种模式,即在泛型方法内声明一个非泛型的辅助函数,用于执行共享操作。这样,只有依赖于类型的代码需要编译器帮助进行复制和粘贴,同时允许使用辅助函数进行共享。

将函数变成内部函数的好处是不会在模块中弄脏单一目的函数。你可以将这种辅助函数声明为方法之外 的函数,只是要小心不要让它成为通用实现块下的方法,否则它仍将被单态化。

静态分发的替代方案是动态分发,它使得代码可以在不知道通用类型的情况下调用trait方法。前面我已经说过,在清单2-2中需要多个方法实例的原因是,否则您的程序将不知道要跳转到哪个地址以调用给定模式上的is_contained_in trait方法。那么,使用动态分发,调用者只需告诉您即可。如果将impl Pattern替换为&dyn Pattern,则告诉调用者他们必须为此参数提供两个信息:模式的地址和is_contained_in方法的地址。在实践中,调用者会给我们一个被称为虚方法表或vtable的内存块的指针,其中包含了所涉及类型 的所有trait方法的实现地址,其中之一就是is_contained_in。当方法内部代码想要调用所提供模式的trait方法时,它在vtable中查找该模式实现的is_contained_in的地址,然后调用该地址的函数。这允许我们使用相同的函数体,无论调用者想使用的类型是什么。


Note: 每个虚表也包含有关具体类型的布局和对齐的信息,因为始终需要该信息来处理类型。如果您想要一个明确的虚表示例,请查看std :: task :: RawWakerVTable类型。

当我们使用dyn关键字选择动态分配时,您会注意到我们必须在其前面放置&。原因是我们不再在编译时知道调用者传入的模式类型的大小,因此我们不知道为其设置多少空间。换句话说,dyn Trait是!Sized,其中!表示不。为了使它可以作为参数取出,我们将其放置在指针后面(我们知道其大小)。由于我们还需要传递方法地址表,因此该指针成为宽指针,其中额外的单词保存指向vtable的指针。您可以使用任何能够容纳宽指针的类型来进行动态分派,例如&mut、Box和Arc。第2-3节列出了第2-2节的动态分派等效内容。

impl String {
  pub fn contains(&self, p: &dyn Pattern) -> bool {
    p.is_contained_in(&*self)
  }
}

码注 2-3:使用动态分派的通用方法

将实现一个trait的类型和它的vtable组合在一起,就构成了一个trait对象。大多数traits都可以转换成trait对象,但不是全部都可以。例如,Clone trait的clone方法返回Self,因此不能转换成trait对象。如果我们接受一个dyn Clone trait对象然后调用它的clone方法,编译器将无法知道返回什么类型。类似地,考虑标准库中的Extend trait,它有一个泛型方法extend,该方法的类型取决于提供的迭代器(因此可能有许多实例)。如果调用一个接受dyn Extend的方法,将无法为extend找到单一的地址放入trait对象的vtable中。必须为每个extend可能调用的类型添加一个条目。这些都是不支持对象特性的trait的示例,因此不能将它们转换成trait对象。要支持对象特性,trait的所有方法都不能是泛型的,也不能使用Self类型。另外,trait不能有任何静态方法(即第一个参数没有解除对Self的引用的方法),因为无法知道调用哪个方法实例。例如,不清楚FromIterator::from_iter(&[0])应执行哪个代码。

阅读有关Trait对象的内容时,您可能会看到有关 Self:Sized Trait约束的提及。这种约束暗示着 Self 没有通过Trait对象使用(因为如果是这样的话,则它就不是 Sized)。您可以在Trait上放置该约束,以要求该Trait永远不使用动态分派,或者您可以在特定方法上放置它,以使在通过Trait对象访问该Trait时该方法不可用。具有 where Self:Sized 约束的方法在检查Trait是否符合对象安全时被豁免。

动态分派可以缩短编译时间,因为不再需要编译多个类型和方法的副本,并且可以提高CPU指令缓存的效率。但是,它也防止编译器针对特定使用的类型进行优化。通过动态分派,在清单2-2中查找所有编译器可以执行的操作就是通过vtable插入对该函数的调用 - 它不能执行任何其他附加优化,因为它不知道该函数调用的另一侧会出现什么代码。此外,对Trait对象的每个方法调用都需要在vtable中查找,这会增加直接调用方法的一些开销。

当你在使用静态分派和动态分派之间做选择时,很少有明确的答案。总体来说,你会想在库中使用静态分派,而在二进制文件中使用动态分派。在库中,你希望让用户决定哪种分派方式最适合他们,因为你不知道他们的需求是什么。如果你使用动态分派,他们被迫也要这样做,而如果你使用静态分派,他们可以选择是否使用动态分派。另一方面,在二进制文件中,你正在编写最终代码,所以要考虑的只有你正在编写的代码的需要。动态分派通常可以让你编写更整洁的代码,省略了通用参数,并且编译速度更快,而且性能损失通常较小,因此通常是二进制文件更好的选择。

Generic Traits

Rust Trait可以通过两种方式之一是使用通用类型参数,例如trait Foo或使用关联类型,例如trait Foo { type Bar; }。这两者之间的区别不是立即显然的,但幸运的是,有一个简单的经验法则:如果您期望给定类型的特性只有一种实现,则使用关联类型,否则使用通用类型参数。

这样做的原因是关联类型通常更容易处理,但不允许多个实现。因此,简单地说,我们的建议就是尽可能使用关联类型。

使用通用特征时,用户必须始终指定所有的通用参数并重复这些参数的任何限制。这可能会很快变得混乱且难以维护。如果你向一个特质添加了一个通用参数,那么必须更新该Trait的所有用户以反映这些更改。由于对于给定类型可能存在多个Trait的实现,编译器可能很难决定你想要使用的Trait实例,这会导致糟糕的消歧函数调用,例如FromIterator::::from_iter。但是优点在于,您可以多次为同一类型实现特质,例如,您可以针对多个右侧类型实现PartialEq,或者您可以在T:Clone的情况下同时实现FromIterator和FromIterator<&T>,这正是通用特征提供的灵活性所在。

然而,对于相关类型而言,编译器只需要知道实现该Trait的类型,并且所有相关类型都会跟随实现一起使用(因为只有一个实现)。这意味着限制条件可以全部存在于该特性本身,而不需要在使用时重复。因此,Trait可以添加其他相关类型而不影响其用户。由于类型决定Trait的所有相关类型,因此您永远不必使用前面段落中显示的统一函数调用语法进行消歧义。但是,您不能对多个目标类型实现Deref,也不能使用多个不同的项目类型实现迭代器。

Coherence and the Orphan Rule

Rust在Trait的实现位置和实现类型上都有一些相当严格的规则。这些规则旨在保持一致性原则:对于任何给定类型和方法,对于该类型使用哪个方法的实现,只有一个正确的选择。为了看到这一点的重要性,考虑一下如果我可以为标准库中的bool类型编写自己的Display trait实现,会发生什么。现在,对于任何尝试打印bool值并包含我的crate的代码,编译器都无法确定选择我编写的实现还是标准库中的实现。没有哪种选择是正确的或比另一种更好的选择,编译器显然也不能随机选择。如果标准库没有参与其中,而是我们有两个依赖于彼此的crate,并且它们都为某些共享类型实现了Trait,则会出现相同的问题。一致性原则确保编译器永远不会陷入这些情况,也永远不必做出这些选择:始终只会有一个显而易见的选择。

维持一致性的简便方法是确保仅定义特征的板条箱可以编写该特征的实现;如果其他人无法实现特征,则其他地方就不会有冲突的实现。然而,实际上这太过严格,基本上使特征无用,因为除非将自己的类型包含到定义的板条箱中,否则将无法为自己的类型实现 std::fmt::Debug 和 serde::Serialize 等特征。相反的极端是说,只能为自己的类型实现特征,解决了这个问题,但引入了另一个问题:定义特征的板条箱现在无法为标准库或其他流行板条箱中的类型提供该特征的实现!理想情况下,我们希望找到一些规则,平衡下游板条箱为其自己的类型实现上游特征的愿望,同时又希望上游板条箱能够为自己的特征添加实现而不会破坏下游代码。


Note: 上游指的是你的代码所依赖的一些内容,下游指的是依赖于你的代码的一些内容。通常,这些术语在创建依赖关系时会直接使用,但它们也可以用来指代代码库的官方分支,如果你对Rust编译器进行分支,则官方Rust编译器就是你的“上游”。


在Rust中,建立平衡的规则是孤儿规则。简单地说,孤儿规则指出,只有当trait或类型在您的crate内部才可以为类型实现trait。因此,您可以为自己的类型实现Debug,也可以为bool实现MyNeatTrait,但是您不能为bool实现Debug。如果您尝试,代码将无法编译,编译器会告诉您存在冲突的实现。

这样可以让你有很大的发展空间,它可以让你为第三方类型实现自己的特性,也可以为你自己的类型实现第三方特性。然而,孤儿规则并不是故事的终点。你要知道还有许多额外的影响、警告以及例外情况。

Blanket Implementations

孤儿规则允许您使用诸如impl MyTrait for T where T:等代码在一系列类型上实现Trait。 这是一个全面的实现,它不仅限于特定类型,而是适用于各种类型。只有定义Trait的板条箱才允许编写全面实现,并且向现有Trait添加全面实现被视为破坏性更改。如果不这样做,包含impl MyTrait for Foo的下游箱可能会突然停止编译,因为通过有关冲突实现的错误更新定义MyTrait的板条箱。

Fundamental Types

有些类型非常关键,需要允许任何人在它们上面实现特质,即使这似乎违反孤儿规则。这些类型带有#[fundamental]属性,目前包括&、&mut和Box。为了孤儿规则的目的,基础类型可以说不存在,因为它们在孤儿规则检查之前被有效地擦除,以便您能够为&MyType实现IntoIterator。如果仅使用孤儿规则,由于它实现了一个外部类型的外部特质,所以这个实现将不被允许-IntoIterator和&都来自标准库。对基础类型进行全局实现也被认为是一种破坏性改变。

Covered Implementations

有一些情况是我们希望允许为异类实现外部Trait,而孤儿规则通常不允许。 最简单的例子是当您想编写类似 impl From for Vec 的内容时。 这里,From特征是外部的,Vec类型也是外部的,但没有破坏一致性的危险。 这是因为冲突的实现只能通过标准库中的平面实现添加(标准库无法命名MyType),这也会是一个破坏性的变化。

为了允许这些实现,孤儿规则包括一个狭窄的豁免条款,允许在非常特定的情况下为外部类型实现外部 trait。具体来说,只有当至少有一个 Ti 是本地类型,并且在第一个这样的 Ti 之前的所有 T 都不是泛型类型 P1..=Pn 时,才允许为 T0 实现 impl<P1..=Pn> ForeignTrait<T1..=Tn>。泛型类型参数(Ps)可以出现在 T0..Ti 中,只要它们被某个中间类型所覆盖。如果 T 作为某种其他类型的类型参数(如 Vec)出现,则称之为被覆盖的类型,但如果它单独出现(只有 T)或仅出现在类似 &T 的基本类型之后,则不算。因此,在 2-4 清单中的所有实现都是有效的。

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中的实现是无效的。

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:外部类型的外部特征无效实现

这项孤儿规则的放松使得确定何为在现有特征上添加新实现时会破坏性变更的规则更加复杂。特别是,仅当新实现包含至少一个新局部类型且该新局部类型符合先前所述例外规则时,添加到现有特征的任何新实现都不会破坏性地变更。而添加其他新实现则会导致破坏性变更。


Note; “请注意,impl ForeignTrait<LocalType,T> for ForeignType 是有效的,但是 impl ForeignTrait<T,LocalType> for ForeignType 不是!这似乎是武断的,但是如果没有这个规则,您可能会编写 impl ForeignTrait<T,LocalType> for ForeignType,另一个 crate 可以编写 impl ForeignTrait<TheirType,T> for ForeignType,只有当两个 crate 被合并时才会出现冲突。相反,孤儿规则要求您的本地类型在类型参数之前,这将打破平局,并确保如果两个 crate 隔离地保持连续性,它们在结合时也会保持连续性。


Trait Bounds

标准库中充斥着trait bounds,比如HashMap中的键必须实现Hash + Eq,以及给thread::spawn的函数必须是FnOnce + Send + 'static。当你自己编写通用代码时,几乎肯定会包括trait bounds,否则你的代码对于泛型类型将无从下手。在写更复杂的泛型实现时,你会发现需要更多的trait bounds约束,因此接下来我们来看一些实现方式。

首先,特征边界不一定要按照 T: Trait 这种格式,其中 T 是你的实现或类型泛型的类型。边界可以是任意类型限制,甚至不需要包括泛型参数、参数类型或本地类型。你可以写出类似 where String: Clone 这样的特征边界,即使 String: Clone 总是成立且不包含任何本地类型。你还可以写出类似 where io::Error: From<MyError> 这样的语句;你的泛型类型参数也不仅仅需要出现在左侧。这不仅允许您表达更复杂的限制,还可以避免不必要的重复限制。例如,如果你的方法想要构建一个 HashMap<K, V, S>,其中 K 为某个通用类型 T,而其值是一个 usize,那么你可以写出 where HashMap<T, usize, S>: FromIterator 这样的语句。这可以避免在使用最终方法时重复查找确切的边界要求,并更清晰地传达你代码的“真实”需求。从中你还可以看到,这也可以显著减少边界的复杂度,如果你要调用的底层特征方法的边界是复杂的,那么这些方法是有用的。


Note: Derive Trait

虽然#[derive(Trait)]非常方便,在trait bounds的上下文中,你应该知道它通常是如何实现的。许多#[derive(Trait)]扩展会被反转成impl Trait for Foo where T: Trait。这通常是你想要的,但并非总是如此。例如,考虑如果我们尝试为包含Arc的Foo以这种方式派生Clone会发生什么。Arc无论T是否实现Clone都会实现Clone,但由于派生的限制,只有T实现Clone时Foo才会实现Clone!这通常不是太大的问题,但它确实增加了一个不必要的限制。如果我们将类型重命名为Shared,这个问题可能会变得更加清晰。当编译器告诉用户他们不能克隆Shared时,想象一下他们会有多困惑!在撰写本文时,这是标准库提供的#[derive(Clone)]的工作方式,但这可能会在未来发生改变。

有时,您希望对通用类型的关联类型设置限制。例如,考虑迭代器方法flatten,它接受一个生成嵌套实现了Iterator的项的迭代器,并生成这些内部迭代器中项的迭代器。它生成的类型Flatten是泛型的,期望参数I是外部迭代器的类型。当且仅当I实现了Iterator并且I本身产生的项实现了IntoIterator时,Flatten实现Iterator。为了让您编写这样的限制,Rust允许您使用语法Type::AssocType引用类型的关联类型。例如,我们可以使用I::Item引用I的Item类型。如果一个类型有多个同名的关联类型,例如提供关联类型的特质本身是泛型的(因此有许多实现),则可以使用::AssocType的语法进行消歧。使用这个语法,您不仅可以为外部迭代器类型编写界限,还可以为该外部迭代器的项类型编写界限。

在广泛使用泛型的代码中,您可能会发现需要编写一个关于引用类型的边界。通常情况下,这没有问题,因为您往往也有一个可以用作这些引用的生命周期参数。但是在某些情况下,您希望该边界表示“这个引用对于任何生命周期都实现了这个 trait”。这种类型的边界称为更高阶的 trait 边界,尤其在与 Fn trait 结合使用时非常有用。例如,您想要通用一个函数,该函数接受对 T 的引用并返回 T 内部的引用。如果编写 F: Fn(&T) -> &U,您需要为这些引用提供生命周期,但您确实希望说“任何生命周期都可以,只要输出与输入相同”。使用更高阶的生命周期,您可以编写 F: for<'a> Fn(&'a T) -> &'a U,以表示对于任何生命周期'a',该边界都必须成立。 Rust 编译器足够智能, 当您像这样编写具有引用的 Fn 边界时,会自动添加 for,这涵盖了大多数情况下使用此功能的情况。有时确实需要显式表达这个边界,但是很少用到,在编写本文时,标准库仅在三个地方使用了它,但它确实存在,因此值得了解。

为了将所有内容整合在一起,请考虑清单2-6中的代码,该代码可用于实现任何可迭代类型及其元素具有调试功能。

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 实现

你可以将这个实现复制粘贴到几乎任何集合类型上,然后它就会“只管工作”。当然,你可能希望有一个更聪明的调试实现,但这很好地说明了特质限制的作用。

Marker Traits

通常,我们使用traits来表示多个类型可以支持的功能;通过调用hash,哈希类型可以被哈希,克隆类型可以通过调用clone来克隆,而调试类型可以通过调用fmt进行格式化。但并非所有traits都以这种方式为功能。一些被称为标记traits的traits,只表示实现类型的属性。标记traits没有方法或关联类型,仅仅告诉您特定类型是否可以或不能以某种方式使用。例如,如果类型实现了Send标记trait,则可以安全地将其发送到线程边界。如果不实现此标记trait,则不能安全发送。此行为没有关联的方法;这只是类型的事实。标准库在std::marker模块中有许多这样的标记traits,包括Send、Sync、Copy、Sized和Unpin。其中大多数(除Copy之外)也是auto-traits;编译器自动为类型实现它们,除非类型包含某些不实现标记traits的内容。

标记特征在 Rust 中起着重要作用:它们允许您编写捕捉代码中未直接表达的语义要求的边界。在需要类型为 Send 的代码中,没有调用来发送。相反,代码假定给定的类型可用于独立线程,并且没有标记特征,编译器将无法检查该假设。程序员需要记住这个假设并非常小心地阅读代码,我们都知道这不是我们想依赖的内容。这条路上充满了数据竞争、段错误和其他运行时问题。

类似于标记特征,标记类型也是这样的类型(例如结构体MyMarker;),它们不含数据也没有方法。标记类型很有用,因为它们可以标记特定状态的类型,从而避免用户误用API。例如,考虑一个类型SshConnection,它可能已经验证过或尚未验证。你可以给SshConnection添加一个泛型类型参数,然后创建两个标记类型:未经验证和已经验证。当用户首次连接时,它们会得到SshConnection。在其实现块中,您仅提供一个方法:连接。连接方法返回SshConnection,并且仅在该实现块中提供运行命令等其余方法。我们将在第3章进一步了解此模式。

Existential Types

在Rust中,你很少需要在函数体中指定变量的类型,或者在调用方法的泛型参数中指定类型。这是由于类型推断,编译器根据类型在代码中出现的位置进行推断使用什么类型。编译器通常只对变量以及闭包的参数(和返回类型)推断类型;顶级定义如函数、类型、特性和特性实现块都需要你显式命名所有类型。这有几个原因,但主要的原因是当你有至少某些已知的点来开始推断时,类型推断会更容易。但是,并不总是容易或者可能完全命名一个类型!例如,如果你从函数返回一个闭包,或者从特性方法中返回一个异步块,它的类型没有一个你可以在代码中输入的名称。

为了处理这种情况,Rust支持存在类型。你很可能已经看过存在类型的实际应用。所有标记为“async fn”或返回类型为“impl Trait”的函数都具有存在返回类型:签名不给出返回值的真实类型,只是暗示该函数返回实现某些调用者可以依赖的一些特定特征集的某种类型。而且至关重要的是,调用者只能依赖于返回类型实现这些特征集,而与此无关的特性则一概不受保证。


Note: “从技术上讲,调用方仅仅依赖返回类型这一点并不严格正确。编译器还会通过impl Trait在返回位置中传播自动派生的Send和Sync等trait。我们将在下一章更深入地探讨这个话题。


这种行为是赋予存在类型其名称的原因:我们断言存在某个符合签名的具体类型,并将寻找该类型的任务交给编译器。编译器通常会通过对函数主体进行类型推断来找出具体类型。

并非所有impl Trait实例都使用存在类型。如果您在函数的参数位置中使用impl Trait,那么它实际上只是该函数的未命名泛型参数的简写。例如,fn foo(s:impl ToString)大多只是fn foo<S:ToString>(s:S)的语法糖。

存在类型在实现具有关联类型的特质时非常有用。例如,想象一下你正在实现 IntoIterator 特质。它有一个关联类型 IntoIter,它保存了可以将所讨论的类型转换为的迭代器类型。使用存在类型,您无需定义一个单独的迭代器类型来用作 IntoIter。相反,您可以将关联类型指定为 impl terator<Item = Self::Item>,并在 fn into_iter(self) 中编写一个表达式,该表达式求值为迭代器,例如使用某些现有迭代器类型的映射和过滤。

存在类型不仅提供了方便,还提供了一种超越方便的特性:它们允许您执行零成本类型擦除。您可以使用存在类型来隐藏底层的具体类型,而不是仅仅因为它们出现在某个公共签名中而导出帮助类型——迭代器和future是常见的例子。您接口的用户仅会看到相关类型实现的特征,而具体类型则作为实现细节留下。这不仅简化了接口,还使您可以随意更改该实现,而不会破坏将来的下游代码。

Summary

本章提供了 Rust 类型系统的全面审查。我们既看到了编译器如何在内存中展现类型,又看到了编译器如何推断类型本身。这是编写不安全代码、复杂应用程序接口和后续章节中的异步代码的重要背景资料。你也会发现,本章中的大部分类型推理与你如何设计 Rust 代码接口有关,我们将在下一章中进行介绍。