原文鏈接: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/)
閱讀更多 PrivateRookie 的文章