原文連結: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.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
)到模組程式碼塊中- 參考 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.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 function
help: 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
中匯出他們。
chrono 就是一個好例子。
檢視它在 https://docs.rs 上的文件,它的主入口匯出如下東西:
所以如果這樣寫:
use chrono::*;
將會在作用域內匯入 serde
,這會遮蓋 serde
crate。
這也是為什麼 chrono 使用 preclude
模組,這個模組只匯出如下內容:
結論
我希望這些能澄清 rust 的模組和檔案,如果有任何疑問,請在 Twitter上告訴我。感謝閱讀!
Github 部落格地址:Rust 模組與檔案
知乎專欄:夜雨秋燈錄 - Rust模組與檔案
- <p>具體配置參考 <a href="https://rustlang-cn.org/office/rust/cargo/">Cargo教程</a> <a href="#fnref1:1" rev="footnote" class="footnote-backref">↩</a></p>