Rust 模塊和文件


Rust 模塊和文件 - 「譯」


原文鏈接:https://amos.me/blog/2019/rust-modules-vs-files/


不久前,我在推特上發起了 https://twitter.com/fasterthanlime/status/1142183262779052051話題,熱度最高的主題是“模塊系統是怎麼映射到文件的?”。

我記得剛接觸 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 的 https://crates.io/crates/rand - 上面通常包含了一個類似 README 文件,包含了簡要描述和一些代碼示例
  • rand 的 https://rust-random.github.io/rand/rand/index.html(在 crates.io 頁面標題或最新版本下有鏈接)。需要注意的是所有發表在 crates.io 的 crate 會在 https://docs.rs 上生成文件 - 我不確定為什麼 rand 也文檔部署在它自己的網頁,或許它早於 docs.rs?
  • 它的 https://github.com/rust-random/rand,如果其他方式(如 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 errorFor 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.rs 和 src/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)到模塊代碼塊中 參考 https://doc.rust-lang.org/stable/std/macro.include.html

但這不是通常導入模塊的方式。按照慣例,如果使用不跟隨代碼塊的 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.rs 與src/math.rs具有完全相同的內容,並且我們的 src/main.rs 完全不變。

事實上,如果如果我們定義了 math 模塊的子模塊, folder/mod.rs 結構更加易於理解。

假設我們想添加一個 sub 函數,因為我們強制執行“一個函數一個文件”的限制,我們希望 add 和 sub 存在於各自的模塊中。

我們現在的文件結構如下:

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 仍在相同位置。我們只是讓它使用 add 和 sub:

// 保證 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 也知道 add 和 sub 模塊。

所以我們需要在 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 functionhelp: possible better candidate is found in another module, you can import it into scope  |2 | use crate::math::add::add;  |

rustc 給出了明確的信息 - 現在我們公開了 add 和 sub 模塊,我們的 crate 模塊結構如下:

crate (我們在這)    `math` 模塊        `add` 模塊            `add` 函數        `sub` 模塊            `sub` 函數

但這和期望略有差距。math 的兩個子模塊組成涉及實現細節。我們並不希望導出這兩個模塊 - 我們也不希望任何人直接導入這兩個模塊!

所以回到聲明和導入子模塊的地方,讓這兩個模塊變為私有,然後分別重新導出它們的 add 和 sub 函數。

// 子模塊是私有的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 模塊的實現細節 - 只有 add 和 sub 函數被導出。

果然,現在 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.rs 或 lib.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::*};


同級模塊

好吧,同級模塊(如 add 和 sub)之間沒有直接訪問的路徑。

如果想在 add 中重新定義 sub,我們在 src/math/sub.rs 不能這樣做:

// 編譯不通過pub fn sub(x: i32, y: i32) -> i32 {    add::add(x, -y)}

add 和 sub 共享父級模塊,但不意味他們共享命名空間。

我們也絕對不應該使用第二個 mod。 add 模塊已存在於模塊層次結構中的某個位置。除此之外 - 因為它是 sub 的子模塊,它要麼存在於 src/math/sub/add.rs 或 src/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 中導出他們。

https://crates.io/crates/chrono 就是一個好例子。

查看它在 https://docs.rs 上的文檔,它的主入口導出如下東西:

https://i.postimg.cc/Ls4jVFKT/chrono-exports.png](https://postimg.cc/dhX7qX0k)

所以如果這樣寫:

use chrono::*;

將會在作用域內導入 serde,這會遮蓋 serde crate。

這也是為什麼 chrono 使用 preclude 模塊,這個模塊只導出如下內容:

https://i.postimg.cc/7PJ09Ncp/chrono-prelude-exports.png](https://postimg.cc/6Tw85CB0)

結論

我希望這些能澄清 rust 的模塊和文件,如果有任何疑問,請在 https://twitter.com/fasterthanlime上告訴我。感謝閱讀!


[^1]: 具體配置參考 [Cargo教程](https://rustlang-cn.org/office/rust/cargo/)


分享到:


相關文章: