安全与非安全 Rust 的交互

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

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

unsafe 关键字有两个用途:声明编译器无法检查的契约的存在,以及声明程序员已经检查了这些契约是否得到维护。

你可以使用 unsafe 来指示在*函数*和* Trait 声明*上存在未经检查的契约。在函数上,unsafe 意味着函数的用户必须检查函数的文档,以确保他们使用函数的方式维护了函数所需的契约。在 Trait 声明上,unsafe 意味着 Trait 的实现者必须检查 Trait 文档,以确保他们的实现维护了 Trait 所需的契约。

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

你可以在 Trait 实现上使用 unsafe 来声明该实现维护了 Trait 的契约。例如,实现 Send 的类型确实可以安全地移动到另一个线程。

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

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

截至 Rust 1.29.2,标准库定义了以下非安全 Trait(还有其他 Trait,但它们尚未稳定,其中一些可能永远不会稳定)

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

Rust 标准库的大部分内部也使用非安全 Rust。这些实现通常都经过了严格的人工检查,因此可以假设构建在这些实现之上的安全 Rust 接口是安全的。

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

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

安全/非安全拆分的的设计意味着安全 Rust 和非安全 Rust 之间存在不对称的信任关系。安全 Rust 本质上必须相信它接触的任何非安全 Rust 都是正确编写的。另一方面,非安全 Rust 不能轻易地信任安全 Rust。

例如,Rust 有 PartialOrdOrd Trait 来区分可以“仅仅”进行比较的类型和提供“完全”排序的类型(这基本上意味着比较行为是合理的)。

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 必须维护的契约的讨论。