5

Rust 模块和文件 - [译]

 2 years ago
source link: https://zhuanlan.zhihu.com/p/73544030
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Rust 模块和文件 - [译]

多少事,从来急。天地转,光阴迫。

收藏数比赞数还多:)什么情况。快1万字的文章翻译不易,求个赞


原文链接:amos.me - Rust modules vs files


不久前,我在推特上发起了 Rust 有什么让人困惑的 话题,热度最高的主题是“模块系统是怎么映射到文件的?”。

我记得刚接触 Rust 时模块让我痛苦挣扎,所以我尝试用一种我认为说得通的方式解释它。

以下所述均使用 Rust 2018 版本。我没有兴趣学习(或教授)老版本的细节,特别是因为老版本让我更加困惑。

如果你有现存的项目,你可以查看 Cargo.toml 文件中的 edtion 查看项目使用的 Rust 版本。 如果没有,那现在就加上 edition = 2018

如果使用最新的 Rust 且通过 cargo new/ cargo init 来创建新项目,新项目会自动选择 2018 版本。

什么是 crate

一个 crate 通常来说是一个项目。它有一个 Cargo.toml 文件,这个文件用于声明依赖,入口,构建选项等项目元数据。 每个 crate 可以独立地在 https://crates.io/ 上发表。

假设我们要创建一个二进制(可执行)项目:

  • cargo new --bin(或者在已有项目上用 cargo init --bin)会为新 crate 生成一个 Cargo.toml 文件。
  • 项目入口为 src/main.rs

对于二进制项目,src/main.rs 是项目主模块的常用路径。它不一定是精确的路径,可以在 Cargo.toml 添加相应配置 [^1],使编译器在别处查看(甚至可以有多个目标二进制文件和多个目标库)。

默认情况下,我们的可执行项目的 src/main.rs 如下:

fn main() {
    println!("Hello world!");
}

我们可以通过 cargo run 构建和运行这个项目,若只想构建项目,则运行 cargo build

构建一个 crate 的时候,cargo 下载并编译所有所需依赖,默认情况下把临时文件和最终生成文件放入 ./target/ 目录下。 cargo 既是包管理器又是构建系统。

crate 依赖

让我们向刚才创建的 crate 添加 rand 依赖来看看命名空间是怎么工作的。我们需要修改 Cargo.toml,其内容如下:

[package]
name = "modules"
version = "0.1.0"
edition = "2018"

[dependencies]
rand = "0.7.0"

如果我们想学习如何使用 rand crate,有以下几种方式:

  • rand 的 crates.io.page - 上面通常包含了一个类似 README 文件,包含了简要描述和一些代码示例
  • rand 的 文档(在 crates.io 页面标题或最新版本下有链接)。需要注意的是所有发表在 crates.io 的 crate 会在 https://docs.rs 上生成文件 - 我不确定为什么 rand 也文档部署在它自己的网页,或许它早于 docs.rs?
  • 它的 源码页,如果其他方式(如 crates.io 的链接和自动生成的文档)失败了的化

现在让我们在 src/main.rs 里使用 rand, src/main.rs 如下:

fn main() {
    let random_boolean = rand::random();
    println!("You {}!", if random_boolean { "win" } else { "lose" });
}
  • 我们不需要使用 use 指令来使用 rand - 它在项目下的文件全局可用,因为它在 Cargo.toml 中被声明为依赖(rust 2018之前的版本则不是这样)
  • 我们完全没必要使用 mod (稍后讲述)

为了明白这篇博客的余下部分,你需要明白 rust 模块仅仅是命名空间 - 他们让你把相关符号组合在一起并保证可见性规则。

  • 我们的 crate 有一个主模块(我们现在所在),它的源在 src/main.rs
  • rand crate 也有一个入口。因为他是一个库,默认情况下其主入口为 src/lib.rs
  • 在我们主模块范围,我们可以在主模块通过依赖名称使用依赖

总之,我们现在只处理两个模块:我们项目主入口还有 rand 的入口。

use 指令

如果我们不喜欢一直这样写 rand::random(),我们可以把 random 注入主模块范围。

use rand::random;
// 我们可以通过 `rand::random()` 或 `random()` 来使用它

fn main() {
    if random() && random() {
        println!("You won twice in a row!");
    } else {
        println!("Try again...");
    }
}

我们也可以使用通配符来导入 rand 主模块导出的所有符号。

// 这会导入 random,还有 thead_rng 等
use rand::*;

fn main() {
    if random() {
        panic!("Unlucky coin toss");
    }
    println!("Hello world");
}

模块不需要在分开的文件里

正如刚才所见,模块是一个让你组合相关符号的语言结构。

不需要把他们放在不同的文件下。

让我们修改下 src/main.rs 来证明这个观点:

mod math {
    pub fn add(x: i32, y: i32) -> i32 {
        x + y
    }
    // 使用 `pub` 来导出 `add()` 函数
    // 如果不这样做,`add()` 会变为 `math` 模块的私有函数
    // 我们将无法在 `math` 模块外使用它
}

fn main() {
    let result = math::add(1, 2);
    println!("1 + 2 = {}", result);
}

从范围角度,我们项目结构如下:

我们 crate 的主模块
    `math`: 我们的 `math` 模块
    `rand`: `rand` crate 的主模块

从文件角度,主模块和 math 模块都在同一个文件 src/main.rs 下。

模块可以在可分开的文件中

现在,如果我们如下修改项目:

src/math.rs

pub fn add(x: i32, y: i32) -> i32 {
    x + y
}

src/main.rs

fn main() {
    let result = math::add(1, 2);
    println!("1 + 2 = {}", result);
}

然而这行不通。

Compiling modules v0.1.0 (/home/amos/Dev/modules)
error[E0433]: failed to resolve: use of undeclared type or module `math`
 --> src/main.rs:2:18
  |
2 |     let result = math::add(1, 2);
  |                  ^^^^ use of undeclared type or module `math`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0433`.
error: Could not compile `modules`.

To learn more, run the command again with --verbose.

虽然 src/main.rssrc/lib.rs(二进制和库项目)会被 cargo 自动识别为程序入口,其他文件则需要在文件中明确声明。

我们的错误在于仅仅创建了 src/math.rs 文件,希望 cargo 会在构建时找到它,但事实上并不是这样的。cargo 甚至不会解析它。 cargo check 命令也不会报错,因为 src/math.rs 现在还不是 crate 源文件的一部分。

为了改正这个错误,可以如下修改 src/main.rs(因为它时项目入口,这是 cargo 已知的):

mod math {
    include!("math.rs");
}
// 注意: 这不是符合 rust 风格的写法,仅作 mod 学习用

fn main() {
    let result = math::add(1, 2);
    println!("1 + 2 = {}", result);
}

现在 crate 可以编译和运行了,因为:

  • 我们定义了一个名为 math 的模块
  • 我们告诉编译器复制/粘贴其他文件(math.rs)到模块代码块中
  • 参考 include! 文档

但这不是通常导入模块的方式。按照惯例,如果使用不跟随代码块的 mod 指令,效果上述一样。

所以也可以这样写:

mod math;

fn main() {
    let result = math::add(1, 2);
    println!("1 + 2 = {}", result);
}

就是这么简单。但容易混淆之处在于,根据 mod 之后是否有代码块,它可以内联定义模块,或者导入其他文件。

这也解释了为什么在 src/math.rs 里不用再定义另一个 mod math {}。因为 src/math.rs 已经在 src/main.rs 中导入,它已经说 src/math.rs 的代码存在于一个名为 math 的模块中。

use

现在我们几乎了解了 mod,那 use 呢?

use 的唯一目的是将符号带入命名空间,让符号使用更加简短。

特别是,use 永远不会告诉编译器去编译 mod 导入文件之外的其他文件

main.rs/math.rs 例子中,在 src/main.rs 写下如下语句时:

mod math;

我们在主模块导入一个名为 math 模块,这个模块导出 add 函数。

从范围角度,结构如下:

crate 主模块(我们在这儿)
  `math` 模块
    `add` 函数

这就是为什么我们要使用 add 函数时要这样引用 math::add,即从主模块到 add 函数的正确路径。

请注意,如果我们从另一个模块调用 add,那么 math::add 可能不是有效路径。 然而,add 有一个更长的添加路径,即 crate::math::add - 它在我们的 crate 中的任何位置都有效(只要 math 模块保持原样)。

所以,如果我们不想每次都使用 math:: 前缀调用 add,可以用 use 指令:

mod math;
use math::add;

fn main() {
    // 看,没有前缀了!
    let result = add(1, 2);
    println!("1 + 2 = {}", result);
}

mod.rs 又是什么呢?

好吧,我说谎了 - 我们还没完全了解 mod

目前,crate 有一个漂亮又扁平的文件结构:

src/
    main.rs
    math.rs

这是有道理的,因为 math 是一个小模块(只有一个函数),它并不需要拥有自己的文件夹。但我们也可以这样改变它的结构:

src/
    main.rs
    math/
        mod.rs

(对于那些熟悉 node.js 的人来说,mod.rs 类似于 index.js)。

就命名空间/范围而言,两种结构都是等价的。我们的新 src/math/mod.rssrc/math.rs具有完全相同的内容, 并且我们的 src/main.rs 完全不变。

事实上,如果如果我们定义了 math 模块的子模块, folder/mod.rs 结构更加易于理解。

假设我们想添加一个 sub 函数,因为我们强制执行“一个函数一个文件”的限制,我们希望 addsub 存在于各自的模块中。

我们现在的文件结构如下:

src/
    main.rs
    math/
        mod.rs
        add.rs (新文件!)
        sub.rs (也是新文件!)

概念上而言,命名空间树如下:

crate (src/main.rs)
    `math` 模块 (src/math/mod.rs)
        `add` 模块 (src/math/add.rs)
        `sub` 模块 (src/math/sub.rs)

我们的 src/main.rs 不需要做很大改动 - math 仍在相同位置。我们只是让它使用 addsub

// 保证 math 在 `./math.rs` 或 `./math/mod.rs` 中定义
mod math;

// 将两个符号带入范围,在 `math` 模块中保证都已导出
use math::{add, sub};

fn main() {
    let result = add(1, 2);
    println!("1 + 2 = {}", result);
}

我们的 src/math/add.rs 正如我们在 math 模块做的一样:定义一个函数,并用 pub 将其导出。

pub fn add(x: i32, y: i32) -> i32 {
    x + y
}

类似地,src/math/sub.rs 文件如下:

pub fn sub(x: i32, y: i32) -> i32 {
    x - y
}

现在来看 src/math/mod.rs。我们知道 cargo 知道 math 这个模块存在, 因为 src/main.rs 中的 mod math; 语句已将其导入。 但我们需要让 cargo 也知道 addsub 模块。

所以我们需要在 src/math/mod.rs 添加如下语句;

mod add;
mod sub;

现在 cargo 知晓所有源文件。

crate 能编译成功吗?(剧透一下:没有哦)

Compiling modules v0.1.0 (/home/amos/Dev/modules)
error[E0603]: module `add` is private
 --> src/main.rs:2:12
  |
2 | use math::{add, sub};
  |            ^^^

error[E0603]: module `sub` is private
 --> src/main.rs:2:17
  |
2 | use math::{add, sub};
  |                 ^^^

发生了什么?好吧,按现在的写法,主模块看起来是这样的:

crate (我们在这儿)
    `math` 模块
        (空的)

所以 math::add 不是一个有效路径,因为 math 模块没有导出任何东西。

好吧,我猜我们可以直接在 mod 前加上 pub

src/math/mod.rs 做如下修改:

pub mod add;
pub mod sub;

又一次,编译不通过:

Compiling modules v0.1.0 (/home/amos/Dev/modules)
error[E0423]: expected function, found module `add`
 --> src/main.rs:5:18
  |
5 |     let result = add(1, 2);
  |                  ^^^ not a function
help: possible better candidate is found in another module, you can import it into scope
  |
2 | use crate::math::add::add;
  |

rustc 给出了明确的信息 - 现在我们公开了 addsub 模块,我们的 crate 模块结构如下:

crate (我们在这)
    `math` 模块
        `add` 模块
            `add` 函数
        `sub` 模块
            `sub` 函数

但这和期望略有差距。math 的两个子模块组成涉及实现细节。我们并不希望导出这两个模块 - 我们也不希望任何人直接导入这两个模块!

所以回到声明和导入子模块的地方,让这两个模块变为私有,然后分别重新导出它们的 addsub 函数。

// 子模块是私有的
mod add;
mod sub;

// 这些是重导出函数
pub use add::add;
pub use sub::sub;

这样改变后,从 src/math/mod.rs 角度看,模块结构如下:

`math` 模块(我们在这)
    `add` 函数(公开)
    `sub` 函数(公开)
    `add` 模块(私有)
        `add` 函数(公开)
    `sub` 模块(私有)
        `sub` 函数(公开)

然而,从 src/main.rs 角度看,模块结构如下:

crate (你在这)
    `math` 模块
        `add` 模块
        `sub` 模块

我们已经成功隐藏 math 模块的实现细节 - 只有 addsub 函数被导出。

果然,现在 crate 编译成功且运行良好。

回顾一下,这是目前完整的文件。

src/main.rs

mod math;
use math::{add, sub};

fn main() {
    let result = add(1, 2);
    println!("1 + 2 = {}", result);
}

src/math/mod.rs

mod add;
mod sub;

pub use add::add;
pub use sub::sub;

src/math/add.rs

pub fn add(x: i32, y: i32) -> i32 {
    x + y
}

src/math/sub.rs

pub fn sub(x: i32, y: i32) -> i32 {
    x - y
}

未使用的导入和符号

如果你用编辑器跟随写到现在,你会注意到 rustc(rust 编译器,由 cargo 调用)抛出一个 warning:

warning: unused import: `sub`
 --> src/main.rs:2:17
  |
2 | use math::{add, sub};
  |                 ^^^
  |
  = note: #[warn(unused_imports)] on by default

的确,现在我们没有在主函数使用 sub。如果我们像下面那样在 use 指令中把它去掉会怎样?

mod math;
use math::add;

fn main() {
    let result = add(1, 2);
    println!("1 + 2 = {}", result);
}

现在 rust 又抛出了错误:

warning: function is never used: `sub`
 --> src/math/sub.rs:1:1
  |
1 | pub fn sub(x: i32, y: i32) -> i32 {
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: #[warn(dead_code)] on by default

解释非常简单。目前在 crate 中,sub 没有在其他地方导出。它在 src/math/sub.rs 中定义, 由 src/math/mod.rs 重新导出。math 模块在且仅在 src/main.rs 可用 - 但我们没有在主模块中使用它。

所以我们让编译器去解析一个源文件,进行类型检查和所有权检查 - 但 sub 函数在最后的可执行文件并没有出现。即使我们想把 crate 作为一个库,sub 函数依然不可用,因为它并没有在程序入口导出。

我们有几个选项。如果想让 crate 既是一个可执行项目和库,仅需让 math 模块变为公开就可以了。

src/lib.rs 里:

// 现在不必使用 `math` 模块里的所有符号,
// 因为我们让他们对所有依赖可见。
pub mod math;

或者,我们可以去掉 sub 函数(毕竟我们没有它)。如果我们知道之后将会使用它,可以对某个函数关闭 warning

src/math/sub.rs 中:

// 这不是好主意
#[allow(unused)]
pub fn sub(x: i32, y: i32) -> i32 {
    x - y
}

但我真的推荐这样做。一旦添加这个注解很容易忘掉死代码。记住,寻找 unused 是很难的。 这是源码控制该干的。但如果你想要,它仍是一个选择。

但这确实回答了一个你可能一直在问自己的问题:“仅仅 use 我真正需要的东西是不是更好,所以剩下的不会被编译/包含在最终的二进制文件中吗?”。 答案是:没关系。

使用通配符导入符号(如 use::some_crate::*;)的唯一害处是污染命名空间。但编译器还是会解析所有源文件,把没有使用的部分去掉(通过消灭死代码),不管命名空间有什么。

目前我们仅使用了那些命名空间/符号树深处的符号。

但如果需要,我们也可以使用父级命名空间里。

假设我们希望 math 模块有一个模块级的常量来开启或关闭日志。

(注意,这样控制日志是一个糟糕的做法,我只是暂时想不到其他愚蠢的例子)。

现在将 src/math/mod.rs 做如下修改:

mod add;
mod sub;

pub use add::add;
pub use sub::sub;

const DEBUG: bool = true;

然后我们可以在其他模块引用 DEBUG,比如 src/math/add.rs

pub fn add(x: i32, y: i32) -> i32 {
    if super::DEBUG {
        println!("add({}, {})", x, y);
    }
    x + y
}

意料之中,编译通过且成功运行:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.03s
     Running `target/debug/modules`
add(1, 2)
1 + 2 = 3

注意:一个模块总是可以访问其父级作用域(通过 super::)- 即便是是父级作用域的私有变量、私有函数等。 DEBUG 是私有的,但我们可以在 add 模块中使用它。

如果我们要定义rust关键字和文件路径惯用语之间的对应关系,我们可以映射:

  • crate::foo/foo - 如果我们认为“根文件系统”为包含 main.rslib.rs 的目录
  • super::foo../foo
  • self::foo./foo

什么时候会需要使用 self 呢?

好吧,对于 src/math/mod.rs 如下两行:

pub use add::add;
pub use sub::sub;

我们可以用单行代码实现:

pub use self::{add:add, sub::sub};

假设子模块只导出了我们希望使用的符号,我们甚至可以使用通配符:

pub use self::{add::*, sub::*};

好吧,同级模块(如 addsub)之间没有直接访问的路径。

如果想在 add 中重新定义 sub,我们在 src/math/sub.rs 不能这样做:

// 编译不通过
pub fn sub(x: i32, y: i32) -> i32 {
    add::add(x, -y)
}

addsub 共享父级模块,但不意味他们共享命名空间。

我们也绝对不应该使用第二个 modadd 模块已存在于模块层次结构中的某个位置。 除此之外 - 因为它是 sub 的子模块,它要么存在于 src/math/sub/add.rssrc/math/sub/add/mod.rs 中 - 这两者都没有意义。

如果我们想访问 add, 必须通过父级模块,就像其他人一样。在 src/math/sub.rs 中:

pub fn sub(x: i32, y: i32) -> i32 {
    super::add::add(x, -y)
}

或者使用 src/math/mod.rs 重新导出的 add

pub fn sub(x: i32, y: i32) -> i32 {
    super::add(x, -y)
}

或者简单地导入 add 模块下的所有东西:

pub fn sub(x: i32, y: i32) -> i32 {
    use super::add::*;
    add(x, -y)
}

请注意,函数有它自己的作用域,所以 use 不会影响这个模块其他地方。

你甚至可以用 {} 限制作用域!

pub fn sub(x: i32, y: i32) -> i32 {
    let add = "something else";
    let res = {
        // 在这个代码块中,`add` 是 `add` 模块导出的函数
        use super::add::*;
        add(x, -y)
    };
    // 现在我们离开代码块,`add` 又变为 "something else"
    res
}

preclude 模式

随着 crate 变得复杂,模块层次也更复杂。除了从 crate 入口导出所有东西, 一些 crate 选择一下最常用的符号并在 prelude 中导出他们。

chrono 就是一个好例子。

查看它在 https://docs.rs 上的文档,它的主入口导出如下东西:

所以如果这样写:

use chrono::*;

将会在作用域内导入 serde,这会遮盖 serde crate。

这也是为什么 chrono 使用 preclude 模块,这个模块只导出如下内容:

我希望这些能澄清 rust 的模块和文件,如果有任何疑问,请在 Twitter上告诉我。感谢阅读!


[^1]: 具体配置参考 Cargo教程


Github 博客地址:

https://privaterookie.github.io/2019-07-14-Rust%E6%A8%A1%E5%9D%97%E4%B8%8E%E6%96%87%E4%BB%B6/​privaterookie.github.io

Rust 社区文章链接:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK