02-安全与不安全
10.1 安全与不安全
“你可以这么做,但是最好知道自己在做什么。”
——A Rustacean
当我们谈论编程语言安全性时,它是一个涵盖不同层次的属性。语言可以是内存安全、类型安全的,也可以是并发安全的。内存安全意味着程序不会写入禁用的内存地址,也不会访问无效的内存;类型安全意味着程序不允许用户为字符串变量分配数字,并且此检查会在编译期发生;并发安全意味着程序在执行多个线程时不会因为条件竞争而修改共享状态。如果一种语言自身提供所有这些层面的安全,那么它被认为是安全的。一般而言,如果在所有可能的程序执行和输入中,它能够提供正确的输出并且不会导致程序崩溃,同时不破坏其内部或外部的状态,那么该程序被认为是安全的。Rust在安全模式下,的确如此。
不安全的程序是指在运行时破坏不变量或者触发未定义行为的程序。这些不安全的效果可能会在函数内部产生局部影响,也可能稍后作为程序的全局状态传播。其中一些是由程序员自身造成的,例如逻辑错误,而其中一些是由于采用的编译器产生的副作用造成的,还有一些是由语言规范本身造成的。不变量是在所有代码路径中执行程序期间必须始终为真的条件。最简单的例子是指向堆中某个对象的指针,它在某段代码中永远不应该为空。如果该不变量被破坏,那么依赖于该指针的代码可能会取消引用它并发生崩溃。诸如C/C++这类语言和基于它们的语言都是不安全的,因为在编译器规范中有很多操作被归类为未定义的行为。未定义的行为是指当编译器规范没有指定在较低级别上发生了什么,而你可以随意假设任何事情都可能发生时程序中出现的情况。未定义行为的一个示例是使用未初始化的变量。请考虑如下C代码:
// both_true_false.c
int main(void) {
bool var;
if (var) {
fputs("var is true!\n");
}
if (!var) {
fputs("var is false!\n");
}
return 0;
}
此程序的输出可能会因C编译器的设置而不同,因为使用未初始化的变量是未定义行为。在一些启用了优化的C编译器上,你可能会得到以下输出结果:
var is true
var is false
代码采用这样不可预测的执行路径是用户不希望在生产环境下看到的。C语言中未定义行为的另一个例子是写入长度为n的数组之外。当写入在内存中发生n+1偏移时,程序可能会崩溃或修改任意内存地址。在最理想的情况下,程序会立即崩溃,你应该知道这一点。在最糟糕的情况下,程序将继续运行,但是会破坏代码的其他部分并给出错误的结果。C语言中存在未定义行为的首要原因是允许编译器优化代码以提高性能,并假定某些特殊情况永远都不会发生,同时不为上述情况添加错误检查代码,从而避免带来与错误处理有关的开销。如果未定义行为可以转换为编译期错误,那将是件好事,但是在编译期检测其中的某些行为有时会变得非常占用资源,因此不这么做是为了让编译器实现更简单一些。
现在,当Rust必须与这些语言进行交互时,它几乎不了解函数调用,以及如何在这些语言的较低层面表示类型,并且因为未定义行为可能发生的位置是未知的,所以它会回避所有这些问题,而为我们提供一个特殊的unsafe代码块,用来与其他语言交互。在不安全模式下,你可以获得额外的一些功能,这些功能在C/C++中被称为未定义的行为。不过“能力越大,责任也越大”。在代码中使用不安全代码的开发人员必须留意在unsafe代码块中执行的操作。当Rust处于不安全模式时,重担就落在了开发者身上。Rust相信程序员能够确保相关的操作是安全的。
幸运的是,这种不安全特性是以一种非常受控的方式提供的,并且通过读取代码可以轻松识别,因为不安全的代码总是以关键字unsafe进行修饰或者以unsafe代码块的形式出现。这与C语言大不相同,因为其中很多内容都是不安全的。
现在需要重点强调一点,虽然Rust提供的保护能够让你的程序免受一些主要的不安全因素的影响,但是在某些情况下,即使你编写的程序是安全的Rust也爱莫能助,例如程序中出现逻辑错误的情况,如下。
- 程序使用浮点数表示货币,但是浮点数不够精确会导致舍入误差。这个错误在某种程度上可以预测(因为在相同的输入数据下,它总是以相同的方式表现出来),并且很容易修复。这是一个逻辑和实现错误,Rust不提供此类错误的保护。
- 一个控制宇宙飞船的程序使用基元数字作为参数来计算距离指标。但是某个库可能会提供一个API,其中距离的单位为公制单位,而用户可能使用英制单位并提供数字,从而导致无效的测量。1999年,美国国家航空航天局发射的火星气候轨道探测者就发生了类似的错误,造成了近1.25亿美元的损失。Rust不能确保用户免受此类错误的影响,但是借助类型系统抽象(例如枚举)和newtype模式,我们将不同的计量单位彼此隔离,并将API的外部限制为仅支持有效的操作,从而大大降低发生这类错误的可能性。
- 程序在没有采用适当锁定机制的情况下通过多线程写入共享数据。其错误表现为不可预测,并且发现它们可能非常困难,因为它是非确定性的。在这种情况下,Rust通过其所有权规则和借用规则来确保用户避免遇到数据竞争问题,这些规则也适用于并发代码,但它无法为你检测死锁。
- 程序通过指针访问对象,在某些情况下,指针是空指针,这将导致程序崩溃。在安全模式下,Rust能够确保用户免受空指针的影响。但是。在不安全模式下,程序员必须确保使用来自其他语言的指针执行的相关操作是安全的。
Rust的不安全模式也会在如下情况中用到,当程序员比编译器更了解某些细节,并且他们的代码中出现一些比较棘手的问题时,因为编译期的所有权规则过于苛刻,从而带来一些障碍。例如,假定你需要将一个字节序列转换为String值,并且你已经知道Vec
- 更新可变静态变量。
- 解引用原始指针,例如const T和 mut T。
- 调用不安全的函数。
- 从联合类型中读取值。
- 在extern代码块中调用某个声明的函数——该元素来自其他语言。
在上述情况下,某些内存安全规则已经放宽,但借用检查程序在这些操作中仍然处于激活状态,并且所有作用域和所有权规则仍然适用。关于Rust的官方文档区分了哪些是未定义的行为,哪些是不安全的行为。为了执行上述操作时轻松地区分这一点,Rust要求用户使用关键字unsafe。它只允许少数几个地方用unsafe关键字进行标记,如下所示。
- 函数和方法。
- 不安全的代码块表达式,例如unsafe{}。
- 特征。
- 实现代码块。