安全与非安全 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 有 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
特性存在要解决的问题。 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 无处不在,这是不可取的。
Send
和 Sync
被标记为不安全,因为线程安全是一个基本属性,不安全代码不可能像防御错误的 Ord
实现那样防御它。类似地,GlobalAllocator
负责记录程序中的所有内存以及其他类似 Box
或 Vec
构建在其之上的内容。 如果它做了一些奇怪的事情(在仍在使用时将同一块内存提供给另一个请求),则没有机会检测到并对其进行任何处理。
是否将您自己的特性标记为 unsafe
的决定取决于相同的考虑因素。 如果 unsafe
代码无法合理地预期防御特性的损坏实现,则将特性标记为 unsafe
是一个合理的选择。
顺便说一句,虽然 Send
和 Sync
是 unsafe
特性,但当此类派生被证明是安全的时,它们也会自动为类型实现。 Send
会自动为仅由其类型也实现 Send
的值组成的所有类型派生。 Sync
会自动为仅由其类型也实现 Sync
的值组成的所有类型派生。 这最大限度地减少了使这两个特性成为 unsafe
的普遍不安全性。 而且没有多少人会去实现内存分配器(或者直接使用它们)。
这是安全 Rust 和不安全 Rust 之间的平衡。 这种分离旨在使使用安全 Rust 尽可能符合人体工程学,但在编写不安全 Rust 时需要额外的努力和谨慎。 本书的其余部分主要是对必须采取的谨慎措施以及不安全 Rust 必须维护的契约的讨论。