Rust - 变量与数据的交互方式(move)

这是我参与11月更文挑战的第24天,活动详情查看:2021最后一次更文挑战

上一篇文章中,对Rust的内存和分配做了简单介绍,当变量离开作用域的时候,Rust就会自动调用drop函数对内存进行回收,看起来简单的原理,但是在更复杂的场景下代码的行为可能是不可预测的,比如当有多个变量使用在堆上分配的内存时。现在让我们探索一些这样的场景。

变量与数据的交互方式 - 移动

Rust 中的多个变量可以采用一种比较独特的方式和同一个数据进行交互,如下代码所示,将变量x的值赋给y

fn main() {
    let x = 1;
    let y = x;
}
复制代码

我们大概可以推论出上述代码的原理:将1这个整数绑定给x变量,let y = x相当于创建了一个x的副本,并且将这个副本绑定给了y。现在有了两个变量,xy,都等于 1。这也正是事实上发生了的,因为整数是有已知固定大小的简单值,所以这两个 1 被放入了栈中。

上面是已知固定大小的简单例子,现在看一下复杂的例子就是String

fn main() {
    let str1 = String::from("hello");
    let str2 = str1;
}
复制代码

上述代码看起来和整数的例子非常相似,所以我们可能会假设他们的运行方式也是类似的:也就是说,第二行可能会生成一个 str1 的副本并绑定到 str2 上。不过,事实上并不完全是这样。

首先我们需要知道String底层是什么样的,String在内存中由三部分组成,如下如所示,是将值hello绑定给str1String在内存中的表现形式。一个指向存放字符串内容内存的指针,一个长度,和一个容量。这一组数据存储在栈上。右侧则是堆上存放内容的内存部分。

图1

图1

长度表示 String 的内容当前使用了多少字节的内存。容量是 String 从操作系统总共获取了多少字节的内存。长度与容量的区别是很重要的,不过在当前上下文中并不重要,所以现在可以忽略容量。

当我们将 str1 赋值给 str2String 的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。换句话说,内存中数据的表现如下图所示。

image-20211122150620699

图2

这个表现形式看起来 并不像 下图 中的那样,如果 Rust 也拷贝了堆上的数据,那么内存看起来就是这样的。如果 Rust 这么做了,那么操作 str2 = str1 在堆上数据比较大的时候会对运行时性能造成非常大的影响。

image-20211122150730454

图3

之前我们提到过当变量离开作用域后,Rust 自动调用 drop 函数并清理变量的堆内存。不过图 2 展示了两个数据指针指向了同一位置。这就有了一个问题:当 str2str1 离开作用域,他们都会尝试释放相同的内存。这是一个叫做 二次释放double free)的错误,也是之前提到过的内存安全性 bug 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。

为了确保内存安全,这种场景下 Rust 有另一个独到的处理。与其尝试拷贝被分配的内存,Rust 则认为 str1 不再有效,因此 Rust 不需要在 str1 离开作用域后清理任何东西。看看在 str2 被创建之后尝试使用 str1 会发生什么:

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

	println!("{}, world!", s1);
}
复制代码

运行cargo run就会报错,因为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
复制代码

如果你在其他语言中听说过术语 浅拷贝shallow copy)和 深拷贝deep copy),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 Rust 同时使第一个变量无效了,这个操作被称为 移动move),而不是浅拷贝。上面的例子可以解读为 s1移动 到了 s2 中。那么具体发生了什么,如下图 所示。

image-20211122151302629

这就解决了二次释放的错误,因为只有 s2 是有效的,当其离开作用域,它就释放自己的内存,完毕。

另外,这里还隐含了一个设计选择:Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何 自动 的复制可以被认为对运行时性能影响较小。

结语

文章首发于微信公众号程序媛小庄,同步于掘金

码字不易,转载请说明出处,走过路过的小伙伴们伸出可爱的小指头点个赞再走吧(╹▽╹)

猜你喜欢

转载自juejin.im/post/7033982002718048270