安全与不安全代码如何交互
安全 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将某个值重新解释为给定类型,从而以任意方式绕过类型安全(详见类型转换)。- 每个指向 sized 类型的裸指针都有一个
offset方法,如果传递的偏移量不在 “边界内”,则会导致未定义行为。 - 所有 FFI(外部函数接口)函数调用都是
unsafe的,因为其他语言可以执行 Rust 编译器无法检查的任意操作。
截至 Rust 1.29.2,标准库定义了以下不安全 trait(还有其他一些,但尚未稳定,其中一些可能永远不会稳定):
Send是一个标记 trait(一个没有 API 的 trait),它承诺实现者可以安全地发送(移动)到另一个线程。Sync是一个标记 trait,它承诺线程可以通过共享引用安全地共享实现者。GlobalAlloc允许自定义整个程序的内存分配器。
Rust 标准库的大部分内部也使用了不安全 Rust。这些实现通常经过了严格的手动检查,因此构建在这些实现之上的安全 Rust 接口可以认为是安全的。
所有这些分隔的需求归结为安全 Rust 的一个基本属性:健全性(soundness)
无论如何,安全 Rust 都不会导致未定义行为。
安全/不安全代码分离的设计意味着安全 Rust 和不安全 Rust 之间存在不对称的信任关系。安全 Rust 本质上必须信任它所接触的任何不安全 Rust 都已经正确编写。另一方面,不安全 Rust 在不小心的情况下不能信任安全 Rust。
举例来说,Rust 有 PartialOrd 和 Ord trait,用于区分“只”能比较的类型和提供“全序”的类型(这基本上意味着比较行为合理)。
BTreeMap 对于偏序类型来说意义不大,因此它要求其键类型实现 Ord。然而,BTreeMap 的内部实现包含不安全 Rust 代码。由于一个草率的 Ord 实现(编写它是安全的)导致未定义行为是不可接受的,所以 BTreeMap 中的不安全代码必须编写得足够健壮,能够应对实际上并非全序的 Ord 实现——尽管要求 Ord 的全部意义就在于此。
不安全 Rust 代码不能仅仅信任安全 Rust 代码是正确编写的。话说回来,如果你输入的键值没有全序,BTreeMap 的行为仍然会完全不稳定。它只是不会导致未定义行为。
有人可能会问,如果 BTreeMap 不能信任 Ord,因为它属于安全代码,那它为什么可以信任任何安全代码呢?例如,BTreeMap 依赖于整数和切片得到正确实现。它们也是安全的,对吧?
区别在于范围。当 BTreeMap 依赖整数和切片时,它依赖的是一个非常具体的实现。这是一个可衡量、可权衡利弊的风险。在这种情况下,风险基本上为零;如果整数和切片坏了,每个人的代码都会坏。此外,它们由维护 BTreeMap 的同一个人维护,所以很容易跟踪它们的变动。
另一方面,BTreeMap 的键类型是泛型的。信任其 Ord 实现意味着信任过去、现在和将来每一个 Ord 的实现。这里的风险很高:总会有人在某个地方犯错,搞砸他们的 Ord 实现,或者甚至只是直截了当地谎称提供了全序,因为“看起来好像能用”。当这种情况发生时,BTreeMap 需要有所准备。
同样的逻辑也适用于信任传递给你的闭包能够正确运行。
这种不受约束的泛型信任问题正是 unsafe trait 存在的原因。理论上,BTreeMap 类型可以要求键类型实现一个名为 UnsafeOrd 的新 trait,而不是 Ord,它可能看起来像这样:
#![allow(unused)] fn main() { use std::cmp::Ordering; unsafe trait UnsafeOrd { fn cmp(&self, other: &Self) -> Ordering; } }
然后,一个类型会使用 unsafe 来实现 UnsafeOrd,表明他们已经确保其实现遵守了 trait 所期望的任何契约。在这种情况下,BTreeMap 内部的不安全 Rust 代码就有理由信任键类型的 UnsafeOrd 实现是正确的。如果它不正确,那是由于不安全 trait 实现的错误造成的,这与 Rust 的安全保证是一致的。
是否将 trait 标记为 unsafe 是一个 API 设计选择。一个安全的 trait 更容易实现,但任何依赖它的不安全代码都必须防范不正确的行为。将 trait 标记为 unsafe 则将此责任转移给了实现者。Rust 传统上避免将 trait 标记为 unsafe,因为它会使不安全 Rust 蔓延开来,这是不可取的。
Send 和 Sync 被标记为 unsafe 是因为线程安全是一个基本属性,不安全代码无法像防范有 bug 的 Ord 实现那样来防范它。类似地,GlobalAllocator 负责管理程序中的所有内存,而 Box 或 Vec 等类型都构建在它之上。如果它做了奇怪的事情(比如在内存仍在被使用时将同一块内存分配给另一个请求),则没有机会检测到这一点并采取任何措施。
决定是否将自己的 trait 标记为 unsafe 取决于同样的考量。如果 unsafe 代码无法合理地预期能够防范 trait 的错误实现,那么将该 trait 标记为 unsafe 就是一个合理的选择。
另外,虽然 Send 和 Sync 是 unsafe trait,但对于那些可以证明派生是安全的类型,它们也会被自动实现。Send 会自动为仅由实现了 Send 的类型的值组成的类型派生。Sync 会自动为仅由实现了 Sync 的类型的值组成的类型派生。这最大程度地减少了将这两个 trait 标记为 unsafe 所带来的普遍不安全性。而且,实际上并没有多少人会实现内存分配器(或者直接使用它们)。
这就是安全 Rust 和不安全 Rust 之间的平衡。这种分离旨在让使用安全 Rust 尽可能符合人体工程学(易用),但在编写不安全 Rust 时需要付出额外的努力和细心。本书的其余部分主要是讨论必须采取的注意事项,以及不安全 Rust 必须遵守的契约。