当前位置:网站首页>每日一R「02」所有权与 Move 语义

每日一R「02」所有权与 Move 语义

2022-08-09 21:54:00 InfoQ

01-所有权原则

今天我们要学习的概念是变量的所有权。对 Rust 有点了解的同学可能知道,所有权是 Rust 有别于其他语言的一个关键特性,也是 Rust 初学者吐槽最多和最容易混淆的特性之一。今天我们将跟着陈天老师来学习下所有权原则。

在学习所有权之前,需要先对一些基本概念达成共识,特别是栈和堆的概念。在大多数程序语言中,栈的作用是用来追踪程序的执行,记录方法的调用情况,所以有时候栈也被称为调用栈。而堆则主要用来存储可以在多个线程间共享的数据。

我想从 Java 语言的角度分享下我对栈和堆的理解。简单来说,在 Java 中堆中存储的是对象或数组,这些内容允许在线程之间共享;而栈中存储的都是基本类型的局部变量,对其他线程来讲是不可见的。栈中存储的基本单元是栈帧,每个栈帧与一个方法相关联。当调用方法时,JVM 会创建栈帧,并将其与调用方法关联起来,然后压入栈顶。调用方法中定义或声明的局部变量,会存储在对应的栈帧中。当方法调用结束后,其对应的栈帧从栈顶被弹出,其中包含的局部变量也被释放。

当在调用方法中使用 new 创建对象时,JVM 会从堆中分配一块空间用来存储创建的对象,然后栈中也会分配一块空间,用来存储指向堆中内存的引用。当栈顶栈帧被弹出后,栈帧中存储的指向堆中内存的应用会被释放,而堆中的对象并不会释放。

那么堆中对象占用的内存如何被释放呢?其实这个问题在所有变成语言中都存在。而且解决这个问题的方法也多种多样,例如:

  • c / c++ 中,堆内存的分配和释放,需要开发者自觉地去维护,使用 malloc / free 来显式地申请和释放堆内存。这样最大的问题就在于,对内存管理完全交由开发者自行维护,而人总是容易犯错的,极容易发生内存泄漏等问题。
  • java 采用了与 c / c++ 不一样的方式来管理堆内存。当 new 关键字时,JVM 会帮助开发者在堆上分配空间。堆空间的回收 JVM 通过 GC 来完成。这样做虽然可以很大程度上确保内存安全,但 GC 需要消耗额外的资源,而且还存在 Stop the world 的问题。

Rust 使用了完全不同于上述语言的方式来解决堆上内存分配和回收的问题。Rust 的方式就是设计了所有权原则,来控制堆上内存的释放与回收。

所有权原则是:

  • 一个值只能被一个变量所拥有,这个变量被称为所有者。
  • 一个值同一时刻只能有一个所有者。
  • 当所有者离开作用域,其拥有的值被丢弃。

我是怎样理解所有权中提到的值的呢?简单来说,这里的值指得就是栈内存中的某块内容或堆内从中的某块内容。

  • 栈内存。
  • 栈中存储的是固定长度的类型值,例如i32 / u32 / u8 等。对于这块空间中的内容,它的所有权由某个变量拥有,这个变量称为这块内存的所有者(
    第一条
    )。例如
    let x: u32 = 5;
    会在栈空间中分配 32 位长度的内存,并将这块内存的内容设置为 5。同样,
    let y: u8 = 1;
    会在栈空间中分配 8 位长度的内存,并将这块内存的内容设置为 1。
  • 所有权第二条怎么理解呢?就是说栈空间中的某块内存(值)同一时刻只能被一个变量拥有。从代码上我们理解一下:
    let x: u32 = 10; let y = x;
    前面一句与上面一样,在栈空间中分配一块内存,并将这块内存的所有权给 x。第二句会在栈空间中分配同样 x 同样大小的空间并将 x 中的内容拷贝一份到这块空间(Copy 语义,会在后面的课程中学习到)。y 是这块新空间的所有者,与所有权原则第一条自洽。
  • 变量的作用域指从变量声明开始,到变量所在代码块结束这段时间。当变量离开其作用域,变量拥有的内存空间被释放,这点也不难理解。
  • 堆内存。
  • 堆中存储的是长度可变的值,例如 Vec,String等。对于这块内存空间,变量是通过什么方式来体现所有权的呢?《Rust语言圣经》是这样描述的:
  • String
    类型是一个复杂类型,由
    存储在栈中的堆指针
    字符串长度
    字符串容量
    共同组成,其中
    堆指针
    是最重要的,它指向了真实存储字符串内容的堆内存,容量是堆内存分配空间的大小,长度是目前已经使用的大小。
  • 从上面的描述中我们可以了解到,对于堆中长度可变的值,Rust 通过栈上固定长度的“胖指针”实现了对堆上值的所有权。
  • 从代码角度理解一下第二条,
    let s1 = String::from("hello"); let s2 = s1;
    前面一句代码,会再堆上分配一块空间存储”hello”,并在栈上分配一个胖指针,它存储有指向堆中空间的堆地址、字符串长度、字符串容量。第二句中,会在栈上创建一个胖指针,胖指针 s1 的内容(堆地址、长度、容量)会被拷贝到 s2 上,堆中的”hello”并不会被拷贝。第二句执行后,s1 便不再拥有堆中内容”hello”的所有权,任何通过 s1 访问堆中内容的尝试都会被 Rust 编译器拒绝。通过这种方式,Rust 可以保证堆中空间有且仅有一个变量引用,从而解决了堆中内存回收的问题。
  • 注:可能会有人注意到,
    let s1 = s;
    与上面的
    let y = x;
    的语义不太一样呢?在 Rust 中,固定长度的类型,包括整型、字符型等,甚至数组[T; n],都是固定长度的,它们存储在栈内存中;而非固定长度类型,例如 String、Vec等,都是存储在堆上的。对于前者,它们是实现了 Copy trait 的,而后者则没有。原因是,如果后者实现了,会违背有且仅有一个所有者的原则。
  • 当胖指针变量离开了它的作用域,它指向的堆内存会被回收。这样 Rust 就不用像 Java 一样,还需要借助 GC 来回收堆内存;也避免了像 C/C++ 一样需要人为地回收内存,从而提高了安全性。

02-Move 语义

变量所有权的转移在 Rust 中有特殊的语义,称为 move 语义。仍然以上节中的语句作为示例:

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

《Rust语言圣经》中有一幅图非常形象地描述了这个过程:



当所有权转移后,通过 s1 便不能访问堆上的内容了。通过这个例子也能更好地理解为什么 Rust 中把 let 语句称为是绑定语句,而非赋值语句。

所有权原则难道就那么完美么,它既然解决了其他变成语言中比较棘手的问题,那它有没有什么缺点?

所有权很强大,避免了内存的不安全性,但是也带来了一个新麻烦: 
总是把一个值传来传去来使用它
。考虑如下的函数:

fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域
 a_string // 返回 a_string 并移出给调用的函数
}
fn main() {
 let s = String::from("hello");
 let s1 = takes_and_gives_back(s);
}

当调用函数时,变量 s 的所有权被转移到了入参 a_string。当函数调用结束后,如果不通过返回值传递出去,调用前变量 s 指向的内存地址就被释放了。传入一个函数,很可能还要从该函数传出去,结果就是语言表达变得非常啰嗦,所以 Rust 又设计了引用,可以在不获取所有权的前提下访问变量拥有的值。

今天的课程链接《
07|所有权:值的生杀大权到底在谁手上?

历史文章推荐
每日一 R「01」跟着大佬学 Rust
原网站

版权声明
本文为[InfoQ]所创,转载请带上原文链接,感谢
https://xie.infoq.cn/article/0606b7cb6f975d078e8318179