Rust 是如何保障内存安全的

发布于: 2020 年 07 月 10 日
Rust是如何保障内存安全的

所有权可以说是Rust中最为独特的一个功能了。正是所有权概念和相关工具的引入,Rust才能够在没有垃圾回收机制的前提下保障内存安全。因此,正确地了解所有权概念及其在Rust中的实现方式,对于所有Rust开发者来讲都是十分重要的。在本章中,我们会详细地讨论所有权及其相关功能:借用、切片,以及Rust在内存中布局数据的方式。

什么是所有权

所有权概念本身的含义并不复杂,但作为Rust语言的核心功能,它对语言的其他部分产生了十分深远的影响。

一般来讲,所有的程序都需要管理自己在运行时使用的计算机内存空间。某些使用垃圾回收机制的语言会在运行时定期检查并回收那些没有被继续使用的内存;而在另外一些语言中,程序员需要手动地分配和释放内存。Rust采用了与众不同的第三种方式:它使用包含特定规则的所有权系统来管理内存,这套规则允许编译器在编译过程中执行检查工作,而不会产生任何的运行时开销。

你可能需要一些时间来消化所有权概念,因为它对于大部分程序员来讲是一个非常新鲜的事物。但只要你持之以恒地坚持下去,就可以基于Rust和所有权系统越来越自然地编写出安全且高效的代码!

理解所有权概念还可以帮助你理解Rust中其余那些独有的特性,它会为你接下来的学习打下坚实的基础!在本文中,我们会通过一些示例来学习所有权,这些示例将聚焦于一个十分常用的数据结构:字符串。

所有权规则

现在,让我们来具体看一看所有权规则。你最好先将这些规则记下来,我们会在随后的章节中通过示例来解释它们:

  • Rust中的每一个值都有一个对应的变量作为它的所有者。

  • 在同一时间内,值有且仅有一个所有者。

  • 当所有者离开自己的作用域时,它持有的值就会被释放掉。

变量作用域

由于我们在第2章完整地编写了一个Rust示例程序,所以接下来的示例代码会略过那些基本的语法,比如fn main() {等语句,你可以手动将下面的示例代码放置在main函数中来完成编译运行任务。这样处理后的示例会更加简单明了,使我们把注意力集中到具体的细节而不是冗余的代码上。

作为所有权的第一个示例,我们先来了解一下变量的作用域。简单来讲,作用域是一个对象在程序中有效的范围。假设有这样一个变量:

let s = "hello";

 

这里的变量s指向了一个字符串字面量,它的值被硬编码到了当前的程序中。变量从声明的位置开始直到当前作用域结束都是有效的。示例4-1中的注释对变量的有效范围给出了具体的说明:

{                    //由于变量s还未被声明,所以它在这里是不可用的

    let s = "hello";   //从这里开始变量s变得可用

    //执行与s相关的操作

}                      //作用域到这里结束,变量s再次不可用

示例4-1:一个变量及其有效范围的说明

换句话说,这里有两个重点:

  • s在进入作用域后变得有效。

  • 它会保持自己的有效性直到自己离开作用域为止。

到目前为止,Rust语言中变量的有效性与作用域之间的关系跟其他编程语言中的类似。现在,让我们继续在作用域的基础上学习String类型。

String类型

为了演示所有权的相关规则,我们需要一个特别的数据类型,它要比第3章的“数据类型”一节中涉及的类型都更加复杂。之前接触的那些类型会将数据存储在栈上,并在离开自己的作用域时将数据弹出栈空间。我们需要一个存储在堆上的数据类型来研究Rust是如何自动回收这些数据的。

我们将以String类型为例,并将注意力集中到String类型与所有权概念相关的部分。这些部分同样适用于标准库中提供的或你自己创建的其他复杂数据类型。我们会在第8章更加深入地讲解String类型。

你已经在上面的示例中接触过字符串字面量了,它们是那些被硬编码进程序的字符串值。字符串字面量的确是很方便,但它们并不能满足所有需要使用文本的场景。原因之一在于字符串字面量是不可变的。而另一个原因则在于并不是所有字符串的值都能够在编写代码时确定:假如我们想要获取用户的输入并保存,应该怎么办呢?为了应对这种情况,Rust提供了第二种字符串类型String。这个类型会在堆上分配到自己需要的存储空间,所以它能够处理在编译时未知大小的文本。你可以调用from函数根据字符串字面量来创建一个String实例:

let s = String::from("hello");

这里的双冒号(::)运算符允许我们调用置于String命名空间下面的特定from函数,而不需要使用类似于string_from这样的名字。我们会在第5章的“方法”一节着重讲解这个语法,并在第7章的“用于在模块树中指明条目的路径”一节中讨论基于模块的命名空间。

上面定义的字符串对象能够被声明为可变的:

let mut s = String::from("hello");

s.pushstr(", world!"); // pushstr()函数向String空间的尾部添加了一段字面量

println!("{}", s); //这里会输出完整的hello, world!

你也许会问:为什么String是可变的,而字符串字面量不是?这是因为它们采用了不同的内存处理方式。

内存与分配

对于字符串字面量而言,由于我们在编译时就知道其内容,所以这部分硬编码的文本被直接嵌入到了最终的可执行文件中。这就是访问字符串字面量异常高效的原因,而这些性质完全得益于字符串字面量的不可变性。不幸的是,我们没有办法将那些未知大小的文本在编译期统统放入二进制文件中,更何况这些文本的大小还可能随着程序的运行而发生改变。

对于String类型而言,为了支持一个可变的、可增长的文本类型,我们需要在堆上分配一块在编译时未知大小的内存来存放数据。这同时也意味着:

  • 我们使用的内存是由操作系统在运行时动态分配出来的。

  • 当使用完String时,我们需要通过某种方式来将这些内存归还给操作系统。

这里的第一步由我们,也就是程序的编写者,在调用String::from时完成,这个函数会请求自己需要的内存空间。在大部分编程语言中都有类似的设计:由程序员来发起堆内存的分配请求。

然而,对于不同的编程语言来说,第二步实现起来就各有区别了。在某些拥有垃圾回收(Garbage Collector,GC)机制的语言中,GC会代替程序员来负责记录并清除那些不再使用的内存。而对于那些没有GC的语言来说,识别不再使用的内存并调用代码显式释放的工作就依然需要由程序员去完成,正如我们请求分配时一样。按照以往的经验来看,正确地完成这些任务往往是十分困难的。假如我们忘记释放内存,那么就会造成内存泄漏;假如我们过早地释放内存,那么就会产生一个非法变量;假如我们重复释放同一块内存,那么就会产生无法预知的后果。为了程序的稳定运行,我们必须严格地将分配和释放操作一一对应起来。

与这些语言不同,Rust提供了另一套解决方案:内存会自动地在拥有它的变量离开作用域后进行释放。下面的代码类似于示例4-1中的代码,不过我们将字符串字面量换成了String类型:

{

    let s = String::from("hello"); //从这里开始,变量s变得有效

    //执行与s相关的操作

}                                 //作用域到这里结束,变量s失效

审视上面的代码,有一个很适合用来回收内存给操作系统的地方:变量s离开作用域的地方。Rust在变量离开作用域时,会调用一个叫作drop的特殊函数。String类型的作者可以在这个函数中编写释放内存的代码。记住,Rust会在作用域结束的地方(即}处)自动调用drop函数。

注意  在C++中,这种在对象生命周期结束时释放资源的模式有时也被称作资源获取即初始化(Resource Acquisition Is Initialization, RAII)。假如你使用过类似的模式,那么你应该对Rust中的特殊函数drop并不陌生。

这种模式极大地影响了Rust中的许多设计抉择,并最终决定了我们现在编写Rust代码的方式。在上面的例子中,这套释放机制看起来也许还算简单,然而一旦把它放置在某些更加复杂的环境中,代码呈现出来的行为往往会出乎你的意料,特别是当我们拥有多个指向同一处堆内存的变量时。让我们接着来看一看其中一些可能的使用场景。

变量和数据交互的方式:移动

Rust中的多个变量可以采用一种独特的方式与同一数据进行交互。让我们看一看示例4-2中的代码,这里使用了一个整型作为数据:

let x = 5;

let y = x;

示例4-2:将变量x绑定的整数值重新绑定到变量y上

你也许能够猜到这段代码的执行效果:将整数值5绑定到变量x上;然后创建一个x值的拷贝,并将它绑定到y上。结果我们有了两个变量x和y,它们的值都是5。这正是实际发生的情形,因为整数是已知固定大小的简单值,两个值5会同时被推入当前的栈中。

现在,让我们看一看这段程序的String版本:

let s1 = String::from("hello");

let s2 = s1;

以上两段代码非常相似,你也许会假设它们的运行方式也是一致的。也就是说,第二行代码可能会生成一个s1值的拷贝,并将它绑定到s2上。不过,事实并非如此。

图4-1展示了String的内存布局,它实际上由3部分组成,如图左侧所示:一个指向存放字符串内容的指针(ptr)、一个长度(len)及一个容量(capacity),这部分的数据存储在了栈中。图片右侧显示了字符串存储在堆上的文本内容。

长度字段被用来记录当前String中的文本使用了多少字节的内存。而容量字段则被用来记录String向操作系统总共获取到的内存字节数量。长度和容量之间的区别十分重要,但我们先不去讨论这个问题,简单地忽略容量字段即可。

当我们将s1赋值给s2时,便复制了一次String的数据,这意味着我们复制了它存储在栈上的指针、长度及容量字段。但需要注意的是,我们没有复制指针指向的堆数据。换句话说,此时的内存布局应该类似于图4-2。

由于Rust不会在复制值时深度地复制堆上的数据,所以这里的布局不会像图4-3中所示的那样。假如Rust依照这样的模式去执行赋值,那么当堆上的数据足够大时,类似于s2 = s1这样的指令就会造成相当可观的运行时性能消耗。

前面我们提到过,当一个变量离开当前的作用域时,Rust会自动调用它的drop函数,并将变量使用的堆内存释放回收。不过,图4-2中展示的内存布局里有两个指针指向了同一个地址,这就导致了一个问题:当s2和s1离开自己的作用域时,它们会尝试去重复释放相同的内存。这也就是我们之前提到过的内存错误之一,臭名昭著的二次释放。重复释放内存可能会导致某些正在使用的数据发生损坏,进而产生潜在的安全隐患。

为了确保内存安全,同时也避免复制分配的内存,Rust在这种场景下会简单地将s1废弃,不再视其为一个有效的变量。因此,Rust也不需要在s1离开作用域后清理任何东西。试图在s2创建完毕后使用s1(如下所示)会导致编译时错误。

let s1 = String::from("hello");

let s2 = s1;

println!("{}, world!", s1);

为了阻止你使用无效的引用,Rust会产生类似于下面的错误提示信息:

error[E0382]: use of moved value: `s1`

 --> src/main.rs:5:28

  |

|     let s2 = s1;

  |         -- value moved here

|

|     println!("{}, world!", s1);

  |                            ^^ value used here after move

  |

  = note: move occurs because `s1` has type `std::string::String`, which does

  not implement the `Copy` trait

假如你在其他语言中接触过浅度拷贝(shallow copy)和深度拷贝(deep copy)这两个术语,那么你也许会将这里复制指针、长度及容量字段的行为视作浅度拷贝。但由于Rust同时使第一个变量无效了,所以我们使用了新的术语移动(move)来描述这一行为,而不再使用浅度拷贝。在上面的示例中,我们可以说s1被移动到了s2中。在这个过程中所发生的操作如图4-4所示。

这一语义完美地解决了我们的问题!既然只有s2有效,那么也就只有它会在离开自己的作用域时释放空间,所以再也没有二次释放的可能性了。

另外,这里还隐含了另外一个设计原则:Rust永远不会自动地创建数据的深度拷贝。因此在Rust中,任何自动的赋值操作都可以被视为高效的。

变量和数据交互的方式:克隆

当你确实需要去深度拷贝String堆上的数据,而不仅仅是栈数据时,就可以使用一个名为clone的方法。我们将在第5章讨论类型方法的语法,但你应该在其他语言中见过类似的东西。

下面是一个实际使用clone方法的例子:

let s1 = String::from("hello");

let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);

这段代码在Rust中完全合法,它显式地生成了图4-3中的行为:复制了堆上的数据。

当你看到某处调用了clone时,你就应该知道某些特定的代码将会被执行,而且这些代码可能会相当消耗资源。你可以很容易地在代码中察觉到一些不寻常的事情正在发生。

栈上数据的复制

上面的讨论中遗留了一个没有提及的知识点。我们在示例4-2中曾经使用整型编写出了如下所示的合法代码:

let x = 5;

let y = x;

println!("x = {}, y = {}", x, y);

这与我们刚刚学到的内容似乎有些矛盾:即便代码没有调用clone,x在被赋值给y后也依然有效,且没有发生移动现象。

这是因为类似于整型的类型可以在编译时确定自己的大小,并且能够将自己的数据完整地存储在栈中,对于这些值的复制操作永远都是非常快速的。这也同样意味着,在创建变量y后,我们没有任何理由去阻止变量x继续保持有效。换句话说,对于这些类型而言,深度拷贝与浅度拷贝没有任何区别,调用clone并不会与直接的浅度拷贝有任何行为上的区别。因此,我们完全不需要在类似的场景中考虑上面的问题。

Rust提供了一个名为Copy的trait,它可以用于整数这类完全存储在栈上的数据类型(我们会在第10章详细地介绍trait)。一旦某种类型拥有了Copy这种trait,那么它的变量就可以在赋值给其他变量之后保持可用性。如果一种类型本身或这种类型的任意成员实现了Drop这种trait,那么Rust就不允许其实现Copy这种trait。尝试给某个需要在离开作用域时执行特殊指令的类型实现Copy这种trait会导致编译时错误。附录C中有关于如何给类型添加Copy注解的详细信息。

那么究竟哪些类型是Copy的呢?你可以查看特定类型的文档来确定,不过一般来说,任何简单标量的组合类型都可以是Copy的,任何需要分配内存或某种资源的类型都不会是Copy的。下面是一些拥有Copy这种trait的类型:

  • 所有的整数类型,诸如u32。

  • 仅拥有两种值(true和false)的布尔类型:bool。

  • 字符类型:char。

  • 所有的浮点类型,诸如f64。

  • 如果元组包含的所有字段的类型都是Copy的,那么这个元组也是Copy的。例如,(i32, i32)是Copy的,但(i32, String)则不是。

本文节选自《Rust权威指南》

【美】Steve Klabnik(史蒂夫·克拉伯尼克)

【美】Carol Nichols(卡罗尔·尼科尔斯) 著

毛靖凯唐刚沙渺 译

Rust核心开发团队编写|Rust开发者不可或缺的案头书

发布于: 2020 年 07 月 10 日 阅读数: 9
用户头像

还未添加个人签名 2019.10.21 加入

还未添加个人简介

评论

发布
暂无评论
Rust是如何保障内存安全的