rust 快速入门——5 项目源码组织

378 阅读32分钟

项目源码组织

普若哥们儿

github.com/wu-hongbing…

gitee.com/wuhongbing/…

项目源码组织

这一章较为复杂,建议参考完整的示例代码来阅读。

开发项目往往涉及到多个源程序文件,都离不开对源码的组织,需要对源码的组织有一个约定。

Cargo 是 Rust 的构建工具和包管理器,它对 Rust 项目源码的组织有专门的约定。

  • 项目Package):Cargo 用来组织、构建、测试和分享包 Crate。
  • Crate) :由多个模块组成的树形结构构成的逻辑单元注意是模块树,不是承载模块的文件树,因为一个文件可以包含多个模块Crate 被编译为库文件(lib)或可执行文件(bin),是 Rust 的最小编译单元。
  • 模块Module):一组用模块名包裹的程序资源,比如函数、trait、常量、复合数据类型等,一个文件中可以定义多个模块,也可以一个文件一个模块,但是同一个模块不能分布在多个文件中。
  • 路径Path):一个命名例如结构体、函数或模块等项的方式。
  • 工作空间WorkSpace):对于大型项目,多个项目组成工作空间。

Rust 组织的术语总是有些标新立异,Package 更类似其它语言中的 ProjectCrate 类似其它语言中的 Packege,因此采用了上述翻译。下文直接使用英文来表达。

Crate 和 Package

crate 是 Rust 在编译时最小的代码单位。前文最简单的 hello_world.rs 就是一个 crate。

crate 有两种类型:bin cratelib crate

  • bin crate 被编译为可执行程序,它们必须有一个 main 函数。
  • lib crate 没有 main 函数,它们提供一些诸如函数之类的东西,供其它 crate 使用。

crate root 是一个源文件,Rust 编译器以它为起始点,并构成 crate 的根文件,编译器由根文件开始搜索组成 crate 的部件。

package 是提供一系列功能的一个或者多个 crate。一个 package 还会包含一个 Cargo.toml 文件,描述如何去构建这些 crate

package 中可以包含至多一个 lib crate任意多个 bin crate,但是必须至少包含一个 crate,无论是 lib crate 还是 bin crate

bin crate

输入命令 cargo new

$ cargo new my_project
     Created binary (application) `my_project` package

得到:

my_project
│   Cargo.toml
└───src
        main.rs

Cargo 会给我们的 package 创建一个包含 Cargo.toml 文件的的新目录,Cargo.toml 包含以下内容:

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

my_project 目录中运行 cargo build,生成 my_project.exe

查看 Cargo.toml 的内容,package 名为 my_project,其中并没有提到 src/main.rs 作为程序的入口,原因是 Cargo 遵循一个约定:src/main.rs 是 bin crate 的根文件,该 bin crate 的名称与所属 package 相同,在这里都是 my_project ,代码执行从该文件中的 fn main() 函数开始。

lib crate

输入命令 cargo new

$ cargo new my_lib --lib
     Created library `my_lib` package

得到:

my_lib
│   Cargo.toml
└───src
        lib.rs

my_lib 目录中运行 cargo build,生成 libmy_lib.rlib

同样的,Cargo 知道如果包目录中包含 src/lib.rs,则 package 带有与其同名的 lib crate,这里名称是 my_lib,且 src/lib.rs 是 crate 根,这也是默认的约定。crate 根文件将由 Cargo 传递给 rustc 来实际构建库或者可执行文件。

如果一个 package 同时含有 src/main.rs 和 src/lib.rs,则它有两个 crate:一个 bin crate 和一个 lib crate ,且名字都与 package 相同。通过将 *.rs 文件放在 src/bin 目录下,一个 package 可以拥有多个 bin crate:每个 src/bin 下的文件都会被编译成一个独立的 bin crate

看完上面,相信大家看出来为何 package 和 crate 容易被混淆了吧?因为用 cargo new 创建的 package 和它其中包含的 crate 是同名的!

不过,只要你牢记 package 是一个项目工程,而 crate 只是一个编译单元,基本上也就不会混淆这个两个概念了。

src/main.rs 或 src/lib.rs 是 crate 是不准确的,严格来说,crate 是逻辑上的概念,crate 所涉及的东西并不只在 src/main.rs 和 src/lib.rs 文件中,这两个文件只是 crate root 。关于根节点的作用参考后续关于模块的小节。

典型的 package 结构

一个真实项目中典型的 package,会包含多个 bin crate,这些 crate 文件被放在 src/bin 目录下,每一个文件都是独立的 bin crate,同时也会包含一个 lib cratelib crate 只能有一个,crate rootsrc/lib.rs

my_project
├── Cargo.toml
├── Cargo.lock
├── src
│   ├── main.rs
│   ├── lib.rs
│   └── bin
│       └── main1.rs
│       └── main2.rs
├── tests
│   └── some_integration_tests.rs
├── benches
│   └── simple_bench.rs
└── examples
    └── simple_example.rs

Cargo.toml 内容为:

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
  • 唯一 lib cratesrc/lib.rs,生成库文件 libmy_project.rlib
  • 默认 bin cratesrc/main.rs,编译后生成的可执行文件 my_project.exe,与 package 同名
  • 其余 bin cratesrc/bin/main1.rs 和 src/bin/main2.rs,它们会分别生成一个与源程序文件同名的二进制可执行文件—— main1.exemain2.exe
  • 集成测试文件:tests 目录下
  • 基准性能测试 benchmark 文件:benches 目录下
  • 项目示例:examples 目录下

这种目录结构基本上是 Rust 的标准目录结构。

==强调一下==:src/bin 目录下的每个源文件对应一个 bin crate ,文件中都必须有一个 main 函数,当然也可以有其它被 main 函数调用的子函数,当然更规范的做法是将子函数放在模块中。

如果希望将子函数分门别类第放在其它文件中,并不能像 C 语言那样简单地在 src 下创建目录和源文件,然后再通过 import 引入到 main 函数所在的文件中,正确的做法是创建模块,见下一节。

模块 Module 基本规则

先罗列一下基本规则,然后接着通过例子理解这些规则。

  • 从 crate 根节点开始: 当编译一个 crate, 编译器首先在 crate root 根文件中寻找需要被编译的代码,通常,对于一个 lib crate 而言是 src/lib.rs,对于一个 bin crate 而言是 src/main.rs
  • 声明模块: 在 crate root 根文件中,可以声明一个新模块;比如,用 mod parent_mod; 声明了一个叫做 parent_mod 的模块。编译器会在下列路径中寻找模块代码:
    • 内联在大括号中,当 mod parent_mod 后方不是一个分号而是一个大括号
    • 在文件 src/parent_mod.rs
    • 在文件 src/parent_mod/mod.rs
  • 声明子模块: 在除了 crate 根节点以外的其他文件中,可以定义子模块。比如,可能在 src/parent_mod.rs 中定义了 mod child_mod;。编译器会在以父模块命名的目录中寻找子模块代码:
    • 内联在大括号中,当 mod child_mod 后方不是一个分号而是一个大括号
    • 在文件 src/parent_mod/child_mod.rs
    • 在文件 src/parent_mod/child_mod/mod.rs

[!Note] 注意,模块的声明不在本级模块,而是父级模块!这与其它语言的思路有所不同,也是比较别扭的地方。

  • 模块中的代码路径: 一旦一个模块是 crate 的一部分,可以在隐私规则允许的前提下,从同一个 crate 内的任意地方,通过代码路径引用该模块的代码。比如,一个 child_mod 模块下的 child_fn 函数可以在 crate::parent_mod::child_mod::child_fn 被找到。
  • 私有 vs 公用: 一个模块里的代码默认对其父模块私有。为了使一个模块公用,应当在声明时使用 pub mod 替代 mod。为了使一个公用模块内部的成员公用,应当在声明前使用 pub
  • use 关键字: 在一个作用域内,use 关键字创建了一个成员的快捷方式,用来减少长路径的重复。在任何可以引用 crate::parent_mod::child_mod::child_fn 的作用域,你可以通过 use crate::parent_mod::child_mod::child_fn; 创建一个快捷方式,然后你就可以在作用域中只写 child_fn 来使用该类型。

理解模块树

模块 可以将一个 crate 中的代码进行分组,以提高可读性与重用性。因为一个模块中的代码默认是私有的,所以还可以利用模块控制项的 私有性。私有项是不可为外部使用的内在实现。我们也可以将模块和其中的项标记为公开的,这样,外部代码就可以使用并依赖与它们。

通过执行 cargo new --lib my_lib,来创建一个新的名为 my_lib 的库,然后在 src/lib.rs 中定义一些模块和函数。

mod parent_mod {
    mod child1_mod {
        fn child1_fn1() {}
        fn child1_fn2() {}
    }

    mod child2_mod {
        fn child2_fn1() {}
        fn child2_fn2() {}
        fn child2_fn3() {}
    }
}

以 mod 关键字为起始定义模块,指定模块的名字,本例中叫做 parent_mod,并且用花括号包围模块的主体。在模块内,还可以定义其他的模块,就像本例中的 child1_mod 和 child2_mod 模块。模块可以定义各种程序项,比如结构体、枚举、常量、特性、或者函数。

通过使用模块,可以将相关的程序项定义组织到一起,保持程序的结构性。

在前面我们提到了,src/main.rs 和 src/lib.rs 叫做 crate 根。之所以这样叫它们是因为这两个文件的内容都分别构成了该 crate 的模块树形结构的根,该结构被称为 模块树module tree),该模块树从逻辑上构成了 crate 。

上例模块树的结构为:

crate
 └── parent_mod
     ├── child1_mod
     │   ├── child1_fn1
     │   └── child1_fn2
     └── child2_mod
         ├── child2_fn1
         ├── child2_fn2
         └── child2_fn3

从上面模块树的结构上看,“crate” 才是这棵树的根,后面会看到,使用 user 关键字引入模块时形如:crate::parent_mod::child1_mod,也是从“crate” 标识符开始。感觉 Rust 组织定义术语时不是很严谨。

==强调一下==:

  • 这个树形结构构成了重要的逻辑概念—— crate ,树的枝叶并不一定与包含这些模块的文件一一对应!
  • 同时也应理解,说 src/main.rs 或 src/lib.rs 文件是一个 crate 是不严谨的,它们只是 crate 根

这个树展示了一些模块是如何被嵌入到另一个模块的(例如,child1_mod 嵌套在 parent_mod 中)。这个树还展示了一些模块是互为 兄弟siblings)的,这意味着它们定义在同一模块中(child1_mod 和 child2_mod 被一起定义在 parent_mod 中)。继续沿用家庭关系的比喻,如果一个模块 A 被包含在模块 B 中,我们将模块 A 称为模块 B 的 child),模块 B 则是模块 A 的 parent)。注意,整个模块树都植根于名为 crate 的隐式模块下。

这个模块树可能会令你想起电脑上文件系统的目录树;这是一个非常恰当的类比!就像文件系统的目录,你可以使用模块来组织你的代码。并且,就像目录中的文件,我们需要一种方法来找到模块。

[!note] 忍不住吐槽一下,rust 程序组织的模型和概念术语非常别扭,远不如 java 的中的包管理中的概念直观清晰。

将模块拆分成多个文件

当模块变得更大时,可能想要将它们的定义移动到单独的文件中,从而使代码更容易阅读。

我们会将模块在各自的文件中定义,而不是将所有模块都定义到 crate 根文件中。

Rust 模块树的构建规则是:

  • main.rslib.rs 为模块树的根;
  • 模块在上一级模块中使用 mod xxx 语句声明,xxx 为模块名,模块的实现代码在与模块名同名的文件中;
  • 子模块的实现文件统一放在与父模块名同名的文件夹下;

Rust 设计这样的规则的目的是为了便于从 main.rslib.rs 根据声明的模块名称定位到模块的实现文件,从而构建模块树。

[!Note] 这与其它语言的思路有所不同,也是比较别扭的地方。比如 Java 语言,包(类似于 Rust 的模块)的声明语句在包实现文件的前部,通过扫描源程序中的所有文件,同样能够构建出包的树形结构。

Rust 构建模块树的规则由下图所示。

2484cca3b219ebb67466f8e81913a26f.svg

图:模块树的规则构建规则

用模块组织 bin crate 中的函数

这里创建一个名为 my_projectbin crate 来说明模块树的构建规则,lib crate与此完全类似。该 crate 的路径同样命名为 my_project,该路径包含了这些文件和目录:

my_project
├── Cargo.toml
└── src
    ├── parent_mod
    │   └── child_mod.rs
    ├── parent_mod.rs
    └── main.rs

示例中 parent_modchild_mod 名称可以是任意的,之所以这样取名,一是为了表示与模块相关,二是为了清晰地反映层次关系。

这个例子中的 crate 根文件是 src/main.rs

use crate::parent_mod::child_mod::child_fn;

pub mod parent_mod;

fn main() {
    let a=child_fn();
    println!("I'm {}!", a);
}

第 1 行,use crate::parent_mod::child_mod::child_fn; 告诉编译器引入当前 crate 指定路径的模块中的 child_fn 函数,这样 child_fn 函数就可以在第 6 行直接使用。

第 3 行 pub mod parent_mod; 声明了一个模块,告诉编译器应该包含/引入在 src/parent_mod.rs 文件中发现的代码,可以看出,这个文件的文件名与模块名同名,文件位于当前文件 main.rs 同级目录中!文件内容为:

pub mod child_mod;

在此处, pub mod child_mod; 意味着在 src/parent_mod/child_mod.rs 中的代码也应该被包括/引入,可以看出,这个文件的文件名与模块名同名,文件位于当前文件 src/parent_mod.rs 同名的目录 src/parent_mod!,模块代码是:

pub fn child_fn() -> i32 {
    return 5;
}

可以看到 Rust 如何从源码中梳理出 crate 编译单元的:首先,模块的声明在上一级模块中,实现模块功能文件名与模块名相同。默认 src/main.rscrate 根 ,其中的 pub mod parent_mod; 声明了一个名为 parent_mod 的模块,根据模块名 parent_mod 找到文件 src/parent_mod.rs,同样根据 pub mod child_mod; 语句找到 src/parent_mod/child_mod.rs ,从而收集到了这个 crate 的全部模块。

可以看出,Rust 从 crate 根 文件开始,根据文件内容 pub mod xxx; 语句为线索,而该语句根据约定隐含地指出了文件位置和文件名,从而找到文件。由此,rust 既知道该 crate 涉及哪些文件,进而知道涉及哪些模块。

用模块组织 lib crate 中的函数

前面提到过 package 可以同时包含一个 src/main.rs bin crate 根和一个 src/lib.rs lib crate 根,并且这两个 crate 默认以 package 名来命名。通常, bin crate 调用 lib crate 中的代码。因为 lib crate 可以共享,这使得其它项目也能共享 package 中的功能。

将公用 API 封装到 lib crate 中,这样通过以 package 名开头的路径,公有项就可以在 bin crate 中使用。bin crate 就完全变成了同其它外部 crate 一样的 lib crate 的用户:它只能使用公有 API。这有助于你设计一个好的 API;你不仅仅是作者,也是用户!

这里创建一个名为 my_projectbin crate ,添加 src/lib.js 文件,于是我们在项目中又得到了一个 lib crate ,我们将上例中bin crate用到的函数都放到lib crate,然后在 main.rs 中调用,最终得到的目录结构为:

 my_project  
 ├── Cargo.toml  
 └── src  
     ├── parent_mod  
     │   └── child_mod.rs  
     ├── parent_mod.rs  
     ├── main.rs
     └── lib.rs

lib.rs 文件内容为:

mod parent_mod;
use crate::parent_mod::child1_mod;

pub fn parent_fn() {
    child1_mod::child1_fn1();
}

我们将 parent_fn 函数公开,为的是能够在 main.rs 中调用。

parent_mod.rs 文件:

pub mod child1_mod;

parent_mod\child1_mod.rs 文件:

pub fn child1_fn1() {
    println!("I am child1_fn1");
}

这样,lib crate 就完整了。

下面在 bin crate 中调用 parent_fn 函数:

use my_project::parent_fn;
fn main() {
    parent_fn();
}

另一种文件模块位置约定

目前为止我们介绍了 Rust 编译器所最常用的模块文件位置约定;不过一种更老的文件位置约定也仍然是支持的。

对于声明于 crate 根的 parent_mod 模块,编译器会在如下位置查找模块代码:

  • src/parent_mod.rs(我们所介绍的)
  • src/parent_mod/mod.rs(老风格,不过仍然支持)

对于 parent_mod 的子模块 child1_mod,编译器会在如下位置查找模块代码:

  • src/parent_mod/child1_mod.rs(我们所介绍的)
  • src/parent_mod/child1_mod/mod.rs(老风格,不过仍然支持)

如果你对同一模块同时使用这两种模块位置约定风格,会得到一个编译错误。在同一项目中的不同模块混用不同的风格是允许的,不过这会使他人感到疑惑。

使用 mod.rs 这一文件名的风格的主要缺点是会导致项目中出现很多 mod.rs 文件,当你在编辑器中同时打开它们时会感到疑惑。

我们将各个模块的代码移动到独立文件了,同时模块树依旧相同。child1_fn1 中的函数调用也无需修改继续保持有效,即便其定义存在于不同的文件中。这个技巧让你可以在模块代码增长时,将它们移动到新文件中。

注意, src/lib.rs 中的 pub use crate::parent_mod::child1_mod 语句也并未发生改变。use 也不会对哪些文件会被编译为 crate 的一部分有任何影响。mod 关键字声明了模块,而 Rust 会在与模块同名的文件中查找模块的代码。

模块的路径

来看一下 Rust 如何在模块树中找到一个项的位置,我们使用路径的方式,就像在文件系统使用路径一样。为了调用一个函数,需要知道它的路径。==强调一下:这里讲的路径是模块树中的路径,而不是源文件在文件系统中路径。==

路径有两种形式:

  • 绝对路径absolute path)是以 crate 根开头的全路径;
    • 对于外部 crate 的代码,是以 crate 名 开头的绝对路径,比如引入标准库中的模块中的程序项的形式为 use std::time::Duration
    • 对于当前项目 crate 的代码,则以标识符 crate 开头,比如 use crate::parent_mod::child1_mod
  • 相对路径relative path)以当前模块为基准(当前模块名不出现在路径中),以 self(表示当前模块,含义类似文件系统中的 .\)、super(表示父模块,含义类似文件系统中的 ..\)或定义在当前模块中的子模块名称标识符为开头。

绝对路径和相对路径都以 :: 作为分割符。

下面举例通过这两种路径来使用程序项。在 crate 根定义了一个新函数 parent_fn,并在其中展示调用 child1_fn1 函数的两种方法。

mod parent_mod {
    pub mod child1_mod {
        pub fn child1_fn1() {}
    }
}

pub fn parent_fn() {
    // 绝对路径
    crate::parent_mod::child1_mod::child1_fn1();

    // 相对路径
    parent_mod::child1_mod::child1_fn1();
}

模块树为:

crate
 └── parent_fn
 └── parent_mod
     └── child1_mod
         └── child1_fn1

第一种方式,我们在 parent_fn 中调用 child1_fn1 函数,使用的是绝对路径。child1_fn1 函数与 parent_fn 被定义在同一 crate 中,这意味着我们可以使用 crate 关键字为起始的绝对路径。

第二种方式,我们在 parent_fn 中调用 child1_fn1,使用的是相对路径。从模块树可以看出,当前模块为根模块,因此相对路径以 parent_mod 为起始。以模块名开头意味着该路径是相对路径。

使用 pub 关键字暴露路径

在 Rust 中,默认所有项(函数、方法、结构体、枚举、模块和常量)对父模块都是私有的。父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用它们父模块中的所有项,包括私有项。这是因为子模块封装并隐藏了它们的实现详情,但是子模块可以看到它们定义的上下文。

Rust 选择以这种方式来实现模块系统功能,默认隐藏内部实现细节。这样一来,可以更改内部代码而不会破坏外部代码。Rust 通过使用 pub 关键字来创建公共项,使子模块的内部部分暴露给上级模块。

修改上面的例子,去掉 pub 关键字:

mod parent_mod {
 mod child1_mod {
  fn child1_fn1() {}
    }
}

pub fn parent_fn(){
    // 绝对路径
    crate::parent_mod::child1_mod::child1_fn1();

    // 相对路径
    parent_mod::child1_mod::child1_fn1();
}

这会引起编译错误,注意第 2 行,编译器指出 child1_mod 模块是私有的。

在第 2 行加上 pub 关键,编译器仍然报错,指出 child1_fn1 是私有的。这表明使模块公有并不使其内容也是公有的。模块上的 pub 关键字只允许其父模块引用它,而不允许访问内部代码。因为模块是一个容器,只是将模块变为公有能做的其实并不太多;同时需要更深入地选择将一个或多个项变为公有。

私有性规则不但应用于模块,还应用于模块中的结构体、枚举、函数和方法等程序项。

继续将 pub 关键字放置在 child1_fn1 函数的定义之前,使其变成公有:

mod parent_mod {
    pub mod child1_mod {
        pub fn child1_fn1() {}
    }
}

pub fn parent_fn(){
    // 绝对路径
    crate::parent_mod::child1_mod::child1_fn1();

    // 相对路径
    parent_mod::child1_mod::child1_fn1();
}

现在代码可以编译通过了!

为了了解为何增加 pub 关键字后可以在 parent_fn 中调用模块路径中的函数与私有性规则有关,让我们看看绝对路径和相对路径。

第 9 行,使用绝对路径,从 crate 也就是 crate 根开始。crate 根中定义了 parent_mod 模块。虽然 parent_mod 模块不是公有的,但是因为 parent_fn 函数与 parent_mod 定义于同一模块中(即,parent_fn 和 parent_mod 是兄弟),因此可以从 parent_fn 中访问 parent_mod 标识符。接下来是使用 pub 标记的 child1_mod 模块。由于可以访问 child1_mod 的父模块,所以可以访问 child1_mod。最后,child1_fn1 函数被标记为 pub ,我们可以访问定义该函数的模块,所以这个函数调用是有效的!

第 12 行,使用相对路径,其逻辑与绝对路径相同,除了第一步:不同于从 crate 根开始,路径从 parent_mod 开始。parent_mod 模块与 parent_fn 定义于同一模块,所以从 parent_fn 中定义的该模块下的相对路径是有效的。接下来因为 child1_mod 和 child1_fn1 被标记为 pub,路径其余的部分也是有效的,因此函数调用也是有效的!

self 开始的相对路径

可以通过在路径的开头使用 self ,从当前模块开始构建相对路径。这类似以 .\ 为起始的一个文件系统路径。比如上例可以写为:

mod parent_mod {
    pub mod child1_mod {
        pub fn child1_fn1() {}
    }
}

pub fn parent_fn(){
    // 绝对路径
    crate::parent_mod::child1_mod::child1_fn1();

    // 相对路径
    self::parent_mod::child1_mod::child1_fn1()
}

super 开始的相对路径

可以通过在路径的开头使用 super ,从父模块开始构建相对路径,而不是从当前模块或者 crate 根开始。这类似以 ..\ 为起始的一个文件系统路径。使用 super 允许我们引用父模块中的已知项,这使得重新组织模块树变得更容易,父模块移动到模块树的其它位置时,这个相对路径仍然有效

下面的示例中,child_mod 模块中的定义的 child_fn 函数通过指定的 super 起始的 parent_fn 路径,来调用父模块中的 parent_fn 函数:

pub mod child_mod {
    fn child_fn1() {
        child_fn2();  // 同级中的函数
        super::parent_fn(); // 父模块中的函数
    }
    fn child_fn2() {}
}

fn parent_fn() {}

child_fn1 函数在 child_mod 模块中,所以我们可以使用 super 进入 child_mod 父模块,在这里,我们可以找到 parent_fn

创建公有的结构体和枚举

如果我们在一个结构体定义的前面使用了 pub ,这个结构体会变成公有的,但是这个结构体的字段仍然是私有的。我们可以根据情况决定每个字段是否公有。在下面的示例中,定义了一个公有结构体 child_mod:ChildStruct,其中有一个公有字段 x 和私有字段 y

mod child_mod {
    pub struct ChildStruct {
        pub x: i32,
        y: i32,
    }
    // 为结构体增加一个方法,创建一个结构体实例
    impl ChildStruct {
        pub fn init(x: i32) -> ChildStruct {
            ChildStruct { x, y: 2 }
        }
    }
}

fn parent_fn() {
    // 调用模块方法,创建一个结构体实例
    let mut child_struct = child_mod::ChildStruct::init(5);
    child_struct.x = 9;

    // 不允许访问私有字段
    // child_struct.y = 9;
}

因为 child_mod:ChildStruct 结构体的 x 字段是公有的,所以我们可以在 parent_fn 中使用点号来随意的读写 x 字段。注意,我们不能在 parent_fn 中使用 y 字段,因为 y 是私有的。尝试去除那一行修改 y 字段值的代码的注释,会引起编译错误。

还要注意一点,因为 child_mod:ChildStruct 具有私有字段,所以这个结构体需要提供一个公共的关联函数来构造 ChildStruct 的实例,这里我们命名为 init。如果 ChildStruct 没有这样的函数,我们将无法在 parent_fn 中创建 ChildStruct 实例,因为我们不能在 ChildStruct 中设置私有字段 y 的值。

与之相反,如果我们将枚举设为公有,则它的所有成员都将变为公有。我们只需要在 enum 关键字前面加上 pub,比如:

mod child_mod {
    pub enum ChildEnum {
        x,
        y,
    }
}

fn parent_fn() {
    // 调用模块方法,创建一个结构体实例
    let cx = child_mod::ChildEnum::x;
    let cy = child_mod::ChildEnum::y;
}

因为创建了名为 ChildEnum 的公有枚举,所以我们可以在 parent_fn 中使用 x 和 y 成员。

如果枚举成员不是公有的,那么枚举会显得用处不大;给枚举的所有成员挨个添加 pub 显得很麻烦,因此枚举成员默认就是公有的。结构体通常使用时,不必将它们的字段公有化,因此结构体遵循常规,内容全部是私有的,除非使用 pub 关键字。

使用 use 关键字将路径引入作用域

使用路径来调用函数显得冗长且重复,可以使用 use 关键字创建一个短路径,然后就可以在作用域中的任何地方使用这个更短的名字。

在下面的示例中,将 crate::parent_mod::child1_mod 模块引入了 parent_fn 函数所在的作用域,这样只需要指定 child1_mod::child1_fn1 即可在 parent_fn 中调用 child1_fn1 函数。

mod parent_mod {
    pub mod child1_mod {
        pub fn child1_fn1() {}
    }
}
use crate::parent_mod::child1_mod;

pub fn parent_fn() {
    child1_mod::child1_fn1();
}

在作用域中增加 use 和路径类似于在文件系统中创建软连接(符号连接,symbolic link)。通过在 crate 根增加 use crate::parent_mod::child1_mod,现在 child1_mod 在作用域中就是有效的名称了。通过 use 引入作用域的路径也会检查私有性,同其它路径一样。

注意 use 只能创建 use 所在的特定作用域内的短路径。下面的示例将 parent_fn 函数移动到了一个叫 customer 的子模块,这又是一个不同于 use 语句的作用域,所以函数体不能编译。

mod parent_mod {
    pub mod child1_mod {
        pub fn child1_fn1() {}
    }
}
use crate::parent_mod::child1_mod;

mod parent_mod2 {
    pub fn parent_fn() {
        child1_mod::child1_fn1();
    }
}

编译器错误显示短路径不再适用于 parent_mod2 模块中。为了修复这个问题,可以将 use 移动到 parent_mod2 模块内,或者在子模块 parent_mod2 内通过 super::child1_mod 引用父模块中的这个短路径。

use 路径的习惯用法

上例指定 use crate::parent_mod::child1_mod; ,然后在 parent_fn 中调用 child1_mod::child1_fn1 ,而不是通过指定一直到 child1_fn1 函数的 use 路径来得到相同的结果,比如:

mod parent_mod {
    pub mod child1_mod {
        pub fn child1_fn1() {}
    }
}
use crate::parent_mod::child1_mod::child1_fn1;

pub fn parent_fn() {
    child1_fn1();
}

使用 use 将 child1_fn1 函数引入作用域,这并不符合习惯。好的习惯是使用 use 将函数的父模块引入作用域,在调用函数时指定父模块,这样可以清晰地表明函数不是在本地定义的,同时使完整路径的重复度最小化。上例的代码不清楚 child1_fn1 是在哪里被定义的。

另一方面,使用 use 引入结构体、枚举和其他项时,习惯是指定它们的完整路径。下例展示了将 HashMap 结构体引入二进制 crate 作用域的习惯用法。

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}

这种习惯用法背后没有什么硬性要求:它只是一种惯例,人们已经习惯了以这种方式阅读和编写 Rust 代码。

这个习惯用法有一个例外,那就是我们想使用 use 语句将两个具有相同名称的项带入作用域,因为 Rust 不允许这样做。下例展示了如何将两个具有相同名称但不同父模块的 Result 类型引入作用域,以及如何引用它们。

use std::fmt;
use std::io;

fn function1() -> fmt::Result {
    // --snip--
}

fn function2() -> io::Result<()> {
    // --snip--
}

可见,使用父模块可以区分这两个 Result 类型。如果指定 use std::fmt::Result 和 use std::io::Result,将在同一作用域拥有了两个 Result 类型,当使用 Result 时,Rust 则不知道我们要用的是哪个。

使用 as 关键字提供新的名称

使用 use 将两个同名类型引入同一作用域这个问题还有另一个解决办法:在这个类型的路径后面,我们使用 as 指定一个新的本地名称或者别名。下例展示了通过 as 重命名其中一个 Result 类型。

use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
}

fn function2() -> IoResult<()> {
}

使用 pub use 重导出名称

使用 use 关键字,将某个名称导入当前作用域后,这个名称在此作用域中就可以使用了,但它对此作用域之外还是私有的。如果想让其他人调用我们的代码时,也能够使用这个名称,就好像它本来就在当前作用域一样,那我们可以将 pub 和 use 合起来使用,将名称重新导出。比如:

mod parent_mod {
    pub mod child1_mod {
        pub fn child1_fn1() {}
    }
}
pub use crate::parent_mod::child1_mod;

pub fn parent_fn() {
    child1_mod::child1_fn1();
}

在这个修改之前,外部代码需要使用路径 my_lib::parent_mod::child1_mod::child1_fn1() 来调用 child1_fn1 函数。现在这个 pub use 从根模块重导出了 child1_mod 模块,外部代码现在可以使用路径 my_lib::child1_mod::child1_fn1

当你代码的内部结构与调用你代码的程序员所想象的结构不同时,重导出会很有用。使用 pub use,我们可以使用一种结构编写代码,却将不同的结构形式暴露出来。这样做使我们的库井井有条,也使开发这个库的程序员和调用这个库的程序员都更加方便。

使用外部包

为了在项目中使用随机数包 rand,在 Cargo.toml 中加入了如下行:

rand = "0.8.5"

在 Cargo.toml 中加入 rand 依赖告诉了 Cargo 要从 crates.io 下载 rand 和其依赖,并使其可在项目代码中使用。

接着,为了将 rand 定义引入项目包的作用域,我们加入一行 use 起始的包名,它以 rand 包名开头并列出了需要引入作用域的项。

use rand::Rng;

fn main() {
    let secret_number = rand::thread_rng().gen_range(1..=100);
}

crates.io 上有很多 Rust 社区成员发布的包,将其引入你自己的项目都需要一道相同的步骤:在 Cargo.toml 列出它们并通过 use 将其中定义的项引入项目包的作用域中。

注意 std 标准库对于你的包来说也是外部 crate。因为标准库随 Rust 语言一同分发,无需修改 Cargo.toml 来引入 std,不过需要通过 use 将标准库中定义的项引入项目包的作用域中来引用它们,比如我们使用的 HashMap

use std::collections::HashMap;

这是一个以标准库 crate 名 std 开头的绝对路径。

嵌套路径来消除大量的 use 行

当需要引入很多定义于相同包或相同模块的项时,为每一项单独列出一行会占用源码很大的空间,比如:

use std::cmp::Ordering;
use std::io;

相反,可以使用嵌套路径将相同的项在一行中引入作用域。这需要指定路径的相同部分,接着是两个冒号,接着是大括号中的各自不同的路径部分,比如:

use std::{cmp::Ordering, io};

在较大的程序中,使用嵌套路径从相同包或模块中引入很多项,可以显著减少所需的独立 use 语句的数量!

我们可以在路径的任何层级使用嵌套路径,这在组合两个共享子路径的 use 语句时非常有用。下例展示了两个 use 语句:一个将 std::io 引入作用域,另一个将 std::io::Write 引入作用域:

use std::io;
use std::io::Write;

两个路径的相同部分是 std::io,这正是第一个路径。为了在一行 use 语句中引入这两个路径,可以在嵌套路径中使用 self

use std::io::{self, Write};

这一行便将 std::io 和 std::io::Write 同时引入作用域。

通过 glob 运算符将所有的公有定义引入作用域

如果希望将一个路径下 所有 公有项引入作用域,可以指定路径后跟 *,glob 运算符:

use std::collections::*;

这个 use 语句将 std::collections 中定义的所有公有项引入当前作用域。使用 glob 运算符时请多加小心!Glob 会使得我们难以推导作用域中有什么名称和它们是在何处定义的。

glob 运算符经常用于测试模块 tests 中,这时会将所有内容引入作用域。

WorkSpace

参考连接:Cargo Workspaces 完整代码示例:rust_workspaces

workspaces 把多个关联的项目放到同一个目录下;然而这与子项目的概念是不同的,大多数的实现主要用于一次构建多个平行、顶级的包作为独立模块,当然你用 workspaces 的时候定义一个对外暴露的主包,并用它来关联其他包也是可以的。

不过最重要的一个特征就是,在 workspaces 下每个包都应该是能够独立运行的个体,能够被本项目以外的项目引入并运行。

除了这些之外,cargo worksapces 还提出了其它约束,如相同的依赖包版本应该一致等,有兴趣可以看看官方介绍

接下来通过示例讲解 Cargo Workspaces。

项目结构

在开始之前我们先来看看最终产物的项目结构

rust_workspaces
├── Cargo.lock
├── Cargo.toml
└── crates
    ├── add-one
    │   ├── Cargo.toml
    │   └── src
    │       └── lib.rs
    ├── add-two
    │   ├── Cargo.toml
    │   └── src
    │       └── lib.rs
    └── adder
        ├── Cargo.toml
        └── src
            └── main.rs

本篇的最终产物会有三个包,分别是 adderadd-oneadd-two,其中 adder 又依赖了其余两个包;全部三个包都放到 crates 目录下,当然放在其它位置也可以,因为这些包都是通过 rust_workspaces\Cargo.toml 引入,引入路径和包的位置一致即可。

项目构建

接下来构建 cargo workspaces 多 crate 项目,实际上与原来用 cargo 的方式一模一样!

构建项目

首先把三个小项目都建起来

# 目录结构
mkdir rust_workspaces
cd rust_workspaces
mkdir crates
cd crates
# 构建三个包
cargo new adder --bin
cargo new add-one --lib
cargo new add-two --lib
根目录配置文件 Cargo. toml

我们知道一个由 Cargo 管理的 Rust 项目的配置文件是 Cargo.toml;一个 workspaces 项目也是一样的

  • 创建 workspaces 配置文件 rust_workspaces\Cargo.toml

这里与我们通常熟悉的部分就不同了,我们不需要填哪些正常项目或是包应该要填的东西,只需要一个 workspace 属性,声明当前 workspaces 有哪些项目就可以了!

  • /Cargo.toml
[workspace]
members = [
    "crates/adder",
    "crates/add-one",
    "crates/add-two",
]

可以看到实际上就是指定每一个 package 根目录就可以了,所以也可以将这三个 package 直接平铺在 workspace 根目录下,而不用放到 crates 目录下。

声明依赖

分别在每个 package 下自己的 Cargo.toml 定义好自己的依赖关系,比如 adder package 需要依赖另外两个包:

  • /crates/adder/Cargo.toml
[package]
name = "adder"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
add-one = { path = "../add-one" }
add-two = { path = "../add-two" }

编写代码

接下来编写各个项目的内容代码

  • /crates/add-one/src/lib.rs
pub fn add_one(x: i32) -> i32 {
    x + 1
}
  • /crates/add-two/src/lib.rs
pub fn add_two(x: i32) -> i32 {
    x + 2
}
  • /crates/adder/src/main.rs
use {add_one, add_two};

fn main() {
    let num = 10;
    println!("Hello, world! {} + 1 = {}", num, add_one::add_one(num));
    println!("Hello, world! {} + 2 = {}", num, add_two::add_two(num));
}

打包运行

只需要在根目录下进行打包,Cargo 就会自动识别,将最终产物统一构建在根目录的 /target 目录下,甚至我们进入到单个项目的根目录下运行相同指令也是一样的效果,不会产生多余的 target 目录

# 当前在根目录,也就是 rust_workspaces 目录下
pwd  # ~/rust_workspaces

cargo build  # 打包项目(可以省略)
cargo run --bin adder  # 指定运行 adder 项目(adder 是一个 bin 项目)
  • 最终结果
Hello, world! 10 + 1 = 11
Hello, world! 10 + 2 = 12