写点什么

Rust 从 0 到 1- 高级特性 - 不安全的 Rust

用户头像
关注
发布于: 11 小时前
Rust从0到1-高级特性-不安全的Rust

我们已经介绍了 Rust 语言中最常用的内容。下面让我们聊聊一些总有一天我们会遇上的一些场景。虽然很少会碰到,但这在一些特定的场景下很有用处。本章将涉及如下内容:

  • 不安全的 Rust:用于选择放弃 Rust 的某些保证并通过自己编写代码确保

  • 高级 traits:关联类型,默认类型参数,完全限定语法,父 trait 和 newtype 模式

  • 高级类型:newtype 模式的更多介绍,类型别名,never 类型和动态大小类型

  • 高级函数和闭包:函数指针和返回闭包

  • 宏:通过写代码的方式在编译时生成更多代码

下面我们首先介绍不安全的 Rust。我们之前讨论过的代码 Rust 都会在编译时强制保证其内存安全性。然而,Rust 中还隐藏有第二种语言,它不会强制保证内存安全性:即“不安全的 Rust”(unsafe Rust)。它与常规 Rust 代码看上去没有什么不同,但是会提供额外的强大力量。

之所以会有不安全的 Rust,是因为静态分析本质上是保守的。当编译器无法确定一段代码是否安全时,最好是拒绝可能实际上是安全的程序。也就是有时代码实际上可能是安全的,但是 Rust 没有足够的信息确认,那么就会拒绝编译通过!这时候,我们可以使用不安全代码告诉编译器,“相信我,我知道我在做什么。”这么做的缺点是我们需要自己承担相应的风险:如果不安全代码使用的不正确,会导致内存不安全,譬如解引用空指针。

另一方面的原因是计算机底层硬件固有的不安全性。如果 Rust 不允许进行不安全操作,那么有些任务则无法完成。Rust 需要能够进行底层的系统编程,譬如直接与操作系统交互,甚至编写我们自己的操作系统!能够进行底层的系统编程是 Rust 语言的目标之一。下面让我们看看利用不安全的 Rust 我们能做什么,以及如何做。

不安全的 Rust 的强大力量

可以通过关键字 unsafe 切换为不安全的 Rust,创建一个新的存放不安全代码的块。有五种可以在不安全的 Rust 中执行的操作,它们称之为 “不安全的超级能力” :

  • 解引用裸指针

  • 调用不安全的函数或方法

  • 访问或修改可变静态变量

  • 实现不安全的 trait

  • 访问 union 的字段

需要注意的是 unsafe 并不会关闭借用检查或任何其它 Rust 所做的安全检查:也就是说如果在不安全代码中使用引用,它仍会被检查。unsafe 关键字只是提供了以上五类不会被编译器检查内存安全的功能。我们在不安全块中任然会获得一定程度降级的安全。

另外,unsafe 并不代表代码一定是危险的或者必然有内存安全问题:其本意是,作为程序员的你将会确保 unsafe 块中的代码会以正确的方式访问内存。

人难免会犯错,因此错误总会发生,但是通过于要求这五类操作必须位于 unsafe 块中,我们将知道任何与内存安全相关的错误必定位于 unsafe 块中。保持 unsafe 块尽可能小,在之后查找内存相关的 bug 时就会庆幸这么做了。

为了尽可能隔离不安全代码,最好是对不安全代码进行抽象和封装,并提供安全的 API,在后面我们讨论不安全函数和方法时会详细介绍。标准库的一部分也是在不安全代码之上实现的安全抽象。将不安全代码通过安全抽象进行封装防止了对 unsafe 使用的泄露,我们只需要使用不安全代码的安全抽象,因为它是安全的。

下面让我们依次介绍上面五类超级能力。并且我们还会介绍通过抽象为不安全代码提供安全的接口。

解引用裸指针

前面我们介绍过 “悬空引用” ,并提到编译器会确保引用总是有效的。不安全的 Rust 有两种类似于引用新的类型,被称为裸指针(raw pointers,或称作原始指针)。和引用一样,裸指针可以是可变的或不可变的,分别写作 *const T 和 *mut T。其中星号并不是解引用运算符,它是类型名称的一部分。在裸指针中,不可变代表指针解引用之后不能直接赋值。

裸指针与引用和智能指针的区别在于:

  • 允许忽略借用规则,可以同时拥有不可变和可变的指针或多个可变指针

  • 不保证指向的内存的有效性

  • 允许为 null

  • 未实现任何自动清理功能

通过放弃以上 Rust 的强制保证,我们舍弃了安全保证以换取更好的性能或使用另一个语言或硬件接口的能力。

下面的例子展示了如何同时创建不可变和可变裸指针:

let mut num = 5;
let r1 = &num as *const i32;let r2 = &mut num as *mut i32;
复制代码

注意在上面的例子中我们并没有使用关键字 unsafe。可以在代码中创建裸指针,但是不能在不安全块之外解引用裸指针。我们通过关键字 as 将不可变和可变引用强制转换为裸指针类型。因为我们是直接从保证安全的引用来创建的,所以知道这些裸指针是有效;但是我们不能对任何裸指针做此假设。

下面我们会创建一个不能确定其有效性的裸指针,参考下面的例子:

let address = 0x012345usize;let r = address as *const i32;
复制代码

上面的例子中,我们展示了如何创建一个指向任意内存地址的裸指针。尝试使用任意内存的行为是不确定的:目标地址可能有数据也可能没有,编译器可能会进行优化从而导致内存地址不存在,或者程序可能会出现存储器区块错误(segmentation fault)。通常没有什么理由这样编写代码,不过确实可以这么写。

下面我们裸指针使用解引用运算符 * 进行解引用,参考下面的例子:

let mut num = 5;
let r1 = &num as *const i32;let r2 = &mut num as *mut i32;
unsafe { println!("r1 is: {}", *r1); println!("r2 is: {}", *r2);}
复制代码

创建一个指针不会造成任何危害;只有当我们尝试访问其指向的值时才有可能最终遇到无效的值。

并且我们在上面的例子中同时创建了指向相同内存位置 num 的裸指针 *const i32 和 *mut i32。如果我们尝试同时创建 num 的不可变和可变引用将无法编译通过,因为这违反了 Rust 的所有权规则,即使是在 unsafe 中。通过裸指针,我们能够同时创建指向同一地址的可变指针和不可变指针,因此如果通过可变指针修改数据,则可能会造成数据竞争问题。小心!

既然有危险,为什么我们还要使用裸指针呢?一个主要的应用场景便是调用 C 代码,下面我们将会讲到;另一个场景是构建借用检查器无法理解的安全抽象,下面我们将会介绍不安全函数,并看看使用不安全代码的安全抽象的例子。

调用不安全函数或方法

第二类需要使用不安全代码块的操作是调用不安全函数。不安全函数和方法与常规函数和方法看上去十分类似,除了其在最开始使用了关键字 unsafe 声明为不安全的。在这里,关键字 unsafe 代表在我们调用该函数时需要满足其要求,因为 Rust 无法保证我们已经满足了其要求。如果我们在 unsafe 块中调用不安全函数,则表明我们已经阅读过此函数的文档并对满足函数的要求负责。下面是对一个没有做任何操作的不安全函数调用的例子:

unsafe fn dangerous() {}
unsafe { dangerous();}
复制代码

我们必须在一个 unsafe 块中调用 dangerous 函数。否则我们将得到类似下面的编译时错误:

$ cargo run   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)error[E0133]: call to unsafe function is unsafe and requires unsafe function or block --> src/main.rs:4:5  |4 |     dangerous();  |     ^^^^^^^^^^^ call to unsafe function  |  = note: consult the function's documentation for information on how to avoid undefined behavior
error: aborting due to previous error
For more information about this error, try `rustc --explain E0133`.error: could not compile `unsafe-example`
To learn more, run the command again with --verbose.
复制代码

我们在 unsafe 块中调用 dangerous 函数,代表我们向 Rust 保证了我们已经阅读过函数的文档,知道如何正确使用,并验证过我们的代码满足函数的所有要求和约定。

不安全函数的函数体也是有效的 unsafe 块,所以在不安全函数中进行不安全操作时无需新增额外的 unsafe 块。

创建不安全代码的安全抽象

函数包含不安全代码并不意味着需要把整个函数标记为不安全的。事实上,将不安全代码封装进安全函数中是一种常见的抽象。以标准库中的函数 split_at_mut 作为例子,让我们看看如何实现。这个安全的方法定义于可变切片中:它将一个切片根据指定的索引位置,将其分为两个切片。其用法参考下面的例子:

let mut v = vec![1, 2, 3, 4, 5, 6];
let r = &mut v[..];
let (a, b) = r.split_at_mut(3);
assert_eq!(a, &mut [1, 2, 3]);assert_eq!(b, &mut [4, 5, 6]);
复制代码

这个函数无法只通过安全 Rust 实现。只使用安全 Rust 的实现类似下面的例子(它无法编译通过,并且出于简单考虑,我们将其实现为函数,并只处理 i32 类型而非泛型):

fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {    let len = slice.len();
assert!(mid <= len);
(&mut slice[..mid], &mut slice[mid..])}
复制代码

在上面的例子中,我们首先获得切片的长度,然后通过检查参数是否小于等于切片长度来断言参数是否越界。如果传入的索引比切片的长度更大,此函数将会发生 panic。然后,我们在一个元组中返回两个可变的切片:一个从切片的开头到指定的索引位置,另一个从指定的索引位置到切片的结尾。如果尝试编译,会得到类似下面的错误:

$ cargo run   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)error[E0499]: cannot borrow `*slice` as mutable more than once at a time --> src/main.rs:6:30  |1 | fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {  |                        - let's call the lifetime of this reference `'1`...6 |     (&mut slice[..mid], &mut slice[mid..])  |     -------------------------^^^^^--------  |     |     |                  |  |     |     |                  second mutable borrow occurs here  |     |     first mutable borrow occurs here  |     returning this value requires that `*slice` is borrowed for `'1`
error: aborting due to previous error
For more information about this error, try `rustc --explain E0499`.error: could not compile `unsafe-example`
To learn more, run the command again with --verbose.
复制代码

Rust 的借用检查器无法理解我们借用了切片的两个不同部分,它只知道我们借用了同一个切片两次。借用切片的不同部分是基本是没问题的,因为两个切片不会重叠,不过 Rust 还没有智能到能够理解这些。当我们知道代码是没问题的,但是 Rust 不知道的时候,就是使用不安全代码的时候了。下面我们将使用 unsafe 块、裸指针和不安全的函数调用来尝试实现 split_at_mut,参考下面的例子:

use std::slice;
fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) { let len = slice.len(); let ptr = slice.as_mut_ptr();
assert!(mid <= len);
unsafe { ( slice::from_raw_parts_mut(ptr, mid), slice::from_raw_parts_mut(ptr.add(mid), len - mid), ) }}
复制代码

前面我们介绍切片的时候说过,切片是一个指向数据的指针,并存储了数据的长度。可以使用 len 方法获取切片的长度,使用 as_mut_ptr 方法返回切片的裸指针。在上面的例子中我们使用 as_mut_ptr 返回了一个 *mut i32 类型的裸指针,并储存在变量 ptr 中。

我们保留了判断索引位置是否越界的断言。然后,在不安全的代码块中:slice::from_raw_parts_mut 函数以裸指针和长度为参数创建一个切片,我们使用它从 ptr 指向的地址开始,长度为 mid 的切片。然后我们调用了 ptr 的 add 方法,得到一个从切片 mid 位置开始的裸指针,并使用这个裸指针创建了切片 mid 位置以后的数据的切片。

函数 slice::from_raw_parts_mut 是不安全的,因为它的参数是一个裸指针,并必须相信这个指针是有效的(其自身无法确认指针的有效性)。裸指针的 add 方法也是不安全的,因为其必须确信地址偏移量上也是有效的指针。因此,我们必须将 slice::from_raw_parts_mut 和 add 放入 unsafe 块中。通过阅读代码和增加越界判断的断言,我们可以说 unsafe 块中所有的裸指针都是指向切片内数据的有效指针。这是一个可以接受和恰当的对 unsafe 的使用。

注意我们无需将函数 split_at_mut 标记为 unsafe,并可以在安全的 Rust 中调用此函数。我们通过在函数中安全的使用不安全的代码创建了一个不安全代码的安全抽象,因为其只会根据函数的参数返回有效的指针。

与之相反,在下面的例子中我们使用 slice::from_raw_parts_mut 创建的切片在使用时很有可能会崩溃:

use std::slice;
let address = 0x01234usize;let r = address as *mut i32;
let slice: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
复制代码

上例的这段代码使用任意内存地址创建了一个长为 10,000 的切片。我们并不拥有这个地址的内存,也不能保证这段代码创建的切片包含有效的 i32 类型数据。尝试像有效的切片一样使用 slice 将导致不可预期的行为。

使用函数 extern 与其它语言的代码交互

有时我们在 Rust 代码中可能需要与其它语言编写的代码进行交互。为此 Rust 有一个关键字 extern 用于创建和使用“语言交互接口”(Foreign Function Interface,FFI)。FFI 是一种编程语言用于定义函数并允许不同编程语言调用的方式。

extern 块中声明的函数对于调用其的 Rust 代码总是不安全的。因为其它语言不会强制执行 Rust 的规则和保证,Rust 也无法检查它们的安全性,所以只能由程序员确保其安全,参考下面的例子:

extern "C" {    fn abs(input: i32) -> i32;}
fn main() { unsafe { println!("Absolute value of -3 according to C: {}", abs(-3)); }}
复制代码

在上面的例子中我们展示了如何集成 C 标准库中的 abs 函数。在 extern "C" 块中,我们列出了希望能够调用的外部函数的定义,"C" 定义了外部函数所使用的应用程序二进制接口(application binary interface,ABI):ABI 定义了如何在汇编级调用此函数。"C" ABI 是最常用的遵循 C 编程语言的 ABI。

也可以使用关键字 extern 创建一个允许其它语言调用的 Rust 函数的接口。和之前使用 extern 块不同,我们只需要 fn 之前增加关键字 extern 并指定所用的 ABI。另外,还需增加注解 #[no_mangle] 来告诉 Rust 编译器不要处理(mangle)此函数的名称。Mangling 发生于当编译器将我们指定的函数名修改为其它名称时,它会增加用于其它编译过程的更多的信息,但是会更难以阅读。每种编程语言的编译器处理函数名字的方式都稍有不同,因此,为了使 Rust 函数在其它语言中可命名,必须禁用 Rust 编译器对函数名的处理。参考下面的例子:

#[no_mangle]pub extern "C" fn call_from_c() {    println!("Just called a Rust function from C!");}
复制代码

当上面这段代码编译为动态库并在 C 语言中链接后,函数 call_from_c 就能够在 C 语言代码中访问。另外注意,在这里我们不需要使用 unsafe。

访问或修改可变静态变量

目前为止,我们都未讨论过全局变量(global variables),Rust 支持,但是对于 Rust 的所有权规则来说是有问题的。如果两个线程访问同一可变全局变量,则可能会导致数据竞争。

在 Rust 中,全局变量被称为静态变量(static variables),参考下面的例子:

static HELLO_WORLD: &str = "Hello, world!";
fn main() { println!("name is: {}", HELLO_WORLD);}
复制代码

静态变量类似于我们在前面介绍过的常量。通常静态变量的名称采用大写蛇形命名法(SCREAMING_SNAKE_CASE),并必须声明变量的类型,在这个例子中即 &'static str。静态变量只能储存声明周期为 'static 的引用,这意味着 Rust 编译器可以算出其生命周期而无需显式声明。访问不可变静态变量是安全的。

常量与不可变静态变量看起来很类似,其区别是静态变量中的值在内存中有一个固定的地址,在使用时总是会访问这个地址。而常量则允许在被使用时复制其数据。

常量与静态变量的另一个区别在于静态变量可以是可变的。访问和修改可变静态变量都是不安全的。下面的例子展示了如何声明、访问和修改可变静态变量:

static mut COUNTER: u32 = 0;
fn add_to_count(inc: u32) { unsafe { COUNTER += inc; }}
fn main() { add_to_count(3);
unsafe { println!("COUNTER: {}", COUNTER); }}
复制代码

像普通的变量一样,我们使用关键字 mut 声明可变性。任何读写可变静态变量 COUNTER 的代码都必须位于 unsafe 块中。运行这段代码将会按照预期打印出 COUNTER: 3,因为它是单线程的。在多个线程场景下,操作 COUNTER 则可能导致数据竞争。

对于可以全局访问的可变数据,难以保证不存在数据竞争,因此 Rust 认为可变静态变量是不安全的。如果有可能,请优先使用前面我们讨论过的并发技术和线程安全的智能指针,这样编译器就能帮助我们检测多线程间的数据访问是否安全。

实现不安全的 trait

另外一个使用 unsafe 的场景是实现不安全的 trait。当至少有一个方法包含有编译器不能验证的不变性约束条件时(invariant,前面讲过对它的理解) trait 是不安全的。可以在 trait 之前增加关键字 unsafe 声明其为不安全的,同时 trait 的实现也必须标记为 unsafe,参考下面的例子:

unsafe trait Foo {    // methods go here}
unsafe impl Foo for i32 { // method implementations go here}
fn main() {}
复制代码

通过使用 unsafe impl,我们(程序员)承诺保证编译器所不能验证的不变性约束条件。

作为一个例子,前面我们讨论并发时,介绍过 Sync 和 Send trait,编译器会自动为完全由标记了 Send 和 Sync 的类型组成的类型自动实现他们。如果我们实现的类型中包含有未实现 Send 或 Sync 的类型,譬如裸指针,并希望将其标记为 Send 或 Sync,则必须使用 unsafe。Rust 不能验证我们的类型是否可以安全的在线程间发送或在多线程下访问,所以需要我们自己进行检查并确保。

访问 union 的字段

最后一种只能用于 unsafe 的操作是访问 union 的字段。union 看起来和 struct 类似,但是在同一时刻只能访问其实例中的一个字段。union 主要用于和 C 语言代码接口中(对应语言中的 union 类型)。访问 union 字段是不安全的,因为 Rust 无法保证当前存储在 union 实例中的数据类型。更多关于 union 的内容可以参考官方文档。

何时使用不安全的代码

我们可以在 unsafe 中进行以上我们讨论过的五种操作(超级力量),甚至不用怎么思考。不过要确保 unsafe 的代码始终是正确的并不容易,因为编译器不能帮助我们确保内存安全性。仅在有必要时使用 unsafe 代码,也不用过于担心,显式的声明 unsafe 使得在发生错误时易于追溯问题的原因。

发布于: 11 小时前阅读数: 3
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-高级特性-不安全的Rust