课程概览 · 第 14 章

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

下一篇:智能指针:让资源归属可组合

任务项目长到三个文件之后,问题不再是“代码怎么写”,而是“谁能看到什么”:领域类型被 CLI 层直接改了内部字段、存储层的错误泄漏到了展示层、测试为了访问私有函数到处加 pub。模块的职责是建立稳定边界,不是镜像文件夹;宏的职责是消除真正重复的结构,不是把正常控制流藏起来。

文件和模块树的差异、pub 可见性范围、属性的编译行为和宏的代码生成边界
图:模块守住依赖与可见性边界;属性和宏只在编译期辅助,不替代清晰的业务控制流。

学习目标与默认选择

学完本章你应当能够:

  • modpubpub(crate)selfsupercrate 组织一个多文件库 crate,并有意识地进行 re-export;
  • 解释模块树与文件树的关系,以及 #[cfg(test)] 测试为何能访问父模块私有项;
  • 使用 #[derive]#[must_use] 和最小范围的 lint 控制;
  • 判断一段重复该用函数、trait 还是 macro_rules!

默认选择:默认私有,按领域概念(而非技术分层潮流)组织模块;测试通过子模块位置访问私有项,不为此放宽生产 API;宏只在函数/trait 表达不了时出场。

原理:模块树与文件树是两回事

crate 是编译与链接的基本单元;模块树决定名称如何组织与可见;文件只是承载模块源码的一种方式。mod domain;src/domain.rs 的内容挂到当前模块的 domain 子模块上;未写 pub 的项只对当前模块及其后代可见。子模块可以看到祖先的所有东西(包括私有项),祖先看不到子模块未公开的东西–这就是 #[cfg(test)] 子模块能测私有函数的原因。

宏在语法分析阶段展开,生成的代码再参与普通类型检查。它能消除样板,但通常比函数更难跳转、报错也更间接,所以先用函数和 trait。

完整的模块树:任务模型的库 crate

一个最小但完整的库 crate,三个文件。注意 domain 的字段是私有的、storage 只暴露两个操作、lib.rs 做有选择的 re-export:

1
2
3
4
5
// 文件:Cargo.toml
[package]
name = "task_model"
version = "0.1.0"
edition = "2024"
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
50
51
// 文件:src/lib.rs —— crate 根:声明模块、有意识地 re-export。
mod domain;
mod storage;

// re-export 是公共 API 设计决策:调用方写 task_model::Task,
// 而不是 task_model::domain::Task。将来重组内部结构时不破坏下游。
pub use domain::{Task, TaskId};
pub use storage::TaskStore;

/// 库的统一入口:加载 + 校验一步完成。
pub fn load_from_lines(lines: &[String]) -> Result<Vec<Task>, domain::DomainError> {
let mut store = storage::InMemoryStore::new();
for (index, line) in lines.iter().enumerate() {
// crate:: 前缀从 crate 根出发,避免随文件移动而失效的相对路径。
// 行号从 1 开始,作为自动分配的任务 ID。
let id = crate::domain::TaskId::new((index + 1) as u64)?;
store
.insert(domain::Task::new(id, line.trim())?)
.map_err(|_| domain::DomainError::InvalidId(0))?;
}
Ok(store.all())
}

#[cfg(test)]
mod tests {
use super::*; // 引入父模块(crate 根)的一切,包括私有项。

#[test]
fn loads_valid_lines() {
let lines = vec![String::from("fix build"), String::from("review pr")];
let tasks = load_from_lines(&lines).expect("valid lines");
assert_eq!(tasks.len(), 2);
}

#[test]
fn rejects_empty_title() {
let lines = vec![String::from(" ")]; // 空白标题
assert!(load_from_lines(&lines).is_err());
}

// 关键:测试访问的是私有路径 crate::domain 的内部校验,
// 而不是把 domain 的函数设成 pub 来迁就测试。
#[test]
fn domain_rejects_zero_id_without_widening_api() {
// domain::TaskId::new 是 pub 的(领域构造规则本来就公开),
// 但 domain 模块内部的 ParseState 枚举是私有的,
// 测试位于 crate::tests,是 domain 的兄弟而非后代,看不到它。
// 这里只验证可见的构造边界:
assert!(crate::domain::TaskId::new(0).is_err());
}
}
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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
// 文件:src/domain.rs —— 领域类型与规则。字段私有,构造必须走校验。
use std::fmt;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TaskId(u64); // 字段私有:外部不能直接构造 TaskId(3)。

impl TaskId {
/// 拒绝 0:0 是“无任务”的哨兵值,不允许混进合法 ID。
#[must_use = "TaskId 构造可能失败,请检查返回的 Result"]
pub fn new(value: u64) -> Result<Self, DomainError> {
if value == 0 {
Err(DomainError::InvalidId(value))
} else {
Ok(TaskId(value))
}
}

pub fn as_u64(self) -> u64 {
self.0 // 本模块内可以访问私有字段。
}
}

#[derive(Debug, Clone, PartialEq)]
pub enum TaskState {
Todo,
InProgress { started_at: u64 },
Done { finished_at: u64 },
Cancelled { reason: String },
}

#[derive(Debug, Clone, PartialEq)]
pub struct Task {
id: TaskId,
title: String,
state: TaskState,
labels: Vec<String>,
}

impl Task {
/// 标题不能为空:不变式在构造点强制,而不是散落在每个调用方。
pub fn new(id: TaskId, title: &str) -> Result<Self, DomainError> {
let title = title.trim();
if title.is_empty() {
return Err(DomainError::EmptyTitle);
}
Ok(Task {
id,
title: title.to_string(),
state: TaskState::Todo,
labels: Vec::new(),
})
}

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

pub fn title(&self) -> &str {
&self.title
}

pub fn state(&self) -> &TaskState {
&self.state
}

pub fn add_label(&mut self, label: &str) {
let label = label.trim();
if !label.is_empty() && !self.labels.iter().any(|l| l == label) {
self.labels.push(label.to_string());
}
}

pub fn labels(&self) -> &[String] {
&self.labels
}
}

#[derive(Debug, PartialEq)]
pub enum DomainError {
InvalidId(u64),
EmptyTitle,
}

impl fmt::Display for DomainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DomainError::InvalidId(value) => write!(f, "任务 ID 非法: {value}"),
DomainError::EmptyTitle => write!(f, "任务标题不能为空"),
}
}
}

impl std::error::Error for DomainError {}

#[cfg(test)]
mod tests {
use super::*; // 测试是 domain 的子模块,能用私有字段做精确断言。

#[test]
fn task_fields_are_private_but_tests_live_inside() {
let id = TaskId::new(1).expect("non-zero id");
let mut task = Task::new(id, "fix build").expect("non-empty title");
task.add_label("ci");
task.add_label("ci"); // 重复标签被去重。
// 直接读私有字段:只有 domain 的后代(本测试模块)可以这样做。
assert_eq!(task.labels.len(), 1);
assert_eq!(task.state, TaskState::Todo);
}
}
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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 文件:src/storage.rs —— 存储边界。实现细节 pub(crate),公共行为走 trait。
use super::domain::{Task, TaskId}; // super:: 从本模块的父级(crate 根)出发。

/// 公共能力边界:调用方面向 trait 编程(见第 10 章)。
pub trait TaskStore {
fn get(&self, id: TaskId) -> Option<&Task>;
fn insert(&mut self, task: Task) -> Result<(), StoreError>;
fn all(&self) -> Vec<Task>;
}

#[derive(Debug, PartialEq)]
pub enum StoreError {
DuplicateId(u64),
}

/// 具体实现:pub(crate) 只给本 crate 内部用(比如 CLI 入口直接构造它),
/// 不进入公共 API;外部世界只认 TaskStore。
pub(crate) struct InMemoryStore {
tasks: Vec<Task>,
}

impl InMemoryStore {
pub(crate) fn new() -> Self {
InMemoryStore { tasks: Vec::new() }
}
}

impl Default for InMemoryStore {
fn default() -> Self {
Self::new()
}
}

impl TaskStore for InMemoryStore {
fn get(&self, id: TaskId) -> Option<&Task> {
self.tasks.iter().find(|task| task.id() == id)
}

fn insert(&mut self, task: Task) -> Result<(), StoreError> {
if self.get(task.id()).is_some() {
return Err(StoreError::DuplicateId(task.id().as_u64()));
}
self.tasks.push(task);
Ok(())
}

fn all(&self) -> Vec<Task> {
self.tasks.clone()
}
}

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

#[test]
fn insert_then_get_roundtrip() {
let id = super::super::domain::TaskId::new(1).expect("non-zero");
// super::super 从 tests 出发向上两级到 crate 根,再进 domain。
let task = super::super::domain::Task::new(id, "fix build").expect("non-empty");
let mut store = InMemoryStore::new();
store.insert(task).expect("first insert");
assert!(store.get(id).is_some());
}

#[test]
fn duplicate_id_is_rejected() {
let id = crate::domain::TaskId::new(1).expect("non-zero");
let t1 = crate::domain::Task::new(id, "a").expect("non-empty");
let t2 = crate::domain::Task::new(id, "b").expect("non-empty");
let mut store = InMemoryStore::new();
store.insert(t1).expect("first");
assert_eq!(store.insert(t2), Err(StoreError::DuplicateId(1)));
}
}

这份文件树的每个设计决策:

  • pub use domain::{Task, TaskId}:re-export 让公共路径是 task_model::Task。不 re-export 的模块名(如 storage::InMemoryStore 的具体类型)保留重组自由。
  • super::domaincrate::domainsuper 表达“我的父级”,crate 表达“本 crate 根”。跨大段代码移动时 crate:: 前缀更稳定;super:: 在贴近父子关系的小范围内更直观。
  • 私有字段 + #[cfg(test)] 子模块domain::testsdomain 的后代,能读 task.labels 私有字段做精确断言,生产 API 不需要任何 pub 迁就。
  • pub(crate)InMemoryStore 只给本 crate 的入口代码直接构造;对外只暴露 TaskStore trait,换实现(文件、数据库)不破坏下游。
  • #[must_use]TaskId::new 返回 Result,忘了检查就是编译警告。

单文件等价演示

上面的模块树需要多个文件。同样的可见性机制可以在一个自包含文件里演练(内嵌 mod 与文件版语义一致),复制即可运行:

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// 单文件版:mod 也可以内联声明,可见性规则与多文件完全相同。
mod domain {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TaskId(u64); // 字段私有。

impl TaskId {
pub fn new(value: u64) -> Result<Self, String> {
if value == 0 {
Err(String::from("任务 ID 不能为 0"))
} else {
Ok(TaskId(value))
}
}
}

#[derive(Debug, PartialEq)]
pub struct Task {
id: TaskId, // 私有字段:模块外不能直接构造或读写。
title: String,
}

impl Task {
pub fn new(id: TaskId, title: &str) -> Result<Self, String> {
if title.trim().is_empty() {
return Err(String::from("标题不能为空"));
}
Ok(Task { id, title: title.trim().to_string() })
}

pub fn title(&self) -> &str {
&self.title
}

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

mod storage {
use super::domain::{Task, TaskId}; // super:: 引用父模块(crate 根)里的 domain。

pub struct InMemoryStore {
tasks: Vec<Task>,
}

impl InMemoryStore {
pub fn new() -> Self {
InMemoryStore { tasks: Vec::new() }
}

pub fn get(&self, id: TaskId) -> Option<&Task> {
self.tasks.iter().find(|task| task.id() == id)
}

pub fn insert(&mut self, task: Task) -> Result<(), String> {
if self.get(task.id()).is_some() {
return Err(String::from("任务 ID 已存在"));
}
self.tasks.push(task);
Ok(())
}

pub fn count(&self) -> usize {
self.tasks.len()
}
}
}

// crate 根的 re-export:公共路径短,内部布局可自由调整。
pub use domain::{Task, TaskId};

fn main() {
let id = TaskId::new(1).expect("non-zero");
let task = Task::new(id, "fix build").expect("non-empty");
let mut store = storage::InMemoryStore::new();
store.insert(task).expect("first insert");

assert_eq!(store.count(), 1);
assert!(store.get(id).is_some());

// 私有字段的边界:下面这行无法编译(Task.title 私有)。
// let leak = store.get(id).expect("found").title.clone();

let missing = TaskId::new(99).expect("valid");
assert!(store.get(missing).is_none());
println!("single-file modules ok");
}

无法编译的例子–试图越过模块边界读私有字段:

1
2
3
4
5
6
7
8
9
10
11
12
// 无法编译:Task 的 title 字段在 domain 模块里是私有的。
// (task_model 是上面模块树编译出的库 crate。)
use task_model::{Task, TaskId};

fn main() {
let id = TaskId::new(1).expect("non-zero");
let task = Task::new(id, "fix build").expect("non-empty");
// error[E0616]: field `title` of struct `Task` is private
// 编译器拒绝:字段没有 pub,模块外只能通过 pub 方法访问。
let leaked = task.title.clone();
println!("{leaked}");
}

编译器拒绝原因:E0616: field 'title' of struct 'Task' is private。修正方式不是给字段加 pub(那会把内部布局变成公共承诺,将来改字段名就是破坏性变更),而是走已有的公共方法 task.title()(单文件演示里已给出)。

属性的常用边界

属性用途边界
#[derive(Debug, Clone, PartialEq)]自动实现常用 trait只对“数据”类型;有不变式的类型手工写构造
#[cfg(test)]仅测试编译测试代码不进入发布二进制
#[must_use]忽略返回值时警告用于 Result、builder 中间步骤等“忽略即 bug”的类型
#[allow(dead_code)]压制未使用警告最小范围 + 注释原因(第 11 章的共享词汇枚举就用了)
#[deny(unsafe_op_in_unsafe_fn)]收紧 unsafe 审查第 17 章展开

原则:不要全局关闭警告。每个 allow 都应有明确原因和尽可能小的作用范围(单个函数、单个字段),并配一行注释说明为什么这个警告在这里是误报。

宏只解决重复结构

println!vec!format!matches! 已覆盖大多数日常需求。想写 macro_rules! 之前先问:函数能不能做?看一个对照:

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
// 需求:断言“任务标题非空且以指定前缀开头”,失败时给出两个值。

// 方案一:宏。宏版断言能原样打印传入的表达式文本(stringify!),
// 对任意可比较类型“零成本”通用。
macro_rules! assert_starts_with {
($title:expr, $prefix:expr) => {
assert!(
$title.starts_with($prefix),
"标题 {:?} 不以 {:?} 开头",
$title,
$prefix
);
};
}

// 方案二:函数。类型检查更精确、可跳转、可单测、IDE 友好。
// 但 assert 消息里的表达式文本需要自己维护。
fn require_prefix(title: &str, prefix: &str) -> Result<(), String> {
if title.starts_with(prefix) {
Ok(())
} else {
Err(format!("标题 {title:?} 不以 {prefix:?} 开头"))
}
}

fn main() {
// 宏:直接可用,失败信息自动带两个值。
assert_starts_with!("fix build", "fix");
// assert_starts_with!("review pr", "fix"); // panic: 标题 "review pr" 不以 "fix" 开头

// 函数:返回 Result,可以组合、可以传播。
require_prefix("fix build", "fix").expect("prefix matches");
let error = require_prefix("review pr", "fix").expect_err("prefix mismatch");
assert_eq!(error, "标题 \"review pr\" 不以 \"fix\" 开头");

// 结论:能写成函数就写函数(本例两个都可行时,函数版本更好维护);
// 宏真正的舞台是“函数签名表达不了”的重复:
// 比如提前 return(try! 的历史角色)、在编译期重复结构(vec![] 字面量)。
println!("macro vs fn ok");
}

判断标准:

  • 普通控制流永远留在函数里。宏能把 return/?/break 藏进调用点,这会破坏调用方对控制流的预期。
  • macro_rules! 合适的场景:断言 helper(要 stringify! 的表达式文本)、重复声明结构(如一次定义多个相似测试)。
  • 过程宏(derive、属性宏、函数式宏)适合序列化、数据库映射、路由这类声明式重复。引入前先看展开后的行为、生成的公共 API 和编译时间成本;#[derive(Debug)] 属于这类里最安全的一档。

边界与失败场景

  • mod 声明与文件不符mod domain;src/domain.rs 不存在–编译错误“file not found for module”。模块必须被祖先显式声明,建了文件不写 mod 等于没编译。
  • 可见性选择错误pub(crate) 想跨 crate 用会报“private”;全 pub 又会把内部类型冻结成公共 API。每次可见性升级(私有 -> pub(crate) -> pub)都应是一次明确的 API 决策。
  • re-export 滥用:把内部模块全部 pub use 进根,等于没有边界;re-export 的量应该是“调用方真正需要的最小集合”。
  • #[cfg(test)] 写在 lib 之外却想访问私有项:测试模块必须是目标模块的后代才能看私有项;放在平级目录里就得靠 pub 迁就。

为什么可行

模块可见性是词法作用域 + 显式声明的组合:子模块天生能看到祖先的全部内容(含私有项),所以测试作为子模块不需要任何 API 让步;祖先看子模块必须走 pub 路径,所以生产代码的每次跨界都在源码里显式可见。#[cfg(test)] 利用条件编译让测试代码根本不参与发布构建。re-export 只改变“路径”,不改变可见性–pub use domain::Task 之所以可行,是因为 domain::Task 本身是 pub 的,re-export 只是给了它一条更短的公共路径。宏之所以危险,是因为它在类型检查之前展开:编译器看到的是展开后的代码,报错位置和源码位置可能相距甚远,这就是“能用函数就不用宏”的技术根源。

常见误区

  • 按技术分层命名模块utilshelperscommon):没有领域含义的模块最终都变成垃圾抽屉;按“哪个概念住在这里”命名(domainstoragecli)。
  • 为测试把字段设成 pub:把生产 API 永久变宽;把测试放进 #[cfg(test)] mod tests(子模块)即可访问私有项。
  • re-export 一切图省事pub use domain::*; 让根模块成为镜像,重组内部结构全部是破坏性变更。
  • 在宏里做控制流:调用方看到一个普通函数调用,却发生了提前 return;这正是 try! 演化成 ? 运算符的原因–后者是语言级显式语法。
  • 全局 #![allow(dead_code)]:真正该删的死代码被警告掩盖;最小范围 + 注释原因。

自测

  1. #[cfg(test)] mod tests 里的 use super::*; 为什么能访问被测模块的私有函数?
    方向:tests 是目标模块的后代,后代模块天然可见祖先的一切(含私有项);use super::* 把父模块的名字引入作用域。
  2. pub(crate)pub 的区别是什么?什么时候选前者?
    方向:pub(crate) 限制在本 crate 内可见;实现细节要被 crate 其他模块共享但不想进公共 API 时用它,保留未来替换自由。
  3. super::domaincrate::domain 各适合什么场景?
    方向:super 表达紧邻父子关系,小范围直观;crate 从根出发的绝对路径,代码移动后仍然有效。
  4. 同一个断言 helper,宏和函数各有什么优势?默认选哪个?
    方向:宏可用 stringify! 打印表达式文本、对任意类型零改动通用;函数有精确类型检查、跳转、单测。默认函数,宏只在签名表达不了时用。
  5. 为什么说“宏里藏控制流”是反模式?
    方向:宏在类型检查前展开,调用点发生的 return/? 不在函数签名里,调用方无法从类型预判;这正是 try!? 替代的原因。