Skip to content

练习与自测

本章练习共 11 题,答案折叠在每题下方。建议先自己写、编译通过后再展开答案;难度标记:★☆☆ 基础 / ★★☆ 综合 / ★★★ 挑战。

说明:练习 1~13.12。带 cargo 字样的题目请在自己新建的练习工程里做(本章不提供工程文件);只涉及标准库、可以用 rustc --edition 2024 单文件验证的题目,会注明验证命令。

练习 1:单元测试、集成测试与 doctest 三件套

难度:★★☆

要求:实现 pub fn csv_split(line: &str) -> Vec<&str>,规则:按 , 切分;每段去掉首尾空格;空行返回空 Vec。为它写三组测试——单元测试(同文件 mod tests,≥3 个用例)、集成测试(tests/csv.rs)和 doctest(≥2 个示例,其中一个用 # 隐藏行)。给出完整目录结构与三个文件的代码。

提示:先决定「空段是否保留」,把这条规则写成测试的断言再写实现。

参考答案(先自己写再看)参考答案(先自己写再看)
csv_demo/
├── Cargo.toml
├── src/
│   └── lib.rs            # 实现 + 单元测试
├── tests/
│   └── csv.rs            # 集成测试(独立 crate,只能用 pub API)
└── benches/
    └── csv_bench.rs      # 练习 3 用
toml
# Cargo.toml
[package]
name = "csv_demo"
version = "0.1.0"
edition = "2024"
rust
// src/lib.rs
//! 极简 CSV 行切分。

/// 按 `,` 切分一行,去掉每段首尾空白;空行返回空 `Vec`。
///
/// 空段会被保留(`"a,,b"` → `["a", "", "b"]`),因为空字段在 CSV 里有意义。
///
/// # Examples
///
/// ```
/// use csv_demo::csv_split;
///
/// assert_eq!(csv_split("a, b ,c"), vec!["a", "b", "c"]);
/// // 下面这行用 `# ` 隐藏:读者看不到赋值,但断言仍然可读
/// # let expected = vec!["", ""];
/// assert_eq!(csv_split(","), expected);
/// ```
pub fn csv_split(line: &str) -> Vec<&str> {
    if line.trim().is_empty() {
        return Vec::new();
    }
    line.split(',').map(str::trim).collect()
}

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

    #[test]
    fn splits_and_trims_fields() {
        assert_eq!(csv_split("a, b ,c"), vec!["a", "b", "c"]);
    }

    #[test]
    fn keeps_empty_fields() {
        assert_eq!(csv_split(","), vec!["", ""]);
        assert_eq!(csv_split("a,,b"), vec!["a", "", "b"]);
    }

    #[test]
    fn empty_or_blank_line_yields_no_fields() {
        assert_eq!(csv_split(""), Vec::<&str>::new());
        assert_eq!(csv_split("   "), Vec::<&str>::new());
    }

    #[test]
    fn single_field_without_comma() {
        assert_eq!(csv_split("  only  "), vec!["only"]);
    }
}
rust
// tests/csv.rs
use csv_demo::csv_split;

#[test]
fn integration_view_of_the_public_api() {
    // 集成测试只看到 pub API,验证的是对外契约
    assert_eq!(csv_split("x, y"), vec!["x", "y"]);
}

#[test]
fn newline_inside_field_is_not_special_here() {
    // 这里只切逗号,换行由调用方按行处理——把它写成测试,规则就固化了
    assert_eq!(csv_split("a\nb,c"), vec!["a\nb", "c"]);
}
powershell
cargo test            # 单元测试 + 集成测试 + doctest 一起跑
cargo test --lib      # 只跑 src 里的单元测试
cargo test --test csv # 只跑 tests/csv.rs
cargo test --doc      # 只跑 doctest

要点解析:三层测试各有分工——单元测试可以随实现重构(改名、改私有函数)而调整,集成测试盯住「对外承诺」,doctest 盯住「文档里写的用法真的能用」。三者的用例故意不完全重复:单元测试覆盖空段与空行,集成测试覆盖换行这类「调用方视角」的假设。

练习 2:#[should_panic] 的正反例

难度:★☆☆

要求:写一个 pub fn parse_port(s: &str) -> u16:非数字 → panic;0 → panic;合法值返回。给出一个合格的 #[should_panic(expected = "...")];再构造一个「不加 expected 就会误报通过」的例子,说明为什么会误判。

提示:让被误判的那个测试在到达目标断言之前先 unwrap() 失败。

参考答案(先自己写再看)参考答案(先自己写再看)
rust
/// 解析端口号。
///
/// # Panics
///
/// 当输入不是整数,或解析结果不在 `1..=65535` 时 panic。
pub fn parse_port(s: &str) -> u16 {
    let n: u32 = s.trim().parse().unwrap_or_else(|_| panic!("端口必须是整数,收到 {s:?}"));
    assert!(n > 0, "端口不能为 0");
    assert!(n <= u16::MAX as u32, "端口超出范围: {n}");
    n as u16
}

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

    #[test]
    fn parses_valid_port() {
        assert_eq!(parse_port(" 8080 "), 8080);
        assert_eq!(parse_port("1"), 1);
        assert_eq!(parse_port("65535"), 65535);
    }

    #[test]
    #[should_panic(expected = "端口必须是整数")]
    fn non_numeric_panics_with_reason() {
        parse_port("abc");
    }

    #[test]
    #[should_panic(expected = "端口不能为 0")]
    fn zero_panics_with_reason() {
        parse_port("0");
    }
}

不合格的反例,以及它为什么会误报通过

rust
#[test]
#[should_panic]                 // 没有 expected:任何 panic 都算通过
fn bad_example() {
    let s = "0";
    let n: u16 = s.parse().unwrap();   // ← "0" 这里能解析成功,继续往下
    assert!(n > 0, "端口不能为 0");      // ← 真正想测的 panic 在这里
    let _ = parse_port("not a port");   // ← 但即使上面没 panic,这行也只需 panic 就通过
}

把中间的 parse() 改成 s.parse::<u8>().unwrap() 并传入 "999",就会在与端口校验无关的地方 panic,而 #[should_panic] 依然判定通过。加上 expected = "端口不能为 0" 后,这种误报立刻消失。

要点解析expected子串匹配,所以选一段只可能出现在目标 panic 消息里的文字(越独特越好)。另外要区分「契约性 panic」(文档里用 # Panics 说明,测试用 #[should_panic])和「可恢复错误」(返回 Result,测试用 assert!(x.is_err()))——parse_port 这种「配置错误就该快速失败」的场景适合 panic,而用户输入解析通常更适合 Result

练习 3:用 criterion 搭建基准测试

难度:★★☆

要求:为练习 1 的 csv_split 搭一个 criterion 基准,要求包含:Cargo.toml[dev-dependencies][[bench]]benches/csv_bench.rs(含 criterion_group!/criterion_main!)、至少两个不同长度的输入、一个 black_box。然后解释输出里 time: [a b c] 三个数分别是什么,以及 change: [-1.2% +0.3% +1.9%] (p = 0.42 > 0.05) 该怎么读。

提示BenchmarkId 能让同一个 group 里出现多条可比对的曲线。

参考答案(先自己写再看)参考答案(先自己写再看)
toml
# Cargo.toml
[package]
name = "csv_demo"
version = "0.1.0"
edition = "2024"

[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }

[[bench]]
name = "csv_bench"
harness = false
rust
// benches/csv_bench.rs
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use csv_demo::csv_split;
use std::hint::black_box;

fn input(fields: usize) -> String {
    // 运行时构造输入,避免常量折叠
    (0..fields).map(|i| format!("f{i},")).collect()
}

fn bench_csv_split(c: &mut Criterion) {
    let mut group = c.benchmark_group("csv_split");
    for fields in [8usize, 128] {
        let line = input(fields);
        group.throughput(Throughput::Bytes(line.len() as u64));
        group.bench_with_input(BenchmarkId::from_parameter(fields), &line, |b, line| {
            b.iter(|| csv_split(black_box(line)))
        });
    }
    group.finish();
}

criterion_group!(benches, bench_csv_split);
criterion_main!(benches);
powershell
cargo bench --bench csv_bench
cargo bench --bench csv_bench -- 128    # 只跑 128 字段那一条(在 -- 之后过滤)

> 输出(示意):

text
csv_split/8             time:   [186.42 ns 187.91 ns 189.55 ns]
csv_split/128           time:   [2.6104 us 2.6312 us 2.6530 us]

输出怎么读

  • time: [a b c]a 是置信区间下界b点估计(criterion 推荐引用的值),c上界。区间越窄说明测量越稳定;区间很宽时不该下结论,应该减少噪声(关后台程序、固定电源模式)后重跑。
  • change: [-1.2% +0.3% +1.9%] (p = 0.42 > 0.05):与上一次同参数运行相比的变化区间。区间跨过 0 且 p > 0.05 → 判定「没有显著变化」;若整个区间都小于 0(例如 [-12.0% -9.5% -7.1%])且 p < 0.05,才是「确实变快了」。
  • 配合 Throughput::Bytes 还会打印 thrpt: 吞吐量,便于看「每字节多少 ns」。

要点解析:不要只看点估计。改动后如果 change 区间跨 0,说明你的优化淹没在噪声里——这时候要么多跑几次,要么这个优化压根不值得做。criterion 的基线保存在 target/criterion/ 下,删掉它等价于「重新开始比较」。

练习 4:用 size_of 验证 niche 优化

难度:★☆☆

要求:定义 enum Id { None, Num(u32), Ptr(Box<u8>) },用 size_of 打印它与 Option<Id> 的大小;再打印 Option<Box<u8>>Option<std::num::NonZeroU32>Option<u64> 的大小。解释每一个数字的原因。

提示:注意 Id 的判别式放在哪里、有没有复用 Box 的空指针 niche。

参考答案(先自己写再看)参考答案(先自己写再看)
rust
use std::mem::size_of;
use std::num::NonZeroU32;

#[derive(Debug)]
enum Id {
    None,          // 判别式候选
    Num(u32),      // 4 字节
    Ptr(Box<u8>),  // 8 字节
}

fn main() {
    println!("Id                    = {}", size_of::<Id>());
    println!("Option<Id>            = {}", size_of::<Option<Id>>());
    println!("Option<Box<u8>>       = {}", size_of::<Option<Box<u8>>>());
    println!("Box<u8>               = {}", size_of::<Box<u8>>());
    println!("Option<NonZeroU32>    = {}", size_of::<Option<NonZeroU32>>());
    println!("Option<u32>           = {}", size_of::<Option<u32>>());
    println!("Option<u64>           = {}", size_of::<Option<u64>>());
    println!("Option<Option<u8>>    = {}", size_of::<Option<Option<u8>>>());
}

> 输出(rustc 1.98.1 / x86_64-pc-windows-msvc 实测):

text
Id                    = 16
Option<Id>            = 16
Option<Box<u8>>       = 8
Box<u8>               = 8
Option<NonZeroU32>    = 4
Option<u32>           = 8
Option<u64>           = 16
Option<Option<u8>>    = 2

逐条解释:

  • Id = 16 字节:最大变体是 Ptr(Box<u8>)(8 字节、对齐 8),函数还需要记录「当前是哪个变体」,因此需要一个判别式字段。编译器把判别式塞进 Box 提供的空位(空指针)里,最终布局是「8 字节载荷 + 8 字节判别/填充」,对齐 8 → 16 字节。
  • Option<Id> = 16 字节,与 Id 相同:Id 仍然存在可复用的空位(Ptr 的空指针值、以及 u32 未用满的取值空间),外层 Option 的判别式被吸收进这些空位,没有额外增长。
  • Option<Box<u8>> = Box<u8> = 8 字节Box 永不为空(永远不可能是 0 地址),空指针这个 niche 被用来表示 None,判别式零开销。
  • Option<NonZeroU32> = 4 字节NonZeroU32 只有 2³²−1 个合法值,0 是现成 niche,判别式零开销。
  • Option<u32> = 8 字节u32 用满了全部 2³² 个位模式,没有任何空位,所以必须外挂一个判别式字段;又因为对齐是 4,判别式也占 4 字节 → 4 + 4 = 8。
  • Option<u64> = 16 字节:同理,u64 无 niche,判别式占一个 8 字节对齐槽 → 8 + 8 = 16。
  • Option<Option<u8>> = 2 字节:内层 Option<u8> 本身占 2 字节,但它的判别式只用到了 0/1 两个取值,外层复用剩余取值当自己的 niche,因此不额外增长。

⚠️ 重要提醒Option<u32>/Option<u64>/Option<Id>具体大小属于实现细节,会随 rustc 版本与目标平台变化。教学上正确的做法是:size_of 实测,不要背数字。能稳定依赖的规律只有两条——「带 niche 的类型其 Option 零开销」(Box<T>/&T/Vec<T>/String/NonZeroXxx),以及「无 niche 的类型其 Option 至少要额外付出一个判别式字段」。

要点解析:把 Id 里的 None 变体去掉,得到 enum Id2 { Num(u32), Ptr(Box<u8>) },此时 Option<Id2> 仍可复用 Ptr 侧的空指针 niche(u32 侧没有空位,但枚举只要存在一个可用空位即可),大小不会增长。设计热点数据结构时,用 NonZeroXxxBox 或引用包裹,常常同时换来「类型更安全」和「内存更小」——把不变量编码进类型是有性能红利的。

练习 5:手写 unsafesplit_at_mut

难度:★★★

要求:不要调用标准库的 split_at_mut,用 as_mut_ptr + slice::from_raw_parts_mut 实现 pub fn split2<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]);越界必须 panic 而不是 UB。写 ≥4 条 SAFETY 说明,并补一个「两段可以独立修改」的单元测试。说明为什么用安全索引写不出这个函数。

提示:两段长度之和必须等于原长度,这是「不重叠」的算术依据。

参考答案(先自己写再看)参考答案(先自己写再看)
rust
use std::slice;

/// 在 `mid` 处把可变切片分成两半。
///
/// # Panics
///
/// 当 `mid > slice.len()` 时 panic(绝不产生 UB)。
pub fn split2<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
    let len = slice.len();
    // 前置条件检查放在 unsafe 块之外:保证任何越界输入都不会进入危险代码
    assert!(mid <= len, "mid ({mid}) 超过切片长度 ({len})");

    let ptr = slice.as_mut_ptr();   // 取得裸指针,本身是安全操作

    // SAFETY:
    // 1) 非空/可解引用:ptr 来自有效切片 &mut [T]。当 len > 0 时 ptr 非空;
    //    当 len == 0 时必有 mid == 0,两段的长度都是 0,
    //    from_raw_parts_mut 允许传入「按 T 对齐的非空 dangling 指针 + len 0」,
    //    而 slice 的 as_mut_ptr 在空切片上返回的正是这种对齐的 dangling 指针。
    // 2) 对齐:ptr 由 &mut [T] 得到,保证按 align_of::<T>() 对齐。
    // 3) 已初始化:&mut [T] 的前提就是这 len 个 T 全部处于已初始化状态。
    // 4) 单次分配 + 不重叠:两段起点分别为 ptr 与 ptr.add(mid),长度分别为 mid 与 len-mid,
    //    长度之和恰为 len,且 mid <= len 保证 ptr.add(mid) 仍在同一分配内;
    //    因此两段互不重叠,满足 &mut T 的独占别名要求。
    // 5) 生命周期:返回的两个 &mut [T] 的生命周期由函数签名与 &mut [T] 输入绑定,
    //    原切片在两段存活期间被借用,不会被第三方访问。
    unsafe {
        let left = slice::from_raw_parts_mut(ptr, mid);
        let right = slice::from_raw_parts_mut(ptr.add(mid), len - mid);
        (left, right)
    }
}

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

    #[test]
    fn halves_can_be_mutated_independently() {
        let mut v = [1, 2, 3, 4];
        let (a, b) = split2(&mut v, 2);
        a[0] = 10;
        b[0] = 40;
        assert_eq!(v, [10, 2, 40, 4]);
    }

    #[test]
    fn split_at_zero_and_at_len() {
        let mut v = [1, 2, 3];
        {
            let (a, b) = split2(&mut v, 0);
            assert!(a.is_empty());
            assert_eq!(b, [1, 2, 3]);
        }
        {
            let (a, b) = split2(&mut v, 3);
            assert_eq!(a, [1, 2, 3]);
            assert!(b.is_empty());
        }
    }

    #[test]
    fn works_on_empty_slice() {
        let mut v: [u8; 0] = [];
        let (a, b) = split2(&mut v, 0);
        assert!(a.is_empty() && b.is_empty());
    }

    #[test]
    #[should_panic(expected = "超过切片长度")]
    fn out_of_range_panics() {
        let mut v = [1, 2, 3];
        let _ = split2(&mut v, 4);
    }
}

为什么安全索引写不出来

rust
fn split_with_safe_index<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
    (&mut slice[..mid], &mut slice[mid..])
    // error[E0499]: cannot borrow `*slice` as mutable more than once at a time
}

slice[..mid]slice[mid..] 都是对 *slice 的可变借用,借用检查器不做区间算术——它不知道 ..midmid.. 不相交,因此拒绝同时存在两个 &mut。标准库能提供 split_at_mut,正是因为它在内部用 unsafe 承担了「我知道这两段不重叠」的证明责任,并把这个证明通过 assert!(mid <= len) 与类型签名对外封住。

要点解析:这道题的关键不是写出 unsafe 块,而是写出那份证明——四条前置条件(非空、对齐、已初始化、单次分配且不重叠)逐条对应「为什么当前上下文满足」。另外注意 assert! 而非 debug_assert!:release 构建里 debug_assert! 会消失,若它承载的是 UB 防线,那 unsafe 块就失去了唯一的守卫。

练习 6:找出 unsafe 代码的 UB 风险

难度:★★★

要求:逐条指出下面代码的 UB 风险(至少 4 条),并给出修正版本。

rust
use std::mem::MaybeUninit;

pub fn first_u32(bytes: &[u8]) -> u32 {
    let p = bytes.as_ptr() as *const u32;
    unsafe { std::ptr::read_unaligned(p) }
}

pub fn sum_bytes(data: &[u8]) -> u8 {
    let mut total: u8 = unsafe { MaybeUninit::uninit().assume_init() };
    for i in 0..=data.len() {
        total = total.wrapping_add(unsafe { *data.get_unchecked(i) });
    }
    total
}

pub unsafe fn as_str(bytes: &[u8]) -> &str {
    std::str::from_utf8_unchecked(bytes)
}

提示:空切片、长度不足、初始化、边界、UTF-8、返回值生命周期,一个都别漏。

参考答案(先自己写再看)参考答案(先自己写再看)

逐条风险

rust
pub fn first_u32(bytes: &[u8]) -> u32 {
    let p = bytes.as_ptr() as *const u32;
    unsafe { std::ptr::read_unaligned(p) }
}
  1. 长度不足就 UBbytes.len() < 4read_unaligned 读取分配之外的字节,是越界读。(read_unaligned 只解决对齐,不解决长度。)
  2. 缺少前置条件文档与检查:函数签名是安全的,却对调用方有隐含要求,违反「安全函数不得有可被违反的隐藏前提」。
rust
pub fn sum_bytes(data: &[u8]) -> u8 {
    let mut total: u8 = unsafe { MaybeUninit::uninit().assume_init() };
    for i in 0..=data.len() {
        total = total.wrapping_add(unsafe { *data.get_unchecked(i) });
    }
    total
}
  1. 未初始化值被使用MaybeUninit::<u8>::uninit().assume_init() 立刻产生一个未初始化的 u8——对整数类型,读取未初始化内存就是 UB(不是「随机值」)。此外这个表达式本身也该被 Clippy 的 uninit_assumed_init 拦下。
  2. 越界访问:范围是 0..=data.len()(含右端点),当 i == data.len()get_unchecked 越界 → UB。应是 0..data.len()
rust
pub unsafe fn as_str(bytes: &[u8]) -> &str {
    std::str::from_utf8_unchecked(bytes)
}
  1. 违反 UTF-8 校验from_utf8_unchecked 要求字节是合法 UTF-8;否则 &str 的「总是合法 UTF-8」不变量被破坏,miri 与后续字符串操作都可能 UB。
  2. 生命周期签名可疑:返回的 &str 被推断为与 bytes 无关的匿名生命周期(实际会被保守推断),调用方可能拿到超过底层数据寿命的引用。应写 pub unsafe fn as_str<'a>(bytes: &'a [u8]) -> &'a str
  3. unsafe fn 内部没有显式 unsafe { }:在 2024 edition 下触发 unsafe_op_in_unsafe_fn 警告(见「2024 edition 的关键变化:unsafe fn 的函数体不再自动是 unsafe 块」)。

修正版本

rust
use std::mem::MaybeUninit;

/// 读取前 4 个字节组成小端 `u32`;不足 4 字节返回 `None`。
pub fn first_u32(bytes: &[u8]) -> Option<u32> {
    // 优先用安全 API:try_into 会做长度检查
    let arr: [u8; 4] = bytes.get(..4)?.try_into().ok()?;
    Some(u32::from_le_bytes(arr))
}

/// 求和,全程安全,且对空切片返回 0。
pub fn sum_bytes(data: &[u8]) -> u8 {
    data.iter().fold(0u8, |acc, b| acc.wrapping_add(*b))
}

/// 把字节转成 `&str`。
///
/// # Safety
///
/// 调用方必须保证 `bytes` 是合法 UTF-8。
pub unsafe fn as_str<'a>(bytes: &'a [u8]) -> &'a str {
    // SAFETY: 由调用方按 # Safety 契约保证 bytes 是合法 UTF-8;
    // 返回引用的生命周期与输入绑定,因此不会超过底层数据寿命。
    unsafe { std::str::from_utf8_unchecked(bytes) }
}

/// 更推荐的形态:把检查留在函数内,外部完全安全。
pub fn as_str_checked(bytes: &[u8]) -> Result<&str, std::str::Utf8Error> {
    std::str::from_utf8(bytes)
}

要点解析:三道题的共同根源都是「把前置条件留给运气」。修正思路一致:能用安全 API 表达就用安全 APIget(..4) + try_into + from_le_bytes 完全覆盖第 1 题),能用迭代器就用迭代器(第 2 题),必须保留 unsafe 时把前置条件写成 # Safety 文档并显式 unsafe { }(第 3 题)。注意 MaybeUninit::uninit().assume_init() 这种写法在旧教程里很常见,现在应当视为错误代码

练习 7:找出不可信基准的问题

难度:★★☆

要求:指出下面基准的四个问题,并写出修正版。

rust
// benches/sum_bench.rs(有问题,不要照抄)
use criterion::Criterion;

fn bench_sum(c: &mut Criterion) {
    c.bench_function("sum", |b| {
        b.iter(|| {
            let data: Vec<u64> = (0..10_000).collect();
            data.iter().sum::<u64>()
        })
    });
}

提示:观察 b.iter 的闭包里都发生了什么;再想想这个基准的「被测对象」到底是什么。

参考答案(先自己写再看)参考答案(先自己写再看)

四个问题:

  1. 测量目标错了Vec 的构造((0..10_000).collect(),含一次堆分配与 10000 次写入)和求和混在一起测。你得到的是「构造 + 求和」的时间,无法回答「求和有多快」。
  2. 结果没有被使用data.iter().sum::<u64>() 的返回值被丢弃,LLVM 有充分理由把整个求和删掉(构造也可能被删),于是你测到的是一个接近空循环的东西——或者一个完全不同的东西,取决于优化器的心情。
  3. 输入是编译期常量0..10_000 是常量范围,求和结果可以被常量折叠(等差数列求和公式)。即使不删代码,测到的也不是真实循环。
  4. 样本与统计问题bench_function 默认参数下 criterion 会自己控制迭代次数,但数据量单一——只测 10000 个元素无法说明复杂度是否随 n 线性。而且没有用 Throughput,无法看出每字节成本。

修正版:

rust
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use std::hint::black_box;

fn build(n: usize) -> Vec<u64> {
    // 运行时构造,且只构造一次(在测量之外)
    (0..n as u64).map(|i| i.wrapping_mul(2654435761)).collect()
}

fn bench_sum(c: &mut Criterion) {
    let mut group = c.benchmark_group("sum_u64");
    for n in [1_000usize, 10_000, 100_000] {
        let data = build(n);                      // setup 在 iter 之外
        group.throughput(Throughput::Elements(n as u64));
        group.bench_with_input(BenchmarkId::from_parameter(n), &data, |b, data| {
            // black_box 包输入(防止把加法串折叠成公式)
            // 并用 black_box 包输出(防止整个循环被删)
            b.iter(|| black_box(data.iter().copied().sum::<u64>()))
        });
    }
    group.finish();
}

criterion_group!(benches, bench_sum);
criterion_main!(benches);

若要对照「朴素实现 vs 迭代器实现」,把两个闭包放进同一个 group 的同一组参数下(BenchmarkId::new("iter", n) / BenchmarkId::new("manual", n)),criterion 会给出可直接比较的曲线。

要点解析:这个基准的四个问题正好对应「基准测试的三个经典陷阱」加一个「覆盖不足」。判断一个基准是否可信的通用问题清单:被测的那一行到底是哪一行?结果被用于什么?输入是常量还是运行时值?准备阶段是否在测量之外?参数覆盖了哪些规模?

练习 8:返回 Result 的表驱动测试

难度:★☆☆

要求:实现 pub fn parse_pair(s: &str) -> Result<(i32, i32), ParseErr>ParseErr 自己定义,含原因字符串)。用一个返回 Result<(), ParseErr> 的测试函数配合表驱动 Vec<(&str, Result<(i32, i32), &str>)> 覆盖 5 组输入,失败信息里带上原始输入。

提示:循环里 ? 不能用于「期望失败」的用例,改用 map_err 归一化后 assert_eq

参考答案(先自己写再看)参考答案(先自己写再看)
rust
#[derive(Debug, PartialEq, Eq)]
pub struct ParseErr(pub String);

impl std::fmt::Display for ParseErr {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "解析失败: {}", self.0)
    }
}

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

pub fn parse_pair(s: &str) -> Result<(i32, i32), ParseErr> {
    let (left, right) = s
        .split_once(',')
        .ok_or_else(|| ParseErr(format!("缺少逗号: {s:?}")))?;
    let a = left.trim().parse::<i32>().map_err(|_| ParseErr(format!("左值不是整数: {left:?}")))?;
    let b = right.trim().parse::<i32>().map_err(|_| ParseErr(format!("右值不是整数: {right:?}")))?;
    Ok((a, b))
}

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

    #[test]
    fn parses_expected_pairs_and_errors() -> Result<(), ParseErr> {
        // (输入, 期望) —— 期望用 &str 表示错误原因的子串,便于比对
        let cases: Vec<(&str, Result<(i32, i32), &str>)> = vec![
            ("1,2", Ok((1, 2))),
            (" 3 , 4 ", Ok((3, 4))),
            ("-5,6", Ok((-5, 6))),
            ("1", Err("缺少逗号")),
            ("a,2", Err("左值不是整数")),
        ];

        for (input, expected) in cases {
            let got = parse_pair(input).map_err(|e| {
                // 归一化:只比较原因子串,避免测试被措辞变化打碎
                if e.0.contains(expected.clone().err().unwrap_or("")) {
                    String::new()
                } else {
                    e.0.clone()
                }
            });
            match expected {
                Ok(pair) => assert_eq!(got, Ok(pair), "输入 {input:?} 的解析结果不符"),
                Err(reason) => {
                    let err = parse_pair(input).unwrap_err();
                    assert!(
                        err.0.contains(reason),
                        "输入 {input:?} 期望错误包含 {reason:?},实际是 {:?}",
                        err.0
                    );
                }
            }
        }
        Ok(())
    }
}

更简洁的等价写法(推荐,避免上面那段绕来绕去的归一化):

rust
#[test]
fn parses_pairs_table_driven() -> Result<(), ParseErr> {
    let ok_cases: [(&str, (i32, i32)); 3] = [("1,2", (1, 2)), (" 3 , 4 ", (3, 4)), ("-5,6", (-5, 6))];
    for (input, expected) in ok_cases {
        assert_eq!(parse_pair(input)?, expected, "输入 {input:?}");
    }

    let err_cases: [(&str, &str); 2] = [("1", "缺少逗号"), ("a,2", "左值不是整数")];
    for (input, reason) in err_cases {
        let err = parse_pair(input).unwrap_err();
        assert!(err.0.contains(reason), "输入 {input:?} 实际错误: {}", err.0);
    }
    Ok(())
}

要点解析? 只能用于「期望成功」的路径;「期望失败」的用例应该显式 unwrap_err() 再检查原因子串。把成功与失败分成两个表,代码短了一半——这也是表驱动测试的一个实用经验:不要试图用一张表同时覆盖两个方向

练习 9:doctest 的 ?、隐藏行与编译标注

难度:★★☆

要求:为一个「读文件并解析为整数」的 pub fn read_number(path: &str) -> Result<i32, std::io::Error> 写文档,至少包含四个代码块:普通可运行的(用 ?)、带 # 隐藏行的、no_run 的、compile_fail 的,并说明每个标注的作用。

提示? 需要 fn main() -> Result<...>,把签名与结尾用 # 隐藏起来。

参考答案(先自己写再看)参考答案(先自己写再看)
rust
use std::fs;
use std::io;
use std::num::ParseIntError;

/// 读取文件并把它解析为一个 `i32`。
///
/// # Examples
///
/// 正常路径:用 `?` 传播错误(doctest 默认把代码包进 `fn main() {}`,
/// 所以这里自己提供返回 `Result` 的 `main`)。
///
/// ```
/// use doc_demo::read_number;
///
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// std::fs::write("num.txt", "42")?;
/// assert_eq!(read_number("num.txt")?, 42);
/// # std::fs::remove_file("num.txt")?;
/// # Ok(())
/// # }
/// ```
///
/// 需要真正执行 IO 的示例用 `no_run`:只保证能编译,不在文档测试里跑。
///
/// ```no_run
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let n = doc_demo::read_number("/etc/hostname")?;
/// println!("{n}");
/// # Ok(())
/// # }
/// ```
///
/// `compile_fail` 钉住「错误用法必须编译不过」。
///
/// ```compile_fail
/// use doc_demo::read_number;
/// // read_number 返回 Result<i32, io::Error>,直接当 i32 用必须编译失败
/// let n: i32 = read_number("num.txt");
/// ```
///
/// `ignore` 会让 rustdoc 完全跳过这段代码——它永远不会被发现已经过期,
/// 因此只应作为最后手段。
///
/// ```ignore
/// 这里是伪代码:读一个远端文件(示例里不展示真实网络调用)
/// ```
pub fn read_number(path: &str) -> Result<i32, io::Error> {
    let text = fs::read_to_string(path)?;
    text.trim()
        .parse::<i32>()
        .map_err(|e: ParseIntError| io::Error::new(io::ErrorKind::InvalidData, e))
}

各标注的作用:

标注编译运行用途
(无)默认行为,最适合「读者可以照抄」的示例
no_run需要真实 IO/网络/耗时操作,但语法必须正确
should_panic是(必须 panic)展示「这个用法会 panic」
compile_fail必须失败钉住类型错误、借用错误等「不该编译通过」的用法
ignore伪代码专用,慎用:内容腐坏无人发现
# 前缀行隐藏准备代码(letusemain 签名)但保持可运行

要点解析# 隐藏行是让 doctest「既完整又不啰嗦」的关键——读者看到的是干净的用法,编译器看到的是完整的程序。注意 # 后的内容仍会被编译进示例,因此不能写不存在的路径(除非在那个示例里先创建)。compile_fail 是 Rust 独有的能力:它把「类型系统必须拒绝这种写法」变成可回归的测试。

练习 10:#[cfg(test)] 与可见性的编译判断

难度:★☆☆

要求:判断下面 3 个测试哪些能编译、哪些不能,并说明原因。

rust
// src/lib.rs
fn helper(n: u8) -> u8 { n + 1 }
pub fn entry(n: u8) -> u8 { helper(n) }

mod inner {
    fn secret() -> u8 { 7 }
}

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

    #[test]
    fn a() { assert_eq!(helper(1), 2); }          // 

    #[test]
    fn b() { assert_eq!(inner::secret(), 7); }    // 

    #[test]
    fn c() { assert_eq!(crate::entry(1), 2); }    // 
}

提示mod inner 里的 fn secret 对谁可见?

参考答案(先自己写再看)参考答案(先自己写再看)

编译通过。 #[cfg(test)] mod tests 是 crate 根模块的子模块,use super::*; 把根模块的私有项 helper 引入了作用域——私有可见性规则允许子模块访问祖先模块的私有项。

编译失败。 mod inner 里的 fn secretinner 的私有项,tests 只是 inner兄弟模块(都是根模块的子模块),兄弟之间没有私有访问权。

text
error[E0603]: function `secret` is private
  --> src/lib.rs:14:24
   |
14 |     fn b() { assert_eq!(inner::secret(), 7); }
   |                          ^^^^^^ private function
note: the function `secret` is defined here

修法一:把 secret 提升到 crate 可见(对外仍是私有的,不影响公开 API)。

rust
mod inner {
    pub(crate) fn secret() -> u8 { 7 }   // crate 内任何模块都能调用
}

#[cfg(test)]
mod tests {
    #[test]
    fn secret_is_testable() {
        assert_eq!(super::inner::secret(), 7);
    }
}

修法二:把测试下沉到 inner 内部,此时 secret 保持完全私有。

rust
mod inner {
    fn secret() -> u8 { 7 }

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

        #[test]
        fn secret_is_testable() {
            assert_eq!(secret(), 7);
        }
    }
}

编译通过。 crate::entrypub 的,从任何模块都能访问;crate:: 前缀从 crate 根开始解析,比相对路径更明确。

要点解析:记住两条规则——「子模块能看祖先的私有项」(所以根模块的测试能测根本身的私有函数),「兄弟模块互相看不到私有项」(所以要 pub(crate) 或把测试下沉)。tests/ 目录下的集成测试更严格:它是另一个 crate,只能看到 pub 项。

练习 12:为小模块建立完整质量网

难度:★★☆

要求:实现 pub fn run_length_encode(input: &str) -> String(把 "aaabbc" 变成 "a3b2c1")。然后补齐:单元测试(含边界:空串、单字符、全同)、集成测试、doctest、一个 #[should_panic]Result 版本、一个 criterion 基准(对比你的实现与一个「朴素 O(n²) 实现」),最后写出你会放进 CI 的三条命令。

提示run_length_encodechars()peekable() 最自然;基准里两个实现放同一个 group 才能直接比较。

参考答案(先自己写再看)参考答案(先自己写再看)
rust
//! 游程编码(run-length encoding)小模块。

/// 把连续相同字符压缩成「字符 + 次数」,例如 `"aaabbc"` → `"a3b2c1"`。
///
/// # Examples
///
/// ```
/// use rle::run_length_encode;
///
/// assert_eq!(run_length_encode("aaabbc"), "a3b2c1");
/// # let empty: &str = "";
/// assert_eq!(run_length_encode(empty), "");
/// ```
pub fn run_length_encode(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    let mut chars = input.chars().peekable();
    while let Some(c) = chars.next() {
        let mut count = 1usize;
        while chars.peek() == Some(&c) {
            chars.next();
            count += 1;
        }
        out.push(c);
        out.push_str(&count.to_string());   // push_str 避免 `+` 链的反复分配
    }
    out
}

/// 朴素 O(n²) 版本:仅用于基准对照,不要在生产里用。
pub fn run_length_encode_naive(input: &str) -> String {
    let chars: Vec<char> = input.chars().collect();
    let mut out = String::new();
    let mut i = 0;
    while i < chars.len() {
        let c = chars[i];
        let mut count = 0;
        // 每次都从 i 往后扫,且用 out = out + ... 反复重建字符串
        while i < chars.len() && chars[i] == c {
            count += 1;
            i += 1;
        }
        out = out + &format!("{c}{count}");
    }
    out
}

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

    #[test]
    fn encodes_basic_input() {
        assert_eq!(run_length_encode("aaabbc"), "a3b2c1");
    }

    #[test]
    fn handles_boundaries() {
        assert_eq!(run_length_encode(""), "");
        assert_eq!(run_length_encode("x"), "x1");
        assert_eq!(run_length_encode("aaaa"), "a4");
    }

    #[test]
    fn counts_multi_byte_chars_by_char_not_byte() {
        // 用 chars() 而不是 bytes(),因此中文/emoji 计一个字符
        assert_eq!(run_length_encode("好好好"), "好3");
    }

    #[test]
    fn naive_matches_fast_version() {
        for case in ["", "x", "aaabbc", "好好好", "abcabc", "zzzzzzzzzz"] {
            assert_eq!(run_length_encode_naive(case), run_length_encode(case), "输入 {case:?}");
        }
    }

    #[test]
    #[should_panic(expected = "输入为空")]
    fn strict_version_rejects_empty_input() {
        strict_encode("");
    }
}

/// 严格版本:空输入视为调用方错误,直接 panic。
pub fn strict_encode(input: &str) -> String {
    assert!(!input.is_empty(), "输入为空");
    run_length_encode(input)
}
rust
// tests/rle.rs
use rle::{run_length_encode, run_length_encode_naive};

#[test]
fn public_api_is_consistent_between_implementations() {
    let text = "aaabbbbbccccccccdd";
    assert_eq!(run_length_encode(text), run_length_encode_naive(text));
}

#[test]
fn output_length_is_linear_in_number_of_runs() {
    // 契约:输出长度 = 2 * 不同字符段数(每段一个字符 + 一个十进制数字)
    let text = "aabbaa";
    assert_eq!(run_length_encode(text).len(), 6);
}
rust
// benches/rle_bench.rs
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use rle::{run_length_encode, run_length_encode_naive};
use std::hint::black_box;

fn input(runs: usize) -> String {
    // 构造 `runs` 个「两个相同字符」的段,避免常量折叠
    (0..runs).map(|i| format!("{}{}", (b'a' + (i % 26) as u8) as char, (b'a' + (i % 26) as u8) as char)).collect()
}

fn bench_rle(c: &mut Criterion) {
    let mut group = c.benchmark_group("run_length_encode");
    for runs in [64usize, 1024] {
        let text = input(runs);
        group.throughput(Throughput::Bytes(text.len() as u64));
        group.bench_with_input(BenchmarkId::new("fast", runs), &text, |b, t| {
            b.iter(|| run_length_encode(black_box(t)))
        });
        group.bench_with_input(BenchmarkId::new("naive", runs), &text, |b, t| {
            b.iter(|| run_length_encode_naive(black_box(t)))
        });
    }
    group.finish();
}

criterion_group!(benches, bench_rle);
criterion_main!(benches);

CI 里我会放的三条命令(在 cargo fmt/clippy 之后):

powershell
cargo test --all-targets                 # 单元 + 集成 + 示例,快速反馈
cargo test --doc                         # doctest 单独跑(nextest 不覆盖它)
cargo llvm-cov --fail-under-lines 85     # 覆盖率门槛,防止新增代码没有测试

如果要把基准也纳入门禁,加一条只在主分支或夜间任务运行的命令(基准需要稳定的机器,不适合每个 PR 都跑):

powershell
cargo bench --bench rle_bench -- --save-baseline main   # 保存基线,之后再与改动比较

要点解析:这道题的关键是把「质量网」当成一个整体来设计——单元测试覆盖边界与实现细节(多字节字符、空串),集成测试验证实现之间的一致性(naivefast 结果相同),doctest 保证文档里的用法可用,基准回答「快版本到底快多少」。注意 run_length_encode_naive 保留在 pub API 里只为对照基准服务;真实项目里它更适合放在 #[cfg(bench)] 或单独的 tests/ 辅助模块中,避免污染公开接口。

本章小结 / 自测清单

内容以 rustc 1.98.1 · Rust 2024 edition 为基准