第三章 设计接口

每个项目,无论大小,都有一个 API。事实上,它通常有几个。其中有些是面向用户的,比如 HTTP 端点或命令行接口,有些是面向开发者的,比如一个库的公共接口。在这些之上,Rust crates 还有一些内部接口:每个类型、特质和模块边界都有自己的微型 API,你的其他代码与之接口。随着你的代码库的规模和复杂性的增加,你会发现值得在如何设计内部 API 上投入一些心思和精力,以使使用和维护代码的经验尽可能的愉快。

在这一章中,我们将探讨在 Rust 中编写惯用接口的一些最重要的考虑因素,无论这些接口的用户是你自己的代码还是其他使用你的库的开发者。这基本上可以归结为四个原则:你的接口应该是意料中的 (unsurprising),灵活的 (flexible),明显的 (obvious),以及受约束的 (constrained)。我将依次讨论这些原则,以便为编写可靠和可用的接口提供一些指导。

我强烈建议你在读完本章后看看 Rust API 指南(https://rust-lang.github.io/api-guidelines/)。那里有一个很好的检查表,你可以按照它来做,并对每个建议进行了详细的梳理。本章中的许多建议也是由 cargo clippy 工具检查的,如果你还没有开始在你的代码上运行,你应该开始运行这个工具。我还鼓励你阅读 RFC 1105(https://rust-lang.github.io/rfcs/1105-api-evolution.html)和 The Cargo Book 中关于 SemVer 兼容性的章节(https://doc.rust-lang.org/cargo/reference/semver.html),这些章节涵盖了 Rust 中什么是和什么不是破坏性变化。

意料中的(Unsurprising)

最小惊奇原则,又称最小惊奇法则,在软件工程中经常出现,它对 Rust 接口也是如此。在可能的情况下,你的接口应该足够直观,如果用户需要猜测,他们通常会猜对。当然,并不是所有关于你的应用程序的东西都会立即变得直观,但任何不令人惊讶的东西都应该是直观的。这里的核心思想是紧贴用户可能已经知道的东西,这样他们就不必以不同于他们习惯的方式重新学习概念。这样一来,你就可以把他们的脑力节省下来,去弄清楚那些真正与你的接口有关的东西。

有很多方法可以让你的接口变得可预测。在这里,我们将看看你如何利用命名、共同特质和人体工程学特质的技巧来帮助用户解决问题。

命名惯例

用户在使用你的接口时,首先会通过它的名字来了解它;他们会立即开始从他们遇到的类型、方法、变量、字段和库的名称中推断出一些东西。如果你的接口重用了其他(也许是常见的)接口的名称--比如说,方法和类型,用户就会知道他们可以对你的方法和类型做出某些假设。一个叫做 iter 的方法可能需要 &self,并且可能给你一个迭代器。一个叫做 into_inner 的方法可能会接收 self,并且可能会返回某种包装好的类型。一个叫做 SomethingError 的类型可能实现了 std::error::Error,并出现在各种 Result 中。通过重复使用相同目的的通用名称,你让用户更容易猜到事情的作用,并让他们更容易理解你的接口的不同之处。

这方面的一个推论是,共用一个名字的东西实际上应该以同样的方式工作。否则--例如,如果你的 iter 方法接受 &self,或者如果你的 SomethingError 类型没有实现 Error--用户很可能会根据他们期望的接口工作方式写出错误的代码。他们会感到惊讶和沮丧,并不得不花时间去研究你的接口与他们的期望有什么不同。当我们可以为用户节省这种摩擦时,我们应该做。

类型的共同 traits

Rust 中的用户也会做出一个主要的假设,即接口中的一切都 "只是在工作"。他们期望能够用 {:?} 打印任何类型,并将任何东西发送到另一个线程,他们还期望每个类型都是 Clone。在可能的情况下,我们应该再次避免让用户感到惊讶,并急切地实现大多数标准特性,即使我们并不立即需要它们。

由于第二章中讨论的一致性规则,编译器将不允许用户在需要时实现这些特质。用户不允许为一个外来的类型(如 Clone)实现一个外来的特性,如你的接口中的一个。他们需要将你的接口类型包裹在他们自己的类型中,如果不接触该类型的内部结构,要写出一个合理的实现可能会相当困难。

在这些标准特性中,首先是 Debug 特性。几乎每个类型都可以,而且应该实现 Debug,即使它只打印类型的名称。使用#[derive(Debug)] 通常是在你的接口中实现 Debug trait 的最好方法,但请记住,所有派生 trait 都会自动为任何泛型参数添加相同的绑定。 你也可以简单地通过利用 fmt::Formatter 上的各种 debug_ 助手来编写自己的实现。

并列第二的是 Rust 的自动特性 SendSync(以及在较小的程度上,Unpin)。如果一个类型没有实现这些特性之一,应该是有很好的理由的。不是 Send 的类型不能被放在 Mutex 中,甚至不能在包含线程池的应用程序中过渡使用。不是同步的类型不能通过 Arc 共享或放在静态变量中。用户已经开始期望类型能在这些情况下工作,特别是在几乎所有东西都在线程池上运行的异步世界中,如果你不确保你的类型实现这些特性,他们会感到沮丧。如果你的类型不能实现这些特性,请确保这个事实和原因都被很好地记录下来。

你应该实现的下一组几乎通用的特性是 CloneDefault。这些特性可以很容易地被派生或实现,对大多数类型来说,实现这些特性是有意义的。如果你的类型不能实现这些特性,请确保在你的文档中指出来,因为用户通常期望能够轻松地创建更多(和新)类型的实例,因为他们认为合适。如果他们不能,他们会感到惊讶。

在预期特质的层次结构中再往下一步就是比较特质。PartialEq, PartialOrd, Hash, Eq, 和 OrdPartialEq 特质是特别可取的,因为用户在某些时候不可避免地会有两个你的类型的实例,他们希望用 ==assert_eq 来比较!。即使你的类型只对同一类型的实例进行等价比较,也值得实现 PartialEq 以使你的用户能够使用 assert_eq

PartialOrdHash 更为专业,可能适用范围不那么广,但在可能的情况下,你也要实现它们。 这对于用户可能用作地图中的键的类型,或者他们可能使用任何 std::collection 集合类型来重复的类型尤其如此,因为它们往往需要这些边界。除了 PartialEqPartialOrd 之外,EqOrd 还对实现类型的比较操作有额外的语义要求。这些在这些特性的文档中都有很好的记录,只有当你确定这些语义确实适用于你的类型时,你才应该实现它们。

最后,对于大多数类型来说,实现 serde 包的 SerializeDeserialize 特性是有意义的。这些都可以很容易地派生出来,而且 serde_derive 包甚至有机制可以重写一个字段或枚举变体的序列化。由于 serde 是一个第三方板块,你可能不希望添加对它的必要依赖。因此,大多数库选择提供一个 serde 特性,只有在用户选择时才增加对 serde 的支持。

你可能想知道为什么我没有把可派生特质 Copy 列入本节。有两件事使 Copy 与其他提到的特质不同。第一件事是,用户一般不期望类型是 Copy;恰恰相反,他们倾向于期望,如果他们想要某个东西的两个副本,他们必须调用 clone。复制改变了移动给定类型的值的语义,这可能会让用户感到惊讶。这与第二个观察相联系:一个类型很容易不再是 Copy,因为 Copy 类型受到高度限制。一个开始很简单的类型很容易最终不得不容纳一个字符串,或者其他一些非拷贝类型。如果发生这种情况,你不得不删除 Copy 的实现,这就是一个向后不兼容的变化。相比之下,你很少需要删除 Clone 的实现,所以这是个不太沉重的承诺。

人体工程学特质的实现 (Ergonomic Trait Implementations)

Rust 不会自动为对实现特质的类型的引用实现特质。换个说法,你一般不能用 &Bar 调用 fn foo<T: Trait>(t: T),即使 Bar:Trait。这是因为 Trait 可能包含了采取 &mut selfself 的方法,这显然不能在 &Bar 上调用。尽管如此,这种行为可能会让看到 Trait 只有 &self 方法的用户感到非常惊讶。

出于这个原因,当你定义一个新的特性时,你通常会想为该特性提供适当的覆盖实现,如 &T where T: Trait, &mut T where T: Trait,以及 &T where T: Trait, &mut T where T: Trait。你可能只能实现其中的一部分,这取决于 Trait 的方法有哪些接收者。标准库中的许多特质都有类似的实现,正是因为这样可以减少用户的意外。

迭代器是另一种情况,在这种情况下,你经常想在对一个类型的引用上特别添加特质实现。对于任何可以被迭代的类型,考虑为 &MyType&mut MyType 实现 IntoIterator。这使得循环在你的类型的借用实例上也能开箱即用,就像用户所期望的那样。

包装类型

Rust 没有经典意义上的对象继承。 然而,Deref trait 和它的表亲 AsRef 都提供了类似于继承的东西。这些特质允许你拥有一个 T 类型的值,并通过直接在 T 类型的值上调用方法,如果 T: Deref<Target = U> 的话。这对用户来说就像魔法一样,而且一般来说是很好的。

如果你提供了一个相对透明的包装类型(如 Arc),你很有可能想要实现 Deref,这样用户就可以通过使用 . 操作符来调用内部类型上的方法。如果访问内部类型不需要任何复杂或潜在的缓慢逻辑,你也应该考虑实现 AsRef,它允许用户轻松地将 &WrapperType 作为 &InnerType 使用。对于大多数包装类型,你还想尽可能地实现 From<InnerType>Into<InnerType>,这样你的用户就可以轻松地添加或删除你的包装。

你可能也遇到过 Borrow 特质,它感觉与 DerefAsRef 非常相似,但实际上有点不同。具体来说,Borrow 是为一个更狭窄的用例而定制的:允许调用者提供同一类型的多个本质上相同的变体中的任何一个。也许,它可以被称为等价 (Equivalent)。例如,对于一个 HashSet<String>Borrow 允许调用者提供一个 &str 或者一个 &String。虽然同样的情况可以用 AsRef 来实现,但如果没有 Borrow 的额外要求,即目标类型对 HashEqOrd 的实现与实现类型完全相同,这就不安全了。Borrow 还为 T&T&mut T 提供了一个 Borrow<T>的覆盖实现,这使得它在特质边界中的使用非常方便,可以接受一个给定类型的自有值或引用值。一般来说,Borrow 只用于你的类型本质上等同于另一个类型,而 DerefAsRef 则是为了更广泛地实现你的类型可以 "作为 "的任何东西。

DEREF 和固有方法 当 T 上有以 self 的方法时,围绕点运算符和 Deref 的魔法会变得混乱和令人惊讶。例如,给定一个值 tT,不清楚 t.frobnicate() 是对 T 还是对底层的 U 进行 frobnicate!由于这个原因,那些允许你透明地调用一些事先不知道的内部类型的方法的类型应该避免使用固有方法。Vec 可以有一个 push 方法,即使它解除对 slice 的引用,因为你知道 slice 不会很快得到一个 push 方法。但是,如果您的类型取消对用户控制的类型的引用,那么您添加的任何固有方法也可能存在于该用户控制的类型上,从而导致问题。在这些情况下,倾向于 fn frobnicate(t: t) 形式的静态方法。这样,t.frobnicate() 总是调用 U::frobnicate,而 t::frobnicate(t) 可以用来 T 本身。

灵活的

你写的每一段代码都隐含地或明确地包括一个契约。契约由一组要求和一组承诺组成。要求是对代码如何被使用的限制,而承诺是对代码如何被使用的保证。当设计一个新的接口时,你要仔细考虑这个契约。一个好的经验法则是避免强加不必要的限制,只做出你能遵守的承诺。 增加限制或删除承诺通常需要一个重大的语义版本变化,并可能破坏其他地方的代码。 另一方面,放松限制或给出额外的承诺,通常是向后兼容的。

在 Rust 中,限制通常以特质约束和参数类型的形式出现,而承诺则以特质实现和返回类型的形式出现。例如,比较清单 3-1 中的三个函数签名。

#![allow(unused)]
fn main() {
fn frobnicate1(s: String) -> String
fn frobnicate2(s: &str) -> Cow<'_, str>
fn frobnicate3(s: impl AsRef<str>) -> impl AsRef<str>

// 清单 3-1:具有不同契约的类似函数签名
}

这三个函数签名都接收一个字符串并返回一个字符串,但它们是在非常不同的契约下进行的。

第一个函数要求调用者以 String 类型的形式拥有字符串,它承诺将返回一个拥有(所有权)的 String。 由于契约要求调用者分配,并要求我们返回一个拥有的字符串,我们以后不能以向后兼容的方式使这个函数免分配。

第二个函数放宽了契约:调用者可以提供任何对字符串的引用,所以用户不再需要分配或放弃对一个字符串的所有权。它还承诺返回一个 std::borrow::Cow,这意味着它可以返回一个字符串引用或者一个拥有的字符串,这取决于它是否需要拥有该字符串。这里的承诺是,该函数将始终返回一个 Cow,这意味着我们不能,比如说,以后改变它以使用其他优化的字符串表示。调用者也必须特别提供一个&str,所以如果他们有,比如说,他们自己的一个预先存在的 String,他们必须将其解除引用为一个 &str 来调用我们的函数。

第三个函数取消了这些限制。它只要求用户传入可以生成字符串引用的类型,并且只承诺返回值可以生成字符串引用。

这些函数签名中没有一个比其他的更好。如果你在函数中需要一个字符串的所有权,你可以使用第一个参数类型来避免额外的字符串拷贝。如果你想让调用者利用拥有的字符串被分配和返回的情况,第二个返回类型为 Cow 的函数可能是一个好选择。相反,我想让你从中得到的是,你应该仔细考虑你的接口对你的约束是什么契约,因为事后改变它可能是破坏性的。

在本节的其余部分,我将举例说明经常出现的接口设计决定,以及它们对你的接口契约的影响。

泛型参数

你的接口必须对用户提出的一个明显的要求是他们必须向你的代码提供什么类型。如果你的函数明确地接受一个 Foo,用户必须拥有并给你一个 Foo。这是没办法的事。在大多数情况下,使用泛型而不是具体类型是值得的,这样可以让调用者传递任何符合你的函数实际需要的类型,而不是只传递一种特定的类型。将清单 3-1 中的 &str 改为 AsRef<str> 是这种放松的一个例子。以这种方式放宽要求的一个方法是,从参数的完全泛型开始,不设约束,然后根据编译器的错误来发现你需要添加什么约束。

然而,如果走到极端,这种方法将使每个函数的每个参数都成为自己的泛型,这将是既难读又难理解的。对于何时应该或不应该使一个给定的参数成为泛型,并没有硬性规定,所以请使用你的最佳判断。一个好的经验法则是,如果你能想到用户可能合理地、经常地想要使用的其他类型,而不是你开始使用的具体类型,就把参数变成泛型。

你可能还记得第 2 章,泛型代码通过单态化,对曾经使用过的每一种类型的组合都是重复的。考虑到这一点,使大量参数泛化的想法可能会让你担心过度扩大你的二进制文件。在第 2 章中,我们也讨论了如何使用动态调度来缓解这种情况,其性能代价(通常)可以忽略不计,这在这里也适用。对于那些你无论如何都要通过引用来获取的参数(记得 dyn Trait 不是 Sized,你需要一个宽指针来使用它们),你可以很容易地用一个使用动态调度的参数来替换你的通用参数。例如,你可以用 &dyn AsRef<str> 来代替 impl AsRef<str>

不过,在你跑去做这件事之前,有几件事情你应该考虑。首先,你是代表你的用户做出这个选择的,他们不能选择不使用动态调度。如果你知道你要应用动态调度的代码永远不会对性能敏感,这可能是好的。但如果有用户想在他们的高性能应用中使用你的库,那么在热循环中调用的函数中的动态调度可能会成为一个问题。其次,在写这篇文章的时候,只有当你有一个简单的特质约束时,使用动态调度才能发挥作用,比如 T:AsRef<str>impl AsRef<str>。对于更复杂的约束,Rust 不知道如何构造动态调度 vtable,所以你不能采取,比如说,&dyn Hash + Eq。最后,请记住,对于泛型,调用者总是可以通过传入一个 trait 对象来选择动态调度。反之则不然:如果你带了一个特质对象,那就是调用者必须提供的。

我们可能很想从具体的类型开始你的接口,然后随着时间的推移把它们变成泛型。要知道为什么,想象一下你把一个函数从 fn foo(v: &Vec<usize>) 改为 fn foo(v: impl AsRef<[usize]>) 。虽然每个 &Vec<usize> 都实现了 AsRef<[usize]>,但类型推理仍然会给用户带来问题。考虑一下如果调用者用 foo(&iter.collect()) 来调用 foo 会发生什么。在最初的版本中,编译器可以确定它应该收集到一个 Vec,但现在它只知道它需要收集到某个实现 AsRef<[usize]> 的类型。而且可能有多个这样的类型,所以有了这个改变,调用者的代码就不会再被编译了!

对象安全

当你定义一个新的 trait 时,该 trait 是否是对象安全的(见第 2 章编译和调度的结尾)是 trait 契约的一个不成文的部分。如果特质是对象安全的,用户可以使用 dyn Trait 将实现你的特质的不同类型视为单一的公共类型。如果不是这样,编译器将不允许该 trait 的 dyn Trait。你应该倾向于你的 trait 是对象安全的,即使这对使用它们的人机工程学来说有一点代价(比如在 &str 上使用 impl AsRef<str>),因为对象安全可以使你的 trait 有新的使用方式。如果你的 trait 必须有一个泛型方法,考虑它的泛型参数是否可以在 trait 本身上,或者它的泛型参数是否也可以使用动态调度来保持 trait 的对象安全。另外,你可以添加一个与该方法绑定的 where Self: Sized trait,这样就可以只用该 trait 的具体实例来调用该方法(而不是通过 dyn Trait)。你可以在 IteratorRead 特质中看到这种模式的例子,它们是对象安全的,但在具体实例上提供了一些额外的便利方法。

你应该愿意做出多少牺牲来保护对象的安全,这个问题没有唯一的答案。我的建议是,你要考虑你的特质将如何被使用,以及用户想把它作为一个特质对象使用是否有意义。如果你认为用户可能会想一起使用你的特质的许多不同的实例,你应该更努力地提供对象安全,而不是认为这种使用情况没有什么意义。例如,动态调度对于 FromIterator trait 来说是没有用的,因为它的一个方法不接受 self,所以你首先就不能构造一个 trait 对象。 同样,std::io::Seek 作为一个 trait 对象本身是相当无用的,因为你能用这样一个 trait 对象做的唯一事情就是寻找,而不能读或写。

DROP trait 对象 你可能认为 Drop 特质作为一个特质对象也是无用的,因为作为一个特质对象,你能用 Drop 做的就是析构它。但事实证明,有一些库特别希望能够丢弃任意类型。例如,一个提供延迟丢弃值的库,例如用于并发垃圾收集或只是延迟清理,只关心值是否可以被丢弃,而不关心其他。有趣的是,Drop 的故事并没有结束;因为 Rust 也需要能够丢弃 trait 对象,每个 vtable 都包含 drop 方法。实际上,每个 dyn Trait 也是一个 dyn Drop

请记住,对象安全是你的公共接口的一部分如果你以一种向后兼容的方式修改了一个特质,比如增加了一个带有默认实现的方法,但这使得该特质不是对象安全的,你需要提升你的主要语义版本号。

借用 vs 所有权

对于你在 Rust 中定义的几乎每一个函数、特质和类型,你必须决定它是否应该拥有,或者只是持有对其数据的引用。幸运的是,这些决定往往是自己做出的。

如果你写的代码需要数据的所有权,比如调用带有 self 的方法或将数据转移到另一个线程,它必须存储所有权数据。当你的代码必须拥有数据时,一般来说,它也应该让调用者提供拥有的数据,而不是通过引用和克隆来获取价值。这使得调用者可以控制分配,并且可以预先了解使用相关接口的成本。

另一方面,如果你的代码不需要拥有这些数据,它应该在引用上操作。这个规则的一个常见例外是像 i32boolf64 这样的小类型,它们直接存储和复制和通过引用存储一样便宜。不过要注意假设这对所有的 Copy 类型都是正确的;[u8; 8192]Copy,但如果到处存储和复制它,就会很昂贵。

当然,在现实世界中,事情往往不那么明确。 有时,你并不事先知道你的代码是否需要拥有数据。例如,String::from_utf8_lossy 需要拥有传递给它的字节序列的所有权,只有当它包含无效的 UTF-8 序列时。在这种情况下,Cow 类型是你的朋友:如果数据允许,它可以让你对引用进行操作,如果需要,它可以让你产生一个拥有的值。

其他时候,引用生存期使接口变得非常复杂,以至于使用起来很麻烦。如果你的用户在让代码在你的接口上面编译时很费劲,那就说明你可能要(甚至不必要地)对某些数据片断采取所有权。如果你这样做,在你决定对可能是一大块字节的数据进行堆分配之前,先从那些克隆起来很便宜或者不属于任何对性能敏感的数据开始。

易出错的和阻塞的析构函数

以 I/O 为中心的类型在析构时往往需要进行清理。这可能包括刷新写入磁盘的数据,关闭文件,或优雅地终止与远程主机的连接。执行这种清理的自然地方是在类型的 Drop 实现中。 不幸的是,一旦一个值被丢弃,除了惊慌失措之外,我们不再有办法向用户传达错误。一个类似的问题出现在异步代码中,我们希望在有工作未完成时完成。当 drop 被调用时,执行器可能已经关闭了,而我们没有办法做更多的工作。我们可以尝试启动另一个执行器,但这也会带来一系列的问题,比如异步代码中的阻塞,我们将在第 8 章看到。

这些问题没有完美的解决方案,无论我们做什么,一些应用程序将不可避免地回落到我们的 Drop 实现。出于这个原因,我们需要通过 Drop 提供尽力的清理。如果清理出错,至少我们试过了--我们吞下错误并继续前进。如果一个执行器仍然可用,我们可能会产生一个 future 来进行清理,但如果它永远不会运行,我们就做了我们能做的。

然而,我们应该为那些希望不留下松散线程的用户提供一个更好的选择。我们可以通过提供一个显式的析构器来做到这一点。这通常以一个方法的形式出现,该方法拥有 self 的所有权,并暴露任何错误(使用-> Result<_, _>)或异步(使用 async fn),这些都是销毁过程中固有的。一个谨慎的用户可以使用该方法来优雅地销毁任何相关的资源。

注意:一定要在文档中突出显示析构函数!

像往常一样,有一个权衡的问题。当你添加一个显式的析构器时,你会遇到两个问题。首先,由于你的类型实现了 Drop,你不能再在析构函数中移出该类型的任何字段。这是因为 Drop::drop 在你的显式析构器运行后仍然会被调用,而且它需要&mut self,这要求 self 的任何部分都没有被移动。其次,drop 接收的是 &mut self,而不是 self,所以你的 Drop 实现不能简单地调用你的显式析构器并忽略其结果(因为它并不拥有 self)。 有几个方法可以解决这些问题,但都不完美。

第一个方法是使你的顶层类型成为一个包裹着 Option 的新类型,而这个新类型又持有一些持有该类型所有字段的内部类型。然后你可以在两个析构函数中使用 Option::take,并且只在内部类型还没有被占用时才调用内部类型的显式析构函数。因为内层类型没有实现 Drop,所以你可以拥有那里的所有字段的所有权。这种方法的缺点是,你希望在顶层类型上提供的所有方法现在必须包括通过 Option(你知道它总是 Some,因为 Drop 还没有被调用)到内部类型上的字段的代码。

第二个解决方法是使你的每个字段都能被取走。 你可以通过用 None 替换 Option 来 "取走"它(这就是 Option::take 的作用),但你也可以对许多其他类型的字段这样做。 例如,你可以通过简单地用它们廉价的构造默认值替换 VecHashMap 来取走它们--std::mem::take 是你的朋友。如果你的类型有合理的 "空"值,这种方法就很好用,但如果你必须用 Option 包裹几乎所有的字段,然后用一个匹配的 unwrap 来修改这些字段的每一次访问,就会变得很乏味。

第三种选择是在 ManuallyDrop 类型中保存数据,它可以解除对内部类型的引用,所以不需要解包。你也可以在 drop 中使用 ManuallyDrop::take 来在销毁时取得所有权。这种方法的主要缺点是,ManuallyDrop::take 是不安全的。没有任何安全机制来确保你在调用 take 后不会尝试使用 ManuallyDrop 中的值,或者不会多次调用 take。如果你这样做了,你的程序就会默默地表现出未定义的行为,而且会有坏事发生。

最终,你应该选择这些方法中最适合你的应用的一种。我倾向于选择第二种方法,只有当你发现自己处于 OptionS 的海洋中时才会切换到其他方法。如果代码足够简单,你可以很容易地检查你的代码的安全性,而且你对自己的能力有信心,那么ManuallyDrop 解决方案是非常好的。

明显的

虽然有些用户可能熟悉支撑你的接口的实现的某些方面,但他们不可能理解所有的规则和限制。他们不会知道在调用 bar 之后再调用 foo 是绝对不行的,也不会知道只有在月亮呈 47 度角且过去 18 秒内没有人打喷嚏的情况下,调用不安全方法 baz 才是安全的。只有当接口清楚地表明有什么奇怪的事情发生时,他们才会伸手去拿文件或仔细阅读类型签名。因此,对你来说,让用户尽可能容易地理解你的接口,并让他们尽可能难以错误地使用你的接口是至关重要的。 在这方面,你所掌握的两个主要技术是你的文档和类型系统,所以让我们依次看一下这两个技术。

注意:你也可以利用命名来向用户暗示,一个接口的内容不只是看起来那么简单。如果用户看到一个名为 dangerous 的方法,他们很有可能会阅读其文档。

文档

让你的接口透明化的第一步是写好文档。我可以写一整本书来介绍如何编写文档,但在这里我们还是专注于针对 Rust 的建议。

首先,清楚地记录你的代码可能会做一些意想不到的事情,或者它依赖于用户做一些超出类型签名规定的事情。恐慌是这两种情况的一个很好的例子:如果你的代码可能会恐慌,请记录这一事实,以及它可能恐慌的情况。同样地,如果你的代码可能会返回一个错误,请记录它在哪些情况下会返回。对于不安全的函数,记录调用者必须保证什么才能使调用安全。

第二,在包和模块层面上为你的代码提供端到端的使用范例。这些例子比特定类型或方法的例子更重要,因为它们让用户感觉到所有东西是如何结合在一起的。有了对接口结构的高层次理解,开发者可能很快就会意识到特定的方法和类型的作用,以及它们应该用在什么地方。 端到端的例子也给用户一个自定义使用的起点,他们可以,而且经常会,复制粘贴这个例子,然后根据他们的需要进行修改。这种 "边做边学 "的方式往往比让他们尝试从组件中拼凑出一些东西更有效。

注意:非常针对方法的例子表明,是的,len 方法确实返回了长度,但不可能告诉用户关于你的代码的任何新情况。

第三,组织你的文档。把所有的类型、特质和函数放在一个顶层的模块中,会使用户难以了解从哪里开始。利用模块的优势,将语义相关的项目组合在一起。然后,使用文档内的链接来相互连接项目。如果 A 类型的文档谈到了 B 特质,那么就应该在这里链接到该特质。 如果你让用户很容易地探索你的接口,他们就不太可能错过重要的联系或依赖关系。也可以考虑用#[doc(hidden)] 来标记你的接口中那些不打算公开但由于遗留原因需要的部分,这样它们就不会使你的文档变得杂乱无章。

最后,尽可能地丰富你的文档。链接到解释概念、数据结构、算法或接口的其他方面的外部资源,这些资源可能在其他地方有很好的解释。RFCs、博客文章和白皮书都是很好的选择,如果有相关的话。使用#[doc(cfg(..))] 来强调只有在某些配置下才可用的项目,这样用户就能很快意识到为什么文档中列出的某些方法是不可用的。使用#[doc(alias = "...")] 使类型和方法可以在其他名称下被发现,以便用户可以搜索到它们。 在顶层文档中,指出用户常用的模块、特性、类型、特质和方法。

类型系统指导

类型系统是一个很好的工具,可以确保你的接口是明显的、自动文档化的,并且是抗误用的。你有几种技术可以使你的接口很难被滥用,从而使它们更有可能被正确使用。

其中第一种是语义类型,在这种类型中,你添加类型来表示一个值的含义,而不仅仅是其原始类型。这里的典型例子是布尔运算:如果你的函数需要三个布尔参数,那么很有可能一些用户会弄乱这些值的顺序,并在出了大错之后才意识到这一点。 另一方面,如果它需要三个不同的双变量枚举类型的参数,那么用户就不能在没有编译器吼叫的情况下弄错顺序:如果他们试图将 DryRun::Yes 传递给 overwrite 参数,这将根本不起作用,将 overwrite::No 作为 dry_run 参数也不行。你也可以在布尔运算之外应用语义类型。例如,围绕数字类型的 newtype 可以为所包含的值提供一个单位,或者它可以将原始指针参数限制在仅由另一个方法返回的参数上。

一个密切相关的技术是使用零大小的类型来表示一个特定的事实对于一个类型的实例是正确的。例如,考虑一个叫做 Rocket 的类型,它代表一个真正的火箭的状态。无论火箭处于什么状态,火箭上的一些操作(方法)都应该是可用的,但有些操作只有在特殊情况下才有意义。例如,如果火箭已经被发射了,就不可能再发射。同样的,如果火箭还没有发射,也不可能分离燃料箱。我们可以将这些建模为枚举变体,但是这样一来,所有的方法在每个阶段都是可用的,我们就需要引入可能的恐慌了。

相反,如清单 3-2 所示,我们可以在 Rocket 上引入一个通用参数 Stage,并使用它来限制什么时候可以使用什么方法。

#![allow(unused)]
fn main() {
struct Grounded;   // (1)
struct Launched;
// and so on
struct Rocket<Stage = Grounded> {
    stage: std::marker::PhantomData<Stage>, // (2)
}
impl Default for Rocket<Grounded> {}    // (3)
impl Rocket<Grounded> {
    pub fn launch(self) -> Rocket<Launched> { }
}
impl Rocket<Launched> { // (4)
    pub fn accelerate(&mut self) { }
    pub fn decelerate(&mut self) { }
}
impl<Stage> Rocket<Stage> { // (5)
    pub fn color(&self) -> Color { }
    pub fn weight(&self) -> Kilograms { }
}

// 第 3-2 项:使用标记类型来限制实现的方法
}

我们引入单元类型来表示火箭的每个阶段 (1)。我们实际上不需要存储阶段--只需要存储它提供的元信息--所以我们把它存储在 PhantomData (2) 后面,以保证它在编译时被消除。然后,我们只在 Rocket 持有特定类型的参数时为其编写实现块。你只能在地面上建造一个火箭(目前),而且你只能从地面上发射它 (3)。只有当火箭发射后,你才能控制它的速度 (4)。无论火箭处于什么状态,你都可以对它做一些事情,这些事情我们放在一个通用的实现块中 (5)。 你会注意到,以这种方式设计的接口,用户根本不可能在错误的时间调用一个方法,我们已经将使用规则编码在类型本身中,并使非法状态无法表示。

这个概念也延伸到许多其他领域;如果你的函数忽略了一个指针参数,除非给定的布尔参数为真,那么最好把这两个参数结合起来。 有了一个枚举类型,其中一个变体代表 false(没有指针),一个变体代表 true,持有一个指针,无论是调用者还是实现者都不会误解这两者之间的关系。 这是一个强大的想法,我强烈鼓励你利用它。

另一个使接口明显的小工具是 #[must_use] 注解。把它添加到任何类型、特质或函数中,如果用户的代码接收到该类型或特质的元素,或调用该函数,而没有明确地处理它,编译器就会发出警告。你可能已经在 Result 的上下文中看到了这一点:如果一个函数返回一个 Result,而你没有把它的返回值分配到某个地方,你会得到一个编译器警告。不过要注意不要过度使用这个注解--只有在用户不使用返回值时很可能会犯错的情况下才会添加它。

受约束的

随着时间的推移,一些用户会依赖你的接口的每一个属性,无论是错误还是功能。这对于公开的库来说尤其如此,因为你无法控制你的用户。因此,在进行用户可见的改变之前,你应该仔细考虑。无论你是添加一个新的类型、字段、方法或特质实现,还是改变一个现有的,你都要确保这个改变不会破坏现有用户的代码,而且你打算将这个改变保留一段时间。频繁的向后不兼容的变更(语义版本学中的主要版本增加)肯定会引起用户的愤怒。

许多向后不兼容的变化是显而易见的,比如重命名一个公共类型或删除一个公共方法,但有些是比较微妙的,与 Rust 的工作方式有很大关系。在这里,我们将介绍一些比较棘手的微妙变化,以及如何为它们制定计划。你会发现,你需要在其中一些变化与你希望你的接口有多大的灵活性之间取得平衡--有时候,有些东西必须要让步。

类型修改

删除或重命名一个公共类型几乎肯定会破坏一些用户的代码。为了解决这个问题,你要尽可能地利用 Rust 的可见性修改器,比如 pub(crate)pub(in path)。你拥有的公有类型越少,你就有更多的自由去改变事情而不破坏现有的代码。

不过,用户代码可以在更多的方面依赖你的类型,而不仅仅是名称。考虑一下清单 3-3 中的公共类型和该代码的给定用途。

#![allow(unused)]
fn main() {
// 在你的接口
pub struct Unit;
// 在用户的代码
let u = lib::Unit;

// 清单 3-3:一个看起来无辜的公共类型
}

现在考虑一下如果你给 Unit 添加一个私有字段会发生什么。尽管你添加的字段是私有的,但这个改变仍然会破坏用户的代码,因为他们所依赖的构造函数已经消失了。 类似地,考虑清单 3-4 中的代码和用法

#![allow(unused)]
fn main() {
// 你的接口
pub struct Unit { pub field: bool };
// 用户代码
fn is_true(u: lib::Unit) -> bool {
    matches!(u, Unit { field: true })
}

// 清单 3-4:访问单个公共字段的用户代码
}

在这里,给 Unit 添加一个私有字段也会破坏用户代码,这次是因为 Rust 的详尽模式匹配检查逻辑能够看到用户看不到的接口部分。它认识到有更多的字段,尽管用户代码无法访问它们,并拒绝用户的模式为不完整。如果我们把元组结构变成带有命名字段的普通结构,也会出现类似的问题:即使字段本身完全相同,任何旧的模式对新的类型定义也不再有效。

Rust 提供了 #[non_exhaustive] 属性来帮助缓解这些问题。你可以把它添加到任何类型的定义中,编译器将不允许在该类型上使用隐式构造函数(如 lib::Unit { field1: true })和非穷举模式匹配(即没有尾巴的模式,..)。如果你怀疑自己将来可能会修改某个特定的类型,这是一个很好的属性。但它确实限制了用户的代码,例如剥夺了用户依赖穷举模式匹配的能力,所以如果你认为一个给定的类型可能会保持稳定,就不要添加它。

Trait 实现

正如你在第 2 章中所回忆的,Rust 的一致性规则不允许一个给定类型的多个特性的实现。由于我们不知道下游代码可能添加了哪些实现,所以添加一个现有特质的覆盖实现通常是一种破坏性的改变。同样的道理也适用于为一个现有类型实现一个外来特质,或者为一个外来类型实现一个现有特质--在这两种情况下,外来特性或类型的所有者可能同时添加一个冲突的实现,所以这一定是一个破坏性的改变。

删除一个特质的实现是一个破坏性的改变,但是为一个新的类型实现特质从来都不是问题,因为没有一个包可以有与该类型冲突的实现。

也许是反直觉的,你也要小心为现有的类型实现任何特质。要知道为什么,请看清单 3-5 中的代码。

// crate1 1.0
pub struct Unit;
pub trait Foo1 { fn foo(&self) }
// note that Foo1 is not implemented for Unit

// crate2; depends on crate1 1.0
use crate1::{Unit, Foo1};
trait Foo2 { fn foo(&self) }
impl Foo2 for Unit { .. }
fn main() {
    Unit.foo();
}

// 清单 3-5: 为一个现有的类型实现一个特质可能会引起问题。

如果你在 crate1 中添加了 impl Foo1 for Unit,而没有将其标记为突破性变化,那么下游的代码会突然停止编译,因为现在对 foo 的调用是不明确的。这甚至可以适用于新的公共特质的实现,如果下游的包使用通配符导入(使用 cate1::*)。如果你提供了一个 prelude 模块,并指示用户使用通配符导入,你将特别需要记住这一点。

对现有特质的大多数改变也是破坏性的改变,例如改变方法签名或添加新方法。改变一个方法的签名会破坏该特质的所有实现,可能还有很多用途,而添加一个新的方法"只是"破坏所有的实现。添加一个带有默认实现的新方法是可以的,因为现有的实现将继续适用。

我在这里说 "一般 "和 "大多数",是因为作为接口作者,我们有一个工具可以让我们绕过其中的一些规则:密封的特质。一个密封的特质是一个只能由其他包使用,而不能实现的特质。这立即使一些破坏性的变化变得不那么破坏。例如,你可以为一个密封的特质添加一个新的方法,因为你知道在当前的包之外没有任何实现需要考虑。同样地,你可以为新的外部类型实现一个密封的特质,因为你知道定义该类型的外部包不可能添加一个冲突的实现。

Sealed 特性最常用于派生特性--为实现特定其他特性的类型提供覆盖实现的特性。只有当外部的包实现你的特性没有意义时,你才应该封闭一个特性;这严重限制了该特性的实用性,因为下游的包将不再能够为他们自己的类型实现该特性。你也可以使用密封的特性来限制哪些类型可以被用作类型参数,比如在清单 3-2 中的火箭例子中,将 Stage 类型限制为只有 GroundedLaunched 的类型。

清单 3-6 显示了如何封存一个特质,以及如何在定义箱中为它添加实现。

#![allow(unused)]
fn main() {
pub trait CanUseCannotImplement: sealed::Sealed /* (1)  */ { .. }
mod sealed {
    pub trait Sealed {}
    impl<T> Sealed for T where T: TraitBounds {} // (2)
}
impl<T> CanUseCannotImplement for T where T: TraitBounds {}

// 清单 3-6:如何密封一个特质并为其添加实现
}

诀窍是添加一个私有的、空的特质,作为你希望封 (1) 的特质的一个超级特质。由于这个父 trait 在一个私有模块中,其他的板块无法接触到它,因此也无法实现它。封闭的特质要求底层类型实现 Sealed,所以只有我们明确允许的类型 (2) 才能最终实现该特质。

注意:如果你确实以这种方式封印了一个特性,请确保你记录了这一事实,这样用户在试图自己实现特性时就不会感到沮丧了。

隐性契约

有时,你对代码的某一部分所做的修改会以微妙的方式影响到接口中其他地方的契约。发生这种情况的两种主要方式是通过再导出和自动特质(auto-traits)。

重导出

如果你的接口的任何部分导出了外部类型,那么对这些外部类型之一的任何改变也是对你接口的改变。例如,考虑一下如果你转到一个新的依赖关系的主要版本,并将该依赖关系中的一个类型作为,例如,你的接口中的一个迭代器类型公开,会发生什么。依赖于你的接口的用户可能也会直接依赖该依赖关系,并期望你的接口提供的类型与该依赖关系中的同名类型相同。但是如果你改变了你的依赖关系的主要版本,即使类型的名称是一样的,这也不再是真的了。清单 3-7 显示了一个这样的例子。

#![allow(unused)]
fn main() {
// your crate: bestiter
pub fn iter<T>() -> itercrate::Empty<T> { .. }
// their crate
struct EmptyIterator { it: itercrate::Empty<()> }
EmptyIterator { it: bestiter::iter() }

// 清单 3-7:重新导出使外部的包成为接口契约的一部分。
}

如果你的 crate 从 itercrate 1.0 移到 itercrate 2.0,但其他方面没有变化,这个列表中的代码将不再被编译。 尽管类型没有变化,编译器认为(正确地)itercrate1.0::Emptyitercrate2.0::Empty 是不同的类型。因此,你不能将后者赋值给前者,这使得你的接口发生了破坏性的变化。

为了减少这样的问题,通常最好使用 newtype 模式来包装外部类型,然后只公开外部类型中你认为有用的部分。在很多情况下,你可以通过使用 impl Trait 来避免 newtype 包装器,只向调用者提供非常小的契约。通过少许承诺,你可以减少改变的次数。

SEMVER 的诀窍 itercrate 的例子可能让你感到不快。如果 Empty 类型没有改变,那么为什么编译器不允许任何使用它的东西继续工作,而不管代码是使用它的 1.0 还是 2.0 版本?答案是。..... 复杂的。它归结为这样一个事实:Rust 编译器并不认为仅仅因为两个类型有相同的字段,它们就是相同的。举个简单的例子,想象一下 itercrate 2.0Empty 增加了一个#[derive(Copy)]。现在,这个类型突然有了不同的移动语义,这取决于你使用的是 1.0 还是 2.0!而用其中一个类型编写的代码在另一个类型中就无法使用。

这个问题往往会出现在大型的、广泛使用的库中,随着时间的推移,破坏性的改变很可能会发生在箱体的某个地方。不幸的是,语义上的版本控制是在包层面上进行的,而不是在类型层面上进行的,因此,任何地方的破坏性改变都是一种破坏性改变。

但是,一切并没有失去。几年前,David Tolnay(serde 的作者,还有其他大量的 Rust 贡献)想出了一个巧妙的技巧来处理这种情况。他称其为 "semver 技巧"。这个想法很简单:如果某个类型的 T 在突破性变化中保持不变(比如从 1.0 到 2.0),那么在发布 2.0 之后,你可以发布一个新的 1.0 次要版本,该版本依赖于 2.0,并且用 2.0 中的 T 的重导出来替换 T

通过这样做,你可以确保两个主要版本中都只有一个单一的 T 类型。这反过来又意味着任何依赖于 1.0 的板块都可以使用 2.0 的 T,反之亦然。因为这只发生在你明确选择的类型上,所以那些事实上会破坏的变化将继续存在。

自动 traits(Auto-Traits)

Rust 有一些特质,这些特质会根据每个类型所包含的内容而自动实现。其中与本讨论最相关的是 SendSync,尽管 UnpinSizedUnwindSafe traits 也有类似的问题。就其本质而言,这些特质为你接口中的几乎所有类型添加了一个隐藏的承诺。 这些特质甚至可以通过其他类型消除的类型传播,比如 impl Trait

这些特性的实现(通常)是由编译器自动添加的,但这也意味着,如果它们不再适用,就不会自动添加。所以,如果你有一个包含私有类型 B 的公共类型 A,而你改变了 B,使其不再是 Send,那么 A 现在也不再是 Send 了。这就是一个破坏性的变化!

这些变化可能很难被跟踪,而且往往直到你的接口的用户抱怨他们的代码不再工作时才被发现。为了在这些情况发生之前抓住它们,在你的测试套件中加入一些简单的测试,以检查你的所有类型是否以你期望的方式实现了这些特性,这是一个很好的做法。 清单 3-8 给出了这样一个测试的例子。

#![allow(unused)]
fn main() {
fn is_normal<T: Sized + Send + Sync + Unpin>() {}
#[test]
fn normal_types() {
    is_normal::<MyType>();
}

// 清单 3-8:测试一个类型是否实现了一组特征
}

注意,这个测试没有运行任何代码,只是测试代码的编译情况。如果 MyType 不再实现 Sync,测试代码将不能编译,你将知道你刚才的改变破坏了自动跟踪的实现。

从文件中隐藏项目 #[doc(hidden)] 属性可以让你在文档中隐藏一个公共项目,而不至于让碰巧知道它存在的代码无法访问。这通常被用来暴露那些被宏所需要的方法和类型,但不是被用户代码所需要的。这种隐藏的项目如何与你的接口契约互动是一个有争议的问题。一般来说,标记为 #[doc(hidden)] 的项目只被认为是你的契约的一部分,就其公共效果而言;例如,如果用户代码最终可能包含一个隐藏的类型,那么该类型是 Send 是契约的一部分,而其名称则不是。隐藏的固有方法和隐藏在密封特质上的特质方法通常不是你的接口契约的一部分,尽管你应该确保在这些方法的文档中明确说明这一点。是的,隐藏的项目仍然应该被记录下来!

总结

在这一章中,我们探讨了设计 Rust 接口的许多方面,无论它是为了供外部使用,还是仅仅作为你的 crate 中不同模块之间的一个抽象边界。我们介绍了很多具体的陷阱和技巧,但最终,高层次的原则应该指导你的思考:你的接口应该是不令人惊讶的、灵活的、明显的和受约束的。在下一章中,我们将深入探讨如何表示和处理 Rust 代码中的错误。