置顶说明:这一系列文章我学习The Rustonomicon时即兴翻译过来的,翻译中加了一些我自己的理解,所以如果要真正学习这部分内容,请一定要阅读原文。
翻译的过程中我大量参考了learnKu上的《Rust 高级编程》。在此我要谢谢他们的无私付出。
如果各位朋友在阅读本系列文章中发现错误,请一定不吝指教,也好让我有信心继续入门rust。
本来不打算翻译前两章的,但发现接下来学习还是很需要这两章的内容的,所以继续翻译玩好了。
初识安全与非安全代码
编程如果不用担心代码的底层实现细节,那感觉一定很棒。想象一下,谁愿意出于本心去关心空元组占用了多少空间呢?可悲的是这有时还是很重要的,我们的确需要关心它!开发人员开始关注实现细节的最常见原因是性能,但更重要的是,当直接与硬件、操作系统或其他语言交互时,这些细节可能是正确性的保证和依仗。
在一种安全的编程语言中,当底层实现细节开始变得重要时,程序员通常有三种选择:调试修改代码以期能让编译器/运行时执行某些优化效果
采用更繁琐或更不常规的设计来获得所需的实现效果
用一种可以能更好的处理这些细节的语言重写实现
对于最后一个选项,程序员倾向于使用C语言。对于只声明C接口的系统,这通常是必要的。
不幸的是,C语言使用起来非常不安全(有时采用这些不安全可能也是权衡后的结果),而且当尝试与另一种语言进行互操作时,这种不安全会被放大。C和与其交互的语言必须时刻小心地确认对方的行为,以防踩到舞伴的脚趾头。
那么这和Rust有什么关系呢?好吧,和C不同,Rust是一种安全的编程语言。但是和C一样,Rust也是一种非安全的编程语言。或者更精确的说,Rust语言同时包括了安全和非安全两种模型规范。
Rust可以看作是两种编程语言的组合:安全Rust和非安全Rust。省事儿一点儿,我们可以顾名思义:安全Rust是安全的。非安全Rust不是安全的。事实上,非安全Rust让我们有能力能做一些真正不安全的事情,就是那种Rust的作者会恳请你不要去做,但我们无论如何我们都会去做的事儿。
安全Rust是真正的Rust编程语言。如果你所做的只是用安全Rust编写代码,那么你将永远不必担心类型安全或内存安全。你将永远不会忍受悬空指针、释放后使用(use-after-free或者UAF)或任何其他类型的未定义行为的折磨。
标准库也为你提供了足够的开箱即用的实用工具,使你能够以纯粹的符合安全Rust语言规范的方式编写高性能应用程序和库。
但也许你想和另一种语言交互;也许你正在编写一个标准库没有公开的低级抽象;也许你正在编写标准库(它完全是用Rust编写的);也许你需要做一些类型系统不理解的事情,还需要直接操作一下该死的比特值...,那么也许你需要用到非安全Rust了。
非安全Rust和安全Rust完全一样,都有相同的规则和语义。只是它允许你做一些不安全的额外事情(我们将在下一节中定义这些不安全事件)。
Rust里这种安全非安全模式分离的价值在于:我们获得了使用像C这样的不安全语言的好处——对实现细节的底层控制——而没有试图将其与另一种完全不同的安全语言集成在一起所带来的大多数问题。
不过还是会遇到一些问题。最明显的是,我们必须了解类型系统预想的属性,并在任何与非安全Rust交互的代码中对它们进行审计。这就是这本书的目的:教你这些预定属性以及如何管理它们。
安全与非安全代码如何交互
安全Rust和非安全Rust之间有什么关系?它们又是如何交互的呢?
安全Rust和非安全Rust的分离是通过Unsafe关键字来控制的,这个关键字扮演着这两种语言之间接口(和边界)的角色。这就是为什么我们可以说安全Rust是一种安全的语言:所有不安全的部件都被完全保留在不安全的边界之内。如果你愿意,你甚至可以在代码文件的顶部引入#![forbid(unsafe_code)],以静态地保证你只能用安全Rust编写代码。
Unsafe关键字有两种用途:声明代码中存在编译器无法检查的安全约定,以及声明程序员已经检查过这些约定是否合规。
你可以使用关键字unsafe来表明函数或trait的声明中存在不受编译器检查的约定。在函数中,非安全意味着函数的用户必须检查函数的文档,以确保他们使用函数的方式能够符合函数所要求的安全约定。在trait声明上,非安全意味着trait的实现者必须检查trait文档,以确保他们的实现符合了该trait所要求的安全约定。
你可以在一段代码块上使用unsafe来声明所有在块内执行的不安全操作都经过了人工验证——这些操作符合相关的安全约定。例如,确认传递给slice::get_unchecked的索引值都没有越界。
你可以在一个trait实现上使用unsafe来声明这个trait实现的安全需要程序员来确认和担保的。例如,实现Send的类型表明这个类型从一个线程移动到另一个线程确实是安全的。(-译者注:Send这个标记trait的签名中有unsafe关键字,因此实现Send的类型在线程间传递的安全不是编译器担保的,是程序员(包括写标准库代码的程序员)通过自己的代码实现的。 -)
标准库有一些标记为非安全的函数,包括:slice::get_unchecked,它可以执行未经安全确认的索引操作,允许随意违反内存安全性。
mem::transmute,它将内存中的值重新解析成另一种类型,允许随意绕过类型安全机制的限制(详情参考类型转换)
所有有固定尺寸的类型的裸指针都有一个offset方法,如果给这个方法传递的偏移量越界时将导致未定义行为 (Undefined Behavior)。参见std中的指针offset方法
所有FFI(Foreign Function Interface)函数的调用都是unsafe的的,因为其他的语言可以做任意操作而Rust编译器无法检查它们。
在Rust 1.29.2中,标准库定义了以下非全trait(还有其他不安全trait,但它们还没有稳定下来,其中一些可能永远不会稳定下来):Send是一个标记trait(没有API的trait),它承诺它的实现类型可以安全地从一个线程发送(move)到另一个线程。
Sync是一个标记trait,它承诺它的实现类型可以通过共享引用在线程间安全共享。
GlobalAlloc允许自定义整个程序的内存分配器。
很多Rust标准库在内部也使用了非安全Rust。这些实现通常都经过严格的人工检查,因此构建在这些实现之上的安全Rust接口可以被认为是安全的。
这种安全和非安全代码需要分离开来的设计模式可以归结为安全Rus的一个基本特征:无论如何,安全Rust不会导致未定义行为。
安全/非安全分离的设计意味着安全Rust和非安全Rust之间存在不对等的信任关系。安全Rust本质上必须相信它接触到的任何非安全Rust都是正确书写的。另一方面,非安全Rust必须非常小心地信任安全Rust。
例如,Rust使用PartialOrd(部分排序)和Ord(全排序)trait来区分实现它们的类型哪些是“将将”可以进行比较(提供部分排序逻辑),哪些提供了“完整”的排序的逻辑(这基本上意味着这种比较行为更为合理)。
BTreeMap对实现部分排序的类型没有意义,所以它要求它的键实现Ord。然而,BTreeMap在它的实现中却有非安全Rust代码。因为即便用安全Rust写的实现如果逻辑上无法实现完全比较(而强行实现完全比较),这仍会导致未定义行为,这是不可接受的。所以在BTreeMap中,我们必须使用非安全Rust代码来对那些声称实现了全排序的键进行一定的防御编码,以保持我们代码的健壮性。
非安全Rust代码就是不相信安全Rust的代码会被正确地编写。也就是说,如果输入的值(逻辑上)没有一个完整的排序,BTreeMap的行为仍然会完全不规律。它只是永远不会导致未定义行为。
有人可能会问,如果BTreeMap不能信任Ord,因为它是安全的,那它为什么能信任其他任何安全代码呢?例如,BTreeMap依赖于正确实现的整数和切片。那些不也是安全Rust代码吗?
这里的不同之处在于范围。当BTreeMap依赖于整数和切片时,它依赖于一个非常特定的实现。这是一个可衡量的风险,可以衡量收益。在这种情况下,风险基本为零;如果整数和切片有问题了,那么每个Rust程序都有问题了。而且它们是是由维护BTreeMap的同一个(/组)人维护的,因此很容易对它们进行监控。
另一方面,BTreeMap的键类型是泛型。信任它的Ord实现意味着信任过去、现在和将来的每个Ord实现。这里的风险很高:某个地方的某人可能会犯一个错误,把他们的Ord实现搞得一团糟,或者甚至直接谎称提供了一个完整排序,因为“它似乎可以工作”。那在这种情况下,BTreeMap需要提前对此做好准备。
同样的逻辑也适用于如何去信任传递给你的闭包,以使程序能正确运行。
非安全trait的出现就是为了解决这一类不受限的泛型信任问题。BTreeMap类型理论上可以要求键实现一个名为UnsafeOrd的新trait,而不是Ord,它可能看起来像这样:
use std::cmp::Ordering;
unsafe trait UnsafeOrd {
fn cmp(&self, other: &Self) -> Ordering;
}
然后,某个类型将使用unsafe来实现这个UnsafeOrd,以此来这表明他们已经确保了他们的实现符合了trait所期望的约定。在这种情况下,BTreeMap内部的非安全Rust有理由信任这些键的类型的UnsafeOrd是正确的。如果还出现意外,(这不是UnsafeOrd这个trait的错,)这是这个非安全trait的实现的错误,这与Rust的安全保障机制不矛盾。
是否将一个trait标记为unsafe是一个API设计选择。Rust传统上避免这样做的,因为这会导致非安全Rust被过度被滥用,这是不可取的。Send和Sync被标记为不安全是因为线程安全是一个底层属性,非安全代码不太可能像防御错误的Ord实现那样有效防御线程安全问题。类似地,GlobalAllocator跟踪记录了程序用到所有内存地址,以及构建在它之上的其他的类似Box或Vec的东西。如果这个分配器做了一些奇怪的事情,比如:当一块内存还在使用时,它却将这同一块内存又分配给了另一个请求,(译者注:内存分配实质是操作系统来分配的,)出现这种情况,Rust完全没有机会检测它,或对它做任何事情。(所以将这类trait标记为unsafe是合理的,因为我们无能无力。)
是否把自己的trait标记为unsafe也取决于同样的考虑。如果非安全代码不能合理地期望能够防御住那些有问题的trait实现,那么将该trait标记为unsafe就是一个合理的选择。
顺便说一下,Send 和 Sync 是会被各种类型自动实现的,只要这种实现可以被证明是安全的。如果一种复合类型其所有的成员的类型都实现了Send,它本身就会自动实现Send;如果一种复合类型其所有的成员的类型都实现了Sync,它本身就会自动实现Sync。将它们标记为unsafe实际减少了非安全代码的滥用。另外GlobalAllocator之所以也标记为unsafe是考虑到没有多少人会去实现内存分配器(或者直接使用它们)。
安全 Rust 和非安全 Rust 各有所长。安全 Rust 被设计成尽可能地方便易用,而使用非安全 Rust 不仅要投入更多的精力,还要格外地小心。本书接下来的内容主要讨论那些需要小心的点,以及非安全 Rust 必须满足的规范。
这是安全Rust和非安全Rust之间的平衡。安全/非安全分离的设计使安全Rust尽可能符合人体工程学,但需要额外的努力和小心时,书写非安全Rust。这本书的其余部分主要是讨论必须采取的谨慎措施,以及非安全Rust必须维护的约定。
非安全 Rust 能做什么
你在非安全Rust中比安全Rust可以多做的事情只有以下几个:解引用裸指针
调用非安全函数(包括C语言函数,编译器内联函数,还有内存分配器等)
实现非安全trait
访问或修改可变静态变量
存取union的成员
就是这样。这些操作被归为不安全的原因是滥用这些东西将导致可怕的未定义行为。唤醒未定义行为将赋予编译器对程序进行任意破坏的绝对权利。你绝对不应该唤醒未定义行为!
与C语言不同,在Rust中未定义行为的类型范围非常有限。语言核心关心的未定义行为就是防止以下事情:解引用(使用*操作符)悬垂指针,或者未对齐指针(下章会介绍这个概念)
调用内部有错误的ABI调用的函数,或展开内部有错误的ABI展开的函数。
引起数据竞争
执行用当前执行线程不支持的目标特性编译出的代码
产生无效值(单独或作为复合类型的成员字段,如枚举/结构体/数组/元组):0和1以外的bool类型值
带有无效判别式的枚举
空的函数指针
落在[0x0,0xD&FF]和[0xE000, 0x10FFFF]以外的char类型值
!类型的值(这个类型的任何值都是无效非法的)
一个整数(i*/u*),浮点值(f*),或从未初始化的内存中读取的裸指针(参见uninitialized memory)。(译者注:nightly版的nomicon中还有‘or uninitialized memory in a str’这句,但我没理解这句是什么意思,就没加上)
悬垂的、未对齐的或指向无效值的引用或Box
拥有无效元数据(metadata)的胖指针(wide reference)、Box或裸指针如果dyn Trait的元数据不是指向与指针(或引用)所指向的实际动态trait匹配的虚函数表的指针,则该元数据无效
如果长度不是有效的usize,则切片元数据无效(即我们一定不能从未初始化的内存中读取数据)
非UTF-8编码的str(译者注:这条在nightly版本中已经去掉了)
组合类型的子成员如果有限定无无效值的类型(比如NonNull)时,子成员恰巧有了无效值,例如NonNull的子成员为null了。(请求自定义无效值是一个非稳定(unstable)的特性,但是一些稳定的标准库类型(如NonNull)会用到它。)
产生无效值的“产生”发生在赋值、传递给函数/原语操作或从函数/原语操作返回值时。(译者注:原语操作primitive operation可以理解为比汇编指令稍微高级一点,一般包括:赋值、调用方法、执行数学运算、数字比较、索引数组、从方法中返回值等)
如果一个引用/指针是空的,或者它所指向的字节不是同一个内存分配的一部分,那么这个引用/指针就是悬垂的。它所指向的字节范围由指针值和指针对象类型的尺寸决定。因此,如果跨度(span)为空,那么“悬空”与“非空”相同。注意,切片和字符串这类的胖指针的原数据包含指针指向的整个范围(range),因此重要的是它们的长度元数据不能太大(特别是内存分配时,切片和字符串的实际内存宽度不能大于isize::MAX个字节)。如果出于某种原因,它们的长度元数据必须很大,这时可以考虑使用裸指针。
好了,这就是导致Rust语言中未定义行为的所有原因。当然,非安全的函数和trait可以自由声明任意的其他安全约束,程序必须维护这些约束条件来避免未定义行为。例如,分配器APIs声明释放未分配内存是未定义行为。
然而,违反这些约束条件通常也只是间接地触发上面列出的行为。另外也有一些额外的约束条件可能来自编译器内联函数(compiler intrinsics),这些内部函数对如何优化代码做出了特殊的假设。例如,Vec和Box使用了要求它们的指针在任何时候都是非空的内在函数
除此之外,Rust对于其他可疑的操作是相当宽容的。Rust认为它是“安全”的:死锁
竞争条件
内存泄漏
调用析构函数失败
整型值溢出
终止程序
删除生产数据库
当然,有以上任何行为的程序极有可能就是错误的。Rust提供了许多工具来减少这种事情的发生,但是完全地杜绝它们其实也是不现实的。
编写非安全代码
和非安全Rust进行交互,Rust通常只会提供一些工具,让我们通过这些工具,再配合作用域限制,去直面最底层的内存二进制数据。这说起来容易,现实比这要复杂得多。例如,考虑以下这个有些玩具性质的函数:
fn index(idx: usize, arr: &[u8]) -> Option {
if idx < arr.len() {
unsafe {
Some(*arr.get_unchecked(idx))
}
} else {
None
}
}
此函数安全正确。我们先检查索引是否在边界内,如果是,则以未经检查(unchecked)的方式直接使用该索引去数组中检索数据。我们说这样一个正确的非安全实现的函数是正确的,这意味着其他安全代码不能通过调用上面这个函数引起未定义的行为(记住,这是安全Rust的唯一基本属性)。
但即使在这样一个微不足道的函数中,不安全块的作用域也是值得怀疑的。比如考虑将
fn index(idx: usize, arr: &[u8]) -> Option {
if idx <= arr.len() {
unsafe {
Some(*arr.get_unchecked(idx))
}
} else {
None
}
}
这段程序现在就不可靠了,安全Rust可以引起未定义行为啦,仅仅因为我们修改了一点安全代码呀。这就是安全机制的本质问题:非本地性。意思是,我们非安全操作(unsafe标记的代码块里的操作)的可靠性必然依赖于其他“安全”操作所建立的状态。
是否进入非安全代码块,并不受其他部分代码正确性的影响,从这个角度看安全机制是模块化的。比如,是否对一个切片执行未经检查的索引,不受切片是不是null或者是不是包含未初始化的内存这些事情的影响。但是,由于程序本身是有状态的,非安全操作的结果实际依赖于其他部分的状态,从这个角度看安全机制不是模块化的。
在处理持久化状态时,非本地性带来的问题就更加明显了。看一下Vec的一个简单实现:
use std::ptr;
// 注意: 这个定义过于简陋,后面实现Vec章节会谈到
pub struct Vec {
ptr: *mut T,
len: usize,
cap: usize,
}
// 注意这种实现方式没有正确处理零尺寸类型(ZST),后面实现Vec章节会谈到
impl Vec {
pub fn push(&mut self, elem: T) {
if self.len == self.cap {
// 省略一些对本示例没用的代码
self.reallocate();
}
unsafe {
ptr::write(self.ptr.offset(self.len as isize), elem);
self.len += 1;
}
}
}
这个代码写得足够简单,以便于审查和修改。现在考虑添加以下方法:
fn make_room(&mut self) {
// 增加Vec的容量
self.cap += 1;
}
这段代码是100%的安全Rust写得,但也是彻底的不可靠的。改变容量违反了Vec的不变性(cap反映分配给Vec的内存空间的大小)。Vec的其他部分并没有也没有好的办法来增加对cap保护机制。那我们只能信任它的值是正确的,因为无法验证它。
因为代码逻辑依赖于结构体的某个成员的不变性,上面这段不安全的代码不仅仅污染了整个函数,它还污染了整个模块(module)。一般来说,限制不安全代码范围的唯一有效的防御手段是在把它放在一个私有模块内部。
不过上面这个函数其实是可以完美工作的。make_room的存在对Vec的可靠性不是问题,因为我们没有设置它为public。那只有定义这个函数的模块可以调用它。另外,make_room直接访问了Vec的私有成员,所以它也只能在Vec所在的模块内使用。
因此我们可以依赖复杂的不变性编写一个完全安全的抽象。这对于安全Rust和非安全Rust之间的关系至关重要。
我们已经了解了非安全代码必须信任一些安全代码,但是不应该信任所有的安全代码。出于相似的原因,私有属性的限制对于非安全代码很重要,原因跟前面的差不多:它防止我们必须信任世界上所有的安全代码,以免它搞乱我们的可信状态。
安全机制万岁!