一句话总结
unsafe 不是"关闭安全检查",而是"向编译器担保这段代码满足它无法自动验证的前提".
它只解锁五种额外能力,借用检查,类型检查在 unsafe 块里照常生效.正确用法是:把不安全操作封装进一个对外安全的接口.
前置回顾
前面八篇从所有权,类型系统,迭代器,闭包一路讲到宏,构建了 Rust 的安全抽象体系.本篇来到这个体系的"地基层":当安全抽象无法满足需求时,unsafe 是怎么工作的,以及如何正确使用它.
先破除一个误解
很多人以为 unsafe 像一个"作弊开关",一打开整段代码就没有任何检查了.这是彻底的误解.
unsafe 不会关闭借用检查
在 unsafe 块里,所有权,借用规则,生命周期,类型检查全部照常运作.unsafe 唯一的作用,是额外解锁五种平时禁止的操作.
它表达的是一句承诺:"编译器,这五种操作的安全性无法自动验证,但已经人工确认过它满足要求." 责任从编译器转移到了编写者身上,仅此而已.
unsafe 像实验室里"高压电区"的门禁卡.刷卡进去,实验室的其他安全规程(防火,通风)一条不少.
它只是额外允许接触平时被锁住的高压设备.出了事,因为是主动刷卡进去操作的,责任在操作者而非门禁系统.
unsafe 解锁的五种能力
unsafe 能且仅能做这五件事.记住这张清单,就划定了它的全部边界.
| 能力 | 说明 |
|---|---|
| 解引用裸指针 | 读写 *const T / *mut T 指向的内存 |
| 调用 unsafe 函数 | 包括 FFI 中的外部 C 函数 |
| 访问/修改可变静态变量 | static mut,存在数据竞争风险 |
| 实现 unsafe 特征 | 如 Send,Sync |
| 访问 union 字段 | 主要用于与 C 互操作 |
日常最常见的是第一种
五种能力里,应用层代码绝大多数时候只会碰到第一种--解引用裸指针.其余几种集中出现在 FFI(与 C 交互),底层并发原语,嵌入式等专门领域.所以本篇重点讲裸指针.
裸指针:脱去保护的引用
Rust 有两种裸指针:*const T(只读)和 *mut T(可变).它们和普通引用 &T 的区别,正是 Rust 安全保证的来源.
| 特性 | 引用 &T / &mut T |
裸指针 *const T / *mut T |
|---|---|---|
| 保证非空 | 是 | 否,可能为空 |
| 保证指向有效内存 | 是 | 否,可能悬垂 |
| 受借用规则约束 | 是(同一时刻读写互斥) | 否,可同时存在多个可变 |
| 解引用需要 unsafe | 否 | 是 |
|
创建裸指针安全,解引用才危险
注意上面的细节:把引用转成裸指针,传递裸指针,都不需要 unsafe;只有解引用(读写它指向的内存)才需要.
原因很直观--光是持有一个地址不会出问题,真正动手去访问那块内存时,才可能踩到空指针或已释放的内存.
内存布局:Rust 如何摆放数据
写 unsafe 代码免不了和内存布局打交道.三个核心概念:
- 大小(size):类型占多少字节,
std::mem::size_of::<T>() - 对齐(alignment):类型的起始地址必须是某个数的倍数,
std::mem::align_of::<T>() - 填充(padding):为满足对齐,字段间编译器插入的空白字节
|
默认布局是不确定的
Rust 默认不保证结构体字段在内存里的顺序--编译器有权重排字段以减少 padding.所以不能假设字段的内存排列.
需要确定布局时(如与 C 交互,做底层序列化),必须显式标注 #[repr(C)](按 C 的规则排布)或 #[repr(packed)](取消填充).这是 FFI 场景的必备知识.
核心原则:把 unsafe 关进笼子
Rust 的整个安全大厦,其实建立在一个关键模式上:用 unsafe 实现底层操作,再用一个对外安全的接口把它包起来,并在内部确保所有不安全前提都被满足.
标准库本身就是最好的范例.Vec,String,Rc 内部全是裸指针和 unsafe,但它们暴露出的 API 完全安全.看一个简化的"安全封装"例子--标准库 split_at_mut 的思路:
|
安全抽象的两个要点
上例体现了安全封装的精髓:
其一,进入 unsafe 块前用 assert 等手段把前提条件检查到位(这里是 mid <= len).
其二,函数签名对外不带 unsafe,调用方无需也无从破坏其安全性.
把不安全性收敛在尽可能小的范围内,外界只能通过受控的安全接口接触它--这就是"把 unsafe 关进笼子".
未定义行为:踩中就全盘失效
滥用 unsafe 最严重的后果是触发未定义行为(Undefined Behavior, UB).一旦发生 UB,编译器对程序的所有推理前提都崩塌了,程序可能崩溃,可能给出错误结果,甚至看似"正常运行"却埋着隐患.
常见的 UB 来源:
- 解引用空指针或已释放的内存(悬垂指针)
- 制造两个同时有效的
&mut指向同一数据(违反别名规则) - 读取未初始化的内存
- 构造非法的值(如让
bool取 0,1 以外的字节)
unsafe 的责任全在编写者
编译器对 unsafe 块内的这些前提不再检查,也无法检查.一旦违反,后果不是"这一行出错",而是整个程序的行为变得不可预测--这是 UB 最可怕的地方.
因此:能不用 unsafe 就不用;必须用时,把它压缩到最小范围,写清注释说明担保了哪些前提,并尽量用现成的安全抽象(标准库,成熟 crate)而非自己造轮子.可以用 Miri 等工具检测潜在的 UB.
快速回顾
- unsafe 不关闭检查,借用与类型检查照常,它只解锁五种额外能力
- 五种能力核心是解引用裸指针,其余多见于 FFI 与底层并发
- 裸指针无非空,有效,借用规则保证;创建安全,解引用才需 unsafe
- 内存布局默认不确定,需确定布局用
#[repr(C)] - 核心模式:用安全接口封装 unsafe,进入前校验前提,收敛不安全范围
- 未定义行为一旦触发,整个程序行为不可预测,责任全在编写者
动手练习
- 裸指针实验:创建一个
i32,取它的*const i32和*mut i32,在unsafe块里读取并修改它,确认块外不加 unsafe 时解引用会编译失败. - 观察 padding:定义
struct Foo { a: u8, b: u32, c: u8 },用size_of打印其大小,再加#[repr(packed)]对比,理解 padding 的作用. - 阅读标准库源码:阅读标准库
Vec::push或slice::split_at_mut的源码,找出其中的unsafe块,分析它如何在进入前保证安全前提.