模块:crate、路径与可见性
课程概览 · 第 14 章
项目从一个文件增长到多个模块后,关键不再是“代码放在哪个目录”,而是“谁能依赖什么”。模块建立名字树和访问边界:领域类型不该被入口层任意改字段,存储实现不该泄漏为公共承诺,测试也不应为了访问私有逻辑而把生产 API 变宽。
学习目标与默认选择
学完本篇,你应当能够:
- 区分 package、crate、模块和文件,并写出多文件模块的加载关系;
- 使用
crate、self、super和use读写稳定路径; - 按范围选择私有、
pub(super)、pub(crate)、pub(in ...)与pub; - 通过
pub use设计稳定门面,而不是暴露内部目录; - 解释单元测试与集成测试分别为何能、为何不能访问私有项。
默认选择是:模块默认私有;实现细节优先留在 crate 内;调用者只依赖 crate 根精心挑选的公共路径。 这让内部重组不会立刻成为下游的破坏性改动。
crate、文件与模块树
crate 是编译边界
Cargo.toml 描述一个 package(包)。一个 package 可以产生一个库 crate 和多个二进制 crate:src/lib.rs 是库 crate 根,src/main.rs 是默认二进制 crate 根,src/bin/*.rs 各自又是一个二进制 crate。crate 是编译和链接的基本单元,不同 crate 的私有项彼此永远不可见。
1 | |
库 crate 与二进制 crate 即使在同一个 package 中,也仍是两个 crate。src/main.rs 不能访问 src/lib.rs 的私有模块;它应当像任何外部使用者一样,通过库名导入公共 API。这条规则能避免“应用入口偷偷依赖库内部”的耦合。
模块树不是文件树
模块是 crate 内部的名字树。模块可以嵌套,类型、函数、常量、trait、宏等项目都生活在某个模块中。文件只是存放模块源码的媒介;目录结构不会自动产生模块树,只有 mod 声明确实把一个模块挂进树中。
1 | |
mod domain; 让编译器按约定查找 src/domain.rs 或 src/domain/mod.rs。现代项目通常偏好 domain.rs,因为目录和文件能同时存在:domain.rs 可以配套 domain/ 子目录。
1 | |
如果 domain.rs 中还有 mod validation;,它加载的是 src/domain/validation.rs。目录只是 domain 子模块文件的搜索位置;domain 本身由 lib.rs 中的 mod domain; 声明。只新建文件而不写 mod,该文件不会参与编译。
小项目也可以直接把子模块写在花括号里。内联版与文件版的可见性完全一样,差别只在源码位置:
1 | |
选择文件的依据不是“每个模块必须一个文件”,而是读者能否在一个文件中理解这个概念。仅有几十行且紧密相关的私有辅助模块,内联通常更清楚;一个有独立职责、测试和子模块的概念,单独文件更合适。
mod 不是 use
这两个关键字容易一起出现,却做不同的事。mod 定义或加载子模块,从而改变模块树;use 把已经存在的路径绑定到当前作用域的名字,不加载文件,也不改变任何项目的可见性。
1 | |
删掉 use,Task 这个短名字不再可用,但 crate::domain::Task 仍存在。删掉 mod domain;,模块根本没有进入 crate,use 也无从导入。排查 “unresolved import” 时先区分这两类问题,会少走很多弯路。
路径:从哪里开始找名字
Rust 路径可理解成在模块树中走路。最常用的四个起点如下:
| 写法 | 起点 | 典型用途 |
|---|---|---|
crate:: | 当前 crate 根 | 跨文件、长期稳定的内部路径 |
self:: | 当前模块 | 强调当前模块的项目或重命名导入 |
super:: | 当前模块的父模块 | 相邻父子模块之间的引用 |
名字 | 当前作用域 | 已定义或已 use 导入的短名 |
给定以下树,validation 中的代码怎样引用 Task,完全取决于它选择的起点:
1 | |
1 | |
两种写法语义相同。super::Task 表达“就在父模块的 Task”,很适合紧密的父子关系;crate::domain::Task 从根出发,不因这个文件被移动到另一层子模块而改变含义,适合跨较大范围的调用。不要把其中一种当成绝对规范;重点是同一局部保持一致,让读者能预测路径。
外部 crate 的路径以依赖名起步,而不是以 crate 起步:
1 | |
2018 edition 之后,外部 crate 通常不再需要 extern crate serde;。Cargo 负责把依赖提供给编译器,use 负责把名字带入当前作用域。
可见性:谁能穿过边界
默认私有的准确含义
Rust 的项目默认对定义模块及其后代模块可见。它不是“只在这一对花括号可见”。因此,父模块定义的私有函数可被子模块调用;反过来,子模块的私有函数不能被父模块调用,也不能被兄弟模块调用。
1 | |
最后两行都不能从 crate 根调用。第一行失败是 secret 私有;第二行即使 read_parent_secret 是 pub,中间路径上的 child 模块仍是私有的。访问一条路径时,路径上每一段都必须允许你走过去。 这是理解很多 E0603(private item)错误的关键。
pub 不会自动公开所有东西
给模块加 pub 只允许外部进入该模块,并不自动公开其中的类型、函数和字段;给结构体加 pub 也不自动公开字段。这样可以逐层决定承诺范围。
1 | |
外部调用者可以写 api::User::new、读写 user.name、调用 user.id(),却不能直接读写 user.id。私有字段保护不变式:将来 id 改成新类型、缓存字段或改为从别处计算时,只要构造函数和 getter 的契约不变,调用方不用修改。
枚举稍有不同:一个 pub enum 的所有变体都会对能访问枚举的调用者可见,因为匹配必须看见完整集合;但变体里若装着结构体,那个结构体字段仍可私有。tuple struct 也要求构造器位置可见:pub struct UserId(u64); 的类型公开,但其 tuple 字段和构造器并不公开。
1 | |
这正是 newtype 封装 ID、金额、状态机句柄的常用写法:对外只开放合法构造方式,而不是开放内部表示。
四种常用公开范围
pub 不是二元开关。下面的范围从窄到宽排列:
| 写法 | 对谁可见 | 用途 |
|---|---|---|
| 无修饰 | 当前模块和后代 | 真正的实现细节 |
pub(self) | 与无修饰相同 | 很少需要,主要用于显式表达 |
pub(super) | 父模块及其后代 | 父模块协调多个私有子模块 |
pub(crate) | 当前 crate 的所有模块 | 多个内部模块共享,不对依赖者承诺 |
pub(in crate::x) | 指定祖先模块及其后代 | 精确限制在一个子树 |
pub | 其他 crate 也可见 | 公共库 API |
pub(in path) 的路径必须是当前模块的祖先。它不是任意“白名单”,不能让两个不相干的兄弟模块互相看见;此限制保留了模块树从祖先向后代传播可见性的简单规则。
1 | |
这段代码刻意展示三层边界:ParsedLine 只给 service 的父模块使用,parse 只给 service 子树使用,text 则只有 service 及其后代可读。真实业务不要为“炫技”堆叠这些修饰符;默认私有、pub(crate) 与 pub 已覆盖绝大多数场景,pub(super) 和 pub(in ...) 用在确实能表达边界的地方。
一次完整的可见性设计
下面是一个小型任务库。它有三层意图:domain 保管不变式,storage 保管存储实现,crate 根只展示调用者真正需要的门面。代码分为三个文件,方便观察模块声明、use 和 re-export 的关系。
1 | |
1 | |
1 | |
从外部 crate 的视角,稳定路径是 task_model::Task、task_model::TaskId 和 task_model::TaskStore,而不是 task_model::domain::Task 或 task_model::storage::MemoryStore。domain 与 storage 在 lib.rs 中仍是私有模块;pub use 只挑选必要项目形成根模块门面。以后把 MemoryStore 换为数据库、把 domain 拆成多个文件,都不会强迫下游替换导入路径。
公开函数的参数和返回类型也应当可被外部正常命名。若 pub fn 的签名泄漏私有类型,调用者即使偶尔能靠类型推断拿到值,也难以构造、匹配或处理它,这是一种糟糕的 API。公开行为时,一并检查它涉及的类型和 trait 是否也有合适的公开路径。
use 与 re-export:短名字和稳定 API
use 只创建当前作用域的别名
use 不复制类型,不移动值,也不改变原路径;它只给当前作用域增加一个名字。别名在同名冲突和语义澄清时非常有用。
1 | |
导入 trait 的情形尤其常见。调用方法时编译器需要相关 trait 在作用域中,但调用代码通常不直接写 trait 名,因此可以用 _ 表达“只为方法解析导入,不需要短名字”。
1 | |
pub use 是重新设计公共路径
pub use 在当前模块再公开一个路径。它不是单纯的美化,也不是让所有内部项目“顺手可用”;它承诺下游可以依赖这条新路径。库的根模块常被用作门面:把核心类型平铺在根,把实现细节留在深层模块。
1 | |
现在调用者可以写 my_lib::Request。以后即使 wire::request 被改名或合并,只要继续 re-export Request,调用者不会受到影响。反过来,pub use wire::*; 往往不是好选择:它把内部布局全部提升为事实 API,名称冲突和破坏性改动都会增加。
re-export 不会绕开隐私规则。你只能公开自己可访问、并且可以合法公开的项目。对于依赖 crate 的类型,常见做法是把它作为自己 API 的一部分时明确 re-export;若不希望把依赖暴露给用户,则在自己的公开类型和函数签名中避免出现它。
测试位于哪里,决定它能测什么
单元测试:模块的后代
把测试写在源文件底部的 #[cfg(test)] mod tests 中,tests 是被测模块的子模块。因此它能访问父模块的私有项目和私有字段。这是为了让测试验证内部不变式,而不是为了给生产 API 开后门。
1 | |
use super::* 的含义只是从父模块导入名字;真正赋予访问资格的是“tests 是父模块的后代”。若测试放在 src/tests.rs 并由 crate 根 mod tests; 引入,它是 crate 根的子模块,不是某个具体模块的后代,不能越过该模块的私有边界。
集成测试:独立的外部 crate
tests/api.rs 中的每个文件都会被 Cargo 编译为独立 crate,它使用你的库时与真实用户相同:只能通过 pub API,且导入路径以包名开始。集成测试专门验证公共契约,因此不能访问私有字段是正确的限制。
1 | |
一个实用分工是:算法分支、内部缓存、精确状态转换放单元测试;对外构造、错误语义、跨模块组合放集成测试。不要为了让集成测试摸到私有函数就把它设成 pub;若它确实值得外部直接调用,才应将其纳入 API 并承担兼容性责任。
小结:先设计边界,再移动文件
模块的核心不是把源码切成许多文件,而是让依赖方向可见。先确定领域概念、公共构造方式和可替换的实现,再用模块承载它们;文件布局只是这一设计的结果。路径上的每一段都通过可见性检查,因此“默认私有 + 最小 re-export”既保护不变式,也让 API 更容易长期维护。
自测
- 为什么新建
src/domain.rs后,仍需要在lib.rs写mod domain;?
方向:文件不会自动成为模块;mod才把它挂入当前模块树并参与编译。 - 为什么
pub struct User { id: u64 }不能让外部读取user.id?
方向:结构体类型的可见性和字段可见性是两层;字段仍默认私有。 pub(crate)适合什么情况?
方向:本 crate 的多个模块需要共享实现,但依赖者不应依赖它,未来仍可自由替换。use与pub use的区别是什么?
方向:前者仅在当前作用域创建短名字;后者还向外公开一条可依赖的路径。- 为什么
#[cfg(test)] mod tests能测试私有字段,而tests/api.rs不行?
方向:前者是源模块的后代,后者被编译成外部 crate。






