7.2 路徑與 use 關鍵字#
模組樹建立起來之後,緊接著的問題就是:怎麼從一個位置走到另一個位置?Rust 的答案是路徑(path),而 use 關鍵字則是把路徑上的項目「拉」進目前作用域的捷徑。這一節要把路徑的三個關鍵字、use 的各種寫法,以及它們最常見的編譯期錯誤一次講清楚。
一、路徑的三種起點#
Rust 的路徑由一系列用 :: 連接的片段組成,每個片段代表一個模組、型別或項目。路徑可以從三個起點出發,這三個起點由三個關鍵字表示。
crate:: 代表目前的 crate 根。從它出發的路徑永遠是絕對的,不受呼叫位置影響:
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
pub fn eat_at_restaurant() {
crate::front_of_house::hosting::add_to_waitlist();
}
fn main() {
eat_at_restaurant();
}
self:: 代表目前的模組,也就是 use 敘述所在的那一層:
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
pub fn eat_at_restaurant() {
self::hosting::add_to_waitlist();
}
}
fn main() {
front_of_house::eat_at_restaurant();
}
super:: 代表父模組。它特別適合在巢狀模組的實作裡,引用上一層的型別或函式:
pub fn eat_at_restaurant() {
println!("歡迎光臨");
}
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
pub fn eat_at_restaurant() {
super::super::eat_at_restaurant();
}
}
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
}
fn main() {
front_of_house::hosting::eat_at_restaurant();
}
這裡的 super::super:: 往上跳了兩層,從 hosting 模組走到 crate 根。這種寫法在巢狀較深時會變得冗長,實務上更常見的做法是在 use 敘述裡就把它綁定成一個短名稱。
| 起點 | 意義 | 典型用途 |
|---|---|---|
crate:: | 目前的 crate 根模組 | 從深處回到頂層,路徑不受位置影響 |
self:: | 目前的模組 | 明確標示「同層的項目」 |
super:: | 父模組 | 在巢狀模組中引用上一層 |
crate:: 與 super:: 的差別在實務上非常明顯。crate:: 是絕對路徑,永遠指向同一個地方;super:: 是相對路徑,指向當前的父層。當你在 config::parser 模組裡寫 super::value::Value 時,它指的是 config::value::Value;但若把這個模組搬到別的位置,super:: 的意義會跟著改變,而 crate::config::value::Value 則不會。
二、use 的作用#
use 關鍵字把某個路徑引入當前作用域,讓你可以用簡短的名字直接引用它,而不必每次都打出完整路徑。
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
use crate::front_of_house::hosting;
fn main() {
hosting::add_to_waitlist();
}
use 導入的預設是私有的,也就是只有目前模組能用。若要把它變成公開的重新導出,需要加上 pub:
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
pub use hosting::add_to_waitlist;
}
fn main() {
front_of_house::add_to_waitlist();
}
這個 pub use 的效果相當於「在 front_of_house 模組裡定義一個指向 hosting::add_to_waitlist 的別名,並且它是公開的」。它不會移動任何東西,只是提供了另一條通往同一函式的路徑。
這一點在實際程式碼裡可以觀察到:use 引入的項目在作用域內就像是在本地宣告的一樣,因此對同一個項目連續 use 兩次會產生「名稱重複定義」的編譯期錯誤。Rust 沒有 C++ 裡 using namespace std; 那樣的全域匯入(use ...::*; 只是在單一模組作用域內做有限的匯入),所以名稱衝突總是能被編譯器在局部範圍內指認出來。
use 的對象是任何一個具名項目:函式、結構體、列舉、列舉的變體、常數、靜態變數、模組,甚至 trait 本身都可以被引入。列舉的變體也能單獨引入,這在處理大量變體時相當方便:
#[derive(Debug, Clone, Copy, PartialEq)]
enum Color {
Red,
Green,
Blue,
}
use Color::{Blue, Green, Red};
fn main() {
let c = Red;
println!("{c:?} {} {}", c == Green, c == Blue);
}
引入變體之後,Red 就像本地定義的列舉變體一樣可以直接使用。引入 trait 的寫法在實務上特別常見:
use std::fmt::Display;
fn show<T: Display>(value: &T) {
println!("{value}");
}
fn main() {
show(&42);
show(&"hello");
}
在這裡,use std::fmt::Display; 把 trait 引入作用域,使得 Display 這個名稱可以直接用於泛型限制,而不必寫完整的 std::fmt::Display。這是第 9 章會正式討論的特徵語法,但在路徑系統的層面,它與引入任何其他項目沒有差別。
三、作用域與路徑的解析順序#
Rust 的名稱解析(name resolution)有一套明確的規則,理解它能避免許多「明明有這個函式卻說找不到」的困惑。
最基本的一條是:名稱從目前作用域開始,逐層向外搜尋,直到找到為止。這與 C++ 的規則相同,但因為 Rust 對私有項目也遵循同樣的規則,所以搜尋的結果可能是一個「在這個位置看得到、但在那個位置看不到」的項目。
第二條規則是同名項目在同一个作用域內不能重複。若你在一個模組裡 use 了 Foo,又在同一個模組裡定義了 Foo,編譯器會報 E0255。這條規則是「不歧義」的直接後果。
第三條規則涉及 use 宣告的位置。Rust 2018 版之後,use 宣告的作用域是它所在的模組,與宣告的先後位置無關——這意味著你可以把 use 全部集中在檔案頂端,不必擔心它是否在使用點之前。Rust 2015 版則是單純的詞法作用域,use 必須在使用之前宣告。
第四條規則最容易被忽略:在函式內部的 use 只在該函式內有效:
mod tools {
pub fn helper() -> i32 {
7
}
}
fn compute() -> i32 {
use crate::tools::helper;
helper()
}
fn main() {
println!("{}", compute());
}
把 use 放在函式內,可以讓「這個函式用到了哪些外部名稱」一目了然,也避免污染模組作用域。當一個函式用到的名稱很少,或是與模組層級的其他名稱衝突時,這是很好的做法。
為了讓這些規則更好管理,Rust 社群普遍採用 use 宣告分組的慣例:先列crate 自己的模組,再列外部 crate,最後列 std,各組之間以空行分隔:
use crate::config::{parse, Config};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt::Display;
rustfmt 預設會把 use 宣告依字母排序,並且在排序時把 self、super、crate 放在最前面——這個順序讓「從本地 crate 引入」和「從外部 crate 引入」在視覺上分開,進一步強化了可讀性。rustfmt 工具會自動維持 use 宣告的字母序,但不會自動加上分組的分隔空行,那需要人工處理或搭配 rustfmt.toml 設定。
四、別名#
use 支援用 as 關鍵字指定別名。這在兩種情況下特別有用:原本的名字太冗長,或兩個型別撞名需要區分。
use std::collections::HashMap as Map;
use std::collections::HashMap;
fn main() {
let mut counts: Map<String, i32> = Map::new();
counts.insert(String::from("a"), 1);
println!("{:?} {}", counts, counts.len());
}
在 Rust 2015 版中,別名是處理「兩邊都有 HashMap」這類衝突的標準手段;但在 2018 版之後,你可以直接用巢狀群組一次匯入多個項目,不必再靠別名迴避:
use std::collections::{HashMap, BTreeMap};
fn main() {
let mut a: HashMap<String, i32> = HashMap::new();
a.insert(String::from("k"), 1);
let mut b: BTreeMap<String, i32> = BTreeMap::new();
b.insert(String::from("k"), 2);
println!("{} {}", a.len(), b.len());
}
別名仍然有它的位置:像 use std::collections::HashMap as Map; 這樣的簡寫,能讓註解型的程式碼讀起來更輕鬆;又或者當同一個 crate 的兩個路徑提供了同名但不同用途的型別時,別名能把它們清楚區分開來。
五、巢狀匯入與群組#
use 最靈活的地方在於它能把多個層級的項目一次引入,而且支援兩種巢狀寫法。
第一種是巢狀群組(nested groups),用一組括號把多個項目一次匯入:
use std::collections::{HashMap, BTreeMap, HashSet};
use std::io::{Read, Write};
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert(String::from("a"), 1);
let mut s: HashSet<i32> = HashSet::new();
s.insert(7);
let mut b: BTreeMap<i32, i32> = BTreeMap::new();
b.insert(1, 1);
println!("{} {} {}", m.len(), s.len(), b.len());
let _ = std::io::stdout().flush();
}
第二種是巢狀路徑(nested paths),在括號裡直接展開一條完整路徑:
use std::collections::{HashMap, hash_map::Entry};
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert(String::from("a"), 1);
match m.entry(String::from("a")) {
Entry::Occupied(e) => {
println!("已存在,值為 {}", e.get());
}
Entry::Vacant(e) => {
e.insert(0);
println!("新增了一筆");
}
}
}
另外還有一種常見寫法是在群組中混入模組本身:
use std::collections::{HashMap, hash_map};
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert(String::from("a"), 1);
let entry = m.entry(String::from("a"));
match entry {
hash_map::Entry::Occupied(e) => println!("已存在:{}", e.get()),
hash_map::Entry::Vacant(e) => {
e.insert(0);
println!("新增一筆");
}
}
println!("{}", m.len());
}
這裡 hash_map 是一個模組,被整個匯入了作用域,因此可以寫 hash_map::Entry。這種寫法在需要使用同一模組內的多個型別時相當實用。
要注意的是,Rust 的 use 路徑必須從 crate 根或外部 crate 的名稱開始(除非使用 self::、super:: 等前綴)。這與 C++ 的 using namespace 完全不同:Rust 鼓勵你在匯入時就把路徑寫完整,之後在程式碼裡使用簡短的名稱。這項設計讓「這個名稱來自哪裡」永遠可以從檔案開頭的 use 清單查到。
六、全域匯入的取捨#
use 還支援一項特殊語法:use 路徑::*;。星號代表「把這個模組裡所有公開的項目都引入」。
use std::collections::*;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert(String::from("a"), 1);
let mut s: HashSet<i32> = HashSet::new();
s.insert(1);
println!("{} {}", m.len(), s.len());
}
星號匯入的範圍是受限的:它只匯入該模組中公開可見的項目,而且只影響宣告它的作用域。這兩點限制讓它比 C++ 的 using namespace 安全得多,但仍不足以消除辨識成本。
另一個限制是它無法與私有項目互動。若某個模組裡有一個 pub(crate) 的函式,星號匯入不會把它帶進來,因為那樣就會破壞可見性邊界了。當你看到程式碼裡出現 HashMap,你無法判斷它是從 std::collections 來的,還是從別的某個模組來的;你也無法一眼看出這個檔案依賴了哪些東西。當兩個 use 都用了星號而且都含有同名項目時,還會產生歧義錯誤。
實務上的建議是:在正式程式碼中避免使用全域匯入,把它留給測試模組。在 #[cfg(test)] mod tests 裡,測試會把一堆東西拉進同一個作用域,用星號可以省掉大量重複的 use 行:
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn works() {
let mut m = HashMap::new();
m.insert(1, 2);
assert_eq!(m.get(&1), Some(&2));
}
}
fn main() {
println!("測試模組之外的進入點");
}
注意即使在測試裡,use super::*; 也只匯入父模組中可見的項目,因為星號不會匯入私有的內容——這是 Rust 用隱私性保護程式碼的一個小而一致的例子。
七、pub use 與穩定的公開 API#
pub use 是塑造公開 API 的核心工具。它讓你可以把深藏在內部模組裡的項目,重新導出到一個穩定的、扁平的位置。
mod parsing {
pub struct Token {
pub text: String,
}
pub fn tokenize(input: &str) -> Vec<Token> {
input
.split_whitespace()
.map(|t| Token {
text: t.to_string(),
})
.collect()
}
}
mod formatting {
pub fn render(tokens: &[super::parsing::Token]) -> String {
tokens
.iter()
.map(|t| t.text.as_str())
.collect::<Vec<&str>>()
.join(" ")
}
}
pub use parsing::{tokenize, Token};
pub use formatting::render;
fn main() {
let tokens = tokenize("hello world");
println!("{}", render(&tokens));
}
使用者在 crate 根寫 use mytool::tokenize; 就能取得函式,完全不需要知道 parsing 模組的存在。這帶來兩個性質。
第一是抽象層級的可控性。公開 API 反映的是使用者的需求(「我要把文字轉成 token」),而不是實作者的檔案佈局(「解析器放在 parsing 模組」)。這是抽象介面原則在 Rust 模組系統裡的直接體現。
第二是重構的自由。只要 crate 根的 pub use 維持不變,你可以任意重新排列內部模組、把一個模組拆成三個、把函式搬到別的檔案,使用者都不受影響。反過來說,如果當初省略了 pub use,使用者就會被迫依賴 mytool::parsing::tokenize 這樣的路徑,未來任何一次內部重構都變成了破壞性變更。
八、常見的編譯期錯誤#
這三類錯誤有一個共同點:它們全部是編譯期的,而且錯誤訊息會直接點名是什麼路徑出了問題、從哪裡開始解析。與多數語言「執行期才發現找不到方法」相比,Rust 把這類問題提前到了第一次編譯。
E0603:模組是私有的。 這個錯誤出現在你從外部存取一個沒有 pub 的模組時:
mod private_module {
pub fn helper() -> i32 {
42
}
}
fn main() {
println!("{}", private_module::helper());
}
上面這段程式碼在 crate 內部是可以編譯的,因為父模組有權存取子模組的私有項目;但當另一個 crate 想要 use mytool::private_module; 時,就會得到 E0603 錯誤,訊息會明確指出「模組 private_module 是私有的」。
修正的方式是兩種:把模組改成 pub mod,或者在 crate 根加上 pub use private_module::helper; 重新導出。後者通常更好,因為它只公開你真正想公開的那一個項目,而不是整個模組。
E0432:未解析的匯入。 這個錯誤表示 use 敘述裡的路徑找不到對應的項目。最常見的原因是拼錯了名稱,或是在 Rust 2015 與 2018 之間的語法差異(例如 2015 需要 extern crate)。另一個常見原因是路徑的起點搞錯了——在 Rust 2018 之後,use 預設從 crate 根開始,若想用相對路徑必須顯式寫 self:: 或 super::。
E0659:歧義。 這個錯誤出現在同一個名稱被匯入多次,而且程式碼中無法判斷該用哪一個:
use std::collections::HashMap;
use std::collections::hash_map::HashMap;
編譯器會指出衝突的匯入:
error[E0252]: the name `HashMap` is defined multiple times
--> src/main.rs:1:5
|
1 | use std::collections::HashMap;
| ------------------------- previous import of the type `HashMap` here
2 | use std::collections::hash_map::HashMap;
| ----------------------------------- same name imported here
修正方式是移除其中一個 use,或用別名把它們區分開:
use std::collections::HashMap;
use std::collections::hash_map::HashMap as Map;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert(String::from("a"), 1);
let mut n: Map<String, i32> = Map::new();
n.insert(String::from("b"), 2);
println!("{} {}", m.len(), n.len());
}
歧義錯誤是好事:它寧可在編譯期拒絕,也不會讓你猜錯。
九、模組設計的原則#
「模組邊界即封裝邊界」是 Rust 模組設計最核心的原則。每一個模組都應該代表一個有清楚職責的單位,並且只公開其他單位真正需要的東西。
這個原則可以拆成幾條可操作的規則。第一,模組名稱應該是名詞,描述它「是什麼」而不是「做什麼」;動作應該留給其中的函式。第二,公開的表面越小越好,pub 的每一個都應該有理由。第三,跨模組的依賴應該是單向的,若兩個模組互相依賴,通常表示它們其實應該合併,或該抽出第三個模組。
第四條規則特別實用:模組的公開介面應該可以用一句話說清楚。若你無法用一句話描述某個模組提供什麼功能,通常表示它太雜亂了,該拆開。
第六條規則處理的是模組的粒度。一個模組如果只有十幾行程式碼,拆分成本可能高於收益;反過來,一個兩千行的模組則應該立刻拆開。實務上,一個模組最好能用一句話說清楚它的職責,並且能在單一畫面上讀完。Rust 編譯器對「單一檔案過大」並沒有任何警告,因此這條規則完全仰賴團隊的紀律。
第七條規則是把「型別」與「行為」放在同一個模組裡。Rust 沒有繼承,型別與其方法通常在同一個模組,這與 C++ 把型別與操作拆成不同標頭檔的傳統不同。若一個型別的方法散落在多個模組裡,閱讀者需要同時開啟多個檔案才能理解這個型別的行為,這是 Rust 社群普遍不採用的做法。
最後一條是謹慎對待 pub use 的位置。重新導出的宣告最好集中在 crate 根的 lib.rs,或是某個功能模組的 mod.rs 頂端。把它散落在各處,會讓「這個 crate 到底公開了什麼」變得難以回答,而這正是公開 API 面最需要清楚的地方。
十、把一個工具模組從私有改為公開 API#
最後讓我們看一個具體的重構案例。假設我們有一個內部使用的工具模組:
mod string_utils {
pub fn reverse(input: &str) -> String {
input.chars().rev().collect()
}
pub fn is_palindrome(input: &str) -> bool {
let cleaned: Vec<char> = input.chars().filter(|c| !c.is_whitespace()).collect();
cleaned.iter().rev().eq(cleaned.iter())
}
}
fn main() {
println!("{}", string_utils::reverse("Rust"));
println!("{}", string_utils::is_palindrome("never odd or even"));
}
這個模組目前是私有的,crate 外部無法使用。若我們希望它成為 crate 公開 API 的一部分,修改點只有兩處:把 mod 改成 pub mod,並在 crate 根加上 pub use 重新導出,讓使用者的匯入路徑不必包含模組名:
mod string_utils {
pub fn reverse(input: &str) -> String {
input.chars().rev().collect()
}
pub fn is_palindrome(input: &str) -> bool {
let cleaned: Vec<char> = input.chars().filter(|c| !c.is_whitespace()).collect();
cleaned.iter().rev().eq(cleaned.iter())
}
}
pub use string_utils::{is_palindrome, reverse};
fn main() {
println!("{}", reverse("Rust"));
println!("{}", is_palindrome("never odd or even"));
}
這個例子裡還有一個細節值得注意:模組本身宣告為 mod string_utils(私有),但透過 pub use 把其中的函式提升到 crate 根。也就是說,crate 外部可以看到 mytool::reverse,卻看不到 mytool::string_utils 這個模組。這種「只公開項目、不公開容器」的做法,是 Rust 中塑造精簡 API 面的常用手段,比直接寫 pub mod 更有彈性,也比完全不公開實用得多。
如果後來發現 string_utils 裡有三個函式都想公開,只需把它們加進 pub use 的清單即可,其他部分完全不動。反過來,若某個函式不打算公開,它就該留在 mod string_utils 裡不加 pub use,同時把函式本身的 pub 改成私有或 pub(crate)。可見性的層次是可以細緻調整的,而這正是模組系統作為封裝機制的價值所在。
graph TD
A["crate 根模組"] --> B["mod front_of_house"]
A --> C["pub use front_of_house::hosting"]
B --> D["pub mod hosting"]
D --> E["pub fn add_to_waitlist"]
A --> F["use crate::front_of_house::hosting<br/>引入到目前作用域"]
F --> G["hosting::add_to_waitlist()"]
B --> H["super:: 指向父層"]
D --> I["super::super:: 往上跳兩層"]
C --> J["對外公開的扁平 API 面"]這張圖把本章的觀念串在一起:模組樹定義了結構,可見性修飾符定義了邊界,use 定義了捷徑,而 pub use 則定義了對外承諾。當這四者搭配得當時,一個 crate 的內部可以任意重構,而它的使用者完全不受影響——這正是 Rust 能在保持靈活度的同時,提供清晰 API 契約的原因。
十一、小結#
Rust 的路徑系統只有三個起點:crate:: 指向絕對的根,self:: 指向當前層,super:: 指向父層。這三個關鍵字涵蓋了所有需求,也讓路徑的意義變得完全可預測。
use 則是把路徑變短的機制,它支援別名、巢狀群組、巢狀路徑與星號匯入,每一種都有各自的適用場景。而 pub use 是其中最重要的一項:它是把內部組織與對外 API 解耦的槓桿,讓 crate 能在不改變使用者程式碼的前提下自由重構。
回顧整節,crate::、self::、super:: 這三個關鍵字提供了一個完全可預測的路徑解析模型:從哪裡出發、走哪一層、到哪裡為止,每一步都寫在程式碼裡。use 則是把這些冗長的路徑壓縮成簡短名稱的機制,它支援別名、群組、巢狀路徑與星號匯入,涵蓋了幾乎所有會遇到的導入需求。
而所有這些機制的最終目的,都服務於同一件事:讓公開的介面與內部的組織解耦。pub use 是這件事最直接的槓桿,也是 Rust 函式庫能夠提供穩定 API 契約的技術基礎。
掌握這些之後,第 7 章的最後一節要處理的是一個更實際的問題:如何把標準庫之外的第三方 crate 引入專案。