课程概览 · 第 14 章
上一篇:错误处理:区分可恢复失败与程序缺陷
下一篇:智能指针:让资源归属可组合
任务项目长到三个文件之后,问题不再是“代码怎么写”,而是“谁能看到什么”:领域类型被 CLI 层直接改了内部字段、存储层的错误泄漏到了展示层、测试为了访问私有函数到处加 pub。模块的职责是建立稳定边界,不是镜像文件夹;宏的职责是消除真正重复的结构,不是把正常控制流藏起来。
图:模块守住依赖与可见性边界;属性和宏只在编译期辅助,不替代清晰的业务控制流。 学习目标与默认选择 学完本章你应当能够:
用 mod、pub、pub(crate)、self、super、crate 组织一个多文件库 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::domain 与 crate::domain :super 表达“我的父级”,crate 表达“本 crate 根”。跨大段代码移动时 crate:: 前缀更稳定;super:: 在贴近父子关系的小范围内更直观。私有字段 + #[cfg(test)] 子模块 :domain::tests 是 domain 的后代,能读 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 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}; 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 () } } }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 ()); 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 macro_rules! assert_starts_with { ($title:expr, $prefix:expr) => { assert! ( $title.starts_with ($prefix), "标题 {:?} 不以 {:?} 开头" , $title, $prefix ); }; }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" ); 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\" 开头" ); 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 只是给了它一条更短的公共路径。宏之所以危险,是因为它在类型检查之前 展开:编译器看到的是展开后的代码,报错位置和源码位置可能相距甚远,这就是“能用函数就不用宏”的技术根源。
常见误区 按技术分层命名模块 (utils、helpers、common):没有领域含义的模块最终都变成垃圾抽屉;按“哪个概念住在这里”命名(domain、storage、cli)。为测试把字段设成 pub :把生产 API 永久变宽;把测试放进 #[cfg(test)] mod tests(子模块)即可访问私有项。re-export 一切图省事 :pub use domain::*; 让根模块成为镜像,重组内部结构全部是破坏性变更。在宏里做控制流 :调用方看到一个普通函数调用,却发生了提前 return;这正是 try! 演化成 ? 运算符的原因–后者是语言级显式语法。全局 #![allow(dead_code)] :真正该删的死代码被警告掩盖;最小范围 + 注释原因。自测 #[cfg(test)] mod tests 里的 use super::*; 为什么能访问被测模块的私有函数?方向:tests 是目标模块的后代,后代模块天然可见祖先的一切(含私有项);use super::* 把父模块的名字引入作用域。 pub(crate) 和 pub 的区别是什么?什么时候选前者?方向:pub(crate) 限制在本 crate 内可见;实现细节要被 crate 其他模块共享但不想进公共 API 时用它,保留未来替换自由。 super::domain 和 crate::domain 各适合什么场景?方向:super 表达紧邻父子关系,小范围直观;crate 从根出发的绝对路径,代码移动后仍然有效。 同一个断言 helper,宏和函数各有什么优势?默认选哪个?方向:宏可用 stringify! 打印表达式文本、对任意类型零改动通用;函数有精确类型检查、跳转、单测。默认函数,宏只在签名表达不了时用。 为什么说“宏里藏控制流”是反模式?方向:宏在类型检查前展开,调用点发生的 return/? 不在函数签名里,调用方无法从类型预判;这正是 try! 被 ? 替代的原因。