德布罗煜
Rust与所有权

Rust与所有权

通过这篇文章,我们将来学习 Rust 最独特的特性 —— 所有权

什么是所有权

所有权 是 Rust 最独特的一个特性,它使得 Rust 无需 GC 也可以保证内存安全。

Rust 的核心特性就是所有权。所有计算机程序在运行时都需要管理它们使用计算机内存的方式。

  • 有些语言具有垃圾收集机制,比如 Java、C#,在程序运行时,它们会不断地寻找不再使用的内存。
  • 在其他语言中,则需要程序员显式地分配并释放内存,比如 C、C++。

而 Rust 采用了一种新的方式:它采用了一种所有权系统,内存通过该系统进行管理,其中包含一组编译器在编译时进行检查的规则。由于这套系统只在编译器作用,无需运行时 GC。因此,当程序运行时,所有权系统不会减慢程序的运行速度。

栈内存与堆内存

在像 Rust 这样的系统级编程语言里,一个值是在 stack 上还是 heap 上对语言的行为和你为什么要做某些决定有着重大的影响。
在你的代码运行时,stack 和 heap 都是你可以使用的内存,但是它们的结构差异很大。

Stack 会根据值的添加顺序进行储存,按相反的顺序将它们移除,也就是后进先出LIFO

  • 其中,添加数据的操作叫做压入栈
  • 移除数据的操作的操作叫做弹出栈

所有存储在 stack 上的数据必须拥有已知的固定大小,编译时大小未知或运行时大小可能发生变化的数据必须存放在 heap 上面。

heap 的内存组织性会较差一点,当你把数据放入 heap 时,你需要请求一块足够大小的空间。操作系统在 heap 中找到一块足够大小的空间,将它标记为“正在使用”,并返回一个指向这个空间地址的指针。

所有权存在的原因

要了解在 Rust 中为什么存在所有权的概念,我们需要先了解所有权解决了哪些问题:

  • 跟踪代码的哪些部分正在使用 heap 的哪些数据
  • 最小化 heap 上的重复数据量
  • 清理 heap 上未使用的数据以避免空间不足

所有权规则

所有权具有以下三条规则:

  • 每个值都有一个对应的变量,这个变量是该值的所有者
  • 每个值同时只能有一个所有者
  • 当所有者超出作用域(scope)时,该值将被删除

变量作用域

Scope,也就是作用域,是程序中一个项目的有效范围,我们来看一个简单的例子:

1
2
3
4
5
fn main () {
// s 未声明,不可使用
let s = 0; // s 可用
// 可以对 s 执行操作
} // s 作用域到此结束,不可使用

这样的设计可以使我们在 Rust 中每次分配内存都必然对应了一次释放。同时,我们也可以使用 drop 函数提前释放掉某个变量和它的所有权。

移动 Move

在大部分语言中,我们可以对一个变量进行声明并赋值,之后可以将这个变量再次赋值给一个新的变量。

1
2
let x = 5;
let y = x;

在 Rust 中,这两个变量各自持有了一个值为 5 的整数类型的值,且这个值是存放在 stack 中的,在后续的代码中它们两个都能够被使用。

然而对于某些存放在 heap 上的复杂类型来说,这样的写法会带来一些独特的效果:

1
2
3
4
5
6
7
fn main() {
let s_a = String::from("hello");
let s_b = s_a; // 这里s_a对应的字符串的所有权move给了s_b

// println!("s_a => {}", s_a); // s_a 已经不能再次使用
println!("s_b => {}", s_b);
}

这里我们解释为 s_a 对字符串"hello" 这个值的所有权被移动给了 s_b ,因此 s_a 不应该再被使用。

在尝试编译上述代码时,rustc 会发出警告 -- move occurs because `s_a` has type `std::string::String`, which does not implement the `Copy` trait 。这里提到了一个叫做 Copy 的 trait,我们将其称为复制。默认情况下,完全存储在 Stack 上的数据都实现了复制这一个 trait ,而实现了这个 trait 的类型,在旧的变量赋值后仍然可用。

而对于存放在 Heap 上的数据,如果我们需要对数据进行深拷贝的话,则需要用到 Clone 这个 trait 。

1
2
3
4
5
fn main() {
let s_a = String::from("hello");
let s_b = s_a.clone();
println!("s_a => {}, s_b => {}", s_a, s_b);
}

如果一个类型或者该类型的一部分实现了 Drop 这个 trait ,那么 Rust 不会再允许它实现 Copy 这个 trait 了。这是因为,在 Move 时,如果发生了 Copy 行为,由于 Copy 行为是隐式的,编译器很难预测什么时候调用 Drop 函数。
而实现了 Clone trait 的类型,由于需要类似 a.clone() 的显式调用行为,编译器就能够通过这种显式调用确定被 clone 的变量的位置,决定何时调用 drop 函数。

一些拥有 Copy trait 的类型

  • 任何简单标量的组合类型都是可以 Copy 的
  • 任何需要分配内存或某种资源的都不是 Copy 的
  • 一些拥有 Copy trait 的类型:
    • 所有的整数类型,如 u32
    • bool
    • char
    • 所有的浮点类型,如 f64
    • Tuple(元组),前提是其所有的字段都是可 Copy 的

所有权与函数

在语义上,将值传递给函数或是把值赋给变量是类似的,都会发生移动(Move)或是复制(Copy)
函数在返回值的过程中同样也会发生所有权的转移。

一个变量的所有权总是遵循相同的模式,把一个值赋给其他变量时就会发生移动。当一个包含 heap 数据的变量离开作用域时,它的值就会被 drop 函数清理,除非数据的所有权移动到另一个变量上了。

引用 Reference

如果我们想让函数获得某个值,但在调用函数后仍能获得该值的所有权,我们可以通过下面的写法将所有权通过返回值返回回来:

1
2
3
4
5
6
7
8
9
fn get_string_length(s: String) -> (String, usize) {
let length = s.len();
return (s, length);
}

fn main() {
let s = String::from("hello");
let (s, length) = get_string_length(s);
}

但是这样的做法太繁琐了,因此在 Rust 中,我们可以通过引用代替这种将所有权传来传去的写法。

1
2
3
4
5
6
7
8
9
fn get_string_length(s: &String) -> usize {
let length = s.len();
return length;
}

fn main() {
let s = String::from("hello");
let length = get_string_length(&s);
}

在这种写法中,我们并没有传递 s 的所有权,而是通过 & 符号声明我们传递的是一个 s 的引用,而不取得它的所有权。

但是在这里,我们是没有办法通过这个引用去修改 s 的值的。如果需要修改它的值,我们需要将这个引用声明为可变引用

1
2
3
4
5
6
7
8
9
fn append_string(s: &mut String, string: &str) {
s.push_str(string);
}

fn main() {
let mut s = String::from("hello");
append_string(&mut s, ", world");
println!("{}", s); // hello, world
}

可变引用有一个重要的限制:在特定的作用域内,对某一块数据,只能有最多一个的可变引用。这样设计的好处是在编译时就可以防止数据竞争。

另外,Rust 也不允许同时存在一个可变引用和不可变引用。

本文作者:德布罗煜
本文链接:https://kira.host/blog/Rust/Rust与所有权/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可