我們先前所練習的Rust程式幾乎都只是把程式敘述寫在main.rs檔案中,雖然我們已經會使用函數和方法來分割不同功能的程式,但當程式愈寫愈多的時候,這樣的作法還是會讓程式變得難以維護。這時就需要用到Rust提供的「模組」系統了。



模組的使用方式就跟我們撰寫函數的方式差不多,我們可以將類似功能的不同函數、方法和其所使用到的結構體、列舉包裝成相同的模組。模組就是一個型別和函數的「命名空間」(namespace),命名空間可以將程式碼有效區隔開來,不互相影響。模組除了可以將型別和函數群組化之外,還可以決定模組要不要公開給其他專案使用。

模組和程式專案的檔案結構有關,讓我們從建立程式專案開始吧!以往我們建立Cargo程式專案的時候都加了--bin參數,讓Cargo建立出可執行的應用程式專案,但是現在要將其改為--lib參數,讓Cargo建立出函式庫的程式專案。

使用以下指令,可以建立出Cargo的函式庫程式專案communicator

cargo new --lib communicator

rust-module

接著移動工作目錄到專案根目錄下。由於這個是函式庫程式專案,Cargo並沒有在src目錄中產生main.rs,取而代之的是,它產生出了一個陌生的lib.rs,檔案內容如下:

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

這段程式我們先不管它,將它留在lib.rs檔案的最底部。由於現在使用的專案沒有main方法了,因此不可以使用cargo run來執行程式專案。如果要測試程式碼有沒有撰寫正確,先使用cargo check吧!

我們打算讓communicator專案處理網路相關的通訊問題,一開始我們先使用mod關鍵字來定義出一個名叫network的模組,並且在模組內再定義一個connect函數。程式如下:

mod network {
    fn connect() {}
}

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

如果要在network模組外呼叫network模組中的connect函數,需要透過::語法來呼叫。程式碼如下:

network::connect();

當然我們也可以使用多個mod關鍵字建立出多個不同的模組。如下:

mod network {
    fn connect() {}
}

mod client {
    fn connect() {}
}

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

network模組和client模組內都有connect函數,它們的connect函數在使用上不會有衝突。如果要呼叫network模組的connect函數,程式敘述就寫network::connect();如果要呼叫client模組的connect函數,程式敘述就寫client::connect()

雖然我們現在正在使用Cargo的函式庫專案,但「模組」其實也可以被定義在應用程式專案上,並沒有限制。此外,模組內也可以再有其它的「子模組」。舉例來說:

mod network {
    fn connect() {}

    mod client {
        fn connect() {}
    }
}

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

以上程式有兩個不同的connect函數,分別是network::connectnetwork::client::connect。此時模組的組織架構如下:

communicator
 └── network
     └── client

communicator專案作為crate被引用到其它專案時,connect函數的呼叫方式就變成communicator::network::connect()communicator::network::client::connect()。當然,目前的程式碼實際上無法讓network模組直接被引用到其它專案,還必須與pub關鍵字搭配使用才行,這部份將在之後介紹。

現在雖然我們已經知道怎麼將程式碼用mod關鍵字切成模組了,但如果通通都寫在lib.rs檔案內,累積起來後也是會因太多而難以維護。因此,我們要將模組寫在不同的檔案內!

先看看以下程式:

mod client {
    fn connect() {}
}

mod network {
    fn connect() {}

    mod server {
        fn connect() {}
    }
}

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

模組的組織架構如下:

communicator
 ├── client
 └── network
     └── server

一步一步進行,把clientnetworkserver都分到不同的檔案中。首先,習慣上,為避免lib.rs的內容過多,我們只會在lib.rs中留下要定義的模組名稱,程式如下:

mod client;

mod network;

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

Cargo在編譯函式庫專案的時候會先來看lib.rs檔案,當它發現lib.rs檔案有定義模組名稱卻未實作的時候,會有以下兩種情形發生:

1. 如果同目錄中有模組名稱.rs檔案的話,就會引用進來。
2. 如果同目錄中有模組名稱目錄,且同目錄中沒有模組名稱.rs檔案的話,就會引用模組名稱目錄中的mod.rs檔案。

如果第一種和第二種情形同時發生,會編譯錯誤。我們讓client模組用第一種方式引用進來,而network則用第二種方式。引用的情形會像是底下這個樣子:

mod client {
    // content from src/client.rs
}

mod network {
    // content from src/network/mod.rs
}

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

lib.rs檔案中定義要使用的模組名稱後,在同樣的src目錄中建立出client.rs檔案,內容如下:

fn connect() {}

這裡要注意的是,用原始碼檔案將模組的程式碼分開之後,該模組的原始碼檔案中就不用再寫mod關鍵字了。換句話說就是,因為client.rs已經定義了這支程式是一個名為client的模組,我們就不再需要寫mod client {}來定義這個模組內的東西。

到這裡我們已經完成client模組的部份,接下來看看network模組和其子模組server要怎麼做吧!

在src目錄中建立network目錄,並在network目錄中建立mod.rs檔案,內容如下:

fn connect() {}

mod server;

注意程式第3行,我們又再度使用mod關鍵字定義模組名稱,而不將它實作出來,所以又要按照剛才提到的那兩個規則引用其它檔案啦!

由於server模組並沒有子模組,因此我們可以直接在目前的network目錄中再建立server.rs檔案,內容如下:

fn connect() {}

如此一來就已經成功將所有模組分散到不同檔案了!

實際上用第一種方式來建立模組也可以讓它擁有子模組,只需要在旁邊建立出與該模組同名的目錄,並在該目錄中放置子模組的原始碼檔案即可,不過要注意的是不要使用到mod.rs這個檔名。不過通常我們還是會直接使用第二種,也就是目錄的方式來設計模組和它的子模組,因為這樣結構會比較清楚。例如剛才的練習的client模組,因為現在它並沒有用到子模組,因此我們可以直接在src目錄下建立client.rs檔案來實作它。

嘗試使用cargo check檢查目前的communicator專案,編譯雖然成功,但是有這個警告訊息function `connect` is never used

rust-module

這是因為目前三個模組中的connect函數,都是私有的狀態,且也沒有被呼叫過任何一次。為了解決這個警告,且讓引用這個communicator專案的專案也能使用這三個模組中的connect函數,我們必須使用pub關鍵字,將connect函數的存取權限設為公開的。用pub關鍵字將程式修改如下:

pub fn connect() {}
pub fn connect() {}

mod server;
pub fn connect() {}

重新使用cargo check編譯程式專案,編譯雖然成功,但會發現剛才的警告訊息function `connect` is never used依然存在,這是因為如果我們不將子模組和模組也都設為公開的話,子模組外和communicator專案外根本還是沒有辦法呼叫到三個模組中的connect函數。

rust-module

pub關鍵字將程式修改如下:

pub fn connect() {}

pub mod server;
pub mod client;

pub mod network;

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

修改後再次使用cargo check編譯程式專案,編譯成功,警告訊息也沒了!

rust-module

沒有使用pub關鍵字設定為公開的模組、函數、型別等項目都會被當作是私有的,而私有的項目之就只有在該模組底下或是該模組的子模組才能存取。另外,使用use關鍵字將命名空間加入至目前程式的scope,其命名空間內的公開資源對於目前程式的scope來說是私有的,如非在use關鍵字前也加上pub關鍵字。舉例來說:

mod outermost {
    pub fn middle_function() {}

    fn middle_secret_function() {}

    mod inside {
        pub fn inner_function() {}

        fn secret_function() {}
    }
}

fn try_me() {
    outermost::middle_function();
    outermost::middle_secret_function();
    outermost::inside::inner_function();
    outermost::inside::secret_function();
}

以上程式,第15、16、17行程式會編譯錯誤。第15行程式因為middle_secret_function函數不是公開的所以編譯錯誤,而第16、17行程式因為inside子模組不是公開的所以編譯錯誤。

若將inside子模組加上pub關鍵字改為公開的,程式如下:

mod outermost {
    pub fn middle_function() {}

    fn middle_secret_function() {}

    pub mod inside {
        pub fn inner_function() {}

        fn secret_function() {}
    }
}

fn try_me() {
    outermost::middle_function();
    outermost::middle_secret_function();
    outermost::inside::inner_function();
    outermost::inside::secret_function();
}

第16行程式會編譯成功,而第17行程式則因為secret_function函數不是公開的,所以編譯錯誤。

將第15行和第17行程式先註解掉,程式如下:

mod outermost {
    pub fn middle_function() {}

    fn middle_secret_function() {}

    pub mod inside {
        pub fn inner_function() {}

        pub fn secret_function() {}
    }
}

fn try_me() {
    outermost::middle_function();
    // outermost::middle_secret_function();
    outermost::inside::inner_function();
    // outermost::inside::secret_function();
}

既然剛才提到私有項目可以給子模組使用,那如果我們想在inner_function函數中呼叫middle_secret_function函數,有辦法做到嗎?程式敘述直接寫middle_secret_function()

mod outermost {
    pub fn middle_function() {}

    fn middle_secret_function() {}

    pub mod inside {
        pub fn inner_function() {
            middle_secret_function();
        }

        pub fn secret_function() {}
    }
}

fn try_me() {
    outermost::middle_function();
    // outermost::middle_secret_function();
    outermost::inside::inner_function();
    // outermost::inside::secret_function();
}

以上程式第8行,我們直接將程式敘述寫成middle_secret_function(),卻造成編譯錯誤,這是因為我們必須要把命名空間寫清楚,指定是要呼叫哪裡的middle_secret_function函數。程式改寫如下:

mod outermost {
    pub fn middle_function() {}

    fn middle_secret_function() {}

    pub mod inside {
        pub fn inner_function() {
            crate::outermost::middle_secret_function();
        }

        pub fn secret_function() {}
    }
}

fn try_me() {
    outermost::middle_function();
    // outermost::middle_secret_function();
    outermost::inside::inner_function();
    // outermost::inside::secret_function();
}

以上程式可以編譯成功。程式第8行,直接以crate::語法開頭來指定命名空間,代表要從目前這個程式的crate開始算起;crate::outermost代表要指定使用目前這個程式的crate底下的outermost模組。

此外,也可以利用super關鍵字來指定回到上一層的命名空間,程式改寫如下:

mod outermost {
    pub fn middle_function() {}

    fn middle_secret_function() {}

    pub mod inside {
        pub fn inner_function() {
            super::middle_secret_function();
        }

        pub fn secret_function() {}
    }
}

fn try_me() {
    outermost::middle_function();
    // outermost::middle_secret_function();
    outermost::inside::inner_function();
    // outermost::inside::secret_function();
}

以上程式可以編譯成功。程式第8行,super::表示要使用inside模組的上一層命名空間,也就是outermost

除了crate::super::語法之外,我們也可以直接以::開頭。在Rust 2015的版本中,::的功能和crate::是一樣的,但是在Rust 2018的版本中,::的功能只能夠用來引用外部crate下的項目。

完成我們的函式庫程式專案後,該怎麼將它引用至可執行應用程式專案中呢?繼續拿剛才完成的communicator函式庫程式專案來舉例,我們先用Cargo產生一個可執行的應用程式專案communicator_exe,指令如下:

cargo new --bin communicator_exe

rust-module

編輯communicator_exe程式專案的Cargo.toml設定檔,在dependencies區塊加入communicator程式專案的設定,如下:

[package]
name = "communicator_exe"
version = "0.1.0"
edition = "2021"

[dependencies]
communicator = { path = "../communicator" }

此時,若要在communicator_exe專案中呼叫communicator專案下的network::server::connect()函數,程式如下:

fn main() {
    communicator::network::server::connect();
}

這邊要注意的是,如果函式庫程式專案的名稱,也就是在函式庫程式專案裡的Cargo.toml設定檔寫的專案名稱為communicator的話,在被其它專案引用時,Cargo.toml也要使用相同的名稱,不能隨意更改。但如果要更改的話,可以在Cargo.tomldependencies區塊中使用package選項來指定要相依的套件名稱(該函式庫原本的名稱),如此一來就我們可以自訂要用什麼名稱來表示該函式庫了。如下設定,在communicator_exe專案中,可以使用comm這個名稱來表示communicator函式庫專案。

[package]
name = "communicator_exe"
version = "0.1.0"
edition = "2021"

[dependencies]
com = { package = "communicator", path = "../communicator" }

如此一來,若要在communicator_exe專案中呼叫communicator::network::server::connect()函數,就變成要寫成com::network::server::connect()。如下:

fn main() {
    com::network::server::connect();
}

我們也可以利用use關鍵字和as關鍵字在特定scope下改變crate、函數、型別等項目的名稱。如下:

例如:

use communicator as com;

fn main() {
    com::network::server::connect();
}

即便我們利用上面的方式將程式碼稍微縮短了,但com::network::server::connect()這樣的語法還是很長,不太好寫。這時可以考慮利用use關鍵字,將指定命名空間下的函數、型別等項目加入至目前程式的scope。舉例來說:

use communicator as com;

use com::client;
use com::network;

use network::server;

fn main() {
    server::connect();
}

use關鍵字可以搭配{}語法使用,可以一次將某個命名空間下的多個項目加進程式。舉例來說:

use communicator as com;

use com::{client, network};

use network::server;

fn main() {
    server::connect();
}

也支援巢狀用法。舉例來說:

use communicator as com;

use com::{client, network, network::server};

fn main() {
    server::connect();
}

use關鍵字甚至能直接使用星號*,來表示某個命名空間下的所有項目。舉例來說:

use communicator as com;

use com::*;

use network::*;

fn main() {
    server::connect();
}

最後我們來看一下communicator程式專案的lib.rs,在一開始我們忽略的由Cargo自動產生出來的tests模組。程式如下:

pub mod client;

pub mod network;

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

我們之前都忽略了lib.rs中的tests,將其考慮進來後,communicator程式專案的組織架構如下:

communicator
 ├── client
 ├── network
 |   └── client
 └── tests

因為有在tests模組定義前使用#[cfg(test)],因此這個tests模組只會在我們使用cargo test來測試程式專案的時候才會被用到。當然tests模組的名稱其實不一定要叫作tests。這個加上#[cfg(test)]定義出來模組,就像是一個擁有許多測試案例的函數集合,定義在這個模組中的函數,如果再加上#[test]的話,在使用cargo test來測試程式專案的時候,該函數就會被執行。

所以現在我們的communicator程式專案,使用cargo test進行測試的結果如下圖:

rust-module

我們可以在tests模組中,搭配use關鍵字來將我們要測試的其它模組加入至tests模組的scope,方便進行測試。修改後的程式如下:

pub mod client;

pub mod network;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        client::connect();
        network::connect();
        network::server::connect();
    }
}

有關於更詳細的程式專案測試方式,會在之後的章節作介紹。

總結

學習完這個章節的內容後,我們已經有能力組織管理我們的Rust程式碼,甚至還可以將我們撰寫的函式庫程式專案分享給其他人使用了。在接下來的章節,我們要來學習Rust程式語言內建的VecHashMap等常用的集合資料結構。

下一章:常用的集合