课程概览 · 第 14 章

上一篇:错误处理:区分可恢复失败与程序缺陷

下一篇:属性:让编译配置、诊断与文档可审查

项目从一个文件增长到多个模块后,关键不再是“代码放在哪个目录”,而是“谁能依赖什么”。模块建立名字树和访问边界:领域类型不该被入口层任意改字段,存储实现不该泄漏为公共承诺,测试也不应为了访问私有逻辑而把生产 API 变宽。

学习目标与默认选择

学完本篇,你应当能够:

  • 区分 package、crate、模块和文件,并写出多文件模块的加载关系;
  • 使用 crateselfsuperuse 读写稳定路径;
  • 按范围选择私有、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
2
3
4
5
6
package: task_model
├── crate: task_model(src/lib.rs)
│ ├── module: domain
│ └── module: storage
└── crate: import_tasks(src/main.rs)
└── 依赖 task_model 这个库 crate

库 crate 与二进制 crate 即使在同一个 package 中,也仍是两个 crate。src/main.rs 不能访问 src/lib.rs 的私有模块;它应当像任何外部使用者一样,通过库名导入公共 API。这条规则能避免“应用入口偷偷依赖库内部”的耦合。

模块树不是文件树

模块是 crate 内部的名字树。模块可以嵌套,类型、函数、常量、trait、宏等项目都生活在某个模块中。文件只是存放模块源码的媒介;目录结构不会自动产生模块树,只有 mod 声明确实把一个模块挂进树中。

1
2
3
// src/lib.rs
mod domain;
mod storage;

mod domain; 让编译器按约定查找 src/domain.rssrc/domain/mod.rs。现代项目通常偏好 domain.rs,因为目录和文件能同时存在:domain.rs 可以配套 domain/ 子目录。

1
2
3
4
5
6
src/
├── lib.rs
├── domain.rs
├── domain/
│ └── validation.rs
└── storage.rs

如果 domain.rs 中还有 mod validation;,它加载的是 src/domain/validation.rs。目录只是 domain 子模块文件的搜索位置;domain 本身由 lib.rs 中的 mod domain; 声明。只新建文件而不写 mod,该文件不会参与编译。

小项目也可以直接把子模块写在花括号里。内联版与文件版的可见性完全一样,差别只在源码位置:

1
2
3
4
5
6
7
8
9
mod domain {
pub struct TaskId(u64);

impl TaskId {
pub fn value(self) -> u64 {
self.0
}
}
}

选择文件的依据不是“每个模块必须一个文件”,而是读者能否在一个文件中理解这个概念。仅有几十行且紧密相关的私有辅助模块,内联通常更清楚;一个有独立职责、测试和子模块的概念,单独文件更合适。

mod 不是 use

这两个关键字容易一起出现,却做不同的事。mod 定义或加载子模块,从而改变模块树;use 把已经存在的路径绑定到当前作用域的名字,不加载文件,也不改变任何项目的可见性。

1
2
3
4
5
6
7
mod domain;

use crate::domain::Task;

fn print_task(task: &Task) {
println!("{:?}", task);
}

删掉 useTask 这个短名字不再可用,但 crate::domain::Task 仍存在。删掉 mod domain;,模块根本没有进入 crate,use 也无从导入。排查 “unresolved import” 时先区分这两类问题,会少走很多弯路。

路径:从哪里开始找名字

Rust 路径可理解成在模块树中走路。最常用的四个起点如下:

写法起点典型用途
crate::当前 crate 根跨文件、长期稳定的内部路径
self::当前模块强调当前模块的项目或重命名导入
super::当前模块的父模块相邻父子模块之间的引用
名字当前作用域已定义或已 use 导入的短名

给定以下树,validation 中的代码怎样引用 Task,完全取决于它选择的起点:

1
2
3
4
crate
└── domain
├── Task
└── validation
1
2
3
4
5
6
7
8
9
10
11
// src/domain/validation.rs
use super::Task;
use crate::domain::Task as AbsoluteTask;

fn accepts(task: &Task) -> bool {
task.title().len() <= 120
}

fn also_accepts(task: &AbsoluteTask) -> bool {
task.title().len() <= 120
}

两种写法语义相同。super::Task 表达“就在父模块的 Task”,很适合紧密的父子关系;crate::domain::Task 从根出发,不因这个文件被移动到另一层子模块而改变含义,适合跨较大范围的调用。不要把其中一种当成绝对规范;重点是同一局部保持一致,让读者能预测路径。

外部 crate 的路径以依赖名起步,而不是以 crate 起步:

1
2
use serde::Serialize;
use std::collections::HashMap;

2018 edition 之后,外部 crate 通常不再需要 extern crate serde;。Cargo 负责把依赖提供给编译器,use 负责把名字带入当前作用域。

可见性:谁能穿过边界

默认私有的准确含义

Rust 的项目默认对定义模块及其后代模块可见。它不是“只在这一对花括号可见”。因此,父模块定义的私有函数可被子模块调用;反过来,子模块的私有函数不能被父模块调用,也不能被兄弟模块调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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);
// parent::secret();
// parent::child::read_parent_secret();
}

最后两行都不能从 crate 根调用。第一行失败是 secret 私有;第二行即使 read_parent_secretpub,中间路径上的 child 模块仍是私有的。访问一条路径时,路径上每一段都必须允许你走过去。 这是理解很多 E0603(private item)错误的关键。

pub 不会自动公开所有东西

给模块加 pub 只允许外部进入该模块,并不自动公开其中的类型、函数和字段;给结构体加 pub 也不自动公开字段。这样可以逐层决定承诺范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pub mod api {
pub struct User {
id: u64,
pub name: String,
}

impl User {
pub fn new(id: u64, name: String) -> Self {
Self { id, name }
}

pub fn id(&self) -> u64 {
self.id
}
}
}

外部调用者可以写 api::User::new、读写 user.name、调用 user.id(),却不能直接读写 user.id。私有字段保护不变式:将来 id 改成新类型、缓存字段或改为从别处计算时,只要构造函数和 getter 的契约不变,调用方不用修改。

枚举稍有不同:一个 pub enum 的所有变体都会对能访问枚举的调用者可见,因为匹配必须看见完整集合;但变体里若装着结构体,那个结构体字段仍可私有。tuple struct 也要求构造器位置可见:pub struct UserId(u64); 的类型公开,但其 tuple 字段和构造器并不公开。

1
2
3
4
5
6
7
8
9
10
11
pub struct UserId(u64);

impl UserId {
pub fn new(value: u64) -> Option<Self> {
(value != 0).then_some(Self(value))
}

pub fn get(self) -> u64 {
self.0
}
}

这正是 newtype 封装 ID、金额、状态机句柄的常用写法:对外只开放合法构造方式,而不是开放内部表示。

四种常用公开范围

pub 不是二元开关。下面的范围从窄到宽排列:

写法对谁可见用途
无修饰当前模块和后代真正的实现细节
pub(self)与无修饰相同很少需要,主要用于显式表达
pub(super)父模块及其后代父模块协调多个私有子模块
pub(crate)当前 crate 的所有模块多个内部模块共享,不对依赖者承诺
pub(in crate::x)指定祖先模块及其后代精确限制在一个子树
pub其他 crate 也可见公共库 API

pub(in path) 的路径必须是当前模块的祖先。它不是任意“白名单”,不能让两个不相干的兄弟模块互相看见;此限制保留了模块树从祖先向后代传播可见性的简单规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 {
let parsed = parser::parse(line);
parsed.number
}
}

这段代码刻意展示三层边界:ParsedLine 只给 service 的父模块使用,parse 只给 service 子树使用,text 则只有 service 及其后代可读。真实业务不要为“炫技”堆叠这些修饰符;默认私有、pub(crate)pub 已覆盖绝大多数场景,pub(super)pub(in ...) 用在确实能表达边界的地方。

一次完整的可见性设计

下面是一个小型任务库。它有三层意图:domain 保管不变式,storage 保管存储实现,crate 根只展示调用者真正需要的门面。代码分为三个文件,方便观察模块声明、use 和 re-export 的关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/lib.rs
mod domain;
mod storage;

pub use domain::{Task, TaskError, TaskId};
pub use storage::TaskStore;

pub fn load_titles(
lines: &[String],
) -> Result<Vec<Task>, TaskError> {
let mut store = storage::MemoryStore::new();

for (index, line) in lines.iter().enumerate() {
let raw_id = (index + 1) as u64;
let id = TaskId::new(raw_id)?;
let task = Task::new(id, line)?;
store.insert(task);
}

Ok(store.into_tasks())
}
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
43
44
45
46
47
48
49
// src/domain.rs
#[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,
}
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
// src/storage.rs
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);
}
}

从外部 crate 的视角,稳定路径是 task_model::Tasktask_model::TaskIdtask_model::TaskStore,而不是 task_model::domain::Tasktask_model::storage::MemoryStoredomainstoragelib.rs 中仍是私有模块;pub use 只挑选必要项目形成根模块门面。以后把 MemoryStore 换为数据库、把 domain 拆成多个文件,都不会强迫下游替换导入路径。

公开函数的参数和返回类型也应当可被外部正常命名。若 pub fn 的签名泄漏私有类型,调用者即使偶尔能靠类型推断拿到值,也难以构造、匹配或处理它,这是一种糟糕的 API。公开行为时,一并检查它涉及的类型和 trait 是否也有合适的公开路径。

use 与 re-export:短名字和稳定 API

use 只创建当前作用域的别名

use 不复制类型,不移动值,也不改变原路径;它只给当前作用域增加一个名字。别名在同名冲突和语义澄清时非常有用。

1
2
3
4
5
6
7
8
9
10
use std::fmt::Result as FmtResult;
use std::io::Result as IoResult;

fn render() -> FmtResult {
Ok(())
}

fn read_file() -> IoResult<()> {
Ok(())
}

导入 trait 的情形尤其常见。调用方法时编译器需要相关 trait 在作用域中,但调用代码通常不直接写 trait 名,因此可以用 _ 表达“只为方法解析导入,不需要短名字”。

1
2
3
4
5
6
7
use std::fmt::Write as _;

fn make_line() -> String {
let mut output = String::new();
writeln!(&mut output, "ready").expect("write to String");
output
}

pub use 是重新设计公共路径

pub use 在当前模块再公开一个路径。它不是单纯的美化,也不是让所有内部项目“顺手可用”;它承诺下游可以依赖这条新路径。库的根模块常被用作门面:把核心类型平铺在根,把实现细节留在深层模块。

1
2
3
4
5
6
7
mod wire {
pub mod request {
pub struct Request;
}
}

pub use wire::request::Request;

现在调用者可以写 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pub struct Counter {
value: u32,
}

impl Counter {
pub fn new() -> Self {
Self { value: 0 }
}

pub fn increment(&mut self) {
self.value += 1;
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn new_counter_starts_at_zero() {
assert_eq!(Counter::new().value, 0);
}
}

use super::* 的含义只是从父模块导入名字;真正赋予访问资格的是“tests 是父模块的后代”。若测试放在 src/tests.rs 并由 crate 根 mod tests; 引入,它是 crate 根的子模块,不是某个具体模块的后代,不能越过该模块的私有边界。

集成测试:独立的外部 crate

tests/api.rs 中的每个文件都会被 Cargo 编译为独立 crate,它使用你的库时与真实用户相同:只能通过 pub API,且导入路径以包名开始。集成测试专门验证公共契约,因此不能访问私有字段是正确的限制。

1
2
3
4
5
6
7
8
9
// tests/api.rs
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");
}

一个实用分工是:算法分支、内部缓存、精确状态转换放单元测试;对外构造、错误语义、跨模块组合放集成测试。不要为了让集成测试摸到私有函数就把它设成 pub;若它确实值得外部直接调用,才应将其纳入 API 并承担兼容性责任。

小结:先设计边界,再移动文件

模块的核心不是把源码切成许多文件,而是让依赖方向可见。先确定领域概念、公共构造方式和可替换的实现,再用模块承载它们;文件布局只是这一设计的结果。路径上的每一段都通过可见性检查,因此“默认私有 + 最小 re-export”既保护不变式,也让 API 更容易长期维护。

自测

  1. 为什么新建 src/domain.rs 后,仍需要在 lib.rsmod domain;
    方向:文件不会自动成为模块;mod 才把它挂入当前模块树并参与编译。
  2. 为什么 pub struct User { id: u64 } 不能让外部读取 user.id
    方向:结构体类型的可见性和字段可见性是两层;字段仍默认私有。
  3. pub(crate) 适合什么情况?
    方向:本 crate 的多个模块需要共享实现,但依赖者不应依赖它,未来仍可自由替换。
  4. usepub use 的区别是什么?
    方向:前者仅在当前作用域创建短名字;后者还向外公开一条可依赖的路径。
  5. 为什么 #[cfg(test)] mod tests 能测试私有字段,而 tests/api.rs 不行?
    方向:前者是源模块的后代,后者被编译成外部 crate。