课程概览 · 第 17 章

上一篇:并发与异步:先划清所有权和取消边界

下一篇:Cargo 与依赖:建立可重复的 Rust 开发闭环

unsafe 不是关闭 Rust 的安全检查,而是把某几项保证交给开发者维护。好的 unsafe 代码应当很小、具有明确前置条件,并让绝大多数调用者保持 safe Rust。本章先讲 safe Rust 依赖的不变量,然后用一个 safe 包装器示范"最小 unsafe 块"的写法,再过一遍高频 trait 的实用语义,最后明确 Miri 在验证链条中的位置。

safe Rust 的有效性不变量、安全包装器中的最小 unsafe 块、测试和 Miri 证据,以及常用 trait 的语义合同
图:unsafe 块只承担被明确证明的局部操作;安全包装器、测试和 trait 语义共同守住外部调用者的安全边界。

学习目标与默认选择

学完本章你应当能够:

  • 说出 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// safe 包装器:先做空检查,再在最小的 unsafe 块里取首元素。
/// 公共 API 是 safe 的:调用方不需要任何前置条件。
fn first_fast<T>(items: &[T]) -> Option<&T> {
if items.is_empty() {
return None;
}
// SAFETY: items.is_empty() 为 false 已在上一个分支证明,
// 因此 items.len() >= 1,索引 0 在边界内;
// get_unchecked 返回的引用与输入切片共享同一个生命周期参数。
Some(unsafe { items.get_unchecked(0) })
}

fn main() {
let tasks = ["登录", "支付", "对账"];
assert_eq!(first_fast(&tasks), Some(&"登录"));
assert_eq!(first_fast::<&str>(&[]), None); // 空输入返回 None,不触发 unsafe
let empty: Vec<u64> = Vec::new();
assert_eq!(first_fast(&empty), None);
let one = [42u64];
assert_eq!(first_fast(&one), Some(&42));
}

诚实的评价:这个函数没有实际收益(items.first() 同样快,且零 unsafe)。它作为教学样本的价值在于示范结构–把同样的模式套在真正有收益的场景(如跳过边界检查的热点循环、FFI 包装、MaybeUninit 初始化)上。如果一处 unsafe 的性能优势未经测量证实,删掉它。

unsafe fn:调用方契约示例

unsafe fn 表示"调用方必须证明前置条件"。它与 unsafe 块不同:后者是"我要执行危险操作",前者是"我要求调用者满足条件"。契约必须写进 # Safety 文档节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/// 读取指针指向的 i32。
///
/// # Safety
///
/// 调用方必须保证:
/// - `ptr` 非空、按 `i32` 对齐;
/// - `ptr` 指向一个已初始化且仍然存活的 `i32`;
/// - 调用期间不存在可变别名(没有活跃的 `&mut i32` 指向同一内存)。
///
/// 违反任一条都是未定义行为,即使程序"看起来能跑"。
unsafe fn read_i32(ptr: *const i32) -> i32 {
// Edition 2024:unsafe fn 体内的危险操作也要放进显式 unsafe 块,
// 审查者能精确看到哪一行需要证明。
unsafe { *ptr }
}

fn main() {
let value: i32 = 4242;
let ptr: *const i32 = &value; // 从合法引用取得裸指针,满足全部前置条件
// SAFETY: value 在本作用域内存活且已初始化;ptr 来自合法引用,
// 对齐和别名条件由借用检查器在创建 &value 时保证。
let read = unsafe { read_i32(ptr) };
assert_eq!(read, 4242);

let boxed = Box::new(7i32);
let ptr2: *const i32 = &*boxed;
// SAFETY: boxed 在本作用域内存活;ptr2 来自合法引用。
assert_eq!(unsafe { read_i32(ptr2) }, 7);
}

Edition 2024 之前,unsafe fn 体内可以直接写危险操作;2024 起必须再包一层显式 unsafe { }。这不是冗余:它把"此函数不安全"(函数级)和"此行执行危险操作"(语句级)区分开,审查者一眼看到需要证明的代码行。

这里有一个必须点破的事实:违反别名规则的 unsafe 代码通常能通过编译–裸指针不参与借用检查,&mut 与裸指针并存也不报 E0499。这类错误要靠 Miri 才能在开发期现形:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
可编译,但是未定义行为(rustc 接受;cargo +nightly miri run 报 UB)。
先用合法引用建裸指针,再通过 &mut 修改同一内存,最后用旧指针读取。

unsafe fn read_i32(ptr: *const i32) -> i32 {
unsafe { *ptr }
}

fn main() {
let mut value: i32 = 100;
let ptr: *const i32 = &value; // 裸指针记录了"当时"的借用状态
let alias = &mut value; // 新的可变借用使旧指针失效
*alias += 1;
println!("{}", unsafe { read_i32(ptr) }); // UB:读取已被独占借用的内存
}

Miri 的报告(节选):

error: Undefined Behavior: attempting a read access using <582> at alloc219[0x0],
but that tag does not exist in the borrow stack for this location
--> src/main.rs:2:14

修正(可运行且无 UB):先结束可变借用,再创建裸指针读取。

fn main() {
let mut value: i32 = 100;
{
let alias = &mut value;
*alias += 1;
} // 可变借用结束
let ptr: *const i32 = &value;
// SAFETY: 可变借用已结束,ptr 指向存活的 i32。
println!("{}", unsafe { read_i32(ptr) }); // 输出 101
}

这个例子同时说明了两件事:为什么"编译通过"不能作为 unsafe 正确性的证据(rustc 完全放行),以及 Miri 为什么是 unsafe 代码的必备工具(它按 Stacked Borrows 规则追踪每个指针的借用栈,精确报告失效的访问)。

(上例假定 read_i32 已按上文定义;两个片段在同一程序内使用。)

FFI 的 ABI 与所有权约束

跨语言调用 C 是 unsafe 最正当的用途。三条硬约束:

  1. ABI#[repr(C)] 才能保证字段布局与 C 一致;Rust 默认 repr(Rust) 布局未定义,绝不能跨 FFI 传递。
  2. 所有权:谁分配谁释放。Rust 侧 Box 分配的内存不能交给 free(),C 侧 malloc 的内存不能交给 Box::from_raw。字符串尤其危险:C 字符串以 NUL 结尾,Rust String 不保证。
  3. 错误处理:C 没有恐慌机制;catch_unwind 在边界处拦截,绝不把 unwind 穿过 FFI。

一个最小的 FFI 形状(示意,不依赖任何 C 库也可编译运行的核心模式是"裸指针 + repr(C) + 显式释放约定"):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
use std::os::raw::c_int;

#[repr(C)]
struct TaskSummary {
id: c_int,
done: c_int, // C 侧没有 bool,用 0/1
}

// 真实场景:这里声明 C 库导出的函数并链接。
// unsafe extern 块声明"这些函数本身是 unsafe 的"(Edition 2024 语法)。
// unsafe extern "C" {
// fn summarize(items: *const TaskSummary, n: usize);
// }

// 桩实现(Rust 侧同签名等价物):教学时用它与真实声明体会同一套调用点纪律。
unsafe extern "C" fn summarize(items: *const TaskSummary, n: usize) {
// SAFETY(被调用方):caller 保证指针与长度匹配。
for i in 0..n {
let s = unsafe { &*items.add(i) };
assert!(s.id > 0);
}
}

fn main() {
let items = [
TaskSummary { id: 1, done: 0 },
TaskSummary { id: 2, done: 1 },
];
// SAFETY: items 是本地数组,指针和长度匹配;
// summarize 只读取数据、不保留指针。
unsafe { summarize(items.as_ptr(), items.len()) };
}

(若把注释里的 unsafe extern 声明打开并去掉桩实现,需要链接提供 summarize 的 C 库,否则得到链接错误;调用点纪律与桩版本完全一致。)

Drop 与显式可失败清理

RAII 是 Rust 资源管理的基石:拥有者离开作用域时 Drop::drop 自动执行。但 drop 的签名是 fn drop(&mut self)不能返回 Result。因此清理失败的报告必须走显式方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
use std::fmt;

#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)] // 课程共享词汇:完整枚举在各例中统一出现
enum TaskState {
Todo,
InProgress { started_at: u64 },
Done { finished_at: u64 },
Cancelled { reason: String },
}

#[derive(Debug, Clone, PartialEq)]
struct Task {
id: u64,
title: String,
state: TaskState,
labels: Vec<String>,
}

#[derive(Debug)]
enum CloseError {
Io(String),
}

impl fmt::Display for CloseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CloseError::Io(msg) => write!(f, "关闭失败: {msg}"),
}
}
}

/// 演示"Drop 只做不可失败的清理;可失败清理要显式方法"。
struct Journal {
tasks: Vec<Task>,
path: String,
closed: bool,
}

impl Journal {
/// 显式关闭:可以返回 Result,调用方必须处理失败。
fn close(&mut self) -> Result<(), CloseError> {
if self.tasks.is_empty() {
return Err(CloseError::Io(format!("{} 无内容可写", self.path)));
}
self.closed = true;
Ok(())
}
}

impl Drop for Journal {
fn drop(&mut self) {
// Drop 不能返回 Result,只能做尽力而为的清理。
// 这里只重置内存状态;真正的 flush 失败由 close() 报告。
self.tasks.clear();
}
}

fn main() {
let mut journal = Journal {
tasks: vec![Task {
id: 1,
title: "对账".to_string(),
state: TaskState::Todo,
labels: Vec::new(),
}],
path: "journal.log".to_string(),
closed: false,
};
assert!(journal.close().is_ok());
assert!(journal.closed);
// journal 离开作用域时 Drop 自动执行(无需也无法检查失败)

let mut empty = Journal {
tasks: Vec::new(),
path: "empty.log".to_string(),
closed: false,
};
// 显式 close 暴露失败:空日志不能关闭成功
let err = empty.close().unwrap_err();
assert_eq!(err.to_string(), "关闭失败: empty.log 无内容可写");
}

规则总结:文件 flush、连接关闭、提交事务这类可能失败的动作提供 close()/flush()/commit() -> ResultDrop 只做释放内存、归还句柄这类不会失败的清理,且实现中避免 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
use std::fmt;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
struct TaskId(u64);

impl TaskId {
fn new(value: u64) -> Result<Self, String> {
if value == 0 {
Err("TaskId 不能为 0".to_string())
} else {
Ok(TaskId(value))
}
}
}

// Display:面向最终用户的文本(CLI 输出)。
impl fmt::Display for TaskId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "#{}", self.0)
}
}

// From<TaskId> for u64:无损回转,让 TaskId 可以参与数值运算边界。
impl From<TaskId> for u64 {
fn from(id: TaskId) -> Self {
id.0
}
}

#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)] // 课程共享词汇:完整枚举在各例中统一出现
enum TaskState {
Todo,
InProgress { started_at: u64 },
Done { finished_at: u64 },
Cancelled { reason: String },
}

#[derive(Debug, Clone, PartialEq)]
struct Task {
id: TaskId,
title: String,
state: TaskState,
labels: Vec<String>,
}

// Default:有自然、安全的默认值时才实现;Task 的 Default 是"未开始的全新任务"。
impl Default for Task {
fn default() -> Self {
Task {
id: TaskId::new(1).expect("1 是合法 id"),
title: String::new(),
state: TaskState::Todo,
labels: Vec::new(),
}
}
}

// AsRef<str>:让 Task 可以被任何接受 &str 的 API 借用其标题。
impl AsRef<str> for Task {
fn as_ref(&self) -> &str {
&self.title
}
}

fn main() {
let mut task = Task::default();
task.title = "对账".to_string();
task.labels.push("finance".to_string());

// Display:用户可读
let line = format!("任务 {}:{}", task.id, task.title);
assert_eq!(line, "任务 #1:对账");
// Debug:诊断可读({:?})
assert!(format!("{task:?}").starts_with("Task {"));
// From<TaskId> for u64:无损回转
assert_eq!(u64::from(task.id), 1);
// AsRef:借出 &str 而不转移所有权
fn title_len(item: impl AsRef<str>) -> usize {
item.as_ref().chars().count()
}
assert_eq!(title_len(&task), 2);
// Clone:显式成本;Copy 类型(如 TaskId)则隐式复制
let copy_of_id = task.id; // Copy:task.id 仍可用
assert_eq!(task.id, copy_of_id);
let cloned = task.clone(); // Clone:深拷贝,两个任务互相独立
assert_eq!(cloned, task);
}

注意 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 内存双向不兼容:跨边界传递的每块内存都要写明分配方与释放方;约定写在注释和类型名里(如 OwnedFd vs 裸 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_fastunsafe 块只覆盖 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:失败无法上报,数据静默丢失;可失败清理必须显式调用。

自测

  1. first_fast// SAFETY: 注释必须写什么才合格?为什么"此代码是安全的"不合格?
    答的方向:写明前置条件(索引 0 在界内)与证明它的前置分支(is_empty() 检查);泛泛声明无法支撑审查。
  2. unsafe fnunsafe { } 块的职责区别是什么?Edition 2024 为什么要求函数体内再包一层块?
    答的方向:前者把举证责任交给调用方,后者标记本行危险操作;区分函数级契约与语句级危险让审查可局部化。
  3. 为什么 Drop 不能做 flush?正确的 API 形状是什么?
    答的方向drop 无返回值,失败无法上报;提供 close()/flush() -> Result,Drop 只做不可失败清理。
  4. TaskId 适合 CopyTask 不适合,判据是什么?为 newtype 实现 Deref 有什么后果?
    答的方向:隐式复制是否廉价且语义正确;Deref 会把底层类型的全部方法泄漏进 newtype,破坏类型边界。
  5. Miri 能证明什么、不能证明什么?
    答的方向:能对执行过的样本精确报告 UB;不能证明任意输入下无 UB,那需要不变量推理。