Rust 模組和檔案 - [譯]

PrivateRookie發表於2019-07-14

原文連結: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)到模組程式碼塊中

但這不是通常匯入模組的方式。按照慣例,如果使用不跟隨程式碼塊的 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 上的文件,它的主入口匯出如下東西:

chrono-exports.png

所以如果這樣寫:

use chrono::*;

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

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

chrono-prelude-exports.png

結論

我希望這些能澄清 rust 的模組和檔案,如果有任何疑問,請在 Twitter上告訴我。感謝閱讀!



Github 部落格地址:Rust 模組與檔案

知乎專欄:夜雨秋燈錄 - Rust模組與檔案


  1. <p>具體配置參考 <a href="https://rustlang-cn.org/office/rust/cargo/">Cargo教程</a>&#160;<a href="#fnref1:1" rev="footnote" class="footnote-backref">&#8617;</a></p>

多少事,從來急。天地轉,光陰迫。

相關文章