练习与自测
本章练习共 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 = falserust
// 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 侧没有空位,但枚举只要存在一个可用空位即可),大小不会增长。设计热点数据结构时,用 NonZeroXxx、Box 或引用包裹,常常同时换来「类型更安全」和「内存更小」——把不变量编码进类型是有性能红利的。
练习 5:手写 unsafe 的 split_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 的可变借用,借用检查器不做区间算术——它不知道 ..mid 与 mid.. 不相交,因此拒绝同时存在两个 &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) }
}- 长度不足就 UB:
bytes.len() < 4时read_unaligned读取分配之外的字节,是越界读。(read_unaligned只解决对齐,不解决长度。) - 缺少前置条件文档与检查:函数签名是安全的,却对调用方有隐含要求,违反「安全函数不得有可被违反的隐藏前提」。
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
}- 未初始化值被使用:
MaybeUninit::<u8>::uninit().assume_init()立刻产生一个未初始化的u8——对整数类型,读取未初始化内存就是 UB(不是「随机值」)。此外这个表达式本身也该被 Clippy 的uninit_assumed_init拦下。 - 越界访问:范围是
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)
}- 违反 UTF-8 校验:
from_utf8_unchecked要求字节是合法 UTF-8;否则&str的「总是合法 UTF-8」不变量被破坏,miri与后续字符串操作都可能 UB。 - 生命周期签名可疑:返回的
&str被推断为与bytes无关的匿名生命周期(实际会被保守推断),调用方可能拿到超过底层数据寿命的引用。应写pub unsafe fn as_str<'a>(bytes: &'a [u8]) -> &'a str。 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 表达就用安全 API(get(..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 的闭包里都发生了什么;再想想这个基准的「被测对象」到底是什么。
参考答案(先自己写再看)
参考答案(先自己写再看)
四个问题:
- 测量目标错了:
Vec的构造((0..10_000).collect(),含一次堆分配与 10000 次写入)和求和混在一起测。你得到的是「构造 + 求和」的时间,无法回答「求和有多快」。 - 结果没有被使用:
data.iter().sum::<u64>()的返回值被丢弃,LLVM 有充分理由把整个求和删掉(构造也可能被删),于是你测到的是一个接近空循环的东西——或者一个完全不同的东西,取决于优化器的心情。 - 输入是编译期常量:
0..10_000是常量范围,求和结果可以被常量折叠(等差数列求和公式)。即使不删代码,测到的也不是真实循环。 - 样本与统计问题:
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 | 否 | 否 | 伪代码专用,慎用:内容腐坏无人发现 |
# 前缀行 | 是 | 是 | 隐藏准备代码(let、use、main 签名)但保持可运行 |
要点解析:# 隐藏行是让 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 secret 是 inner 的私有项,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::entry 是 pub 的,从任何模块都能访问;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_encode 用 chars() 加 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 # 保存基线,之后再与改动比较要点解析:这道题的关键是把「质量网」当成一个整体来设计——单元测试覆盖边界与实现细节(多字节字符、空串),集成测试验证实现之间的一致性(naive 与 fast 结果相同),doctest 保证文档里的用法可用,基准回答「快版本到底快多少」。注意 run_length_encode_naive 保留在 pub API 里只为对照基准服务;真实项目里它更适合放在 #[cfg(bench)] 或单独的 tests/ 辅助模块中,避免污染公开接口。