写点什么

Rust 从 0 到 1- 所有权 - 概念介绍

用户头像
关注
发布于: 2021 年 04 月 01 日
Rust从0到1-所有权-概念介绍

所有权(ownership)是 Rust 的核心特性 。该功能虽然解释起来并不复杂,但它对 Rust 的其他部分有着很深的影响。

目前所有的语言都需要对内存的使用进行管理,主要分为两类,一类使用垃圾回收机制,譬如 Java、Go;一类需要在编写代码时显示的进行内存的申请和释放,譬如 C、C++。而 Rust 采用了与以上两种都不同的一种机制,也就是所有权机制,并且在编译阶段编译器就会根据一系列所有权相关的规则进行检查。同时,所有权机制,不会对代码运行时的性能造成任何影响。

请大家注意,这是 Rust 非常核心的概念和基础,能帮助我们更好的理解 Rust 的一些独特的特性,也能帮助我们编写出更加高效和安全的代码。由于在 Rust 语言中,值是位于栈上还是堆上会在很大程度上影响语言的行为,从而影响我们在编码时的选择,因此,我们下面先介绍下堆和栈的概念。

栈(Stack)与堆(Heap)

栈和堆都是代码在运行时可供使用的内存,但是它们的结构不同。以放入值的顺序存储值并以相反顺序取出值,即 后进先出last in, first out)。就像叠盘子,我们把新的盘子放在顶部,当需要用盘子时,也先从顶部拿走。往栈里增加数据叫做 进栈pushing onto the stack),而从栈中移出数据叫做 出栈popping off the stack)。

栈中存储的数据都必须是大小已知且固定的。在编译阶段大小未知或大小在运行时会变化的数据,只能存储在堆上。堆的要求没有栈这么严格:当向堆放入数据时,会先向系统请求一定大小的空间,系统会在堆中找到一块足够大的空间,把它标记为已使用,并返回一个表示该存储空间地址的 指针( pointer )。这个过程称作 在堆上分配内存( allocating on the heap ),有时简称为 分配(allocating)。因为指针的大小是已知且固定的,因此可以将指针存储在栈上,当在需要获取实际数据时,再从栈中取出指针,通过指针的地址,获得实际存储的数据。

入栈比在堆上分配内存要快,因为(入栈时)系统无需为存储新数据去搜索内存空间;其位置总是在栈顶。相比之下,在堆上分配内存则需要更多的工作,这是因为系统必须先找到一块足够存放数据的内存空间,并接着做一些记录为下一次分配做准备。

访问栈上的数据也比访问堆上的数据快,因为堆上的数据必须通过指针来访问。现代处理器在内存中跳转越少就越快(缓存)。因此,处理器在处理的数据彼此较近的时候比较远的时候能更好的工作。另外,在堆上分配大量的空间也会消耗时间。

当代码调用一个函数时,传递给函数的值(包括指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈。

跟踪哪部分代码正在使用堆上的哪些数据,最大限度的减少堆上的重复数据的数量,以及清理堆上不再使用的数据确保不会耗尽空间,这些都是所有权系统要处理的。一旦理解了所有权系统,你就不需要经常考虑栈和堆的问题了。但是了解所有权系统就是为了管理堆数据,能够帮助理解所有权机制。

所有权规则

首先,让我们看一下所有权的规则。当看后面的举例时,请谨记这些规则:

  1. Rust 中的每一个值(value)都有一个被称为其所有者(owner)的变量。

  2. 值有且只有一个所有者。

  3. 当所有者(变量)离开作用域,这个值将被丢弃。

变量作用域

作用域是一个变量在程序中有效的范围。参考下面的例子:

fn main() {  {                      // 作用域起始,变量 s 在这里无效, 它尚未声明    let mut s = "hello"; // 从这里开始,变量 s 是有效的    // do stuff with s    println!("the string is {}", s);  }                      // 作用域结束,变量 s 不再有效  println!("the string is {}", s);}
复制代码

这里有两个重要的时间点:

  • 当 s 进入作用域 时,它就是有效的。

  • 这一直持续到它 离开作用域 为止。

因此,例子中的最后一句在编译时会报错。目前为止,变量是否有效与作用域的关系跟其他编程语言类似。在此基础上后面将介绍 String 类型。

String 类型

为了演示所有权的规则,我们需要一个比前面 “基础概念-数据类型” 中讲到的基本数据类型要复杂的数据类型。因为,前面介绍的基本数据类型都是存储在栈上的并且当离开作用域时被移出栈,而我们需要一个在堆上存储数据的类型来解释 Rust 是如何知道该在何时清理数据的,也就是后面作为例子的 Rust 标准库提供的 String。(标准库提供的其他类型或你自己创建的其他复杂数据类型道理上是一样的,不过 String 应该是我们最为常见的)

    在上面的例子中我们是通过硬编码的方式将字符串文本赋值给变量 的,这时候变量 对应的值,也就是字符串 "hello" 实际上是不可改变的(意思是我们不能在 "hello" 后面再追加一个字符串", world!"变成 "hello, world!",我们只能采用将 "hello, world!" 整个字符串文本重新赋值给变量 s 的方式),变量 s 的类型实际上是 &str ,要实现追加的效果,我们必须使用变量类型 String ,参考下面的例子:

fn main() {                 let mut s = String::from("hello");    s.push_str(", world!");    println!("the string is {}", s);         }
复制代码

两个冒号 :: 是运算符,用于使用 String 类型命名空间(namespace)下的函数,这样我们就可以不用起似 string_from 这样的名字,关于这方面的内容,后面会有章节详细解释。

这两种字符串使用的方法造成的不同效果,主要在于对内存的使用上的不同。

内存的分配

使用字符串文本赋值的方式在编译时就知道其大小且固定不变,文本被直接硬编码进最终的可执行文件中,快速且高效。但这也限制了它的应用场景,很多时候,我们需要的字符串文本内容是在编译阶段无法预先知道的,并且后面也可能会发生改变,譬如,运行程序的命令行参数。

而 String 类型,为了支持一个可变,大小可增长的文本,会在堆上分配一块在编译时未知大小的内存来存放。这意味着:

  • 必须在运行时向系统请求内存。

  • 当我们使用完这个 String 变量时要有一种机制或方法将内存返还给系统。

第一条在编码时由代码显示的去申请,譬如,当调用 String::from 时,它请求其所需的内存。其他编程语言的做法也是类似的。

而第二条不同语言采用的做法主要有两种,一种是使用垃圾回收器(garbage collector,GC), GC 会记录并清除不再使用的内存;一种需要程序员识别出不再使用的内存并调用代码显式释放。Rust 则采取了一种不同的策略,即,内存在拥有它的变量离开作用域后就被自动释放。参考下面的例子:

fn main() {   {                let mut s = String::from("hello");  // 从这里开始,变量 s 是有效的    println!("the string is {}", s);    // 使用 s 变量  }                                     // 作用域结束,变量 s 不再有效}
复制代码

当变量离开作用域,Rust 为我们调用一个特殊的函数 drop,在这里 String 会进行释放内存的操作。Rust 在结尾的 } 处自动调用 drop

在 C++ 中,这种在生命周期结束时释放资源的模式被称作 RAII(Resource Acquisition Is Initialization)。如果使用过 RAII 模式的话应该对 Rust 的 drop 函数并不陌生。
复制代码

这种方式对编写 Rust 代码有着深远的影响。现在它看起来比较简单,但是当我们有多个变量使用在堆上分配的同一块内存时,场景将变的复杂。

变量的赋值(一):移动

Rust 中的多个变量与“同一数据”(通过变量互相赋值的方式)的关系,对于不同的变量类型是不同的。参考下面的例子:

let x = 5;let y = x;
复制代码

因为整数是有已知固定大小的简单值,所以有两个 5 被放入栈的顶部。下面看看 String 的版本,参考下面的例子:

let s1 = String::from("hello");let s2 = s1;
复制代码

看起来和上面整数的例子很相似,但是实际上底层发生的事情完全不一样。String 由三部分组成,如下图(来自官网)左侧所示:一个指向存放字符串内容内存的指针,一个长度(表示字符串当前使用了多少字节的内存),和一个容量(从系统总共获取了多少字节的内存),这一组数据存储在栈上。右侧则是堆上存放的字符串实际的内容,这是第一次将字符串 "hello" 赋值给 s1 时的情况。

当第二步我们将 s1 赋值给 s2 时,字符串被复制了,让我们看看实际发生了什么,Rust 从栈上拷贝了它的指针、长度和容量,但是并没有复制指针指向的堆上的数据。如下图所示(来自官网):

Rust 如此做的原因,主要是性能上的考虑,避免大块的数据 copy。

前面说过当变量离开作用域后,Rust 会自动调用 drop 函数并清理变量的堆内存。但是当两个变量的指针指向了同一位置,那么当 s2 和 s1 离开作用域时,他们都会尝试释放相同的内存。这会造成 double free 的错误,两次释放相同的内存会导致内存污染,它可能会导致潜在的安全漏洞。

    因此,为了确保内存安全,在这种场景下 Rust 会认为 s1 不再有效,因此也不需要在 s1 离开作用域后清理任何东西。参考下面的例子:

fn main() {   let s1 = String::from( "hello" );  let s2 = s1;  println!("{}, world!", s1);                     }
复制代码

上面的例子会在编译时报错,因为 s1 已变为无效的变量。这种行为有些类似其它语言中所说的浅拷贝(shallow copy)和 深拷贝(deep copy),整数的例子类似深拷贝,String 的例子类似浅拷贝,不过 Rust 会同时使第一个变量变为无效,这个在 Rust 中被称为移动(move)。

因为只有 s2 是有效的,当其离开作用域,它就释放自己的内存,这样就避免了 double free 的问题。另外,Rust 在设计上永远也不会自动进行数据的 “深拷贝”。因此,任何默认的赋值行为对运行时性能影响都比较小。

变量的赋值(二):克隆

如果我们确实需要“深拷贝”,即复制 String 中堆上的数据,而不仅仅是栈上的数据,可以使用 clone 函数。参考下面的例子:

fn main() {   let s1 = String::from("hello");  let s2 = s1.clone();  println!("s1 = {}, s2 = {}", s1, s2);}
复制代码

clone 操作的性能和资源消耗可能会相对较大,在使用时需要特别注意。

只使用栈内存的变量

参考下面的例子:

fn main() {   let x = 5;  let y = x;  println!("x = {}, y = {}", x, y);}
复制代码

上面这段代码没有调用 clone,但是仍然可以正常运行,x 依然有效且没有被移动到 y 中。这是因为像整型这样的在编译时已知大小的类型是整个存储在栈上的,拷贝他的值很快,也就不需要在创建变量 y 后使 x 无效。

 Copy 是 Rust 一个特殊的 trait(trait 不知道怎么翻译合适,官方字面解释是定义共享的行为,有点像接口或抽象方法,后面章节会详细解释) ,可以用在类似整型这样的存储在栈上的类型上。如果一个类型拥有 Copy trait,一个旧的变量在将其赋值给其他变量后仍然可用。Rust 不允许同时使用 Drop trait 和 Copy trait。如果我们对其值离开作用域时需要特殊处理的类型使用 Copy ,将会出现一个编译时错误。

那么哪些类型实现了 Copy 呢?可以查看给定类型的文档来确认,不过作为一个通用的规则,任何简单标量值的组合可以实现 Copy ,不需要分配堆内存或其它形式的资源的类型可以实现 Copy(我理解是在做复制时不占用太多资源并且操作比较快速的类型)。下面是一些实现 Copy 的类型:

  • 所有整数类型,比如 u32。

  • 布尔类型,bool,它的值是 true 和 false。

  • 所有浮点数类型,比如 f64。

  • 字符类型,char。

  • 元组,当且仅当其包含的类型也都是实现了 Copy 的时候。比如,(i32, i32) 是实现了 Copy 的,但 (i32, String) 就不是。

所有权与函数

将值通过参数传递给函数和变量的赋值相似,也会产生移动或者复制,参考下面的例子:

fn main() {    let s = String::from("hello");// 变量 s 进入作用域    takes_ownership(s);           // 变量 s 的值“移动”到函数参数                                  // 变量 s 变为无效    let x = 5;                    // 变量 x 进入作用域    makes_copy(x);                // 变量 x 的值“拷贝”到函数参数                                  // 因为变量 x 是基本类型,存储在栈上    println!("x value is {}", x); // 所以后面x可以继续被使用} // 变量 x 和 s 的作用域结束,s 已经无效,无需额外释放内存
fn takes_ownership(para: String) {// 参数 para 进入作用域 println!("para is {}", para);} // 作用域结束,para 占用的内存被释放 fn makes_copy(para: i32) { // 参数 para 进入作用域 println!("para is {}", para);} // 作用域结束
复制代码

当尝试在调用 takes_ownership 含糊后使用 s 时,Rust 会抛出一个编译时错误。这些静态检查使我们免于犯错。大家可以动手试一试。

返回值与作用域

返回值也可以改变所有权。参考下面的例子:

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                                        // 将返回值“移动”给变量 s3} // 作用域结束,变量 s3 释放内存,变量 s2 已经无效,无需额外操作  // 变量 s1 释放内存.fn gives_ownership() -> String {      let result = String::from("hello"); // 变量 result 进入作用域    result                              // 变量 result 被返回并“移动”                                        // 给调用它的函数}// 函数 takes_and_gives_back 将传入一个字符串并返回该值fn takes_and_gives_back(para: String) -> String { // 变量 para 进入                                                  // 作用域    para  // 变量 para 被返回并“移动”给调用它的函数}
复制代码

使用堆的变量的所有权总是遵循以下模式:值赋给另一个变量时进行“移动”。当持有堆中数据值的变量离开作用域时,其值将通过 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() 返回字符串长度 (s, length)}
复制代码

在每一个函数中都获取所有权并接着返回所有权有些啰嗦。我们是否可以使用一个值但不获取所有权?如果我们还要接着使用变量的话,每次都传进去再返回来就比较麻烦了(譬如上例中的变量 s1 )。请听下回分解。

发布于: 2021 年 04 月 01 日阅读数: 18
用户头像

关注

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

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

评论

发布
暂无评论
Rust从0到1-所有权-概念介绍