安全与不安全如何交互

安全 Rust 和不安全 Rust 之间的关系是什么?它们如何交互?

安全 Rust 和不安全 Rust 之间的分离由 unsafe 关键字控制,它充当两者之间的接口。这就是为什么我们可以说安全 Rust 是一种安全的语言:所有不安全的部分都被专门保留在 unsafe 边界之后。如果你愿意,你甚至可以在你的代码库中加入 #![forbid(unsafe_code)] 来静态地保证你只编写安全 Rust。

unsafe 关键字有两种用途:声明编译器无法检查的契约的存在,以及声明程序员已经检查过这些契约已被遵守。

你可以使用 unsafe 来指示对函数特性声明存在未检查的契约。在函数上,unsafe 表示函数的用户必须检查函数的文档,以确保他们以维护函数要求的契约的方式使用它。在特性声明上,unsafe 表示特性的实现者必须检查特性文档,以确保他们的实现维护特性要求的契约。

你可以在一个代码块上使用 unsafe 来声明所有在其中执行的不安全操作都经过验证以遵守这些操作的契约。例如,传递给 slice::get_unchecked 的索引是越界的。

你可以在特性实现上使用 unsafe 来声明该实现遵守特性的契约。例如,实现 Send 的类型实际上可以安全地移动到另一个线程。

标准库有许多不安全的函数,包括

  • slice::get_unchecked,它执行未检查的索引,允许随意违反内存安全。
  • mem::transmute 将某个值重新解释为具有给定类型,以任意方式绕过类型安全(有关详细信息,请参阅转换)。
  • 每个指向大小类型的原始指针都有一个 offset 方法,如果传递的偏移量不是 "在范围内",则会调用未定义行为。
  • 所有 FFI (外部函数接口) 函数都是 unsafe 的,因为其他语言可以执行 Rust 编译器无法检查的任意操作。

从 Rust 1.29.2 开始,标准库定义了以下不安全特性(还有其他的,但它们尚未稳定,其中一些可能永远不会稳定)

  • Send 是一个标记特性(一个没有 API 的特性),它承诺实现者可以安全地发送(移动)到另一个线程。
  • Sync 是一个标记特性,它承诺线程可以通过共享引用安全地共享实现者。
  • GlobalAlloc 允许自定义整个程序的内存分配器。

Rust 标准库的许多部分也在内部使用不安全的 Rust。这些实现通常经过严格的手动检查,因此可以认为在这些实现之上构建的安全 Rust 接口是安全的。

所有这种分离的需求归结为安全 Rust 的一个基本属性,即健全性属性

无论如何,安全 Rust 都不会导致未定义行为。

安全/不安全分割的设计意味着安全 Rust 和不安全 Rust 之间存在不对称的信任关系。安全 Rust 本质上必须信任它接触的任何不安全 Rust 都已正确编写。另一方面,不安全 Rust 在没有小心的情况下不能信任安全 Rust。

例如,Rust 具有 PartialOrdOrd 特性来区分可以“仅仅”进行比较的类型,以及那些提供“总”排序的类型(这基本上意味着比较行为合理)。

BTreeMap 对于部分排序的类型来说并没有真正的意义,因此它要求其键实现 Ord。但是,BTreeMap 在其实现内部有不安全的 Rust 代码。因为对于一个马虎的 Ord 实现(这是安全编写的)来说,导致未定义行为是不可接受的,所以必须编写 BTreeMap 中的不安全代码,以使其对实际上不是总排序的 Ord 实现具有鲁棒性 — 即使这就是要求 Ord 的全部意义所在。

不安全 Rust 代码不能信任安全 Rust 代码被正确编写。也就是说,如果你输入没有总排序的值,BTreeMap 仍然会表现得完全不稳定。它只是永远不会导致未定义行为。

有人可能会想,如果 BTreeMap 不能信任 Ord 因为它是安全的,那么为什么它可以信任任何安全代码?例如,BTreeMap 依赖于整数和切片来正确实现。它们也是安全的,对吗?

区别在于范围。当 BTreeMap 依赖于整数和切片时,它依赖于一个非常具体的实现。这是一种衡量过的风险,可以与收益权衡。在这种情况下,风险基本上为零;如果整数和切片被破坏,每个人都会被破坏。此外,它们由维护 BTreeMap 的同一批人维护,因此很容易关注它们。

另一方面,BTreeMap 的键类型是通用的。信任它的 Ord 实现意味着信任过去、现在和未来的每个 Ord 实现。这里的风险很高:某人会在某个地方犯错并搞砸他们的 Ord 实现,甚至仅仅是谎称提供了总排序,因为“它看起来可以工作”。当这种情况发生时,BTreeMap 需要做好准备。

同样的逻辑也适用于信任传递给你的闭包的行为正确。

这种无界通用信任的问题是 unsafe 特性存在的原因。BTreeMap 类型理论上可以要求键实现一个名为 UnsafeOrd 的新特性,而不是 Ord,它可能如下所示

#![allow(unused)]
fn main() {
use std::cmp::Ordering;

unsafe trait UnsafeOrd {
    fn cmp(&self, other: &Self) -> Ordering;
}
}

然后,类型将使用 unsafe 来实现 UnsafeOrd,表明它们已确保其实现维护特性所期望的任何契约。在这种情况下,BTreeMap 内部的不安全 Rust 可以合理地信任键类型的 UnsafeOrd 实现是正确的。如果不是,那就是不安全特性实现的错误,这与 Rust 的安全保证是一致的。

是否将特性标记为 unsafe 的决定是一个 API 设计选择。安全的特性更容易实现,但任何依赖它的不安全代码都必须防御不正确的行为。将特性标记为 unsafe 会将此责任转移给实现者。Rust 传统上避免将特性标记为 unsafe,因为它会使不安全 Rust 普遍存在,这是不可取的。

SendSync 被标记为不安全,因为线程安全是一个基本属性,不安全代码不可能像防御有问题的 Ord 实现那样希望防御它。同样,GlobalAllocator 保留程序中所有内存的帐户,并且 BoxVec 等其他内容构建在其之上。如果它做了一些奇怪的事情(在仍在使用时将同一块内存提供给另一个请求),则没有机会检测到并采取任何措施。

是否将你自己的特性标记为 unsafe 的决定取决于同样的考虑。如果 unsafe 代码不能合理地期望防御特性的错误实现,那么将特性标记为 unsafe 是一个合理的选择。

顺便说一句,虽然 SendSyncunsafe 特性,但当可以证明这些推导是安全的时,它们会自动为类型实现。Send 会自动为所有仅由其类型也实现 Send 的值组成的类型派生。Sync 会自动为所有仅由其类型也实现 Sync 的值组成的类型派生。这最大限度地减少了使这两个特性 unsafe 的普遍不安全性。而且没有多少人会实现内存分配器(或者直接使用它们)。

这就是安全 Rust 和不安全 Rust 之间的平衡。这种分离旨在使使用安全 Rust 尽可能符合人体工程学,但在编写不安全 Rust 时需要额外的努力和注意。本书的其余部分主要讨论必须采取的注意事项,以及不安全 Rust 必须遵守的契约。