第五章 项目结构

本章提供了一些结构化 Rust 项目的想法。对于简单的项目来说,由 cargo new 设置的结构可能是你很少考虑的。你可能会添加一些模块来分割代码,并添加一些额外功能的依赖关系,但仅此而已。然而,随着项目规模和复杂性的增加,你会发现你需要超越这些。也许你的 crate 的编译时间已经失控了,或者你需要条件依赖,或者你需要一个更好的持续集成策略。在这一章中,我们将看看 Rust 语言,特别是 Cargo 提供的一些工具,使我们更容易管理这些东西。

特点(Features)

features 是 Rust 定制项目的主要工具。在其核心部分,feature 只是一个构建标志,crate 可以传递给他们的依赖,以增加可选功能。features 本身并没有语义,相反,你可以选择一个 feature 对你的 crate 意味着什么。

一般来说,我们以三种方式使用 features:启用可选的依赖关系,有条件地包含 crate 的额外组件,以及增强代码的行为。请注意,所有这些用途都是附加的;features 可以增加 crate 的功能,但它们通常不应该做诸如删除模块或替换类型或函数签名的事情。这源于这样一个原则:如果开发者对他们的 Cargo.toml 做了一个简单的修改,比如添加一个新的依赖关系或启用一个 feature,这不应该使他们的 crate 停止编译。如果一个 crate 有相互排斥的 features,那么这个原则很快就会失效,如果 crate A 依赖于 crate C 的一个 features,而 crate B 依赖于 C 的另一个相互排斥的 features,那么增加对 crate B 的依赖就会破坏 crate A! 出于这个原因,我们通常遵循这样的原则:如果 crate A 能够针对 crate C 的某些 features 进行编译,那么在 crate C 上启用所有 features 时,它也应该能够编译。

Cargo 很难偏向这一原则。例如,如果两个 crate (A 和 B) 都依赖于 crate C,但它们各自在 C 上启用不同的功能,Cargo 将只编译 crate C 一次,并带有 A 或 B 所需要的所有功能。也就是说,它需要将 C 所要求的 features 通过 A 和 b 结合起来。因此,通常很难向 Rust crate 中添加互排斥的 features;可能的情况是,两个依赖项将依赖于具有不同功能的 crate,如果这些功能是相互排斥的,下游 crate 将无法构建。

注意:我强烈建议您配置您的持续集成基础设施,以检查您的机箱编译的任何组合的功能。一个帮助你做到这一点的工具是 cargo-hack,你可以在 https://github.com/taiki-e/cargo-hack 上找到它

定义和包含 features(Defining and Including Features)

features 在 Cargo.toml 中定义。清单 5-1 展示了一个名为 foo 的 crate 示例,它有一个简单的 feature,启用可选依赖项 syn

#![allow(unused)]
fn main() {
[package]
name = "foo"
...
[features]
derive = ["syn"]
[dependencies]
syn = { version = "1", optional = true }

// 清单 5-1:启用可选依赖项的 features
}

当 Cargo 编译这个 crate 时,默认情况下它不会编译 syn crate,这减少了编译时间(通常是显著的)。只有当下游的组件需要使用 derive features 所启用的 api 并显式选择使用时,才会编译 syn 组件。清单 5-2 显示了这样一个下游框条如何启用派生 features,从而包括 syn 依赖。

#![allow(unused)]
fn main() {
[package]
name = "bar"
...
[dependencies]
foo = { version = "1", features = ["derive"] }

// 清单 5-2:启用依赖项的 features
}

有些功能使用得非常频繁,所以让一个 crate 选择不使用这些功能比选择使用这些功能更有意义。为了支持这一点,Cargo 允许你为一个 crate 定义一组默认的 features。同样,它也允许你选择不使用依赖关系的默认 features。清单 5-3 显示了 foo 如何使其 derive features 在默认情况下被启用,同时选择退出 syn 的一些默认 features,而只启用 derive features 所需的 features。

#![allow(unused)]
fn main() {
[package]
name = "foo"
...
[features]
derive = ["syn"]
default = ["derive"]
[dependencies.syn]
version = "1"
default-features = false
features = ["derive", "parsing", "printing"]
optional = true

// 清单 5-3:添加和选择默认 features,以及可选的依赖项
}

在这里,如果一个 crate 依赖于 foo,并且没有明确选择退出默认 features,它也会编译 foosyn 依赖关系。这样选择不使用默认 features,只选择自己需要的 features,是减少编译时间的一个好方法。

作为 features 的可选依赖项

当你定义一个 feature 时,等号后面的列表本身就是一个 features 列表。 这听起来可能有点奇怪,在清单 5-3 中,syn 是一个依赖项,而不是一个 features。事实证明,Cargo 将每个可选的依赖关系都变成了与该依赖关系同名的 features。如果你试图添加一个与可选依赖关系同名的 feature,你会发现这一点;Cargo 不允许这样做。Cargo 正在为 features 和依赖关系提供不同的命名空间支持,但在撰写本文时还没有稳定下来。同时,如果你想让一个 features 以依赖关系命名,你可以用 package = "" 来重命名依赖关系,以避免名称冲突。一个 feature 启用的 features 列表也可以包括依赖关系的 features。例如,你可以写 derive = ["syn/derive"] 来让你的 derive feature 启用 syn 依赖关系的 derive feature。

在你的 crate 中使用 features

当使用 features 时,你需要确保你的代码只在依赖性可用时才使用它。如果你的 feature 启用了一个特定的组件,你需要确保如果该 feature 没有被启用,该组件就不会被包括在内。

你可以使用条件编译来实现这一点,它允许你使用注释来给出某段代码应该或不应该被编译的条件。条件性编译主要通过#[cfg] 属性来表达。还有一个密切相关的 cfg!宏,它可以让你根据类似的条件改变运行时行为。你可以用条件编译做各种各样的事情,我们在本章后面会看到,但最基本的形式是 #[cfg(feature = "some-feature")] ,它使源代码中的下一个 "东西" 只有在启用了 some-feature feature 时才会被编译。类似地,if cfg!(feature = "some-feature") 等同于 if true 只有在启用 derive feature 时才会被编译(否则为 false)。

#[cfg] 属性比 cfg! 宏更常被使用,因为宏会根据 feature 修改运行时行为,这就很难保证 features 是相加的。你可以把 #[cfg] 放在某些 Rust 项目的前面--比如函数和类型定义、impl 块、模块和 use 语句--也可以放在某些其他结构上,比如结构域、函数参数和语句。但是 #[cfg] 属性不能随便出现;它可以出现的地方是由 Rust 语言团队仔细限制的,这样就可以避免条件编译造成过于奇怪和难以调试的情况。

请记住,修改你的 API 的某些公共部分可能会无意中使一个 feature 变得不具附加性,这反过来会使一些用户无法编译你的 crate。你通常可以使用向后兼容的修改规则作为这里的经验法则--例如,如果你使一个枚举变量或一个公共结构字段成为一个 feature 的条件,那么该类型也必须用 #[non_exhaustive] 来注释。否则,如果由于依赖关系树中的第二个 crate 添加了该 feature,那么没有启用该 feature 的依赖 crate 可能就无法再进行编译。

注意

如果你正在编写一个大的 crate,你期望你的用户只需要其中的一个子集的功能,你应该考虑使较大的组件(通常是模块)被功能所保护。这样,用户可以选择加入,并支付编译成本,只有他们真正需要的部分。

工作区(Workspaces)

Crates 在 Rust 中扮演着许多角色--它们是依赖关系图中的顶点,是 trait 一致性的边界,也是编译 features 的范围。正因为如此,每个 crate 都被当作一个单独的编译单元来管理;Rust 编译器或多或少地把 crate 当作一个大的源文件来编译,最终变成一个单一的二进制输出(无论是二进制还是库)。

虽然这简化了编译器的许多方面,但它也意味着大的 crate 在工作时可能会很痛苦。如果你在应用程序的某个部分改变了一个单元测试、一个注释或一个类型,编译器必须重新评估整个 crate,以确定有什么变化,如果有的话。在内部,编译器实现了一些机制来加速这一过程,如增量重新编译和并行代码生成,但最终你的编译器的大小是你的项目需要多长时间来编译的一个重要因素。

由于这个原因,随着项目的发展,你可能想把它分成多个内部相互依赖的 crate。Cargo 有一个方便的功能:工作区(workspace)。 工作区是一个 crate(通常称为子 crate)的集合,由一个顶级的 Cargo.toml 文件连接起来,如清单 5-4 所示。

#![allow(unused)]
fn main() {
[workspace]
members = [
    "foo",
    "bar/one",
    "bar/two",
]

// 清单 5-4:工作空间
}

members 数组是一个目录列表,每个目录都包含工作区中的一个 crate。这些 crate 在自己的子目录中都有自己的 Cargo.toml 文件,但它们共享一个 Cargo.lock 文件和一个输出目录。crate 的名字不需要与 member 中的条目相匹配。一个工作区中的 crate 共享一个名称前缀是很常见的,但不是必须的,通常选择 "主" crate 的名称。例如,在 tokio crate 中,成员被称为 tokiotokio-testtokio-macros,等等。

也许工作区最重要的 feature 是,你可以通过在工作区的根部调用 cargo 来与工作区的所有成员进行交互。想检查它们是否都能编译,cargo check 会检查它们。想运行所有的测试? cargo test 会对它们进行测试。这并不像把所有东西都放在一个 crate 里那么方便,所以不要把所有东西都拆成小 crate,但这是一个相当好的近似值。

注意:货物命令通常会在工作区做 "正确的事情"。如果你需要消除歧义,比如两个工作区的 crate 都有一个同名的二进制文件,请使用 -p 标志(代表软件包)。如果你在一个特定工作区的子目录中,你可以通过--工作区来执行整个工作区的命令。

一旦你有了工作区级的 Cargo.toml 和工作区成员的数组,你就可以使用路径依赖来设置你的 crate 相互依赖,如清单 5-5 所示。

#![allow(unused)]
fn main() {
bar/two/Cargo.toml
[dependencies]
one = { path = "../one" }
bar/one/Cargo.toml
[dependencies]
foo = { path = "../../foo" }

// 清单 5-5:工作区 crate 之间的 crate 依赖关系
}

现在,如果你对 bar/two 中的 crate 做了改变,那么只有这个 crate 被重新编译,因为 foo 和 bar/one 并没有改变。从头开始编译你的项目甚至可能更快,因为编译器不需要评估你的整个项目源以寻找优化机会。

指定工作区内部的依赖关系

最明显的方法是使用路径指定器来指定工作空间中的一个 crate 依赖于另一个 crate,如清单 5-5 所示。然而,如果你的单个子 crate 是用于公共消费的,你可能想使用版本指定器来代替。

假设你有一个依赖于清单 5-5 中 bar 工作区的一个 crate 的 Git 版本,其中 one = { git = ". . ." .},以及 foo 的发布版本(也来自 bar),foo = "1.0.0"。Cargo 会尽职尽责地获取存放整个 bar 工作区的 Git 仓库,并看到该仓库又依赖于工作区中位于 .../../foo 的 foo。但 Cargo 不知道发布的版本 foo = "1.0.0" 和 Git 仓库中的 foo 是同一个 crate!它认为它们是两个独立的依赖关系。它认为它们是两个独立的依赖项,只是碰巧名字相同而已。

你可能已经看到了这是怎么回事。如果你试图用一个接受 foo 类型的 API 来使用 foo(1.0.0)中的任何类型,编译器会拒绝该代码。即使这些类型有相同的名字,编译器也不能知道它们是同一个底层类型。而用户会被彻底搞糊涂,因为编译器会说 "预期 foo::Type,得到 foo::Type "这样的话。

缓解这个问题的最好方法是,只有在子系统之间依赖未发布的变化时才使用路径依赖。只要一个人与 foo 1.0.0 一起工作,它就应该在其依赖关系中列出 foo = "1.0.0"。只有当你对 foo 做了一个人们需要的改变时,你才应该改变一个人,以使用路径依赖。一旦你发布了一个可以依赖的新版本的 foo,你应该再次删除路径依赖。

这种方法也有其不足之处。现在,如果你改变了 foo,然后运行一个的测试,你会看到一个将使用旧的 foo 进行测试,这可能不是你预期的。你可能想配置你的持续集成基础设施,以测试每个子程序,同时使用其他子程序的最新发布版本,并将所有子程序配置为使用路径依赖。

项目配置

运行 cargo new 后,你会得到一个最小的 Cargo.toml,其中有 crate 的名称、版本号、一些作者信息和一个空的依赖列表。这可以让你走得更远,但随着项目的成熟,你可能想在 Cargo.toml 中添加一些有用的东西。

包元数据(Crate Metadata)

首先要添加到 Cargo.toml 中的是所有 Cargo 支持的元数据指令,也是最明显的。除了描述和主页等显而易见的字段外,还可以包括一些信息,如 crate 的 README 路径(readme)、与 cargo run 一起运行的默认二进制文件(default-run),以及帮助 cates.io 对 crate 进行分类的额外关键词和类别。

对于具有更复杂的项目布局的 crate,设置包括和排除元数据字段也很有用。这决定了哪些文件应该包括在你的包中并发布。默认情况下,Cargo 会包含一个 crate 目录下的所有文件,除了你的 .gitignore 文件中列出的文件,但如果你在同一目录下还有大型测试装置、不相关的脚本或其他辅助数据,而你又希望它们处于版本控制之下,这可能不是你想要的。正如它们的名字所示,includeexclude 分别允许你只包括一组特定的文件或排除与一组给定模式相匹配的文件。

注意:如果你有一个永远不应该被发布的 crate,或者只应该被发布到某些替代的注册中心(也就是说,不应该被发布到 crates.io),你可以将 publish 指令设置为 false 或者允许的注册中心列表。

你可以使用的元数据指令的列表在继续增加,所以请确保定期查看 Cargo 参考资料中的清单格式页面(https://doc.rustlang.org/cargo/reference/manifest.html)

构建配置(Build Configuration)

Cargo.toml 还可以让你控制 Cargo 如何构建你的 crate。最明显的工具是 build 参数,它允许你为你的 crate 编写一个完全自定义的构建程序(我们将在第 11 章重新讨论这个问题)。然而,Cargo 还提供了两个较小的,但非常有用的机制,我们将在这里探讨:补丁和配置文件。

[patch]

Cargo.toml 的 [patch] 部分允许你为一个依赖指定一个不同的来源,你可以暂时使用,无论这个被修补的依赖出现在你的依赖中的哪个位置。当你需要针对某个横向依赖的修改版本来编译你的 crate,以测试错误修复、性能改进或即将发布的新的次要版本时,这一点非常宝贵。清单 5-6 显示了一个关于如何临时使用一组依赖关系的变体的例子。

#![allow(unused)]
fn main() {
[patch.crates-io]
use a local (presumably modified) source
regex = { path = "/home/jon/regex" }
use a modification on a git branch
serde = { git = "https://github.com/serde-rs/serde.git", branch = "faster" }
patch a git dependency
[patch.'https://github.com/jonhoo/project.git']
project = { path = "/home/jon/project" }

// 清单 5-6:使用 `[patch]` 重写 Cargo.toml 中的依赖源
}

即使你打了一个依赖关系的补丁,Cargo 也会注意检查 crate 的版本,这样你就不会意外地打错 crate 的主要版本了。如果你出于某种原因,过渡性地依赖同一个 crate 的多个主要版本,你可以给每个 crate 打上不同的标识符,如清单 5-7 中所示。

#![allow(unused)]
fn main() {
[patch.crates-io]
nom4 = { path = "/home/jon/nom4", package = "nom" }
nom5 = { path = "/home/jon/nom5", package = "nom" }

// 清单 5-7: 使用 `[patch]` 在 Cargo.toml 中重写同一 crate 的多个版本
}

Cargo 会查看每个路径中的 Cargo.toml,意识到 /nom4 包含主版本 4,/nom5 包含主版本 5,并对这两个版本进行适当的修补。package 关键字告诉 Cargo 在这两种情况下以 nom 的名字来寻找一个 crate,而不是像默认情况下那样使用依赖标识符(左边的部分)。你也可以在你的常规依赖中这样使用 package 来重命名一个依赖!

请记住,当你发布一个 crate 时,补丁不会被考虑到上传的软件包中。依赖于你的 crate 的 crate 将只使用它自己的 [patch] 部分(可能是空的),而不是你的 crate 的部分。

crates VS. packages

你可能想知道包(package)和箱(crate)的区别是什么。这两个词在非正式场合经常互换使用,但它们也有具体的定义,这取决于你是在谈论 Rust 编译器、Cargo、crates.io,还是其他什么东西。我个人认为 crate 是一个 Rust 模块的层次结构,从一个根 .rs 文件开始(在这个文件中你可以使用 crate 级别的属性,如 #![feature])--通常是 lib.rs 或 main.rs 这样的文件。相比之下,包是一个 crate 和元数据的集合,所以基本上所有的东西都由 Cargo.toml 文件描述。这可能包括一个库 crate、多个二进制 crate、一些集成测试 crate,甚至可能包括多个工作区成员,这些成员本身也有 Cargo.toml 文件。

[profile]

[profile] 部分允许你向 Rust 编译器传递额外的选项,以改变它编译 crate 的方式。这些选项主要分为三类:性能选项、调试选项,以及以用户定义的方式改变代码行为的选项。它们都有不同的默认值,取决于你是在调试模式下还是在发布模式下编译(其他模式也存在)。

三个主要的性能选项是 opt-levelcodegen-unitsltoopt-level 选项通过告诉编译器如何积极地优化你的程序来调整运行时的性能(0 代表 "完全没有",3 代表 "尽可能多")。设置越高,你的代码就越优化,这可能使它运行得更快。不过,额外的优化是以更高的编译时间为代价的,这就是为什么优化通常只在发布版本中启用。

注意:你也可以将 opt-level 设置为 "s",以优化二进制大小,这在嵌入式平台上可能很重要。

codegen-units 选项是关于编译时的性能。它告诉编译器,它允许将一个 crate 的编译分成多少个独立的编译任务(代码生成单元)。一个大 crate 的编译被分割成越多的部分,它的编译速度就越快,因为更多的线程可以帮助平行编译 crate。不幸的是,为了实现这种加速,这些线程需要或多或少地独立工作,这意味着代码优化受到影响。例如,想象一下,在一个线程中编译的 crate 可以从另一个 crate 中的一些代码的内联中受益--因为这两个 crate 是独立的,所以内联不能发生!这种设置是对的。那么,这种设置是对编译时性能和运行时性能的一种权衡。默认情况下,Rust 在调试模式下使用有效的无限制数量的 codegen 单元(基本上,"尽可能快地编译"),在发布模式下使用较少的数量(在撰写本文时为 16)。

lto 设置开启了链接时优化(LTO),它使编译器(或者链接器,如果你想从技术上了解它的话)共同优化你的程序的位,称为编译单元,这些单元最初是单独编译的。LTO 的具体细节超出了本书的范围,但其基本思想是,每个编译单元的输出包括有关进入该单元的代码的信息。在所有单元被编译后,链接器对所有单元进行另一次传递,并使用这些额外的信息来优化合并后的编译代码。这个额外的过程增加了编译时间,但恢复了大部分运行时的性能,这些性能可能是由于将编译分割成小部分而损失的。特别是,LTO 可以为那些对性能敏感的程序提供显著的性能提升,这些程序可能会从交叉比率优化中受益。不过要注意的是,交叉比率 LTO 会使你的编译时间增加很多。

Rust 默认在每个 crate 内的所有编码单元中执行 LTO,试图弥补因使用许多编码单元而造成的优化损失。由于 LTO 只在每个 crate 内执行,而不是跨 crate,所以这个额外的过程并不繁琐,而且增加的编译时间应该低于使用大量编码单元所节省的时间。Rust 还提供了一种被称为 "瘦 LTO "的技术,它允许 LTO 通道大部分被并行化,但代价是会错过一些 "完整 "LTO 通道会发现的优化。

注意: LTO 在很多情况下也可以用来跨越外部函数接口的边界进行优化。参见 linker-pluginlto rustc 标志以了解更多细节。

[profile] 部分也支持帮助调试的标志,如 debugdebug-assertionsoverflow-checksdebug 标志告诉编译器在编译的二进制文件中包含调试符号。这增加了二进制文件的大小,但这意味着你可以在回溯和配置文件中得到函数名称和其他东西,而不仅仅是指令地址。debug-assertions 标志启用了 debug_assert!宏和其他相关的调试代码,否则不会被编译(通过 cfg(debug_assertions))。这样的代码可能会使你的程序运行得更慢,但它使你更容易在运行时发现有问题的行为。溢出检查(overflow-checks)标志,顾名思义,可以对整数操作进行溢出检查。这使它们的速度变慢(注意到一个趋势了吗?),但可以帮助你在早期抓住棘手的错误。默认情况下,这些都是在调试模式下启用,在发布模式下禁用。

[profile.*.panic]

[profile] 部分有另一个值得单独讨论的标志:panic。这个选项决定了当你的程序中的代码调用 panic 的时候会发生什么,可以是直接调用,也可以是通过 unwrap 这样的东西间接调用。你可以将 panic 设置为 unwind(大多数平台上的默认值)或 abort。我们将在第 9 章中更多地讨论 panicunwinding,但我将在这里做一个简单的总结。

通常在 Rust 中,当你的程序恐慌时,恐慌的线程开始解开它的栈。你可以认为解开堆栈是强行从当前函数递归到该线程的堆栈底部。也就是说,如果 main 调用了 foofoo 调用了 bar,而 bar 调用了 baz,那么 baz 中的恐慌将强行从 baz 返回,然后是 bar,然后是 foo,最后是 main,导致程序退出。一个展开的线程会正常丢弃堆栈上的所有值,这给了值一个清理资源、报告错误等的机会。这给了运行中的系统一个机会,即使在恐慌的情况下也能优雅地退出。

当一个线程惊慌失措并展开(unwinds)时,其他线程继续运行,不受影响。只有当运行 main 的线程退出时,程序才会终止。也就是说,恐慌通常被隔离在发生恐慌的线程中。

这意味着展开(unwinding)是一把双刃剑;程序在一些失败的组件中蹒跚前行,这可能会导致各种奇怪的行为。例如,想象一下,一个线程在更新 Mutex 中的状态时,中途慌乱了。任何随后获得该 Mutex 的线程现在都必须准备好处理这一事实,即状态可能处于部分更新、不一致的状态。 出于这个原因,一些同步原语(如 Mutex)会记住它们最后被访问时是否发生了恐慌,并将其传达给随后试图访问该原语的任何线程。如果一个线程遇到了这样的状态,它通常也会恐慌,这将导致一个级联,最终终止整个程序。但这可以说比带着损坏的状态继续运行要好得多!

支持展开(unwinding)所需的簿记不是免费的,它通常需要编译器和目标平台的特殊支持。例如,许多嵌入式平台根本就不能有效地解开堆栈。因此,Rust 支持一种不同的恐慌模式:abort 确保整个程序在发生恐慌时立即退出。在这种模式下,没有线程可以做任何清理工作。 这看起来很严重,而且确实如此,但它确保了程序永远不会在半工作状态下运行,而且错误会立即显现。

警告 panic 设置是全局性的--如果你把它设置为 abort,你所有的依赖也会被编译成 abort

你可能已经注意到,当一个线程恐慌时,它往往会打印一个回溯:导致恐慌发生的函数调用的痕迹。这也是一种展开(unwinding)的形式,尽管它与这里讨论的展开恐慌行为是分开的。你可以通过给 rustc 传递 -Cforce-unwind-tables 即使在 panic=abort 情况下也可以进行回溯,这使得 rustc 包括回溯堆栈所需的信息,同时仍然可以终止恐慌的程序。

配置文件覆盖(PROFILE OVERRIDES)

你可以使用 profile overrides 为特定的依赖关系或特定的 profile 设置 profile 选项。例如,清单 5-8 显示了如何使用 [profile.<profile-name>.package.<crate-name>] 语法,在调试模式下为 serde crate 启用积极的优化,为所有其他 crate 启用适度的优化。

#![allow(unused)]
fn main() {
[profile.dev.package.serde]
opt-level = 3
[profile.dev.package."*"]
opt-level = 2

// 清单 5-8:覆盖特定依赖关系或特定模式的配置文件选项
}

如果某些依赖在调试模式下速度过慢(如解压缩或视频编码),而你又需要对其进行优化,以便你的测试套件不至于花上几天时间来完成,那么这种优化覆盖就会很方便。你也可以在 ~/.cargo/config 的 Cargo 配置文件中使用 [profile.dev] (或类似)部分来指定全局配置文件的默认值。

当你为一个特定的依赖关系设置优化参数时,请记住,这些参数只适用于作为该 crate 的一部分而编译的代码;如果本例中的 serde 有一个通用的方法或类型,而你在你的 crate 中使用该方法或类型,该方法或类型的代码将在你的 crate 中被单态化和优化,你 crate 的配置文件设置将适用,而不是 serde 的配置文件覆盖中的设置。

条件编译

你写的大多数 Rust 代码都是通用的--无论它在什么 CPU 或操作系统上运行,都会有同样的效果。但有时你必须做一些特别的事情,让代码在 Windows、ARM 芯片上运行,或者在针对特定平台应用二进制接口(ABI)进行编译时运行。或者你想在某个 CPU 指令可用时编写一个特定函数的优化版本,或者在持续集成(CI)环境中运行时禁用一些缓慢但无趣的设置代码。为了迎合这样的情况,Rust 提供了条件编译的机制,即只有在编译环境的某些条件为真时,才会编译一个特定的代码段。

我们用 cfg 关键字来表示有条件的编译,你在本章前面的 "在你的 crate 中使用 features"中看到过。它通常以#[cfg(condition)] 属性的形式出现,表示只有在条件为真时才会编译下一个项目。Rust 也有 #[cfg_attr(condition, attribute)],如果条件成立,则编译为 #[attribute],否则为 no-op。你也可以使用 cfg!(condition) 宏将 cfg 条件评估为布尔表达式。

每个 cfg 结构都需要一个由选项组成的单一条件,比如 feature = "some-feature",以及组合器 allanynot,它们的作用可能就是你所期望的。选项要么是简单的名字,比如 unix,要么是键/值对,比如 feature 条件所使用的。

有许多有趣的选项,你可以使汇编依赖于此。让我们从最常见的到最不常见的来看看它们。

  • Feature 选项

    你已经看过这些例子了。特征选项的形式是 feature = "name-of-feature",如果指定的特征被启用,则认为是真的。你可以使用组合器在一个条件中检查多个特征。例如,any(feature = "f1", feature = "f2") 如果特征 f1 或特征 f2 被启用,则为真。

  • 操作系统选项

    这些使用键/值语法,键为 target_os,值为 windowsmacoslinux。你也可以用 target_family 来指定一个操作系统系列,它的值是 windowsunix。 这些都很常见,它们已经有了自己的命名简式,所以你可以直接使用 cfg(windows)cfg(unix)。例如,如果你想让一个特定的代码段只在 macOS 和 Windows 上编译,你可以这样写。#[cfg(any(windows, target_os = "macos"))]

  • 上下文选项

    这些可以让你根据特定的编译环境来定制代码。其中最常见的是测试选项,只有当编译箱在测试配置文件下被编译时,它才是真的。请记住,test 只为被测试的 crate 设置,而不是为它的任何依赖项设置。这也意味着,在运行集成测试时,测试不会在你的 crate 中设置;是集成测试在测试配置文件下被编译,而你的实际 crate 被正常编译(也就是没有设置测试)。这同样适用于 docdoctest 选项,它们分别只在构建文档或编译 doctests 时设置。 还有 debug_assertions 选项,它默认设置为调试模式。

  • Tool 选项

    一些工具,像 clippy 和 Miri,设置了自定义选项(后面会有更多介绍),让你在这些工具下运行时自定义编译。通常,这些选项是以相关的工具命名的。例如,如果你想让一个特定的计算密集型测试不在 Miri 下运行,你可以给它一个属性#[cfg_attr(miri, ignore)]

  • 架构选项

    这些可以让你根据编译器所针对的 CPU 指令集来进行编译。你可以用 target_arch 来指定一个特定的架构,它的值是 x86mipsarch64,或者你可以用 target_feature 来指定一个特定的平台特性,它的值是 avxsse2。对于非常低级的代码,你可能还会发现 target_endiantarget_pointer_width 选项很有用。

  • 编译器选项

    这些可以让你的代码适应它所编译的平台 ABI,并且可以通过 target_env 的值(如 gnumsvcmusl)获得。由于历史原因,这个值通常是空的,特别是在 GNU 平台上。通常只有当你需要直接与环境 ABI 对接时才需要这个选项,例如当使用 #[link] 与 ABI 特定的符号名链接时。

虽然 cfg 条件通常是用来定制代码的,但有些也可以用来定制依赖关系。例如,依赖关系 winrt 通常只在 Windows 上有意义,而 nix crate 可能只在基于 Unix 的平台上有用。清单 5-9 给出了一个如何使用 cfg 条件的例子。

#![allow(unused)]
fn main() {
[target.'cfg(windows)'.dependencies]
winrt = "0.7"
[target.'cfg(unix)'.dependencies]
nix = "0.17"

// 清单 5-9:条件性依赖
}

在这里,我们指定 winrt 0.7 版仅在 cfg(windows) 下被视为依赖(所以,在 Windows 上),而 nix 0.17 版仅在 cfg(unix) 下被视为依赖(所以,在 Linux、macOS 和其他基于 Unix 的平台上)。有一点需要注意的是,[dependencies] 部分是在构建过程的早期进行评估的,当时只有某些 cfg 选项可用。特别是,feature 和上下文选项在这个时候还不能用,所以你不能用这个语法来拉入基于 feature 和上下文的依赖。然而,你可以使用任何只依赖于目标规范或架构的 cfg,以及任何由调用 rustc 的工具明确设置的选项(如 cfg(miri))。

注意:在我们讨论依赖性规范的时候,我强烈建议你设置你的 CI 基础设施,使用 cargo-denycargo-audit 等工具对你的依赖性进行基本审计。这些工具可以检测到以下情况:你过渡性地依赖某个特定依赖的多个主要版本,你依赖未维护或有已知安全漏洞的 crate,或者你使用你可能想要避免的许可证。使用这样的工具是以自动化的方式提高你的代码库质量的一个好方法。

添加你自己的自定义条件编译选项也很简单。你只需要确保当 rustc 编译你的 crate 时,--cfg=myoption 被传递给 rustc。最简单的方法是将你的 --cfg 添加到 RUSTFLAGS 环境变量中。这在 CI 中很有用,你可能想根据你的测试套件是在 CI 上还是在开发机器上运行来定制它:在你的 CI 设置中把 --cfg=ci 添加到 RUSTFLAGS,然后在你的代码中使用 cfg(ci)cfg(not(ci))。这样设置的选项也可以在 Cargo.toml 的依赖关系中使用。

版本控制(Versioning)

所有的 Rust crates 都是有版本的,并且要遵循 Cargo 的语义版本控制的实现。语义版本管理规定了什么样的变更需要什么样的版本增加,以及哪些版本被认为是兼容的,以及以什么样的方式兼容的规则。RFC 1105 标准本身非常值得一读(它的技术含量并不高),但总结起来,它区分了三种变化:破坏性变化,需要进行重大的版本变更;增加,需要进行小的版本变更;以及错误修复,只需要进行补丁版本变更。RFC 1105 很好地概述了什么是 Rust 中的破坏性修改,我们在本书的其他地方也提到了它的某些方面。

我不会在这里详述不同类型变化的确切语义。相反,我想强调一些在 Rust 生态系统中出现的不太直接的版本号方式,当你决定如何对你自己的 crate 进行版本管理时,你需要记住这些方式。

最小支持的 Rust 版本

第一个 Rust 主义是最小支持的 Rust 版本(MSRV)。 在 Rust 社区有很多关于项目在 MSRV 和版本划分时应该遵守什么政策的争论,而且没有真正好的答案。问题的核心在于,一些 Rust 用户被限制在使用旧版本的 Rust,通常是在企业环境中,他们没有什么选择。 如果我们不断利用新稳定的 API,这些用户将无法编译我们的 crates 的最新版本,将被抛弃。

有两种方法可以使处于这种情况的用户生活得更轻松。第一种是建立一个 MSRV 政策,承诺新版本的程序包将总是与过去 X 个月的任何稳定版本一起编译。具体数字不一,但 6 或 12 个月是很常见的。在 Rust 的六周发布周期中,这分别对应于最近的四个或八个稳定版本。任何引入项目的新代码都必须用 MSRV 编译器进行编译(通常由 CI 检查),或者被保留到 MSRV 政策允许它被合并为原样。这有时会很麻烦,因为这意味着这些 crate 不能利用语言所能提供的最新和最好的东西,但它会使你的用户生活得更轻松。

第二个技巧是确保在 MSRV 发生变化时,增加你的 crate 的次要版本号。因此,如果你发布了 2.7.0 版本的 crate,并将你的 MSRV 从 Rust 1.44 增加到 Rust 1.45,那么一个停留在 1.44 上并依赖于你的 crate 的项目可以使用依赖版本指定符 version = "2, <2.7" 来保持项目的工作,直到它可以转移到 Rust 1.45 上。重要的是,你要增加次要版本,而不仅仅是补丁版本,这样你就可以在必要时通过发布另一个补丁来为之前的 MSRV 版本发布关键的安全修复。

一些项目对他们的 MSRV 支持如此重视,以至于他们认为 MSRV 的变化是一个破坏性的变化,并增加了主要版本号。这意味着下游项目将明确地选择加入 MSRV 变化,而不是选择退出,但这也意味着那些没有如此严格的 MSRV 要求的用户,如果不更新他们的依赖关系,就无法看到未来的错误修复,这可能需要他们也发布一个破坏性的变化。正如我所说,这些解决方案没有一个是没有缺点的。在今天的 Rust 生态系统中执行 MSRV 是一个挑战。只有一小部分 crate 提供了任何 MSRV 保证,即使你的依赖关系提供了保证,你也需要不断地监控它们,以了解它们何时增加 MSRV。当他们这样做的时候,你需要用前面提到的限制性的版本范围来发布你的 crate,以确保你的 MSRV 不会改变。这可能反过来迫使你放弃对你的依赖关系的安全和性能更新,因为你将不得不继续使用旧版本,直到你的 MSRV 政策允许更新。而这个决定也会延续到你的依赖者身上。有人提议将 MSRV 检查纳入 Cargo 本身,但截至目前,还没有任何可行的方案被稳定下来。

最小的依赖版本

当你第一次添加一个依赖关系时,并不总是很清楚你应该给这个依赖关系一个什么样的版本说明。程序员通常会选择最新的版本,或者只选择当前的主要版本,但这两种选择都有可能是错误的。我所说的 "错误 "并不是指你的 crate 不能编译,而是指做出这个选择可能会给你的 crate 的用户带来麻烦。 让我们看看为什么这些情况都有问题。

首先,考虑这样的情况:你添加了对 hugs = "1.7.3"的依赖,这是最新发布的版本。现在想象一下,某个地方的开发者依赖于你的 crate,但他们也依赖于另一个 crate,foo,它本身也依赖于 hugs。进一步想象,foo 的作者对他们的 MSRV 政策非常谨慎,所以他们依赖于 hugs = "1, <1.6"。这里,你会遇到麻烦。当 Cargo 看到 hugs = "1.7.3"时,它只考虑>=1.7 的版本。但是它看到 foohugs 的依赖需要<1.6,所以它放弃了,并报告说没有一个版本的 hugs 与所有需求兼容。

注意:在实践中,有很多原因可以解释为什么一个 crate 可能明确地不需要一个较新版本的依赖关系。最常见的原因是为了执行 MSRV,满足企业的审计要求(较新的版本将包含未被审计的代码),以及确保可重复的构建,其中只使用准确列出的版本。

这很不幸,因为你的 crate 很可能在比如说 hugs 1.5.6 中编译得很好。也许它甚至可以在任何 1.X 版本中编译正常。但如果使用最新的版本号,你就等于告诉 Cargo 只考虑这个小版本或更大的版本。那么,解决办法是用 hugs = "1"来代替吗?不,这也不完全正确。可能是你的代码确实依赖于 hugs 1.6 中添加的东西,所以虽然 1.6.2 可以,但 1.5.6 就不行了。如果你只在最终使用较新版本的情况下编译你的 crate,你就不会注意到这一点,但如果依赖关系图中的某些 crate 指定 hugs = "1, <1.5",你的 crate 就不会被编译了。

正确的策略是列出最早的版本,该版本拥有你的 crate 所依赖的所有东西,并确保在你向你的 crate 添加新的代码时也是如此。你最好的办法是使用 Cargo 的不稳定的-Zminimal-versions 标志,这使得你的 crate 使用所有依赖的最小可接受版本,而不是最大版本。然后,将所有的依赖关系设置为最新的主要版本号,尝试编译,并为任何不能编译的依赖关系添加一个次要版本,如此反复,直到一切编译正常,你现在有了最低版本要求。

值得注意的是,就像 MSRV 一样,最小的版本检查也面临着一个生态系统的采用问题。虽然你可能已经正确地设置了所有的版本指定器,但你所依赖的项目可能没有设置。这使得 Cargo 的最小版本标志在实践中难以使用(这也是它仍然不稳定的原因)。如果你依赖 foo,而 foo 依赖 bar,并指定 bar="1",而实际上它需要 bar="1.4",那么无论你如何列出 foo,Cargo 都会报告说编译 foo 失败,因为-Z 标志告诉它总是喜欢最小版本。你可以通过在你的依赖项中直接列出 bar 并加上适当的版本要求来解决这个问题,但这些变通方法的设置和维护是很痛苦的。你可能最终会列出大量的依赖关系,而这些依赖关系只是通过你的横向依赖关系拉进来的,而且你必须随着时间的推移保持这个列表的更新。

注意:目前的一个建议是提出一个标志,即对当前的 crate 倾向于最小的版本,但对依赖性倾向于最大的版本,这似乎很有希望。

变更记录(Changelogs)

除了最微不足道的 crate,我强烈建议保留一份更新日志。没有什么比看到一个依赖关系得到了重大的版本升级,然后不得不翻阅 Git 日志来弄清什么变化以及如何更新你的代码更令人沮丧的了。我建议你不要只是把 Git 日志转到一个名为 changelog 的文件里,而是要保留一个手动更新日志。它更有可能是有用的。

Keep a Changelog 格式是一个简单但很好的更新日志格式,该格式记录在 https://keepachangelog.com/。

未发布的版本

即使依赖关系的来源是一个目录或一个 Git 仓库,Rust 也会考虑版本号。这意味着,即使你还没有向 crates.io 发布版本,语义版本管理也是很重要的;在两次发布之间,你的 Cargo.toml 中列出的版本是很重要的。语义版本管理标准并没有规定如何处理这种情况,但是我将提供一个工作流程,这个工作流程可以很好地发挥作用,而不会过于繁琐。

在你发布了一个版本后,立即将 Cargo.toml 中的版本号更新为下一个补丁版本,后缀为-alpha.1。如果你刚刚发布了 2.0.3,就把新版本定为 2.0.4-alpha.1。

当你在两个版本之间对代码进行修改时,要注意是否有增加性或破坏性的变化。如果发生了这样的情况,而相应的版本号在上次发布后没有变化,就把它递增。例如,如果最后发布的版本是 2.0.3,目前的版本是 2.0.4-alpha.2,而你做了一个增加性的修改,那么就把这个版本改为 2.1.0-alpha.1,如果你做了一个破坏性的修改,就变成 3.0.0-alpha.1。如果已经做了相应的版本增加,只需增加α编号即可。

当你发布的时候,去掉后缀(除非你想做一个预发布),然后发布,从头开始。

这个过程是有效的,因为它使两个常见的工作流程运作得更好。首先,想象一下,一个开发者依赖于你的 crate 的主要版本 2,但他们需要一个目前只在 Git 中可用的功能。然后你提交了一个突破性的修改。如果你不同时增加主版本,他们的代码就会突然以意想不到的方式失败,要么无法编译,要么出现奇怪的运行时问题。如果你按照这里的程序,他们就会收到 Cargo 的通知,说发生了破坏性修改,他们必须解决这个问题,或者提交一个特定的提交。

接下来,想象一下,一个开发者需要一个他们刚刚为你的 crate 贡献的功能,但这还不是你的 crate 的任何发布版本的一部分。他们在 Git 依赖关系后面使用你的 crate 已经有一段时间了,所以他们项目中的其他开发者已经有了你的 crate 仓库的旧版检查。如果你不在 Git 中增加主版本号,这个开发者就没有办法告知他们的项目现在依赖于刚刚合并的功能。如果他们推送他们的改动,他们的同事会发现项目不再编译了,因为 Cargo 会重新使用旧的签出。另一方面,如果开发者能够增加 Git 依赖的次要版本号,那么 Cargo 就会意识到旧的签出已经过时了。

这个工作流程绝非完美。它没有提供一个很好的方法来沟通不同版本之间的多个小改动或大改动,而且你仍然需要做一些工作来跟踪版本。然而,它确实解决了 Rust 开发者在针对 Git 依赖关系工作时遇到的两个最常见的问题,而且即使你在两个版本之间做了多个这样的改动,这个工作流程仍然可以抓住许多问题。

如果你不太担心版本号过小或连续的问题,你可以通过简单地总是递增版本号的适当部分来改进这个建议的工作流程。不过要注意,根据你做这种修改的频率,这可能会使你的版本号变得相当大

总结

在这一章中,我们看了一些配置、组织和发布 crate 的机制,这些都是为了你自己和其他人的利益。我们还讨论了在 Cargo 中使用依赖关系和功能时的一些常见问题,希望这些问题在将来不会让你感到困扰。在下一章中,我们将讨论测试问题,并探讨如何超越我们熟悉和喜爱的 Rust 的简单 #[test] 函数。