当我们项目较小时,一个 main.rs 文件就能搞定所有。但是如果项目较大,功能较多时,就很难搞定了。我们需要对相关功能进行分组和划分不同功能的代码,这样编写和维护都会比较方便。
如果你是一个Java程序员,相信你一定用过这样几个东西。首先项目较大,依赖较多,我们通常会使用maven/gradle 等工具进行依赖管理,然后将各个功能划分到不同的 package 中,比如(controller/service/dao/domain/utils等等)。
那么rust 程序员该如何管理大型项目呢?
①、Cargo: Rust 的包管理工具,能够管理外部依赖以及进行项目的编译和测试 create;
②、package: Cargo 提供的功能,一个包会含有一个 Cargo.toml 文件,是提供一系列功能的一个或多个 create。
③、create: 表示项目,是 Rust 中的独立编译单元。每个 create 对应生成一个库或可执行文件(.lib/.dll/.so/.exe)。
④、模块(Modules)和 use: 允许你控制作用域和路径的私有性。
⑤、路径(path):为 struct、function 或 module 等项命名的方式。
PS:其实这么多名词核心问题就是如何管理作用域,我们代码中的变量、方法,开发者如何调用?又或者能够调用哪些?编译器如何去找?
Cargo 是Rust 的包管理工具,并不是一个编译器。
Rust 的编译器是 rustc
。
我们使用 Cargo 编译工程实际上还是调用 rustc
来完成的。如果我们想知道 cargo 在后面是如何调用 rustc 完
成编译的,可以使用 cargo build --verbose 选项查看详细的编译命令。
常用的 Cargo 命令:
一、cargo -h : 帮助命令
二、cargo new: 创建项目
三、cargo build: 编译项目
四、cargo run: 运行项目
五、cargo check: 只检查编译错误,而不做代码优化以及生成可执行程序,非常适合在开发过程中快速检查语法、类型错误。
六、cargo clean: 清理以前编译的结果。
七、cargo doc: 生成该项目的文档。
八、cargo test: 执行单元测试。
九、cargo bench: 执行 benchmark 性能测试。
十、cargo update: 升级所有依赖项的版本,重新生成 Cargo.lock 文件。
十一、cargo install: 安装可执行程序。这个命令非常有用,可以扩展 cargo 的子命令,为它增加新的功能。比如 可以使用 cargo install cargo-tree
命令,然后通过 cargo tree 打印依赖项的树形结构。
十二、cargo uninstall: 卸载可执行程序。
create 的作用:将相关功能组合到一个作用域内,便于在项目间进行共享,防止冲突。
①、crate 是一个二进制项(binary)或者库(library)。
②、crate root 是一个源文件,Rust 编译器以它为起始点,并构成你的 crate 的根模块(module)。
③、包*(package) 是提供一系列功能的一个或者多个 crate。一个包会包含有一个 Cargo.toml 文件,阐述如何去构建这些 crate。
包中可以包含至多一个库 crate(library crate)。包中可以包含任意多个二进制 crate(binary crate),但是必须至少包含一个 crate(无论是库的还是二进制的)。
下面我们通过 Cargo 创建一个包。
$ cargo new my-project
Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs
可以看到Cargo 会给我们的包创建一个 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]
我们会发现并没有提到 src/main.rs,因为 Cargo 遵循的一个约定:src/main.rs 就是一个与包同名的二进制 crate 的 crate 根。
同样的,Cargo 知道如果包目录中包含 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。
模块 的作用:
①、在一个 crate 中,将代码进行分组,以提高可读性与重用性。
②、控制项目的访问权限(private/public),默认是私有(private)。
// 前台
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() {}
}
}
我们在 src 目录下创建了一个 lib.rs 文件,然后在里面添加上面的代码。
这里面我们定义一个模块,是以 mod
关键字为起始,然后指定模块的名字(这里叫做 front_of_house
),并且用花括号包围模块的主体。在模块内,我们还可以定义其他的模块,就像本例中的 hosting
和 serving
模块。模块还可以保存一些定义的其他项,比如结构体、枚举、常量、特性、或者函数。
通过使用模块,我们可以将相关的定义分组到一起,并指出他们为什么相关。程序员可以通过使用这段代码,更加容易地找到他们想要的定义,因为他们可以基于分组来对代码进行导航,而不需要阅读所有的定义。程序员向这段代码中添加一个新的功能时,他们也会知道代码应该放置在何处,可以保持程序的组织性。
这个模块的树形结构如下:
crate
└── front_of_house
├── hosting
│ ├── add_to_waitlist
│ └── seat_at_table
└── serving
├── take_order
├── serve_order
└── take_payment
上一节我们定义了模块以及如何创建模块,那么如何访问模块中某一项呢?
这就需要使用路径(path),就像在文件系统使用路径一样。如果我们想要调用一个函数,我们需要知道它的路径。
crate
开头。self
、super
或当前模块的标识符开头。绝对路径和相对路径后都跟一个或多个由双冒号(::
)分割的标识符。
// 前台模块
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
}
}
pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();
// 相对路径
front_of_house::hosting::add_to_waitlist();
}
PS:建议使用绝对路径,这样代码调用位置移动也不用修改。
上面的代码,我们编译,会报如下错误:
这是因为:
①、 Rust 中默认所有项(函数、方法、结构体、枚举、模块和常量)都是私有的。
②、父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用他们父模块中的项。
为了让上面的代码编译通过,我们可以使用关键字 pub
将 front_of_house 模块下的 hosting 模块定义为公共的。
mod front_of_house {
pub mod hosting {
fn add_to_waitlist() {}
}
}
我们接着编译,发现还是报错:
看报错是因为 add_to_waitlist()
方法是私有的,我们只是给其父模块增加了 pub
关键字,这说明:
通过关键字
pub
使其模块公有,但是其内容默认还是私有的。
我们需要将 add_to_waitlist()
方法也加上 pub
关键字,才会编译通过。
super
关键字表示父模块路径,类似文件系统中的 ..
开头的语法。
fn serve_order() {}
mod back_of_house {
fn fix_incorrect_order() {
cook_order();
super::serve_order();
}
fn cook_order() {}
}
前面例子中无论我们选择 add_to_waitlist
函数的绝对路径还是相对路径,每次我们想要调用 add_to_waitlist
时,都必须指定front_of_house
和 hosting
。
pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();
// 相对路径
self::front_of_house::hosting::add_to_waitlist();
}
那么有没有办法简化这个路径呢?
答案是:我们可以使用 use
关键字将路径一次性引入作用域,然后调用该路径中的项,就如同它们是本地项一样。
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 也可以引用相对路径,并且也会检查路径私有性。
as 关键字可以为引入的路径指定本地别名。
比如使用 use
将两个同名类型引入同一作用域,调用的时候就必须带上父路径,如果不想带上,可以给这两个同名类型起一个别名。
use std::fmt::Result;
use std::io::Result as IoResult;
fn function1() -> Result {
// --snip--
Ok(())
}
fn function2() -> IoResult<()> {
// --snip--
Ok(())
}
使用 use
关键字,将某个名称导入当前作用域后,这个名称在此作用域中就可以使用了,但它对此作用域之外还是私有的。
如果想让其他人调用我们的代码时,也能够正常使用这个名称,就好像它本来就在当前作用域一样,那我们可以将 pub
和 use
合起来使用。这种技术被称为 “重导出(re-exporting)”:我们不仅将一个名称导入了当前作用域,还允许别人把它导入他们自己的作用域。
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();
hosting::add_to_waitlist();
hosting::add_to_waitlist();
}
在Java项目中,我们引入外部依赖通常是在 pom.xml 文件中引入外部依赖。
在 rust 中,我们是在 Cargo.toml 文件中引入,比如引入一个随机数依赖:
rand = "0.8.3"
在 Cargo.toml 中加入 rand
依赖告诉了 Cargo 要从 crates.io 下载 rand
和其依赖,并使其可在项目代码中使用。
接着,为了将 rand
定义引入项目包的作用域,我们加入一行 use
起始的包名,它以 rand
包名开头并列出了需要引入作用域的项。
use std::io;
use rand::Rng;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is: {secret_number}");
println!("Please input your guess.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
println!("You guessed: {guess}");
}
当需要引入很多定义于相同包或相同模块的项时,可以进行合并。
①、比如:
use std::cmp::Ordering;
use std::io;
可以改写为:
use std::{cmp::Ordering, io};
②、层级共享
use std::io;
use std::io::Write;
可以改写为:
use std::io::{self, Write};
如果希望将一个路径下 所有 公有项引入作用域,可以指定路径后跟 *
,glob 运算符:
use std::collections::*;
这个 use
语句将 std::collections
中定义的所有公有项引入当前作用域。
使用 *
运算符时要注意:这会使得我们难以推导作用域中有什么名称和它们是在何处定义的。
glob 运算符经常用于测试模块
tests
中,这时会将所有内容引入作用域。用于预导入(prelude)模块。
当模块变多时(多个方法、结构体等),我们需要将它们的定义移动到单独的文件中,从而使代码更容易阅读。
这里需要注意两个点:
①、模块定义时,如果模块名后面是“;”,而不是代码块,那么rust 会从与模块同名的文件中加载内容。
②、模块树的结构不会变化。
比如,我们在 lib.rs 创建如下内容:
// 前台模块
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();
// 相对路径
self::front_of_house::hosting::add_to_waitlist();
}
现在,我们想把 font_of_house
模块移动出去,需要进行两步操作:
一、在 src 目录下创建 font_of_house.rs 文件,内容如下:
pub mod hosting {
pub fn add_to_waitlist() {
}
}
二、lib.rs 改写
// 前台模块
mod front_of_house;
pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();
// 相对路径
self::front_of_house::hosting::add_to_waitlist();
}
同理,如果我们还想把 hosting
里面的模块也提取出去,那该怎么办呢?
假设,我们直接在 src 目录下创建 hosting.rs 文件,然后看看编译结果:
这对应了我们前面说的模块树的结构是不变的,所以编译器是找 src/font_of_house 目录下的 hosting.rs 文件,我们不能将其放在 src 目录下。