Rust 生态系统

如今,编程很少发生在真空中--几乎每一个你建立的 Rust crate 都有可能依赖一些不是你写的代码。这种趋势是好是坏,还是两者兼而有之,这是一个激烈争论的话题,但无论如何,这都是当今开发者体验的一个现实。

在这个勇敢的相互依存的新世界里,牢固掌握哪些库和工具是可用的,并及时了解 Rust 社区所提供的最新和最伟大的东西,这比任何时候都重要。本章专门讨论了如何利用、跟踪、了解和回馈 Rust 生态系统。由于这是最后一章,在结尾部分我还会提供一些建议,让你可以探索更多的资源来继续发展你的 Rust 技能。

那里有什么?(What’s Out There?)

尽管 Rust 相对年轻,但它已经有了一个足够大的生态系统,很难跟踪所有可用的东西。如果你知道自己想要什么,你也许可以通过搜索找到一组合适的 crate,然后使用下载统计和对每个 crate 的仓库进行肤浅的检查,以确定哪些 crate 可能成为合理的依赖。然而,还有大量的工具、工具箱和一般的语言特性,你可能不一定知道要寻找这些工具、工具箱和语言特性,它们有可能为你节省无数的时间和困难的设计决策。

在本节中,我将介绍一些我多年来发现的有帮助的工具、库和 Rust 特性,希望它们在某些时候也能对你有所帮助。

工具

首先,这里有一些我发现自己经常使用的 Rust 工具,你应该加入你的工具箱:

  • cargo-deny

    提供了一种对依赖关系图进行提示的方法。在写这篇文章的时候,你可以使用 cargo-deny 来只允许某些许可证,拒绝列出板条或特定板条的版本,检测有已知漏洞或使用 Git 源的依赖关系,以及检测在依赖关系图中多次出现不同版本的板条。当你读到这里的时候,可能已经有了更多方便的衬垫。

  • cargo-expand

    展开给定 crate 中的宏,并让你检查输出结果,这使你更容易发现宏转录器或程序性宏深处的错误。当你自己编写宏时,cargo-expand 是一个宝贵的工具。

  • cargo-hack

    帮助你检查你的货箱在启用任何功能组合的情况下都能工作。该工具的界面与 Cargo 本身的界面相似(如 cargo check、build 和 test),但让你能够用所有可能的组合(poweret)来运行一个给定的命令,即箱子的特性。

  • cargo-llvm-lines

    分析从 Rust 代码到中间表示(IR)的映射,该映射被传递给 Rust 编译器中实际生成机器代码的部分(LLVM),并告诉你 Rust 代码的哪些部分产生了最大的 IR。这很有用,因为更大的 IR 意味着更长的编译时间,所以确定哪些 Rust 代码产生了更大的 IR(由于,例如,单态化)可以突出减少编译时间的机会。

  • cargo-outdated

    检查你的任何依赖关系,无论是直接的还是横向的,是否有更新的版本。重要的是,与 cargo update 不同,它甚至会告诉你新的主要版本,所以它是检查你是否由于主要版本指定器过期而错过新版本的重要工具。请记住,如果你在你的接口中暴露了一个依赖的类型,那么提高该依赖的主要版本可能会对你的 crate 造成破坏性的变化!

  • cargo-udeps

    识别 Cargo.toml 中列出的任何从未实际使用的依赖关系。也许你过去使用过它们,但后来它们变得多余了,或者它们应该被移到 dev-dependencies 中;无论如何,这个工具可以帮助你减少依赖关系闭合中的臃肿。

虽然它们不是专门用于开发 Rust 的工具,但我也强烈推荐 fdripgrep--它们比它们的前辈 find 和 grep 有了很好的改进,而且刚好是用 Rust 自己写的。我每天都在使用这两个工具。

接下来是一些有用但鲜为人知的箱子,我经常拿出来用,而且我怀疑我将继续长期依赖这些箱子。

  • bytes

    提供了一种有效的机制来传递单块连续内存的子片,而不需要复制或处理生命期。这在低级别的网络代码中是非常好的,因为你可能需要在一个字节块中有多个视图,而复制是不可能的。

  • criterion

    一个统计学驱动的基准测试库,使用数学来消除基准测量中的噪音,并可靠地检测性能随时间的变化。如果你在你的工具箱中包括微观的基准测试,你几乎肯定应该使用它。

  • cxx

    为从 Rust 中调用 C++代码和从 C++中调用 Rust 代码提供了一个安全且符合人体工程学的机制。如果你愿意投入一些时间,提前更彻底地声明你的接口,以换取更好的跨语言兼容性,这个库非常值得你关注。

  • flume

    实现了一个多生产者、多消费者的通道,比 Rust 标准库中的通道更快、更灵活、更简单。它还支持异步和同步操作,因此是这两个世界之间的一个伟大的桥梁。

  • hdrhistogram

    高动态范围(HDR)直方图数据结构的 Rust 端口,它提供了一个跨越广泛价值范围的直方图的紧凑表示。在你目前追踪平均值或最小/最大值的任何地方,你都应该使用 HDR 直方图来代替;它可以让你更好地了解你的指标的分布。

  • heapless

    提供不使用堆的数据结构。相反,heapless 的数据结构都是由静态内存支持的,这使得它们非常适合于嵌入式环境或其他不希望分配的情况。

  • itertools

    扩展了标准库中的 Iterator 属性,为重复数据处理、分组和计算权力集提供了很多新的方便方法。这些扩展方法可以大大减少代码中的模板,比如你在一个数值序列上手动实现一些常见的算法,比如同时找到最小和最大(Itertools::minmax),或者你使用一种常见的模式,比如检查一个迭代器是否正好有一个项目(Itertools::exact_one)。

  • nix

    在类似 Unix 的系统上提供与系统调用的习惯性绑定,这比在直接使用类似 libc 的东西时试图拼凑与 C 兼容的 FFI 类型要好得多。

  • pin-project

    提供了为注释类型强制执行引脚安全不变式的宏,这反过来又为这些类型提供了一个安全的引脚接口。这使得你可以避免为你自己的类型获得 Pin 和 Unpin 的大部分麻烦。还有一个 pinproject-lite,它避免了(目前)对程序性宏机制的严重依赖,但代价是人机工程学方面略差。

  • ring

    从 C 语言编写的密码学库 BoringSSL 中提取好的部分,并通过一个快速、简单和难以滥用的接口将其引入 Rust。如果你需要在你的程序箱中使用密码学,它是一个很好的起点。你很可能已经在 rustls 库中遇到过了,它使用 ring 来提供一个现代的、安全的默认 TLS 栈。

  • slab

    实现一个高效的数据结构来代替 HashMap<Token, T>,其中 Token 是一个不透明的类型,仅用于区分地图中的条目。这种模式在管理资源时经常出现,当前的资源集必须被集中管理,但单个资源也必须以某种方式被访问。

  • static_assertions

    提供静态断言--即在编译时评估的断言,因此在编译时可能失败。你可以用它来断言一些事情,比如一个类型实现了一个给定的特性(如 Send)或具有一个给定的大小。我强烈建议在这些保证可能很重要的代码中添加这些类型的断言。

  • structopt

    包裹了著名的参数解析库 clap,并提供了一种完全使用 Rust 类型系统(加上宏注释)来描述你的应用程序的命令行界面的方法。当你解析你的应用程序的参数时,你会得到一个你定义的类型的值,因此你会得到所有类型检查的好处,比如穷举匹配和 IDE 自动补全。

  • thiserror

    使得编写自定义枚举错误类型,如我们在第 4 章讨论的那些,成为一种乐趣。它负责实现推荐的特征并遵循既定的惯例,让你只需定义你的应用程序所特有的关键部分。

  • tower

    有效地采用了函数签名 async fn(Request) -> Response,并在其之上实现了整个生态系统。其核心是服务特性,它代表了一种可以将请求转化为响应的类型(我猜想有一天它可能会进入标准库)。这是一个很好的抽象,可以在其上建立任何看起来像服务的东西。

  • tracing

    它提供了有效追踪你的应用程序的执行所需的所有管道。最重要的是,它与你要追踪的事件类型以及你想用这些事件做什么无关。这个库可以用于记录、指标收集、调试、剖析和明显的跟踪,所有这些都有相同的机制和接口。

Rust 工具

Rust 工具链有一些你可能不知道要寻找的功能。这些功能通常是针对非常特殊的用例,但如果它们与你的用例相匹配,它们就会成为救命稻草。

Rustup

Rustup 是 Rust 工具链的安装程序,它的工作非常高效,以至于它往往会淡出后台,被人遗忘。你偶尔会用它来更新你的工具链,设置一个目录覆盖,或者安装一个组件,但仅此而已。然而,Rustup 支持一个非常方便的技巧,值得了解:工具链覆盖速记。你可以把+toolchain 作为第一个参数传递给任何 Rustup 管理的二进制文件,二进制文件将像你为给定的工具链设置覆盖一样工作,运行该命令,然后将覆盖重新设置为之前的样子。因此,cargo +nightly miri 将使用 nightly 工具链运行 Miri,cargo +1.53.0 check 将检查代码是否用 Rust 1.53.0 编译。后者在检查你有没有破坏你的最小支持的 Rust 版本合同时特别方便。

Rustup 还有一个漂亮的子命令,doc,它可以在你的浏览器中打开当前版本 Rust 编译器的 Rust 标准库文档的本地拷贝。如果你在没有互联网连接的情况下进行开发,这一点是非常宝贵的。

Cargo

Cargo 也有一些不容易被发现的功能。首先是 cargo tree,这是 Cargo 本身的一个子命令,用于检查箱子的依赖关系图。 这个命令的主要目的是把依赖关系图打印成树状。这本身就很有用,但 cargo tree 真正的闪光点在于 --invert 选项:它接收一个装箱的标识符,并产生一个反转的树形图,显示从当前装箱带来的所有依赖路径。因此,例如,cargo tree -i rand 会打印出当前 crate 依赖任何版本的 rand 的所有方式,包括通过传递性依赖关系。如果你想消除一个依赖关系,或者一个依赖关系的特定版本,并想知道为什么它仍然被拉进来,这就非常有价值了。你也可以通过-e features 选项来包括有关箱体的每个 Cargo 特性被启用的信息。

说到 Cargo 的子命令,编写自己的子命令真的很容易,不管是为了和别人分享还是为了自己的本地开发。当 Cargo 被调用一个它不认识的子命令时,它会检查是否存在一个名字为 cargo-$subcommand 的程序。如果存在,Cargo 就会调用该程序,并把命令行上的参数传给它--因此,cargo foo bar 就会调用 cargo-foo,参数为 bar。Cargo 甚至会把这个命令和 cargo help 结合起来,把 cargo help foo 翻译成对 cargo-foo --help 的调用。

当你在更多的 Rust 项目上工作时,你可能会注意到 Cargo(以及更普遍的 Rust)在磁盘空间方面并不宽松。每个项目都有自己的目标目录用于编译工件,随着时间的推移,你最终会积累几份相同的编译工件用于共同的依赖。将每个项目的工件分开是一个明智的选择,因为它们不一定能在不同的项目间兼容(比如,如果一个项目使用的编译器标志与另一个不同)。但在大多数开发者环境中,共享构建工件是完全合理的,在项目间切换时可以节省大量的编译时间。幸运的是,配置 Cargo 来共享构建工件很简单:只要在你的 ~/.cargo/config.toml 文件中设置 [build] target 为你希望共享工件的目录,Cargo 就会处理剩下的事情。再也看不到目标目录了!只要确保你偶尔清理一下该目录,并注意 cargo clean 会清理你所有项目的构建工件。

注意:使用共享构建目录会给那些假定编译器工件总是在 target/ 子目录下的项目带来问题,所以要注意这一点。还要注意的是,如果一个项目确实使用了不同的编译器标志,那么每次你进入或离开该项目时,你都会重新编译受影响的依赖项。在这种情况下,你最好在项目的 Cargo 配置中把目标目录覆盖到一个不同的位置。

最后,如果你觉得 Cargo 花了很长时间来编译你的程序,你可以使用目前不稳定的 Cargo -Ztimings 标志。使用该标志运行 Cargo 时,会输出以下信息:处理每个 crate 需要多长时间,构建脚本需要多长时间,哪些 crate 需要等待其他 crate 完成编译,以及其他大量有用的衡量标准。这可能会突出一个特别慢的依赖链,然后你可以努力消除它,或者揭示一个从头开始编译本地依赖的构建脚本,你可以使用系统库来代替。如果你想更深入地研究,还有 rustc -Ztime-passes,它可以发出关于每个编译器内部花费的时间的信息--尽管这些信息可能只有在你想对编译器本身做出贡献时才有用。

rustc

Rust 编译器也有一些鲜为人知的功能,可以证明对有进取心的开发者很有用。第一个是当前不稳定的 -Zprint-type-sizes 参数,它可以打印出当前编译器中所有类型的大小。除了最微小的 crate 外,这将产生大量的信息,但在试图确定调用 memcpy 所花费的意外时间的来源时,或者在分配大量特定类型的对象时寻找减少内存使用的方法时,这是非常有价值的。 -Zprint-type-sizes 参数还显示了每个类型的计算对齐方式和布局,这可能会给你指出一些地方,例如,把 usize 变成 u32 可能会对一个类型的内存表示产生重大影响。在你调试了一个特定类型的大小、排列和布局之后,我建议添加静态断言,以确保它们不会随着时间的推移而退步。你也可能对 variant_size_differences lint 感兴趣,如果一个 crate 包含枚举类型,其变体在大小上有很大差异,它会发出警告。

注意:要用特定的标志来调用 rustc,你有几个选择:你可以在 RUSTFLAGS 环境变量中设置这些标志,或者在你的。cargo/config.toml 中设置 [build] rustflags,让它们适用于 Cargo 中的每一次 rustc 调用,或者你可以使用 cargo rustc,它将把你提供的任何参数只传递给当前 crate 的 rustc 调用。

如果你的剖析样本看起来很奇怪,栈帧被重新排序或完全丢失,你也可以试试 -Cforce-frame-pointers = yes。帧指针提供了一种更可靠的方式来展开栈--这在剖析过程中经常进行--但代价是一个额外的寄存器被用于函数调用。尽管在启用常规调试符号的情况下,栈展开应该可以正常工作(记得在使用发布配置文件时设置 debug = true),但情况并非总是如此,帧指针可以解决你遇到的任何问题。

标准库

与其他编程语言相比,Rust 的标准库通常被认为是很小的,但是它在广度上的不足,在深度上得到了弥补;你不会在 Rust 的标准库中找到网络服务器的实现或 X.509 证书解析器,但是你会在 Option 类型上找到 40 多种不同的方法和 20 多种特性的实现。对于它所包含的类型,Rust 尽力提供任何相关的功能,以有意义地提高工效,所以你可以避免所有的冗长的模板,否则很容易出现。在本节中,我将介绍标准库中的一些类型、宏、函数和方法,这些类型、宏、函数和方法你可能以前没有遇到过,但它们通常可以简化或改进(或同时)你的代码。

宏和函数

让我们从几个独立的实用程序开始。首先是 write! 宏,它可以让你使用格式化字符串向文件、网络套接字或其他任何实现 Write 的东西写入。你可能已经对它很熟悉了,但是 write! 的一个鲜为人知的特点是,它可以与 std::io::Writestd::fmt::Write 一起工作,这意味着你可以用它直接将格式化的文本写入一个 String 中。也就是说,你可以 use std::fmt::Write; write! (&mut s, "{}+1={}", x, x + 1); 将格式化的文本追加到String s!

iter::once 函数接收任何值,并产生一个产生该值的迭代器。如果你不想分配迭代器,在调用接收迭代器的函数时,或者与 Iterator::chain 结合,向现有的迭代器追加一个单项时,这个函数就很方便。

我们在第 1 章中简要地谈到了 mem::replace,但是如果你错过了它,还是值得再次提起的。这个函数接收一个 T 的独占引用和一个拥有的 T,将两者互换,使引用者现在是拥有的 T,并返回先前引用者的所有权。当你需要在只有独占引用的情况下取得一个值的所有权时,这个函数很有用,比如在 Drop 的实现中。参见 mem::take,当 T:Default

类型

接下来,让我们看一下一些方便的标准库类型。BufReaderBufWriter 类型对于向底层 I/O 资源发出许多小的读或写调用的 I/O 操作是必须的。这些类型包裹了各自的底层读或写,并实现了读和写本身,但它们还对 I/O 资源的操作进行了缓冲,这样许多小的读只做一个大的读,许多小的写只做一个大的写。这可以极大地提高性能,因为你不需要经常跨越操作系统的系统调用障碍。

第 3 章中提到的 Cow 类型,在你想灵活掌握什么类型或需要灵活掌握返回的类型时很有用。你很少将 Cow 作为一个函数参数使用(请记住,如果有必要,你应该让调用者分配),但它作为一个返回类型是非常宝贵的,因为它允许你准确地表示可能分配或不分配的函数的返回类型。它也很适合那些可以作为输入或输出的类型,比如类似 RPC 的 API 中的核心类型。假设我们有一个像清单 13-1 中的 EntityIdentifier 类型,它被用于一个 RPC 服务接口。

#![allow(unused)]
fn main() {
struct EntityIdentifier {
    namespace: String,
    name: String,
}

// 清单 13-1:一个需要分配的组合输入/输出类型的表示方法
}

现在想象一下两个方法:get_entity 需要一个 EntityIdentifier 作为参数,而 find_by 根据一些搜索参数返回一个 EntityIdentifierget_entity 方法只需要一个引用,因为该标识符在被发送到服务器之前(大概)会被序列化。但是对于 find_by,实体将从服务器的响应中被反序列化,因此必须被表示为一个自有的值。如果我们让 get_entity 接受&EntityIdentifier,这将意味着调用者仍然必须分配自有的 Strings 来调用 get_entity,尽管接口并不要求这样做,因为首先需要构建一个 EntityIdentifier 我们可以为 get_entity 引入一个单独的类型,即 EntityIdenifierRef,它只容纳 &str 类型,但这样我们就有两个类型来代表一件事。Cow 来救场了!清单 13-2 显示了一个内部持有奶牛的 EntityIdentifier

#![allow(unused)]
fn main() {
struct EntityIdentifier<'a> {
    namespace: Cow<'a, str>,
    name: Cow<'a str>,
}

// 清单 13-2:不需要分配的组合输入/输出类型的表示方法
}

通过这种结构,get_entity 可以接受任何 EntityIdentifier<'_>,这允许调用者只使用引用来调用该方法。 而 find_by 可以返回 EntityIdentifier<'static>,其中所有字段都是 Cow::Owned。一个类型在两个接口上共享,没有不必要的分配要求。

注意:如果你这样实现一个类型,我建议你也提供一个 into_owned 方法,通过调用 Cow::into_owned 在所有字段上将一个 <'a> 实例变成一个 <'static> 实例。 否则,当用户只有一个 <'a> 时,他们将没有办法对你的类型做更持久的克隆。

std::sync::Once 类型是一个同步基元,可以让你在初始化时精确地运行一段给定的代码。这对于作为 FFI 一部分的初始化来说是非常好的,因为 FFI 边界的另一端的库要求只执行一次初始化。

VecDeque 类型是 std::collection 中一个经常被忽视的成员,我发现自己经常会用到它--基本上,每当我需要一个栈或一个队列时。它的接口类似于 Vec,和 Vec 一样,它在内存中的表示是单一的内存块。 不同的是,VecDeque 在单一的分配中保持对实际数据的开始和结束的跟踪。这允许从 VecDeque 的任何一边不断地推送和弹出,这意味着它可以作为一个栈,作为一个队列,甚至可以同时使用。你所付出的代价是,这些值在内存中不一定是连续的(它们可能已经被包裹起来),这意味着 VecDeque<T>没有实现 AsRef<[T]>

方法

让我们来快速浏览一下一些整洁的方法。首先是 Arc::make_mut,它接收一个&mut Arc<T>并给你一个&mut T。如果该 Arc 是最后一个存在的,它给你该 Arc 后面的 T;否则,它分配一个新的 Arc<T>来保存 T 的克隆,把它换成当前引用的 Arc,然后把&mut 给新单子 Arc 中的 T

Clone::clone_from 方法是。clone() 的另一种形式,它让你重新使用你所克隆的类型的实例,而不是分配一个新的实例。换句话说,如果你已经有一个 x:T,你可以做 x.clone_from(y) 而不是 x = y.clone(),这样你就可以为自己节省一些分配。

std::fmt::Formatter::debug_* 是迄今为止自己实现 Debug 的最简单的方法,如果 #[derive(Debug)] 对你的使用情况不起作用的话,比如你只想包括一些字段,或者暴露出你的类型的字段的 Debug 实现没有暴露的信息。当实现 Debugfmt 方法时,只需在传入的 Formatter 上调用适当的 debug_方法(例如 debug_structdebug_map),在生成的类型上调用所包含的方法来填写关于该类型的细节(如添加一个字段的 field 或添加一个键/值条目的 entries),然后再调用 finish

Instant::elapsed 返回创建 Instant 后的持续时间。这比创建一个新的 Instant 并减去先前的实例的常见方法要简洁得多。

Option::as_deref 接收一个 Option<P>,其中 P:Deref 并返回 Option<&P::Target>(还有一个 as_deref_mut 方法)。这个简单的操作可以使对 Option 进行操作的函数转换链更加简洁,因为它避免了不可捉摸的.as_ref().map(|r| &**r)

Ord::clamp 让你可以把任何实现 Ord 的类型,夹在给定范围的两个其他值之间。也就是说,给定一个下限 min 和一个上限 max,如果 x 小于 minx.clamp(min, max) 返回 min,如果 x 大于 max,返回 max,否则返回 x

Result::transpose 和其对应的 Option::transpose 反转了嵌套 ResultOption 的类型。也就是说,将一个 Result<Option<T>, E> 转置成一个 Option<Result<T, E>>,反之亦然。当与 ? 结合时,这个操作可以在易变的环境中使用 Iterator::next 和类似的方法时使代码更简洁。

Vec::swap_removeVec::remove 的孪生兄弟。Vec::remove 保留了向量的顺序,这意味着要移除中间的一个元素,它必须将向量中所有后面的元素向下移动一个。这对大的向量来说可能非常慢。另一方面,Vec::swap_remove 将要删除的元素与最后一个元素交换,然后将向量的长度截断一个,这是一个常时操作。不过要注意的是,它将会对你的向量进行洗牌,从而使旧的索引失效!

野外的模式(Patterns in the Wild)

当你开始探索不是你自己的代码库时,你很可能会遇到一些常见的 Rust 模式,而我们在书中到目前为止还没有讨论过。了解它们将使你在遇到它们时更容易识别它们,从而理解它们的目的。甚至有一天,你可能会在自己的代码库中发现它们的用处。

索引指针(Index Pointers)

索引指针允许你在一个数据结构中存储对数据的多个引用,而不会触犯借用检查器。例如,如果你想存储一个数据集合,以便能以多种方式有效地访问它,例如通过保留一个以一个字段为键的 HashMap 和一个以另一个字段为键的 HashMap,你不想把底层数据也存储多次。你可以使用 ArcRc,但它们使用的动态引用计数会带来不必要的开销,而且额外的记账需要你为每个条目存储额外的字节。你可以使用引用,但由于数据和引用生活在同一个数据结构中(这是一个自引用的数据结构,我们在第 8 章中讨论过),所以生存期变得难以管理。你可以使用原始指针与 Pin 相结合,以确保指针保持有效,但这引入了很多复杂性和不安全因素,你需要仔细考虑。

大多数 crate 使用索引指针--或者,我喜欢叫它们不确定(indeferences)--来代替。这个想法很简单:将每个数据条目存储在一些可索引的数据结构中,比如 Vec,然后只将索引存储在一个派生数据结构中。然后执行一个操作,首先使用派生数据结构来有效地找到数据索引,然后使用索引来检索引用的数据。不需要生存期--如果你愿意,你甚至可以在派生数据表示中设置周期!

indexmap crate 提供了一个 HashMap 实现,其中迭代顺序与地图插入顺序相匹配,它提供了这种模式的一个很好的例子。该实现必须将键存储在两个地方,既要存储在键到值的映射中,又要存储在所有键的列表中,但是它显然不想在键类型本身很大的情况下保留两个副本。所以,它使用了索引指针。具体来说,它把所有的键/值对保存在一个 Vec 中,然后保存一个从键哈希到 Vec 索引的映射。要遍历映射中的所有元素,它只需遍历 Vec。为了查找一个给定的键,它对该键进行散列,在映射中查找该散列,从而得到键在 Vec 中的索引(索引指针),然后使用该索引从 Vec 中获取键的值。

实现图数据结构和算法的 petgraph crate 也使用这种模式。该工具箱存储了一个所有节点值的 Vec 和另一个所有边缘值的 Vec,然后只使用这些 Vec 的索引来引用一个节点或边缘。因此,例如,与一条边相关的两个节点被简单地作为两个 u32 存储在该边中,而不是作为引用或引用计数的值。

诀窍在于你如何支持删除。要删除一个数据条目,你首先需要在所有的派生数据结构中搜索它的索引并删除相应的条目,然后你需要从根数据存储中删除数据。如果根数据存储是一个 Vec,删除该条目也会改变其他一个数据条目的索引(当使用 swap_remove 时),所以你需要去更新所有的派生数据结构,以反映被移动的条目的新索引。

析构警卫(Drop Guards)

Drop guards 提供了一种简单而可靠的方法来确保一段代码即使在出现恐慌的情况下也能运行,这在不安全的代码中往往是必不可少的。一个例子是一个函数,它接收了一个闭包 f: FnOnce,并使用原子学在互斥下执行它。假设该函数使用 compare_exchange(在第 10 章中讨论过)将一个布尔值从 false 设置为 true,调用 f,然后将布尔值设置为 false 以结束互斥。但是考虑到如果 f 惊慌失措会发生什么--函数将永远无法运行它的清理工作,而且没有其他调用能够再次进入互斥部分。

使用 catch_unwind 可以解决这个问题,但是 drop guards 提供了一个替代方案,通常更符合人体工程学。清单 13-3 显示了在我们当前的例子中,我们如何使用一个回避器来确保布尔值总是被重置。

#![allow(unused)]
fn main() {
fn mutex(lock: &AtomicBool, f: impl FnOnce()) {
    // .. while lock.compare_exchange(false, true).is_err() ..
    struct DropGuard<'a>(&'a AtomicBool);
    impl Drop for DropGuard<'_> {
        fn drop(&mut self) {
            lock.store(true, Ordering::Release);
        }
    }
    let _guard = DropGuard(lock);
    f();
}

// 清单 13-3:使用析构警卫确保代码在解锁恐慌后被运行
}

我们引入了实现 Drop 的本地类型 DropGuard,并将清理代码放在其实现的 Drop::drop 中。任何必要的状态都可以通过 DropGuard 的字段传递进来。然后,我们在调用可能发生恐慌的函数之前,构造一个守护类型的实例,这里是 f。当 f 返回时,不管是由于恐慌还是因为它的正常返回,卫兵被丢弃,它的析构器运行,锁被释放,一切都很好。

重要的是,守护被分配给一个变量,在用户提供的代码被执行后,该变量会在作用域的末端被放弃。这意味着,即使我们不再引用该防护的变量,也需要给它一个名字,因为 let _ = DropGuard(lock) 会在用户提供的代码运行之前立即放弃该防护。

注意:与 catch_unwind 一样,drop guards 只在恐慌解除时起作用。 如果代码在编译时带有 panic=abort,那么在恐慌发生后没有代码可以运行。

这种模式经常与线程局部结合使用,当库代码可能希望设置线程局部状态,使其仅在闭包的执行期间有效,因此需要在事后清除掉。例如,在写这篇文章时,Tokio 使用这种模式来提供关于调用 Future::poll 的执行者的信息给像 TcpStream 这样的叶子资源,而不必通过用户可见的函数签名来传播这些信息。如果在 Future::poll 因恐慌而返回后,线程的本地状态仍然显示某个特定的执行器线程是活跃的,那就不好了,所以 Tokio 使用一个 drop guard 来确保线程的本地状态被重置。

注意:你经常会看到 CellRc<RefCell>被用于线程局部。这是因为线程局部只能通过共享引用来访问,因为一个线程可能会再次访问它已经在调用栈中更高位置引用的线程局部。这两种类型都提供了内部可变性,而不会产生太多的开销,因为它们只用于单线程的使用,所以是这种使用情况的理想选择。

扩展 Traits(Extension Traits)

扩展特质允许 crate 为实现不同 crate 特质的类型提供额外的功能。例如,itertools crate 为 Iterator 提供了一个扩展特质,它为常见的(或不太常见的)迭代器操作增加了一些方便的快捷方式。另一个例子是,tower 提供了 ServiceExt,它为 tower-service 特质中的低级接口增加了几个符合人体工程学的操作。

当你不控制基本特性时,扩展特性往往是有用的,比如 Iterator,或者当基本特性生活在自己的 crate 中时,这样它就很少看到破坏性的发布,从而不会造成不必要的生态系统分裂,比如 Service

一个扩展特质扩展了它的基础特质(trait ServiceExt: Service),并且只由提供的方法组成。它还为任何实现了基属性的 T 提供了一个覆盖实现(implit<T> ServiceExt for T where T: Service {})。这些条件共同确保了扩展特质的方法在任何实现了基础特质的事物上都可用。

crate 预导入(Crate Preludes)

在第 12 章中,我们谈到了标准库的 prelude,它使一些类型和特性自动可用,而不需要你写任何 use 语句。沿着类似的思路,那些输出你经常一起使用的多种类型、特征或函数的板块,有时会以一个叫做 prelude 的模块的形式定义他们自己的前奏,它重新输出这些类型、特征和函数中某些特别常见的子集。这个模块的名字没有什么神奇之处,它也不会被自动使用,但它是一个信号,告诉用户他们很可能要在想使用这个模块的文件中加入 use somecrate::prelude::** 是一个 glob 导入,告诉 Rust 使用指定模块中所有公开可用的项目。当 crate 有很多项目时,这可以节省大量的输入工作,你通常需要为这些项目命名。

注意:通过 * 使用的项目比通过名字明确使用的项目有较低的优先权。这就是允许你在自己的板块中定义与标准库前奏中的项目重叠的项目,而不需要指定使用哪一个。

preludes 对于暴露出大量扩展特性的板块来说也很好,因为特性方法只有在定义它们的特性处于范围内时才能被调用。例如,diesel crate,它提供了对关系数据库的人性化访问,大量使用了扩展特性,所以你可以写这样的代码。

#![allow(unused)]
fn main() {
posts.filter(published.eq(true)).limit(5).load::<Post>(&connection)
}

只有当所有正确的特性都在范围内时,这一行才会起作用,而这是由 prelude 来处理的。

一般来说,当你在代码中加入 glob 导入时,你应该小心,因为它们有可能把对指定模块的添加变成向后不兼容的改变。例如,如果有人给你的 glob 导入的模块添加了一个新的特性,而这个新的特性使一个已经有其他 foo 方法的类型上的方法 foo 可用,那么在这个类型上调用 foo 的代码将不再被编译,因为现在对 foo 的调用是模糊的。有趣的是,虽然 glob 导入的存在使得任何模块的增加在技术上都是一种破坏性的改变,但 Rust 关于 API 进化的 RFC(RFC 1105;见 https://rust-lang.github.io/rfcs/1105-apievolution.html)并不要求一个库为这种改变发布一个新的主要版本。RFC 对原因做了很详细的说明,我推荐你去读一读,但主要内容是允许次要版本要求对依赖者进行最小限度的侵入性修改,比如在边缘情况下必须添加类型注释,因为否则很大一部分修改都需要新的主要版本,尽管它们实际上不太可能破坏任何消费者。

特别是在 preludes 的情况下,在自动售货(vending) crate 推荐的情况下,使用 glob 进口通常是没有问题的,因为它的维护者知道他们的用户会对 preludes 模块使用 glob 进口,因此在决定一个变化是否需要一个主要的版本升级时,会考虑到这一点。

保持更新

Rust,作为一种年轻的语言,正在迅速发展。语言本身、标准库、工具和更广泛的生态系统都还处于起步阶段,而且每天都有新的发展。虽然保持对所有变化的关注是不可行的,但值得你花时间去关注重大的发展,这样你就可以在你的项目中利用最新和最伟大的功能。

对于监测 Rust 本身的改进,包括新的语言特性、标准库的增加和核心工具的升级,https://blog.rust-lang.org/ 的 Rust 官方博客是一个很好的、数量不多的地方,可以开始。它主要是关于每个新的 Rust 版本的公告。我建议你养成阅读这些内容的习惯,因为它们往往包括一些有趣的花絮,会慢慢但肯定地加深你对该语言的了解。为了更深入地挖掘,我强烈建议你阅读 Rust 和 Cargo 的详细更新日志(链接通常可以在每个版本公告的底部找到)。更新日志中的变化并没有大到需要在发布说明中写上一段话,但这可能正是你两周后所需要的。如果想了解一个不那么频繁的新闻来源,可以到 https://doc.rust-lang.org/edition-guide/《版本指南》上看看,它概述了每个 Rust 版本中的新内容。Rust 版本往往每三年发布一次。

注意:Clippy 经常能够告诉你什么时候可以利用一种新的语言或标准库功能--总是启用 Clippy!

如果你对 Rust 本身的开发过程感到好奇,你可能也想订阅 Inside Rust 博客,网址是 https://blog.rustlang.org/inside-rust/。它包括来自各个 Rust 团队的更新,以及事件报告、较大的修改建议、版本规划信息等等。要想自己参与到 Rust 的开发中来--我非常鼓励这样做,因为这很有趣,也是一个很好的学习经验--你可以在 https://www.rust-lang.org/governance/,查看各种 Rust 工作组,它们各自专注于改进 Rust 的一个特定方面。找到一个对你有吸引力的工作组,在它开会的地方签到并询问你如何能够提供帮助。你也可以在 https://internals.rustlang.org/,加入关于 Rust 内部的社区讨论;这是另一个深入了解 Rust 设计和开发的每一部分的好方法。

正如大多数编程语言一样,Rust 的大部分价值来自于它的社区。Rust 社区的成员不仅不断地开发新的省力工具箱,发现新的 Rust 专用技术和设计模式,而且他们还集体地、持续地帮助彼此理解、记录和解释如何最好地利用 Rust 语言。我在本书中所涉及的一切,以及更多的内容,都已经被社区在成千上万的评论线程、博客文章、Twitter 和 Discord 对话中讨论过。即使只是偶尔接触一下这些讨论,也几乎可以保证让你对某个语言特性、某个技术或某个板块有新的认识。

Rust 社区有很多地方,但有一些好地方可以开始,如用户论坛(https://users.rust-lang.org/)、Rust subreddit(https://www.reddit.com/r/rust/)、Rust Community Discord(https://discord.gg/rust-lang-community)和 Rust Twitter 账户(https://twitter.com/rustlang)。你不需要参与所有这些,也不需要一直参与,只要选择一个你喜欢的氛围,并偶尔检查一下就可以了!

本周 Rust 博客 (https://this-week-inrust.org/) 是保持最新发展的一个很好的单一位置,它是 "[Rust's] 进展和社区的每周总结"。它链接到官方公告和更新日志,以及流行的社区讨论和资源,有趣的新板块,贡献的机会,即将到来的 Rust 活动,以及 Rust 工作机会。它甚至还列出了有趣的语言 RFC 和编译器 PR,所以这个网站真的是应有尽有。辨别哪些信息对你有价值,哪些没有价值可能有点令人生畏,但即使只是滚动浏览,偶尔点击一些看起来有趣的链接,也是保持新的 Rust 知识源源不断地进入你的大脑的一个好方法。

注意:想查询某项功能何时稳定落地? 我可以使用。.....(https://caniuse.rs/)为您提供服务。

下一步是什么?(What Next?)

那么,你已经从头到尾读了这本书,吸收了它所传授的所有知识,并且仍然渴望得到更多?很好!有许多其他优秀的资源可以拓宽和加深你的知识和理解。有许多其他优秀的资源可以拓宽和加深你对 Rust 的认识和理解,在这最后一节,我将给你一个我最喜欢的调查,以便你可以继续学习。我根据不同人的学习方式将它们分为几个小节,这样你就可以找到适合你的资源了。

注意:自学的一个挑战,特别是在开始的时候,是很难察觉到进展。即使是最简单的东西,当你不得不不断地参考文档和其他资源,寻求帮助,或调试以了解 Rust 的某些方面是如何工作的时候,实现也会花费大量的时间。所有这些非编码工作会让你看起来像在踩水,而不是真正的进步。但是你在学习,这本身就是一种进步--只是更难注意和欣赏。

通过观察学习(Learn by Watching)

观察有经验的开发者的代码,本质上是一种生活黑客,可以弥补独自学习的缓慢起步阶段。它允许你观察设计和构建的过程,同时利用别人的经验。听有经验的开发者阐述他们的想法,并在他们出现的时候解释棘手的概念或技术,这是比你自己挣扎着解决问题的一个很好的选择。你还会学到各种辅助知识,如调试技术、设计模式和最佳实践。最终,你将不得不坐下来自己做事情--这是检查你是否真正理解你所观察到的东西的唯一方法--但借鉴别人的经验几乎肯定会使早期阶段更加愉快。如果这种经验是互动的,那就更好了。

因此,既然如此,这里有一些我推荐的 Rust 视频频道:

也许不出意料,我自己的频道:https://www.youtube.com/c/JonGjengset/。我有长篇的编码视频和短篇的基于代码的理论/概念解释视频,以及偶尔的视频,深入到有趣的 Rust 编码故事。

The Awesome Rust Streaming listing: https://github.com/jamesmunns/awesome-rust-streaming/. 这个资源列出了各种流传 Rust 编码或其他 Rust 内容的开发者。

Tim McNamara 的频道,《Rust in Action》: https://www.youtube.com/c/timClicks/ 的作者。蒂姆的频道和我的一样,在实施和理论之间分配时间,尽管蒂姆对创造性的视觉项目有特殊的诀窍,这使得观看很有趣。

Jonathan Turner 的 Systems with JT 频道:https://www.youtube.com/c/SystemswithJT/。乔纳森的视频记录了他们在 Nushell 上的工作,他们对 "新型 shell "的看法,提供了对在一个非琐碎的现有代码库上工作的伟大感觉。

Ryan Levick 的频道:https://www.youtube.com/c/RyanLevicksVideos/。Ryan 主要发布解决特定 Rust 概念的视频,并通过具体的代码实例进行讲解,但他偶尔也会做一些实现视频(比如用于微软飞行模拟器的 FFI!),以及深入研究著名的 crates 在引擎盖下是如何工作的。

鉴于我制作了 Rust 视频,我是这种教学方法的粉丝,这应该不足为奇。但这种接受式或互动式的学习不一定非要以视频的形式出现。 另一个向有经验的开发者学习的好途径是结对编程。如果你有一个在 Rust 某个方面有专长的同事或朋友,你想学习,问问你是否可以和他们进行结对编程,一起解决一个问题!

边做边学(Learn by Doing)

由于你的最终目标是更好地编写 Rust,所以编程经验是无可替代的。无论你从什么地方或多少资源中学习,你都需要把学习的东西付诸实践。然而,找到一个好的起点可能是很棘手的,所以在这里我将给出一些建议。

在我深入了解这个名单之前,我想就如何挑选项目提供一些一般性的指导。首先,选择一个你关心的项目,而不要太担心别人是否关心它。 虽然有很多流行的、成熟的 Rust 项目很希望你能成为贡献者,而且能够说 "我为著名的库 X 做出了贡献 "也很有趣,但你的首要任务必须是自己的兴趣。如果没有具体的动机,你会很快失去动力,发现贡献是一件苦差事。最好的目标是你自己使用的项目和遇到问题的项目--去修复它们吧!没有什么比摆脱一个长期存在的个人困扰,同时也为社区做出贡献更令人满意的了。

好吧,那么回到项目建议。首先,也是最重要的,考虑为 Rust 编译器及其相关工具做出贡献。这是一个高质量的代码库,有良好的文档和无穷无尽的问题(你自己可能也知道一些),而且有几个伟大的导师可以提供如何解决问题的大纲。如果你在问题追踪器中寻找标有 E-easy 或 E-mentor 的问题,你可能会很快找到一个好的候选人。随着你获得更多的经验,你可以不断提高水平,为更棘手的部分作出贡献。

如果这不是你的那杯茶,我建议找一些你经常使用的、用另一种语言写的东西,并把它移植到 Rust 中去--不一定是为了取代原来的库或工具,只是因为这种经验可以让你专注于写 Rust,而不必花太多时间自己想出所有功能。如果结果是好的,它已经存在的事实表明,其他人也需要它,所以你的移植可能也有更多的观众数据结构和命令行工具往往是很好的移植对象,但要找到一个吸引你的利基。

如果你是那种更喜欢 "从头开始" 的人,我建议回顾一下你迄今为止的开发经验,想想你在多个项目中最终写的类似代码(无论是用 Rust 还是用其他语言)。这样的重复往往是一个很好的信号,说明某些东西是可以重用的,可以变成一个库。如果你没有想到什么,David Tolnay 在 https://github.com/dtolnay/requestfor-implementation/,维护了一个其他 Rust 开发者要求的较小的实用工具箱的列表,这可能会提供一个灵感来源。如果你想找一些更实质性的、更有野心的东西,在 https://github.com/notyet-awesome-rust/not-yet-awesome-rust/,还有一个 Not Yet Awesome 列表,列出了应该存在于 Rust 中但还没有的东西。

通过阅读学习(Learn by Reading)

虽然情况在不断改善,但要找到超过初级水平的好的 Rust 阅读材料仍然很棘手。 这里收集了一些我最喜欢的资源的指针,这些资源不断教给我新的东西,或者在我有特别小的或细微的问题时作为良好的参考。

首先,我建议翻阅从 https://www.rust-lang.org/learn/ 链接的官方虚拟 Rust 书籍。有些书,比如《Cargo》,更像是参考书,而另一些,比如《Embedded》,更像是指南,但它们都是关于各自主题的坚实技术信息的深度来源。尤其是 Rustonomicon(https://doc.rust-lang.org/nomicon/),在你编写不安全代码时是一个救星。

还有两本值得一看的书是《Rustc 开发指南》(https://rustc-dev-guide.rust-lang.org/)和《标准库开发者指南》(https://std-dev-guide.rustlang.org/)。如果你对 Rust 编译器是如何工作的或者标准库是如何设计的感到好奇,或者在你尝试为 Rust 本身做贡献之前想要一些指导,这些都是非常好的资源。官方的 Rust 指南也是一个信息宝库;我已经在书中提到了 Rust API 指南(https://rust-lang.github.io/api-guidelines/),但也有一份 Rust 不安全代码指南参考(https://rust-lang.github.io/unsafe-code-guidelines/),当你读到这本书时,可能还有更多。

注意:https://www.rustlang.org/learn/ 列出的资源之一是 Rust Reference,它基本上是 Rust 语言的完整规范。虽然它的部分内容相当枯燥,比如用于解析的确切语法或关于原始类型的内存表示的基础知识,但它的部分内容却很吸引人,比如关于类型布局和被认为是未定义行为的枚举的部分。

还有一些非官方的虚拟 Rust 书籍,是非常有价值的经验和知识集合。例如,The Little Book of Rust Macros (https://veykril.github.io/tlborm/),如果你想编写非实质性的声明性宏,它是不可或缺的;The Rust Performance Book (https://nnethercote.github.io/perf-book/) 充满了在微观和宏观层面上提高 Rust 代码性能的技巧和窍门。其他伟大的资源包括 Rust Fuzz Book(https://rust-fuzz.github.io/book/),它更详细地探讨了模糊测试,以及 Rust Cookbook(https://rust-langnursery.github.io/rust-cookbook/),它为常见的编程任务提供了习惯性的解决方案。甚至还有一个寻找更多书籍的资源,The Little Book of Rust Books(https://lborb.github.io/book/unofficial.html)!

如果你喜欢更多的实践性阅读,Tokio 项目已经发布了 mini-redis(https://github.com/tokio-rs/mini-redis/),这是一个不完整的,但却很容易理解的 Redis 客户端和服务器的实现,它的文档非常好,而且是专门写来作为编写异步代码的指南。如果你更喜欢数据结构,Learn Rust with Entirely Too Many Linked Lists (https://rust-unofficial.github.io/too-many-lists/) 是一本富有启发性和趣味性的读物,其中涉及到很多关于所有权和引用的棘手细节。如果你正在寻找更接近硬件的东西,Philipp Oppermann 的 Writing an OS in Rust(https://os.phil-opp.com/)会非常详细地介绍整个操作系统栈,同时在这个过程中教你一些好的 Rust 模式。如果你想了解更多以对话方式撰写的有趣的深入研究,我也强烈推荐 Amos 的文章集(https://fasterthanli.me/tags/rust/)。

当你对自己的 Rust 能力更有信心,并且需要快速参考而不是长篇大论的教程时,我发现 Rust Language Cheat Sheet(https://cheats.rs/)非常适合快速查找东西。它还为大多数主题提供了非常好的视觉解释,所以即使你在查找你不熟悉的东西,解释也是非常平易近人的。

最后,如果你想测试一下你对 Rust 的理解,可以去试试 David Tolnay 的 Rust 测验(https://dtolnay.github.io/rust-quiz/)。这里面有一些真正的头脑风暴,但每一个问题都有一个详尽的解释,所以即使你错了一个,你也会从这个经验中得到启发!"。

在教学中学习(Learn by Teaching)

我的经验是,到目前为止,要想把一件事学好、学透,最好的办法就是试着把它教给别人。我从写这本书中学到了很多东西,而且每次我制作新的 Rust 视频或播客节目时都会学到新东西。因此,我全心全意地建议你尝试向他人传授你从阅读本书中学到的或你从这里学到的一些东西。它可以采取你喜欢的任何形式:当面、写博文、发推特、制作视频或播客,或发表演讲。重要的是,你要尝试用自己的语言将你新发现的知识传达给那些还不了解这个主题的人--这样做,你也是对社会的一种回馈,这样,下一个出现的你就会更容易上手了。教书是一种谦卑和深刻的教育经历,我不能强烈推荐它。

注意:无论你是想教还是想被教,一定要访问 Awesome Rust Mentors(https://rustbeginners.github.io/awesome-rust-mentors/)。

总结

在这一章中,我们已经涵盖了 Rust 的内容,超出了你本地工作区的范围。我们调查了有用的工具、库和 Rust 特性;研究了如何随着生态系统的不断发展而保持更新;然后讨论了如何让你的手变脏并为生态系统做出贡献。最后,我们讨论了在本书已经结束的情况下,你可以去哪里继续你的 Rust 之旅。就这样,除了宣布之外,没有什么可做的了。