Cargo package是Cargo.toml描述的交付单元,crate是一次编译的单元,模块则在crate内组织名称与可见性。
文件夹不会自动成为模块,use也不会加载文件;把pub加到目标项上,仍不保证外部代码有可达路径。
从crate root声明模块树,只开放所需路径,并用pub use在稳定边界重导出公共API。
是什么,为什么存在
Rust用三个不同层次组织代码。 Cargo package 是带有Cargo.toml的项目交付单元; crate 是编译器一次处理的库或可执行程序; 模块(module) 则是一个crate内部的命名与可见性单元。日常说的“项目”“包”和“库”容易把这三层混在一起,读编译错误时必须使用准确名称。
一个package至少包含一个crate。它最多有一个library crate,却可以有多个binary crate;常规布局中,src/lib.rs是library crate的入口,src/main.rs和src/bin/下的目标是binary crate的入口。每个入口文件都是相应crate的 crate root ,同时形成模块树的根模块。
crate内的模块把相关项放进层级命名空间,并决定哪些实现细节能从哪些位置访问。路径指出目标项在模块树中的位置,use只把已有路径绑定到当前作用域,pub及其受限形式则控制可见范围。模块因此同时解决名称冲突、代码导航和封装边界,而不只是拆分文件。
当一个文件开始混合多个职责,或者库使用者不得依赖内部目录时,就需要模块边界。crate边界更重:它形成独立编译目标,也形成库与可执行程序之间的接口边界。工作空间、依赖解析和发布属于Cargo层,不能用模块规则替代。
工作原理
三层结构
下面这张图展示一个同时包含库和可执行程序的package。两个crate分别拥有根模块;图中的catalog与pricing只属于library crate的模块树。
package名和crate名通常接近,但它们不是同一种标识符。Cargo package可以叫store-domain,Rust源码中的默认library crate路径则写作store_domain。crate::更不是package名,它从当前crate的根模块开始解析。
模块声明与文件
mod catalog { ... }在当前位置声明并定义内联模块。mod catalog;声明相同的逻辑模块,但让编译器从外部文件读取其内容。在src/lib.rs中写mod catalog;时,常规候选是src/catalog.rs或src/catalog/mod.rs,两者不能同时存在。
子模块的文件位置由逻辑父模块决定。若src/catalog.rs包含mod pricing;,编译器会查找src/catalog/pricing.rs或src/catalog/pricing/mod.rs。移动模块定义到文件不会改变crate::catalog::pricing这样的逻辑路径;文件系统只是模块树的一种存放方式。
文件不会因为位于src/下就自动参与编译。某个可达模块中的mod声明才把它接入crate的模块树。相反,use crate::catalog::Product;不声明模块,也不控制要编译哪些文件;它只让Product这个短名称在当前作用域可用。
绝对路径与相对路径
以crate::开头的路径从当前crate根模块解析,适合表达跨越多个同级模块的稳定位置。self::从当前模块开始,super::从父模块开始;不带这些前缀的路径先按当前作用域中的名称解析。外部crate通常直接以crate名开头,例如serde::Serialize。
在一个模块内部,super::很适合访问紧邻父模块的协作项;跨越较远边界时,crate::往往更清楚。路径选择不是可见性的替代品:路径解析到某个项后,编译器仍会检查调用位置是否有权访问路径上的每一层。
use支持嵌套导入、self和as。类型通常直接导入,容易冲突的函数或同名类型则保留父模块名,或者用反映领域含义的别名。use默认只影响它所在的作用域;子模块不会自动继承父模块写下的导入。
可见性沿路径生效
普通项默认是私有的,只能由定义它的模块及其后代访问。pub扩大项的可见范围,但从某个调用点访问它时,路径上的祖先模块也必须可访问。公开结构体的字段仍分别控制可见性;公开枚举的变体则默认公开。
受限可见性把接口停在更窄的边界。pub(crate)对当前crate可见,pub(super)对父模块范围可见,pub(in crate::some_module)对指定祖先模块范围可见。在Rust 2018及之后的edition中,pub(in ...)的路径必须从crate、self或super开始。
重导出(re-export) 使用pub use为已有公共项创建另一条公共路径。它既能把深层实现路径提升到crate root,也能让内部模块本身保持私有。重导出改变调用方能使用的API路径,但不会复制定义,也不会让原路径自动变得公开。
示例
内联模块、绝对路径与相对路径
第一个示例把运费逻辑放在shipping模块中。根模块只能调用公开的quote;子模块tracking则可以用super::调用父模块的私有辅助函数。
mod shipping {
const BASE_CENTS: u32 = 250;
fn zone(code: &str) -> &str {
if code.starts_with("EU-") { "EU" } else { "OTHER" }
}
pub fn quote(code: &str, weight_kg: u32) -> u32 {
BASE_CENTS + weight_kg * 80 + tracking::zone_fee(code)
}
mod tracking {
pub(super) fn zone_fee(code: &str) -> u32 {
if super::zone(code) == "EU" { 0 } else { 500 }
}
}
}
fn main() {
let cents = crate::shipping::quote("EU-FR", 3);
println!("shipping: {cents} cents");
}shipping: 490 centszone对shipping及其后代私有,所以tracking可以访问它。zone_fee标成pub(super)后,父模块shipping可以访问它,但crate root不能直接调用它。这里的两个限制共同留下一个有意设计的公开入口。
导入同名项
billing与shipping都提供total,直接把两个函数导入同一作用域会发生名称冲突。一个保留模块前缀,另一个使用领域别名,可以让调用点继续表达来源。
mod billing {
pub fn total(subtotal: u32) -> u32 {
subtotal + subtotal / 5
}
}
mod shipping {
pub fn total(weight_kg: u32) -> u32 {
250 + weight_kg * 80
}
}
mod report {
use crate::billing::total as invoice_total;
use crate::shipping;
pub fn print() {
let goods = invoice_total(1_000);
let delivery = shipping::total(3);
println!("invoice: {goods}");
println!("shipping: {delivery}");
}
}
fn main() {
report::print();
}invoice: 1200
shipping: 490别名invoice_total描述了业务含义,不只是为了绕过编译器。保留shipping::total则符合“函数通过父模块调用”的常见风格。两种选择可以并存,关键是调用点能看出使用的是哪个total。
用重导出构造公共API
下面的store模块模拟library crate的根模块。catalog保持私有,外部代码只通过根模块重导出的Product和quote使用功能。
mod store {
mod catalog {
pub struct Product {
name: String,
cents: u32,
}
impl Product {
pub fn new(name: &str, cents: u32) -> Self {
Self { name: name.into(), cents }
}
pub fn label(&self) -> &str {
&self.name
}
}
pub fn quote(product: &Product, count: u32) -> u32 {
product.cents * count
}
}
pub use catalog::{Product, quote};
}
fn main() {
let keyboard = store::Product::new("Keyboard", 4_500);
let cents = store::quote(&keyboard, 2);
println!("{}: {cents} cents", keyboard.label());
}Keyboard: 9000 centsProduct是公开类型,但name和cents字段仍然私有,调用方必须经过构造函数与方法。pub use提供store::Product,因此内部以后可以移动catalog,只要保留这条公共路径和行为,调用方就不必随之修改。
跨同级模块使用crate内接口
最后一个示例让checkout调用同级的inventory。库存查询不是crate的外部API,因此使用pub(crate);checkout::can_ship才是根模块选择公开的入口。
mod inventory {
pub(crate) fn available(sku: &str) -> u32 {
match sku {
"KB-01" => 8,
"MS-02" => 0,
_ => 0,
}
}
}
mod checkout {
pub fn can_ship(sku: &str, requested: u32) -> bool {
crate::inventory::available(sku) >= requested
}
}
fn main() {
println!("KB-01 x3: {}", checkout::can_ship("KB-01", 3));
println!("MS-02 x1: {}", checkout::can_ship("MS-02", 1));
}KB-01 x3: true
MS-02 x1: falsepub(crate)不是“以后可能会公开”的占位符,而是一项可检查的边界声明。它允许crate内的同级协作,同时阻止依赖此library crate的代码绑定到库存模块的内部查询方式。
陷阱
修复方法: 先从Cargo.toml与常规目标文件确认package中有哪些crate,再从每个crate root沿mod声明画出模块树。讨论依赖与发布时说package,讨论编译目标时说crate,讨论内部命名空间时说模块。
修复方法: 在逻辑父模块中只写一次mod orders;,并选择orders.rs或orders/mod.rs其中一种入口。子模块声明放在父模块的定义中,而不是一律堆到lib.rs。
修复方法: 决定调用方应长期依赖的路径,再公开必要祖先或在稳定边界pub use目标项。用外部风格的集成测试验证公共路径,不要只在定义模块的后代中测试。
修复方法: 从binary crate按library crate名导入,例如package store-domain的默认库通常写作use store_domain::Product;。共享逻辑放入library crate,让每个二进制成为它的普通调用方。
修复方法: 导入实际使用的类型,函数则保留父模块前缀或使用明确别名。若提供prelude,要把它视为经过设计的公共API,并审查每次新增重导出。
文件树不是模块树
常规Cargo布局让两棵树看起来相似,但编译器依据声明构造模块树。假设library crate有以下逻辑结构:
crate
└── catalog
├── Product
└── pricing
└── quote一种对应文件布局是src/lib.rs声明mod catalog;,src/catalog.rs再声明mod pricing;,实现位于src/catalog/pricing.rs。也可以把catalog的入口放在src/catalog/mod.rs,但不能与src/catalog.rs并存。入口文件的选择不改变crate::catalog::pricing::quote这条逻辑路径。
#[path = "..."]可以覆盖外部模块的默认文件位置,但它会切断读者对常规映射的预期。只有生成代码、平台分支或遗留布局确实需要时才使用,并把该属性留在逻辑父模块附近。普通业务模块采用常规命名更容易被编辑器、构建工具和人类定位。
edition不会自动迁移目录
现代Rust允许catalog.rs搭配catalog/pricing.rs,不再要求所有带子模块的入口都叫mod.rs。旧布局仍然有效,所以升级edition不要求机械改名。真正的约束是同一位置不能同时用两种入口表示同一个模块。
公共API是可达路径集合
可见性检查针对使用位置和完整路径。深层项的pub表示它可以在祖先允许的范围内继续传播,不代表所有外部代码已经能命名它。公开API因此不是源文件中所有pub的简单列表,而是外部调用方实际可达的路径集合。
pub use internal::Product;可以让私有internal模块中的公开Product通过crate root可达。调用方绑定的是新路径,定义仍只存在一份。门面模式利用这一点把目录重构与公共API分离,但删除或改名既有重导出仍可能是破坏性变更。
公开类型的签名也会把其他类型带到接口边界。如果公共函数返回只能通过私有路径描述的类型,编译器的私有接口检查会报告问题,或者调用方会得到难以使用的API。审查公共函数时,要沿参数、返回值、trait bound与关联类型继续检查可达性。
结构体与枚举的边界不同
pub struct Product不会自动公开字段,这允许构造函数维持不变量。若生成代码把所有字段都标成pub,调用方就能绕过验证并依赖表示细节。与之不同,pub enum Status的变体默认公开,因为调用方通常需要构造或匹配这些变体。
package内的crate边界
src/lib.rs与src/main.rs同时存在时,Cargo会构建两个crate。library crate可以公开共享领域逻辑;binary crate通过库的crate名使用它,就像同package之外的调用方一样。二进制自己的crate::始终指向main.rs形成的根模块。
src/bin/import.rs、examples/demo.rs和tests/api.rs也分别形成编译目标。尤其是tests/下的每个集成测试都作为独立crate编译,所以它只能使用library crate的公共API。这个边界使集成测试很适合验证重导出路径是否真的对外可用。
crate边界还决定pub(crate)的范围。某个项对library crate内部公开,并不意味着同package的binary或集成测试可以访问它。若调用方确实需要该能力,就设计稳定的pub接口;若只为测试内部实现,优先在相应模块的后代测试模块中访问私有项。
Cargo目标形成独立根
常见package可以用下面的文件布局同时表达一个库、两个可执行程序和一个集成测试。每个目标都有自己的crate root,而catalog.rs只是library crate模块树中的一个文件。
store-domain/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── main.rs
│ ├── catalog.rs
│ └── bin/
│ └── importer.rs
└── tests/
└── public_api.rssrc/main.rs生成默认二进制,src/bin/importer.rs生成另一个二进制。它们都可以依赖src/lib.rs生成的库,但彼此的私有模块不会合并。若main.rs与importer.rs都需要某段逻辑,应把它放进library crate的合适模块并公开最小接口,而不是复制实现。
集成测试tests/public_api.rs也从外部使用库,这与写在src/lib.rs内的#[cfg(test)] mod tests不同。后者是library crate模块树的后代,能够访问祖先模块的私有项;前者形成独立crate,只能看到公开可达路径。选择测试位置时应明确要验证内部实现还是外部契约。
导入绑定与API路径
use声明创建的是当前作用域中的名称绑定。默认的use是私有绑定,方便当前模块及其后代引用目标;pub use则让该绑定按声明的可见范围供其他模块使用。两者都不改变目标项原来所在的位置,也不会创建第二份类型。
同一个项可以通过多条路径访问。若crate root写下pub use catalog::Product;,库内部仍可使用crate::catalog::Product,外部调用方则使用store_domain::Product。这两个名称指向相同类型,所以不会产生转换或运行时包装。
嵌套导入只是减少重复前缀。例如use std::io::{self, Read, Write};同时绑定模块名io、trait Read和trait Write。花括号结构不表示模块父子关系发生变化;它只是一组use声明的紧凑写法。
导入的作用域同样值得审查。在父模块写下use crate::catalog::Product;,不会让子模块无条件获得裸名称Product。子模块可以自行导入,或者在确实需要依赖父级绑定时显式使用super::Product;自行导入通常更能显示直接依赖。
别名属于调用方词汇
as只改变本作用域中的绑定名,不会重命名定义或改变其他模块看到的API。遇到两个Result、两个Error或两个Config时,应按职责取名,例如FormatResult与IoResult,而不是使用Result1这类无信息别名。
公开重导出也可以使用as,这会成为外部可见的正式名称。此时别名不再只是局部便利,而是API契约的一部分。重命名前要搜索下游使用点,必要时保留旧重导出并给出迁移期。
边界检查与可维护性
Rust的可见性是编译期名称访问规则,不是安全边界。只要公共API提供了读取或修改能力,模块私有性不能阻止有权运行该进程的代码观察结果。它的价值在于约束源代码依赖,让不变量和可替换实现有明确的维护位置。
最窄可见性也不是把每个函数都藏到最深层。若多个同级模块稳定共享一项crate内能力,pub(crate)比重复包装或复制代码更准确;若只有父模块协调子模块,pub(super)可以表达更小范围。范围应跟随架构关系,而不是根据当前编译错误逐个放宽。
公共门面需要同时维护名称和语义。保留pub use catalog::Product能让内部文件移动对调用方透明,但若构造函数、字段可见性或trait实现发生不兼容变化,路径不变也不能避免破坏。模块设计减少耦合,却不会自动替你管理所有API兼容性。
使用cargo check --all-targets能覆盖默认库、二进制、example、测试与其他已声明目标的编译关系。只运行cargo run可能遗漏没有参与该次运行的目标,恰好让错误的crate路径或私有API使用继续潜伏。模块重构后应检查全部目标,并运行依赖公共路径的集成测试。
声明顺序不是执行顺序
模块和函数定义属于item,其名称通常可以在同一模块中先使用、后定义。把mod reports;移到main上方不会让它“更早加载”,放到下方也不会延迟加载。编译器在构建crate时解析整棵item树。
因此,修复未解析路径应检查模块是否声明在正确的逻辑父级,以及目标是否在当前可见范围内,而不是反复调整item顺序。macro_rules!有自己的文本作用域规则,不能据此推断普通模块也按脚本顺序加载。
#[cfg(...)] mod platform;会让模块声明本身受条件控制。生成代码若只在一个平台或feature组合上检查,就可能漏掉另一份模块文件中的错误;涉及条件模块时,应对项目支持的配置分别执行检查。
条件模块的导入和重导出通常也要使用一致的#[cfg],否则关闭该配置时,路径会指向根本不存在的项。把条件放在边界声明附近,能让模块树的每种形态更容易审查。
4个问题 · 1道输出预测题 · 1道找错题