跳转到内容

Rust unsafe 详解

没有悬垂引用、没有数据竞争、没有越界访问:这门课讲过的每一项保证,都是编译器替你把关的。unsafe 是你对编译器说的一句话:“让我做一件你没法证明安全的事,责任由我来承担。”

很多人以为 unsafe 会把 Rust 的安全检查全部关掉。其实不会。在 unsafe 块里,你只是多了 5 件平时不允许做的事:

  1. 解引用裸指针(raw pointer)
  2. 调用 unsafe 函数
  3. 访问或修改可变的 static 变量
  4. 实现 unsafe trait
  5. 访问 union 的字段

除此之外的一切,比如借用规则、移动语义、类型检查,在 unsafe 代码里照样完全有效。它是一把只用于 5 种操作的手术刀,而不是安全检查的总开关。

Rust 的普通引用(& 和 &mut)总是指向有效且正确对齐的数据,这是编译器保证的一部分。裸指针(*const T 或 *mut T)没有这个保证,所以读取它需要 unsafe:

fn main() {
let value = 5;
let pointer = &value as *const i32;
unsafe {
println!("{}", *pointer);
}
}

输出:

5
▶ 动手试一试

在安全代码里就可以创建裸指针,只有解引用它(读取它指向的值)才需要 unsafe,因为编译器已经没法确认这个指针是否有效了。

有些函数本身就标记为 unsafe,因为它们有编译器检查不了的前提条件。调用这样的函数,就等于你承诺:这些前提条件我已经亲自确认过了。

unsafe fn dangerous() {
println!("这个函数相信你已经确认过它的前提条件");
}
fn main() {
unsafe {
dangerous();
}
}

输出:

这个函数相信你已经确认过它的前提条件
▶ 动手试一试

标准库里有一些真正的 unsafe 函数可以调用,slice 的 get_unchecked 就是一个。它和 numbers[i] 的作用一样,只是跳过了边界检查。如果 i 越界,它不会 panic,而是读取那块内存里碰巧存着的任何东西。这叫作未定义行为(undefined behavior):程序可能崩溃,可能打印出乱码,也可能看起来一切正常,直到某天突然出问题。

所以调用它的规则是“只传入你确定有效的索引”。常见的做法是在一个普通的安全函数里,先亲自检查这条规则:

fn first_or_zero(numbers: &[i32]) -> i32 {
if numbers.is_empty() {
return 0;
}
// SAFETY: slice 不是空的,所以索引 0 一定在范围内。
unsafe { *numbers.get_unchecked(0) }
}
fn main() {
println!("{}", first_or_zero(&[7, 8, 9]));
println!("{}", first_or_zero(&[]));
}

输出:

7
0
▶ 动手试一试

看看调用者看到的是什么:fn first_or_zero(numbers: &[i32]) -> i32。签名里没有 unsafe,也没有办法用错它,因为函数在走到 unsafe 块之前就已经检查过规则了。unsafe 被缩到很小,藏在了函数内部。

有经验的 Rust 开发者都有两个习惯:

  • 在每个 unsafe 块上方写一行 // SAFETY: 注释,说明为什么规则得到了满足。这是写给下一个读代码的人看的,也是写给将来的你看的。
  • 让 unsafe 块尽可能小,这样需要反复核对的代码就更少。

说实话,真实项目里你不会写这个函数:numbers.first() 就能安全地完成同样的事,而且编译器很擅长去掉那些它能证明多余的边界检查。但这个模式(先检查规则,再在安全函数内部调用 unsafe)正是 Vec、String 这些类型的构建方式。FFI 那一页也用同样的技巧来包装 C 函数。

既然 Rust 处处讲安全,为什么还允许这种东西?因为有些事情确实没法在编译期证明安全,尽管它们在实际中完全没问题:直接和硬件打交道、调用 C 库(见 FFI),或者实现某些高性能数据结构。有了 unsafe,标准库(偶尔也包括你自己的代码)才能实现这些底层部件,而建立在它们之上的一切,仍然是普通的、经过完整检查的安全 Rust。

写了 unsafe 并不意味着你的代码就有 bug 了。它的意思是:在这个块里,编译器没法再帮你发现某几类错误,你得亲手维护同样的保证。大多数有经验的 Rust 开发者很少直接写 unsafe。它通常待在库的最底层,是几小块经过仔细审查的代码,外面包着一层安全的 API 供其他人使用。

写一行 let mut score = 10;,创建一个指向它的 *mut i32 裸指针,然后通过这个指针给它加 5,最后打印 score。提示:用 &mut score as *mut i32 创建指针,在 unsafe 块里写 *pointer += 5。

查看答案
fn main() {
let mut score = 10;
let pointer = &mut score as *mut i32; // 创建指针是安全的
unsafe {
*pointer += 5; // 通过它写入就不安全了
}
println!("score = {score}");
}

输出:

score = 15
▶ 动手试一试

这段代码在实际中是安全的,因为 score 还活着,而且写入的时候没有别的代码在用它。一旦用了 unsafe,遵守这些承诺就是你的责任。

写一个 fn get_or(numbers: &[i32], index: usize, default: i32) -> i32。它返回 index 位置的元素;如果索引越界,就返回 default。读取时用 get_unchecked,并确保这个函数不可能被以违反规则的方式调用。提示:先比较 index < numbers.len(),只有结果为真时才调用 get_unchecked。

查看答案
fn get_or(numbers: &[i32], index: usize, default: i32) -> i32 {
if index < numbers.len() {
// SAFETY: 刚刚检查过,index 在范围内。
unsafe { *numbers.get_unchecked(index) }
} else {
default
}
}
fn main() {
let numbers = [10, 20, 30];
println!("{}", get_or(&numbers, 1, -1));
println!("{}", get_or(&numbers, 5, -1));
}

输出:

20
-1
▶ 动手试一试

索引 5 根本到不了 unsafe 块,所以会被安全地处理掉。作为对比,完全安全的写法是 numbers.get(index).copied().unwrap_or(default),平时你写的就应该是这个。

  • unsafe 只解锁 5 种操作(主要是裸指针和调用 unsafe 函数),其他一切照样受到完整检查。
  • 裸指针(*const T / *mut T)可以在安全代码里创建,解引用它才需要 unsafe。
  • 把 unsafe 包在一个先检查规则的小型安全函数里,并用 // SAFETY: 注释说明它为什么是可靠的。
  • unsafe 的存在是为了能写出底层代码(访问硬件、和 C 互操作),而使用这些代码的地方依然是安全的。
  • 作为新手,你很少需要自己写它,先找安全的抽象。

小测验

1. unsafe 块到底解锁了什么?

2. 能在安全代码里创建裸指针吗?

3. 作为新手,你需要自己写 unsafe 的频率大概是?

4. 写了 unsafe 代码,是不是就说明程序有 bug 了?

得分: 0 / 4