Agent X-Ray
RuntimeNotesAbout
Notes/代码工程/Rust 深度教材/第6章

第6章:工程骨架 —— 集合、错误、模块、测试与文档

9 分钟 · 更新于 2026-09-01

第6章:工程骨架 —— 集合、错误、模块、测试与文档

单个类型正确,并不代表项目可维护。本章把前面的工作项模型装进 work-catalog 库,重点观察五条边界:数据怎样存储、失败怎样返回、实现怎样隐藏、契约怎样测试、公开 API 怎样被文档化。


一、先画工程边界

目标项目不是“把代码拆成多个文件”,而是形成一个可以被其他程序依赖的小型库:

text
work-catalog/
├── Cargo.toml
├── src/
│   ├── lib.rs       # 公开入口
│   ├── model.rs     # 领域类型
│   ├── catalog.rs   # 集合与操作
│   └── error.rs     # 结构化错误
└── tests/
    └── catalog_flow.rs

拆分前先回答五个问题:

  1. 顺序遍历和按编号查找分别需要什么数据结构?
  2. 重复编号、缺失对象和非法状态属于可恢复错误还是程序缺陷?
  3. 调用者真正需要哪些公开类型与方法?
  4. 哪些规则适合单元测试,哪些行为必须从公开 API 验证?
  5. 使用者能否仅凭文档示例完成一次正确调用?

模块结构应服务于这些边界,而不是为了追求“一个文件只能放一种语法”。

二、Vec<T>:有顺序、会增长的列表

rust
#[derive(Debug, Clone, PartialEq, Eq)]
struct WorkItem {
    id: u64,
    title: String,
    done: bool,
}
let mut items: Vec<WorkItem> = Vec::new();
items.push(WorkItem { id: 1, title: "数据建模".into(), done: true });
items.push(WorkItem { id: 2, title: "工程骨架".into(), done: false });

Vec<T> 的长度在运行时可变,元素连续存放,适合按顺序遍历与索引读取。访问方式表达不同承诺:

rust
let first = &items[0]; // 调用者确信存在,否则 panic
match items.get(10) {
    Some(item) => println!("{}", item.title),
    None => println!("没有这个位置"),
}

外部输入形成的索引优先使用 .get()。三种遍历对应三种所有权语义:

写法元素类型结果
for item in itemsWorkItem消耗 Vec
for item in &items&WorkItem只读借用
for item in &mut items&mut WorkItem可变借用

push 可能扩容并搬迁缓冲区,因此元素引用仍在使用时不能修改 Vec

rust
let mut values = vec![1, 2, 3];
let first = &values[0];
// values.push(4); // first 后面仍要使用,禁止潜在搬迁
println!("{first}");

正确做法是先用完引用再修改集合,或保存稳定编号而不是元素地址。


三、HashMap<K, V>:按编号建立索引

每次按编号遍历 Vec 是线性查找。可以增加位置索引:

rust
use std::collections::HashMap;
let mut positions: HashMap<u64, usize> = HashMap::new();
positions.insert(1, 0);
positions.insert(2, 1);
if let Some(index) = positions.get(&2) {
    println!("工作项 2 在位置 {index}");
}

HashMap 适合按键查值、去重与计数。get 返回借用,不会移走值。entry 可把查询与更新合成一次:

rust
let words = ["rust", "item", "rust"];
let mut counts = HashMap::new();
for word in words {
    *counts.entry(word).or_insert(0) += 1;
}
assert_eq!(counts.get("rust"), Some(&2));

主存储与索引组合:

rust
#[derive(Debug, Default)]
struct Catalog {
    items: Vec<WorkItem>,
    positions: HashMap<u64, usize>,
}
impl Catalog {
    fn get(&self, id: u64) -> Option<&WorkItem> {
        let index = *self.positions.get(&id)?;
        self.items.get(index)
    }
}

? 此处作用于 Option:任一步为 None 就立即返回 None。 双结构带来一致性成本:插入、删除、重排都必须同步索引。小数据可先只用 Vec;确有查找或唯一性需求时再增加 HashMap


四、panic!Result:失败必须分类

Rust 把失败分成两类:

  • 不可恢复、内部不变量被破坏:panic!
  • 调用者可以处理:Result<T, E>。 用户输错编号、重复添加工作项都可恢复,不应让库直接崩溃。unwrapexpect 会在 None / Errpanic,适合测试、示例和已被逻辑证明的前置条件,不应习惯性用于文件、网络与用户输入。 定义结构化领域错误:
rust
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CatalogError {
    DuplicateId(u64),
    NotFound(u64),
    AlreadyDone(u64),
}
impl fmt::Display for CatalogError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::DuplicateId(id) => write!(f, "工作项编号 {id} 已存在"),
            Self::NotFound(id) => write!(f, "工作项编号 {id} 不存在"),
            Self::AlreadyDone(id) => write!(f, "工作项编号 {id} 已完成"),
        }
    }
}
impl std::error::Error for CatalogError {}

枚举错误让调用者按变体处理,测试也能断言具体原因,而不是解析字符串。 添加工作项:

rust
impl Catalog {
    pub fn add(&mut self, item: WorkItem) -> Result<(), CatalogError> {
        if self.positions.contains_key(&item.id) {
            return Err(CatalogError::DuplicateId(item.id));
        }
        let id = item.id;
        let index = self.items.len();
        self.items.push(item);
        self.positions.insert(id, index);
        Ok(())
    }
}

传播错误:

rust
impl Catalog {
    pub fn complete(&mut self, id: u64) -> Result<(), CatalogError> {
        let index = self.positions.get(&id).copied()
            .ok_or(CatalogError::NotFound(id))?;
        let item = self.items.get_mut(index)
            .ok_or(CatalogError::NotFound(id))?;
        if item.done {
            return Err(CatalogError::AlreadyDone(id));
        }
        item.done = true;
        Ok(())
    }
}

? 不是忽略错误,而是成功时取值、失败时提前返回,并在需要时转换错误。库返回结构化错误,应用层决定提示、重试、记录还是退出。


五、Package、Crate 与 Module

三个概念不要混淆:

概念含义
PackageCargo.toml 定义的 Cargo 项目
Crate编译单元,根通常是 lib.rsmain.rs
Modulecrate 内的命名空间与可见性边界

一个 package 最多有一个 library crate,也可以有多个 binary crate。库入口 src/lib.rs 负责声明模块并设计公开门面:

rust
mod catalog;
mod error;
mod model;
pub use catalog::Catalog;
pub use error::CatalogError;
pub use model::WorkItem;

内部文件布局没有直接暴露给使用者。以后即使拆分 catalog.rs,只要 crate 根的重新导出保持不变,调用方仍可写 work_catalog::Catalog


六、可见性与 use:默认私有,按需开放

model.rs

rust
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkItem {
    id: u64,
    title: String,
    done: bool,
}
impl WorkItem {
    pub fn new(id: u64, title: impl Into<String>) -> Self {
        Self { id, title: title.into(), done: false }
    }
    pub fn id(&self) -> u64 { self.id }
    pub fn title(&self) -> &str { &self.title }
    pub fn is_done(&self) -> bool { self.done }
    pub(crate) fn mark_done(&mut self) {
        self.done = true;
    }
}
  • pub:模块私有;
  • pub:对 crate 外公开;
  • pub(crate):只在当前 crate 内公开;
  • pub(super):仅父模块范围可见。 字段保持私有,调用者只能通过构造函数和方法建立合法状态。mark_done 仅给内部 Catalog 使用,外部不能绕开错误检查。 catalog.rs 引入依赖:
rust
use std::collections::HashMap;
use crate::error::CatalogError;
use crate::model::WorkItem;

use 只缩短路径,不会移动值。as 可解决同名冲突,pub use 则兼具引入与重新导出,是设计公开门面的关键工具。


七、最小实现

src/catalog.rs

rust
use std::collections::HashMap;
use crate::{CatalogError, WorkItem};
#[derive(Debug, Default)]
pub struct Catalog {
    items: Vec<WorkItem>,
    positions: HashMap<u64, usize>,
}
impl Catalog {
    pub fn len(&self) -> usize { self.items.len() }
    pub fn is_empty(&self) -> bool { self.items.is_empty() }
    pub fn add(&mut self, item: WorkItem) -> Result<(), CatalogError> {
        if self.positions.contains_key(&item.id()) {
            return Err(CatalogError::DuplicateId(item.id()));
        }
        let id = item.id();
        let index = self.items.len();
        self.items.push(item);
        self.positions.insert(id, index);
        Ok(())
    }
    pub fn get(&self, id: u64) -> Option<&WorkItem> {
        let index = *self.positions.get(&id)?;
        self.items.get(index)
    }
    pub fn complete(&mut self, id: u64) -> Result<(), CatalogError> {
        let index = self.positions.get(&id).copied()
            .ok_or(CatalogError::NotFound(id))?;
        let item = self.items.get_mut(index)
            .ok_or(CatalogError::NotFound(id))?;
        if item.is_done() {
            return Err(CatalogError::AlreadyDone(id));
        }
        item.mark_done();
        Ok(())
    }
    pub fn iter(&self) -> impl Iterator<Item = &WorkItem> {
        self.items.iter()
    }
}

src/lib.rs

rust
//! 一个用于学习 Rust 工程骨架的内存工作项目录。
mod catalog;
mod error;
mod model;
pub use catalog::Catalog;
pub use error::CatalogError;
pub use model::WorkItem;

工程边界已经形成:模型维护自身状态,Catalog 维护集合与索引,错误模块描述失败,lib.rs 提供稳定入口。


八、单元测试:靠近实现验证内部规则

catalog.rs 底部:

rust
#[cfg(test)]
mod tests {
    use super::*;
    fn sample_item(id: u64) -> WorkItem {
        WorkItem::new(id, format!("item-{id}"))
    }
    #[test]
    fn add_and_get_item() {
        let mut catalog = Catalog::default();
        catalog.add(sample_item(1)).unwrap();
        let item = catalog.get(1).expect("工作项应存在");
        assert_eq!(item.id(), 1);
        assert!(!item.is_done());
    }
    #[test]
    fn duplicate_id_is_rejected() {
        let mut catalog = Catalog::default();
        catalog.add(sample_item(1)).unwrap();
        assert_eq!(
            catalog.add(sample_item(1)).unwrap_err(),
            CatalogError::DuplicateId(1),
        );
    }
    #[test]
    fn complete_twice_is_rejected() {
        let mut catalog = Catalog::default();
        catalog.add(sample_item(1)).unwrap();
        catalog.complete(1).unwrap();
        assert_eq!(
            catalog.complete(1).unwrap_err(),
            CatalogError::AlreadyDone(1),
        );
    }
}

#[cfg(test)] 只在测试构建时编译该模块。单元测试可访问私有项,适合检查内部不变量。 常用断言:assert!assert_eq!assert_ne!debug_assert! 系列通常只在非优化构建启用,适合验证内部假设,不能替代正式错误处理。 测试也可返回 Result 以便使用 ?

rust
#[test]
fn complete_flow() -> Result<(), CatalogError> {
    let mut catalog = Catalog::default();
    catalog.add(WorkItem::new(1, "写测试"))?;
    catalog.complete(1)?;
    assert!(catalog.get(1).unwrap().is_done());
    Ok(())
}

九、集成测试:只从使用者视角看公开 API

tests/catalog_flow.rs

rust
use work_catalog::{Catalog, CatalogError, WorkItem};
#[test]
fn user_can_add_and_complete_an_item() {
    let mut catalog = Catalog::default();
    catalog.add(WorkItem::new(1, "发布版本")).unwrap();
    catalog.complete(1).unwrap();
    assert!(catalog.get(1).unwrap().is_done());
}
#[test]
fn missing_item_returns_public_error() {
    let mut catalog = Catalog::default();
    assert_eq!(
        catalog.complete(404).unwrap_err(),
        CatalogError::NotFound(404),
    );
}

tests/ 下每个文件是独立 crate,只能访问公开 API。它能检验 API 是否好用、pub use 是否完整、内部细节是否意外泄漏。 常用命令:

bash
cargo test
cargo test duplicate_id
cargo test -- --show-output
cargo test -- --test-threads=1
cargo test -- --ignored

默认测试可能并行运行。依赖端口、文件或环境变量的测试应隔离资源;只有确实共享资源时才考虑串行。


十、文档与文档测试:公开 API 的第一层界面

项级文档使用 ///

rust
/// 一个按插入顺序保存工作项、按编号建立索引的目录。
///
/// # Examples
///
/// ```
/// use work_catalog::{Catalog, WorkItem};
///
/// let mut catalog = Catalog::default();
/// catalog.add(WorkItem::new(1, "写文档"))?;
/// assert_eq!(catalog.get(1).unwrap().title(), "写文档");
/// # Ok::<(), work_catalog::CatalogError>(())
/// ```
#[derive(Debug, Default)]
pub struct Catalog {
    // ...
}

crate 或 module 文档使用 //!。常见标题有 # Examples# Errors# Panics# Safety。 文档代码块会被 cargo test 编译和运行,能同时验证示例、公开路径和用户体验。以 # 开头的辅助行参与编译但不展示;只编译不运行可用 no_run,故意验证无法编译可用 compile_fail。 生成文档:

bash
cargo doc --no-deps
cargo doc --no-deps --open

不要用 ignore 掩盖本应修复的过期示例。能执行的文档才不容易与代码分叉。


十一、基准测试与 CI

普通测试回答「对不对」,基准测试回答「在受控条件下有多快」。稳定 Rust 项目通常使用 Criterion 等工具。基准应明确输入规模、隔离初始化、避免结果被优化掉并进行多次采样;一次纳秒级数字不足以下结论。 最小 GitHub Actions:

yaml
name: Rust CI
on:
  push:
  pull_request:
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt, clippy
      - run: cargo fmt --all -- --check
      - run: cargo clippy --all-targets --all-features -- -D warnings
      - run: cargo test --all-features
      - run: cargo doc --no-deps

它把格式、静态检查、测试和文档生成变成每次变更的自动门禁。第三方 Action 会执行外部代码,生产仓库应固定可信版本并审查权限与供应链风险。


十二、常见误区

  1. 小数据也维护多个索引:先测量需求,再承担一致性成本。
  2. 外部失败大量 unwrap:库返回 Result,应用边界决定是否退出。
  3. 错误只用 String:调用者只能解析文字;稳定领域错误优先使用枚举。
  4. 所有字段和模块都 pub:公开 API 越大,未来修改成本越高。
  5. 文件越多越工程化:模块应围绕变化原因和边界拆分。
  6. 只测成功路径:重复编号、找不到、重复完成才是规则密集区。
  7. 集成测试依赖内部路径:通常说明公开门面设计不完整。
  8. 文档示例不编译:不可执行示例最容易随 API 漂移。
  9. 一次 benchmark 就宣布优化成功:必须说明环境、规模和统计方法。

十三、实践练习

练习一:选择主存储

只用 Vec<WorkItem> 实现新增、查询和删除,测量或推导各操作复杂度。再增加 HashMap<u64, usize> 索引,列出必须同步维护的路径。

练习二:扩展错误类型

增加 InvalidTitleCapacityExceeded,让调用方可以通过枚举变体区分原因。为错误实现 Display,但测试优先断言变体而不是错误字符串。

练习三:收紧公开 API

把字段设为私有,只公开构造、查询和状态转换方法。运行集成测试,确认测试只能使用 crate 对外承诺的接口。

练习四:补一条文档测试

Catalog::add 写可运行示例,包含成功路径和重复编号错误。执行 cargo test --doc,确保文档代码不会随实现变化而失效。

练习五:建立 CI 最小门禁

设计一组命令,使格式错误、warning、单元测试失败、集成测试失败和文档测试失败都会阻止合并。说明基准测试为什么通常不适合作为每次提交的硬门禁。

十四、总结

  • Vec<T> 擅长顺序存储与遍历,HashMap<K, V> 擅长按键访问;组合使用会引入一致性成本。
  • panic! 表示无法继续维护程序不变量,Result 表示调用者有机会处理的失败。
  • Package、Crate 与 Module 解决不同层级的组织问题;文件拆分只是模块设计的一种实现方式。
  • 默认私有能缩小修改影响面,公开 API 应只暴露稳定契约。
  • 单元测试验证内部规则,集成测试验证使用者视角,文档测试同时验证示例与接口。
  • CI 的价值是把团队约定变成自动检查,而不是简单增加命令数量。
  • 工程化的核心是让数据、错误、可见性和验证边界彼此一致。

十五、下一章

这三章已经形成完整基础主线:用类型建模数据,用泛型与 Trait 建立抽象,再用集合、错误、模块、测试与文档搭起工程骨架。

下一章把本章的工程骨架真正做成一个命令行程序,并用 linefind 的演进串起闭包、迭代器、环境变量、错误输出与测试:

  • 第7章:函数式 Rust —— 闭包、迭代器与命令行实战

  • Rust 深度教程
  • 第4章:数据建模
  • 第5章:抽象能力
  • 第7章:函数式 Rust
  • Pi 第12章:测试模式 —— 测试替身与断言面的工程对照

本章目录
一、先画工程边界二、Vec<T>:有顺序、会增长的列表三、HashMap<K, V>:按编号建立索引四、panic! 与 Result:失败必须分类五、Package、Crate 与 Module六、可见性与 use:默认私有,按需开放七、最小实现八、单元测试:靠近实现验证内部规则九、集成测试:只从使用者视角看公开 API十、文档与文档测试:公开 API 的第一层界面十一、基准测试与 CI十二、常见误区十三、实践练习十四、总结十五、下一章Related Documents
苏ICP备2025204887号-2