切換語言為:簡體

Rust中的專案、包和模組介紹

  • 爱糖宝
  • 2024-07-07
  • 2090
  • 0
  • 0

包和模組的基本概念

當工程規模變大時,把程式碼寫到一個甚至幾個檔案中,都是不太聰明的做法,可能存在以下問題:

  1. 單個檔案過大,導致開啟、翻頁速度大幅變慢

  2. 查詢和定位效率大幅降低,類比下,你會把所有知識內容放在一個幾十萬字的文件中嗎?

  3. 只有一個程式碼層次:函式,難以維護和協作,想象一下你的作業系統只有一個根目錄,剩下的都是單層子目錄會如何:disaster

  4. 容易滋生 Bug

同時,將大的程式碼檔案拆分成包和模組,還允許我們實現程式碼抽象和複用:將你的程式碼封裝好後提供給使用者,那麼使用者只需要呼叫公共介面即可,無需知道內部該如何實現。

因此,跟其它語言一樣,Rust 也提供了相應概念用於程式碼的組織管理:

包和Package

事實上,在真實的專案中,遠比我們之前的 cargo new 的預設目錄結構要複雜,好在,Rust為我們提供了強大的包管理工具:

  • 專案(Package) :可以用來構建、測試和分享包

  • 工作空間(WorkSpace):對於大型專案,可以進一步將多個包聯合在一起,組織成工作空間

  • 包(Crate) :一個由多個模組組成的樹形結構,可以作為三方庫進行分發,也可以生成可執行檔案進行執行

  • 模組(Module) :可以一個檔案多個模組,也可以一個檔案一個模組,模組可以被認為是真實專案中的程式碼組織單元

其實專案 Package 和包 Crate 很容易被搞混,甚至在很多書中,這兩者都是不分的,但是由於官方對此做了明確的區分,因此我們會在本節中試圖(掙扎著)理清這個概念。

包 Crate

對於 Rust 而言,包是一個獨立的可編譯單元,它編譯後會生成一個可執行檔案或者一個庫。

一個包會將相關聯的功能打包在一起,使得該功能可以很方便的在多個專案中分享。例如標準庫中沒有提供但是在三方庫中提供的 rand 包,它提供了隨機數生成的功能,我們只需要將該包透過 use rand; 引入到當前專案的作用域中,就可以在專案中使用 rand 的功能:rand::XXX

同一個包中不能有同名的型別,但是在不同包中就可以。例如,雖然 rand 包中,有一個 Rng 特徵,可是我們依然可以在自己的專案中定義一個 Rng,前者透過 rand::Rng 訪問,後者透過 Rng 訪問,對於編譯器而言,這兩者的邊界非常清晰,不會存在引用歧義。

專案 Package

鑑於 Rust 團隊標新立異的起名傳統,以及包的名稱被 crate 佔用,庫的名稱被 library 佔用,經過斟酌, 我們決定將 Package 翻譯成專案,你也可以理解為工程、軟體包。

由於 Package 就是一個專案,因此它包含有獨立的 Cargo.toml 檔案,以及因為功能性被組織在一起的一個或多個包。一個 Package 只能包含一個庫(library)型別的包,但是可以包含多個二進制可執行型別的包。

二進制 Package

讓我們來建立一個二進制的Package:

$ cargo new my-project
     Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

這裏,Cargo 為我們建立了一個名稱是 my-project 的 Package,同時在其中建立了 Cargo.toml 檔案,可以看一下該檔案,裡面並沒有提到 src/main.rs 作為程式的入口,原因是 Cargo 有一個慣例:src/main.rs 是二進制包的根檔案,該二進制包的包名跟所屬 Package 相同,在這裏都是 my-project,所有的程式碼執行都從該檔案中的 fn main() 函式開始。

使用 cargo run 可以執行該專案,輸出:Hello, world!

庫 Package

再來建立一個庫型別的 Package

$ cargo new my-lib --lib
     Created library `my-lib` package
$ ls my-lib
Cargo.toml
src
$ ls my-lib/src
lib.rs

首先,如果你試圖執行 my-lib,會報錯:

$ cargo run
error: a bin target must be available for `cargo run`

原因是庫型別的 Package 只能作為三方庫被其它專案引用,而不能獨立執行,只有之前的二進制 Package 纔可以執行。

與 src/main.rs 一樣,Cargo 知道,如果一個 Package 包含有 src/lib.rs,意味它包含有一個庫型別的同名包 my-lib,該包的根檔案是 src/lib.rs

容易混淆的Package和包

看完上面,相信大家看出來為何 Package 和包容易被混淆了吧?因為你用 cargo new 建立的 Package 和它其中包含的包是同名的!

不過,只要你牢記 Package 是一個專案工程,而包只是一個編譯單元,基本上也就不會混淆這個兩個概念了:src/main.rs 和 src/lib.rs 都是編譯單元,因此它們都是包。

典型的Package結構

上面建立的 Package 中僅包含 src/main.rs 檔案,意味著它僅包含一個二進制同名包 my-project。如果一個 Package 同時擁有 src/main.rs 和 src/lib.rs,那就意味著它包含兩個包:庫包和二進制包,這兩個包名也都是 my-project —— 都與 Package 同名。

一個真實專案中典型的 Package,會包含多個二進制包,這些包檔案被放在 src/bin 目錄下,每一個檔案都是獨立的二進制包,同時也會包含一個庫包,該包只能存在一個 src/lib.rs

.
├── Cargo.toml
├── Cargo.lock
├── src
│   ├── main.rs
│   ├── lib.rs
│   └── bin
│       └── main1.rs
│       └── main2.rs
├── tests
│   └── some_integration_tests.rs
├── benches
│   └── simple_bench.rs
└── examples
    └── simple_example.rs

  • 唯一庫包:src/lib.rs

  • 預設二進制包:src/main.rs,編譯後生成的可執行檔案與 Package 同名

  • 其餘二進制包:src/bin/main1.rs 和 src/bin/main2.rs,它們會分別生成一個檔案同名的二進制可執行檔案

  • 整合測試檔案:tests 目錄下

  • 基準效能測試 benchmark 檔案:benches 目錄下

  • 專案示例:examples 目錄下

這種目錄結構基本上是 Rust 的標準目錄結構,在 GitHub 的大多數專案上,你都將看到它的身影。

理解了包的概念,我們再來看看構成包的基本單元:模組。

Module

模組。使用模組可以將包中的程式碼按照功能性進行重組,最終實現更好的可讀性及易用性。同時,我們還能非常靈活地去控制程式碼的可見性,進一步強化 Rust 的安全性。

建立巢狀模組

使用 cargo new --lib restaurant 建立一個小餐館,注意,這裏建立的是一個庫型別的 Package,然後將以下程式碼放入 src/lib.rs 中:

// 餐廳前廳,用於吃飯
mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}

以上的程式碼建立了三個模組,有幾點需要注意的:

  • 使用 mod 關鍵字來建立新模組,後面緊跟著模組名稱

  • 模組可以巢狀,這裏巢狀的原因是招待客人和服務都發生在前廳,因此我們的程式碼模擬了真實場景

  • 模組中可以定義各種 Rust 型別,例如函式、結構體、列舉、特徵等

  • 所有模組均定義在同一個檔案中

類似上述程式碼中所做的,使用模組,我們就能將功能相關的程式碼組織到一起,然後透過一個模組名稱來說明這些程式碼為何被組織在一起。這樣其它程式設計師在使用你的模組時,就可以更快地理解和上手。

模組樹

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment

這顆樹展示了模組之間彼此的巢狀關係,因此被稱為模組樹。其中 crate 包根是 src/lib.rs 檔案,包根檔案中的三個模組分別形成了模組樹的剩餘部分。

父子模組

如果模組 A 包含模組 B,那麼 A 是 B 的父模組,B 是 A 的子模組。在上例中,front_of_house 是 hosting 和 serving 的父模組,反之,後兩者是前者的子模組。

聰明的讀者,應該能聯想到,模組樹跟計算機上檔案系統目錄樹的相似之處。不僅僅是組織結構上的相似,就連使用方式都很相似:每個檔案都有自己的路徑,使用者可以透過這些路徑使用它們,在 Rust 中,我們也透過路徑的方式來引用模組。

用路徑引用模組

想要呼叫一個函式,就需要知道它的路徑,在 Rust 中,這種路徑有兩種形式:

  • 絕對路徑,從包根開始,路徑名以包名或者 crate 作為開頭

  • 相對路徑,從當前模組開始,以 selfsuper 或當前模組的識別符號作為開頭

讓我們繼續經營那個慘淡的小餐館,這次為它實現一個小功能: 檔名:src/lib.rs

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // 絕對路徑
    crate::front_of_house::hosting::add_to_waitlist();

    // 相對路徑
    front_of_house::hosting::add_to_waitlist();
}

上面的程式碼爲了簡化實現,省去了其餘模組和函式,這樣可以把關注點放在函式呼叫上。eat_at_restaurant 是一個定義在包根中的函式,在該函式中使用了兩種方式對 add_to_waitlist 進行呼叫。

因為 eat_at_restaurant 和 add_to_waitlist 都定義在一個包中,因此在絕對路徑引用時,可以直接以 crate 開頭,然後逐層引用,每一層之間使用 :: 分隔:

crate::front_of_house::hosting::add_to_waitlist();

對比下之前的模組樹:

crate
 └── eat_at_restaurant
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment

可以看出,絕對路徑的呼叫,完全符合了模組樹的層級遞進,非常符合直覺,如果類比檔案系統,就跟使用絕對路徑呼叫可執行程式差不多:/front_of_house/hosting/add_to_waitlist,使用 crate 作為開始就和使用 / 作為開始一樣。

再回到模組樹中,因為 eat_at_restaurant 和 front_of_house 都處於包根 crate 中,因此相對路徑可以使用 front_of_house 作為開頭:

front_of_house::hosting::add_to_waitlist();

如果類比檔案系統,那麼它類似於呼叫同一個目錄下的程式,你可以這麼做:front_of_house/hosting/add_to_waitlist,嗯也很符合直覺。

使用絕對還是相對?

如果只是爲了引用到指定模組中的物件,那麼兩種都可以,但是在實際使用時,需要遵循一個原則:當代碼被挪動位置時,儘量減少引用路徑的修改,相信大家都遇到過,修改了某處程式碼,導致所有路徑都要挨個替換,這顯然不是好的路徑選擇。

回到之前的例子,如果我們把 front_of_house 模組和 eat_at_restaurant 移動到一個模組中 customer_experience,那麼絕對路徑的引用方式就必須進行修改:crate::customer_experience::front_of_house ...,但是假設我們使用的相對路徑,那麼該路徑就無需修改,因為它們兩個的相對位置其實沒有變:

crate
 └── customer_experience
    └── eat_at_restaurant
    └── front_of_house
        ├── hosting
        │   ├── add_to_waitlist
        │   └── seat_at_table

從新的模組樹中可以很清晰的看出這一點。

程式碼可見性

讓我們執行下面(之前)的程式碼:

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // 絕對路徑
    crate::front_of_house::hosting::add_to_waitlist();

    // 相對路徑
    front_of_house::hosting::add_to_waitlist();
}

意料之外的報錯了,畢竟看上去確實很簡單且沒有任何問題:

error[E0603]: module `hosting` is private
 --> src/lib.rs:9:28
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                            ^^^^^^^ private module

錯誤資訊很清晰:hosting 模組是私有的,無法在包根進行訪問,那麼為何 front_of_house 模組就可以訪問?因為它和 eat_at_restaurant 同屬於一個包根作用域內,同一個模組內的程式碼自然不存在私有化問題(所以我們之前章節的程式碼都沒有報過這個錯誤!)。

模組不僅僅對於組織程式碼很有用,它還能定義程式碼的私有化邊界:在這個邊界內,什麼內容能讓外界看到,什麼內容不能,都有很明確的定義。因此,如果希望讓函式或者結構體等型別變成私有化的,可以使用模組。

Rust 出於安全的考慮,預設情況下,所有的型別都是私有化的,包括函式、方法、結構體、列舉、常量,是的,就連模組本身也是私有化的。

pub關鍵字

類似其它語言的 public 或者 Go 語言中的首字母大寫,Rust 提供了 pub 關鍵字,透過它你可以控制模組和模組中指定項的可見性。

由於之前的解釋,我們知道了只需要將 hosting 模組標記為對外可見即可:

mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

/*--- snip ----*/

但是不幸的是,又報錯了:

error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:12:30
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                              ^^^^^^^^^^^^^^^ private function

哦?難道模組可見還不夠,還需要將函式 add_to_waitlist 標記為可見的嗎? 是的,沒錯,模組可見性不代表模組內部項的可見性,模組的可見性僅僅是允許其它模組去引用它,但是想要引用它內部的項,還得繼續將對應的項標記為 pub

在實際專案中,一個模組需要對外暴露的資料和 API 往往就寥寥數個,如果將模組標記為可見代表著內部項也全部對外可見,那你是不是還得把那些不可見的,一個一個標記為 private?反而是更麻煩的多。

既然知道了如何解決,那麼我們為函式也標記上 pub

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

/*--- snip ----*/

順利透過編譯,感覺自己又變強了。

0則評論

您的電子郵件等資訊不會被公開,以下所有項目均必填

OK! You can skip this field.