unsafe 深入
unsafe 的边界、义务与证明,写 unsafe 前必须知道什么。
unsafe 深入:边界、义务与证明
五大超能力
unsafe 不是「关掉借用检查」,也不是「进入另一种语言」。它精确地解锁了五件事,仅此而已:
| # | 超能力 | 典型场景 |
|---|---|---|
| 1 | 解引用裸指针(*const T / *mut T) | 手写数据结构、跨 FFI 传递缓冲 |
| 2 | 调用 unsafe fn 或 unsafe 方法 | get_unchecked、str::from_utf8_unchecked、FFI |
| 3 | 访问或修改 static mut | 与 C 共享全局状态(强烈建议改用 AtomicXxx/OnceLock) |
| 4 | 实现 unsafe trait(如 Send/Sync) | 手写并发容器、包装外部句柄 |
| 5 | 访问 union 字段 | FFI 的 C union、位重解释 |
注意清单上没有的东西:unsafe 不能绕过借用规则(借用检查器在 unsafe 块内照样生效)、不能忽略初始化检查、不能改变类型系统。它只是把「编译器替你证明的这五类义务」转交给你。
rust
fn demo() {
let x = 42u32;
let raw: *const u32 = &x; // 创建裸指针是安全的
// SAFETY: raw 来自 &x,x 在本次调用期间一直存活,且对齐正确。
let value = unsafe { *raw }; // 解引用需要 unsafe
assert_eq!(value, 42);
let slice = [1u8, 2, 3];
// SAFETY: 索引 1 < slice.len() = 3,越界条件已由常量保证。
let second = unsafe { *slice.get_unchecked(1) }; // 调用 unsafe 方法
assert_eq!(second, 2);
}2024 edition 的关键变化:unsafe fn 的函数体不再自动是 unsafe 块
这是本章最需要写准确的一条。历史上(2015/2018/2021 edition),unsafe fn 的函数体整体被视为一个 unsafe 块:在 unsafe fn 里解引用裸指针不需要再写 unsafe { }。
Rust 2024 edition 改变了这一点:lint unsafe_op_in_unsafe_fn 在 edition 2024 下默认警告(warn),要求你在 unsafe fn 内部把真正危险的操作显式包进 unsafe { } 块。理由很简单:一个 unsafe fn 可能有 50 行,其中只有 2 行是真正做危险操作的;旧规则让「哪里危险」完全不可见,也让「在函数里新增危险操作」不需要任何标记。
rust
// 2024 edition:下面这样写会得到 warning: unsafe operation outside of an unsafe block
unsafe fn read_at(p: *const u8) -> u8 {
*p // ← E0133 警告点
}
// 正确写法:显式 unsafe 块 + SAFETY 注释,危险范围一眼可见
unsafe fn read_at_ok(p: *const u8) -> u8 {
// SAFETY: 由调用方保证 p 非空、指向一个已初始化的 u8、且在该次调用期间有效。
unsafe { *p }
}rustc 1.98.1 --edition 2024 对第一段代码的实际诊断:
text
warning[E0133]: dereference of raw pointer is unsafe and requires unsafe block
--> probe.rs:13:5
|
13 | *p
| ^^ dereference of raw pointer
|
= note: raw pointers may be null, dangling or unaligned; they can violate aliasing
rules and cause data races: all of these are undefined behavior
note: an unsafe function restricts its caller, but its body is safe by default
= note: `#[warn(unsafe_op_in_unsafe_fn)]` (part of `#[warn(rust_2024_compatibility)]`)
on by default要点:
- 这是 warn 而不是 error(
rust_2024_compatibility组的一部分),可以显式#[allow(unsafe_op_in_unsafe_fn)],但新代码不应该关掉它。 - 在 edition 2015/2018/2021 的 crate 里,该 lint 默认是 allow;用
cargo fix --edition迁移到 2024 时,工具会自动帮你插入unsafe { }块。 - 显式块的好处不只是可读性:它让「函数整体是 unsafe 的」与「函数里哪几行在做危险操作」两件事分开表达,也让
// SAFETY:注释可以贴在正确的粒度上。
// SAFETY: 注释规范
Rust 社区的事实标准是:每一个 unsafe 块、每一个 unsafe impl、每一个 unsafe fn 的调用点,都要有一条以 SAFETY: 开头的注释,说明为什么当前上下文满足该操作的前置条件。 Clippy 的 undocumented_unsafe_blocks(在 clippy::pedantic 组中)会检查 unsafe 块是否有注释,CI 里可以打开:
powershell
cargo clippy --all-targets -- -W clippy::undocumented_unsafe_blocks注释该写什么、不该写什么(下面三个片段都能编译,差别只在注释质量):
rust
fn demo(len: usize) {
let mut storage = [0u8; 8];
let mut ptr = storage.as_mut_ptr();
let capacity = storage.len();
// 差:等于没说——审查者无法判断你是否真的想过前置条件
// SAFETY: 这里是安全的
unsafe { *ptr = 1 }
// 差:只说做了什么,没说为什么成立
// SAFETY: 解引用指针
unsafe { *ptr = 2 }
// 好:明确列出前置条件,并说明当前上下文如何满足它们
// SAFETY: 由函数开头的 `assert!(len <= capacity)` 保证 len 不超过分配长度;
// ptr 来自本函数的 `[u8; 8]` 局部数组,因此非空、按 u8 对齐、已初始化,
// 且在这条语句执行时没有任何其他引用指向 storage(互斥借用)。
unsafe { std::slice::from_raw_parts_mut(ptr, len) };
}好的 SAFETY 注释回答三个问题:这个操作要求什么前置条件?当前上下文怎么满足的?如果不满足会怎样? 对 unsafe fn,注释应写明调用方必须保证什么(因为那是文档契约的一部分):
rust
/// 从 `ptr` 读取 `len` 个 `u8` 并返回切片。
///
/// # Safety
///
/// 调用方必须保证:
/// - `ptr` 非空且按 `u8` 的要求对齐;
/// - `ptr` 指向的 `len` 个字节全部已初始化,且在返回的引用存活期内不被释放或改写;
/// - 该内存区域在本次调用期间不被其他线程以可变方式访问(即满足别名规则)。
pub unsafe fn bytes_at<'a>(ptr: *const u8, len: usize) -> &'a [u8] {
// SAFETY: 由调用方按上述 # Safety 契约保证所有前置条件。
unsafe { std::slice::from_raw_parts(ptr, len) }
}⚠️ 注意:
# Safety小节是unsafe fn的必需文档(crate 开启#![warn(clippy::missing_safety_doc)]时会检查)。它的位置在 doc comment 里,而// SAFETY:注释在函数体内——两者配合使用:前者面向调用方,后者面向审查者。
UB 的后果:为什么「幽灵行为」比崩溃更可怕
未定义行为(undefined behavior,UB)不是「实现相关的行为」,而是编译器可以假设它永不发生。一旦发生,编译器的推理前提被破坏,它有权生成任何代码——包括让你的 if 分支消失、让你的循环永不终止、让你在没写过的分支上崩溃。
经典例子:越界 + 容量误报导致优化器「证明」矛盾。
rust
// 危险示例(不要抄):给优化器提供了不可能成立的前提
fn bad(n: usize) -> u32 {
let mut v: Vec<u32> = Vec::with_capacity(n);
// SAFETY: 这个注释是假的!我们谎称 v 有 n 个已初始化的元素。
unsafe { v.set_len(n) };
let mut sum = 0;
for i in 0..n {
sum += v[i]; // 读到未初始化内存 → UB
}
sum
}后果可能是:读到垃圾值、崩溃、或者看似正常工作然后在换一个 rustc 版本/换一个优化等级后炸掉。UB 最恶劣的地方是它可能通过测试。这就是为什么必须用 miri 而不是「跑一遍看看」。
同样要避免的两个历史 API:
std::mem::uninitialized::<T>():已废弃(deprecated),因为连let x: u8 = mem::uninitialized()都是立即 UB(生产未初始化的整数本身就是 UB)。替代品是MaybeUninit<T>。std::mem::zeroed::<T>():对「所有位模式都合法」的类型没问题,但对&T、Box<T>、NonZeroU8、含bool/enum的结构体是 UB。只有在T: Copy且确实允许全零时才用;不确定就用MaybeUninit逐个字段write。
rust
use std::mem::MaybeUninit;
/// 构造一个 `[u32; 4]`,全程不产生未初始化值的「使用」。
fn build_array() -> [u32; 4] {
// 整个数组一起当未初始化存储看待,避免 `[MaybeUninit<u32>; 4]` 的逐元素形态
let mut buf = MaybeUninit::<[u32; 4]>::uninit();
let ptr = buf.as_mut_ptr() as *mut u32; // 拿到指向整块存储的裸指针
for i in 0..4usize {
// SAFETY: ptr 指向一块可写的 [u32; 4] 存储,i < 4 保证偏移在界内;
// write 是覆盖写、不读取旧值,因此不需要先初始化。
unsafe { ptr.add(i).write(i as u32 * 10) };
}
// SAFETY: 上面的循环已写满下标 0..4 的全部元素,整块数组完全初始化,
// 满足 assume_init 的前置条件。
unsafe { buf.assume_init() }
}> 输出: [0, 10, 20, 30](size_of::<MaybeUninit<[u32; 4]>>() == size_of::<[u32; 4]>() == 16)
⚠️ 注意:不要抄那些调用
MaybeUninit::array_assume_init的示例——rustc 1.98.1实测它仍是 unstable(error[E0658]: use of unstable library feature 'maybe_uninit_array_assume_init',issue #96097)。用上面的「整块MaybeUninit<[T; N]>+ 裸指针write+assume_init」写法即可,它完全基于稳定 API。
用 miri 抓 UB
miri 是 Rust 官方的 MIR 解释器,能在运行期检查出绝大多数 UB:越界、悬垂引用、未对齐访问、数据竞争、无效的 bool/enum 判别式、违反 Stacked Borrows/Tree Borrows 别名规则等。
powershell
rustup toolchain install nightly
rustup +nightly component add miri
cargo +nightly miri setup # 首次运行会构建 miri 的 sysroot
cargo +nightly miri test # 用 miri 跑所有测试(含 doctest 之外的单元/集成测试)
cargo +nightly miri test -- --test-threads=1 # 更慢但更易读⚠️ 注意:
miri需要 nightly 工具链,运行速度比原生慢 100~1000 倍,并且不支持真正的 FFI(调用 C 函数会被拒绝或需要-Zmiri-disable-isolation之类的开关)。所以实践是:把unsafe相关的小测试用miri跑,把完整测试套件留在 stable 上跑。 CI 里给 miri 单独开一个 job,只跑标记出来的子集:
powershell
cargo +nightly miri test --lib unsafe_ # 按名字过滤,只跑与 unsafe 有关的测试🧠 原理:
miri不是符号执行,而是逐条解释 MIR,同时维护一套「指针来源与借用栈」模型。它能发现「同一块内存同时存在&mut和&」这类时间维度上的错误——这是任何静态检查器和 ASan 都抓不到的类型。
miri 报了 UB 但你不想懂它的原因,是最危险的组合:看到红字就去 unsafe 外面包 #[allow]、或者在 miri 命令里加 -Zmiri-disable-...,等于放弃了唯一的证据来源。正确做法是:把报告里的第一行(比如 error: Undefined Behavior: trying to retag from <...> for Unique permission)当作线索,回到代码里找出「谁在什么时候还能访问这块内存」,修掉真正的别名冲突。
unsafe impl Send / unsafe impl Sync 的论证要求
Send/Sync 是 auto trait:编译器会为所有字段都是 Send 的类型自动实现 Send。当你 unsafe impl Send for MyType,你是在推翻编译器的自动推理,必须给出论证。
记忆锚点(必须一字不差地准确):
Send:把值的所有权移到另一个线程是安全的。Sync:把值的共享引用(&T)传到另一个线程是安全的(等价于&T: Send)。
rust
use std::cell::UnsafeCell;
/// 一个只允许单线程写入、但写入是原子的计数器包装。
/// 注意:这里刻意选择用 AtomicUsize 而不是让用户自己保证,减少论证面。
pub struct Counter {
value: std::sync::atomic::AtomicUsize,
}
// 不需要任何 unsafe impl:AtomicUsize 本身是 Send + Sync,
// 于是 Counter 被自动推导为 Send + Sync。这是首选方案。
/// 反面教材:如果我们用 UnsafeCell<usize>,就必须自己论证——
/// 但因为没有任何同步,它既不 Send 也不 Sync,无法安全地 unsafe impl。
pub struct UnsafeCounter {
value: UnsafeCell<usize>,
}
// 绝不能写 `unsafe impl Sync for UnsafeCounter {}`:
// 两个线程用 &UnsafeCounter 并发写 value 就是数据竞争。论证该覆盖的内容(写进代码注释,放在 unsafe impl 上方):
rust
use std::ffi::c_void;
/// 围绕 C 库句柄的薄包装。
pub struct MyHandle {
raw: *mut c_void,
}
// SAFETY: MyHandle 是围绕 *mut c_void 的薄包装,指向的 C 结构体由底层库保证
// 在进程生命周期内有效且从不被释放。底层 C API 文档明确说明该句柄可在多线程
// 间并发调用(内部自带互斥),因此
// - 把所有权移到别的线程(Send)不违反任何别名或生命周期规则;
// - 通过 &MyHandle 在其他线程调用也是安全的(Sync),因为所有可变状态
// 都被 C 库内部的锁保护,Rust 侧不存在未同步的可变访问。
// 若上述任一前提被破坏(例如底层库改成非线程安全),必须撤销这两个 impl。
unsafe impl Send for MyHandle {}
unsafe impl Sync for MyHandle {}⚠️ 注意:
unsafe impl Send/Sync的正确性不能靠测试证明——数据竞争可能一万次运行才复现一次。miri能抓单线程化的竞态模型(在-Zmiri-preemption-rate下),但它不是并发测试的替代品。优先选择不需要unsafe impl的设计:用AtomicXxx、Mutex、RwLock、OnceLock,让编译器自动推导。
什么时候用 unsafe,什么时候不用
该用的情况(只有三类,且都需要论证):
- FFI:调用 C/C++ 库、暴露 C ABI 给外部。这是
unsafe存在的首要理由。 - 性能关键的、已被基准验证的热点:例如在
criterion+flamegraph之后确认某处边界检查或 UTF-8 校验是瓶颈,并且安全的替代方案(chunks_exact、split_at、bytes()迭代器)都无法达到目标。 - 实现安全抽象:
Vec、RefCell、Rc、Arc、Mutex内部都是unsafe,但它们对外提供 100% 安全的 API。这是unsafe最高尚的用法:把危险困在 crate 内部,让整个生态受益。
不该用的情况:
- 99% 的场景不需要
unsafe。在写unsafe之前,先问:能不能用Cell/RefCell/Rc/Arc/Mutex/OnceLock/split_at_mut/get/try_into表达?能用就不用。 - 不要为了「避免一次边界检查」或「少一次 clone」引入
unsafe。收益通常是 1~10%,风险是 UB。 transmute是最后手段。它绕过全部类型检查,长度不匹配、对齐不匹配、生命周期不匹配都会静默 UB。绝大多数「我需要 transmute」的需求有更好的替代:
| 想做的事 | 不要用 | 应该用 |
|---|---|---|
u32 ↔ [u8; 4] | transmute | u32::to_le_bytes / from_le_bytes |
&[u8] → &[u32] | transmute + 手动长度 | bytemuck(try_cast_slice,会检查对齐与长度) |
| 整数指针 ↔ 指针 | transmute | ptr::with_exposed_provenance / as 转换 |
enum ↔ 整数 | transmute | 显式 match 或 TryFrom |
| 延长引用生命周期 | transmute | 重新设计所有权,或 Rc/Arc/arena |
MaybeUninit<T> → T | transmute | assume_init(并论证已初始化) |
- 不要用
static mut:Rust 2024 起对static mut的引用会触发static_mut_refslint(deny 级别)。需要全局可变状态时,用AtomicXxx、OnceLock、Mutex,或者把状态放进参数里传递。
完整示例:用 unsafe 实现安全 API(手写 split_at_mut)
std::slice::split_at_mut 是教科书级的案例:一个不可能用安全 Rust 表达的操作,被 unsafe 包装成完全安全的 API。
为什么安全版本写不出来?如果尝试用安全的切片索引返回两个 &mut:
rust
// 这个版本无法通过借用检查:两个可变借用同时存在
fn split_bad<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
}借用检查器无法知道 ..mid 与 mid.. 不重叠,因此拒绝。手写 unsafe 版本:
rust
use std::slice;
/// 把可变切片在 `mid` 处分成两半。
///
/// # Panics
///
/// 当 `mid > slice.len()` 时 panic。
pub fn split_at_mut_checked<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
let len = slice.len();
assert!(mid <= len, "mid ({mid}) 超过切片长度 ({len})");
// as_mut_ptr 返回的裸指针是安全的;真正的危险操作在下面的 unsafe 块里
let ptr = slice.as_mut_ptr();
// SAFETY: 四个要求逐一核对——
// 1. 非空:slice 的 len > 0 时 ptr 来自有效切片,非空;len == 0 时 mid 必为 0,
// 两个 from_raw_parts_mut 的 len 都是 0,此时允许使用 dangling 但对齐的指针。
// 2. 对齐:ptr 来自 &mut [T],一定按 T 对齐。
// 3. 已初始化:整个 slice 的 len 个元素都由调用方保证已初始化(&mut [T] 的前提)。
// 4. 单次分配:两段的长度之和恰为 len,起点分别为 ptr 与 ptr.add(mid),因此
// 它们落在同一块分配内且不重叠(mid <= len)——满足 &mut 的别名规则。
unsafe {
let left = slice::from_raw_parts_mut(ptr, mid);
let right = slice::from_raw_parts_mut(ptr.add(mid), len - mid);
(left, right)
}
}不变量清单(写进测试,也写进代码审查清单):
- 前置条件 1:
mid <= slice.len()。违反时assert!立即 panic,绝不进入unsafe块——把检查放在unsafe块之外,是让「错误输入不可能触发 UB」的关键。 - 前置条件 2:
slice是有效的&mut [T]。这由类型系统保证,不需要额外检查。 - 不变量 1:两段长度之和 == 原长度,不丢元素、不重复。
- 不变量 2:两段不重叠,所以可以同时可变使用。
- 不变量 3:函数返回后,原始
slice的生命周期被两段借用「继承」,只要两段还在用,原切片就不能被访问(这是生命周期省略规则自动完成的)。
rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_and_allows_independent_mutation() {
let mut v = [1, 2, 3, 4, 5];
let (left, right) = split_at_mut_checked(&mut v, 2);
left[0] = 100;
right[0] = 500;
assert_eq!(v, [100, 2, 500, 4, 5]); // 两段互不干扰
}
#[test]
fn empty_halves_are_allowed() {
let mut v = [1, 2, 3];
let (left, right) = split_at_mut_checked(&mut v, 0);
assert!(left.is_empty());
assert_eq!(right, [1, 2, 3]);
}
#[test]
#[should_panic(expected = "超过切片长度")]
fn out_of_range_panics_instead_of_ub() {
let mut v = [1, 2, 3];
let _ = split_at_mut_checked(&mut v, 4);
}
}> 输出(cargo test):
text
running 3 tests
test tests::empty_halves_are_allowed ... ok
test tests::out_of_range_panics_instead_of_ub ... ok
test tests::splits_and_allows_independent_mutation ... ok🚀 进阶:标准库的
split_at与split_at_mut在rustc 1.98.1上已经可用于 const 上下文(实测:const P: (&[u8], &[u8]) = b"abcdef".split_at(3);与const N: usize = { let mut a = [1u8, 2, 3, 4]; let (x, y) = a.split_at_mut(2); x.len() + y.len() };都能编译)。自己写unsafe版本时,别忘了先对照标准库 API:先确认标准库没有你要的功能,再考虑手写。
⚠️ 注意:
cargo +nightly miri test是这个函数必须过的关卡。miri会检查from_raw_parts_mut返回的两个&mut是否真的不重叠——如果assert!被误改成debug_assert!并在 release 下失效,miri用 debug 构建跑时会拦住越界,但release 构建里的 UB 不会被任何测试发现。这也是为什么前置条件检查要用assert!而不是debug_assert!,除非你能用类型证明它不可能越界。
与其他语言的对照
| 主题 | Python | Java | C / C++ | Go | Rust 的做法与理由 |
|---|---|---|---|---|---|
| 单元测试入口 | pytest 按文件名/函数名发现 | JUnit @Test + 反射 | 无标准方案(GoogleTest 等) | 同包 _test.go + go test | #[cfg(test)] mod tests + #[test]:测试与代码同文件,编译期开关,能直接测私有函数 |
| 断言 | 裸 assert(-O 下会被去掉) | assertEquals + AssertJ | assert() 宏(NDEBUG 下消失) | 手写 if ... t.Fatalf | assert!/assert_eq!/assert_ne!/assert_matches!,失败信息含源码位置与两侧 Debug 值 |
| 参数化/表驱动 | @pytest.mark.parametrize | JUnit 5 @ParameterizedTest | 手写循环 | 表驱动 struct 切片 | 手写 Vec<(input, expected)> 循环,或 rstest 的 #[case] |
| 属性测试 | Hypothesis | jqwik | 移植自 QuickCheck 的库 | testing/quick | proptest(带收缩)或 quickcheck |
| mock | unittest.mock | Mockito | GoogleMock | 手工接口 + 假实现 | mockall 的 #[automock] 从 trait 生成 mock |
| 文档测试 | doctest 模块 | 无(Javadoc 代码不执行) | 无 | Example 函数(go test 会跑) | /// 里的代码块默认可执行,no_run/ignore/should_panic/compile_fail 精确控制 |
| 基准测试 | timeit / pytest-benchmark | JMH(需注解处理器与独立模块) | Google Benchmark | testing.B + go test -bench | criterion:置信区间、离群点检测、与上次结果对比;内置 #[bench] 需 nightly |
| 性能剖析 | cProfile / py-spy | JFR / async-profiler | perf / VTune | pprof | perf + cargo-flamegraph;CLI 端到端用 hyperfine |
| 内存错误检测 | Valgrind / ASan | ASan / Valgrind | Valgrind / ASan / TSan | race detector(-race) | miri 解释 MIR,能抓别名违规、未初始化读、模型化数据竞争——语言内建,不是外部工具 |
| 内存安全默认值 | 安全(GC,但慢) | 安全(GC,仍有空指针异常) | 默认不安全:越界/悬垂/UAF 都是 UB | 安全(GC,但有 nil panic 与竞态) | 安全默认,unsafe 显式、局部、可审计,并能被 Clippy 与 miri 逐个检查 |
| 「危险代码」的可见性 | 无此概念 | sun.misc.Unsafe(被强烈劝阻) | 整个语言都是危险的 | unsafe 包(少数 API) | 五大超能力清单 + // SAFETY: 注释 + unsafe_op_in_unsafe_fn 强制显式块 |
| 包管理与测试集成 | pip + venv + 插件 | Maven/Gradle 插件链 | 无标准(CMake 等) | go mod + 内建工具 | cargo test 一个入口覆盖单元/集成/文档/基准,配合 cargo llvm-cov、cargo nextest |
💡 对照重点:C/C++ 与 Rust 的差别不是「有没有 unsafe」,而是默认值。C 里每一行指针操作都要求你脑子里的证明;Rust 里只有
unsafe块里的少数几行要求证明,其余由编译器背书。这把「审计整个程序」缩成「审计被标注出来的几十行」,是工程上可操作性的巨大差别。
常见坑与编译错误
坑 1:测试并行执行导致共享文件冲突
症状:单独跑每个测试都通过,cargo test 一起跑就随机失败,报 Os { code: 32, kind: AlreadyExists } 或断言值对不上。
rust
// 有问题的写法:两个测试抢同一个固定路径,而测试默认并行执行
fn write_report(path: &str, body: &str) -> std::io::Result<()> {
std::fs::write(path, body)
}
#[test]
fn test_a() {
write_report("out/report.txt", "a").unwrap(); // 与 test_b 竞争同一个文件
assert_eq!(std::fs::read_to_string("out/report.txt").unwrap(), "a");
}
#[test]
fn test_b() {
write_report("out/report.txt", "b").unwrap();
assert_eq!(std::fs::read_to_string("out/report.txt").unwrap(), "b");
}原因:cargo test 默认用多线程并行跑测试(线程数约为 CPU 核数),两个测试同时对同一路径读写。
修法(推荐):让每个测试用自己的路径,用进程号 + 测试名隔离。
rust
fn unique_path(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
// 进程号隔离不同 cargo test 进程,测试名隔离同一进程内的测试
p.push(format!("mycrate_{}_{}.txt", std::process::id(), name));
p
}
#[test]
fn test_a_isolated() {
let path = unique_path("test_a");
write_report(path.to_str().unwrap(), "a").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "a");
let _ = std::fs::remove_file(&path); // 清理,避免污染机器
}修法(备选):确实必须共享同一资源时,用 cargo test -- --test-threads=1 串行化,或用一把 static 的 Mutex 做进程内互斥。但并行是默认且有益的,优先改测试而不是关并行。
坑 2:#[should_panic] 太宽松
症状:代码里提前 unwrap() 失败,测试却「通过」。
rust
// 差:任何 panic 都算通过
#[test]
#[should_panic]
fn rejects_negative() {
let v = config_from_str("timeout=-1"); // 这里若 unwrap 先 panic,也算通过
assert!(v.is_err());
}原因:没有 expected 时,#[should_panic] 只断言「发生了 panic」,不关心从哪来。
修法:加 expected,并确保那串文字只可能来自目标路径。
rust
#[test]
#[should_panic(expected = "timeout 不能为负")]
fn rejects_negative() {
let _ = parse_timeout("-1"); // 该函数在负值时 panic!("timeout 不能为负: -1")
}坑 3:doctest 里的 unwrap 与 ?
症状 1:文档示例里写 let x = foo().unwrap();——读者抄走后在生产代码里继续 unwrap,把错误处理甩给别人。
修法:示例要展示推荐用法。需要传播错误时用 ?,并把示例包在返回 Result 的 main 里。
rust
/// ```
/// use my_crate::load_config;
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let cfg = load_config("app.toml")?; // 展示真实的错误传播方式
/// assert_eq!(cfg.name, "app");
/// # Ok(())
/// # }
/// ```症状 2:直接在示例顶层写 let x = foo()?;,得到 error[E0277]: the '?' operator can only be used in a function that returns Result,因为 doctest 默认把代码包进 fn main() {}。
修法:如上,自己提供一个 fn main() -> Result<...>(用 # 隐藏签名行保持文档整洁),或改用 .expect("必须存在") 并在文档里说明前置条件。
坑 4:基准测到被优化掉的代码
症状:某个基准报告 time: [0.0000 ns 0.0001 ns ...],或比合理值快几百倍。
原因:结果没被使用,LLVM 把整段调用删掉了。
修法:black_box 包住输入与输出,用运行时构造的输入;需要 setup 时用 iter_batched 把准备阶段移出测量。
rust
use criterion::Criterion;
use std::hint::black_box;
fn compute(input: &[u8]) -> usize {
input.iter().map(|b| *b as usize).sum()
}
fn bench_compute(c: &mut Criterion) {
let input: Vec<u8> = (0..1024).map(|i| (i % 251) as u8).collect(); // 运行时构造
c.bench_function("compute", |b| {
// 输入与输出都过 black_box:输入防常量折叠,输出防整段被删除
b.iter(|| black_box(compute(black_box(&input))))
});
}坑 5:dbg! 留在提交里
症状:git diff 里混进了 dbg!(x);,日志被 stderr 刷屏——而且 dbg! 在 release 构建里也会执行(它不是 debug_assert!)。
修法:把它变成 lint 错误。
toml
# Cargo.toml
[lints.clippy]
dbg_macro = "warn" # 属于 restriction 组,必须显式打开
undocumented_unsafe_blocks = "warn"
missing_safety_doc = "warn"powershell
cargo clippy --all-targets -- -D warnings需要保留调试输出就换成 tracing/log 的宏,它们受日志级别控制,不会漏到生产。
坑 6:unsafe 代码缺少 SAFETY 注释
症状:代码评审里永远说不清「这块为什么安全」,半年后没人敢改。
原因:unsafe 块没有任何记录义务时,审查者只能重新推导一遍前置条件。
修法:见坑 5 的 [lints.clippy] 配置;同时把「每个 unsafe 块必须紧跟一条 SAFETY: 注释」写进团队的 review checklist。光有注释还不够——注释必须回答「前置条件是什么、当前怎么满足的、不满足会怎样」,否则只是装饰。
坑 7:miri 报告 UB,但你选择「不理解原因就绕过」
症状:看到 error: Undefined Behavior: trying to retag from <0x...> for Unique permission,于是加 -Zmiri-disable-stacked-borrows 或 #[allow] 继续发布。
原因:看不懂就关检查,等于把 UB 变成「随机在生产环境炸」。
修法:按固定顺序读报告。
- 第一行的 UB 类型(
retag/ out-of-bounds / uninitialized / data race)说明问题是别名、边界、初始化还是并发。 - 报告里的
help:段通常直接指出可疑代码行与原因(例如「this error occurs as part of an access to ... which was later invalidated」)。 - 回到代码找「谁还持有引用」:
&mut要求独占,&要求共享。常见根因是从裸指针重新造引用时旧引用仍在使用,或把&T变成*mut T后写了它。 - 修完用
cargo +nightly miri test复跑确认干净。若某段确实依赖miri未完整建模的行为(例如真实 FFI),在测试层跳过它,而不是在实现里关检查。
rust
#[test]
#[cfg_attr(miri, ignore = "调用了 C 库,miri 不支持真实 FFI")]
fn works_with_c_library() {
// ...
}速查表
| 目的 | 命令 / 写法 |
|---|---|
| 单元测试模块 | #[cfg(test)] mod tests { use super::*; ... } |
| 标记测试函数 | #[test] fn name() { ... } |
| 断言相等 / 不等 / 真值 | assert_eq!(a, b) / assert_ne!(a, b) / assert!(cond) |
| 断言匹配模式 | use std::assert_matches; 后 assert_matches!(expr, Pat if guard) |
| 期望 panic | #[should_panic(expected = "子串")](务必带 expected) |
| 测试返回错误 | #[test] fn t() -> Result<(), E> { ...; Ok(()) } |
| 跳过慢测试 | #[ignore = "原因"] + cargo test -- --ignored |
| 集成测试 | tests/<name>.rs(独立 crate,只能测 pub API);共享代码放 tests/common/mod.rs |
| 开发依赖 | cargo add --dev pretty_assertions(写入 [dev-dependencies]) |
| 文档测试 | /// 代码块默认执行;标注 no_run / ignore / should_panic / compile_fail;隐藏行用 # |
| 只跑 doctest / 单元 / 某文件 | cargo test --doc / --lib / --test <name> |
看到 println! 输出 | cargo test -- --nocapture |
| 串行执行 | cargo test -- --test-threads=1 |
| 精确匹配测试名 | cargo test foo -- --exact |
| 更快的测试运行器 | cargo nextest run(不跑 doctest,需另跑 cargo test --doc) |
| 覆盖率 | cargo llvm-cov --html / --fail-under-lines 80 |
| 基准测试骨架 | cargo add --dev criterion + [[bench]] harness = false + criterion_group! |
| 防止基准被优化掉 | std::hint::black_box(1.66 起稳定) |
| 端到端 CLI 基准 | hyperfine --warmup 3 ".\a.exe" ".\b.exe" |
| 火焰图 / 热点 | cargo flamegraph(Linux/macOS 最佳,Windows 需 WSL2 或 ETW 后端) |
| 检测 UB | rustup +nightly component add miri + cargo +nightly miri test |
| 观察编译期成本 | cargo build --timings / cargo llvm-lines |
五大 unsafe 超能力 | 解裸指针、调 unsafe 函数、访 static mut、实现 unsafe trait、访问 union 字段 |
| 2024 edition 变化 | unsafe fn 体内需显式 unsafe { }(unsafe_op_in_unsafe_fn 默认 warn) |
| 注释规范 | 每个 unsafe 块前写 // SAFETY: <前置条件为何成立>;unsafe fn 的文档写 # Safety 小节 |
| 别用的旧 API | mem::uninitialized(已废弃)、mem::zeroed(多数类型是 unsafe 的雷)、try!、裸 transmute |