包和模組的基本概念
當工程規模變大時,把程式碼寫到一個甚至幾個檔案中,都是不太聰明的做法,可能存在以下問題:
單個檔案過大,導致開啟、翻頁速度大幅變慢
查詢和定位效率大幅降低,類比下,你會把所有知識內容放在一個幾十萬字的文件中嗎?
只有一個程式碼層次:函式,難以維護和協作,想象一下你的作業系統只有一個根目錄,剩下的都是單層子目錄會如何:
disaster
容易滋生 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
作為開頭相對路徑,從當前模組開始,以
self
,super
或當前模組的識別符號作為開頭
讓我們繼續經營那個慘淡的小餐館,這次為它實現一個小功能: 檔名: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 ----*/
順利透過編譯,感覺自己又變強了。