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