安全与非安全代码的交互方式

安全与非安全代码之间的关系是什么?它们又如何交互呢?

安全与非安全代码是靠unsafe关键字分离的,它扮演着两种语言之间接口的角色。这也是我们理直气壮地声称安全Rust是安全的原因:所有的非安全代码都被unsafe隔离在外。只要你愿意,你甚至可以在代码根部添加#![forbid(unsafe_code)]以保证你只会写安全的代码。

unsafe关键字有两层含义:声明代码中存在编译器无法检查的安全规范,同时声明开发者会自觉遵守相关规范而不会主动破坏它。

你可以使用关键字unsafe表明函数和trait的声明中存在不受编译器检查的规范。对于函数,unsafe意味着调用函数的开发者必须查阅函数的文档以确保他们的用法符合函数的安全要求。而对于trait的声明,unsafe意味着实现trait的开发者必须查阅trait的文档以确保trait的实现符合其安全要求。

你可以给一个代码块添加unsafe关键字,声明块中的所有代码都已经人工检查过符合相关规范。比如,传递给slice::get_unchecked的索引值都没有越界。

你也可以在实现一个trait时使用unsafe关键字,声明实现符合trait的安全规范。比如,实现Send的类型可以绝对安全地转移(move)进另一个线程中。

标准库也有一些非安全函数,包括:

  • slice::get_unchecked,可接受不受检查的索引值,也就是存在内存安全机制被破坏的可能
  • mem::transmute,将值重新解析成另一种类型,即允许随意绕过类型安全机制的限制。
  • 所有指向确定大小类型(sized type)的裸指针都有offset方法,当传入的偏移量越界时将导致未定义行为(Undefined Behavior)。
  • 所有FFI(Foreign Function Interface)函数都是unsafe的,因为其他的语言可以做各种的操作而Rust编译器无法检查它。

在Rust 1.0中,有两个非安全的trait:

  • Send是一个标志trait(即没有任何方法的trait),承诺所有的实现都可以安全地发送(move)到另一个线程。
  • Sync也是一个标志trait,承诺线程可以通过共享的引用共享它的实现。

许多Rust标准库其实内部也使用了非安全Rust。这些库的实现方法都经过了严苛的人工检查,所以这些基于非安全Rust实现的安全Rust接口依然可以认为是安全的。

这种代码 隔离的存在说明了安全Rust的一个基本特征:

无论如何,安全Rust代码都不能导致未定义行为

可以看出,安全和非安全的Rust之间存在一种不对称的信任关系。安全Rust必须无条件信任非安全Rust,假定所有与之打交道的非安全代码都是正确的。反过来,非安全Rust却要谨慎对待安全Rust的代码。

举个例子,Rust有PartialOrdOrd两个trait,区别在于前者仅仅表示可以被比较的类型,而后者则表示实现了完整顺序(total ordering)的类型(也就是比较的机制更符合直觉)。

BTreeMap只有在键是完整顺序时才能正常工作,所以它要求它的键必须实现Ord。但是,BTreeMap的内部实现却依赖于非安全Rust代码。因为如果Ord的实现本身是错误的(尽管代码是安全的)将导致未定义行为,所以BTreeMap内部的非安全代码必须对那些实际上没有做到完整顺序的Ord保持足够的鲁棒性——虽然完整顺序本身是我们选择Ord的唯一理由。

非安全Rust不能简单地信任安全Rust都是正确的。也就是说,如果你传入到BTreeMap的值不具备完整顺序,BTreeMap的行为将会完全混乱。它仅仅能保证不会产生未定义行为罢了。

有人或许会问,如果BTreeMap不能因为Ord是安全的就信任它,那为什么BTreeMap可以信任其他的安全代码?比如,BTreeMap依赖integer和slice的正确实现。那些不也是安全的代码吗?

区别之一是范围。当BTreeMap依赖于integer和slice时,它是依赖于某种特定的实现,其收益和风险是可以评估的。依赖integer和slice的风险其实几乎为0,因为如果连它们都是错误的话,那么所有的代码都不可能正确了。而且,它们和BTreeMap是由相同的开发者维护的,也比较容易配合。

而反过来,BTreeMap的键类型是一个范型。信任它意味着要信任过去、现在和未来的所有的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的内部就可以合理地信任键的类型对于UnsafeOrd的实现是正确的。如果真的出错了,这个锅将由实现非安全trait的开发者来背,与Rust自身的安全机制并不冲突。

一个trait是否应该标志为unsafe是API设计上的选择。Rust通常会尽量避免这么做,因为它会导致非安全Rust的滥用,这并不是设计者们希望看到的。SendSync被标识为非安全是因为线程安全性是一个底层特性,非安全代码不太可能有效地检查它,并不像检查Ord的错误实现那样容易。你也可以根据类似的标准判断是否要把你自己的trait标为unsafe。如果让安全代码去检查trait实现的正确性不太现实,那么把trait标为unsafe就是合理的。

顺便说一下,SendSync是会被各种类型自动实现的,只要这种实现可以被证明是安全的。如果一种类型其所有的值的类型都实现了Send,它本身就会自动实现Send;如果一种类型其所有的值的类型都实现了Sync,它本身就会自动实现Sync。将它们设为unsafe实际减少了非安全代码的滥用。

安全Rust和非安全Rust各有所长。安全Rust被设计成尽可能地方便易用,而使用非安全Rust不仅要投入更多的精力,还要格外地小心。本书接下来的内容主要讨论那些需要小心的点,以及非安全Rust必须满足的规范。

非安全Rust比安全Rust可以多做的事情只有以下几个:解引用裸指针调用非安全函数(包括C语言函数,编译器内联函数,还有直接内存分配等)实现非安全trait访问或修改可变静态变量就这些。这些操作被归为非安全 ...