四、Baby steps

1 版本

rust的版本指的是rust编译器版本,主要分为三个互不冲突的概念:语义化版本(Semantic Versioning),发行版本,Edition。

1.1 语义化版本

根据语义化版本的规定,语义化版本格式为:主版本号.次版本号.修订号,版本号递增规则如下:

  1. 主版本号:当你做了不兼容的 API 修改,
  2. 次版本号:当你做了向下兼容的功能性新增,
  3. 修订号:当你做了向下兼容的问题修正。

2005年rust的第一个语义化版本0.1.0发布,在主版本号为0时进行了快速迭代,直到2015年的第一个正式版本1.0.0发布,再到如今1.x.x的版本。而自从rust有了1.x.x版本以来,它就承诺永远不会破坏用1.0.01.x.x之间的版本编写的代码,这就意味着rust是向后兼容的。

具体的语义化版本会随着项目开发更新下去,可以参考:Rust Changelogs

1.2 发行版本

无停滞稳定

作为一个语言,rust十分注重代码的稳定性。rust希望成为你代码坚实的基础,假如持续地有东西在变,这个希望就实现不了。但与此同时,如果不能实验新功能的话,在发布之前rust开发团队又无法发现其中重大的缺陷,而一旦发布便再也没有修改的机会了。

对于这个问题的解决方案被称为无停滞稳定(stability without stagnation),其指导性原则是:无需担心升级到最新的稳定版rust。每次升级应该是无痛的,并应带来新功能,更少的 bug 和更快的编译速度。

发布通道和发布时刻表(Riding the Trains)

rust开发运行于一个发布时刻表(train schedule)之上,也就是说,所有的开发工作都位于rust源码仓库的 master 分支。发布采用software release train模型,该模型已被 Cisco IOS 和其他软件项目使用。 rust有三个发布渠道:

  • Nightly夜版
  • Betaβ版
  • Stable稳定版

image-20230409152022643

如图,在rust的github源码仓库里,可以看到这三个主要的分支,其它的分支会随着项目的变化而变化。其中master分支就对应稳定版。大部分rust开发者主要采用稳定版通道,不过希望实验新功能的开发者可能会使用nightlybeta版。

如下是一个开发和发布过程如何运转的例子:假设 Rust 团队正在进行 Rust 1.5 的发布工作。该版本发布于 2015 年 12 月,不过这里只是为了提供一个真实的版本。Rust 新增了一项功能:一个 master 分支的新提交。每天晚上,会产生一个新的 nightly 版本。每天都是发布版本的日子,而这些发布由发布基础设施自动完成。所以随着时间推移,发布轨迹看起来像这样,版本一天一发:

1
2
3
4
5
nightly: * - - * - - * - - * - - * - - * - * - *

beta:

stable:

Rust 有一个为期 6 周的发布循环,每 6 周时间,就是准备发布新版本的时候。Rust 仓库的 beta 分支会从用于 nightly 的 master 分支产生。现在,有了两个发布版本:

1
2
3
4
5
nightly: * - - * - - *
|
beta: *

stable:

大部分 Rust 用户不会主要使用beta 版本,不过在 CI 系统中对beta 版本进行测试能够帮助 Rust 发现可能的回归缺陷(regression)。同时,每天仍产生nightly发布:

1
2
3
4
5
nightly: * - - * - - * - - * - - *
|
beta: *

stable:

比如我们发现了一个回归缺陷。好消息是在这些缺陷流入稳定发布之前还有一些时间来测试beta版本!fix 被合并到 master,为此nightly版本得到了修复,接着这些 fix 将 backport 到 beta 分支,一个新的beta发布就产生了:

1
2
3
4
5
nightly: * - - * - - * - - * - - * - - *
|
beta: * - - - - - - - - *

stable:

第一个 beta 版的 6 周后,是发布稳定版的时候。stable 分支从 beta 分支生成:

1
2
3
4
5
nightly: * - - * - - * - - * - - * - - * - * - *
|
beta: * - - - - - - - - *
|
stable: *

现在,Rust 1.5 发布了。然而,我们忘了些东西:因为又过了 6 周,我们还需发布新版 Rust 的 beta 版,Rust 1.6。所以从 beta 生成 stable 分支后,新版的 beta 分支也再次从 nightly 生成:

1
2
3
4
5
nightly: * - - * - - * - - * - - * - - * - * - *
| |
beta: * - - - - - - - - * *
|
stable: *

这被称为火车模型(train model),因为每 6 周,一个版本离开车站(leaves the station),不过从beta通道到达稳定通道还有一段旅程。

Rust 每 6 周发布一个版本,如时钟般准确。如果你知道了某个 Rust 版本的发布时间,就可以知道下个版本的时间:6 周后。每 6 周发布版本的一个好的方面是下一班车会来得更快。如果特定版本碰巧缺失某个功能也无需担心:另一个版本很快就会到来!这有助于减少因临近发版时间而偷偷释出未经完善的功能的压力。

多亏了这个过程,你总是可以切换到下一版本的 Rust 并验证是否可以轻易的升级:如果 beta 版不能如期工作,你可以向 Rust 团队报告并在发布稳定版之前得到修复!beta 版造成的破坏是非常少见的,不过 rustc 也不过是一个软件,可能会存在 bug。

不稳定功能

这个发布模型中另一个值得注意的地方:不稳定功能(unstable features)。Rust 使用一个被称为功能标记(feature flags)的技术来确定给定版本的某个功能是否启用。如果新功能正在积极地开发中,其提交到了 master,因此会出现在 nightly版中,不过会位于一个功能标记之后。作为用户,如果你希望尝试这个正在开发的功能,则可以在源码中使用合适的标记来开启,不过必须使用nightly版。

如果使用的是beta或稳定版 Rust,则不能使用任何功能标记。这是在新功能被宣布为永久稳定之前获得实用价值的关键。这既满足了希望使用最尖端技术的开发者,那些坚持稳定版的人也知道其代码不会被破坏。这就是所谓的无停滞稳定。

比如我们曾经见过cargo-expand这个crate,使用时需要切换到nightly版本就是一个例子。

1.3 Edition

前面提到rust有一个为期6周的发布循环,这意味着用户会稳定得到新功能的更新。其他编程语言发布大更新但不甚频繁,Rust 选择更为频繁的发布小更新。一段时间之后,所有这些小更新会日积月累。

每两到三年,Rust 团队会生成一个新的 Rust 版次(edition)。每一个版次会结合已经落地的功能,并提供一个清晰的带有完整更新文档和工具的功能包。新版次会作为常规的 6 周发布过程的一部分发布。

这为不同的人群提供了不同的功能:

  • 对于活跃的 Rust 用户,其将增量的修改与易于理解的功能包相结合。
  • 对于非用户,它表明发布了一些重大进展,这意味着 Rust 可能变得值得一试。
  • 对于 Rust 自身开发者,其提供了项目整体的集合点。

截止在2023年这篇文章编写时,Rust目前有三个可用版次:Rust 2015、Rust 2018 和 Rust 2021,并且,Rust团队已经确定今后每三年发布一个版次,因此下一个版次应该是Rust 2024。

在项目的Cargo.toml文件中的 edition 字段表明代码应该使用哪个版本编译,如果该字段不存在,其默认为 2015 以提供后向兼容性。

1
2
3
4
[package]
name = "ruststudy"
version = "0.1.0"
edition = "2021"

其中,2015版次对应1.0.0-1.30.x语义化版本,2018版次对应1.31.0-1.55.x语义化版本,2021版是从1.56.0-至今的语义化版本。

每个项目都可以选择不同于默认的 2015 的版次。这样可能会包含不兼容的修改,比如新增关键字可能会与代码中的标识符冲突并导致错误。不过除非选择兼容这些修改,旧的代码仍将能够编译,即便升级了 Rust 编译器的版本。

所有 Rust 编译器都支持任何之前存在的编译器版本,并可以链接任何支持版本的crate。编译器修改只影响最初的解析代码的过程。因此,如果你使用 Rust 2015 而某个依赖使用 Rust 2018,你的项目仍旧能够编译并使用该依赖。反之,若项目使用 Rust 2018 而依赖使用 Rust 2015 亦可工作。

大部分功能在所有版本中都能使用。开发者使用任何 Rust 版本将能继续接收最新稳定版的改进。然而在一些情况,主要是增加了新关键字的时候,则可能出现了只能用于新版本的功能。只需切换版本即可利用新版本的功能。

2 编译过程概述

rustc 是 Rust 编程语言的编译器,由项目本身提供。编译器获取源代码并生成二进制代码,作为库或可执行文件。

了解rust的编译过程是深入理解rust不可少的一部分,这里就从宏观上介绍一下rust的编译过程。

Rust 编译器在两个方面很特别:它对您的代码做了其他编译器不会做的事情(例如借用检查),并且它有很多非常规的实现选择(例如queries)。

首先,让我们看看编译器对源代码做了什么。

2.1 调用编译器

当用户以文本形式编写 Rust 源程序并在其上调用 rustc 编译器时,编译就开始了。编译器需要执行的工作由命令行选项定义。例如,可以启用nightly编译标志( -Z 标志),仅执行 check 构建,或编译为中间代码而不是编译成可执行机器代码等等。 rustc 可执行调用可以通过使用 cargo 来间接调用,事实上大部分开发者都是如此。

命令行参数解析发生在 rustc_driver 中。这个crate定义了用户请求的编译配置,并将其作为 rustc_interface::Config 传递给编译过程的其余部分。

2.2 词法分析

原始 Rust 源代码由位于 rustc_lexer 的低级词法分析器分析。在此阶段,源代码被转换为原子的代码单元流,也就是我们常说的由词法单元token组成的词法单元流token stream。词法分析器支持Unicode字符编码。

2.3 语法分析

词法单元流通过位于 rustc_parse 中的更高级别的词法分析器(解析器),为编译过程的下一阶段做准备。

解析器将来自词法分析器的token stream使用递归下降(自上而下)方法进行语法分析,翻译成抽象语法树(AST)。

2.4 语义分析 HIR

接下来,进行语义分析阶段。首先使用 AST 并将其转换为高级中间表示(High-Level Intermediate Representation,HIR),这是一种对编译器更友好的 AST 表示。这个过程称为降低(lowering)。在这个过程中涉及很多诸如 for 循环被转化为loopif let 被转为match以及 async fn 之类的脱糖(desugaring)等等。HIR 相对于 AST 更有利于编译器的分析工作。

接下来编译器会使用 HIR 进行类型推断(自动检测表达式类型的过程)、特征求解(将impl与每个对特征的引用配对的过程)和类型检查。类型检查是将 HIR ( hir::Ty ) 中发现的类型(表示用户编写的内容)转换为编译器使用的内部表示形式 ( Ty<'tcx>) 的过程。该信息用于验证程序中使用的类型的类型安全性、正确性和一致性。

2.5 语义分析 MIR

下一步,将 HIR 降低到中级中间表示(Mid-level Intermediate Representation,MIR),用于借用检查。在此过程中,还构建了过渡中间代码表示 THIR,这是一个更加脱糖的HIR。THIR用于模式和详尽检查,用它转换成 MIR 也比用 HIR 更方便。

MIR做了很多优化,这些优化在借用检查之后运行。这改进了以后生成的代码,也提高了编译速度。

关于 MIR 还需要了解它的三个关键特性:

  • 它是基于控制流图(Control Flow Graph)的。
  • 它没有嵌套表达式。
  • MIR 中的所有类型都是完全明确的,不存在隐性表达。人类可读,所以在 Rust 学习过程中,可以通过查看 MIR 来了解 Rust 代码的一些行为,比如更好地理解复杂生命周期。

Rust 代码是单态的,这意味着需要复制所有通用代码,并将类型参数替换为具体类型。为此,我们需要收集一个列表,列出要为其生成代码的具体类型。这称为单态化收集,它也发生在 MIR 级别。

2.6 代码生成

我们将最后的阶段模糊地称为代码生成阶段(code generation)。代码生成阶段是将更高级别的源表示形式转换为可执行二进制文件的阶段。 通常,rustc 使用 LLVM 进行代码生成。但也支持 Cranelift 和 GCC。

LLVM 是模块化和可重用的编译器和工具链技术的集合,LLVM 这个名称本身并不是首字母缩写词,而是项目的全名。作为编译器后端,LLVM不仅仅为rust使用,事实上C++、C、Swift等等都可以使用它。采用LLVM的一些原因如下:

  • LLVM后端支持的平台很多,我们不需要担心CPU、操作系统的问题(运行库除外)。
  • LLVM后端的优化水平较高,我们只需要将代码编译成LLVM IR,就可以由LLVM后端作相应的优化。
  • 我们不必编写整个编译器后端。这减少了实施和维护负担。
  • 可以自动将 Rust 编译到 LLVM 支持的任何平台。例如,一旦 LLVM 添加了对 WASM 的支持,那么 rustc、clang 和许多其他语言都能够编译成 WASM。
  • LLVM IR本身比较贴近汇编语言,同时也提供了许多ABI层面的定制化功能。

Rust 核心团队也会帮忙维护LLVM,发现了 Bug 也会提交补丁。虽然 LLVM 有这么多优点,但它也有一些缺点,比如编译比较慢。所以,Rust 团队在去年引入了新的后端 Cranelift ,用于加速 Debug 模式的编译。

通常,rustc_codegen_ssa包含与后端无关的代码,然后由 Cranelift 来处理。从2021年1月开始,通过rustc_codegen_ssa 又为所有后端提供了一个抽象接口以实现,以允许其他代码源后端(如 Cranelift)。这意味着,Rust 语言将来可以接入多个编译后端。

现在来看看代码生成阶段的步骤:

第一步是将 MIR 转换为 LLVM 中间表示(LLVM IR)。根据在上一步中创建的列表,这实际上是 MIR 单态化的地方。

第二步 LLVM IR 被传递给LLVM,后者对其进行了更多优化。

第三步由LLVM生成机器代码。它基本上是添加了额外的低级类型和注释的汇编代码(例如 ELF 对象或 WASM)。

最后,将不同的库或二进制文件链接在一起以生成最终的二进制文件。

以上就是 Rust 整体的编译过程,注意,这只是一个简单的概述,事实上这是一个复杂的过程,其中每一部分都有大量细节。

3 词法结构

作为大部分开发者,其实无需了解编译器每个部分的实现细节。但是作为学习,了解rust的词法结构有助于梳理rust相对复杂的语法。

词法和语法片段使用的符号如下:

表义符示例释义
CAPITALKW_IF, INTEGER_LITERAL由词法分析生成的词法单元 token
ItalicCamelCaseLetStatement, Item句法产生式(syntactical production)
stringx, while, *确切的字面字符(串)
\x\n, \r, \t, \0转义字符
x?pub?x重复零次或一次(可选项)
x*OuterAttribute*x 重复零次或多次
x+MacroMatch+x 重复一次或多次
xa..bHEX_DIGIT1..6x 重复 a 到 b 次
|u8 | u16,Block | Item
[ ][b B]列举出的任意字符
[ - ][a-z]a 到 z 范围内的任意字符
~[ ]~[b B]列举范围外的任意字符
~string~\n, ~*/此字符序列外的任意字符
( )(, Parameter)?程序项分组

3.1 关键字

Rust 将关键字分为三类:

  • 严格关键字(strict)
  • 保留关键字(reserved)
  • 弱关键字(weak)

严格关键字

这类关键字只能在正确的上下文中使用。不能将它们用于宏名、函数名、函数参数、生存周期参数等等。

词法分析如下:

KW_AS : as KW_BREAK : break KW_CONST : const KW_CONTINUE : continue KW_CRATE : crate KW_ELSE : else KW_ENUM : enum KW_EXTERN : extern KW_FALSE : false KW_FN : fn KW_FOR : for KW_IF : if KW_IMPL : impl KW_IN : in KW_LET : let KW_LOOP : loop KW_MATCH : match KW_MOD : mod KW_MOVE : move KW_MUT : mut KW_PUB : pub KW_REF : ref KW_RETURN : return KW_SELFVALUE : self KW_SELFTYPE : Self KW_STATIC : static KW_STRUCT : struct KW_SUPER : super KW_TRAIT : trait KW_TRUE : true KW_TYPE : type KW_UNSAFE : unsafe KW_USE : use KW_WHERE : where KW_WHILE : while

以下关键字从 2018 版开始启用:

KW_ASYNC : async KW_AWAIT : await KW_DYN : dyn

弱关键字

这类关键字只有在特定的上下文中才有特殊的意义。例如,可以声明名为 union 的变量或方法。

  • macro_rules 用于创建自定义宏。

  • union 用于声明联合体union,它只有在联合体声明中使用时才是关键字。

  • 'static 用于静态生命周期,不能用作通用泛型生存期参数和循环标签。

  • 在 2015 版本中,当dyn用在非 :: 开头的路径限定的类型前时,它是关键字。

词法分析如下:

KW_UNION : union KW_STATICLIFETIME : 'static

在 2015 版本是弱关键字,2018 开始被提升为严格关键字: KW_DYN : dyn

保留关键字

这类关键字目前还没有被使用,但是它们被保留以备将来使用。它们具有与严格关键字相同的限制。这样做的原因是通过禁止当前程序使用这些关键字,从而使当前程序能兼容 Rust 的未来版本。

通过保留关键字,可以推测出将来版本可能要支持的一些特性或功能。

词法分析如下:

KW_ABSTRACT : abstract KW_BECOME : become KW_BOX : box KW_DO : do KW_FINAL : final KW_MACRO : macro KW_OVERRIDE : override KW_PRIV : priv KW_TYPEOF : typeof KW_UNSIZED : unsized KW_VIRTUAL : virtual KW_YIELD : yield

以下关键字从 2018 版开始成为保留关键字:

KW_TRY : try

3.2 标识符

标识符遵循Unicode 标准附件 #31中针对 Unicode 15.0 版的规范,并添加了如下所述的内容。下面是标识符的一些示例:

  • foo
  • _identifier
  • r#true
  • Москва
  • 東京

根据该规范可以得知,默认标识符语法(Default Identifier Syntax)的巴科斯范式(BNF)如下:

1
<Identifier> := <Start> <Continue>* (<Medial> <Continue>+)*

这个正式语法提供了标识符的定义,保证向后兼容每个后续的 Unicode 版本,而且还添加了扩展任何新 Unicode 字符的能力:公式允许扩展,也称为配置文件。也就是说,语法所用的每个类别的特定代码点的集合可以根据环境的要求进行定制。

StartContinueMedial根据配置的不同而不同。比如:使用 Unicode 作为标识符的语言规则的典型模式是定义一个 ID_Start 类和一个 ID_Continue 类,分别代表标识符的首字符和后续字符的符号集合。

ID_Start字符源自 Unicode General_Category 的大写字母、小写字母、首字母大写字母等等。

ID_Continue 字符包括 ID_Start 字符,加上具有 Unicode General_Category 的非间距标记、间距组合标记、十进制数等等。

Medial为中间的可选字符,Medial中的字符不得与StartContinue 中的字符重叠。

rust采用的配置文件为:

XID_startXID_continue 涵盖了用于构成常见的 C 和 Java 语言族标识符的字符范围。

词法分析(见:Identifiers):

IDENTIFIER_OR_KEYWORD : XID_start XID_continue* | _ XID_continue+

RAW_IDENTIFIER : r#IDENTIFIER_OR_KEYWORD 排除 crate, self, super, Self

NON_KEYWORD_IDENTIFIER : IDENTIFIER_OR_KEYWORD 排除严格关键字和保留关键字

IDENTIFIER : NON_KEYWORD_IDENTIFIER | RAW_IDENTIFIER

原始标识符(RAW_IDENTIFIER)类似于普通标识符,但以 r# 为前缀。与普通标识符不同,原始标识符可以是任何严格或保留的关键字,但上面列出的 RAW_IDENTIFIER 除外。反过来说,如果没有在原始标识符中描述的 r# 前缀,标识符可能不是严格关键字或保留关键字。

这些规则不用特意去记,基本上按照其它编程语言的规范来书写就基本没有问题。

3.3 注释

词法分析:

LINE_COMMENT(行注释) : // (~[/ ! \n] | //) ~\n* | //

BLOCK_COMMENT(块注释) : /* (~[* !] | ** | BlockCommentOrDoc) (BlockCommentOrDoc | ~*/)* */ | /**/ | /***/

INNER_LINE_DOC(内部行文档型注释) : //! ~[\n IsolatedCR]*

INNER_BLOCK_DOC(内部块文档型注释) : /*! ( BlockCommentOrDoc | ~[*/ IsolatedCR] )* */

OUTER_LINE_DOC(外部行文档型注释) : /// (~/ ~[\n IsolatedCR]*)?

OUTER_BLOCK_DOC(外部块文档型注释) : /** (~* | BlockCommentOrDoc ) (BlockCommentOrDoc | ~[*/ IsolatedCR])* */

BlockCommentOrDoc(块注释或文档型注释) : BLOCK_COMMENT | OUTER_BLOCK_DOC | INNER_BLOCK_DOC

IsolatedCR(独立的制表符) : 后面没有跟 \n\r

下面是一些正确的例子:

  • 行注释要注意第三个符号不能是单独的/!\n

    1
    2
    //
    ////
  • 块注释的第三个符号不能时单独的*!

    1
    2
    3
    4
    5
    /* */
    /**/
    /***/
    /*/*支持嵌套 */*/
    /* /** 嵌套任何块注释或文档型注释*/ */
  • 内部文档型注释:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //! 应用于此 crate 的隐式匿名模块的文档型注释

    pub mod outer_module {

    //! - 内部行文档型注释
    //!! - 仍是内部行文档型注释 (但是这样开头会更具强调性)

    /*! - 内部块文档型注释 */
    /*!! - 仍是内部块文档型注释 (但是这样开头会更具强调性) */
    pub mod inner_module {}
    }
  • 外部行文档型注释同样支持嵌套:

    1
    2
    3
    4
    ///
    /** */
    /** /** /* /** */ */ */ */
    fn main() {}

3.4 空白符

空白符是任何非空字符串,它里面只包含具有 Pattern_White_Space 属性的 Unicode 字符。即:

  • U+0009 (水平制表符, '\t'
  • U+000A (换行, '\n'
  • U+000B (垂直制表符)
  • U+000C (分页符)
  • U+000D (回车, '\r'
  • U+0020 (空格, ' '
  • U+0085 (下一行)
  • U+200E (从左到右的标记)
  • U+200F (从右到左的标记)
  • U+2028 (行分隔符)
  • U+2029 (段落分隔符)

Rust 是一种格式自由(free-form)的语言,这意味着所有形式的空格仅用于分隔语法中的token,没有语义意义。

Rust 程序中,如果将一个空白符元素替换为任何其他合法的空白符元素(例如单个空格字符),它们仍有相同的意义。

3.5 token

token 是采用非递归方式的正则文法(regular languages)定义的基本语法产生式(primitive productions)。Rust 源码输入可以被分解成以下几类 token:

具体参考见:Tokens

4 语句和表达式

4.1 语句

Rust 主要有两种语句:声明语句和表达式语句。

句法:

Statement : ; | Item | LetStatement | ExpressionStatement | MacroInvocationSemi

另外,单个分号(;)本身就是一条语句,还有一个是宏调用(MacroInvocationSemi)。

声明语句

声明语句是将一个或多个名称引入封闭语句块的语句。声明的名称可能表示新变量或新程序项

有两种声明语句是程序项声明语句和let声明语句。

程序项声明语句

程序项声明语句的句法形式与模块中的程序项声明的句法形式相同。在语句块中声明的程序项会将其作用域限制为包含该语句的块。这类程序项以及在其内声明子项(sub-items)都没有给定的规范路径。例外的是,只要程序项和 trait(如果有的话)的可见性允许,在(程序项声明语句内定义的和此程序项或 trait 关联的)实现中定义的关联项在外层作用域内仍然是可访问的。除了这些区别外,它与在模块中声明的程序项的意义也是相同的。

程序项声明语句不会隐式捕获包含它的函数的泛型参数、参数和局部变量。如下程序中,inner 不能访问 outer_var

1
2
3
4
5
6
7
fn outer() {
let outer_var = true;

fn inner() { /* outer_var 的作用域不包括这里 */ }

inner();
}
let语句

句法: LetStatement : OuterAttribute* let PatternNoTopAlt ( : Type )? (= Expression )? ;

let语句通过一个不可反驳型模式引入了一组新的变量,变量由该模式给定。模式后面有一个可选的类型标注(annotation),再后面是一个可选的初始化表达式。当没有给出类型标注时,编译器将自行推断类型,如果没有足够的信息来执行有限次的类型推断,则将触发编译器报错。由变量声明引入的任何变量从声明开始直到封闭块作用域结束都是可见的。

表达式语句

句法:

ExpressionStatement : ExpressionWithoutBlock ; | ExpressionWithBlock ;?

表达式语句是对表达式求值并忽略其结果的语句。通常,表达式语句的目的是触发对其表达式求值的效果。

如果在允许语句的上下文中使用仅包含块表达式或控制流表达式组成的表达式,则可以省略尾随分号。这有可能会导致解析歧义,因为它可以被解析为独立语句,也可以被解析为另一个表达式的一部分;在这种情况下,它被解析为一条语句。用作语句的 ExpressionWithBlock 表达式的类型必须是单元类型。

1
2
3
4
5
6
7
v.pop();          // 忽略从 pop 返回的元素
if v.is_empty() {
v.push(5);
} else {
v.remove(0);
} // 分号可以省略。
[1]; // 单独的表达式语句,而不是索引表达式。

当省略尾随分号时,结果必须是 () 类型。

1
2
3
4
5
6
7
8
9
10
11
12
// bad: 下面块的类型是i32,而不是 `()` 
// Error: 预期表达式语句的返回值是 `()`
// if true {
// 1
// }

// good: 下面块的类型是i32,(加`;`后的语句的返回值就是 `()`了)
if true {
1
} else {
2
};

语句的属性

语句接受外部属性。对语句有意义的属性是 cfglint 检查属性

4.2 表达式

句法: Expression : ExpressionWithoutBlock | ExpressionWithBlock

ExpressionWithoutBlock : OuterAttribute ( LiteralExpression | PathExpression | OperatorExpression | GroupedExpression | ArrayExpression | AwaitExpression | IndexExpression | TupleExpression | TupleIndexingExpression | StructExpression | CallExpression | MethodCallExpression | FieldExpression | ClosureExpression | ContinueExpression | BreakExpression | RangeExpression | ReturnExpression | UnderscoreExpression | MacroInvocation )

ExpressionWithBlock : OuterAttribute ( BlockExpression | AsyncBlockExpression | UnsafeBlockExpression | LoopExpression | IfExpression | IfLetExpression | MatchExpression )

可以看到,表达式可以是块表达式和非块表达式。一个表达式可能有两个角色:它总是能产生一个值,并且它可能具有效果(effects),也称为副作用(side effects)。表达式的计算结果为一个值,并在计算期间具有效果。许多表达式包含子表达式,此子表达式也被称为此表达式的操作数。每种表达式都表达了以下几点含义:

  • 在计算表达式时是否计算操作数
  • 计算操作数的顺序
  • 如何组合操作数的值以获取表达式的值

基于对这几种含义的实现要求,表达式通过其内在结构规定了其执行结构。块只是另一种表达式,所以块、语句和表达式可以递归地彼此嵌套到任意深度。

表达式优先级

Rust 运算符和表达式的优先级顺序如下,从强到弱。处于相同优先级的二元运算符按其结合(associativity)顺序进行分组。

运算符/表达式结合性
Paths(路径)
Method calls(方法调用)
Field expressions (字段表达式)从左向右
Function calls, array indexing(函数调用,数组索引)
?
Unary(一元运算符) - * ! & &mut
as从左向右
* / %从左向右
+ -从左向右
<< >>从左向右
&从左向右
^从左向右
``
== != < > <= >=需要圆括号
&&从左向右
`
.. ..=需要圆括号
= += -= *= /= %= &= `= ^= <<= >>=`
return break closures(返回、中断、闭包)

操作数的求值顺序

下面的表达式都以相同的方式计算它们的操作数,具体列表后面也有详述。其他表达式要么不接受操作数,要么按照各自约定的条件进行求值。

  • 解引用表达式(Dereference expression)
  • 错误传播表达式(Error propagation expression)
  • 取反表达式(Negation expression)
  • 算术和二进制逻辑运算(Arithmetic and logical binary operators)
  • 比较运算(Comparison operators)
  • 类型转换表达式(Type cast expression)
  • 分组表达式(Grouped expression)
  • 数组表达式(Array expression)
  • 等待表达式(Await expression)
  • 索引表达式(Index expression)
  • 元组表达式(Tuple expression)
  • 元组索引表达式(Tuple index expression)
  • 结构体表达式(Struct expression)
  • 调用表达式(Call expression)
  • 方法调用表达式(Method call expression)
  • 字段表达式(Field expression)
  • 中断表达式(Break expression)
  • 区间表达式(Range expression)
  • 返回表达式(Return expression)

在应用表达式的效果之前,将计算这些表达式的操作数。采用多个操作数的表达式按照源代码中的编写从左到右计算。

例如,两个 next 方法调用将始终按相同的顺序调用:

1
2
3
4
5
let mut one_two = vec![1, 2].into_iter();
assert_eq!(
(1, 2),
(one_two.next().unwrap(), one_two.next().unwrap())
);

由于表达式是递归执行的,这些表达式也会从最内层到最外层逐层求值,忽略兄弟表达式,直到没有(未求值的)内部子表达式为止。

位置表达式和值表达式

表达式分为两大类:位置表达式(place expressions)和值表达式(value expressions)。还有第三类次要表达式,称为被分配表达式(assignee expressions,翻译仅供参考)。它们各自形成了自己的上下文,因此,在每个表达式中,操作数可以出现在位置上下文,也可出现在值上下文中。表达式的求值既依赖于它自己的类别,也依赖于它所在的上下文。

通过位置和值理解内存模型中曾提到位置和值的概念:

  • 位置指的是某一块内存位置,它有自己的地址和空间以及自己所保存的值。位置可能位于栈中,可能位于堆中,也可能位于全局内存区。

  • 值指的是存储到位置中的数据(保存在内存某个位置中的数据)。值的类型有多种,比如数值类型、指针类型、指针(裸指针、胖指针)、字符类型等等。

对应这两个概念:

  • 位置表达式是表示内存位置的表达式。这些表达式是指向局部变量、静态变量解引用(*expr)、数组索引表达式(expr[expr])、字段引用(expr.f)、圆括号括起来的位置表达式的路径。所有其他形式的表达式都是值表达式。

  • 值表达式是代表实际值的表达式。

下面列出的的上下文是位置表达式的上下文:

被分配表达式是出现在赋值表达式的左操作数中的表达式。明确地,被分配表达式是:

被分配表达式中允许任意括号。

移动语义类型和复制语义类型

当位置表达式在值表达式上下文中被求值时,或在模式中被值绑定时,这表示此值会保存进(held in)当前表达式代表的内存地址。如果该值的类型实现 Copy,则将复制该值。如果该值的类型没有实现 Copy,但实现了 Sized,那么就有可能把该值从原来的位置移动(move)过来。从位置表达式里移出值对位置表达式也有要求,只有如下的位置表达式才可能把值从其中移出(move out):

把值从一个位置表达式里移出到一个局部变量,那此(表达式代表的)地址将被取消初始化(deinitialized),并且该地址在重新初始化之前无法再次读取该位置。

除以上列出的情况外,任何在值表达式上下文中使用位置表达式都是错误的。

位置表达式的可变性

如果一个位置表达式将会被赋值、可变借出隐式可变借出或被绑定到包含 ref mut 模式上,则该位置表达式必须是可变的(mutable)。我们称这类位置表达式为可变位置表达式。与之相对,其他位置表达式称为不可变位置表达式。

下面的表达式可以是可变位置表达式上下文:

  • 当前未被借出的可变变量
  • 可变静态(static)项
  • 临时值
  • 字段,在可变位置表达式上下文中,可以对此子表达式求值。
  • *mut T 指针的解引用
  • 对类型为 &mut T 的变量或变量的字段的解引用。注意:这条是下一条规则的必要条件的例外情况。
  • 对实现 DerefMut 的类型的解引用,那对这里解出的表达式求值就需要在一个可变位置表达式上下文中进行。
  • 对实现 IndexMut 的类型做索引,那对此检索出的表达式求值就需要在一个可变位置表达式上下文进行。注意对索引(index)本身的求值不用。

临时位置/临时变量

在大多数位置表达式上下文中使用值表达式时,会创建一个临时的未命名内存位置,并将该位置初始为该值,然后这个表达式的求值结果就为该内存位置。此过程也有例外,就是把此临时表达式提升为一个静态项(static)。(这种情况下表达式将直接在编译时就求值了,求值的结果会根据编译器要求重新选择地址存储)。临时位置/临时变量的销毁作用域(drop scope)通常在包含它的最内层语句的结尾处。

隐式借用

某些特定的表达式可以通过隐式借用一个表达式来将其视为位置表达式。例如,可以直接比较两个非固定尺寸的切片是否相等,因为 == 操作符隐式借用了它自身的操作数:

1
2
3
4
5
6
7
let a: &[i32];
let b: &[i32];
// ...
// &[i32] 解引用后是一个动态尺寸类型,理论上两个动态尺寸类型上无法比较大小的,但这里因为隐式借用此成为可能
*a == *b;
// 等价于下面的形式:
::std::cmp::PartialEq::eq(&*a, &*b);

隐式借用可能会被以下表达式采用:

五、std标准库模块

在前三章,我们将介绍输入输出、路径以及文件系统的标准库。

1 io

std::io模块包含许多在执行输入和输出时需要的常见操作。该模块中最核心的部分是 ReadWrite,它们提供用于读取和写入输入和输出的最通用接口。

1.1 ReadWrite

因为它们是 traits,所以 ReadWrite 由许多其他类型实现,也可以为你的自定义类型实现它们。 因此,有很多不同类型的 I/O: FileTcpStream,有时甚至是 Vec。 例如,Read 添加了 read 方法,我们可以在 File 上使用该方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let mut f = File::open("foo.txt")?;
let mut buffer = [0; 10];

// 最多读取 10 个字节
let n = f.read(&mut buffer)?;

println!("The bytes: {:?}", &buffer[..n]);
Ok(())
}

由于 ReadWrite十分重要,这两个特征的实现者有一个绰号:readerswriters。所以有时你会看到“一个reader”而不是“一个实现 Read 特征的类型”。这样说起来更加简便。

1.2 ReadersWriters读取和写入数据

Readers 支持面向字节的输入,它需要一个输入源,用于确定从哪里读取字节。

例如:

Writers 支持面向字节和 UTF-8 文本输出,是那些你可以把值写入的地方,例如:

  • 使用 std::fs::File::create 创建的文件;
  • 基于网络连接 std::net::TcpStream 传输数据;
  • std::io::stdout()std::io:stderr() 可以用于向标准输出和标准错误写入内容;
  • std::io::Cursor<Vec<u8>> 类似,但允许读取和写入数据,并在 vector 中寻找不同的位置;
  • std::io::Cursor<&mut [u8]> 和上面的类似,但是不能增长内部的 buffer,因为它仅仅是已存在的字节数组的引用;

1.3 Readers读取数据的方法

以读取文件为例,std::io::Read 有几个读取数据的方法,它们都以 &mut self 作为参数。

read

read从输入源中提取一些字节到指定的缓冲区中,返回读取的字节数。函数签名:

1
fn read(&mut self, buf: &mut [u8]) -> Result<usize>

该函数不提供有关是否阻塞等待数据的任何保证,但是如果对象需要阻塞读取而不能阻塞,则通常会通过 Err 返回值来发出信号。

缓冲区参数的类型是 &mut [u8],这最多读取 buffer.len() 个字节。如果此方法的返回值为 Ok(n),则实现必须保证 0 <= n <= buf.len() 。非零值 n 表示缓冲区 buf 已用来自该源的 n 字节数据填充。如果 n0 ,那么它可以表示以下两种情况之一:

  • reader已到达其“文件末尾”并且可能不再能够生成字节。请注意,这并不意味着读取器将始终无法再生成字节。例如,在 Linux 上,此方法将为 TcpStream 调用 recv 系统调用,其中返回零表示连接已正确关闭。然而对于 File ,有可能到达文件末尾并得到零结果,但如果将更多数据附加到文件,以后对 read 的调用将返回更多数据。
  • 指定的缓冲区的长度为 0 个字节。

如果返回值 n 小于缓冲区大小,这不是错误,即使读取器尚未到达流的末尾也是如此。例如,这可能是因为现在实际可用的字节较少(例如接近文件末尾)或因为 read() 被信号中断。

使用者在调用 read 之前应该确保 buf 已初始化,用未初始化的 buf来调用 read 是不安全的,并且可能导致未定义的行为。

.read() 方法是一个低级方法,甚至继承了底层操作系统的一些特性。如果你正在为一种新型数据源实现 Read,这会给你很大的余地;如果你试图读取一些数据,那会很痛苦。因此,Rust 提供了几种更高级的便利方法。它们都具有 .read() 方面的默认实现,它们都处理 ErrorKind::Interrupted,所以你的实现里无需这样做。

read_to_end

函数签名:

1
fn read_to_end(&mut self, buf: &mut Vec<u8>) -> Result<usize>

读取输入源中直到 EOF 的所有字节,将它们放入 buf 中,它是Vec<u8>类型,从输入源读取的所有字节都将附加到指定的缓冲区 buf 。此函数将不断调用 read() 以将更多数据附加到 buf ,直到 read() 返回 Ok(0) 或非 ErrorKind::Interrupted类型的错误。如果成功,此函数将返回读取的总字节数。

如果此函数遇到ErrorKind::Interrupted类型的错误,则该错误将被忽略,操作将继续。如果遇到任何其他读取错误,则此函数会立即返回。任何已读取的字节都将附加到 buf

比如,从文件读取:

1
2
3
4
5
6
7
8
9
10
11
12
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let mut f = File::open("foo.txt")?;
let mut buffer = Vec::new();

// read the whole file
f.read_to_end(&mut buffer)?;
Ok(())
}

read_to_string

函数签名:

1
fn read_to_string(&mut self, buf: &mut String) -> Result<usize>

同上,但是追加数据到 String 中。

如果成功,此函数返回已读取并附加到 buf 的字节数。如果输入源中的数据不是有效的 UTF-8,则返回错误并且 buf 不变。

1
2
3
4
5
6
7
8
9
10
11
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let mut f = File::open("foo.txt")?;
let mut buffer = String::new();

f.read_to_string(&mut buffer)?;
Ok(())
}

read_exact

函数签名:

1
fn read_exact(&mut self, buf: &mut [u8]) -> Result<()>

读取填充 buf 所需的确切字节数,此函数读取尽可能多的字节以完全填充指定的缓冲区 buf

如果此函数遇到ErrorKind::Interrupted类型的错误,则该错误将被忽略,操作将继续。如果此函数在完全填充缓冲区之前遇到“文件结尾”,它会返回ErrorKind::UnexpectedEof 类型的错误。在这种情况下, buf 的内容未指定。如果遇到任何其他读取错误,则此函数会立即返回。在这种情况下, buf 的内容未指定。

如果此函数返回错误,则未指定它已读取了多少字节,但它绝不会读取超过完全填满缓冲区所需的字节数。

1
2
3
4
5
6
7
8
9
10
11
12
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let mut f = File::open("foo.txt")?;
let mut buffer = [0; 10];

// read exactly 10 bytes
f.read_exact(&mut buffer)?;
Ok(())
}

by_ref

函数签名:

1
fn by_ref(&mut self) -> &mut Self

为这个 Read 实例创建一个基于引用的适配器。返回的适配器也实现了 Read ,并且将简单地借用当前的阅读器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::io;
use std::io::Read;
use std::fs::File;

fn main() -> io::Result<()> {
let mut f = File::open("foo.txt")?;
let mut buffer = Vec::new();
let mut other_buffer = Vec::new();

{
let reference = f.by_ref();

// 最多读取 5 个字节
reference.take(5).read_to_end(&mut buffer)?;

} // 丢弃 &mut 引用,以便我们可以再次使用 f

// 原始文件仍然可用,请读取其余内容
f.read_to_end(&mut other_buffer)?;
Ok(())
}

bytes

函数签名:

1
fn bytes(self) -> Bytes<Self>

将此 Read 实例的字节数转换为Iterator,返回的类型实现了Iterator,其中 ItemResult<u8, io::Error>

1
2
3
4
impl<R: Read> Iterator for Bytes<R> {
type Item = Result<u8>;
// ...
}

如果一个字节被成功读取,则产生的项目为 Ok ,否则为Err 。 EOF 被映射到从此迭代器返回的 None

1
2
3
4
5
6
7
8
9
10
11
12
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let f = File::open("foo.txt")?;

for byte in f.bytes() {
println!("{}", byte.unwrap());
}
Ok(())
}

默认实现为每个字节调用 read ,这对于不在内存中的数据来说效率非常低,例如 File 。在这种情况下应该考虑使用BufReader (见:Buffered Readers and Writers

chain

函数签名:

1
chain<R: Read>(self, next: R) -> Chain<Self, R>

创建一个适配器,将多个 Reader 连接起来。Chain的实现如下:

1
impl<T: Read, U: Read> Read for Chain<T, U>

返回的 Read 实例将首先从该对象中读取所有字节,直到遇到 EOF。之后输出等同于 next 的输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let f1 = File::open("foo.txt")?;
let f2 = File::open("bar.txt")?;

let mut handle = f1.chain(f2);
let mut buffer = String::new();

// read the value into a String. We could use any Read method here,
// this is just one example.
handle.read_to_string(&mut buffer)?;
Ok(())
}

take

函数签名:

1
fn take(self, limit: u64) -> Take<Self>

创建一个最多从中读取 limit 字节的适配器。Take的实现如下:

1
impl<T: Read> Read for Take<T>

此函数返回一个新的 Read 实例,它将最多读取 limit 个字节,之后它将始终返回 EOF ( Ok(0) )。任何读取错误都不会计入读取的字节数,以后对 read() 的调用可能会成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let f = File::open("foo.txt")?;
let mut buffer = [0; 5];

// read at most five bytes
let mut handle = f.take(5);

handle.read(&mut buffer)?;
Ok(())
}

1.4 Writers写入数据的方法

std::io::Write写入数据的方法如下。

write

函数签名:

1
fn write(&mut self, buf: &[u8]) -> Result<usize>

buf 中的一些字节写入writer中。此函数将尝试写入 buf 的全部内容,但整个写入可能不会成功,或者写入也可能会产生错误。对 write 的调用最多代表一次写入任何包装对象的尝试。

不能保证对 write 的调用会阻塞等待数据的写入,否则阻塞的写入可以通过Err变体来指示。

每次调用 write 都可能产生一个 I/O 错误,指示操作无法完成。如果返回错误,则缓冲区中没有字节写入此写入器。如果无法将整个缓冲区写入此写入器,则不认为是错误(允许只写入一部分)。ErrorKind::Interrupted类型的错误是非致命错误,如果没有其他事情可做,则应重试写入操作。

如果返回值为 Ok(n) ,则保证 n <= buf.len()0 的返回值通常意味着底层对象不再能够接受字节并且将来可能也不能,或者提供的缓冲区是空的。

1
2
3
4
5
6
7
8
9
10
use std::io::prelude::*;
use std::fs::File;

fn main() -> std::io::Result<()> {
let mut buffer = File::create("foo.txt")?;

// 写入字节字符串的前缀子串,不一定是全部
buffer.write(b"some bytes")?;
Ok(())
}

Reader::read() 一样,这是一种低级方法,应避免直接使用。

flush

函数签名:

1
fn flush(&mut self) -> Result<()>

刷新此输出流,确保所有缓存的数据都到达目的地。如果由于 I/O 错误或到达 EOF 而不能写入所有字节,则认为是错误。

1
2
3
4
5
6
7
8
9
10
11
use std::io::prelude::*;
use std::io::BufWriter;
use std::fs::File;

fn main() -> std::io::Result<()> {
let mut buffer = BufWriter::new(File::create("foo.txt")?);

buffer.write_all(b"some bytes")?;
buffer.flush()?;
Ok(())
}

write_all

函数签名:

1
fn write_all(&mut self, buf: &[u8]) -> Result<()>

尝试将整个缓冲区 buf 所有字节写入。

此方法将不断调用 write ,直到没有更多数据要写入或返回非ErrorKind::Interrupted类型的错误。直到整个缓冲区已成功写入或发生此类错误后,此方法才会返回。将返回由此方法生成的第一个不是ErrorKind::Interrupted类型的错误。

如果缓冲区不包含数据,则永远不会调用 write

1
2
3
4
5
6
7
8
9
use std::io::prelude::*;
use std::fs::File;

fn main() -> std::io::Result<()> {
let mut buffer = File::create("foo.txt")?;

buffer.write_all(b"some bytes")?;
Ok(())
}

by_ref

函数签名:

1
fn by_ref(&mut self) -> &mut Self

Read类似,为此 Write 实例创建一个“通过引用”适配器,返回的适配器也实现了 Write ,并且将简单地借用当前的编写器。

1
2
3
4
5
6
7
8
9
10
11
12
use std::io::Write;
use std::fs::File;

fn main() -> std::io::Result<()> {
let mut buffer = File::create("foo.txt")?;

let reference = buffer.by_ref();

// 可以像使用原始缓冲区一样使用引用
reference.write_all(b"some bytes")?;
Ok(())
}

write_vectored

函数签名:

1
fn write_vectored(&mut self, bufs: &[IoSlice<'_>]) -> Result<usize>

类似于 write ,不同之处在于它尝试将多个缓冲区写入此writer

数据按顺序从每个缓冲区复制,最后一个缓冲区可能只被部分消耗。此方法必须表现为对 write 的调用,并连接缓冲区。默认实现使用提供的第一个非空缓冲区或空缓冲区(如果不存在)调用 write

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::io::IoSlice;
use std::io::prelude::*;
use std::fs::File;

fn main() -> std::io::Result<()> {
let mut data1 = [1; 8];
let mut data2 = [15; 8];
let io_slice1 = IoSlice::new(&mut data1);
let io_slice2 = IoSlice::new(&mut data2);

let mut buffer = File::create("foo.txt")?;

// 写入字节字符串的某些前缀,不一定要全部。
buffer.write_vectored(&[io_slice1, io_slice2])?;
Ok(())
}

is_write_vectored

函数签名:

1
fn is_write_vectored(&self) -> bool

确定此 Writer 是否具有有效的 write_vectored 实现。

如果 Writer 没有覆盖默认的 write_vectored 实现,使用它的代码可能希望避免使用该方法,并合并写入单个缓冲区以获得更高的性能。

默认实现返回 false

1.5 Buffered Readers and Writers

为了提高效率,可以缓冲读取器和写入器。就像C语言中fread/fwriteread/write的区别一样——库函数提供了用户空间缓冲区,当用户空间缓冲区满或者操作结束时才进行读取/写入操作,通常可以减少系统调用的次数。

BufReader

1
2
3
4
pub struct BufReader<R> {
inner: R,
buf: Buffer,
}

BufReader<R> 结构向任何Reader添加缓冲。前面提到过,直接使用 Read 实例可能效率极低。例如, TcpStream 上对 read 的每次调用都会导致系统调用。 而BufReader<R> 对底层的 Read 执行大量、不频繁的读取,并维护结果的内存缓冲区。

BufReader<R> 可以提高对同一文件或网络套接字进行小而重复的读取调用的程序的速度。当一次阅读大量内容或只阅读一次或几次时,它无济于事。当从一个已经在内存中的源读取时,比如 Vec<u8>,它也没有任何优势 。

BufReader<R> 被删除时,其缓冲区的内容将被丢弃。在同一流上创建 BufReader<R> 的多个实例可能会导致数据丢失。用 BufReader::into_inner 展开 BufReader<R> 后从底层读取器读取数据也会导致数据丢失。

BufReader实现了BufRead

1
impl<R: Read> BufRead for BufReader<R>

BufWriter

1
2
3
4
5
6
7
8
9
10
11
12
pub struct BufWriter<W: Write> {
inner: W,
// The buffer. Avoid using this like a normal `Vec` in common code paths.
// That is, don't use `buf.push`, `buf.extend_from_slice`, or any other
// methods that require bounds checking or the like. This makes an enormous
// difference to performance (we may want to stop using a `Vec` entirely).
buf: Vec<u8>,
// #30888: If the inner writer panics in a call to write, we don't want to
// write the buffered data a second time in BufWriter's destructor. This
// flag tells the Drop impl if it should skip the flush.
panicked: bool,
}

BufWriter<W>包装一个 writer并缓冲它的输出。

直接使用实现了 Write 的东西可能会非常低效。例如, TcpStream 上对 write 的每次调用都会导致系统调用。 BufWriter<W> 保留一个内存中的数据缓冲区,并将其以大量、不频繁的批次写入底层写入器。

BufWriter<W> 可以提高对同一文件或网络套接字进行小而重复的写入调用的程序的速度。当一次写入大量数据或只写入一次或几次时,它无济于事。当写入内存中的目标时,比如 Vec<u8>,它也没有任何优势。

在删除 BufWriter<W> 之前调用 flush 至关重要。尽管丢弃会尝试刷新缓冲区的内容,但丢弃过程中发生的任何错误都将被忽略。调用 flush 确保缓冲区为空,因此丢弃操作甚至不会尝试文件操作。

1.6 BufRead新增的读取数据的方法

fill_buf

函数签名:

1
fn fill_buf(&mut self) -> Result<&[u8]>

返回内部缓冲区的内容,如果内部缓冲区为空,则使用内部reader中的更多数据填充内部缓冲区。

这个函数是一个较低级别的调用。它需要与 consume 方法配对才能正常运行。调用此方法时,不会“读取”任何内容,因为稍后调用 read 可能会返回相同的内容。因此,必须使用从此缓冲区消耗的字节数调用 consume ,以确保字节永远不会返回两次。

返回的空缓冲区表示流已达到 EOF。如果读取底层读取器但返回错误,则此函数将返回 I/O 错误。

锁定的标准输入实现 BufRead

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::io;
use std::io::prelude::*;

let stdin = io::stdin();
let mut stdin = stdin.lock();

let buffer = stdin.fill_buf().unwrap();

// 使用缓冲区
println!("{:?}", buffer);

// 确保我们处理过的字节以后不再返回
let length = buffer.len();
stdin.consume(length);

consume

函数签名:

1
fn consume(&mut self, amt: usize)

告诉此缓冲区 amt 字节已从缓冲区中消耗掉,因此不应再在对 read 的调用中返回它们。

这个函数是一个较低级别的调用。它需要与 fill_buf 方法配对才能正常运行。此函数不执行任何 I/O,它只是通知此对象从 fill_buf 返回的一些缓冲区已被消耗,不应再返回。因此,如果 fill_buf 在调用它之前没有被调用,这个函数可能会做一些奇怪的事情。

amt 必须是 <= fill_buf 返回的缓冲区中的字节数。由于 consume() 旨在与 fill_buf 一起使用,因此该方法的示例见上一小节。

read_line

函数签名:

1
fn read_line(&mut self, buf: &mut String) -> Result<usize>

读取所有字节直到到达换行符( \n或者\r\n),包含换行符,并将它们附加到提供的 String 缓冲区,简单来说就是读取一行文本。

缓冲区的先前内容将被保留。为避免附加到缓冲区,您需要先将其 clear

如果成功,此函数将返回读取的总字节数,如果此函数返回 Ok(0) ,则流已到达 EOF。

这个函数是阻塞的,应该小心使用:攻击者有可能连续发送字节而不发送换行符或 EOF。你可以使用 take 来限制读取的最大字节数。

如果读取的字节不是有效的 UTF-8,也会返回错误。如果遇到 I/O 错误,那么 buf 可能包含一些已经读取的字节,前提是到目前为止读取的所有数据都是有效的 UTF-8。

std::io::Cursor是实现 BufRead 的类型。在这个例子中,我们使用 Cursor 来读取字节切片中的所有行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::io::{self, BufRead};

let mut cursor = io::Cursor::new(b"foo\nbar");
let mut buf = String::new();

// 游标在 'f'
let num_bytes = cursor.read_line(&mut buf)
.expect("reading from cursor won't fail");
assert_eq!(num_bytes, 4);
assert_eq!(buf, "foo\n");
buf.clear();

// 游标在 'b'
let num_bytes = cursor.read_line(&mut buf)
.expect("reading from cursor won't fail");
assert_eq!(num_bytes, 3);
assert_eq!(buf, "bar");
buf.clear();

// 游标在 EOF 处
let num_bytes = cursor.read_line(&mut buf)
.expect("reading from cursor won't fail");
assert_eq!(num_bytes, 0);
assert_eq!(buf, "");

lines

函数签名:

1
fn lines(self) -> Lines<Self>

LinesBufRead 实例行上的迭代器,它实现了Iterator,如下:

1
2
3
4
5
6
7
8
pub struct Lines<B> {
buf: B,
}

impl<B: BufRead> Iterator for Lines<B> {
type Item = Result<String>;
// ...
}

从此函数返回的这个迭代器将生成 io::Result<String> 实例,返回的每个字符串末尾都没有换行符字节( \n或者\r\n)。

std::io::Cursor是实现 BufRead 的类型。在此示例中,我们使用 Cursor 遍历字节切片中的所有行。

1
2
3
4
5
6
7
8
9
use std::io::{self, BufRead};

let cursor = io::Cursor::new(b"lorem\nipsum\r\ndolor");

let mut lines_iter = cursor.lines().map(|l| l.unwrap());
assert_eq!(lines_iter.next(), Some(String::from("lorem")));
assert_eq!(lines_iter.next(), Some(String::from("ipsum")));
assert_eq!(lines_iter.next(), Some(String::from("dolor")));
assert_eq!(lines_iter.next(), None);

read_until

函数签名:

1
fn read_until(&mut self, byte: u8, buf: &mut Vec<u8>) -> Result<usize>

将所有字节读入 buf ,直到到达指定的分隔符字节byte或EOF。

此函数将从底层流中读取字节,直到找到分隔符或 EOF。一旦找到,直到并包括定界符(如果找到)的所有字节都将附加到 buf

如果成功,此函数将返回读取的总字节数。如果遇到 I/O 错误,那么到目前为止读取的所有字节都将出现在 buf 中,并且其长度将被适当调整。此函数将忽略ErrorKind::Interrupted 的所有实例,否则将返回 fill_buf 返回的任何错误。

此函数是阻塞的,应谨慎使用:攻击者可能会连续发送字节而无需发送定界符或 EOF。

在此示例中,我们使用 Cursor 读取连字符分隔段中字节片中的所有字节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::io::{self, BufRead};

let mut cursor = io::Cursor::new(b"lorem-ipsum");
let mut buf = vec![];

// 游标在 'l'
let num_bytes = cursor.read_until(b'-', &mut buf)
.expect("reading from cursor won't fail");
assert_eq!(num_bytes, 6);
assert_eq!(buf, b"lorem-");
buf.clear();

// 游标在 'i'
let num_bytes = cursor.read_until(b'-', &mut buf)
.expect("reading from cursor won't fail");
assert_eq!(num_bytes, 5);
assert_eq!(buf, b"ipsum");
buf.clear();

// 游标在 EOF 处
let num_bytes = cursor.read_until(b'-', &mut buf)
.expect("reading from cursor won't fail");
assert_eq!(num_bytes, 0);
assert_eq!(buf, b"");

split

函数签名:

1
fn split(self, byte: u8) -> Split<Self>

返回此读取器内容的迭代器,拆分为字节 byte

SplitBufRead 实例内容的迭代器,它实现了Iterator,如下:

1
2
3
4
5
6
7
8
pub struct Split<B> {
buf: B,
delim: u8,
}
impl<B: BufRead> Iterator for Split<B> {
type Item = Result<Vec<u8>>;
// ...
}

此函数返回的迭代器将返回 io::Result<Vec<u8>> 的实例。返回的每个vector末尾都没有分隔符字节。

只要 read_until 会产生错误,此函数就会产生错误。

作为示例,我们使用 Cursor 遍历字节切片中所有以连字符分隔的段:

1
2
3
4
5
6
7
8
9
use std::io::{self, BufRead};

let cursor = io::Cursor::new(b"lorem-ipsum-dolor");

let mut split_iter = cursor.split(b'-').map(|l| l.unwrap());
assert_eq!(split_iter.next(), Some(b"lorem".to_vec()));
assert_eq!(split_iter.next(), Some(b"ipsum".to_vec()));
assert_eq!(split_iter.next(), Some(b"dolor".to_vec()));
assert_eq!(split_iter.next(), None);

1.7 使用Bufwriter减少系统调用

让我们将数字 1 到 10 写入 TcpStream

1
2
3
4
5
6
7
8
use std::io::prelude::*;
use std::net::TcpStream;

let mut stream = TcpStream::connect("127.0.0.1:34254").unwrap();

for i in 0..10 {
stream.write(&[i+1]).unwrap();
}

因为我们没有缓冲,所以我们依次写入每一个,从而导致每写入一个字节就进行一次系统调用的开销。我们可以用 BufWriter<W> 解决这个问题:

1
2
3
4
5
6
7
8
9
10
use std::io::prelude::*;
use std::io::BufWriter;
use std::net::TcpStream;

let mut stream = BufWriter::new(TcpStream::connect("127.0.0.1:34254").unwrap());

for i in 0..10 {
stream.write(&[i+1]).unwrap();
}
stream.flush().unwrap();

通过用 BufWriter<W> 包装流,这十个写入都由缓冲区组合在一起,并且在刷新 stream 时将全部在一个系统调用中写出。

另外,如果要设置 buffer 大小,可以使用 BufWriter::with_capacity代替new,创建一个至少具有指定缓冲区容量的新 BufWriter<W>

1
2
// 创建一个缓冲区有15字节的缓冲区
let mut stream = BufWriter::with_capacity(15, TcpStream::connect("127.0.0.1:34254").unwrap());

1.8 Seek移动游标

Seek 特征提供了一个可以在字节流中移动的游标,流通常具有固定大小,允许相对于任一端或当前偏移量进行查找。

比如,File 实现了Seek,使用其中的seek方法以移动游标:

1
2
3
4
5
6
7
8
9
10
11
12
use std::io;
use std::io::prelude::*;
use std::fs::File;
use std::io::SeekFrom;

fn main() -> io::Result<()> {
let mut f = File::open("foo.txt")?;

// 从文件的开头将游标移动 42 个字节
f.seek(SeekFrom::Start(42))?;
Ok(())
}

seek的函数签名:

1
fn seek(&mut self, pos: SeekFrom) -> Result<u64>;

可以在流中寻找以字节为单位的偏移量。

SeekFrom是一个枚举:

1
2
3
4
5
pub enum SeekFrom {
Start(u64),
End(i64),
Current(i64),
}

其中:

  • Start将偏移量设置为提供的字节数
  • End将偏移量设置为此对象的大小加上提供的字节数
  • Current将偏移量设置为当前位置加上提供的字节数

除了seek之外,还有一些方法:

  • rewind用于回到流的开头,这是一个方便的方法,相当于 seek(SeekFrom::Start(0))

    1
    fn rewind(&mut self) -> Result<()>
  • stream_position从流的开头返回当前搜索位置,这相当于 self.seek(SeekFrom::Current(0))

    1
    fn stream_position(&mut self) -> Result<u64>

    比如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    use std::{
    io::{self, BufRead, BufReader, Seek},
    fs::File,
    };

    fn main() -> io::Result<()> {
    let mut f = BufReader::new(File::open("foo.txt")?);

    let before = f.stream_position()?;
    f.read_line(&mut String::new())?;
    let after = f.stream_position()?;

    println!("The first line was {} bytes long", after - before);
    Ok(())
    }

1.9 常用的读写类型

标准流

分为标准输入Stdin,标准输出Stdout,标准错误Stderr。标准流是多个线程共享的,因此使用需要使用互斥锁。并且提供了相应的函数来构造:

(1)标准输入

标准输入通过std::io::stdin构造:

1
pub fn stdin() -> Stdin {}

返回的每个句柄都是对共享全局缓冲区的引用,其访问通过互斥锁同步。

使用隐式同步:

1
2
3
4
5
6
7
use std::io;

fn main() -> io::Result<()> {
let mut buffer = String::new();
io::stdin().read_line(&mut buffer)?;
Ok(())
}

使用显式同步:

1
2
3
4
5
6
7
8
9
10
use std::io::{self, BufRead};

fn main() -> io::Result<()> {
let mut buffer = String::new();
let stdin = io::stdin();
let mut handle = stdin.lock();

handle.read_line(&mut buffer)?;
Ok(())
}

lock方法将此句柄锁定到标准输入流,返回一个可读保护。当返回的锁超出范围时释放锁。

另外,read_line方法锁定句柄并读取一行输入,将其附加到指定的缓冲区,它的函数签名:

1
pub fn read_line(&self, buf: &mut String) -> Result<usize>

示例:

1
2
3
4
5
6
7
8
9
10
use std::io;

let mut input = String::new();
match io::stdin().read_line(&mut input) {
Ok(n) => {
println!("{n} bytes read");
println!("{input}");
}
Err(error) => println!("error: {error}"),
}
(2)标准输出

同标准输入,可以通过std::io::stdout构造:

1
pub fn stdout() -> Stdout {}

使用隐式同步:

1
2
3
4
5
6
7
use std::io::{self, Write};

fn main() -> io::Result<()> {
io::stdout().write_all(b"hello world")?;

Ok(())
}

使用显式同步:

1
2
3
4
5
6
7
8
9
10
use std::io::{self, Write};

fn main() -> io::Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();

handle.write_all(b"hello world")?;

Ok(())
}
(3)标准错误

可以通过std::io::stderr构造:

1
pub fn stderr() -> Stderr {}

使用隐式同步:

1
2
3
4
5
6
7
use std::io::{self, Write};

fn main() -> io::Result<()> {
io::stderr().write_all(b"hello world")?;

Ok(())
}

使用显式同步:

1
2
3
4
5
6
7
8
9
10
use std::io::{self, Write};

fn main() -> io::Result<()> {
let stderr = io::stderr();
let mut handle = stderr.lock();

handle.write_all(b"hello world")?;

Ok(())
}

游标Cursor

Cursor 包装内存缓冲区并为其提供 Seek 实现。

1
2
3
4
5
6
7
8
9
10
pub struct Cursor<T> {
inner: T,
pos: u64,
}

impl<T> Cursor<T> {
pub const fn new(inner: T) -> Cursor<T> {
Cursor { pos: 0, inner }
}
}

Cursor 们与内存缓冲区一起使用,new方法的参数inner可以是任何实现 AsRef<[u8]> 的东西,因此也可以传递 &[u8]&strVec<u8>

Cursor 的结构很简单,只有两个字段:inner 本身和一个整数,即 inner 中下一次读取将开始的偏移量,该位置最初为 0

Cursor 实现 ReadBufReadSeek,如果 inner 的类型是 &mut [u8]Vec<u8>,那么 Cursor 也会实现 Write

写入 Curosr 会覆盖 inner 中从当前位置开始的字节。如果试图写超出 &mut [u8] 的末尾,会得到一个部分写或一个 io::Error。不过,使用 Curosr 写入 Vec<u8> 的末尾是可以的,它会增大 vector。因此,Cursor<&mut [u8]>Cursor<Vec<u8>> 实现了所有 4std::io::prelude 中的特征。

TcpStream

std::net::TcpStream是本地和远程套接字之间的 TCP 流,可读可写,TcpStream::connect(("hostname", PORT)) 尝试去连接到一个 server 并且返回 io::Result<TcpStream>

1
2
3
4
5
6
7
8
9
10
use std::io::prelude::*;
use std::net::TcpStream;

fn main() -> std::io::Result<()> {
let mut stream = TcpStream::connect("127.0.0.1:34254")?;

stream.write(&[1])?;
stream.read(&mut [0; 128])?;
Ok(())
} // the stream is closed here

Command

std::process::Command:支持生成子进程并将数据传输到其标准输入,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::error::Error;
use std::io::Write;
use std::process::{Command, Stdio};

fn main() -> Result<(), Box<dyn Error + Send + Sync + 'static>> {
let mut child = Command::new("grep")
.arg("-e")
.arg("a.*e.*i.*o.*u")
.stdin(Stdio::piped())
.spawn()?;

let my_words = vec!["hello", "world"];
let mut to_child = child.stdin.take().unwrap();
for word in my_words {
writeln!(to_child, "{}", word)?;
}
drop(to_child); // close grep's stdin, so it will exit
child.wait()?;

Ok(())
}

其它读取器和写入器

std::io 模块还提供了一些返回实验性的读取器和写入器的函数:

  • io::sink:没有实际操作,所有写操作返回 Ok,但是数据被丢弃了:

    1
    2
    3
    4
    5
    use std::io::{self, Write};

    let buffer = vec![1, 2, 3, 5, 8];
    let num_bytes = io::sink().write(&buffer).unwrap();
    assert_eq!(num_bytes, 5);
  • io::empty:总是读取成功,但返回属于结束。不将任何内容读入缓冲区的一个有点悲伤的例子:

    1
    2
    3
    4
    5
    use std::io::{self, Read};

    let mut buffer = String::new();
    io::empty().read_to_string(&mut buffer).unwrap();
    assert!(buffer.is_empty());
  • io::repeat:返回 Reader 无止境地重复给定字节

    1
    2
    3
    4
    5
    use std::io::{self, Read};

    let mut buffer = [0; 3];
    io::repeat(0b101).read_exact(&mut buffer).unwrap();
    assert_eq!(buffer, [0b101, 0b101, 0b101]);

2 path

std::path,用于跨平台路径操作。

该模块提供两种类型, PathBufPath (类似于 Stringstr ),用于抽象地处理路径。这些类型分别是 OsStringOsStr 的薄包装,这意味着它们根据本地平台的路径语法直接处理字符串。

这些功能都涉及使用文件名,因此我们将从文件名类型开始。

2.1 OsStringOsStr

std::ffi::OsString是一种可以表示拥有的、可变的平台原生字符串的类型,但可以简单地与 Rust 字符串相互转换。

由于操作系统不会强制文件名是有效的 Unicode,下面是两个创建文本文件的 shell 命令,只有第一个使用有效的 UTF-8 文件名:

1
2
$ echo "hello world" > ô.txt
$ echo "O brave new world, that has such filenames in't" > $'\xf4'.txt

对于内核,任何字节串(不包括空字节和斜杠)都是可接受的文件名。在 Windows 上也有类似的情况,几乎任何 16 位 “宽字符” 字符串都是可接受的文件名,即使是无效的 UTF-16 字符串也是如此。操作系统处理的其他字符串也是如此,例如命令行参数和环境变量。

在 Unix 系统上,字符串通常是非零字节的任意序列,在许多情况下被解释为 UTF-8,任何字节串(不包括空字节和斜杠)都是可接受的文件名。在 Windows 上也有类似的情况,非零 16 位值的任意序列都是可接受的文件名,在有效时被解释为 UTF-16。在 Rust 中,字符串始终是有效的 UTF-8,其中可能包含零。

OsStringOsStr 通过同时表示 Rust 和平台原生字符串值来弥合这一差距,特别是如果可能的话,允许将 Rust 字符串免费转换为“操作系统”字符串。

std::ffi::&OsStrOsString 就像 &strString 一样:前者是引用,后者是具有所有权的字符串。

OsString常用方法

由于OsString 实现 From<String> ,可以使用 my_string.into() 从普通 Rust 字符串创建 OsString

1
2
3
4
5
use std::ffi::OsString;

fn main() {
let o_str: OsString = String::from("hello world!").into();
}

也可以使用 OsString::new 方法创建一个空的 OsString ,然后使用 OsString::push 方法将字符串切片压入填充它。

1
2
3
4
5
6
7
use std::ffi::OsString;

fn main() {
let mut os_string = OsString::new();
os_string.push("hello world");
println!("{:?}", os_string);
}

OsString转换为String

1
2
3
4
5
6
7
use std::ffi::OsString;

fn main() {
let os_string = OsString::from("foo");
let string = os_string.into_string();
assert_eq!(string, Ok(String::from("foo")));
}

with_capacity方法创建一个至少具有给定容量的新 OsString 。如果一开始就知道需要创建多大的字符串,就不需要创建空串然后动态添加,前者的好处是不需要重新分配堆空间:

1
2
3
4
5
6
7
8
9
10
11
use std::ffi::OsString;

fn main() {
let mut os_string = OsString::with_capacity(10);
let capacity = os_string.capacity();

// 这个push操作不需要重新分配空间
os_string.push("foo");

assert_eq!(capacity, os_string.capacity());
}

as_os_str方法将OsString转换为 OsStr 切片:

1
2
3
4
5
6
7
use std::ffi::{OsStr, OsString};

fn main() {
let os_string = OsString::from("foo");
let os_str = OsStr::new("foo");
assert_eq!(os_string.as_os_str(), os_str);
}

OsStr常用方法

&str强制转换为 OsStr 切片:

1
2
3
4
5
use std::ffi::OsStr;

fn main() {
let os_str = OsStr::new("foo");
}

to_str用于从OsStr 切片转换为&str,这种转换可能需要检查 UTF-8 的有效性:

1
2
3
4
5
6
use std::ffi::OsStr;

fn main() {
let os_str = OsStr::new("foo");
assert_eq!(os_str.to_str(), Some("foo"));
}

将切片复制到拥有的 OsString 中:

1
2
3
4
5
6
7
use std::ffi::{OsStr, OsString};

fn main() {
let os_str = OsStr::new("foo");
let os_string = os_str.to_os_string();
assert_eq!(os_string, OsString::from("foo"));
}

2.2 std::path::Path

std::path::Path是路径的一部分(类似于 str )。此类型支持许多用于检查路径的操作,包括将路径分解为其组件(在 Unix 上以 / 分隔,在 Windows 上以 /\ 分隔),提取文件名,确定路径是否是绝对的路径,等等。

这是一个未定大小的类型,这意味着它必须始终在 &Box 之类的指针后面使用。

路径操作包括从切片中解析组件和构建新的路径,要解析路径,可以从 str 切片创建 Path 切片,一个简单的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::ffi::OsStr;
use std::path::Path;

fn main() {
let path = Path::new("/tmp/foo/bar.txt");

let parent = path.parent();
assert_eq!(parent, Some(Path::new("/tmp/foo")));

let file_stem = path.file_stem();
assert_eq!(file_stem, Some(OsStr::new("bar")));

let extension = path.extension();
assert_eq!(extension, Some(OsStr::new("txt")));
}

Path 提供以下常用方法。

new

直接将字符串切片包装为 Path 切片。

1
2
3
4
5
use std::path::Path;

fn main() {
Path::new("foo.txt");
}

可以从 String 甚至其它 Path 创建 Path

1
2
3
4
5
6
7
8
use std::path::Path;

fn main() {
let string = String::from("foo.txt");
let from_string = Path::new(&string);
let from_path = Path::new(&from_string);
assert_eq!(from_string, from_path);
}

parent

函数签名:

1
pub fn parent(&self) -> Option<&Path>

如果有的话,parent返回不包含其最终组成部分的 Path

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::io;
use std::path::Path;

fn main() {
let path = Path::new("/foo/bar");
let parent = path.parent().unwrap();
assert_eq!(parent, Path::new("/foo"));

let grand_parent = parent.parent().unwrap();
assert_eq!(grand_parent, Path::new("/"));
assert_eq!(grand_parent.parent(), None);

let relative_path = Path::new("foo/bar");
let parent = relative_path.parent();
assert_eq!(parent, Some(Path::new("foo")));
let grand_parent = parent.and_then(Path::parent);
assert_eq!(grand_parent, Some(Path::new("")));
let great_grand_parent = grand_parent.and_then(Path::parent);
assert_eq!(great_grand_parent, None);
}

这意味着它为具有一个组成部分的相对路径返回 Some("") 。如果路径在根或前缀中终止,或者它是空字符串,则返回 None

file_name

函数签名:

1
pub fn file_name(&self) -> Option<&OsStr>

返回 Path 的最后一个组成部分(如果有)。如果路径是普通文件,则这是文件名。如果它是目录的路径,则这是目录名称。如果路径在 .. 中终止,则返回 None

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::ffi::OsStr;
use std::path::Path;

fn main() {
assert_eq!(Some(OsStr::new("bin")), Path::new("/usr/bin/").file_name());
assert_eq!(
Some(OsStr::new("foo.txt")),
Path::new("tmp/foo.txt").file_name()
);
assert_eq!(
Some(OsStr::new("foo.txt")),
Path::new("foo.txt/.").file_name()
);
assert_eq!(
Some(OsStr::new("foo.txt")),
Path::new("foo.txt/.//").file_name()
);
assert_eq!(None, Path::new("foo.txt/..").file_name());
assert_eq!(None, Path::new("/").file_name());
}

is_absolute、is_relative、is_dir、is_file

这四个方法分别用来判断路径是否为:绝对路径、相对路径、目录、文件。

1
2
3
4
5
6
7
8
9
10
11
12
use std::path::Path;

fn main() {
assert_eq!(Path::new("./is_a_directory/").is_dir(), true);
assert_eq!(Path::new("a_file.txt").is_dir(), false);

assert_eq!(Path::new("./is_a_directory/").is_file(), false);
assert_eq!(Path::new("a_file.txt").is_file(), true);

assert!(!Path::new("foo.txt").is_absolute());
assert!(Path::new("foo.txt").is_relative());
}

join

函数签名:

1
pub fn join<P: AsRef<Path>>(&self, path: P) -> PathBuf

创建一个有所有权的 PathBuf ,其中 pathself 相连。如果 path 是绝对路径,仅仅返回 path 的副本,所以这个方法能被用于转换任何路径为绝对路径。

1
2
3
4
5
6
7
use std::path::{Path, PathBuf};

fn main() {
assert_eq!(Path::new("/etc").join("passwd"), PathBuf::from("/etc/passwd"));
let abs_path = PathBuf::from("/root/").join(PathBuf::from("/etc/passwd"));
println!("{:?}", abs_path);
}

components

在路径的 Component s上生成迭代器,包含给定路径从左至右的所有部分。内容类型是 std::path::Component,它是一个枚举,能代表一个文件路径中所有不同的片段:

1
2
3
4
5
6
7
pub enum Component<'a> {
Prefix(PrefixComponent<'a>),
RootDir,
CurDir,
ParentDir,
Normal(&'a OsStr),
}

解析路径时,存在少量路径规范化:

  • 重复的分隔符将被忽略,因此 a/ba//b 都将 ab 作为组件。
  • . 的出现被规范化,除非它们位于路径的开头。例如, a/./ba/b/a/b/.a/b 都有 ab 作为组件,但 ./a/b 以额外的 CurDir 组件开头。
  • 尾部斜杠被规范化, /a/b/a/b/ 是等效的。

请注意,除此之外不会发生其他规范化。特别是, a/ca/b/../c 是不同的,以解释 b 是符号链接的可能性(因此其父链接不是 a )。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::ffi::OsStr;
use std::path::{Component, Path};

fn main() {
let mut components = Path::new("/tmp/foo.txt").components();

assert_eq!(components.next(), Some(Component::RootDir));
assert_eq!(
components.next(),
Some(Component::Normal(OsStr::new("tmp")))
);
assert_eq!(
components.next(),
Some(Component::Normal(OsStr::new("foo.txt")))
);
assert_eq!(components.next(), None)
}

ancestors

生成基于 Path 及其祖先的迭代器。迭代器将生成在 parent 方法使用零次或多次时返回的 Path 。这意味着,迭代器将产生 &self&self.parent().unwrap()&self.parent().unwrap().parent().unwrap() 等。如果 parent 方法返回 None ,迭代器也会这样做。迭代器将始终产生至少一个值,即 &self

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::path::Path;

fn main() {
let mut ancestors = Path::new("/foo/bar").ancestors();
assert_eq!(ancestors.next(), Some(Path::new("/foo/bar")));
assert_eq!(ancestors.next(), Some(Path::new("/foo")));
assert_eq!(ancestors.next(), Some(Path::new("/")));
assert_eq!(ancestors.next(), None);

let mut ancestors = Path::new("../foo/bar").ancestors();
assert_eq!(ancestors.next(), Some(Path::new("../foo/bar")));
assert_eq!(ancestors.next(), Some(Path::new("../foo")));
assert_eq!(ancestors.next(), Some(Path::new("..")));
assert_eq!(ancestors.next(), Some(Path::new("")));
assert_eq!(ancestors.next(), None);
}

更多方法

还有一些更多的方法,比较简单,比如existscanonicalize等等,具体可以参考标准库文档。将 Path 转换为字符串有三种方法,每一个都允许 Path 中出现无效 UTF-8 的可能性:

  • path.to_str():返回 Option<&str>,如果包含无效的 UTF-8,返回 None
  • path.to_string_lossy():这基本上是同一件事,但它设法在所有情况下返回某种字符串。如果路径不是有效的 UTF-8,这些方法会创建一个副本,用 Unicode 替换字符 U+FFFD ('�') 替换每个无效的字节序列;
  • path.display():用于路径打印,它返回的值不是字符串,但它实现了 std::fmt::Display,因此它可以与 format!()println!() 等一起使用。 如果路径不是有效的 UTF-8,则输出可能包含 字符。

2.3 std::path::PathBuf

std::path::PathBuf拥有所有权的可变路径(类似于 String )。此类型提供了 pushset_extension 等方法,这些方法会就地改变路径。它还实现了 DerefPath ,这意味着 Path 切片上的所有方法也可用于 PathBuf 值。

基础的使用方法就是生成路径,总的来说有三种方法。

第一种可以使用 push 从组件构建 PathBuf

1
2
3
4
5
6
7
8
9
10
11
use std::path::PathBuf;

fn main() {
let mut path = PathBuf::new();

path.push(r"C:\");
path.push("windows");
path.push("system32");

path.set_extension("dll");
}

但是, push 最适合用于动态情况。当提前了解所有组件时,这是第二种执行此操作的更好方法:

1
2
3
4
5
use std::path::PathBuf;

fn main() {
let path: PathBuf = [r"C:\", "windows", "system32.dll"].iter().collect();
}

第三种方法,由于这些都是字符串,我们可以使用 From::from

1
2
3
4
5
use std::path::PathBuf;

fn main() {
let path = PathBuf::from(r"C:\windows\system32.dll");
}

具体使用哪种方法,取决于所处的实现情景中。

3 fs

std::fs用于文件系统操作操作。此模块包含操作本地文件系统内容的基本方法,其中的所有方法都表示跨平台文件系统操作。额外的特定于平台的功能可以在 std::os::$platform 的扩展特征中找到。

下面介绍一些常用的函数操作。

3.1 创建和删除

create_dir

在提供的路径上创建一个新的空目录。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::create_dir("/some/dir")?;
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • 用户缺少在 path 创建目录的权限。
  • 给定路径的父路径不存在。(若要同时创建目录及其所有缺少的父目录,请使用 create_dir_all 函数。
  • path 已存在。

create_dir_all

递归创建目录及其所有父组件(如果缺少)。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::create_dir_all("/some/dir")?;
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • 如果 path 指定的路径中的任何目录尚不存在,否则无法创建该目录。 fs::create_dir 概述了创建目录时(确定目录不存在后)的特定错误条件。

对于无法创建 path 中指定的任何目录的情况,这是一个值得注意的例外,因为它是同时创建的。此类案例被认为是成功的。也就是说,从多个线程或进程并发调用 create_dir_all 可以保证不会因自身的争用条件而失败。

remove_dir

删除空目录。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::remove_dir("/some/dir")?;
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • path 不存在。
  • path 不是目录。
  • 用户缺少删除提供的 path 处的目录的权限。
  • 目录不为空。

remove_dir_all

删除目录的所有内容后,删除此路径上的目录。这个方法一定要小心使用。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::remove_dir_all("/some/dir")?;
Ok(())
}

remove_file

从文件系统中删除文件。不过不能保证立即删除文件(例如,根据平台的不同,其他打开的文件描述符可能会阻止立即删除)。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::remove_file("a.txt")?;
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • path 指向一个目录。
  • 该文件不存在。
  • 用户缺少删除文件的权限。

3.2 移动、拷贝和链接

copy

函数签名:

1
pub fn copy<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<u64>

将一个文件的内容from复制到另一个文件to。此函数还会将原始文件的权限位复制到目标文件。

此函数将覆盖 to 的内容。请注意,如果 fromto 都指向同一个文件,则此操作可能会截断该文件。

复制成功后,将返回复制的总字节数,该字节数等于 metadata 报告的 to 文件的长度。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::copy("foo.txt", "bar.txt")?; // Copy foo.txt to bar.txt
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • from 既不是常规文件,也不是指向常规文件的符号链接。
  • from 不存在。
  • 当前进程没有读取 from 或写入 to 的权限。

rename

函数签名:

1
pub fn rename<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<()>

将文件或目录重命名为新名称,如果 to 已存在,则替换原始文件。如果新名称位于不同的挂载点上,这将不起作用。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::rename("a.txt", "b.txt")?; // Rename a.txt to b.txt
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • from 不存在。
  • 用户缺少查看内容的权限。
  • fromto 位于不同的文件系统上。

函数签名:

1
2
3
4
pub fn hard_link<P: AsRef<Path>, Q: AsRef<Path>>(
original: P,
link: Q
) -> Result<()>

在文件系统上创建新的硬链接。link 路径将是指向 original 路径的链接。请注意,系统通常要求这两个路径都位于同一个文件系统上。

如果 original 命名符号链接,则是否遵循符号链接取决于平台。在可以不遵循它的平台上,它不会被遵循,并且创建的硬链接指向符号链接本身。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::hard_link("a.txt", "b.txt")?; // Hard link a.txt to b.txt
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • original 路径不是文件或不存在。

3.3 查看信息

canonicalize

函数签名:

1
pub fn canonicalize<P: AsRef<Path>>(path: P) -> Result<PathBuf>

返回路径的规范、绝对路径的形式,其中所有中间组件都规范化并解析了符号链接。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
let path = fs::canonicalize("../a/../foo.txt")?;
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • path 不存在。
  • 路径中的非最终组成部分不是目录。

metadata

函数签名:

1
pub fn metadata<P: AsRef<Path>>(path: P) -> Result<Metadata>

给定一个路径,查询文件系统以获取有关文件、目录等元数据的信息。此函数将遍历符号链接以查询有关目标文件的信息。

1
2
3
4
5
6
7
use std::fs;

fn main() -> std::io::Result<()> {
let attr = fs::metadata("/some/file/path.txt")?;
// inspect attr ...
Ok(())
}

查询有关文件的元数据,而不遵循符号链接。

1
2
3
4
5
6
7
use std::fs;

fn main() -> std::io::Result<()> {
let attr = fs::symlink_metadata("/some/file/path.txt")?;
// inspect attr ...
Ok(())
}

read_dir

函数签名:

1
pub fn read_dir<P: AsRef<Path>>(path: P) -> Result<ReadDir>

返回指定目录中条目的迭代器。迭代器将产生 io::Result<DirEntry> 的实例。最初构造迭代器后可能会遇到新的错误。跳过当前目录和父目录(通常为 ... )的条目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::io;
use std::fs::{self, DirEntry};
use std::path::Path;

// 一种仅通过访问文件来遍历目录的可能实现方式
fn visit_dirs(dir: &Path, cb: &dyn Fn(&DirEntry)) -> io::Result<()> {
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
// 递归访问目录
if path.is_dir() {
visit_dirs(&path, cb)?;
} else {
cb(&entry);
}
}
}
Ok(())
}

遍历path并收集的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::{fs, io};

fn main() -> io::Result<()> {
let mut entries = fs::read_dir(".")?
.map(|res| res.map(|e| e.path()))
.collect::<Result<Vec<_>, io::Error>>()?;

// 不保证 `read_dir` 返回条目的顺序。
// 如果需要可重复的排序,则应对条目进行显式排序。

entries.sort();

// 现在,条目已经按照路径进行了排序。

Ok(())
}

函数签名:

1
pub fn read_link<P: AsRef<Path>>(path: P) -> Result<PathBuf>

读取符号链接,返回链接指向的文件。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
let path = fs::read_link("a.txt")?;
Ok(())
}

3.4 文件打开方式

std::fs::File结构体提供对文件系统上打开文件的访问的对象。

可以读取或写入 File 的实例,具体取决于打开它时使用的选项。文件还实现 Seek 来更改文件内部包含的逻辑游标。文件超出范围时会自动关闭。 Drop 的实现会忽略关闭时检测到的错误。

该结构体实现了std::io::Readstd::io::Write,因此可以使用这些特征的方法。具体这些方法已经在标准库std::io中介绍过了。

create

签名:

1
pub fn create<P: AsRef<Path>>(path: P) -> Result<File>

以只写模式打开文件。如果文件不存在,此函数将创建一个文件,如果存在,则会截断它。

比如,创建一个新文件并向其写入字节:

1
2
3
4
5
6
7
8
use std::fs::File;
use std::io::prelude::*;

fn main() -> std::io::Result<()> {
let mut file = File::create("foo.txt")?;
file.write_all(b"Hello, world!")?;
Ok(())
}

open

签名:

1
pub fn open<P: AsRef<Path>>(path: P) -> Result<File>

尝试以只读模式打开文件。如果 path 尚不存在,此函数将返回错误。

create_new

签名:

1
pub fn create_new<P: AsRef<Path>>(path: P) -> Result<File>

在读写模式下创建新文件。如果文件不存在,此函数将创建一个文件,如果存在,则返回错误。这样,如果调用成功,则返回的文件保证是新的。

目前这是实验性的方法,仍然处在nightly版本中。

1
2
3
4
5
6
7
8
#![feature(file_create_new)]

use std::fs::File;

fn main() -> std::io::Result<()> {
let mut f = File::create_new("foo.txt")?;
Ok(())
}

options

签名:

1
pub fn options() -> OpenOptions

返回一个新的OpenOptions对象。它是一个控制文件打开方式的配置项结构体。 open()create() 方法只能以只读或者只写的方式打开文件,有时不合适,此时可以使用该对象打开或创建具有特定选项的文件。

1
2
3
4
5
6
use std::fs::File;

fn main() -> std::io::Result<()> {
let mut f = File::options().append(true).open("example.log")?;
Ok(())
}

它等效于 OpenOptions::new() ,但允许你编写更具可读性的代码。代替 OpenOptions::new().append(true).open("example.log") ,你可以编写 File::options().append(true).open("example.log") 。这也避免了导入 OpenOptions 的需要。

3.5 OpenOptions文件打开配置

该结构体可用于配置文件打开方式的选项和标志。此结构体公开了配置如何打开 File 以及允许对打开的文件执行哪些操作的功能。 File::openFile::create 方法是使用此生成器的常用选项的别名。

一般来说,使用 OpenOptions 时,首先调用 OpenOptions::new ,然后将调用链式调用到设置每个选项的方法,然后调用 OpenOptions::open ,传递你尝试打开的文件的路径。这将为你提供一个 io::Result ,里面有一个 File 可以进一步操作。

比如,打开文件进行读取和写入,如果文件不存在,则创建该文件:

1
2
3
4
5
6
7
use std::fs::OpenOptions;

let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open("foo.txt");

append

如果想以追加模式打开,可以将append设置为true,表示写入将追加到文件而不是覆盖以前的内容。请注意,设置 .write(true).append(true) 与仅设置 .append(true) 具有相同的效果:

1
2
3
use std::fs::OpenOptions;

let file = OpenOptions::new().append(true).open("foo.txt");

对于大多数文件系统,操作系统保证所有写入都是原子的:不会因为另一个进程同时写入而破坏任何写入。

使用追加模式时,一个可能明显的注意事项:确保在一次操作中将属于一起的所有数据写入文件。这可以通过在将字符串传递给 write() 之前连接字符串来完成,或者使用缓冲编写器(具有足够大小的缓冲区),并在消息完成时调用 flush()

另外,如果同时具有读取和追加访问权限打开文件,请注意,在打开后和每次写入后,读取位置可能会设置在文件的末尾。因此,在写入之前,需要保存当前位置(使用 seek(SeekFrom::Current(0)) ),并在下次读取之前将其还原。

如果文件不存在,则此函数不会创建该文件,此时要使用 OpenOptions::create 方法执行此操作。

truncate

truncate设置用于截断上一个文件的选项。如果在设置此选项的情况下成功打开文件,则会将文件截断为 0 长度(如果该文件已存在)。必须使用写入权限打开文件,截断才能正常工作。

1
2
3
use std::fs::OpenOptions;

let file = OpenOptions::new().write(true).truncate(true).open("foo.txt");

4 time

该模块用于量化时间。它主要提供如下结构:

  • Duration,表示时间跨度的 Duration 类型,通常用于系统超时。
  • Instant,单调非递减时钟的测量,不透明且仅能配合 Duration 使用。
  • SystemTime,系统时钟的测量值,可用于与文件系统或其他进程等外部实体通信。

4.1 使用Duration表示时间跨度

每个 Duration 由整数秒和以纳秒表示的小数部分组成:

1
2
3
4
5
const NANOS_PER_SEC: u32 = 1_000_000_000;
pub struct Duration {
secs: u64,
nanos: Nanoseconds, // Always 0 <= nanos < NANOS_PER_SEC
}

其中,nanos部分表示纳秒,它的范围是[0,NANOS_PER_SEC],由于1_000_000_000纳秒等于1秒,如果超过则需要进位,加到secs中去。另外,如果底层系统不支持纳秒级精度,绑定系统超时的 API 通常会将纳秒数取整。

构建时间跨度

可使用Duration::new(Sec, Nano_sec)来构建Duration。例如,Duration::new(1, 300)构建了一个1秒300纳秒的Duration,即总共1_000_000_300纳秒。

特殊地,如果两个参数都指定为0,那么表示时长为0,可用is_zero()来检测某个Duration是否是0时长。0时长可用于上下文切换,例如sleep睡眠0秒,表示不用睡眠,但会交出CPU使得发生上下文切换。

还可以通过from_开头的函数进行构建:

  • Duration::from_secs(3):3秒时长
  • Duration::from_millis(300):300毫秒时长
  • Duration::from_micros(300):300微秒时长
  • Duration::from_nanos(300):300纳秒时长
  • Duration::from_secs_f32(2.3):2.3秒时长
  • Duration::from_secs_f64(2.3):2.3秒时长

通过时间跨度进行单位转换

构建出的Duration可以通过以下方法提取并转换它的秒、毫秒、微秒、纳秒单位。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::time::Duration;

fn main() {
let duration = Duration::new(1,3000);
println!("as_secs,转换为秒: {:?} ", duration.as_secs());
println!("as_millis,转换为毫秒: {:?}", duration.as_millis());
println!("as_micros,转换为微秒: {:?}", duration.as_micros());
println!("as_micros,转换为纳秒: {:?}", duration.as_nanos());
println!("as_secs_f32,转换为小数表示: {:?}", duration.as_secs_f32());
println!("as_secs_f64,转换为小数表示: {:?}", duration.as_secs_f64());
println!("subsec_millis,小数部分转换为毫秒: {:?}", duration.subsec_millis());
println!("subsec_micros,小数部分转换为微秒: {:?}", duration.subsec_micros());
println!("subsec_nanos,小数部分转换为纳秒: {:?}", duration.subsec_nanos());
}

输出结果:

1
2
3
4
5
6
7
8
9
as_secs,转换为秒: 1 
as_millis,转换为毫秒: 1000
as_micros,转换为微秒: 1000003
as_micros,转换为纳秒: 1000003000
as_secs_f32,转换为小数表示: 1.000003
as_secs_f64,转换为小数表示: 1.000003
subsec_millis,小数部分转换为毫秒: 0
subsec_micros,小数部分转换为微秒: 3
subsec_nanos,小数部分转换为纳秒: 3000

通过时间跨度进行运算和比较

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::time::Duration;

fn main() {
let duration = Duration::new(1,3000);
println!("checked_add,时长的加法运算,超出Duration范围时返回None: {:?} ", duration.checked_add(Duration::new(0, 1)));
println!("checked_add,时长的减法运算,超出Duration范围时返回None: {:?} ", duration.checked_sub(Duration::new(0, 1)));
println!("checked_add,时长的乘法运算,超出Duration范围时返回None: {:?} ", duration.checked_mul(2));
println!("checked_add,时长的除法运算,超出Duration范围(分母为0)时返回None: {:?} ", duration.checked_div(2));
println!("checked_add,饱和时长的除法运算,如果发生溢出则返回 Duration::MAX: {:?} ", duration.saturating_add(Duration::new(0, 1)));
println!("checked_add,饱和时长的除法运算,如果发生溢出则返回 0: {:?} ", duration.saturating_sub(Duration::new(0, 1)));
println!("checked_add,饱和时长的除法运算,如果发生溢出则返回 Duration::MAX: {:?} ", duration.saturating_mul(2));
println!("时长乘以小数,得到的结果如果超出范围或无效,则panic: {:?} ", duration.mul_f32(2.3));
println!("时长乘以小数,得到的结果如果超出范围或无效,则panic: {:?} ", duration.mul_f64(2.3));
println!("时长除以小数,得到的结果如果超出范围或无效,则panic: {:?} ", duration.div_f32(2.3));
println!("时长除以小数,得到的结果如果超出范围或无效,则panic: {:?} ", duration.div_f64(2.3));
}

4.2 使用Instant表示时间点

单调非递减时钟的测量,不透明且仅能配合 Duration 使用。

所谓不透明,指的是只能相互比较,没有办法从Instant得到“秒数”,相反,它只允许测量两个Instant之间的持续时间(或比较两个Instant)。

除非出现平台错误,否则始终保证后创建的Instant不小于任何先前创建的Instant

例如,此刻是处在某个时间点A,下一次(例如某个时长过后),处在另一个时间点B,时间点B一定不会早于时间点A,即便修改了操作系统的时钟或硬件时钟,它也不会时光倒流的现象。

创建一个“现在”时间的Instant

1
2
3
use std::time::Instant;

let now = Instant::now();

除此之外,还有一些实用的方法。

  • checked_add接受一个duration,如果 t 可以表示为 Instant (这意味着它在基础数据结构的范围内),则返回 Some(t) ,其中 t 是时间 self + duration ,否则返回 None

  • checked_sub同理,如果 t 可以表示为 Instant (这意味着它在基础数据结构的范围内),则返回 Some(t) ,其中 t 是时间 self - duration ,否则返回 None

  • elapsed返回自Instant创建以来到现在经过的时间量。

  • duration_since接受另一个Instant,返回从另一Instant到当前Instant经过的时间跨度(Duration),如果该Instant晚于这一Instant,则返回0(Duration)。

    1
    2
    3
    4
    5
    6
    7
    8
    use std::time::{Duration, Instant};
    use std::thread::sleep;

    let now = Instant::now();
    sleep(Duration::new(1, 0));
    let new_now = Instant::now();
    println!("{:?}", new_now.duration_since(now));
    println!("{:?}", now.duration_since(new_now)); // 0ns

Instant可以直接进行大小比较,也可以进行加减操作。

4.3 使用Timeout表示超时时间

5 sync

std::sync提供了有用的同步原语。主要有:

以下是可用同步对象的概述:

  • Arc:原子引用计数指针,可在多线程环境中使用,以延长某些数据的生命周期,直到所有线程都完成使用它。
  • Barrier:线程屏障,确保多个线程在继续执行之前相互等待到达程序中的某个点。
  • Condvar:条件变量,提供在等待事件发生时阻塞线程的能力。
  • mpsc:多生产者、单消费者队列,用于基于消息的通信。可以提供轻量级的线程间同步机制,但代价是一些额外的内存。
  • Mutex:互斥锁,保证一次最多有一个线程能够访问部分数据。
  • Once:用于线程安全的一次性全局初始化例程
  • OnceLock:用于线程安全的全局变量的一次性初始化
  • RwLock:读写锁,允许同时多个读取者,同时只允许一个写入者。在某些情况下,这可能比互斥锁更有用。

其中的大部分,都在并发编程中已经介绍过。

6 borrow

std::borrow用于处理借用数据。它包含的三个特征在Borrow, BorrowMut, ToOwned已经介绍过。

7 net

std::net用于处理TCP/UDP网络通信。整个模块的组织架构如下:

7.1 通过TCP通信

std::net::TcpListener是一个结构体,用于TCP套接字服务端,监听连接。

创建监听者

一般通过bind关联方法创建一个新的 TcpListener(可以称之为侦听器或监听器) ,它将绑定到指定的地址,函数签名如下:

1
pub fn bind<A: ToSocketAddrs>(addr: A) -> Result<TcpListener>

传入的addr需要指定地址和端口,比如:127.0.0.1:8080,地址类型A可以是 ToSocketAddrs 特征的任何实现者,我们马上在后面介绍。

下面的例子创建了一个监听本地80端口的TCP服务端:

1
2
3
use std::net::TcpListener;

let listener = TcpListener::bind("127.0.0.1:80").unwrap();

如果 addr 产生多个地址,则 bind 将尝试使用每个地址,直到一个成功并返回对应的监听器。如果没有一个地址成功创建监听,则返回上次尝试(最后一个地址)返回的错误。

1
2
3
4
5
6
7
8
9
use std::net::{SocketAddr, TcpListener};

fn main() {
let addrs = [
SocketAddr::from(([127, 0, 0, 1], 80)),
SocketAddr::from(([127, 0, 0, 1], 443)),
];
let listener = TcpListener::bind(&addrs[..]).unwrap();
}

在这个例子中,会首先尝试创建绑定到 127.0.0.1:80 的 TCP 监听器。如果失败,则尝试创建一个绑定到 127.0.0.1:443 的 TCP 侦听器,如果失败,返回错误。

如果端口号指定为0,则将请求操作系统为该侦听器分配一个端口。分配的端口可以通过 TcpListener::local_addr 方法查询。

1
2
3
4
5
6
use std::net::TcpListener;

fn main() {
let socket = TcpListener::bind("127.0.0.1:0").unwrap();
println!("{:?}",socket.local_addr());
}

在这个例子中,侦听器将由操作系统分配一个端口。

最后注意返回值是Result<TcpListener>,说明绑定可能会失败。比如,按照惯例,连接到端口 80 需要管理员权限(非管理员只能侦听高于 1023 的端口),因此如果我们尝试在没有管理员身份的情况下连接到端口 80,则可能会失败。再比如,我们将要绑定的端口已经被其它程序占用,也会失败。

阻塞等待新传入的连接

通过accept方法接收新传入的连接,该函数将阻塞调用线程,直到建立新的 TCP 连接。建立后,将返回相应的 TcpStream 和远程连接地址。

函数签名:

1
pub fn accept(&self) -> Result<(TcpStream, SocketAddr)>

例子:

1
2
3
4
5
6
7
8
9
use std::net::TcpListener;

fn main(){
let listener = TcpListener::bind("127.0.0.1:8086").unwrap();
match listener.accept() {
Ok((_socket, addr)) => println!("new client: {addr:?}"),
Err(e) => println!("couldn't get client: {e:?}"),
}
}

通过TCP流读写数据

连接创建后,就可以读取和写入数据了。

通过accept函数返回的结果中,第一项是TcpStream类型,它也是一个结构体,用于表示本地和远程套接字之间的 TCP 流。通过 acceptTcpListener 上建立连接创建 TcpStream 后,数据可以通过对其进行读取和写入。

第二种创建TcpStream 的方式是使用TcpStream::connect 函数连接到远程主机,函数签名如下:

1
pub fn connect<A: ToSocketAddrs>(addr: A) -> Result<TcpStream>

addr 是远程主机的地址。任何实现 ToSocketAddrs 特征的东西都可以提供给地址。

比如,打开到 127.0.0.1:8080 的 TCP 连接:

1
2
3
4
5
6
7
8
9
use std::net::TcpStream;

fn main() {
if let Ok(stream) = TcpStream::connect("127.0.0.1:8080") {
println!("Connected to the server!");
} else {
println!("Couldn't connect to server...");
}
}

如果 addr 产生多个地址,则将尝试使用每个地址 connect 直到连接成功。如果没有一个地址能够成功连接,则返回上次连接尝试(最后一个地址)返回的错误。

比如,打开到 127.0.0.1:8080 的 TCP 连接。如果连接失败,则打开到 127.0.0.1:8081 的 TCP 连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::net::{SocketAddr, TcpStream};

fn main() {
let addrs = [
SocketAddr::from(([127, 0, 0, 1], 8080)),
SocketAddr::from(([127, 0, 0, 1], 8081)),
];
if let Ok(stream) = TcpStream::connect(&addrs[..]) {
println!("Connected to the server!");
} else {
println!("Couldn't connect to server...");
}
}

ReadWrite中提到过,TcpStream实现了这两个特征,因此可以通过特征中定义的方法从TCP流中读写数据。

下面是一个读取和写入的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::thread;

fn main() {
let handle1 = thread::spawn(|| {
let listener = TcpListener::bind("127.0.0.1:8086").unwrap();
match listener.accept() {
Ok((mut _socket, addr)) => {
println!("new client: {addr:?}");
let mut buffer = String::new();
_socket.read_to_string(&mut buffer).unwrap();
println!("received: {}", buffer);
}
Err(e) => println!("couldn't get client: {e:?}"),
}
});
let handle2 = thread::spawn(|| {
let mut costomer = TcpStream::connect("127.0.0.1:8086").unwrap();
costomer.write_all(b"some data").unwrap();
});

handle1.join().unwrap();
handle2.join().unwrap();
}

在这个例子中,其中一个线程内创建了一个监听者listener,并使用accept阻塞并等待新的连接到来,如果有新的连接则会创建TCP流,接下来使用read_to_string方法从中读取数据到buffer

在另外一个线程则通过connect方法创建了另一个连接到listener的TCP流,然后使用wrtie_all写入了一些字节。

程序输出:

1
2
new client: 127.0.0.1:52777
received: some data

使用incoming持续监听

TcpListener 上的 incoming 方法返回一个迭代器,它为我们提供了一系列TCP流,函数签名如下:

1
pub fn incoming(&self) -> Incoming<'_>

Incoming是一个结构体,它实现了Iterator特征:

1
2
3
4
5
6
7
#[stable(feature = "rust1", since = "1.0.0")]
impl<'a> Iterator for Incoming<'a> {
type Item = io::Result<TcpStream>;
fn next(&mut self) -> Option<io::Result<TcpStream>> {
Some(self.listener.accept().map(|p| p.0))
}
}

其中,关联类型表明它返回io::Result<TcpStream>类型的值,当我们迭代它时,会产生一系列流,每个流都可以代表一个连接。并且,返回的迭代器永远不会返回 None 并且也不会产生对应的 SocketAddr 结构。迭代它相当于在循环中调用 TcpListener::accept 。你可以把它看作无限 accept 连接的迭代器。

1
2
3
4
5
6
7
8
9
10
11
use std::net::TcpListener;

fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

for stream in listener.incoming() {
let stream = stream.unwrap();

println!("Connection established!");
}
}

这样就可以持续监听一个地址并为每个TCP流创建连接,比如我们从浏览器访问127.0.0.1:7878时,可以看到程序输出:

1
2
3
4
5
6
Connection established!
Connection established!
Connection established!
Connection established!
Connection established!
Connection established!

这里可能打印多条消息,原因可能是浏览器正在请求该页面以及其他资源,例如浏览器选项卡中显示的favicon.ico图标,也可能是浏览器多次尝试连接到服务器,因为服务器没有响应任何数据。

TcpListener上的更多其它方法

具体可以参考官方文档,下面做一个简述:

  • local_addr:返回此listener的本地套接字地址
  • set_nonblocking:传入一个布尔值,将listener变为阻塞或非阻塞模式。如果IO操作成功,则返回 Ok ,无需执行任何操作。如果 IO 操作无法完成并需要重试,则返回类型为 io::ErrorKind::WouldBlock 的错误。
  • set_ttl:设置此套接字上的 IP_TTL 选项的值。该值表示从该套接字发送的每个数据包中使用的生存时间字段。
  • ttl:获取此套接字的 IP_TTL 选项的值。
  • take_error:获取此套接字上的 SO_ERROR 选项的值。这将检索底层套接字中存储的错误,清除过程中的字段。这对于检查调用之间的错误很有用。
  • try_clone:为底层套接字创建一个新的具有所有权的句柄。返回的 TcpListener 是对此对象引用的同一个套接字的引用。两个句柄都可用于接受传入连接,并且在一个侦听器上设置的选项将影响另一个侦听器。

六、Cargo

1 代理配置

受限于一些原因,可能下载速度会受到影响,需要配置proxy。

1
2
3
$ cd $HOME/.cargo/
# 第一次正常情况没有config文件,创建即可
$ vi config.toml

config.toml文件中添加:

1
2
3
4
5
[http]
proxy = "127.0.0.1:7890"

[https]
proxy = "127.0.0.1:7890"

这个是全局的代理,当我们再次使用cargo时就会走这个代理了。

如果要取消代理,只需要将文件中的配置删掉即可。

注意,如果仍然走了代理,请检查是否也给git配置了代理,cargo在自己的代理没生效时,会尝试git代理,因此需要设置一下:

1
$ vi ~/.gitconfig

改下配置,让它只对 Github 生效:

1
2
[http "https://github.com"]
proxy = http://127.0.0.1:19180

2 Getting Started

2.1 安装

获取 Cargo 的最简单方法是使用 rustup 安装 Rust 的当前稳定版本。使用 rustup 安装 Rust 也会安装 cargo

在 Linux 和 macOS 系统上,按如下方式完成此操作:

1
curl https://sh.rustup.rs -sSf | sh

它将下载脚本并开始安装。

在 Windows 上,可以直接下载并运行 rustup-init.exe 。如果你是适用于 Linux 的 Windows 子系统用户,请在终端中运行以下命令,

1
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

然后按照屏幕上的说明安装 Rust。

总之,无论哪种方式,如果一切顺利,你将看到以下内容:

1
Rust is installed now. Great!

之后,可以使用 rustup 命令为 Rust 和 Cargo 安装 betanightly 通道。

有关其他安装选项和信息,访问Rust 网站的安装页面

或者,可以从源代码构建 Cargo

2.2 快速上手

在前面已经知道,要创建新的package项目,使用 cargo new

1
cargo new hello_world

默认情况下,这会默认使用 --bin 来生成二进制项(binary crate),要创建库项(library crate),需要传递 --lib

让我们看看cargo为我们生成了什么:

1
2
3
4
.
├── Cargo.toml
└── src
└── main.rs

首先,我们来看看 Cargo.toml

1
2
3
4
5
6
[package]
name = "hello_world"
version = "0.1.0"
edition = "2021"

[dependencies]

它是 Cargo 使用的配置文件,被称为manifest(清单),它包含 Cargo 编译包所需的所有元数据。

这是 src/main.rs 中的内容:

1
2
3
fn main() {
println!("Hello, world!");
}

默认为我们生成了一个“hello world”程序,在模块系统中介绍过,它被称为二进制crate(二进制包)。

我们可以使用cargo build编译它,然后运行:

1
2
$ ./target/debug/hello_world
Hello, world!

还可以使用 cargo run 一步完成编译和运行。

3 基础指南

3.1 创建项目并编译运行

书接快速上手,当使用cargo run编译运行项目时,你可能会注意到生成一个新文件 Cargo.lock ,它包含有关我们的依赖项的信息。由于目前在Cargo.toml中并没有任何依赖项,因此放到后面再讨论。

当你的项目编写完毕准备发布时,可以使用发布模式 cargo build --release 在打开优化的情况下编译文件,使用cargo build --release 将生成的二进制文件会放入 target/release 而不是 target/debug 中。

在开发时,使用cargo build默认以调试模式进行编译,由于编译器不进行优化,编译时间会更短,但代码运行速度会变慢。发布模式的编译时间较长,但实际代码运行速度会更快。

3.2 处理已有的项目

当你获得一份cargo项目时,比如从github上下载别人的源代码:

1
git clone https://github.com/rust-lang/regex.git

要构建它非常简单,只需要使用cargo build,这将获取所有依赖项,然后将它们与项目一起构建。

3.3 依赖项

crates.io是 Rust 社区维护的中心化注册服务(就像python的pip和node的npm一样),用户可以在其中寻找和下载所需的包。对于 cargo 来说,默认就是从这里下载依赖。

要依赖 crates.io 上托管的库,需要将其添加到 Cargo.toml 中。

如果你的 Cargo.toml 还没有 [dependencies] 部分,请添加该部分,然后列出你要使用的包名称和版本。下面的例子添加 time 的依赖项:

1
2
[dependencies]
time = "0.1.12"

等号左边是crate包名,右边是版本字符串,它符合SemVer规范。指定依赖项部分会介绍此处选项的更多信息。

如果我们还想添加对 regex 的依赖项,只需要在之前的依赖项下继续添加即可,整个的Cargo.toml如下:

1
2
3
4
5
6
7
8
[package]
name = "hello_world"
version = "0.1.0"
edition = "2021"

[dependencies]
time = "0.1.12"
regex = "0.1.41"

重新运行 cargo build ,cargo将获取新的依赖项及其所有依赖项,编译它们,并更新(或创建) Cargo.lock

Cargo.lock 包含有关我们使用的所有这些依赖项的哪个版本的确切信息。这个文件确保如果某个包,比如 regex 更新,我们仍将使用Cargo.lock 记录的修订版本进行构建,直到我们选择 cargo update

3.4 项目结构

典型的package结构,这是Cargo文件放置的约定。

如果二进制、示例、基准或集成测试由多个源文件组成,请将 main.rs 文件与额外模块一起放置在 src/binexamplestests 目录。可执行文件的名称将是目录名称。

有关手动配置目标的更多详细信息,请参阅配置目标。有关控制 Cargo 如何自动推断目标名称的更多信息,请参阅目标自动发现

3.5 Cargo.toml vs Cargo.lock

Cargo.tomlCargo.lock 有两个不同的用途。在我们讨论它们之前,先做一个总结:

  • Cargo.toml 是广义上描述项目的依赖关系,由用户编写
  • Cargo.lock 包含有关项目的依赖项的准确信息。它由 Cargo 维护,不应手动编辑

如果你正在构建非最终产品,例如其他 rust 包将依赖的 rust 库,请将 Cargo.lock 放入 .gitignore 中。如果你正在构建可像命令行工具或应用程序一样可执行的最终产品,或者 crate-type 为 staticlibcdylib 的系统库,需要将Cargo.lock 放入 git 。具体原因参考为什么版本控制中二进制文件有 Cargo.lock ,但库文件没有?

让我们再深入一点。

Cargo.toml 是一个清单文件(manifest),我们可以在其中指定有关包的一堆不同元数据。例如,我们可以说我们依赖于另一个包:

1
2
3
4
5
6
[package]
name = "hello_world"
version = "0.1.0"

[dependencies]
regex = { git = "https://github.com/rust-lang/regex.git" }

该包对 regex 库有一个依赖项。我们已经在本例中声明过,我们依赖于 GitHub 上的特定 Git 存储库。由于我们没有指定任何其他信息,Cargo 假设我们打算使用默认分支上的最新提交来构建我们的包。

听起来不错,但有一个问题:如果你今天构建这个包,然后向我发送一份副本,而我明天构建这个包,则可能会发生一些奇怪的事情,这是因为此时可能会有更多对 regex 的提交,我的构建将包含新的提交,而你的则不会。因此,我们会得到不同的版本。但我们想要的是可重复的构建。

可以通过在 Cargo.toml 中定义特定的 rev 值来解决这个问题,这样 Cargo 就可以准确地知道构建包时要使用哪个版本:

1
2
[dependencies]
regex = { git = "https://github.com/rust-lang/regex.git", rev = "9f9f693" }

现在我们的构建将是相同的。但有一个很大的缺点:现在我们每次想要更新库时都必须手动考虑 SHA-1。这既乏味又容易出错。

Cargo.lock此时便派上用场,由于它的存在,我们不需要手动跟踪确切的修订:Cargo 会为我们做这件事。当我们有这样的清单时:

1
2
3
4
5
6
[package]
name = "hello_world"
version = "0.1.0"

[dependencies]
regex = { git = "https://github.com/rust-lang/regex.git" }

我们第一次构建,Cargo 将获取最新的提交并将该信息写入 Cargo.lock 中。该文件将如下所示:

1
2
3
4
5
6
7
8
9
10
11
[[package]]
name = "hello_world"
version = "0.1.0"
dependencies = [
"regex 1.5.0 (git+https://github.com/rust-lang/regex.git#9f9f693768c584971a4d53bc3c586c33ed3a6831)",
]

[[package]]
name = "regex"
version = "1.5.0"
source = "git+https://github.com/rust-lang/regex.git#9f9f693768c584971a4d53bc3c586c33ed3a6831"

可以看到这里有更多信息,包括我们用来构建的确切版本。现在,当你将包交给其他人时,他们将使用完全相同的 SHA,即使我们没有在 Cargo.toml 中指定它。

当我们准备好选择新版本的库时,Cargo 可以重新计算依赖项并为我们更新内容:

1
2
cargo update            # 更新所有依赖项
cargo update -p regex # 只更新 “regex”

这将生成一个带有新版本信息的新 Cargo.lock 。需要注意的是 cargo update -p regex 传递的参数实际上是一个 Package IDregex 只是一个简写形式。

3.6 cargo运行测试

Cargo 可以使用 cargo test 命令运行测试。 Cargo 寻找在两个位置运行的测试:在每个 src 文件中以及 tests/ 中的任何测试。 src 文件中的测试应该是单元测试和文档测试。 tests/ 中的测试应该是集成测试。

这是在我们的包中运行 cargo test 的示例,目前还没有测试:

1
2
3
4
5
6
7
8
cargo test
Compiling regex v1.5.0 (https://github.com/rust-lang/regex.git#9f9f693)
Compiling hello_world v0.1.0 (file:///path/to/package/hello_world)
Running target/test/hello_world-9c2b65bbb79eabce

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

如果我们的包有测试,我们会看到更多的输出以及正确数量的测试。

还可以通过传递过滤器来运行特定测试:

1
cargo test foo

这将运行名称中带有 foo 的任何测试。

cargo test 还运行其他检查。它将编译你包含的所有示例,以确保它们仍然可以编译。它还运行文档测试以确保来自文档注释的代码示例能够编译。关于测试请参考编写自动化测试, 了解编写和组织测试的一般视图,参考配置目标

3.7 cargo home

“Cargo home”充当下载和源缓存。构建 crate 时,Cargo 将下载的构建依赖项存储在 Cargo home中。你可以通过设置 CARGO_HOME 环境变量来更改 Cargo home 的位置。默认情况下,Cargo home位于 $HOME/.cargo/ 。若你需要在项目中通过代码的方式来获取 CARGO_HOMEhome 包提供了相应的 API。

Cargo home 由以下组件组成:

文件

  • config.toml Cargo的全局配置文件。
  • credentials.toml 是通过 cargo login 提供私有化登录证书,用于登录package注册中心。
  • .crates.toml.crates2.json 这些隐藏文件包含通过 cargo install 安装的 crate 的包信息。不要手动编辑它们!

目录

  • bin 目录包含通过 cargo installrustup 安装的crate 的可执行文件。为了能够访问这些二进制文件,请将目录的路径添加到你的 $PATH 环境变量中。
  • git 中存储了 Git 的资源文件:
    • git/db 当一个crate依赖于 git 存储库时,Cargo 会将存储库作为裸存储库克隆到此目录中,并在必要时更新它。
    • git/checkouts 如果使用git存储库和commit号时,则会从 git/db 内的裸存储库将存储库所需的提交checkout到此目录中。这为编译器提供了为该依赖项指定的提交的存储库中包含的实际文件。可以对同一存储库的不同提交进行多次签出。
  • registry 包含了注册中心( 例如 crates.io )的元数据和 packages
    • registry/index 是一个 git 仓库,包含了注册中心中所有可用包的元数据( 版本、依赖等 )
    • registry/cache 中保存了已下载的依赖,这些依赖包以 gzip 的压缩档案形式保存,后缀名为 .crate
    • registry/src,若一个已下载的 .crate 档案被一个 package 所需要,该档案会被解压缩到 registry/src 文件夹下,最终 rustc 可以在其中找到所需的 .rs 文件

清除缓存

理论上,你始终可以删除缓存的任何部分,如果有 crate 需要它们,Cargo 将尽力通过重新提取存档或签出git存储库或简单地从网络重新下载源来恢复源。

或者,cargo-cache 提供了一个简单的 CLI 工具,用于仅清除缓存的选定部分或在命令行中显示其组件的大小。

3.8 构建缓存

target目录

默认情况下cargo build 的结果会被放入项目根目录下的 target 文件夹中,要更改位置,你可以设置 CARGO_TARGET_DIR 环境变量build.target-dir 配置值或 --target-dir 命令行标志。

target下的目录结构取决于你是否使用 --target 标志来针对特定平台进行构建。如果未指定 --target ,Cargo 将以针对主机架构构建的模式运行。构建结果会放入项目根目录下的 target 目录中,target 下每个子目录中包含了相应的发布配置(profile),例如 target/releasetarget/debug 是自带的profile,前者往往用于生产环境,因为会做大量的性能优化,而后者则用于开发环境,此时的编译效率和报错信息是最好的。

除此之外我们还可以定义自己想要的 profile ,例如用于测试环境的 profiletest,用于预发环境的 profilepre-prod 等。

目录描述
target/debug/包含了 dev profile 的构建输出(cargo buildcargo build --debug)
target/release/release profile 的构建输出,cargo build --release
target/foo/自定义 foo profile 的构建输出,cargo build --profile=foo

由于历史原因, devtest 发布配置文件存储在 debug 目录中, releasebench 发布配置文件存储在 release 目录中。用户定义的发布配置文件存储在与发布配置文件同名的目录中。

如果指定 --target 构建另一个目标时,输出将放置在具有目标名称的目录中:

目录例子
target/<triple>/debug/target/thumbv7em-none-eabihf/debug/
target/<triple>/release/target/thumbv7em-none-eabihf/release/

当没有使用 --target 时,Cargo 会与构建脚本和过程宏一起共享你的依赖包,对于每个 rustc 命令调用而言,RUSTFLAGS也将被共享。

而使用 --target 后,构建脚本、过程宏会针对宿主机的 CPU 架构进行各自构建,且不会共享 RUSTFLAGS

在配置文件目录(例如 debugrelease )下,包含最终的结果:

目录描述
target/debug/包含编译后的输出,例如二进制可执行文件、库对象( library target )
target/debug/examples/包含示例对象( example target )

另外某些命令将其输出放置在 target 目录顶层的专用目录中:

目录描述
target/doc/包含通过 cargo doc 生成的文档
target/package/包含 cargo packagecargo publish 生成的输出

Cargo 还创建构建过程所需的其他几个目录和文件。它们的布局被视为 Cargo 的内部布局,并且随时可能会发生变化。其中一些目录是:

目录描述
target/debug/deps依赖和其它输出成果
target/debug/incrementalrustc 增量编译的输出,该缓存可以用于提升后续的编译速度
target/debug/build/构建脚本的输出

依赖信息文件

在每一个编译成果的旁边,都有一个依赖信息文件,文件后缀是 .d。该文件是类似于 Makefile 的语法,指示重建工件所需的所有文件依赖项。这些旨在与外部构建系统一起使用,以便它们可以检测 Cargo 是否需要重新执行。默认情况下,文件中的路径是绝对路径。也可以通过修改build.dep-info-basedir 配置选项以使用相对路径。

1
2
# 关于 `.d` 文件的一个示例 : target/debug/foo.d
/path/to/myproj/target/debug/foo: /path/to/myproj/src/lib.rs /path/to/myproj/src/main.rs

共享缓存

第三方工具 sccache 可用于在不同的工作空间中共享已经构建好的依赖包。

为了设置 sccache,首先需要使用 cargo install sccache 进行安装,然后在调用 Cargo 之前将 RUSTC_WRAPPER 环境变量设置为 sccache

  • 如果用的 bash,可以将 export RUSTC_WRAPPER=sccache 添加到 .bashrc
  • 也可以使用 build.rustc-wrapper 配置项

有关更多详细信息,请参阅 sccache 文档。

4 详细指北

4.1 指定依赖项

crate 可以依赖于 crates.io 或其他注册表、 git 存储库或本地文件系统上的子目录中的其他库。你还可以临时覆盖依赖项的位置。例如,为了能够测试你正在本地处理的依赖项中的错误修复。对于不同的平台,你可以有不同的依赖项,以及仅在开发期间使用的依赖项。让我们看看如何执行这些操作。

指定来自 crates.io 的依赖项

默认情况下,Cargo 配置从 crates.io 查找依赖项。在这种情况下,只需要名称和版本字符串。比如,指定time的依赖:

1
2
[dependencies]
time = "0.1.12"

字符串 "0.1.12" 是版本要求。虽然它看起来像指定了 time 的特定版本,但它实际上指定了一系列版本并允许SemVer兼容更新。如果新版本号不修改主要、次要、补丁分组中最左边的非零数字,则允许更新。在这种情况下,如果我们运行 cargo update -p time ,如果是最新的 0.1.z 版本,cargo 应该将我们更新到版本 0.1.13 ,但不会将我们更新到 0.2.0 。相反,如果我们将版本字符串指定为 1.0 ,(如果它是最新的 1.y 版本,而不是 2.0 不被视为与任何其他版本兼容。)则应该更新为 1.1 版本。

以下是指定版本要求及其允许的版本的更多示例:

1
2
3
4
5
6
7
8
1.2.3  :=  >=1.2.3, <2.0.0
1.2 := >=1.2.0, <2.0.0
1 := >=1.0.0, <2.0.0
0.2.3 := >=0.2.3, <0.3.0
0.2 := >=0.2.0, <0.3.0
0.0.3 := >=0.0.3, <0.0.4
0.0 := >=0.0.0, <0.1.0
0 := >=0.0.0, <1.0.0

此兼容性约定与 SemVer 的不同之处在于它对待 1.0.0 之前版本的方式。虽然 SemVer 规范表示 1.0.0 之前不兼容,但 Cargo 认为 0.x.y0.x.z 兼容,其中 y ≥ zx > 0

可以使用特殊运算符进一步调整选择兼容版本的逻辑,尽管大多数时候没有必要。

使用^插入符

插入符是默认策略的替代语法, ^1.2.31.2.3 完全相同。

使用~波浪符

波形符要求指定具有一定更新能力的最小版本。如果指定主要版本、次要版本和补丁版本,或者仅指定主要版本和次要版本,则仅允许补丁级别的更改。如果仅指定主要版本,则允许进行次要版本和补丁级别更改。下面是一个示例:

1
2
3
~1.2.3  := >=1.2.3, <1.3.0
~1.2 := >=1.2.0, <1.3.0
~1 := >=1.0.0, <2.0.0
使用*通配符

通配符 * 所在的位置会替换成任何数字:

1
2
3
*     := >=0.0.0
1.* := >=1.0.0, <2.0.0
1.2.* := >=1.2.0, <1.3.0

不过 crates.io 并不允许只使用一个 * 来指定版本号 : *

使用比较符

允许手动指定版本范围或要依赖的确切版本:

1
2
3
4
>= 1.2.0
> 1
< 2
= 1.2.3
组合使用

可以组合使用,多个版本要求用逗号分隔,例如 >= 1.2, < 1.5

注意事项,请参考Multiple requirements

指定来自其他注册服务的依赖项

为了使用 crates.io 之外的注册服务,我们需要对 $HOME/.cargo/config.toml ($CARGO_HOME 下) 文件进行配置,添加新的服务提供商,有两种方式。

第一种方式,修改 .cargo/config.toml 添加以下内容:

1
2
[registries]
ustc = { index = "https://mirrors.ustc.edu.cn/crates.io-index/" }

这个是科大的镜像,在国内速度会快一些。对于这种方式,我们的项目的 Cargo.toml 中的依赖包引入方式也有所不同:

1
2
[dependencies]
time = { registry = "ustc" }

这一种使用方式在引用依赖包时要明确指定注册服务: time = { registry = "ustc" }

另外一种方式则不需要,修改 .cargo/config.toml

1
2
3
4
5
[source.crates-io]
replace-with = 'ustc'

[source.ustc]
registry = "git://mirrors.ustc.edu.cn/crates.io-index"

首先将源 source.crates-io 替换为 ustc,然后在第二部分指定了 ustc 源的地址。

不过,如果你要将自己的crate发布到 crates.io 上,那该包的依赖也必须在 crates.io 上。

指定 git 存储库的依赖项

要依赖位于 git 存储库中的库,需要指定的最少信息是使用 git 键的存储库的位置:

1
2
[dependencies]
regex = { git = "https://github.com/rust-lang/regex.git" }

Cargo 将在此位置获取 git 存储库,由于我们没有指定任何其他信息,Cargo 假设我们打算使用默认分支上的最新提交来构建我们的包,这不一定是主分支。你可以将 gitrevtagbranch 键组合来指定其他内容。

比如以下是指定你要在名为 next 的分支上使用最新提交的示例:

1
2
[dependencies]
regex = { git = "https://github.com/rust-lang/regex.git", branch = "next" }

任何不是分支(branch)或标签(tag)的内容都属于 rev 。这可以是提交哈希值,例如 rev = "4c59b707" ,也可以是远程存储库公开的命名引用,例如 rev = "refs/pull/493/head"

一旦添加了 git 依赖项,Cargo 就会将该依赖项锁定到当时的最新提交。一旦锁定到位,新的提交将不会自动拉取。但是,可以使用 cargo update 手动下拉它们。需要注意的是锁定一旦被删除,那 Cargo 依然会按照 Cargo.toml 中配置的地址和版本去拉取新的版本,如果你配置的版本不正确,那可能会拉取下来一个不兼容的新版本。

crates.io 不允许发布具有 git 依赖项的包,请参阅多种方式混合部分以获取备用替代方案。

指定路径依赖项

Cargo 支持本地路径依赖关系,一般来说,本地依赖包都是同一个项目内的内部包,例如假设我们有一个 hello_world 项目( package ),现在在其根目录下新建一个package:

1
2
#  在 hello_world/ 目录下
cargo new hello_utils

新建的 hello_utils 文件夹跟 srcCargo.toml 同级,现在修改 Cargo.tomlhello_world 项目引入新建的包:

1
2
3
4
5
[dependencies]
hello_utils = { path = "hello_utils" }
# 以下路径也可以
# hello_utils = { path = "./hello_utils" }
# hello_utils = { path = "../hello_world/hello_utils" }

这告诉 Cargo 我们依赖于一个名为 hello_utils 的包,它可以在 hello_utils 文件夹中找到(相对于它所写入的 Cargo.toml 文件夹)。

再运行cargo build 将自动构建 hello_utils 及其所有依赖项,其他人也可以开始使用该包。但是,crates.io 上不允许使用仅通过路径指定的依赖项的包。如果我们想发布 hello_world ,我们需要将 hello_utils 的版本发布到 crates.io 并在依赖项行中指定其版本,比如:

1
2
[dependencies]
hello_utils = { path = "hello_utils", version = "0.1.0" }

crates.io 不允许发布具有 path 依赖项的包,请参阅多种方式混合部分以获取备用替代方案。

多种方式混合

我们可以同时使用多种方式来引入同一个包,可以指定注册表版本和 gitpath 位置。 gitpath 依赖项将在本地使用,在发布到诸如 crates.io 时使用注册表版本。除此之外,不允许其他组合。例子:

1
2
3
4
5
6
7
8
9
10
[dependencies]
# 本地使用时,通过 path 引入,
# 发布到 `crates.io` 时,通过 `crates.io` 的方式引入: version = "1.0"
bitflags = { path = "my-bitflags", version = "1.0" }

# 本地使用时,通过 git 仓库引入
# 当发布时,通过 `crates.io` 引入: version = "1.0"
smallvec = { git = "https://github.com/servo/rust-smallvec", version = "1.0" }

# 特别注意 若 version 无法匹配,Cargo 将无法编译

根据平台引入依赖

我们还可以根据特定的平台来引入依赖:

1
2
3
4
5
6
7
8
9
10
11
[target.'cfg(windows)'.dependencies]
winhttp = "0.4.0"

[target.'cfg(unix)'.dependencies]
openssl = "1.0.1"

[target.'cfg(target_arch = "x86")'.dependencies]
native = { path = "native/i686" }

[target.'cfg(target_arch = "x86_64")'.dependencies]
native = { path = "native/x86_64" }

此处的语法跟 Rust 的 #[cfg] 语法非常相像,支持 notanyall 运算符来组合各种 cfg 名称/值对。

如果想知道你的平台上有哪些 cfg 目标可用,请从命令行运行 rustc --print=cfg 。如果想知道哪些 cfg 目标可用于其他平台(例如 64 位 Windows),请运行 rustc --print=cfg --target=x86_64-pc-windows-msvc

与 Rust 源代码不同,在这里不能使用 [target.'cfg(feature = "fancy-feature")'.dependencies] 根据自定义的条件来决定是否引入某个依赖,如有需求请改用 [features] 部分:

1
2
3
4
5
6
[dependencies]
foo = { version = "1.0", optional = true }
bar = { version = "1.0", optional = true }

[features]
fancy-feature = ["foo", "bar"]

通过 cfg(feature)cfg(debug_assertions), cfg(test)cfg(proc_macro) 的方式来条件引入依赖也是不行的,目前无法根据这些配置值添加依赖项。

除了 #[cfg] 语法之外,Cargo 还支持列出完整的依赖项应用于完整目标:

1
2
3
4
5
[target.x86_64-pc-windows-gnu.dependencies]
winhttp = "0.4.0"

[target.i686-unknown-linux-gnu.dependencies]
openssl = "1.0.1"

自定义 target 引入

如果你在使用自定义的 target :例如 --target bar.json,那么可以通过下面方式来引入依赖:

1
2
3
4
5
6
[target.bar.dependencies]
winhttp = "0.4.0"

[target.my-special-i686-platform.dependencies]
openssl = "1.0.1"
native = { path = "native/i686" }

不过,这种使用方式在 stable 版本的 Rust 中无法被使用。

开发时依赖项

可以将 [dev-dependencies] 部分添加到 Cargo.toml 中,其格式相当于 [dependencies]

1
2
[dev-dependencies]
tempdir = "0.3"

这里的依赖只会在运行测试、示例和 benchmark 时才会被引入。假设A 包引用了 B,而 B 通过 [dev-dependencies] 的方式引用了 C 包, 那 A 在发布构建时是不会引用 C 包的。

我们还可以指定平台特定的测试依赖包:

1
2
[target.'cfg(unix)'.dev-dependencies]
mio = "0.0.1"

发布包时,只有指定 version 的开发依赖项才会包含在发布的包中。对于大多数用例,发布时不需要开发依赖项,尽管某些用户(例如操作系统打包者)可能希望在包内运行测试,因此如果可能的话提供 version 仍然是一个好的习惯。

构建时依赖项

还可以指定某些依赖仅用于构建:

1
2
[build-dependencies]
cc = "1.0.3"

当然对于平台特定的也可以使用:

1
2
[target.'cfg(unix)'.build-dependencies]
cc = "1.0.3"

在这种情况下,只有当主机平台与指定的目标匹配时才会构建依赖关系。

构建脚本无法使用 [dependencies][dev-dependencies] 中的依赖,而 [build-dependencies] 中的依赖也无法被构建脚本之外的代码所使用。因此它们的依赖关系不需要一致。通过使用独立的依赖关系来实现独立的目的,可以使cargo项目依赖变得更简单、更干净。

选择 features

如果你依赖的包提供了条件性的 features,你可以指定使用哪一个:

1
2
3
4
[dependencies.awesome]
version = "1.3.5"
default-features = false # 不要包含默认的 features,而是通过下面的方式来指定
features = ["secure-password", "civet"]

重命名 Cargo.toml 中的依赖项

Cargo.toml 中编写 [dependencies] 部分时,为依赖项编写的键通常与你在代码中导入的包的名称相匹配。不过,对于某些项目,您可能希望在代码中引用不同名称的包,无论它如何在 crates.io 上发布。例如,你可能希望:

  • 避免在 Rust 源代码中使用 use foo as bar
  • 依赖于一个 crate 的多个版本。
  • 依赖于来自不同注册中心的同名crate。

可以使用 Cargo 提供的 package key 来完成这一点:

1
2
3
4
5
6
7
8
[package]
name = "mypackage"
version = "0.0.1"

[dependencies]
foo = "0.1"
bar = { git = "https://github.com/example/project.git", package = "foo" }
baz = { version = "0.1", registry = "custom", package = "foo" }

现在Rust 代码中提供了三个 crate:

1
2
3
extern crate foo; // crates.io
extern crate bar; // git repository
extern crate baz; // registry `custom`

所有这三个 crate 的 Cargo.toml 中都有包名称 foo ,我们显式的通过 package = "foo" 的方式告诉 Cargo:我们需要的就是这个 foo package,虽然它被重命名为 barbaz

有一点需要注意,当使用可选依赖时,如果你将 foo 包重命名为 bar 包,那引用foo的 feature 时的路径名也要做相应的修改:

1
2
3
4
5
[dependencies]
bar = { version = "0.1", package = 'foo', optional = true }

[features]
log-debug = ['bar/log-debug'] # 若使用 'foo/log-debug' 会导致报错

从工作区继承依赖项

通过在工作区的 [workspace.dependencies] 表中指定依赖项,可以从工作区继承依赖项。之后,使用 workspace = true 将其添加到 [dependencies] 表中。

除了 workspace 键之外,依赖项还可以包含以下键:

  • optional :请注意, [workspace.dependencies] 表不允许指定 optional
  • features :这些与 [workspace.dependencies] 中声明的功能相加

除了 optionalfeatures 之外,继承的依赖项不能使用任何其他依赖项键(例如 versiondefault-features )。

[dependencies][dev-dependencies][build-dependencies][target."...".dependencies] 部分中的依赖项支持引用 [workspace.dependencies] 定义的功能的依赖关系。

1
2
3
4
5
6
7
8
9
10
11
12
[package]
name = "bar"
version = "0.2.0"

[dependencies]
regex = { workspace = true, features = ["unicode"] }

[build-dependencies]
cc.workspace = true

[dev-dependencies]
rand = { workspace = true, optional = true }

4.2 重写依赖关系(依赖覆盖)

想要覆盖依赖的场景很常见,例如:

  • 你正在同时开发一个包和一个项目,而后者依赖于前者,你希望能在该项目中对正在开发的包进行测试
  • 你引入的一个依赖包在 master 分支发布了新的代码,恰好修复了某个 bug,因此你希望能单独对该分支进行下测试
  • 你即将发布一个包的新版本,为了确保新版本正常工作,你需要对其进行集成测试
  • 你为项目的某个依赖包提了一个 PR 并解决了一个重要 bug,在等待合并到 master 分支,但是时间不等人,因此你决定先使用自己修改的版本,等未来合并后,再继续使用官方版本

下面将介绍一些不同的用例,这些用例包含有关使用覆盖依赖项的不同方法。

测试 bugfix 版本

假设你正在使用 uuid 库,但在处理它时发现了该库一个错误。你很热情,因此决定帮助uuid修复该错误。最初Cargo.toml如下所示:

1
2
3
4
5
6
[package]
name = "my-library"
version = "0.1.0"

[dependencies]
uuid = "1.0"

要做的第一件事就是把源仓库克隆下来:

1
git clone https://github.com/uuid-rs/uuid.git

下面,修改项目的 Cargo.toml 添加以下内容以引入本地克隆的版本(这里的目录为项目同级的目录):

1
2
[patch.crates-io]
uuid = { path = "../uuid" }

我们使用自己修改过的 patch 来覆盖来自 crates.io 的版本,这会将 uuid 的本地签出版本添加到本地包的 crates.io 注册表中。

接下来,我们需要确保我们的锁定文件已更新以使用这个新版本的 uuid ,以便我们的包使用本地签出的副本,而不是来自 crates.io 的副本。 [patch] 的工作方式是,它会在 ../uuid 处加载依赖项,然后每当 crates.io 查询 uuid 的版本时,它也会返回本地版本。

这意味着本地签出的版本号意义重大,会影响补丁是否使用。我们的清单声明了 uuid = "1.0" 这意味着我们只会解析的版本号为 >= 1.0.0, < 2.0.0 ,而 Cargo 的贪婪解析算法也意味着我们将解析为该范围内的最大版本。通常这并不重要,因为 git 存储库的版本已经更高或与 crates.io 上发布的最大版本相匹配,但记住这一点很重要。

总之,接下来需要做的就是:

1
2
3
4
cargo build
Compiling uuid v1.0.0 (.../uuid)
Compiling my-library v0.1.0 (.../my-library)
Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs

可以看到现在正在使用 uuid 的本地版本进行构建(请注意构建输出中括号中的路径)。如果你没有看到构建本地路径版本,则可能需要运行 cargo update -p uuid --precise $version ,其中 $version 是本地签出的 uuid 副本的版本。

一旦修复了最初发现的错误,接下来要做的就是将其作为PR提交给 uuid 仓库。完成此操作后,你还可以更新 [patch] 部分。 [patch] 内部的列表与 [dependencies] 部分类似,因此当对方合并PR后,你可以将 path 依赖项更改为:

1
2
[patch.crates-io]
uuid = { git = 'https://github.com/uuid-rs/uuid.git' }

等未来uuid新的内容更新到 crates.io 后,就可以移除这个补丁,直接更新 [dependencies] 中的 uuid 版本即可。

使用未发布的次要版本

现在让我们从错误修复转向添加功能。在处理 my-library 时,你发现 uuid 箱中需要一个全新的功能。假设你已经实现了此功能,在上面使用 [patch] 进行了本地测试,并提交了拉取请求。让我们回顾一下在实际发布之前如何继续使用和测试它。

我们还假设 crates.io 上 uuid 的当前版本是 1.0.0 ,但从那时起 git 存储库的 master 分支已更新为 1.0.1 。该分支包含你之前提交的新功能。要使用此存储库,我们需要编辑 Cargo.toml 使其变为:

1
2
3
4
5
6
7
8
9
[package]
name = "my-library"
version = "0.1.0"

[dependencies]
uuid = "1.0.1"

[patch.crates-io]
uuid = { git = 'https://github.com/uuid-rs/uuid.git' }

注意,我们将 [dependencies] 中的 uuid 版本提前修改为 1.0.1,由于该版本在 crates.io 尚未发布,因此 patch 版本会被使用。

现在,当我们的库构建完成时,它将从 git 存储库获取 uuid 并解析为存储库内的 1.0.1,而不是尝试从 crates.io 下载版本。一旦 1.0.1 在 crates.io 上发布, [patch] 部分就可以删除。

还值得注意的是 [patch] 是会传递的。比如我们有一个更大的包叫做my-binary,它使用了 my-library ,例如:

1
2
3
4
5
6
7
8
9
10
[package]
name = "my-binary"
version = "0.1.0"

[dependencies]
my-library = { git = 'https://example.com/git/my-library' }
uuid = "1.0"

[patch.crates-io]
uuid = { git = 'https://github.com/uuid-rs/uuid.git' }

patch 不仅仅对于 my-binary 项目有用,对于 my-binary 的依赖 my-library 来说,一样可以间接生效。

如果你要覆盖的依赖项不是从 crates.io 加载的,则必须稍微更改一下 [patch] 的使用方式。例如,如果依赖项是 git 依赖项,可以使用以下命令将其覆盖到本地路径:

1
2
[patch."https://github.com/your/repository"]
my-library = { path = "../my-library/path" }

使用未发布的主要版本

假设我们要发布一个大版本 2.0.0,在我们向上游提交所有更改后,我们可以更新 my-library 的清单,如下所示:

1
2
3
4
5
[dependencies]
uuid = "2.0"

[patch.crates-io]
uuid = { git = "https://github.com/uuid-rs/uuid.git", branch = "2.0.0" }

2.0.0版本实际上并不存在于 crates.io 上,但我们仍然可以通过使用 [patch] 部分将其放入 git 依赖项中。

让我们再看看上面的 my-binary 清单:

1
2
3
4
5
6
7
8
9
10
[package]
name = "my-binary"
version = "0.1.0"

[dependencies]
my-library = { git = 'https://example.com/git/my-library' }
uuid = "1.0"

[patch.crates-io]
uuid = { git = 'https://github.com/uuid-rs/uuid.git', branch = '2.0.0' }

与之前的小版本不同,大版本的 patch 不会发生间接的传递,以上配置中, my-binary 将继续使用 1.x.y 系列的版本,而 my-library 将使用最新的 2.0.0 patch。这样设计的原因是,它允许你通过依赖关系图逐步推出对crate的重大更改,而不必被迫立即更新所有内容。

将 [patch] 与多个版本一起使用

之前介绍过可以使用 package 键重命名同一个包的多个版本。例如,假设 serde 库有一个错误修复,我们希望将其用于其 1.* 系列,但我们也希望使用我们自己本地修改的 2.0.0 版本。为了满足这两个 patch,可以使用如下内容的 Cargo.toml

1
2
3
[patch.crates-io]
serde = { git = 'https://github.com/serde-rs/serde' }
serde2 = { git = 'https://github.com/example/serde', package = 'serde', branch = 'v2' }

第一个 serde = ... 指令指示应该从 git 存储库使用 serde 1.* (拉取我们需要的错误修复),第二个 serde2 = ... 指令指示serde包也应该从 https://github.com/example/serdev2 分支中提取。我们假设该分支上的 Cargo.toml 提到了版本 2.0.0 ,同时将其重命名为 serde2。这样,在代码中就可以分别通过 serdeserde2 引用不同版本的依赖库了。

[patch] 部分覆盖依赖总结

Cargo.toml[patch] 部分可用于覆盖与其他副本的依赖关系。语法类似于 [dependencies] 部分:

1
2
3
4
5
6
7
8
9
[patch.crates-io]
foo = { git = 'https://github.com/example/foo.git' }
bar = { path = 'my/local/bar' }

[dependencies.baz]
git = 'https://github.com/example/baz.git'

[patch.'https://github.com/example/baz']
baz = { git = 'https://github.com/example/patched-baz.git', branch = 'my-branch' }

[patch] 表由类似依赖关系的子表组成。 [patch] 之后的每个键都是正在修补的源的 URL,或者注册表的名称。名称 crates-io 可用于覆盖默认注册表 crates.io。上面示例中的第一个 [patch] 演示了覆盖 crates.io,第二个 [patch] 演示了覆盖 git 源。

这些表中的每个条目都是正常的依赖项规范,与清单的 [dependencies] 部分中的相同。 [patch] 部分中列出的依赖项已解析并用于修补指定 URL 处的源。上面的清单片段使用 foobar 修补 crates-io 源(例如 crates.io 本身)。它还使用来自其他地方的 my-branch 修补 https://github.com/example/baz 源。

源可以使用不存在的 crate 版本进行修补,也可以使用已存在的 crate 版本进行修补。如果使用源中已存在的 crate 版本修补源,则源的原始 crate 将被替换。

路径覆盖

有时可能只是暂时处理一个crate,并且不想像上面的 [patch] 部分那样修改 Cargo.toml 。对于此用例,Cargo 提供了一种更为有限的覆盖版本,称为路径覆盖。

路径覆盖是通过 .cargo/config.toml 而不是 Cargo.toml 指定的。在 .cargo/config.toml 内部,指定一个名为 paths 的键:

1
paths = ["/path/to/uuid"]

该数组应填充包含 Cargo.toml 的目录。在本例中,我们只是添加 uuid ,因此它将是唯一被覆盖的。此路径可以是绝对路径,也可以是相对于包含 .cargo 文件夹的目录的路径。

然而,路径覆盖比 [patch] 部分受到更多限制,因为它们无法更改依赖关系图的结构。当使用路径替换时,之前的一组依赖项必须与新的 Cargo.toml 规范完全匹配。例如,这意味着路径覆盖不能用于测试向包添加依赖项,而在这种情况下必须使用 [patch] 。因此,路径覆盖的使用通常仅限于快速错误修复,而不是较大的更改。

4.3 清单格式

每个包的 Cargo.toml 文件称为其清单。它以 TOML 格式编写。它包含编译包所需的元数据。使用命令 cargo locate-project 可以查找到目前项目使用的清单位置。

清单包含以下部分:

  • cargo-features — 不稳定,仅限夜版使用。
  • [package] — 定义一个package
    • namepackage的名称。
    • versionpackage的版本。
    • authorspackage的作者。
    • edition — Rust 版次。
    • rust-version — 支持的最低 Rust 版本。
    • descriptionpackage的描述。
    • documentationpackage文档的 URL。
    • readmepackage的 README 文件的路径。
    • homepagepackage主页的 URL。
    • repositorypackage源存储库的 URL。
    • licensepackage许可证(开源协议)。
    • license-filepackage许可证文件的路径。
    • keywordspackage的关键词。
    • categoriespackage的分类。
    • workspacepackage的工作空间路径。
    • buildpackage构建脚本的路径。
    • linkspackage本地链接库的名称。
    • exclude — 发布时要排除的文件。
    • include — 发布时要包含的文件。
    • publish — 阻止项目发布。
    • metadata — 外部工具的额外配置信息。
    • default-run — 由 cargo run 运行的默认可执行文件。
    • autobins — 禁用可执行文件的自动发现。
    • autoexamples — 禁用示例文件自动发现。
    • autotests — 禁用测试文件自动发现。
    • autobenches — 禁用bench文件自动发现。
    • resolver— 设置要使用的依赖解析器。==todo设置链接==
  • Cargo Target 列表。
  • Dependency tables(项目依赖表):
  • [badges] — 用于在注册服务(例如 crates.io ) 上显示项目的一些状态信息,例如当前的维护状态:活跃中、寻找维护者、deprecated(弃用)
  • [features] — 条件编译功能。==todo==
  • [patch] — 覆盖依赖。
  • [replace] — 覆盖依赖项(已弃用),建议使用[patch]
  • [profile] — 编译器设置和优化。==todo==
  • [workspace] — 工作空间的定义。

下面进行详细解释。

[package] 部分

Cargo.toml 中的第一部分是 [package]

1
2
3
4
[package]
name = "hello_world" # the name of the package
version = "0.1.0" # the current version, obeying semver
authors = ["Alice <a@example.com>", "Bob <b@example.com>"]

Cargo 所需的必须字段是 nameversion 。如果发布到crates.io,可能需要其他字段。

name字段

项目名用于引用一个项目( package ),它有两个用途:

  • 其它项目引用我们的 package 时,会使用该 name
  • 作为 lib 或 bin 目标的默认名称时会使用它

项目名只能使用字母数字和 -_ ,并且不能为空。cargo newcargo init 对包名称施加了一些额外的限制,例如强制它是有效的 Rust 标识符而不是关键字。crates.io 施加了更多限制,例如:

  • 仅允许使用 ASCII 字符。
  • 不能使用保留字段。
  • 不能使用特殊的 Windows 名称,例如“nul”。
  • 最多使用 64 个字符的长度。
  • 不能使用已经被使用的名称,例如 uuid 已经在 crates.io 上被使用,则无法发布(如果不准备发布则没有此限制)

version字段

Cargo 融入了语义化版本的概念。例如字符串 "0.1.12" 是一个 semver 格式的版本号,符合 "x.y.z" 的形式,其中 x 被称为主版本major, y 被称为小版本 minor ,而 z 被称为补丁 patch,可以看出从左到右,版本的影响范围逐步降低,补丁的更新是无关痛痒的,并不会造成 API 的兼容性被破坏。

使用该规则,你还需要遵循一些基本规则:

  • 使用标准的 x.y.z 形式的版本号,例如 1.0.0 而不是 1.0
  • 在版本到达 1.0.0 之前,怎么都行,但是如果有破坏性变更( breaking changes ),需要增加 minor 版本号。例如,为结构体新增字段或为枚举新增成员就是一种破坏性变更
  • 1.0.0 之后,如果发生破坏性变更,需要增加 major 版本号
  • 1.0.0 之后不要去破坏构建流程
  • 1.0.0 之后,不要在 patch 更新中添加新的 api ( pub 声明),如果要添加新的 pub 结构体、特征、类型、函数、方法等对象时,增加 minor 版本号

authors字段

authors 字段在数组中列出了被视为项目“作者”的人员或组织,它是可选的。你可以列出原作者或主要作者、当前维护者或包的所有者。每个作者条目末尾的尖括号内可以包含可选的电子邮件地址。比如:

1
2
3
[package]
# ...
authors = ["Graydon Hoare", "Fnu Lnu <no-reply@rust-lang.org>"]

该字段仅出现在包元数据和 build.rs 内的 CARGO_PKG_AUTHORS 环境变量中。它不会显示在 crates.io 用户界面中。

另外,清单中的 [package] 部分一旦发布到 crates.io 就无法进行更改,因此对于已发布的包来说,authors 字段是无法修改的。

edition字段

可选。它会影响你的项目编译时使用的 Rust 版次。在 [package] 中设置 edition 键将影响项目中的所有目标/项目,项目括测试套件、基准测试、二进制文件、示例等。

1
2
3
[package]
# ...
edition = '2021'

大多数清单都有 edition 字段,由最新稳定版本的 cargo new 自动填充。默认情况下, cargo new 当前使用 2021 版本创建清单。

如果 edition 字段不存在于 Cargo.toml 中,则假定为 2015 版本以实现向后兼容性。请注意,使用 cargo new 创建的所有清单都不会使用此历史回退,因为它们将 edition 显式指定为较新的值。

rust-version字段

可选。它告诉 Cargo 可以使用哪个版本的 Rust 语言和编译器来编译你的项目。如果当前选择的 Rust 编译器版本比指定版本旧,cargo 将退出并显示错误,告诉用户需要什么版本。

支持该字段的第一个 Cargo 版本随 Rust 1.56.0 一起发布。在旧版本中,该字段将被忽略,并且 Cargo 将显示警告。

1
2
3
[package]
# ...
rust-version = "1.56"

Rust 版本必须是包含两个或三个组成部分的裸版本号;它不能包含 semver 运算符或预发布标识符。在检查 Rust 版本时,编译器预发布标识符(例如 -nightly)将被忽略。 rust-version 必须等于或比首次引入配置的 edition 的版次更新。

[package] 中设置 rust-version 键将影响项目中的所有目标/项目,项目括测试套件、基准测试、二进制文件、示例等。使用 --ignore-rust-version 选项可以忽略 rust-version

description字段

描述是关于该包的简短介绍。 crates.io 将在您的包中显示此信息。这应该是纯文本(不是 Markdown)。

1
2
3
[package]
# ...
description = "A short description of my package"

如果你要发布到 crates.io,则需要设置 description

documentation字段

documentation 字段用于说明项目文档的地址,若没有设置,crates.io 会自动链接到 docs.rs 上的相应页面。

1
2
3
[package]
# ...
documentation = "https://docs.rs/bitflags"

readme字段

readme 字段应该指向项目的 README.md 文件,该文件应该存在项目的根目录下(跟 Cargo.toml 同级),支持 Markdown 格式。当发布到的 crates.io 上时,项目首页会基于该文件的内容进行渲染。

1
2
3
[package]
# ...
readme = "README.md"

若该字段未设置且项目根目录下存在 README.mdREADME.txtREADME 文件,则该文件的名称将被默认使用。

你也可以通过将 readme 设置为 false 来禁止该功能,若设置为 true ,则默认值 README.md 将被使用。

homepage字段

该字段用于设置项目主页的 URL:

1
2
3
[package]
# ...
homepage = "https://serde.rs/"

repository字段

设置项目的源代码仓库地址,例如 GitHub 链接:

1
2
3
[package]
# ...
repository = "https://github.com/rust-lang/cargo/"

license 和 license-file 字段

license 字段包含发布软件包所依据的软件开源协议的名称。license-file 字段包含包含开源协议文件的路径(相对于 Cargo.toml )。

crates.io 将 license 字段解释为 SPDX 2.1 许可证表达式。该名称必须是 SPDX 许可证列表 3.11 中的已知许可证。目前不支持括号。请参阅 SPDX 网站了解更多信息。

SPDX 许可证表达式支持 ANDOR 运算符来组合多个许可证。

1
2
3
[package]
# ...
license = "MIT OR Apache-2.0"

使用 OR 表示用户可以选择任一许可证。使用 AND 表示用户必须同时遵守两个许可证。 WITH 运算符表示具有特殊例外的许可证。一些例子:

  • MIT OR Apache-2.0
  • LGPL-2.1-only AND MIT AND BSD-2-Clause
  • GPL-2.0-or-later WITH Bison-exception-2.2

如果软件包使用非标准许可证,则可以指定 license-file 字段来代替 license 字段。

1
2
3
[package]
# ...
license-file = "LICENSE.txt"

发布到crates.io时,你需要设置 licenselicense-file 其中一个。

keywords 字段

该字段使用字符串数组的方式来指定项目的关键字列表,当用户在 crates.io 上搜索时,这些关键字可以提供索引的功能。

1
2
3
[package]
# ...
keywords = ["gamedev", "graphics"]

crates.io 最多支持设置 5 个关键字。每个关键字必须是 ASCII 文本,以字母开头,只能包含字母、数字、 _- ,且最多 20 个字符。

categories 字段

categories 用于描述项目所属的类别:

1
categories = ["command-line-utilities", "development-tools::cargo-plugins"]

crates.io 最多支持设置 5 个类别,每个类别必须是官方All Valid Category Slugs中的一员。

workspace 字段

workspace 字段用于配置当前项目所属的工作空间。如果未指定,则会逐级向上寻找,直至找到第一个设置了 [workspace]Cargo.toml。如果一个成员不在工作空间的子目录时,设置该字段将非常有用。

1
2
3
[package]
# ...
workspace = "path/to/workspace/root"

需要注意的是 Cargo.toml 清单还有一个 [workspace] 部分专门用于设置工作空间,若它被设置了,则 package 中的 workspace 字段将无法被指定。也就是说,一个 crate 不能既是工作空间中的根 crate(包含 [workspace] ),又是另一个工作空间的成员 crate(包含 package.workspace )。

有关更多信息,请参阅工作空间

build 字段

build 字段指定包根目录中的一个文件,它是用于构建本机代码的构建脚本。

1
2
3
[package]
# ...
build = "build.rs"

默认值为 "build.rs" ,它从包根目录中名为 build.rs 的文件加载脚本。使用 build = "custom_build_name.rs" 指定不同文件的路径,或使用 build = false 禁用构建脚本的自动检测。

用于指定项目链接的本地库的名称。链接名为“git2”的本机库(例如 Linux 上的 libgit2.a )的包可能会指定:

1
2
3
[package]
# ...
links = "git2"

exclude 和 include 字段

excludeinclude 字段可用于显式指定打包要发布的项目时包含或不包含哪些文件,往往用于发布到注册服务时。exclude 字段中指定的模式标识一组未包含的文件, include 中的模式指定显式包含的文件。你可以使用 cargo package --list 来检查哪些文件被包含在项目中。

1
2
3
[package]
# ...
exclude = ["/ci", "images/", ".*"]
1
2
3
[package]
# ...
include = ["/src", "COPYRIGHT", "/examples", "!/examples/big_example"]

如果两个字段均未指定,则默认情况下将包含包根目录中的所有文件,但下面列出的排除项除外。

如果未指定 include ,则以下文件将被排除:

  • 项目不是 git 仓库,则所有以 . 开头的隐藏文件会被排除
  • 项目是 git 仓库,通过 .gitignore 配置的文件会被排除

无论 includeexclude 是否被指定,以下文件都会被排除在外:

  • 任何包含 Cargo.toml 的子目录会被排除
  • 根目录下的 target 目录会被排除

以下文件会永远被 include ,你无需显式地指定:

  • Cargo.toml
  • 若项目包含可执行文件或示例代码,则最小化的 Cargo.lock 会自动被包含
  • license-file 指定的协议文件

这两个字段互相排斥,设置 include 将覆盖 exclude。更高级的用法请参考The exclude and include fields

publish 字段

该字段常常用于防止项目因为失误被发布到 crates.io 等注册服务上,例如在公司中保持项目的私有性。

1
2
3
[package]
# ...
publish = false

该值还可以是字符串数组,这些字符串是允许发布到的注册服务名称。

1
2
3
[package]
# ...
publish = ["some-registry-name"]

publish 数组中包含了一个注册服务名称,则 cargo publish 命令会使用该注册服务,除非你通过 --registry 来设定额外的规则。

metadata 表

默认情况下,Cargo 会警告 Cargo.toml 中未使用的键,以帮助检测拼写错误等。然而, package.metadata 表被 Cargo 完全忽略,并且不会发出警告。此部分可用于想要在 Cargo.toml 中存储包配置的工具。例如:

1
2
3
4
5
6
7
8
[package]
name = "..."
# ...

# Metadata used when generating an Android APK, for example.
[package.metadata.android]
package-name = "my-awesome-android-app"
assets = "path/to/static"

与其相似的还有 [workspace.metadata],都可以作为外部工具的配置信息来使用。

default-run 字段

[package] 部分中的 default-run 字段可用于指定 cargo run 选择的默认二进制文件。例如,当同时存在 src/bin/a.rssrc/bin/b.rs 时:

1
2
[package]
default-run = "a"

指定默认使用a.rs

[badges] 部分

该部分用于指定项目当前的状态,该状态会展示在 crates.io 的项目主页中。crates.io 之前在其网站上的项目旁边显示了状态,但该功能已被删除。软件包应将状态放入其 README 文件中,该文件将显示在 crates.io 上(请参阅readme 字段 )。

1
2
3
4
5
6
7
8
9
10
11
12
[badges]
# `maintenance` 是项目的当前维护状态,它可能会被其它注册服务所使用,但是目前还没有被 `crates.io` 使用: https://github.com/rust-lang/crates.io/issues/2437
#
# `status` 字段时必须的,以下是可用的选项:
# - `actively-developed`: 新特性正在积极添加中,bug 在持续修复中
# - `passively-maintained`: 目前没有计划去支持新的特性,但是项目维护者可能会回答你提出的 issue
# - `as-is`: 该项目的功能已经完结,维护者不准备继续开发和提供支持了,但是它的功能已经达到了预期
# - `experimental`: 作者希望同大家分享,但是还不准备满足任何人的特殊要求
# - `looking-for-maintainer`: 当前维护者希望将项目转移给新的维护者
# - `deprecated`: 不再推荐使用该项目,需要说明原因以及推荐的替代项目
# - `none`: 不显示任何 badge ,因此维护者没有说明他们的状态,用户需要自己去调查发生了什么
maintenance = { status = "..." }

4.4 Cargo目标

Cargo项目由与可以编译到crate中的源文件相对应的目标组成。项目可以是库[lib]、二进制[[bin]]、示例[[example]]、测试[[test]]和基准测试[[bench]]目标。目标列表可以在 Cargo.toml 清单中配置,大部分时候都无需手动配置,通常通过源文件的目录布局自动推断。

库目标(Library)

库目标定义了一个可以由其他库和可执行文件使用和链接的“库”。文件名默认为 src/lib.rs ,库名称默认为包名称。一个项目只能有一个库。可以在 Cargo.toml[lib] 表中自定义库目标的设置。

1
2
3
4
5
# 一个自定义库目标的示例
# in Cargo.toml
[lib]
crate-type = ["cdylib"]
bench = false

二进制目标(Binaries)

二进制目标是编译后可以运行的可执行程序。默认的二进制文件名是 src/main.rs ,默认为项目的名称。其他二进制文件存储在 src/bin/ 目录中。可以在 Cargo.toml[[bin]] 表中自定义每个二进制文件的设置。

二进制文件可以使用library库目标的公共 API。并且也可以通过 [dependencies] 来引入外部的依赖库。

可以使用 cargo run --bin <bin-name> 的方式来运行指定的二进制对象,以下是二进制对象的配置示例:

1
2
3
4
5
6
7
8
9
# Example of customizing binaries in Cargo.toml.
[[bin]]
name = "cool-tool"
test = false
bench = false

[[bin]]
name = "frobnicator"
required-features = ["frobnicate"]

示例目标(Examples)

位于 examples 目录下的文件是展示库提供的功能的示例代码,编译后,它们被放置在 target/debug/examples 目录中。与二进制目标一样,示例目标可以使用library库目标的公共 API。并且也可以通过 [dependencies] 来引入外部的依赖库。

默认情况下,示例是可执行的二进制文件,带有 fn main() 函数入口,你也可以将示例对象改成库目标的类型:

1
2
3
[[example]]
name = "foo"
crate-type = ["staticlib"]

如果想要指定运行某个示例,可以使用 cargo run --example <example-name> 命令。如果是库类型的示例目标,则可以使用 cargo build --example <example-name> 进行构建。

与此类似,还可以使用 cargo install --example <example-name> 来将示例目标编译出的可执行文件安装到默认的目录中,将该目录添加到 $PATH 环境变量中,就可以直接全局运行安装的可执行文件。

最后,cargo test 命令默认会对示例目标进行编译,以防止示例代码因为长久没运行,导致严重过期以至于无法运行。如果希望在示例中使用 cargo test 运行 #[test] 函数,需要将 test 字段设置为 true

测试目标(Tests)

Cargo 项目中有两种类型的测试:单元测试和集成测试。后者的文件位于根目录下的 tests 目录中,使用 cargo test 命令运行测试。默认情况下,Cargo 和 rustc 使用 libtest 工具,它负责收集用 #[test] 属性注释的函数并并行执行它们,报告每个测试的成功和失败。通过harness 字段可以修改默认的工具,参考harness

Cargo 中还有另一种特殊的测试方式:文档测试。它们由 rustdoc 处理,并且执行模型略有不同。

有关具体的关于测试和组织结构的内容,请参考编写自动化测试

基准测试目标(Benchmarks)

基准测试提供了一种使用 cargo bench 命令测试代码性能的方法。它们遵循与Tests目录相同的结构,每个基准函数都用 #[bench] 属性进行注释。#[bench] 属性目前不稳定,仅在夜间版本上可用。

4.5 配置目标

你可能会注意到[lib][[bin]] 的写法不一致,后者多了一个[],原因是这种语法是 TOML 提供的数组特性[[bin]] 这种写法意味着我们可以在 Cargo.toml 中创建多个 [[bin]] ,每一个对应一个二进制文件,而由于只能指定一个库目标,因此只能使用 [lib] 形式。

我们以 [lib] 为例来说明相应的配置项,其它的目标都基本相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
[lib]
name = "foo" # 对象名称: 库对象、`src/main.rs` 二进制对象的名称默认是项目名
path = "src/lib.rs" # 对象的源文件路径
test = true # 能否被测试,默认是 true
doctest = true # 文档测试是否开启,默认是 true
bench = true # 基准测试是否开启
doc = true # 文档功能是否开启
plugin = false # 是否可以用于编译器插件(deprecated).
proc-macro = false # 是否是过程宏类型的库
harness = true # 是否使用libtest harness : https://doc.rust-lang.org/stable/rustc/tests/index.html
edition = "2015" # 对象使用的 Rust Edition版次
crate-type = ["lib"] # 生成的包类型
required-features = [] # 构建对象所需的 Cargo Features (N/A for lib).

其中:

name

对于库目标和默认的二进制目标( src/main.rs ),默认的名称是项目的名称( package.name )。

对于其它类型的对象,默认是目录或文件名。

除了 [lib] 外,name 字段对于其他目标都是必须的。

path

path 字段指定crate的源相对于 Cargo.toml 文件的位置。如果未指定,则根据目标名称自动推断。

test

test 字段指示 cargo test 默认情况下是否测试目标。 库目标、二进制目标和测试目标的默认值为 true

doctest

doctest 字段指示文档示例是否默认由 cargo test 进行测试。这仅与库目标[lib]相关,对其他部分没有影响。库目标的默认值为 true

bench

bench 字段指示目标是否默认由 cargo bench 进行基准测试。库目标、二进制目标和基准测试目标的默认值为 true

doc

doc 字段指示目标是否默认包含在 cargo doc 生成的文档中。库目标和二进制目标的默认值为 true

注意:如果二进制目标的名称与库目标相同,则将跳过该二进制目标。

plugin

该字段用于 rustc 插件,该字段已被弃用。

proc-macro

proc-macro 字段表示该库是一个过程宏(具体可参考自定义derive)。这仅对 [lib] 目标有效。

harness

harness 字段指示 --test 标志将传递给 rustc ,它将自动包含 libtest 库,该库是用于收集和运行标有#[test]属性或带有 #[bench] 属性的目标。所有目标的默认值为 true

如果设置为 false ,那么用户需要负责定义 main() 函数来运行测试和基准测试。

无论是否启用该字段,测试都会启用 cfg(test) 条件表达式

edition

edition 字段定义目标将使用的 Rust 版次。如果未指定,则默认为 [package]edition 字段。通常不应设置此字段,并且仅适用于高级场景,例如逐步将大型包过渡到新版本。

crate-type

crate-type 字段定义目标将生成的crate类型。它是一个字符串数组,允许你为单个目标指定多个crate类型。只能为库目标和示例目标指定该值。二进制目标、测试目标和基准测试目标始终是“bin”包类型。默认值如下:

目标包类型
正常的库目标“lib”
过程宏的库目标“proc-macro”
示例目标“bin”

可用选项有 binlibrlibdylibcdylibstaticlibproc-macro 。可以在 Rust 参考手册中阅读有关不同 crate 类型的更多信息。

required-features

required-features 字段指定构建目标所需的features列表。如果未启用任何必需的功能,则将跳过目标。该字段只对 [[bin]][[bench]][[test]][[example]] 有效,对于 [lib] 没有任何效果。

1
2
3
4
5
6
7
8
9
[features]
# ...
postgres = []
sqlite = []
tools = []

[[bin]]
name = "my-pg-tool"
required-features = ["postgres", "tools"]

目标自动发现

默认情况下,Cargo 根据文件的package结构自动确定要构建的目标,被称为目标自动发现。而上两节介绍的配置项,例如 [lib][[bin]][[test]][[bench]][[example]] ,用于手动修改配置目标。

可以禁用自动目标发现,以便仅构建手动配置的目标。将 [package]autotestsautobenches 设置为 false 将禁用相应目标类型的自动发现。

仅在特殊情况下才需要禁用自动发现。例如,如果你有一个库,你需要一个名为 bin 的模块,这会出现问题,因为 Cargo 通常会尝试将 bin 目录中的任何内容编译为可执行文件。以下是此场景的示例布局:

1
2
3
4
5
├── Cargo.toml
└── src
├── lib.rs
└── bin
└── mod.rs

要防止 Cargo 将 src/bin/mod.rs 推断为可执行文件,需要在 Cargo.toml 中设置 autobins = false 以禁用自动发现:

1
2
3
[package]
# …
autobins = false

对于 2015 版的项目,如果在 Cargo.toml 中手动定义了至少一个目标,则自动发现的默认值为 false 。从 2018 版开始,默认值始终为 true

4.6 工作空间

工作区(或工作空间)是一个或多个package(称为工作区成员)组成的集合,这些package被一起管理。

工作空间的特点如下:

  • 通用命令可以在所有工作空间成员上运行,例如 cargo check --workspace
  • 所有package项目共享一个位于工作区根目录中的通用 Cargo.lock 文件。
  • 所有package项目共享一个公共输出目录,默认为工作区根目录中名为 target 的目录。
  • 所有package项目共享元数据,例如 workspace.package
  • Cargo.toml 中的 [patch][replace][profile.*] 部分仅在根的清单中被识别,并在成员 crate 的清单中被忽略。

Cargo.toml 中, [workspace] 表包含以下部分:

  • [workspace] — 定义工作区。
    • resolver — 设置要使用的依赖解析器。==todo设置链接==
    • members — 要包含在工作区中的package。
    • exclude — 要从工作区中排除的package。
    • default-members — 未选择特定package时要操作的package。
    • package — 用于在package中继承的键。
    • dependencies — 用于在package依赖项中继承的键。
    • metadata — 外部工具的额外设置。
  • [patch] — 覆盖依赖项。
  • [replace] — 覆盖依赖项(已弃用)。
  • [profile] — 编译器设置和优化。

[workspace] 部分

要创建工作空间,首先需要将 [workspace] 添加到 Cargo.toml

1
2
[workspace]
# ...

工作区至少必须有一个成员,可以是根项目(Root package),也可以是虚拟工作空间(Virtual workspace)。

Root package 根项目

如果将 [workspace] 部分添加到已定义 [package]项的 Cargo.toml 中,则该package是工作区的root package。工作空间根目录是工作空间的 Cargo.toml 所在的目录。示例:

1
2
3
4
5
6
[workspace]

[package]
name = "hello_world" # the name of the package
version = "0.1.0" # the current version, obeying semver
authors = ["Alice <a@example.com>", "Bob <b@example.com>"]
Virtual workspace 虚拟工作空间

如果创建带有 [workspace] 部分但没有 [package] 部分的 Cargo.toml 文件,这称为虚拟工作清单。对于没有主 package 的场景或你希望将所有的 package 组织在单独的目录中时,这种方式就非常适合。比如:

根项目的定义:

1
2
3
4
# [PROJECT_DIR]/Cargo.toml
[workspace]
members = ["hello_world"]
resolver = "2"

定义了一个工作空间,在它的下面有hello_world子项目:

1
2
3
4
5
6
# [PROJECT_DIR]/hello_world/Cargo.toml
[package]
name = "hello_world" # the name of the package
version = "0.1.0" # the current version, obeying semver
edition = "2021" # the edition, will have no effect on a resolver used in the workspace
authors = ["Alice <a@example.com>", "Bob <b@example.com>"]

请注意,在虚拟清单中,应手动指定 resolver = "2" 。它通常是从虚拟清单中不存在的 package.edition 字段推断出来的,并且成员的版本字段不会影响工作区使用的解析器。

members 和 exclude 字段

membersexclude 字段定义哪些包是工作区的成员:

1
2
3
[workspace]
members = ["member1", "path/to/member2", "crates/*"]
exclude = ["crates/foo", "path/to/other"]

在工作区目录中的所有path 依赖项 都会自动成为成员。可以使用 members 键列出其他成员,该键应该是包含带有 Cargo.toml 文件的目录的字符串数组。

members 列表还支持使用 *? 等典型文件名 glob 模式来匹配多个路径。

exclude 键可用于阻止路径包含在工作空间中。如果根本不希望某些路径依赖项出现在工作区中,或者使用 glob 模式并且你想要删除目录,这可能会很有用。

当位于工作区的子目录中时,Cargo 将自动在父目录中搜索具有 [workspace] 定义的 Cargo.toml 文件,以确定要使用的工作区。 package.workspace 清单键可以在成员中使用,以手动设置指向工作区的根目录以覆盖此自动搜索。如果成员不在工作区根目录的子目录中,则手动设置可能很有用。

Package selection

在工作区中,与package相关的 Cargo 命令(例如 cargo build )可以使用 -p / --package--workspace 命令行标志来确定要操作哪个package。如果这两个标志都没有指定,Cargo 将使用当前工作目录中的package。如果当前目录是虚拟工作空间,它将应用于所有成员(就像在命令行中指定 --workspace 一样)。另外可请参考default-members

default-members 字段

可以指定可选的 default-members 键来设置在工作区根目录中操作的成员并且不使用命令行标志:

1
2
3
[workspace]
members = ["path/to/member1", "path/to/member2", "path/to/member3/*"]
default-members = ["path/to/member2", "path/to/member3/foo"]

指定后, default-members 必须扩展为 members 的子集。

package 表

workspace.package 表是你定义可由工作区成员继承的键的位置。这些键可以通过在成员package中使用 {key}.workspace = true 定义来继承。

支持的键如下:

支持的键支持的键
authorscategories
descriptiondocumentation
editionexclude
homepageinclude
keywordslicense
license-filepublish
readmerepository
rust-versionversion

其中:

  • license-filereadme 相对于工作空间根目录
  • includeexclude 相对于你的package根目录

例子:

1
2
3
4
5
6
7
8
9
# [PROJECT_DIR]/Cargo.toml
[workspace]
members = ["bar"]

[workspace.package]
version = "1.2.3"
authors = ["Nice Folks"]
description = "A short description of my package"
documentation = "https://example.com/bar"
1
2
3
4
5
6
7
# [PROJECT_DIR]/bar/Cargo.toml
[package]
name = "bar"
version.workspace = true
authors.workspace = true
description.workspace = true
documentation.workspace = true

dependencies 表

workspace.dependencies 表是你定义要由工作区成员继承的依赖项的位置。

指定工作区依赖关系与项目依赖关系类似,除了:

  • 此表中的依赖项不能声明为 optional
  • 此表中声明的 features[dependencies] 中的 features 相加

然后,你可以将工作区依赖项继承为项目依赖项

例子:

1
2
3
4
5
6
7
8
# [PROJECT_DIR]/Cargo.toml
[workspace]
members = ["bar"]

[workspace.dependencies]
cc = "1.0.73"
rand = "0.8.5"
regex = { version = "1.6.0", default-features = false, features = ["std"] }
1
2
3
4
5
6
7
8
9
10
11
12
13
# [PROJECT_DIR]/bar/Cargo.toml
[package]
name = "bar"
version = "0.2.0"

[dependencies]
regex = { workspace = true, features = ["unicode"] }

[build-dependencies]
cc.workspace = true

[dev-dependencies]
rand.workspace = true

metadata 表

workspace.metadata 表被 Cargo 忽略,并且不会发出警告。此部分可用于想要在 Cargo.toml 中存储工作区配置的工具。例如:

1
2
3
4
5
6
7
[workspace]
members = ["member1", "member2"]

[workspace.metadata.webcontents]
root = "path/to/webproject"
tool = ["npm", "run", "build"]
# ...

package.metadata 的包级别有一组类似的表。虽然 Cargo 没有指定这些表的内容格式,但建议外部工具可能希望以一致的方式使用它们,例如如果数据丢失,且这对于相关工具有意义的话,则引用 workspace.metadata 中的数据来自 package.metadata

4.7 Cargo Features

Cargo Features提供了一种表达条件编译和可选依赖项的机制。可以在 Cargo.toml[features] 表中定义一组命名功能,每个功能都可以启用或禁用。可以使用 --features 等标志在命令行上启用正在构建的项目的功能。可以在 Cargo.toml 中的依赖项声明中启用依赖项功能。

[features] 部分

功能(features)在 Cargo.toml[features] 表中定义。每个功能都指定一系列其他功能或它启用的可选依赖项。以下示例说明了如何将功能用于 2D 图像处理库,其中可以选择包含对不同图像格式的支持:

1
2
3
[features]
# 定义一个 feature : webp, 但它并没有启用其它 feature
webp = []

当定义了 webp 后,我们就可以在代码中通过 cfg 表达式来进行条件编译。例如项目中的 lib.rs 可以使用以下代码对 webp 模块进行条件引入:

1
2
#[cfg(feature = "webp")]
pub mod webp;

#[cfg(feature = "webp")] 的含义是:只有在 webp feature 被定义后,以下的 webp 模块才能被引入进来。由于我们之前在 [features] 里定义了 webp,因此以上代码的 webp 模块会被成功引入。

Cargo.toml 中定义的 feature 会被 Cargo 通过命令行参数 --cfg 传给 rustc,最终由后者完成编译:rustc --cfg ...。若项目中的代码想要测试 feature 是否存在,可以使用 cfg 属性cfg

功能可以列出要启用的其他功能。例如,ICO 图像格式可以包含 BMP 和 PNG 图像,因此当启用它时,应该确保也启用这些其他功能:

1
2
3
4
5
[features]
bmp = []
png = []
ico = ["bmp", "png"]
webp = []

功能名称可以包含 Unicode XID 标准中的字符(包括大多数字母),并且还允许以 _ 或数字 09 开头,以及在第一个字符还可能包含 -+. 。crates.io 对功能名称语法施加了额外的限制,即它们只能是 ASCII 字母数字字符或 _-+

default 功能

默认情况下,除非明确启用,否则所有功能均被禁用。这可以通过指定 default 功能来更改:

1
2
3
4
5
6
[features]
default = ["ico", "webp"]
bmp = []
png = []
ico = ["bmp", "png"]
webp = []

使用如上配置的项目被构建时,default feature 首先会被启用,然后它接着启用了 icowebp feature。可以通过以下方式更改此行为:

  • --no-default-features 命令行标志禁用包的默认功能。
  • default-features = false 选项可以在依赖声明中指定。

选择默认功能集时要小心。默认功能很方便,可以更轻松地使用package,而无需强迫用户仔细选择启用哪些功能以供常用,但也有一些缺点。除非指定 default-features = false ,否则依赖项会自动启用默认功能。这可能会导致难以确保默认功能未启用,尤其是对于在依赖关系图中多次出现的依赖关系。每个包必须确保指定 default-features = false 以避免启用它们。

可选依赖项

依赖项可以标记为“可选”,这意味着默认情况下不会编译它们。例如,假设我们的 2D 图像处理库使用外部包来处理 GIF 图像。这可以这样写:

1
2
[dependencies]
gif = { version = "0.11.1", optional = true }

默认情况下,此可选依赖项隐式定义如下所示的功能:

1
2
[features]
gif = ["dep:gif"]

这意味着仅当启用 gif 功能时才会包含此依赖项。代码中可以使用相同的 cfg(feature = "gif") 语法,并且可以像命令行选项 --features gif 等任何功能一样启用依赖项。

当然,我们还可以通过显式定义 feature 的方式来启用这些可选依赖库,例如为了支持 AVIF 图片格式,我们需要引入两个依赖包,由于 avif 是通过 feature 引入的可选格式,因此它依赖的两个包也必须声明为可选的:

1
2
3
4
5
6
[dependencies]
ravif = { version = "0.6.3", optional = true }
rgb = { version = "0.8.25", optional = true }

[features]
avif = ["ravif", "rgb"]

之后,avif feature 一旦被启用,那这两个依赖库也将自动被引入。

依赖库自身的 feature

可以在依赖项声明中启用依赖项的功能。 features 键指示要启用的功能:

1
2
[dependencies]
serde = { version = "1.0.118", features = ["derive"] }

以上配置为 serde 依赖开启了 derive feature,还可以通过 default-features = false 来禁用依赖库的 default feature :

1
2
[dependencies]
flate2 = { version = "1.0.3", default-features = false, features = ["zlib"] }

这里我们禁用了 flate2default feature,但又手动为它启用了 zlib feature。

这种方式可能无法确保禁用默认功能。如果另一个依赖项包含 flate2 而不指定 default-features = false ,则将启用默认功能。

除此之外,还能通过下面的方式来间接开启依赖库的 feature :

1
2
3
4
5
6
[dependencies]
jpeg-decoder = { version = "0.1.20", default-features = false }

[features]
# Enables parallel processing support by enabling the "rayon" feature of jpeg-decoder.
parallel = ["jpeg-decoder/rayon"]

如上所示,我们定义了一个 parallel feature,同时为其启用了 jpeg-decoder 依赖的 rayon feature。

"package-name/feature-name" 语法是可选依赖项,则它也会启用 package-name 。通常这不是你想要的。可以像 "package-name?/feature-name" 一样添加 ? ,只有在其他内容启用可选依赖项时才会启用给定功能。

例如,假设我们在库中添加了一些序列化支持,并且需要在一些可选依赖项中启用相应的功能。可以这样做:

1
2
3
4
5
6
[dependencies]
serde = { version = "1.0.133", optional = true }
rgb = { version = "0.8.25", optional = true }

[features]
serde = ["dep:serde", "rgb?/serde"]

在此示例中,启用 serde 功能将启用 serde 依赖项。它还将为 rgb 依赖项启用 serde 功能,但前提是有其他东西已启用 rgb 依赖项。

通过命令行参数启用 feature

以下命令行标志可用于控制启用哪些功能:

  • --features 功能:启用列出的功能。多个功能可以用逗号或空格分隔。如果使用空格,且从 shell 运行 Cargo(例如 --features "foo bar" ),请务必在所有功能周围使用引号。如果在工作区中构建多个项目,则可以使用 package-name/feature-name 语法来指定特定工作区成员的功能。
  • --all-features :激活在命令行上选择的所有包的所有功能。
  • --no-default-features :不激活所选软件包的 default 功能。

功能统一

功能对于定义它们的package来说是唯一的,不同package之间的 feature 允许同名。因此,在一个package上启用 feature 不会导致另一个package的同名 feature 被误启用。

当一个依赖项被多个package使用时,Cargo 在构建它时将使用该依赖项上启用的所有功能的联合。这有助于确保仅使用依赖项的单个副本。有关更多详细信息,请参阅这里

例如,让我们看一下使用大量功能的 winapi 包。如果您的包依赖于一个包 foo ,它启用了 winapi 的“fileapi”和“handleapi”功能,并且另一个依赖项 bar 启用了“std”和 winapi 的“winnt”功能,然后 winapi 将在启用所有四个功能的情况下构建。

winapi-features

这样做的结果是features是可加的。也就是说,启用一个 feature 不应该导致某个功能被禁止,并且启用任意功能组合通常应该是安全的。功能不应引入 SemVer 不兼容的更改。

例如,如果我们想可选地支持 no_std 环境(不使用标准库),那么有两种做法:

  • 默认代码使用标准库,当 no_std feature 启用时,禁用相关的标准库代码
  • 默认代码使用非标准库,当 std feature 启用时,才使用标准库的代码

前者就是功能削减,与之相对,后者是功能添加,根据之前的内容,我们应该选择后者的做法:

1
2
3
4
5
6
7
8
9
#![no_std]

#[cfg(feature = "std")]
extern crate std;

#[cfg(feature = "std")]
pub fn function_that_requires_std() {
// ...
}
彼此互斥的 feature

某极少数情况下,features 之间可能会互相不兼容。我们应该避免这种设计,因为如果一旦这么设计了,那你可能需要修改依赖图的很多地方才能避免两个不兼容 feature 的同时启用。

如果实在没有办法,可以考虑增加一个编译错误来让报错更清晰:

1
2
#[cfg(all(feature = "foo", feature = "bar"))]
compile_error!("feature \"foo\" and feature \"bar\" cannot be enabled at the same time");

不要使用互斥的功能,而是考虑其他一些选项:

  • 将功能拆分为单独的package。
  • 当存在冲突时,选择一项功能而不是另一项。 cfg-if可以帮助编写更复杂的 cfg 表达式。
  • 构建代码以允许同时启用这些功能,并使用运行时选项来控制使用哪些功能。例如,使用配置文件、命令行参数或环境变量来选择要启用的行为。
检查已解析的 features

在复杂的依赖关系图中,有时很难理解如何在不同的包上启用不同的功能。 cargo tree 命令提供了多个选项来帮助检查和可视化启用了哪些功能。下面一些可以尝试的选项:

  • cargo tree -e features :该命令以依赖图的方式来展示已启用的 features,包含了每个依赖包所启用的特性。
  • cargo tree -f "{p} {f}" :这是一个更紧凑的视图,显示每个包上启用的功能的逗号分隔列表。
  • cargo tree -e features -i foo :这将反转树,显示功能如何流入给定的包“foo”。这可能很有用,因为查看整个图表可能会非常大且令人难以承受。当您试图找出特定软件包上启用了哪些功能以及原因时,请使用此选项。请参阅 cargo tree 页面底部的示例了解更多。

feature解析器v2版本

可以使用 Cargo.toml 中的 resolver 字段指定不同的功能解析器,如下所示:

1
2
3
4
[package]
name = "my-package"
version = "1.0.0"
resolver = "2"

有关指定解析器版本的更多详细信息,请参阅解析器版本

简单来说,版本 "2" 解析器避免在某些情况下统一功能,而这种统一可能是不需要的。比如:

  • 为特定平台开启的 features 且此时并没有被构建,启用的功能将被忽略
  • build-dependenciesproc-macros 不再跟普通的依赖共享 features
  • dev-dependenciesfeatures 不会被启用,除非正在构建的对象需要它们(例如测试对象、示例对象等)

对于部分场景而言,feature 同一化确实是需要避免的,例如,一个构建依赖开启了 std feature,而同一个依赖又被用于 no_std 环境,很明显,开启 std 将导致错误的发生。

然而,缺点是这会增加构建时间,因为依赖关系被构建多次(每次都有不同的功能)。使用版本 "2" 解析器时,建议检查多次构建的依赖项,以减少总体构建时间。如果不需要构建具有单独功能的重复包,请考虑将功能添加到依赖项声明中的 features 列表中,以便重复项最终具有相同的功能(因此 Cargo 将仅构建一次)。可以使用 cargo tree --duplicates 命令检测这些重复的依赖项。它将显示哪些包被多次构建;查找列出的任何具有相同版本的条目。有关获取已解析功能信息的更多信息,请参阅检查已解析功能。对于构建依赖项,如果使用 --target 标志进行交叉编译,则不需要这样做,因为构建依赖项始终与该场景中的正常依赖项分开构建。

构建脚本

构建脚本可以通过检查 CARGO_FEATURE_<name> 环境变量来检测包上启用了哪些功能,其中 <name> 是转换为大写的功能名称,- 被转换为 _

required features

如果未启用某个功能, required-features 字段可用于禁用特定的 Cargo 目标

语义版本兼容性

启用功能不应引入 SemVer 不兼容的更改。例如,该功能不应以可能破坏现有用途的方式更改现有 API。

添加和删除功能定义和可选依赖项时应小心,因为这些有时可能是向后不兼容的更改。简而言之,请遵循以下规则:

feature 文档和发现

我们鼓励你记录你的软件包中提供了哪些功能。这可以通过在 lib.rs 顶部添加文档注释来完成。作为示例,请参阅 regex crate,呈现后的效果可以在 docs.rs 上查看。如果你有其他文档(例如用户指南),请考虑在其中添加文档(例如,请参阅 serde.rs)。如果你有二进制项目,请考虑在自述文件或项目的其他文档中记录功能(例如,请参阅 sccache)。

特别是对于不稳定的或者不该再被使用的 feature 而言,它们应该被放在文档中进行清晰的说明。

在 docs.rs 上发布的文档可以使用 Cargo.toml 中的元数据来控制构建文档时启用哪些功能。有关更多详细信息,请参阅 docs.rs 元数据文档

Rustdoc 对文档进行注释提供实验性支持,以指示使用某些 API 需要哪些功能。有关更多详细信息,请参阅 doc_cfg 文档syn 文档就是一个例子,你可以在其中看到彩色框,其中注明了使用它需要哪些功能。

如何寻找定义的features

若依赖库的文档中对其使用的 features 做了详细描述,那你会更容易知道他们使用了哪些 features 以及该如何使用。

当依赖库的文档没有相关信息时,你也可以通过源码仓库的 Cargo.toml 文件来获取,但是有些时候,使用这种方式来跟踪并获取全部相关的信息是相当困难的。

更多feature使用示例

对于更多feature使用示例,参考这里

环境变量

package ID

发布配置

注册服务

FAQ

为什么版本控制中二进制文件有 Cargo.lock ,但库文件没有?