课程概览 · 第 14 章
上一篇:错误处理:区分可恢复失败与程序缺陷
下一篇:属性:让编译配置、诊断与文档可审查
模块不是把文件切小的技巧,而是让依赖方向、可见性和公共承诺明确的工具。把任务领域类型、存储实现和公开入口分别组织起来,可以隐藏实现而保留稳定路径。本章先解释模块如何进入编译,再用三个源码文件建立一个小型库;这是文章内的独立项目,不要求与其他章节拼装。
前置知识与学习目标
需要理解结构体私有字段、trait 与 Result。学完本章应能:
- 区分 package、crate、模块与文件,定位模块加载错误;
- 用 mod 声明模块,用 use 绑定名字,选择正确路径起点;
- 根据可见性建立最小 pub use 门面;
- 判断单元测试和集成测试各能访问什么。
package 与 crate
Cargo.toml 描述 package。一个 package 可以包含一个库 crate、默认二进制及其他二进制目标:src/lib.rs 是库根,src/main.rs 是默认二进制根,src/bin/*.rs 各自是二进制 crate。crate 是编译边界,模块则组织 crate 内的命名空间。
文件布局(示意 package 的目标与模块,不是需要复制的源码)。
1 2 3 4 5 6
| package: task_model ├── crate: task_model(src/lib.rs) │ ├── module: domain │ └── module: storage └── crate: 默认二进制(src/main.rs,可选) └── 通过公共 API 使用 task_model 库
|
同一 package 的 main.rs 与 lib.rs 仍是不同 crate;二进制不能偷偷访问库私有模块。集成测试和 examples 目标也是库的外部调用者。以实际库目标名称导入,而不是误用当前 crate 的根路径。
文件如何进入模块树
新建文件不会自动参与编译,父模块必须通过 mod 加载它。mod domain; 通常寻找 domain.rs 或 domain/mod.rs,不要同时提供这两种候选。现代布局常用 domain.rs 加 domain/ 子目录。
代码片段(仅标准库;放在后文 task_model 的 src/lib.rs;所需完整 src/domain.rs 与 src/storage.rs 见“三文件任务库”,这两行在完整 lib.rs 中已经出现,不要重复添加)。
1 2
| mod domain; mod storage;
|
文件布局(展示再增加 validation 子模块时的搜索位置;后文三文件主例不需要 validation 文件)。
1 2 3 4 5 6
| src/ ├── lib.rs ├── domain.rs ├── domain/ │ └── validation.rs └── storage.rs
|
若 domain.rs 写 mod validation;,加载的是 src/domain/validation.rs。模块也可以内联,无需为了每个小概念都创建文件。
独立示例(仅标准库;完整内容放独立项目 src/main.rs,Edition 2024;执行 cargo +stable run)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| mod domain { pub struct TaskId(u64);
impl TaskId { pub fn new(value: u64) -> Self { Self(value) } pub fn get(&self) -> u64 { self.0 } } }
use crate::domain::TaskId;
fn main() { assert_eq!(TaskId::new(7).get(), 7); }
|
mod 定义或加载模块,use 给已经存在的路径创建当前作用域名字。删掉 use 仍能写完整路径,删掉 mod 则整个模块不存在;排查 unresolved import 时先区分这两种情况。use 不复制类型、不移动值,也不改变可见性。
路径从哪里开始
| 起点 | 含义 | 常见用途 |
|---|
crate:: | 当前 crate 根 | 跨内部模块引用 |
self:: | 当前模块 | 明确本地路径 |
super:: | 父模块 | 紧密的父子协作 |
依赖名,例如 serde:: | 外部 crate | 引入依赖提供的 API |
独立示例(仅标准库;替换独立项目 src/main.rs;执行 cargo +stable run)。validation 内联,与目录布局示意表达同样的父子关系。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| mod domain { pub struct Task { pub title: String, }
pub mod validation { use super::Task; use crate::domain::Task as AbsoluteTask;
pub fn valid(task: &Task) -> bool { !task.title.trim().is_empty() } pub fn same_type(task: &AbsoluteTask) -> bool { valid(task) } } }
fn main() { let task = domain::Task { title: "write docs".into() }; assert!(domain::validation::same_type(&task)); }
|
super 强调父子关系;crate 明确从根出发,但移动模块时仍可能需要更新路径,不是自动重构机制。局部保持一致比坚持一种绝对写法更重要。Rust 2018 之后通常无需 extern crate,Cargo 提供依赖,use 负责引入名字。
代码片段(依赖语法说明;放在使用 Serde 的 src/main.rs 顶层,需 Cargo.toml 的 serde = { version = "1", features = ["derive"] };完整第三方项目见第 16 章,本块不提供入口)。
1 2
| use serde::Serialize; use std::collections::HashMap;
|
可见性与公共路径
默认私有项对定义模块及其后代可见,而不是只对同一对花括号可见。子模块可以访问父模块的私有项;父模块与兄弟模块不能反向越过子模块的私有边界。访问一条路径时,每一段都要满足可见性;叶子函数是 pub 不会自动公开中间模块。
独立示例(仅标准库;替换独立项目 src/main.rs;执行 cargo +stable run)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| mod parent { fn secret() -> u32 { 7 } mod child { pub fn read_parent_secret() -> u32 { super::secret() } } pub fn run() -> u32 { child::read_parent_secret() } }
fn main() { assert_eq!(parent::run(), 7); }
|
在上例 main 添加 parent::secret() 会因函数私有失败,添加 parent::child::read_parent_secret() 会因 child 模块私有失败,通常是 E0603。给结构体加 pub 也不会公开私有字段;tuple struct 的字段私有时,其字面量构造器同样不能被外部使用。公开 enum 的变体可被可访问该 enum 的调用者匹配,变体携带的结构体仍保留自己的字段隐私。
| 写法 | 可见范围 |
|---|
默认或 pub(self) | 定义模块及后代 |
pub(super) | 父模块及后代 |
pub(crate) | 当前 crate |
pub(in crate::x) | 指定祖先模块子树 |
pub | 可通过公开路径到达的外部调用者 |
pub(in ...) 必须指向祖先,不是对任意兄弟模块的白名单。默认私有、pub(crate) 与 pub 已覆盖多数需求;只为真实边界增加更精细的限制。
pub use:把公共 API 与实现路径分开
pub use 不只是缩短名字,而是在当前模块公开一条调用者可依赖的路径。私有模块中声明的 pub 类型可以经根模块 re-export 对外提供;这不会公开整个内部模块,也不会绕开类型自身的私有字段。
公开函数涉及的参数、返回值、错误类型与 trait 都应有外部可命名的路径。不要随手 pub use internal::*:这会把内部布局扩大为兼容性承诺。下一节将 domain 与 storage 隐藏,只公开领域类型、存储 trait 与加载函数。
三文件任务库
在独立目录创建库。下面命令在其父目录执行;之后所有 Cargo 命令在 task_model/ 执行。不在博客仓库创建 Rust 工程。
多文件示例(项目创建命令;仅标准库;执行目录为项目父目录)。
1
| cargo +stable new --lib --edition 2024 task_model
|
文件布局(主例所需源码与外部示例)。
1 2 3 4 5 6 7 8
| task_model/ ├── Cargo.toml ├── src/ │ ├── lib.rs │ ├── domain.rs │ └── storage.rs └── examples/ └── use_tasks.rs
|
多文件示例(仅标准库;task_model/src/lib.rs 完整内容;依赖同项目 domain.rs 与 storage.rs;执行目录 task_model/)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| mod domain; mod storage;
pub use domain::{Task, TaskError, TaskId}; pub use storage::TaskStore;
pub fn load_tasks(lines: &[String]) -> Result<Vec<Task>, TaskError> { let mut store = storage::MemoryStore::new(); for (index, line) in lines.iter().enumerate() { let id = TaskId::new((index + 1) as u64)?; let task = Task::new(id, line)?; store.insert(task); } Ok(store.into_tasks()) }
|
多文件示例(仅标准库;task_model/src/domain.rs 完整内容;由 lib.rs 加载;执行目录 task_model/)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct TaskId(u64);
impl TaskId { pub fn new(value: u64) -> Result<Self, TaskError> { if value == 0 { return Err(TaskError::InvalidId); } Ok(Self(value)) } pub fn get(self) -> u64 { self.0 } }
#[derive(Debug, Clone, PartialEq, Eq)] pub struct Task { id: TaskId, title: String, }
impl Task { pub fn new(id: TaskId, title: &str) -> Result<Self, TaskError> { let title = title.trim(); if title.is_empty() { return Err(TaskError::EmptyTitle); } Ok(Self { id, title: title.to_owned() }) } pub fn id(&self) -> TaskId { self.id } pub fn title(&self) -> &str { &self.title } }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TaskError { InvalidId, EmptyTitle, }
|
多文件示例(仅标准库;task_model/src/storage.rs 完整内容;使用 domain 的领域类型;执行目录 task_model/)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| use crate::domain::{Task, TaskId};
pub trait TaskStore { fn get(&self, id: TaskId) -> Option<&Task>; fn insert(&mut self, task: Task); }
pub(crate) struct MemoryStore { tasks: Vec<Task>, }
impl MemoryStore { pub(crate) fn new() -> Self { Self { tasks: Vec::new() } } pub(crate) fn into_tasks(self) -> Vec<Task> { self.tasks } }
impl TaskStore for MemoryStore { fn get(&self, id: TaskId) -> Option<&Task> { self.tasks.iter().find(|task| task.id() == id) } fn insert(&mut self, task: Task) { self.tasks.push(task); } }
|
多文件示例(仅标准库;task_model/examples/use_tasks.rs 完整内容;依赖上述库的根 re-export 与 load_tasks;执行目录 task_model/)。
1 2 3 4 5 6 7 8 9 10 11
| use task_model::{TaskError, load_tasks};
fn main() { let lines = vec![" write tests ".to_owned(), "review pr".to_owned()]; let tasks = load_tasks(&lines).expect("valid titles"); assert_eq!(tasks[0].id().get(), 1); assert_eq!(tasks[0].title(), "write tests"); assert_eq!(tasks[1].id().get(), 2); assert_eq!(tasks[1].title(), "review pr"); assert_eq!(load_tasks(&[" ".to_owned()]), Err(TaskError::EmptyTitle)); }
|
多文件示例(执行命令;目录 task_model/;依赖上述全部文件)。
1 2
| cargo +stable check --lib cargo +stable run --example use_tasks
|
外部稳定路径是 task_model::Task、TaskId、TaskError、TaskStore 与 load_tasks,不是私有 domain、storage 路径。MemoryStore 是 crate 内部实现,不能从外部导入。这里的 trait 为公开抽象,不意味着当前函数接收任意存储实现;load_tasks 明确使用内存实现。
单元测试与集成测试的边界
单元测试模块是被测模块的后代,所以可以访问其私有字段;权限来自模块关系,不是 use super::* 这一行。若把 tests 放在 crate 根,它就不是 domain 的后代,不能穿透 domain 内部隐私。
代码片段(仅标准库;追加到上述 task_model/src/domain.rs,沿用该文件 Task、TaskId;执行目录 task_model/,用 cargo +stable test)。
1 2 3 4 5 6 7 8 9 10 11
| #[cfg(test)] mod tests { use super::*;
#[test] fn constructor_trims_private_title() { let id = TaskId::new(1).expect("valid id"); let task = Task::new(id, " write tests ").expect("valid title"); assert_eq!(task.title, "write tests"); } }
|
tests/api.rs 是独立外部 crate,只使用公共 API。为了测试而把实现函数设为 pub 会扩大承诺,应先判断它是否真的属于对外 API。
多文件示例(仅标准库;新增 task_model/tests/api.rs,依赖上述库;执行目录 task_model/,用 cargo +stable test)。
1 2 3 4 5 6 7 8
| use task_model::{Task, TaskId};
#[test] fn public_constructor_preserves_title() { let id = TaskId::new(1).expect("valid id"); let task = Task::new(id, "write tests").expect("valid title"); assert_eq!(task.title(), "write tests"); }
|
深入阅读:别名、trait 导入与精细可见性
同名结果类型可以用 use std::fmt::Result as FmtResult 与 use std::io::Result as IoResult 分开。只为方法解析导入 trait 时,as _ 避免引入可直接引用的短名字。
独立示例(仅标准库;放独立项目 src/main.rs,不添加到 task_model;执行 cargo +stable run)。
1 2 3 4 5 6 7 8 9 10 11
| use std::fmt::Write as _;
fn make_line() -> String { let mut output = String::new(); writeln!(&mut output, "ready").expect("writing to String"); output }
fn main() { assert_eq!(make_line(), "ready\n"); }
|
精确可见性适合父模块协调私有子模块,但不要为了展示语法堆叠修饰。以下 parser 只向 service 子树提供解析,根模块通过公开 load 使用它。
独立示例(仅标准库;替换独立项目 src/main.rs;执行 cargo +stable run)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| mod service { pub(super) struct ParsedLine { pub(crate) number: usize, text: String, } mod parser { use super::ParsedLine;
pub(in crate::service) fn parse(line: &str) -> ParsedLine { ParsedLine { number: 1, text: line.trim().to_owned() } } } pub fn load(line: &str) -> (usize, usize) { let parsed = parser::parse(line); (parsed.number, parsed.text.len()) } }
fn main() { assert_eq!(service::load(" ready "), (1, 5)); }
|
自测
- 为什么仅创建 domain.rs 不会让它参与编译?mod 与 use 分别做什么?
- pub 类型为什么仍不能从外部用私有字段构造?
- task_model 的 domain 私有,为什么外部仍能访问 Task?
- MemoryStore 与 TaskStore 在公共承诺上有什么区别?
- domain.rs 内 tests 和 tests/api.rs 的访问资格为什么不同?
参考答案
- mod 将文件挂入模块树;use 只绑定已有路径的名字。
- 类型、字段与构造器各有可见性,类型公开不自动公开字段。
- 根模块 pub use 提供独立公开路径,不公开整个 domain。
- 前者是可替换实现,后者经门面对外提供,是公开抽象。
- 前者是 domain 的后代,后者是外部 crate,只能访问公共 API。