Skip to content

unsafe 深入

unsafe 的边界、义务与证明,写 unsafe 前必须知道什么。

unsafe 深入:边界、义务与证明

五大超能力

unsafe 不是「关掉借用检查」,也不是「进入另一种语言」。它精确地解锁了五件事,仅此而已:

#超能力典型场景
1解引用裸指针(*const T / *mut T手写数据结构、跨 FFI 传递缓冲
2调用 unsafe fnunsafe 方法get_uncheckedstr::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 而不是 errorrust_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>():对「所有位模式都合法」的类型没问题,但对 &TBox<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 实测它仍是 unstableerror[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/Syncauto 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 的设计:用 AtomicXxxMutexRwLockOnceLock,让编译器自动推导。

什么时候用 unsafe,什么时候不用

该用的情况(只有三类,且都需要论证):

  1. FFI:调用 C/C++ 库、暴露 C ABI 给外部。这是 unsafe 存在的首要理由。
  2. 性能关键的、已被基准验证的热点:例如在 criterion + flamegraph 之后确认某处边界检查或 UTF-8 校验是瓶颈,并且安全的替代方案(chunks_exactsplit_atbytes() 迭代器)都无法达到目标。
  3. 实现安全抽象VecRefCellRcArcMutex 内部都是 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]transmuteu32::to_le_bytes / from_le_bytes
&[u8]&[u32]transmute + 手动长度bytemucktry_cast_slice,会检查对齐与长度)
整数指针 ↔ 指针transmuteptr::with_exposed_provenance / as 转换
enum ↔ 整数transmute显式 matchTryFrom
延长引用生命周期transmute重新设计所有权,或 Rc/Arc/arena
MaybeUninit<T>Ttransmuteassume_init(并论证已初始化)
  • 不要用 static mut:Rust 2024 起对 static mut 的引用会触发 static_mut_refs lint(deny 级别)。需要全局可变状态时,用 AtomicXxxOnceLockMutex,或者把状态放进参数里传递。

完整示例:用 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
}

借用检查器无法知道 ..midmid.. 不重叠,因此拒绝。手写 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)
    }
}

不变量清单(写进测试,也写进代码审查清单):

  • 前置条件 1mid <= slice.len()。违反时 assert! 立即 panic,绝不进入 unsafe 块——把检查放在 unsafe 块之外,是让「错误输入不可能触发 UB」的关键
  • 前置条件 2slice 是有效的 &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_atsplit_at_mutrustc 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!,除非你能用类型证明它不可能越界。


与其他语言的对照

主题PythonJavaC / C++GoRust 的做法与理由
单元测试入口pytest 按文件名/函数名发现JUnit @Test + 反射无标准方案(GoogleTest 等)同包 _test.go + go test#[cfg(test)] mod tests + #[test]:测试与代码同文件,编译期开关,能直接测私有函数
断言assert-O 下会被去掉)assertEquals + AssertJassert() 宏(NDEBUG 下消失)手写 if ... t.Fatalfassert!/assert_eq!/assert_ne!/assert_matches!,失败信息含源码位置与两侧 Debug
参数化/表驱动@pytest.mark.parametrizeJUnit 5 @ParameterizedTest手写循环表驱动 struct 切片手写 Vec<(input, expected)> 循环,或 rstest#[case]
属性测试Hypothesisjqwik移植自 QuickCheck 的库testing/quickproptest(带收缩)或 quickcheck
mockunittest.mockMockitoGoogleMock手工接口 + 假实现mockall#[automock] 从 trait 生成 mock
文档测试doctest 模块无(Javadoc 代码不执行)Example 函数(go test 会跑)/// 里的代码块默认可执行,no_run/ignore/should_panic/compile_fail 精确控制
基准测试timeit / pytest-benchmarkJMH(需注解处理器与独立模块)Google Benchmarktesting.B + go test -benchcriterion:置信区间、离群点检测、与上次结果对比;内置 #[bench] 需 nightly
性能剖析cProfile / py-spyJFR / async-profilerperf / VTunepprofperf + cargo-flamegraph;CLI 端到端用 hyperfine
内存错误检测Valgrind / ASanASan / ValgrindValgrind / ASan / TSanrace detector(-racemiri 解释 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-covcargo 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 串行化,或用一把 staticMutex 做进程内互斥。但并行是默认且有益的,优先改测试而不是关并行

坑 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,把错误处理甩给别人。

修法:示例要展示推荐用法。需要传播错误时用 ?,并把示例包在返回 Resultmain 里。

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 变成「随机在生产环境炸」。

修法:按固定顺序读报告。

  1. 第一行的 UB 类型(retag / out-of-bounds / uninitialized / data race)说明问题是别名、边界、初始化还是并发
  2. 报告里的 help: 段通常直接指出可疑代码行与原因(例如「this error occurs as part of an access to ... which was later invalidated」)。
  3. 回到代码找「谁还持有引用」:&mut 要求独占,& 要求共享。常见根因是从裸指针重新造引用时旧引用仍在使用,或&T 变成 *mut T 后写了它
  4. 修完用 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 后端)
检测 UBrustup +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 小节
别用的旧 APImem::uninitialized(已废弃)、mem::zeroed(多数类型是 unsafe 的雷)、try!、裸 transmute

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