Rust学习笔记5 - 模块管理

本文是 Rust 学习笔记系列第五篇,参考 Rust 指南的第 7 章,涉及使用包、箱子、模块来管理项目。

在编写大项目时,很难将整个项目完整的装到脑子里,所以如何组织代码就很重要了。通过合理聚集相关功能、拆分不同功能的代码,可以让我们能够定位特定功能的代码的位置。

到目前为止,我们都是在一个文件里写的示例。随着项目变大,就需要将代码拆分到不同的模块、不同的文件中去。一个包(package)可以包含多个可执行箱子(binary crates)和最多一个库箱子(library crate)。随着包变大,可以将一部分提取到单独的箱子中(会成为外部依赖)。对于多个不相关的包构成的非常大的项目,cargo 提供了工作空间(workspace)。

除了聚集和拆分功能,封装具体实现可以让我们在更高的层次复用代码:一旦实现了一个功能,其他的代码可以通过公开的接口来使用该功能,而不需要关心具体实现。公开的接口与私有的实现可以通过代码来自己决定。

另一个相关的概念是「作用域」:在代码所在的嵌套上下文中,有一系列的名称是「在作用域内」的。在编写、阅读或者编译代码时,开发者与编译器需要知道特定位置的特定名称是指代的什么项目(item):变量、函数、结构体、枚举、模块、常量还是什么其他的。同一个作用域中,不能有多个项目拥有相同的名称。

Rust 提供了多种特性用于管理代码结构,包括哪些细节要暴露,哪些细节要隐藏,以及每个作用域都有哪些名称。这些特性有时候被统称为「模块系统」:

  • 包(Packages):Cargo 的特性,用于构建、测试、共享箱子
  • 箱子(Crates):一个模块树,用于生成一个库或者可执行文件
  • 模块(Modules)与使用(use):允许控制路径的组织、作用域、可见性
  • 路径(Paths):一种命名项目(item)的方式,比如结构体、函数或者模块

包和箱子

首先我们介绍包(Package)和箱子(Crate),箱子代表一个库或者可执行文件。箱子的根是一个源文件,Rust 编译器从这个文件开始编译,并构建箱子的根模块(模块会在稍微后面一点介绍)。包由一个或者多个箱子组成。包里会有一个 Cargo.toml 文件用于描述如何构建这些箱子。

包的内容物由多个规则决定:包里面最多只能有一个库箱子,包里面可以有任意多个可执行箱子,包里面至少要有一个箱子(可以是库箱子或者可执行箱子)。

通过 cargo new <name> 命令可以创建新的包。我们实际执行一下看看会发生什么:它会创建一个文件夹,并在里面创建一些文件。

在创建的文件中,我们先介绍这两个:

1
2
Cargo.toml
src/main.ts

Cargo 会创建一个 Cargo.toml 文件,表示一个包。Cargo.toml 里面并没有提到 src/main.ts 文件,这是因为 Cargo 约定 src/main.rs 是与包同名的可执行箱子的根源文件。与之类似,Cargo 约定,如果包里面存在 src/lib.ts,那包里就存在一个与包同名的库箱子,且 src/lib.ts 就是这个箱子的根源文件。Cargo 会把这些根源文件传给 rustc 来构建库或者可执行文件。

这个例子里,我们只有 src/main.ts 文件。如果包里同时存在 src/main.tssrc/lib.ts,那这个包里就同时存在与包同名的可执行箱子与库箱子。通过在 src/bin/ 目录下添加源文件,可以增加新的二进制箱子,每个文件都构成一个可执行箱子。

箱子可以聚集相关的功能到一个作用域里,以便可以在多个项目中共享。通过把箱子引入到作用域,我们就可以使用该箱子的功能了。通过箱子来封装功能代码可以避免命名冲突。因为不同箱子之间不属于同一个作用域,相同的命名不会产生冲突。

说完包和箱子,我们再介绍一下模块和模块系统中的其他部分,“路径”(paths)可以为项目(item)命名、use 关键字可以将路径引入作用域、pub 关键字用于将项目公开化、以及 as 关键字、外部包等。

首先先介绍模块。

模块,控制作用域和可见性

模块(Module)可以将箱子内部的代码分成多个部分,提供可读性与易用性。模块也可以控制其中的项目的可见性(privacy),让其是公开的(在外部也可以使用)或者私有的(内部实现细节,外部不可见)。

作为例子,我们写一个提供餐厅的功能的库箱子。我们先定义好函数的签名,先不写实现。

在餐厅里,顾客所在的区域称为前台,在这里领位带领顾客入座,服务员为顾客点单和收银,酒保制作饮品。后台是大厨、厨师、洗碗工、经理们工作的地方。

我们可以用 cargo new --lib restaurant 创建一个包含库箱子的包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 餐厅前台
mod front_of_house {
// 领位
mod hosting {
// 让顾客排队
fn add_to_waitlist() {}
// 让顾客入座
fn seat_at_table() {}
}
// 服务员
mod serving {
// 点单
fn take_order() {}
// 服务
fn serve_order() {}
// 收银
fn take_payment() {}
}
}

这段代码可表示下面的模块结构:

1
2
3
4
5
6
7
8
9
crate:
front_of_house:
hosting:
add_to_waitlist: fn
seat_at_table: fn
serving:
take_order: fn
serve_order: fn
take_payment: fn

模块树中包含一些嵌套关系、一些邻接关系。如果模块 A 包含在模块 B 中,我们就称模块 A 是模块 B 的子模块,模块 B 是模块 A 的父模块。整个模块树的根节点是一个名为 crate 的隐藏模块。

路径,引用模块树中的项目

Rust 为了在模块树中找到项目,使用了与文件系统类似的路径(path)。

路径有两种形式:

  • 绝对路径:从箱子的根开始,以箱子名或者字面量 crate 为前缀
  • 相对路径:从当前模块开始,以 selfsuper 或者当前模块中的标识符为前缀

不管是绝对路径还是相对路径,都用 :: 连接多个标识符。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 餐厅前台
mod front_of_house {
// 领位
mod hosting {
// 让顾客排队
fn add_to_waitlist() {}
}
}

// 根模块(crate模块)中定义的公开函数
pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();

// 相对路径
front_of_house::hosting::add_to_waitlist();
}

在这个例子中,虽然路径的引用是正确的,但是会因为 hosting 模块并非公开的而在编译时报错。

模块不仅仅用于组织代码,也用于定义 Rust 的可见性边界:模块中封装的实现并不能被外界所使用。所以想让一些项目私有的话,可以用一个模块把它装在里面。

Rust 中所有项目(items)的默认可见性都是私有的。父模块不能使用子模块中私有的项目,但是子模块可以使用任意父模块的私有项目。因为子模块对外隐藏细节,但是它仍然位于父模块的作用域内。

使用 pub 关键字来公开路径

我们需要让路径上的每个节点都是可见的,才能够最终访问到路径所指的项目:

  • front_of_house 本来就是对 eat_at_restaurant 可见的
  • hostingadd_to_waitlist 则需要使用 pub 关键字来将其变为公开的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 餐厅前台
mod front_of_house {
// 领位
pub mod hosting {
// 让顾客排队
pub fn add_to_waitlist() {}
}
}

// 根模块(crate模块)中定义的公开函数
pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();

// 相对路径
front_of_house::hosting::add_to_waitlist();
}

使用 super 关键字开始的路径

我们可以用 super 来从父模块开始构造路径,类似文件系统中的 .. 符号。

例如:

1
2
3
4
5
6
7
8
9
10
mod back_of_house {
fn fix_incorrect_order() {
cook_order();
super::serve_order();
}

fn cook_order() {}
}

fn serve_order() {}

使结构体和枚举公开化

我们也可以用 pub 将结构体和枚举变成公开的,但是这里会多一些细节。如果我们对结构体使用 pub 关键字,结构体本身会变为公开的,但是其字段仍然是私有的。我们可以根据需要对每个字段设置可见性。

例如:

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
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"),
}
}
}
}

pub fn eat_at_restaurant() {
let mut meal = back_of_house::Breakfast::summer("Rye");
meal.toast = String::from("Wheat");

// 这个会报错,因为 seasonal_fruit 在此处不可见
// meal.seasonal_fruit = String::from("blueberries");

println!("I'd like {} toast please", meal.toast);
}

与结构体不同,将枚举公开化的话,其所有的变种都会变为公开的,我们只需要在 enum 关键字前面加上 pub 关键字即可。

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

使用路径来指代项目看起来要写比较长的串,用起来会比较繁琐且臃肿。我们可以用 use 关键字来指代路径,并将其名称引入到当前作用域中,这样就不需要每次使用时都通过路径来访问了。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 餐厅前台
mod front_of_house {
// 领位
pub mod hosting {
// 让顾客排队
pub fn add_to_waitlist() {}
}
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
hosting::add_to_waitlist();
hosting::add_to_waitlist();
}

use 关键字就有点像文件系统中的软链接。

使用 use 关键字的语言习惯

下面这串代码与上面的功能完全一致,但是我们会习惯用上面的那一种。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 餐厅前台
mod front_of_house {
// 领位
pub mod hosting {
// 让顾客排队
pub fn add_to_waitlist() {}
}
}

use crate::front_of_house::hosting::add_to_waitlist;

pub fn eat_at_restaurant() {
add_to_waitlist();
add_to_waitlist();
add_to_waitlist();
}

引入函数时,我们习惯只引入目标函数的路径父节点,这样我们在使用时就需要从其父节点开始写,与那些本就定义在当前作用域中的项目区分开来。而引入结构体、枚举等其他项目时,我们习惯直接引入目标路径。这个习惯并没有什么特别的原因,仅仅因为开发者习惯阅读这样的代码。

有一个例外情况是,如果我们要引入多个同名的项目,我们就都引入其父节点。

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

对于上面引入同名项目的问题,有另一个解决方法就是将其中一个在当前作用域中重命名,我们使用 as 关键字为其指定新的名称:

1
2
3
4
5
use std::fmt::Result;
use std::io::Result as IoResult;

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

使用 pub use 将名称重新导出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}

// hosting 也将作为 crate 的公开成员导出
pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
hosting::add_to_waitlist();
hosting::add_to_waitlist();
}

使用外部包

我们可以在 Cargo.toml 中添加一个依赖来引入外部的包:

1
2
[dependencies]
rand = "0.5.5"

这样会让 Cargo 从 crates.io 下载 rand 包和其所有的依赖,并让 rand 在项目中可用。

1
2
3
4
5
6
// 以 rand 开始的路径可以引用其包中的内容
use rand::Rng;

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

标准库 std 也是一个包外部的箱子,因为其内置于 Rust 语言,不需要在 Cargo.toml 中添加依赖。

使用嵌套路径来组合多个 use 语句

1
2
3
4
5
use std::cmp::Ordering;
use std::io;

// 可以合并为
use std::{cmp::Ordering, io};

在嵌套路径中可以用 self 表示当前的路径:

1
2
3
4
5
use std::io;
use std::io::Write;

// 合并为
use std::io::{self, Write};

使用 * 引入

如果我们要引入一个路径中的所有公开项目,我们可以在最后一节使用 * 来指代所有项目:

1
use std::collections::*;

将模块拆分到多个文件中

当模块变得很多时,需要将其代码写到单独的文件中,以便查找。

我们将 front_of_house 模块的代码移到 src/front_of_house.rs 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/front_of_house.rs

// 领位
pub mod hosting {
// 让顾客排队
pub fn add_to_waitlist() {}
// 让顾客入座
fn seat_at_table() {}
}

// 服务员
mod serving {
// 点单
fn take_order() {}
// 服务
fn serve_order() {}
// 收银
fn take_payment() {}
}

然后将 src/lib.rs 的内容改为:

1
2
3
4
5
6
7
8
9
10
// src/lib.rs
mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
hosting::add_to_waitlist();
hosting::add_to_waitlist();
}

mod <name> 后面接 ; 会让 Rust 从同名的文件中加载该模块的内容。

我们再创建一个 src/front_of_house/hosting.rs 文件,把 hosting 模块的代码移进去:

1
2
3
4
5
6
// src/front_of_house/hosting.rs

// 让顾客排队
pub fn add_to_waitlist() {}
// 让顾客入座
fn seat_at_table() {}

然后修改 src/front_of_house.rs 的内容:

1
2
3
4
// src/front_of_house.rs

// 领位
pub mod hosting;

可以看到文件结构与模块树是一致的。

总结

Rust 的模块系统可以让你用多个箱子与模块等来组织包里的代码。所有的内容都是默认私有的,对当前模块外部是不可见的。

# Rust
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×