Rust 使用包、Crate 和模块管理不断增长的项目
本文在原文有删减,原文参考使用包、Crate 和模块管理不断增长的项目。
Rust 有许多功能可以管理代码的组织,包括:
- 包(Packages):Cargo 的一个功能,它允许你构建、测试和分享 crate。
- Crates:一个模块的树形结构,它形成了库或二进制项目。
- 模块(Modules)和 use:允许你控制作用域和路径的私有性。
- 路径(path):一个命名例如结构体、函数或模块等项的方式。
包和 Crate
crate 是 Rust 在编译时最小的代码单位,crate 有两种形式:二进制项和库。
二进制项 可以被编译为可执行程序,比如一个命令行程序或者一个服务器,必须有一个 main 函数来定义当程序被执行的时候所需要做的事情。
库 没有 main 函数,也不会编译为可执行程序,提供一些诸如函数之类的东西给其他项目使用。
crate root 是一个源文件,Rust 编译器以它为起始点,并构成 crate 的根模块。
包(package)是提供一系列功能的一个或者多个 crate,一个包会包含一个 Cargo.toml 文件阐述如何去构建这些 crate。
包中可以包含至多一个库 crate(library crate),包中可以包含任意多个二进制 crate(binary crate),但是必须至少包含一个 crate(无论是库的还是二进制的)。
输入命令 cargo new 创建包:
$ cargo new my-project
Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs
注:ls 命令为 Linux 平台的指令,Windows 下可用 dir。
Cargo 遵循的一个约定:
- src/main.rs 就是一个与包同名的二进制 crate 的 crate 根。
- 如果包目录中包含 src/lib.rs ,则包带有与其同名的库 crate,且 src/lib.rs 是 crate 根。
- crate 根文件将由 Cargo 传递给 rustc 来实际构建库或者二进制项目。
一个只包含 src/main.rs 的包,意味着它只含有一个名为 my-project 的二进制 crate。如果一个包同时含有 src/main.rs 和 src/lib.rs,则它有两个 crate:一个二进制的和一个库的,且名字都与包相同。
通过将文件放在 src/bin 目录下,一个包可以拥有多个二进制 crate:每个 src/bin 下的文件都会被编译成一个独立的二进制 crate。
定义模块来控制作用域与私有性
下面介绍模块、路径、use关键词和pub关键词如何在编译器中工作,以及大部分开发者如何组织他们的代码:
-
从 crate 根节点开始: 当编译一个 crate, 编译器首先在 crate 根文件(通常,对于一个库 crate 而言是src/lib.rs,对于一个二进制 crate 而言是src/main.rs)中寻找需要被编译的代码。
-
声明模块: 在 crate 根文件中可以声明一个新模块,如用mod garden声明了一个叫做garden的模块,编译器会在下列路径中寻找模块代码:
- 内联,在大括号中,当mod garden后方不是一个分号而是一个大括号
- 在文件 src/garden.rs
- 在文件 src/garden/mod.rs
-
声明子模块: 在除了 crate 根节点以外的其他文件中可以定义子模块,如在src/garden.rs中定义了mod vegetables,编译器会在以父模块命名的目录中寻找子模块代码:
- 内联,在大括号中,当mod vegetables后方不是一个分号而是一个大括号
- 在文件 src/garden/vegetables.rs
- 在文件 src/garden/vegetables/mod.rs
-
模块中的代码路径: 在同一个 crate 内,只要隐私规则允许,可以从任意位置引用该模块的代码。如可以通过
crate::garden::vegetables::Asparagus
来引用garden vegetables
模块下的Asparagus
类型。 -
私有 vs 公用: 一个模块里的代码默认对其父模块私有,为了使一个模块公用应当在声明时使用 pub mod 替代 mod,为了使一个公用模块内部的成员公用应当在声明前使用 pub。
-
use 关键字: 在一个作用域内,可以用 use关键字创建了一个成员的快捷方式来减少长路径的重复,如在
crate::garden::vegetables::Asparagus
的作用域可以通过use crate::garden::vegetables::Asparagus;
创建一个快捷方式,然后就可以在作用域中只写Asparagus
来使用该类型。
创建一个名为backyard的二进制 crate 来说明这些规则,该 crate 的路径同样命名为backyard,文件目录如下:
backyard
├── Cargo.lock
├── Cargo.toml
└── src
├── garden
│ └── vegetables.rs
├── garden.rs
└── main.rs
这个例子中的 crate 根文件是src/main.rs,内容如下:
use crate::garden::vegetables::Asparagus;
//告诉编译器应该包含在src/garden.rs文件中发现的代码
pub mod garden;
fn main() {
let plant = Asparagus {};
println!("I'm growing {:?}!", plant);
}
文件 src/garden.rs 代码如下:
//在src/garden/vegetables.rs中的代码也应该被包括
pub mod vegetables;
文件 src/garden/vegetables.rs 代码如下:
#[derive(Debug)]
pub struct Asparagus {}
在模块中对相关代码进行分组
模块可以将一个 crate 中的代码进行分组,提高可读性和重用性。模块内的代码默认是私有的,可以利用模块的私有性来控制访问权限,私有项是外部无法使用的内部实现细节。同时,我们可以标记模块及其内部的项为公开,使外部代码可以使用和依赖它们。
执行 cargo new --lib restaurant
创建一个新的名为 restaurant 的库,文件 src/lib.rs 代码如下:
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() {}
}
}
上面示例中的模块树的结构如下:
crate
└── front_of_house
├── hosting
│ ├── add_to_waitlist
│ └── seat_at_table
└── serving
├── take_order
├── serve_order
└── take_payment
注:src/main.rs
和 src/lib.rs
之所以被叫做 crate 根,是因为这两个文件的内容都分别在 crate 模块结构的根组成了一个名为 crate 的模块,该结构被称为 模块树(module tree)。
整个模块树都植根于名为 crate 的隐式模块下,模块树的结构类似于电脑上文件系统的目录树。
引用模块项目的路径
来看一下 Rust 如何在模块树中找到一个项的位置,调用一个函数需要知道它的路径,路径有两种形式:
- 绝对路径(absolute path)是以 crate 根(root)开头的全路径,对于外部 crate 的代码是以 crate 名开头的绝对路径,对于当前 crate 的代码则以字面值 crate 开头。
- 相对路径(relative path)从当前模块开始,以 self、super 或当前模块的标识符开头。
注:绝对路径和相对路径都后跟一个或多个由双冒号(::)分割的标识符。
使用绝对路径和相对路径来调用 add_to_waitlist 函数:
//无法通过编译:hosting 模块是私有的
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
}
}
// front_of_house 模块在模块树中与 eat_at_restaurant 定义在同一层级
pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();
// 相对路径
front_of_house::hosting::add_to_waitlist();
}
一般更倾向于使用绝对路径,因为把代码定义和项调用各自独立地移动是更常见的。
在 Rust 中,默认所有项(函数、方法、结构体、枚举、模块和常量)对父模块都是私有的,父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用它们父模块中的项。
使用 pub 关键字暴露路径
为 mod hosting 和 fn add_to_waitlist 添加 pub 关键字使它们可以在 eat_at_restaurant 函数中被调用:
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();
// 相对路径
front_of_house::hosting::add_to_waitlist();
}
如果你计划共享你的库 crate 以便其它项目可以使用你的代码,公有 API 将是决定 crate 用户如何与你代码交互的契约。关于管理公有 API 的修改以便被人更容易依赖你的库的考量,可以参考 The Rust API Guidelines。
二进制和库 crate 包的最佳实践
我们提到过包可以同时包含一个 src/main.rs 二进制 crate 根和一个 src/lib.rs 库 crate 根,并且这两个 crate 默认以包名来命名。通常,这种包含二进制 crate 和库 crate 的模式的包,在二进制 crate 中只有足够的代码来启动一个可执行文件,可执行文件调用库 crate 的代码。又因为库 crate 可以共享,这使得其它项目从包提供的大部分功能中受益。
模块树应该定义在 src/lib.rs 中,这样通过以包名开头的路径,公有项就可以在二进制 crate 中使用。二进制 crate 就完全变成了同其它 外部 crate 一样的库 crate 的用户:它只能使用公有 API。这有助于你设计一个好的 API;你不仅仅是作者,也是用户!
super 开始的相对路径
可以通过在路径的开头使用 super 从父模块开始构建相对路径,而不是从当前模块或者 crate 根开始,这类似以 .. 语法开始一个文件系统路径。
使用以 super 开头的相对路径从父目录开始调用函数:
fn deliver_order() {}
mod back_of_house {
fn fix_incorrect_order() {
cook_order();
super::deliver_order();
}
fn cook_order() {}
}
当认为 back_of_house 模块和 deliver_order 函数之间可能具有某种关联关系,并且重新组织 crate 的模块树时需要一起移动,我们就可以使用 super。
创建公有的结构体和枚举
可以使用 pub 来设计公有的结构体和枚举,如果在一个结构体定义的前面使用了 pub 结构体会变成公有的,但是这个结构体的字段仍然是私有的。带有公有和私有字段的结构体:
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");
println!("I'd like {} toast please", meal.toast);
// 如果取消下一行的注释代码不能编译;
// 不允许查看或修改早餐附带的季节水果
// meal.seasonal_fruit = String::from("blueberries");
}
back_of_house::Breakfast 具有私有字段,必须提供一个公共的关联函数来构造 Breakfast 的实例 ,否则将无法在 eat_at_restaurant 中创建 Breakfast 实例。
与之相反,如果将枚举设为公有,则它的所有成员都将变为公有。在 enum 关键字前面加上 pub 设计公有枚举,使其所有成员公有:
mod back_of_house {
pub enum Appetizer {
Soup,
Salad,
}
}
pub fn eat_at_restaurant() {
let order1 = back_of_house::Appetizer::Soup;
let order2 = back_of_house::Appetizer::Salad;
}
枚举成员不是公有的会显得用处不大,因此枚举成员默认就是公有的。结构体通常使用时不必将它们的字段公有化,因此结构体遵循常规内容默认全部是私有的。
使用 use 关键字将路径引入作用域
不得不编写路径来调用函数显得不便且重复,可以使用 use 关键字创建一个短路径,然后就可以在作用域中的任何地方使用这个更短的名字。通过 use 引入作用域的路径也会检查私有性,同其它路径一样。
通过在 crate 根增加 use crate::front_of_house::hosting,现在 hosting 在作用域中就是有效的名称了,如同 hosting 模块被定义于 crate 根一样:
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();
}
use 只能创建 use 所在的特定作用域内的短路径:
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
use crate::front_of_house::hosting;
mod customer {
pub fn eat_at_restaurant() {
//编译器错误:短路径不在适用于 customer 模块中
hosting::add_to_waitlist();
}
}
如果想修复这个编译错误,可以将 use 移动到 customer 模块内,或者在子模块 customer 内通过 super::hosting 引用父模块中的这个短路径。
创建惯用的 use 路径.
使用 use 引入函数
使用 use 将 add_to_waitlist 函数引入作用域并不符合习惯:
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();
}
要想使用 use 将函数的父模块引入作用域,必须在调用函数时指定父模块以表明函数不是在本地定义的,同时使完整路径的重复度最小化。
使用 use 引入结构体、枚举和其他项
将 HashMap 结构体引入作用域的习惯用法:
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert(1, 2);
}
使用 use 引入结构体、枚举和其他项时,习惯是指定它们的完整路径,这是一种惯例。
使用 use 的例外用法
如果想使用 use 语句将两个具有相同名称的项带入作用域则需要指定父模块,将两个具有相同名称但不同父模块的 Result 类型引入作用域:
use std::fmt;
use std::io;
fn function1() -> fmt::Result {
// --snip--
}
fn function2() -> io::Result<()> {
// --snip--
}
使用 as 关键字提供新的名称
使用 use 将两个同名类型引入同一作用域这个问题还有另一个解决办法:在这个类型的路径后面使用 as 指定一个新的本地名称或者别名。
通过 as 重命名其中一个 Result 类型:
use std::fmt::Result;
//选择 IoResult 作为 std::io::Result 的新名称
use std::io::Result as IoResult;
fn function1() -> Result {
// --snip--
}
fn function2() -> IoResult<()> {
// --snip--
}
使用 pub use 重导出名称
使用 use 关键字将某个名称导入当前作用域后,这个名称在此作用域中就可以使用了,但它对此作用域之外还是私有的。如果想让其他人调用我们的代码时也能够正常使用这个名称,那可以将 pub 和 use 合起来使用,这种技术被称为 “重导出(re-exporting)”。
通过 pub use 使名称可从新作用域中被导入至任何代码:
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
pub use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
使用外部包
前面的项目使用了一个外部包 rand 来生成随机数,为了在项目中使用 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);
}
std 标准库也是外部 crate,只是无需修改 Cargo.toml 来引入 std,但需要通过 use 将标准库中定义的项引入项目包的作用域中来引用它们:
use std::collections::HashMap;
嵌套路径来消除大量的 use 行
当需要引入很多定义于相同包或相同模块的项时,为每一项单独列出一行会占用源码很大的空间。有两行 use 语句都从 std 引入项到作用域:
// --snip--
use std::cmp::Ordering;
use std::io;
// --snip--
可以使用嵌套路径将相同的项在一行中引入作用域,指定嵌套的路径在一行中将多个带有相同前缀的项引入作用域:
// --snip--
use std::{cmp::Ordering, io};
// --snip--
可以在路径的任何层级使用嵌套路径,这在组合两个共享子路径的 use 语句时非常有用。通过两行 use 语句引入两个路径,其中一个是另一个的子路径:
use std::io;
use std::io::Write;
为了在一行 use 语句中引入这两个路径,可以在嵌套路径中使用 self:
use std::io::{self, Write};
通过 glob 运算符将所有的公有定义引入作用域
如果希望将一个路径下所有公有项引入作用域,可以指定路径后跟 *(glob 运算符):
//将 std::collections 中定义的所有公有项引入当前作用
use std::collections::*;
使用 glob 运算符时需要小心,Glob 会使得我们难以推导作用域中有什么名称和它们是在何处定义的。
glob 运算符经常用于测试模块 tests 中,这时会将所有内容引入作用域。
将模块拆分成多个文件
当模块变得更大时可能会将它们的定义移动到单独的文件中,从而使代码更容易阅读。
声明 front_of_house 模块,其内容将位于 src/front_of_house.rs:
mod front_of_house;
pub use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
在 src/front_of_house.rs 中定义 front_of_house 模块:
pub mod hosting {
pub fn add_to_waitlist() {}
}
mod 不同于其他编程语言中看到的 "include" 操作,它更依赖于代码文件目录。
为了移动 hosting,修改 src/front_of_house.rs 使之仅包含 hosting 模块的声明:
pub mod hosting;
接着创建一个 src/front_of_house 目录和一个包含 hosting 模块定义的 hosting.rs 文件:
pub fn add_to_waitlist() {}
编译器所遵循的哪些文件对应哪些模块的代码的规则,意味着目录和文件更接近于模块树。
另一种文件路径
前面介绍了 Rust 编译器所最常用的文件路径,不过另一种更老的文件路径也仍然是支持的。
-
对于声明于 crate 根的 front_of_house 模块,编译器会在如下位置查找模块代码:
- src/front_of_house.rs(我们所介绍的)
- src/front_of_house/mod.rs(老风格,不过仍然支持)
-
对于 front_of_house 的子模块 hosting,编译器会在如下位置查找模块代码:
- src/front_of_house/hosting.rs(我们所介绍的)
- src/front_of_house/hosting/mod.rs(老风格,不过仍然支持)
如果同一模块同时使用这两种路径风格,会得到一个编译错误。在同一项目中的不同模块混用不同的路径风格是允许的,不过这会使他人感到疑惑。
使用 mod.rs 这一文件名的风格的主要缺点是会导致项目中出现很多 mod.rs 文件,当在编辑器中同时打开它们时对程序员很不友好。