0%

Rust 权威指南(03):认识所有权

所有权是 Rust 中最独特的一个功能,正是所有权概念和相关工具的引入,Rust 才能够在没有垃圾回收机制的前提下保障内存安全。

什么是所有权

一般来说,所有程序都需要管理自己在运行时使用的计算机内存空间:

  • 一些使用垃圾回收机制的语言会在运行时定期检查并回收那些没有被继续使用的内存
  • 而在另外一些语言中,程序员需要手动地分配和释放内存

Rust 采用了与众不同的第三种方式:它使用包含特定规则的所有权系统来管理内存,这套规则允许编译器在编译过程中执行检查工作,而不会产生任何运行时开销

栈与堆

对于 Rust 这样的系统级编程语言,一个值被存储在栈上还是被存储在堆上会极大地影响语言的行为,进而影响编写代码时的设计决策。栈和堆都是代码在运行时可以使用的内存空间,但是它们通常以不同的结构组织而成。

所有存储在栈中的数据都必须拥有一个已知且固定的大小,对于那些在编译器无法确定大小的数据,你就只能将它们存储在堆中。而堆空间的管理较为松散:当你希望将数据放入堆中时,可以请求特定大小的空间。操作系统会根据你的请求在堆中找到一块足够大的的可用空间,将它标记为可用,并把指向这块空间地址的指针返回给我们。该过程也称为 堆分配

向栈上推入数据要比在堆上进行分配更有效率一些,因为操作系统省去了搜索新数据存储位置的工作,该位置永远处于栈的顶端。而且由于多了指针跳转的环节,所以访问堆上的数据要慢于访问栈上的数据。

所有权规则

如下列出了 Rust 中的所有权规则:

  • Rust 中的每一个值都有一个对应的变量作为它的 所有者
  • 在同一时间内,值有且只有一个所有者
  • 当所有者离开自己的作用域时,它持有的值会被释放掉

作用域

简单来说,作用域是一个对象在程序中有效的范围。变量从声明的位置开始直到当前作用域结束都是有效的。之前我们介绍的数据类型都是将数据存储在栈上,并在离开自己的作用域时将数据弹出栈空间。这里我们使用一个存储在堆上的数据类型来研究 Rust 是如何自动回收这些数据的。

String 类型会在堆上分配自己需要的存储空间,所以它能够处理在编译时未知大小的文本。因此对于 String 类型的数据:

  • 使用的内存是由操作系统在运行时动态分配出来的(堆内存分配)
  • 当使用完 String 时,需要通过某种方式来讲这些内存归还给操作系统

当我们创建 String 类型的数据时,就会完成堆内存的分配请求。一般来说,都是由程序员来发起堆内存的分配请求。但对于堆内存的释放,不同编程语言采用的方式就有所不同了:

  • 某些拥有垃圾回收(Garbage Collector,GC)机制的语言,GC 会替代程序员来负责记录并清楚那些不再使用的内存
  • 而对于那些没有 GC 的语言,识别不再使用的内存并调用代码显示释放的工作依然需要程序员完成

而 Rust 则提供了另一套解决方案:内存会自动地、在拥有它的变量离开作用域后进行释放。Rust 在变量离开作用域时,会调用一个名为 drop 的特殊函数,可以在该函数中编写释放内存的代码。这类似于 C++ 中的 RAII(Resource Acquisition Is Initialization)。

Rust 中的多个变量采用一种独特的方式来与同一数据进行交互。当执行如下代码时,变量 x 和 y 的值都是 5,由于整数数据类型是保存在栈上的,因此两个值 5 会同时推入当前栈中

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

但是对于 String 版本,由于Rust 不会在复制值时深度地复制堆上的数据,此时内存布局类似于下图:

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

当一个变量离开当前的作用域时,Rust 会自动调用它的 drop 函数并将变量使用的堆内存释放回收。对于上述情景,为了防止重复释放内存,同时又为了避免复制分配的内存,Rust 在这种情形下会简单地将 s1 废弃,不再将其视为一个有效的变量。因此 Rust 也不需要在 s1 离开作用域后清理任何东西。试图在 s2 创建完成之后使用 s1 也会导致编译错误。在 Rust 中,通常使用 移动 来描述这一行为,即 s1 被移动到了 s2 中。

1
2
3
4
5
6
7
2 |     let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{} world!", s1);
| ^^ value borrowed here after move

当你确实需要深度拷贝 String 堆上的数据时,就可以使用一个名为 clone 的方法。这里也隐含了另一个设计原则:Rust 永远不会自动地创建数据的深度拷贝,因此在 Rust 中,任何自动赋值操作都可以被视为高效的。

Rust 提供了一个名为 Copy 的 trait,它可以用于整数这类完全存储在栈上的数据类型。一旦某种类型拥有了 Copy 这种 trait,那么它的变量就可以在复制给其他变量后依然保持可用性。如果一种类型或者该类型的任意成员实现了 Drop 这种 trait,那么 Rust 就不允许实现 Copy 这种 trait。

一般来说,任何简单标量的组合类型都是可以 Copy 的,任何需要分配内存或者某种资源的类型都不会是 Copy 的:

  • 所有的整数类型
  • 所有的浮点类型
  • 布尔类型
  • 字符类型
  • 如果元组中所有字段的类型都是 Copy 的,那么该元组也是 Copy 的

所有权与函数

将值传递给函数在语义上类似于对变量进行赋值,将变量传递给函数将会触发移动或者复制,就像是赋值语句一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
let s = String::from("Hello");
takes_ownership(s);

let x = 5;
makes_copy(x);
}

fn takes_ownership(some_string: String) {
println!("{}", some_string);
}

fn makes_copy(some_integer: i32) {
println!("{}", some_integer);
}

函数在返回值的过程中也会发生所有权的转移。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
let s1 = gives_ownership();

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

let s3 = takes_and_gives_back(s2);
}

fn gives_ownership() -> String {
let some_string = String::from("hello");
some_string
}

fn takes_and_gives_back(a_string: String) -> String {
a_string
}

变量所有权的转移总是遵循相同的模式:将一个值复制给另一个变量时就会转移所有权。当一个持有堆数据的变量离开作用域时,它的数据就会被 drop 清理回收,除非这些数据的所有权转移到了另一个变量上。

引用与借用

在所有的函数中都要获取所有权并返回所有权显得有些繁琐:当你希望在调用函数时保留参数的所有权,就不得不将传入的值作为结果返回。Rust 针对这类场景提供了 引用 功能。

1
2
3
4
5
6
7
8
9
10
fn main() {
let s1 = String::from("hello");

let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
s.len()
}

calculate_length 函数中,使用 String 的引用作为参数而没有直接转移值的所有权,而在调用函数时使用 &s1,它创建了一个执行 s1 的引用。& 代表的就是 引用 语义,它们允许你在不获取所有权的前提下使用值。与使用 & 进行引用相反的操作被称为 解引用,它使用 * 作为运算符。

引用不持有值的所有权,所以当引用离开当前作用域时,它指向的值不会被丢弃。这种通过引用传递参数给函数的方法也被称为 借用

与变量类似,引用默认是不可变的,Rust 不允许我们去修改引用指向的值。

1
2
3
4
5
6
7
8
9
fn main() {
let s = String::from("hello");

change(&s);
}

fn change(some_string: &String) {
some_string.push_str(", world");
}
1
2
   Compiling const_reference v0.1.0 (/root/code/private/rust/chapter_04/const_reference)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference

为了实现可变引用,需要做如下修改:

  • 将变量 s 声明为 mut,即可变的
  • 使用 &mut s 来给函数传入一个可变引用,函数签名也需要修改为 &mut String,表明该函数接收一个可变引用作为参数
1
2
3
4
5
6
7
8
9
fn main() {
let mut s = String::from("hello");

change(&mut s);
}

fn change(some_string: &mut String) {
some_string.push_str(", world");
}

可变引用在使用上有一个限制:对于特定作用域中的特定数据来说,一次只能声明一个可变引用。违背该原则将导致编译错误。该规则使得引用的可变性只能以一种受到严格限制的方式来使用。可以通过花括号来创建一个新的作用域范围,这就使得我们可以创建多个可变引用。但是这些可变引用不会同时存在。而且需要注意,不能在拥有不可变引用的同时创建可变引用。但是,同时存在多个不可变引用是合法的,对数据的只读操作不会影响到其他读取数据的用户。

在 Rust 语言中,编译器会确保引用永远不会进入垂悬状态(类似于 C 语言中的垂悬指针)。

1
2
3
4
5
6
7
8
9
fn main() {
let reference_to_nothing = dangle();
}

fn dangle() -> &String {
let s = String::from("hello");

&s
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
   Compiling dangle v0.1.0 (/root/code/private/rust/chapter_04/dangle)
error[E0106]: missing lifetime specifier
--> src/main.rs:5:16
|
5 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
|
5 | fn dangle() -> &'static String {
| +++++++

For more information about this error, try `rustc --explain E0106`.

简单总结下 Rust 中的引用:

  • 任何时间里,要么只能拥有一个可变引用,要么只能拥有任意数量的不可变引用
  • 引用总是有效的

切片

除了引用,Rust 还有另外一种不持有所有权的数据类型 切片。切片允许引用集合中某一段连续的元素序列,而不是整个集合。字符串切片是指向 String 对象中某个连续部分的引用,使用 &s[starting_index..ending_index] 创建一个 String 的切片引用(而不是整个字符串本身的引用)。字符串切片类型写作为 &str。字符串切片的边界必须位于有效的 UTF-8 字符边界内:

  • 当省略 .. 之前的值,表示希望范围从第一个元素开始
  • 当省略 .. 之后的值,表示想要包含 String 的最后一个字节
  • 当同时省略收尾两个值,创建指向整个字符串所有字节的切片
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
let s = String::from("hello world");
println!("{}", first_word(&s));
}

fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();

for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i]
}
}

return &s[..]
}

字符串字面量就是切片,它是一个指向二进制程序特定位置的切片。上面这个函数还可以进一步优化,在定义函数时使用字符串切片来代替字符串引用可以使得我们的 API 更加通用,而且不会损失任何功能:

  • 当持有 String 对象时,可以通过 &s[..] 创建完整 String 的切片来作为参数
  • 当持有字符串切片时,可以直接调用该函数
  • 而对于字符串字面量,本身就是切片,也可以直接调用函数

除了字符串切片,Rust 还有其他更加通用的切片类型。例如:

1
2
let a = [1, 2, 3, 4, 5]
let slice = &a[1..3]

这里 slice 就是 &[i32] 的类型,它在内部存储了一个指向起始元素的引用及长度,这与字符串切片的工作机制完全一样。

所有权、借用和切片的概念是 Rust 可以在编译时保证内存安全的关键所在:

  • 像其他系统级语言一样,Rust 给了程序员完善的内存使用控制能力
  • 而 Rust 还能够自动清除所有那些所有者离开作用域的数据,这极大地减轻了使用者的心智负担

Reference