The Rust programming language 读书笔记——包(package)、单元包(crate)与模块系统

模块系统

在编写较为复杂的项目时,合理地对代码进行组织与管理非常重要。只有按照不同的特性来组织或分割相关功能的代码,才能够清晰地找到实现指定功能的代码片段,确定哪些地方需要修改。

除了对功能进行分组,对实现的细节进行封装可以使开发者在更高的层次上复用代码:一旦实现了某个功能,其他代码就可以通过公共接口调用这个操作,而无需了解具体的实现细节。

Rust 提供了一系列的功能来管理代码,包括决定哪些细节是暴露的,那些细节是私有的,以及不同的作用域内存在哪些名称。这些功能被统称为模块系统

  • 包(package):一个用于构建、测试并分享单元包的 Cargo 特性
  • 单元包(crate):一个用于生成库或可执行文件的树形模块结构
  • 模块(module)use 关键字:用于控制文件结构、作用域及路径的私有性
  • 路径(path):一种用于命名条目的方法,这些条目包括结构体、函数和模块等

包与单元包

当我们使用 cargo new 命令创建新项目时,如:
cargo new restaurant

Cargo 会自动创建如下结构的 Rust 项目:

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

Cargo 默认会将自动生成的 src/main.rs 源文件视作一个二进制单元包(crate)的根节点,与包(package)拥有相同的名称(即 restaurant)。
假设包的目录中包含文件 src/lib.rs,Cargo 也会自动将其视作与包同名的库单元包的根节点。
可以在路径 src/bin 下添加源文件来创建更多的二进制单元包,这些源文件都会被视作独立的二进制单元包。

自动生成的 src/main.rs 源文件内容如下:

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

我们可以创建一个 src/lib.rs 源文件,把上面的打印输出的操作作为公共函数定义在 lib.rs 中,再在 main.rs 中调用该公共函数,效果与之前是一致的。

src/lib.rs 代码:

1
2
3
pub fn greeting() {
println!("Hello, World!");
}

src/main.rs 代码:

1
2
3
fn main() {
restaurant::greeting();
}

因为 lib.rs 默认会作为一个与包同名(都叫 restaurant)的库单元包(crate)存在,且其中的 greeting 函数已被声明为公开的(pub),因此可以直接在 main.rs 中使用 restaurant::greeting() 调用 lib.rs 中定义的 greeting 函数。

使用 cargo run 命令运行项目后,target/debug 路径下除了像之前一样生成 restaurant 可执行文件外,还会额外生成 librestaurant.rlib 库文件。

单元包可以将相关的功能分组,并放到同一作用域下,这样便可以使这些功能轻松地在多个项目中共享。
将单元包的功能保留在它们自己的作用域中有助于指明某个特定功能来源于哪个单元包,并避免可能的命名冲突。
比如 rand 包提供了一个名为 Rng 的 trait,我们同样也可以在自己的单元包中定义一个名为 Rng 的结构体。正是由于这些功能被放置在了各自的作用域中,我们能够使用 rng::Rng 访问 rand 包中提供的 Rng trait,而 Rng 则指向刚刚创建的 Rng 结构体。

通过定义模块来控制作用域及私有性

假设我们需要编写一个提供就餐服务的库单元包。一个现实的店面常常会划分为前厅与后厨两个部分,前厅负责点单和结账等,后厨则负责制作料理。

为了按照餐厅的实际工作方式来组织单元包,可以将函数放置在嵌套的模块中。修改 src/lib.rs 源代码文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mod front_of_house {
mod hosting {
fn seat_at_table() {
println!("Seat at table.");
}
}
mod serving {
fn take_order() {
println!("Taking order.");
}
fn take_payment() {
println!("Taking payment.");
}
}
}

我们可以使用 mod 关键字来定义一个模块(如本例中的 front_of_house),模块内还可以继续定义其他模块(如本例中的 hostingserving)。模块内同样也可以包含其他条目的定义,如结构体、枚举、常量、trait 或函数等。

src/main.rssrc/lib.rs 被称作单元包(crate)的根节点,它们的内容各自组成了一个名为 crate 的模块。这个模块的结构也被称为模块树。
上面 src/lib.rs 形成的树状模块结构如下:

1
2
3
4
5
6
7
crate
└── front_of_house
├── hosting
│ └── seat_at_table
└── serving
├── take_order
└── take_payment

路径

类似于在文件系统中使用路径进行导航,在 Rust 的模块树中定位某个条目同样需要使用路径。

路径有两种形式:

  • 使用单元包名或字面量 crate 从根节点开始的绝对路径
  • 使用 selfsuper 或内部标识符从当前模块开始的相对路径

绝对路径与相对路径都至少由一个标识符组成,标识符之间使用双冒号(::)分隔。

现在尝试在模块外部调用模块中定义的函数。在 src/lib.rs 末尾添加一个公共函数 eat_at_restaurant,调用模块 front_of_house 中定义的函数:

1
2
3
4
5
6
pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::seat_at_table();
// 相对路径
front_of_house::serving::take_order();
}

修改 src/main.rs,在 main 函数中调用上一步中定义的公共函数:

1
2
3
fn main() {
restaurant::eat_at_restaurant();
}

尝试编译项目,会报出如下错误:

1
2
error[E0603]: module `hosting` is private
error[E0603]: module `serving` is private

即模块 hostingserving 是私有的,Rust 不允许我们访问。
Rust 中的模块不仅仅用于组织代码,同时也定义了私有边界:外部代码无法知晓、调用或依赖那些由私有边界封装了的实现细节。
Rust 中的所有条目(函数、方法、结构体、枚举、模块及常量)默认都是私有的,处于父级模块中的条目无法使用子模块中的私有条目,但子模块中的条目可以使用其祖先模块中的条目
Rust 希望默认隐藏内部的实现细节,这样用户就能明确地知道修改哪些内容不会破坏外部代码。

使用 pub 关键字暴露路径

可以使用 pub 关键字将某些条目标记为公共的,从而使子模块中的这些部分可以被暴露到祖先模块中。
接上面的例子,为了使父模块中的 eat_at_restaurant 函数能够正常访问子模块中定义的函数,可以使用 pub 关键字来标记 hostingserving 模块。
需要注意的是,模块被 pub 标记,其效果仅限于模块本身,并不会影响到它内部条目的状态,模块中的内容依旧是私有的。为了使前面的代码正常工作,还必须在需要公开的函数前面添加 pub 关键字。

编辑 src/lib.rs 中,内容改动如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mod front_of_house {
pub mod hosting {
pub fn seat_at_table() {
println!("Seat at table.");
}
}
pub mod serving {
pub fn take_order() {
println!("Taking order.");
}
pub fn take_payment() {
println!("Taking payment.");
}
}
}

pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::seat_at_table();
// 相对路径
front_of_house::serving::take_order();
}

此时程序即可以正常运行。

将结构体声明为公共的

当我们在结构体定义前使用 pub 关键字时,结构体本身就成为了公共结构体,但是它的字段依旧保持私有状态。
我们可以逐一决定是否将某个字段公开。

下面的代码定义了一个公共的 back_of_house::Breakfast 结构体,并令其 toast 字段公开,而 seasonal_fruit 字段保持私有。使得客户可以自行选择想要的面包,而只有厨师才能根据季节与存货决定配餐水果。

编辑 src/lib.rs 源文件,添加如下 back_of_house 模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mod back_of_house {
pub struct Breakfast {
pub toast: String,
seasonal_fruit: String,
}
impl Breakfast {
pub fn summer(toast: &str) -> Breakfast {
Breakfast {
toast: String::from(toast),
seasonal_fruit: String::from("peaches"),
}
}
}
}

为了测试新添加的代码能否正常工作,修改 src/lib.rs 中的 eat_at_restaurant 函数如下:

1
2
3
4
5
6
7
8
9
10
pub fn eat_at_restaurant() {
// 选择黑麦面包作为夏季早餐
let mut meal = back_of_house::Breakfast::summer("Rye");
// 修改我们想要的面包类型
meal.toast = String::from("Wheat");
println!("I'd like {} toast please", meal.toast);

// 接下来的这一行无法通过编译,我们不能看到或更换附带的季节性水果
// meal.seasonal_fruit = String::from("blueberries");
}

back_of_house::Breakfast 结构体中的 toast 字段是公共的,我们因此能够在 eat_at_restaurant 中使用点号读写 toast 字段。
同样由于 seasonal_fruit 字段是私有的,我们不能在 eat_at_restaurant 中访问它。
另外,由于 back_of_house::Breakfast 拥有一个私有字段,这个结构体必须提供一个公共的关联函数来构造 Breakfast 实例(本例中的 summer),否则我们将无法在结构体外部创建任何的 Breakfast 实例。

use 关键字将路径导入作用域

基于路径来调用函数的写法看上去会有些重复与冗长。无论我们使用绝对路径还是相对路径来指定 seat_at_table 函数,都必须在每次调用时指定路径上的 front_of_househosting 节点。
可以借助 use 关键字将路径引入作用域,简化上述步骤。如:

1
2
3
4
5
6
7
8
9
10
11
// src/lib.rs
// ...
// 绝对路径
use crate::front_of_house::hosting;
// 相对路径
use self::front_of_house::serving;

pub fn eat_at_restaurant() {
hosting::seat_at_table();
serving::take_order();
}

在作用域中使用 use 引入路径有点类似于在文件系统中创建符号链接。通过在单元包的根节点下添加上述两条 use 语句,hostingserving 成了该作用域下的一个有效名称,就如同这两个模块被定义在根节点下一样。

这里使用了 use crate::front_of_house::hosting 并接着调用 hosting::seat_at_table,而没有使用 use crate::front_of_house::hosting::seat_at_table 来直接引入 seat_at_table 函数。
相对而言,前者的方式更常用一些。使用 use 将函数的父模块引入作用域,意味着我们必须在调用函数时指定这个父模块,从而更清晰地表明当前函数没有被定义在当前作用域中。

不同于函数,使用 use 将结构体、枚举或其他条目引入作用域时,我们习惯于通过指定完整路径的方式引入。

使用 as 提供新的名称

使用 use 将多个同名类型引入作用域时,还可以在路径后使用 as 关键字为类型指定一个新的本地名称,也就是别名。如:

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

使用 pub use 重导出名称

当我们使用 use 关键字将名称引入作用域时,这个名称会以私有的方式在新的作用域中生效。为了让外部代码能够访问到这些名称,可以通过组合使用 pubuse 修饰其路径。
这项技术也被称作重导出。

比如使用 pub 修饰前面 src/lib.rs 中的某条 use 语句:

1
pub use crate::front_of_house::hosting;

于是在另一个文件 src/main.rs 中也就可以使用 restaurant::hosting::seat_at_table() 形式的代码调用 hosting 模块中的函数了。
通过使用 pub use,我们可以在编写代码时使用一种结构,在对外暴露时使用另外一种不同的结构。这一方法可以让我们的代码库对编写者和调用者同时保持良好的组织结构。

将模块拆分为不同的文件

当模块规模逐渐增大时,我们可以将它们的定义移动到新的文件中。
比如我们需要将 src/lib.rs 中定义的 front_of_house 模块移动到它自己的文件 src/front_of_house.rs 中。首先将根节点文件 lib.rs 中的代码改为如下版本:

1
2
3
4
5
6
7
8
9
mod front_of_house;

pub use self::front_of_house::serving;
pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::seat_at_table();
serving::take_order();
}

mod front_of_house 后使用分号而不是代码块,会让 Rust 前往与当前模块同名的文件中加载模块内容。因此可以将 front_of_house 模块的具体定义转移到 src/front_of_house.rs 文件中,效果是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/front_of_house.rs
pub mod hosting {
pub fn seat_at_table() {
println!("Seat at table.");
}
}
pub mod serving {
pub fn take_order() {
println!("Taking order.");
}
pub fn take_payment() {
println!("Taking payment.");
}
}

事实上还可以更进一步,继续拆解 front_of_house 模块到其他文件中。首先将 src/front_of_house.rs 文件的内容改为如下版本:

1
2
pub mod hosting;
pub mod serving;

接着创建一个 src/front_of_house 目录,以及一个 src/front_of_house/hosting.rs 文件用来存放 hosting 模块的定义,一个 src/front_of_house/serving.rs 文件存放 serving 模块的定义:

1
2
3
4
// src/front_of_house/hosting.rs
pub fn seat_at_table() {
println!("Seat at table.");
}

1
2
3
4
5
6
7
// src/front_of_house/serving.rs
pub fn take_order() {
println!("Taking order.");
}
pub fn take_payment() {
println!("Taking payment.");
}

最终效果与前两种版本也是一致的。
此时 restaurant 项目的目录结构如下:

1
2
3
4
5
6
7
8
9
10
restaurant
├── Cargo.lock
├── Cargo.toml
└── src
├── front_of_house
│   ├── hosting.rs
│   └── serving.rs
├── front_of_house.rs
├── lib.rs
└── main.rs

所有的修改都没有改变原有的模块树结构,尽管这些定义被放置到了不同的文件中,eat_at_restaurant 中的函数调用依旧有效。

参考资料

The Rust Programming Language