课程概览 · 第 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));
}

自测

  1. 为什么仅创建 domain.rs 不会让它参与编译?mod 与 use 分别做什么?
  2. pub 类型为什么仍不能从外部用私有字段构造?
  3. task_model 的 domain 私有,为什么外部仍能访问 Task?
  4. MemoryStore 与 TaskStore 在公共承诺上有什么区别?
  5. domain.rs 内 tests 和 tests/api.rs 的访问资格为什么不同?

参考答案

  1. mod 将文件挂入模块树;use 只绑定已有路径的名字。
  2. 类型、字段与构造器各有可见性,类型公开不自动公开字段。
  3. 根模块 pub use 提供独立公开路径,不公开整个 domain。
  4. 前者是可替换实现,后者经门面对外提供,是公开抽象。
  5. 前者是 domain 的后代,后者是外部 crate,只能访问公共 API。