[The RUST Programming Language]Chapter 4. Understanding Ownership (1)

无人久伴 提交于 2020-01-11 15:19:38


所有权是Rust最独特的一个特性,它可以让Rust在没有垃圾回收的情况下保证内存的安全性。因此,理解所有权在Rust中的工作原理是非常重要的。在本章中,我们将讨论所有权和几个与它相关的特性:借用、切片以及Rust如何在内存中存放数据。

What Is Ownership? 什么是所有权?

Rust的核心特性是ownership所有权,尽管这个特性非常直白,易于解释,但它对Rust这门语言有非常深远的影响。

所有程序在运行时,都须要管理它们使用到的内存。有些语言有垃圾回收的机制,看起来似乎不须要再在程序运行时去关心内存的使用;而另外一些编程语言,则要求程序员必须明确的分配或是释放内存。而Rust则采取了第三条方案,它内置了一个所有权系统来管理内存,这个所有权系统中有一系列的规则,编译器在编译阶段会检查程序是否符合所有权管理系统的规则,所以当你的程序编译完成,去运行时,所有权这个特性并不会拖慢程序的运行。

对于绝大多数的程序员而言,所有权是一个闻所未闻的新概念,所以你须要一定的时间来理解它。一个好消息是,随着你对于Rust和所有权系统经验的增长,你能更加自然的开发出兼顾安全和高性能的程序。所以,请务必坚持。

当你理解了所有权,你将会有一个坚实的基础来理解那些使得Rust独一无二的特性。在本章中,你会通过几个有关字符串的例子来理解所有权。

The Stack and the Heap 栈和堆
在许多编程语言中,你并不需要时常去考虑栈和堆,但是在一个像是Rust这样的系统级编程语言中,一笔数据是放在栈还是堆上,会对程序后续的行为产生深远的影响,你必须在编程时好好的做决定。本章中,部分所有权的知识会通过栈和堆的方式介绍,所以这里有必要做点栈和堆的预习功课。
栈和堆都是内存的一部分,你的代码执行时会用到它们,但是它们的结构并不是相同的。栈按数据获得的先后顺序来依次存放它们,然后倒过来删除,即常说的后进先出原则。让我们把栈来比作一叠叠盘子:当你要拿多个盘子的时候,你须要将新的盘子摞到最上面,而当你想要取下盘子时,你只能从最上面的盘子开始拿起,想要从中间位置或是最底下开始取,显然并不好操作。往栈里添加数据,我们称之为push推,而从栈上移除数据,则称之为pop弹出。
所有在栈上存储的数据都必须有明确的大小,那些大小在编译时还不确定,或是大小会发生变化的数据,都须要转而存储到堆上。堆并不像栈一样是结构化的:当你往堆上添加数据时,你须要一个明确大小的内存空间,操作系统会在堆上找是否有空闲的空间能够满足,如果找到了,那它就会将这一部分空间标注为已用并返回一个指针,指针会记录这个可用内存的地址。这种操作被称为在堆上分配,有些时候也简写为allocating分配。往栈上推数据不被认为是分配,因为栈上的指针是已知的,并且大小固定,你可以在栈上存储指针,但当你想获得实际的数据,你还是得依据指针去找。
假设我们要去餐馆就餐,当迈入餐馆,我会告诉服务员我们总共有几个人,而服务员就会去找是否有空桌能坐得下,然后把我们带到那里。如果我们中有个人迟到了,那也没关系,他可以问服务员我们坐在哪里。
往栈上推数据执行起来要比在堆上分配来得快,因为操作系统不须要再去找一个地方来存储数据,数据直接放在栈的最上面即可。相对的,堆上分配需要更多额外的工作,操作系统首先必须去找是否有一个足够大的空间来存储数据,同时它还须要保存地址,为下一次分配做准备。
同样的,访问在堆上的数据要比访问在栈上的数据慢不少,因为你必须通过指针才能找到堆上的数据。当内存偏移较小时,当代的处理器能够以更快的速度运行。让我们继续假设餐馆的场景,这里有一个侍者专门收集每桌的订单,能从一桌上获得所有的订单显然要比去一桌一桌挨个拿高效的多。在相同的场景下,处理器须要处理的数据如果离得比较近(譬如它们都放在栈上),那它显然会比处理离得较远的数据(譬如存在堆上)快得多。况且,在堆上分配大量空间也须要一些时间。
当你的代码调用一个函数,一些值(堆上的指针)被传入了函数,函数的本地变量获取到了这些值并将它们推到栈上,当函数运行结束,这些栈上的值又被弹出。
保持观察代码在哪些地方用到了堆上的哪些值、最小化堆上的重复数据、清除在堆上未被用到的值来避免内存溢出,这些都是所有权系统关注的地方。等你理解了所有权,你就不须要再常常去思考该用堆还是栈。管理堆上的数据是所有权系统存在的原因,牢记这点将帮助你理解所有权系统的运行方式。

Ownership Rules 所有权规则

第一步,让我们先来看下所有权的规则。时刻铭记下面的规则,我们将在之后的例子中举例说明:

  • Rust中所有的值都有一个变量叫做owner所有者
  • 每个数据在同一时间有且只能有一个所有者
  • 当所有者离开了值的作用域,这个值就会被销毁

Variable Scope 变量作用域

在第2章中,我们已经接触过一个简单的Rust程序,对Rust程序有了个基础的了解。现在我们将跳过一些基础语法,在本章代码示例中,我们将不再特别注明fn main() {,如果你想跟着示例代码做的话,请不要忘记手工创建一个main函数再引用示例中的代码。这样做的目的,是为了让我们的示例更加简洁,本章我们将把侧重点放在所有权而非代码本身上。

所有权的第一个例子,须要我们了解变量的作用域。所谓作用域,即是指某一个功能或是变量在程序中生效的范围。譬如我们通过下面的代码创建了一个变量:

let s = "hello";

变量s是一个字符串,且字符串的值是程序中通过硬代码写入的。这个变量从它定义的那一刻就生效了,直到当前作用域结束。下面代码中的标注可以帮你进一步理解s的作用域:

{                      // s 在这行还是无效的,因为 s 还为定义
    let s = "hello";   // s 从这行开始生效
    // 以下省略一些关于 s 的操作
}                      // 作用域结束,s 到这行就失效了

上面的例子中,介绍了重要的两点:

  • s引入了作用域, 它就生效了
  • 它将保持生效直到程序离开作用域

目前为止,作用域和变量有效性间的关系还同其它编程语言没有什么不同。别急,让我们先介绍下String类型,然后在此之上再去理解Rust所有权。

The String Type String类型

为了说明所有权的规则,我们须要一个比第3章的数据类型还要复杂的数据类型。我们之前介绍的数据类型,都是存储在栈上,在作用域结束时,栈同时会被清空。但现在我们须要看下那些被存在堆上的数据,并研究下Rust是怎么知道何时该清理这些堆上的数据的。

这里我们将使用String作为例子并着重研究String所有权的知识。这些概念同样适用于其它复杂的数据类型,无论它们是由标准库提供,亦或由你自己创建。我们会在第8章中对String再做更深入的讨论。

我们已经看到过字符串,它们在我们的程序中是一串硬代码编写的字符。字符串用起来非常舒服,但它并不适合所有有文本应用的场景。首要原因,字符串是无法修改的;其次,在很多情况下,我们无法在写代码时就知道每一个字符串的具体值:譬如我们须要捕获用户的输入并存储起来。为了应对这种情况,Rust提供了第二种字符串类型String,这种数据类型的数据,会被存储在堆上,这样我们就能存储那些在编译时无法得知的文本。我们可以通过from方法,用一串字符串创建一个String

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

双冒号::是一个操作符,用于提供from函数的命名空间,说明它是String类型下的函数,而非使用一些像是string_from这样的名字。这种语法我们会在第5章方法语法中介绍,并在第7章中再对模块化命名空间做深入讨论。

这种类型的字符串,是可以被修改的:

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

s.push_str(", world!"); // push_str() 在String后又添加了一段字符串

println!("{}", s); // 这将会打印 `hello, world!`

那么,为什么这里会有区别呢?为什么String可以被修改,但是字符串不能?这是因为这两种类型对于内存操作的区别导致。

Memory and Allocation 内存分配

对于字符串,我们在编译时就已经知道了它具体的内容,所以在最终产生的可执行文件中,它将直接以硬代码的形式存在,这也是为什么字符串往往更快且效率更高。但这些特性也会使得字符串无法被修改,因为我们无法在不知道文本的确切内容或是文本大小在程序执行时有可能发生变化的情况下,就划出一块内存来存储文本。

String类型,是一种可修改并可增长的文本,内容在编译时不可知,我们须要将一部分内存分配到堆上用来存储它的内容。这意味着:

  • 程序必须在运行时向操作系统请求分配内存
  • 我们须要在String使用完毕,不再须要的时候,将内存还给操作系统

针对上面的第一条,我们已经通过String::from完成,它实现了请求内存。在很多编程语言中这都是非常常见的。

然而,对于第二条,这就比较特殊了。在一些语言中,提供了垃圾回收机制,它将检查并清理内存中不再使用的部分,所以程序员无须刻意去考虑内存释放。而如果没有垃圾回收,这就需要我们自己有能力去判断在哪些时候内存将不再被使用,然后就像我们请求内存资源时那样,通过其它的一些代码将内存释放掉。从历史的经验教训来看,通过人工手段正确的释放内存资源,是一个非常困难的程序问题。如果我们忘记了释放内存,那就会浪费内存资源;如果太早释放掉内存,我们就有可能获取不到某个有效变量;如果你释放同一个内存两次,那也会产生bug。综上种种,我们须要一个途径来精确的匹配allocate内存分配和free内存释放。

对于这个问题,Rust采取了一种特别的途径:内存会在变量离开作用域时自动释放。这是针对我们上面有关String的作用域的例子:

{
    let s = String::from("hello"); // s 从这行开始生效
    // 一些和 s 有关的操作
}                                  // 作用域结束,s不再有效

s离开它的作用域,我们须要将内存返还给操作系统,无需思考,这是一个自然而然的事。每当一个变量离开它的作用域,Rust就会为我们执行一个特殊的函数,这个函数叫做drop,这个String的所有者会通过这个函数释放内存。Rust会自动在每一个闭合的尖括号后调用drop函数。

在C++中,这种每个对象生命周期结束时释放资源的模式有时也会被称作RAII(资源获取就是初始化)。如果你有用过RAII模式,那么就会发现Rust中的drop函数与它非常像。

这种模式对于Rust代码的编写有非常深远的影响。尽管在这个例子中它看起来非常简单,但在更加复杂的场景下,代码的行为将变得不可捉摸,尤其是当我们想要在堆上为多个变量存储值得时候。那么接下来就让我们讨论下更加复杂的情况。

Ways Variables and Data Interact: Move 变量和数据的交互方法:移动

多个变量在Rust中有多种方法与相同的数据做交互,譬如下面就是一个有关整型的例子:

let x = 5;
let y = x;

这段代码你或许会这样理解:“将5绑定到x,然后复制x中的值并将它绑定到y。”这样我们就有了两个变量xy,且它们的值都等于5。这确实是代码真实做的事,因为整型是一个简单的值,有固定的大小,所以栈上会有两个5

现在我们再来看下使用String的版本:

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

这看起来与我们之前的代码非常接近,所以我们或许会推测它们是按一样的方式工作:第二行代码会复制s1中的值,并将它绑定到s2。抱歉,但这并非真实的情况。

一起来看下下面的图,了解下String背后究竟在发生什么吧。一个String由三部分组成:一个指针指向存放String内容的内存地址、它的长度、容量,这些数据都是存放在栈上的。图中的右侧则是在内存堆上存放的具体内容。

图4-1
4-1
长度len是指String内容在内存中使用的字节数;容量capacity是指String从操作系统收到的总内存字节数。长度和容量的区别在本例中本没有那么重要,所以请先忽略掉容量。

当我们将s1分配给s2String的数据被复制了,但这里只是指复制了栈上的指针、长度和容量信息。我们不会将被指针指向的堆上的数据也一起复制。换句话说,内存上的数据会像下图这样:

图4-2
4-2
你可能会疑问,为什么Rust没有像下图4-3中的样子,将堆上的数据也复制一遍?因为如果Rust这样做了,那执行像是s2 = s1这样的操作势必会产生巨大的性能开销,尤其是当在堆上的数据非常大时。

图4-3
4-3
在之前我们已经讲过,当一个变量离开它的作用域后,Rust会自动调用drop函数来清理并释放变量在堆上的内存。但上面的例子中,你会发现两个指针指向了同一个内存地址,这就会带来一个问题:当s2s1离开作用域,它们都会尝试释放相同的内存空间。这种情况有个众所周知的名称,double free二次释放,它是我们一早就提过的一种内存安全性漏洞。重复释放内存将会导致内存污染,并导致潜在的安全性问题。

为了确保内存安全,在Rust中这里还有一个更进一步的操作。不同于尝试复制分配的内存,Rust会认为s1已经不再有效,因此在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
  |
3 |     let s2 = s1;
  |         -- value moved here
4 |
5 |     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

如果你曾经使用过一些其它语言,并同说过浅拷贝和深拷贝的概念,那复制指针、长度和容量而不复制具体数据的方式或许听起来很像做了次浅拷贝。但因为Rust不仅执行了浅拷贝,还无效了第一个变量,所以在这里,我们将这个操作称之为move移动。Rust中真实发生的会是图4-4这样的情况:

图4-4

4-4
这样解决了我们的问题,因为只有s2是有效的,当离开作用域时,只有它会去释放内存。

此外,这里需要提一下Rust设计时的一个选择:Rust永远不会自动创建数据的深拷贝,所以Rust中任何自动的复制都可以被认为在运行时是低开销的。

Ways Variables and Data Interact: Clone 变量和数据的交互方法:克隆

如果我们想深拷贝String在堆上的数据,而非仅仅浅拷贝栈上的数据,我们可以使用一个通用方法clone。我们会在第5章中讨论方法语法,但因为这些方法在许多编程语言中都是一个很普通的特性,所以你或许已经在其它地方已经见过它的。

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

let s1 = String::from("hello");
let s2 = s1.clone();

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

这个例子实现的即是图4-3中的操作,堆中的数据也一并复制了。

当你看到clone方法被调用,你得意识到这段代码很任性,它或许会有高昂的开销。clone是一个显式的标志,它告诉你会有些不同的事要发生。

Stack-Only Data: Copy 栈数据:复制

还有一点我们尚未介绍,下面这段代码使用了整型变量,并且它是有效且可执行的:

let x = 5;
let y = x;

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

上面的代码似乎否定了我们刚刚掌握的知识:我们没有使用clone,但x还是有效的且没有移动到y

这个原因是因为,那些像整型这类的数据类型,它们在编译时都有一个明确的长度,被完整的存储到了栈上,复制这类数据是非常快速、方便的。所以我们没有任何理由阻止x继续有效,哪怕我们又创建了变量y。换句话说,在这种情况下,深拷贝和浅拷贝没有任何区别,即便我们在这里使用了clone,也不会与我们直接使用浅拷贝有任何区别。

Rust中有种特别的标注叫做Copy特性,我们能够将它放在任何像是整型一样存在栈上的值前(我们会在第10章讨论更多有关特性的知识)。如果一个类型有Copy特性,那么一个旧的变量会在给新变量赋值后依旧有效。如果某种数据类型已经实现了Drop特性,Rust不会让我们再给这个类型标注Copy特性。假如有些类型你想让它在离开作用域后再做点什么,为此你还给它标注了Copy特性,那你只会得到一个编译时错误。想要了解更多有关Copy标注的内容,请看附录C中的Derivable Traits

那哪些类型是可以Copy的呢?你可以查看相关类型的帮助文档去一一确认,但这里有一个简单的规则,任何由简单标量组成的值都是可以被Copy的、那些不须要分配或是不须要某些资源的东西也可以Copy。下面是一些可以Copy的类型:

  • 所有整型类型,譬如u32
  • 波尔类型,bool,它们只包含值truefalse
  • 所有浮点类型,如f64
  • 字符类型char
  • 包含可以Copy的元素的元组。譬如(i32,i32)可以Copy(i32, String)却不行

Ownership and Functions 所有权和函数

把一个值传递给函数的语法与把这些值传递给一个变量的语法很相似。将变量传递给函数也会发生移动或是复制,就像变量赋值操作一样。下面的代码就是一个例子,请仔细查看注释中的信息:

fn main() {
    let s = String::from("hello");  // s 进入作用域

    takes_ownership(s);             // s的值移动到了函数
                                    // s在这里已经无效了

    let x = 5;                      // x进入了作用域

    makes_copy(x);                  // x会移动到函数
                                    // 但因为i32是可以Copy的,所以x仍将有效
                                    // 下面可以继续使用x

} // 这里x离开了作用域,之后是s,但因为s的值已经移动走了,所以不会发生任何事

fn takes_ownership(some_string: String) { // some_string 进入作用域
    println!("{}", some_string);
} // some_string离开作用域,drop被执行,后台的内存被释放

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
    println!("{}", some_integer);
} // some_integer离开作用域,没有其它任何事发生

如果我们想在takes_ownership之后再使用s,Rust会抛出一个编译时错误。这些静态分析帮助我们免于出错。尝试往main函数中添加更多的代码来调用sx,看看你在哪里可以访问它们而所有权规则又在哪里会阻止你使用它们。

Return Values and Scope 返回值和作用域

返回值也会传递所有权。下面的例子即是一个例子:

fn main() {
    let s1 = gives_ownership();         // gives_ownership 将它的返回值移动到s1
    let s2 = String::from("hello");     // s2 进入作用域
    let s3 = takes_and_gives_back(s2);  // s2 移动到 takes_and_gives_back, 
    								    // takes_and_gives_back 又将它的返回值移动到s3
} // s3 离开了作用域并被drop
  // s2 离开了作用域,但因为它已经移动过,所以不会发生什么。
  // s1 离开了作用域并被drop

fn gives_ownership() -> String {             // gives_ownership 会将返回值移动到调用它的函数中

    let some_string = String::from("hello"); // some_string 进入作用域

    some_string                              // some_string 被返回并移动到调用它的函数
}

// takes_and_gives_back 会获取一个String并返回它
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域

    a_string  // a_string 被返回并移动到调用它的函数
}

变量的所有权总是遵循着一样的模式:当赋值给另一个变量时,就会移动它;当一个值存储在堆上的变量离开它的作用域时,它的值也会被drop给清理掉,除非这些数据已经通过移动而被另一个变量所有。

每个函数都须要获取所有权并返回所有权,这样显得非常麻烦。那有没有什么办法可以让函数只使用值而又不拿走它的所有权呢?设想下吧,如果有个变量我会在多个函数中使用到它,为了能够重复使用这个值,除了每个函数的返回值我还不得不返回这个变量的所有权,那也太麻烦了。

我们可以使用元组来传递多个值,就像下面这样:

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}

但对于一个常用的功能来说,这显然会带来太多工作量和不必要的代码。幸运的是,为了解决这个问题,Rust引入了reference参考的功能。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!