Unsafe 与常用 trait:把不安全性封装在最小边界
课程概览 · 第 17 章
unsafe 不是关闭 Rust 的安全检查,而是把某几项保证交给开发者维护。好的 unsafe 代码应当很小、具有明确前置条件,并让绝大多数调用者保持 safe Rust。本章先讲 safe Rust 依赖的不变量,然后用一个 safe 包装器示范"最小 unsafe 块"的写法,再过一遍高频 trait 的实用语义,最后明确 Miri 在验证链条中的位置。
学习目标与默认选择
学完本章你应当能够:
- 说出 safe Rust 依赖的核心不变量(别名规则、有效性、对齐);
- 写一个"先检查、后最小 unsafe 块"的 safe 包装函数,配
// SAFETY:注释写明由前置分支证明的确切前置条件; - 解释
unsafe fn的调用方契约与# Safety文档节的写法,以及 Edition 2024 对unsafe fn体内显式unsafe块的要求; - 说明 FFI 的 ABI/所有权约束、
Drop与显式可失败close/flush的分工; - 按"调用者的直觉"选择实现
Debug/Display/Default/From/TryFrom/AsRef/Deref/Clone/Copy。
默认选择:不写 unsafe。借用报错、性能直觉、"C++ 里一直这么写"都不是使用 unsafe 的理由。真正需要 unsafe 的场景只有几类:FFI、解引用裸指针、访问 static mut、实现底层容错数据结构、访问 union 字段。即便在这些场景里,unsafe 也应被包在 safe API 之后。
概念讲解:safe Rust 依赖什么不变量
借用检查器保证 &mut T 在其有效期内独占访问(别名规则),&T 指向有效、正确对齐且满足类型约束的数据。编译器基于这些承诺重排、缓存和优化内存访问。裸指针操作绕开这些检查;一旦制造悬垂指针、未对齐访问、无效位模式或违反别名规则,即使表面"能运行"也是未定义行为(UB)–优化级别一变就可能暴露。
UB 的可怕之处在于它是对编译器的承诺被打破,而不是"崩溃"或"输出错误值"这么温和。这就是为什么 unsafe 代码的审查标准不是"跑过了测试",而是"每条前置条件都能被证明"。
safe 包装器:first_fast<T>
示范标准写法:公共 API 是 safe 的;空检查在前;unsafe 块最小化(只有一个表达式);// SAFETY: 注释引用前置分支,而不是泛泛写"这是安全的":
1 | |
诚实的评价:这个函数没有实际收益(items.first() 同样快,且零 unsafe)。它作为教学样本的价值在于示范结构–把同样的模式套在真正有收益的场景(如跳过边界检查的热点循环、FFI 包装、MaybeUninit 初始化)上。如果一处 unsafe 的性能优势未经测量证实,删掉它。
unsafe fn:调用方契约示例
unsafe fn 表示"调用方必须证明前置条件"。它与 unsafe 块不同:后者是"我要执行危险操作",前者是"我要求调用者满足条件"。契约必须写进 # Safety 文档节:
1 | |
Edition 2024 之前,unsafe fn 体内可以直接写危险操作;2024 起必须再包一层显式 unsafe { }。这不是冗余:它把"此函数不安全"(函数级)和"此行执行危险操作"(语句级)区分开,审查者一眼看到需要证明的代码行。
这里有一个必须点破的事实:违反别名规则的 unsafe 代码通常能通过编译–裸指针不参与借用检查,&mut 与裸指针并存也不报 E0499。这类错误要靠 Miri 才能在开发期现形:
1 | |
这个例子同时说明了两件事:为什么"编译通过"不能作为 unsafe 正确性的证据(rustc 完全放行),以及 Miri 为什么是 unsafe 代码的必备工具(它按 Stacked Borrows 规则追踪每个指针的借用栈,精确报告失效的访问)。
(上例假定 read_i32 已按上文定义;两个片段在同一程序内使用。)
FFI 的 ABI 与所有权约束
跨语言调用 C 是 unsafe 最正当的用途。三条硬约束:
- ABI:
#[repr(C)]才能保证字段布局与 C 一致;Rust 默认repr(Rust)布局未定义,绝不能跨 FFI 传递。 - 所有权:谁分配谁释放。Rust 侧
Box分配的内存不能交给free(),C 侧malloc的内存不能交给Box::from_raw。字符串尤其危险:C 字符串以 NUL 结尾,RustString不保证。 - 错误处理:C 没有恐慌机制;
catch_unwind在边界处拦截,绝不把 unwind 穿过 FFI。
一个最小的 FFI 形状(示意,不依赖任何 C 库也可编译运行的核心模式是"裸指针 + repr(C) + 显式释放约定"):
1 | |
(若把注释里的 unsafe extern 声明打开并去掉桩实现,需要链接提供 summarize 的 C 库,否则得到链接错误;调用点纪律与桩版本完全一致。)
Drop 与显式可失败清理
RAII 是 Rust 资源管理的基石:拥有者离开作用域时 Drop::drop 自动执行。但 drop 的签名是 fn drop(&mut self)–不能返回 Result。因此清理失败的报告必须走显式方法:
1 | |
规则总结:文件 flush、连接关闭、提交事务这类可能失败的动作提供 close()/flush()/commit() -> Result;Drop 只做释放内存、归还句柄这类不会失败的清理,且实现中避免 panic。
高频 trait 的实用语义
选型表(按"调用者会怎么用"来定):
| trait | 语义 | 实现建议 |
|---|---|---|
Debug | 诊断用 {:?} | 几乎所有领域类型都 derive |
Display | 用户可读 {} | 只为真正面向用户的文本实现 |
Default | 自然、安全的默认值 | 有合理零值才实现;Task::default() 给"全新空任务"合理,给"银行账户"不合理 |
From<T> | 无条件、无损转换 | 入参/出参边界转换 |
TryFrom<T> | 可能失败的转换 | 用户输入、外部数据解析 |
AsRef<T> | 借出视图 | 让 API 接受 impl AsRef<str> 而非具体容器 |
Deref | 智能指针解引用 | 基本只留给智能指针;当继承用会泄漏全部底层方法 |
Clone | 显式深拷贝 | 明确成本;调用点可见 |
Copy | 隐式按位复制 | 只适合小型纯值(TaskId 合适,Task 不合适) |
一个覆盖常用 trait 的小示例:
1 | |
注意 TaskId 满足 Copy(一个 u64,复制成本可忽略),Task 不满足(含 String/Vec,复制是深拷贝,必须显式 clone)。这个差异就是 Copy 的判据:隐式复制是否廉价且语义正确。反例:为 newtype 实现 Deref(比如 impl Deref for TaskId { type Target = u64 })会让 u64 的所有方法泄漏到 TaskId 上,task_id.pow(3) 编译通过–类型边界被静默破坏。
边界与失败场景
- 裸指针解引用不检查任何东西:
unsafe { *ptr }对空指针、悬垂指针、错误对齐都不会在编译期或运行期报错(release 下),结果就是 UB。Miri 能在开发期抓到一部分。 unsafe fn的契约只能靠文档与审查:编译器不检查调用方是否满足# Safety节。这是 unsafe fn 比 unsafe 块危险的原因:错误可能在千里之外的调用点。- FFI 内存双向不兼容:跨边界传递的每块内存都要写明分配方与释放方;约定写在注释和类型名里(如
OwnedFdvs 裸c_int)。 Drop中 panic:drop 期间 panic 若叠上另一个 panic 就会 abort;Drop 实现里避免任何可失败操作。static mut:Edition 2024 起取&'static mut是硬错误;用static+ 原子类型或OnceLock/LazyLock代替。
为什么可行:unsafe 是"局部豁免",不是"全局关闭"
unsafe 块只豁免四件事:解引用裸指针、调用 unsafe fn、访问可变静态、访问 union 字段。块外的借用检查、类型检查、生命周期检查照常工作;块内创建的引用一旦流回 safe 代码,又受全部规则约束。这就是"最小边界"可行的原因:first_fast 的 unsafe 块只覆盖 get_unchecked(0) 一个表达式,它产生的 &T 立刻回到 safe 世界,借用检查器继续追踪它的生命周期。审查 unsafe 代码因此可以局部化–证明一个块的前置条件,而不是审查整个程序。Miri 补上执行期验证:它解释执行程序,在 UB 发生处精确报告(未初始化读、越界、别名违规、泄漏)。但 Miri 是样本验证工具:它能证明"这些测试输入下没有 UB",不能证明"任意输入下没有 UB"。后者仍然要靠不变量推理 + // SAFETY: 注释 + 测试覆盖。
常见误区
- “加 unsafe 让编译器闭嘴”:借用报错是设计信号;unsafe 只把它变成运行期 UB,问题没消失,只是更难发现。
- SAFETY 注释写"这是安全的":没有信息量。必须写明哪条前置条件由哪段代码证明。
- 用
Deref模拟继承:见上文TaskId反例;组合用字段,多态用 trait。 unsafe impl Send/Sync图省事:这是在向编译器承诺不存在数据竞争;承诺错了是 UB 而非警告。- 以为 Miri 通过 = 设计正确:Miri 只验证跑过的路径;不安全设计正确性靠证明,不靠工具盖章。
- Drop 里做 flush:失败无法上报,数据静默丢失;可失败清理必须显式调用。
自测
first_fast里// SAFETY:注释必须写什么才合格?为什么"此代码是安全的"不合格?
答的方向:写明前置条件(索引 0 在界内)与证明它的前置分支(is_empty()检查);泛泛声明无法支撑审查。unsafe fn和unsafe { }块的职责区别是什么?Edition 2024 为什么要求函数体内再包一层块?
答的方向:前者把举证责任交给调用方,后者标记本行危险操作;区分函数级契约与语句级危险让审查可局部化。- 为什么
Drop不能做 flush?正确的 API 形状是什么?
答的方向:drop无返回值,失败无法上报;提供close()/flush() -> Result,Drop 只做不可失败清理。 TaskId适合Copy而Task不适合,判据是什么?为 newtype 实现Deref有什么后果?
答的方向:隐式复制是否廉价且语义正确;Deref会把底层类型的全部方法泄漏进 newtype,破坏类型边界。- Miri 能证明什么、不能证明什么?
答的方向:能对执行过的样本精确报告 UB;不能证明任意输入下无 UB,那需要不变量推理。






