本文是 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 | Cargo.toml |
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.ts
和 src/lib.ts
,那这个包里就同时存在与包同名的可执行箱子与库箱子。通过在 src/bin/
目录下添加源文件,可以增加新的二进制箱子,每个文件都构成一个可执行箱子。
箱子可以聚集相关的功能到一个作用域里,以便可以在多个项目中共享。通过把箱子引入到作用域,我们就可以使用该箱子的功能了。通过箱子来封装功能代码可以避免命名冲突。因为不同箱子之间不属于同一个作用域,相同的命名不会产生冲突。
说完包和箱子,我们再介绍一下模块和模块系统中的其他部分,“路径”(paths)可以为项目(item)命名、use
关键字可以将路径引入作用域、pub
关键字用于将项目公开化、以及 as
关键字、外部包等。
首先先介绍模块。
模块,控制作用域和可见性
模块(Module)可以将箱子内部的代码分成多个部分,提供可读性与易用性。模块也可以控制其中的项目的可见性(privacy),让其是公开的(在外部也可以使用)或者私有的(内部实现细节,外部不可见)。
作为例子,我们写一个提供餐厅的功能的库箱子。我们先定义好函数的签名,先不写实现。
在餐厅里,顾客所在的区域称为前台,在这里领位带领顾客入座,服务员为顾客点单和收银,酒保制作饮品。后台是大厨、厨师、洗碗工、经理们工作的地方。
我们可以用 cargo new --lib restaurant
创建一个包含库箱子的包。
1 | // 餐厅前台 |
这段代码可表示下面的模块结构:
1 | crate: |
模块树中包含一些嵌套关系、一些邻接关系。如果模块 A 包含在模块 B 中,我们就称模块 A 是模块 B 的子模块,模块 B 是模块 A 的父模块。整个模块树的根节点是一个名为 crate
的隐藏模块。
路径,引用模块树中的项目
Rust 为了在模块树中找到项目,使用了与文件系统类似的路径(path)。
路径有两种形式:
- 绝对路径:从箱子的根开始,以箱子名或者字面量
crate
为前缀 - 相对路径:从当前模块开始,以
self
、super
或者当前模块中的标识符为前缀
不管是绝对路径还是相对路径,都用 ::
连接多个标识符。
举个例子:
1 | // 餐厅前台 |
在这个例子中,虽然路径的引用是正确的,但是会因为 hosting 模块并非公开的而在编译时报错。
模块不仅仅用于组织代码,也用于定义 Rust 的可见性边界:模块中封装的实现并不能被外界所使用。所以想让一些项目私有的话,可以用一个模块把它装在里面。
Rust 中所有项目(items)的默认可见性都是私有的。父模块不能使用子模块中私有的项目,但是子模块可以使用任意父模块的私有项目。因为子模块对外隐藏细节,但是它仍然位于父模块的作用域内。
使用 pub
关键字来公开路径
我们需要让路径上的每个节点都是可见的,才能够最终访问到路径所指的项目:
front_of_house
本来就是对eat_at_restaurant
可见的hosting
和add_to_waitlist
则需要使用pub
关键字来将其变为公开的
1 | // 餐厅前台 |
使用 super
关键字开始的路径
我们可以用 super
来从父模块开始构造路径,类似文件系统中的 ..
符号。
例如:
1 | mod back_of_house { |
使结构体和枚举公开化
我们也可以用 pub
将结构体和枚举变成公开的,但是这里会多一些细节。如果我们对结构体使用 pub
关键字,结构体本身会变为公开的,但是其字段仍然是私有的。我们可以根据需要对每个字段设置可见性。
例如:
1 | mod back_of_house { |
与结构体不同,将枚举公开化的话,其所有的变种都会变为公开的,我们只需要在 enum
关键字前面加上 pub
关键字即可。
使用 use
关键字将路径引入到作用域中
使用路径来指代项目看起来要写比较长的串,用起来会比较繁琐且臃肿。我们可以用 use
关键字来指代路径,并将其名称引入到当前作用域中,这样就不需要每次使用时都通过路径来访问了。
例子:
1 | // 餐厅前台 |
use
关键字就有点像文件系统中的软链接。
使用 use
关键字的语言习惯
下面这串代码与上面的功能完全一致,但是我们会习惯用上面的那一种。
1 | // 餐厅前台 |
引入函数时,我们习惯只引入目标函数的路径父节点,这样我们在使用时就需要从其父节点开始写,与那些本就定义在当前作用域中的项目区分开来。而引入结构体、枚举等其他项目时,我们习惯直接引入目标路径。这个习惯并没有什么特别的原因,仅仅因为开发者习惯阅读这样的代码。
有一个例外情况是,如果我们要引入多个同名的项目,我们就都引入其父节点。
使用 as
关键字为项目提供新的名称
对于上面引入同名项目的问题,有另一个解决方法就是将其中一个在当前作用域中重命名,我们使用 as
关键字为其指定新的名称:
1 | use std::fmt::Result; |
使用 pub use
将名称重新导出
1 | mod front_of_house { |
使用外部包
我们可以在 Cargo.toml
中添加一个依赖来引入外部的包:
1 | [dependencies] |
这样会让 Cargo 从 crates.io 下载 rand
包和其所有的依赖,并让 rand
在项目中可用。
1 | // 以 rand 开始的路径可以引用其包中的内容 |
标准库 std
也是一个包外部的箱子,因为其内置于 Rust 语言,不需要在 Cargo.toml
中添加依赖。
使用嵌套路径来组合多个 use
语句
1 | use std::cmp::Ordering; |
在嵌套路径中可以用 self
表示当前的路径:
1 | use std::io; |
使用 *
引入
如果我们要引入一个路径中的所有公开项目,我们可以在最后一节使用 *
来指代所有项目:
1 | use std::collections::*; |
将模块拆分到多个文件中
当模块变得很多时,需要将其代码写到单独的文件中,以便查找。
我们将 front_of_house 模块的代码移到 src/front_of_house.rs
中:
1 | // src/front_of_house.rs |
然后将 src/lib.rs
的内容改为:
1 | // src/lib.rs |
在 mod <name>
后面接 ;
会让 Rust 从同名的文件中加载该模块的内容。
我们再创建一个 src/front_of_house/hosting.rs
文件,把 hosting 模块的代码移进去:
1 | // src/front_of_house/hosting.rs |
然后修改 src/front_of_house.rs
的内容:
1 | // src/front_of_house.rs |
可以看到文件结构与模块树是一致的。
总结
Rust 的模块系统可以让你用多个箱子与模块等来组织包里的代码。所有的内容都是默认私有的,对当前模块外部是不可见的。