Rust学习笔记2 - 所有权

本文是 Rust 学习笔记系列第二篇,参考 Rust 指南的第 4 章,涉及所有权、借用、切片。

所有权(Ownership)是 Rust 中最为独特的一个特性,它允许 Rust 在不需要垃圾回收机制的情况下保证内存安全。所有权机制是掌握 Rust 的关键。

所有权

所有权是 Rust 的核心机制。所有权本身简单的,很容易解释的,但是对 Rust 其他的地方影响深远。

所有的程序都需要在运行时管理内存。有些语言提供了垃圾回收机制来不断查找不会被使用的内存;有些语言开发者必须自己处理内存的申请和释放。Rust 与以上两种都不同,通过编译器在编译期检查一系列所有权相关的规则来管理内存,而这些规则在运行时并不会带来额外的开销。

栈与堆

在很多语言中,开发者不需要经常考虑栈和堆的问题。但是在系统编程语言中,值存储在栈中与堆中则会影响语言如何操作该值。

栈(Stack)和堆(Heap)都是的运行时可用内存的一部分,但是他们的结构是不同的。栈储存的值会以与创建相反的顺序销毁,即「后进先出」规则。所有在栈中存储的值都必须有已知且固定的尺寸。尺寸未知或者会被改变的值应该存储在堆中。相比于栈,堆比较宽松,向堆中存值的时候,首先申请固定尺寸的空间,内存分配器会在堆寻找一块相同尺寸的未被使用连续空间,标记为被使用,然后返回一个指向这段空间开始位置的指针。这个过程被称为「堆分配」,通常简称为「分配」。指针是一个已知固定尺寸的值,所有可以在栈中保存指针,但是访问实际数据的时候必须通过该指针读取实际内存。

要比喻的话,堆就像餐厅里的桌位分配。

栈中的值的访问要比堆快,因为既不需要在存值时寻找空闲位置,也不需要在读取时通过指针寻址。

当调用函数时,参数会传入函数,而函数中的局部变量则会分配在栈中,当函数执行结束时,这些局部变量会被释放。

而在堆中跟踪使用空间,最小化重复数据,清除无用数据则是所有权机制要处理的问题。一旦理解了所有权机制,就不需要再经常考虑堆和栈的问题了。

所有权规则

Rust 的所有权规则有 3 条:

  1. 每个值都被一个变量所拥有,这个变量被称为「所有者」
  2. 一个值同时只能有一个所有者
  3. 一旦所有者脱离了作用域,值就会被释放

变量作用域

变量的作用域是指在程序中可以访问该变量的范围。变量从其声明时有效,到其作用域结束时失效。

与其他语言中的作用域规则类似(比如 EcmaScript 6 的块级作用域)。

String 类型

为了解释所有权规则,需要比标量类型、复合类型(这两种都是存储在栈中的)更加复杂的类型作为例子。

我们用 String 类型作为例子。我们已经知道字符串字面量("abc"),但是字面量是不可变的。当我们需要处理可变的文本或者编译期不可知的文本时,我们就需要另一种类型 String,这种类型的数据会被分配在堆中,且允许存储编译期不可知的文本。可以通过字符串字面量来初始化 String 类型的值:

1
let s = String::from("hello");

:: 操作符允许我们将 from 函数放入 String 类型的命名空间,避免使用 string_from 之类的名字,也容易避免命名冲突。

String 类型的值可以是可变的:

1
2
3
4
5
let mut s = String::from("hello");

s.push_str(", world!");

println!("{}", s);

内存与分配

字符串字面量是编译期已知的值,文本是直接硬编码进可执行程序的。所以使用字面量会快而有效率。但是这都是源于字面量的不可变性。

String 类型中,为了处理可变的文本,需要在堆中分配内存。这就表示:

  1. 内存必须在运行时申请
  2. 需要在用完之后释放这些内存

第一条,内存申请,已经通过 String::from 完成了,它的内部实现会申请需要的内存。这和其他的变成语言类似。

但是第二天就不一样了。在提供了 GC(垃圾回收器)的语言中,GC 会跟踪并在内存不再使用的时候,释放这段内存。但是如果没有 GC,我们就需要自己辨别内存在什么时候不会再被使用,并且用代码去释放内存。如何正确的做到这一点是一个历史难题。如果忘记释放,则会浪费内存。如果过早释放,则会导致值不可用。如果释放两次同样会导致 BUG。

Rust 采用第三种途径:当所有者脱离作用域(变量不再会被使用时)就释放掉对应的内存。

1
2
3
{
let s = String::from("hello"); // s定义好后就可用了
} // 作用域结束,s不可用,String值所使用的内存被释放

s 脱离作用域是,Rust 会调用 dropString 类型可以通过 drop 函数来定义如何释放内存。Rust 在块结束时会自动调用 drop

这种模式极大的影响了 Rust 的代码书写。刚刚的例子看起来还很简单,但是更复杂的情况、要处理多个堆变量的时候,可能会产生非预期的结果。

变量与数据交互:移动

多个变量处理同一个数据有多种不同的情况。

比如:

1
2
let x = 5; // 把值5绑定给变量x
let y = x; // 复制x的值给y

由于 5 是标量类型,在栈中存储,值赋值给新的变量是直接复制的。

再看 String 的情况:

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

String 的文本数据储存在堆中,栈中会储存堆内存的指针、申请空间的尺寸、已使用空间的尺寸。将 s1 赋值给 s2 时,栈内存会被复制,而堆内存并不会被复制,也就是说 s1s2 同时拥有同一片内存的指针。

之前讲过,Rust 对脱离作用域的变量自动调用 drop 函数,那这种情况下 s1s2 共同指向的那块堆内存就会被释放两次。这被称为「双重释放」错误,是之前提到的一种一种内存安全缺陷。

为了保证内存安全,Rust 实际上还多做了一些事情,将 s1 赋值给 s2 时,会标记 s1 失效。这样,在 s1 脱离作用域时,Rust 就不会对它调用 drop 函数了。

1
2
let s1 = String::from("hello"); // s1 有效
let s2 = s1; // s1 失效,s2 有效

其他语言里有深拷贝和浅拷贝的概念,Rust 中的做法看起来比较像浅拷贝,但是 Rust 同时会让前一个变量失效,这种行为可以叫做「移动」。

此外,Rust 不会自动进行「深拷贝」。所以任何自动进行的拷贝可以认为是开销较小的。

变量与数据交互:克隆

如果我们确实需要进行深拷贝 String 的数据,我们可以调用它的 clone 方法。

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

这种方式会进行堆内存的复制。

任何使用 clone 的场景可以认为是有一定开销的。

栈数据:拷贝

还有一种情况是前面提到的例子:

1
2
let x = 5; // 把值5绑定给变量x
let y = x; // 复制x的值给y

这种情况下不需要调用 clonex 也仍然有效,并不是「移动」到了 y

原因是整数是一种固定已知尺寸的类型,且完全存储在栈中,所以拷贝实际上可以非常快地完成。而且因为不会产生「双重释放」问题,也没有理由将 x 禁用掉。换言之这种情况不存在深拷贝/浅拷贝的区别。

Rust 有一种特殊的标记:Copy 特性(trait),用于描述类似整数这样的仅存储在栈的类型。如果一种类型有 Copy 特性,那赋值后,旧的变量依旧有效。Rust 不允许开发者让任何实现了 Drop 特性(或者其一部分实现了 Drop 特性)的类型被标记为 Copy 特性。如果任何类型在变量脱离作用域时需要执行额外代码且标记了 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"); // s进入了作用域
takes_ownership(s); // s移动进了函数,并在当前作用域失效

let x = 5; // x进入了作用域
makes_copy(x); // x需要进入函数,但是x是可复制的,所有x仍然有效
} // x,s依次离开作用域,不需要drop

fn takes_ownership(some_string: String) { // some_string进入作用域
println!("{}", some_string);
} // some_string离开作用域,`drop` 会被调用

fn makes_copy(some_integer: i32) { // some_integer进入作用域
println!("{}", some_integer);
} // some_integer离开作用域,不需要drop

返回值与作用域

函数返回值也会造成所有权转移

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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,在当前作用域失效
// 且takes_and_gives_back的返回值移动到了s3
} // s3, s2, s1 依次脱离作用域,s1和s3会被drop,s2不会被drop

fn gives_ownership() -> String {
let some_string = String::from("hello"); // some_string进入作用域
some_string // some_string作为返回值移出了函数,在当前作用域失效
} // some_string脱离作用域,不会被drop

fn takes_and_gives_back(a_string: String) -> String { // a_string进入作用域
a_string // a_string作为返回值移出了函数,在当前作用域失效
} // a_string脱离作用域,不会被drop

根据这些规则,我们来试一试获取字符串的长度并打印出来:

1
2
3
4
5
6
7
8
9
10
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() returns the length of a String
(s, length)
}

可以看到,如果需要把字符串传到函数里,那么所有权会转移进去,然后我们用元组把字符和长度都传出来,才能够同时打印字符串和长度。

然而这样写是很繁琐的,所有传到函数里的内容如果还需要再用还得传出来。

我们可以用引用来解决这个问题。

引用与借用

我们先看一下 Rust 里面如何用引用解决上面的问题:

1
2
3
4
5
6
7
8
9
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // 向函数提供String的引用而不是它本身
println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize { // 接受String的引用
s.len()
}

Rust 中,& 符号代表引用(References),可以在不影响所有权的情况下引用值。

引用的反操作是解引用(Dereferencing)用符号 * 表示。之后会介绍如何使用解引用。

我们再看一下上面的例子,所有权和内存的变化:

1
2
3
4
5
6
7
8
9
fn main() {
let s1 = String::from("hello"); // s1进入作用域
let len = calculate_length(&s1); // s1向calculate_length提供引用,所有权不变
println!("The length of '{}' is {}.", s1, len);
} // s1离开作用域,被drop

fn calculate_length(s: &String) -> usize { // 拿到s的引用
s.len()
} // s离开作用域,因为是引用,没有所有权,所以不进行drop

使用引用作为函数参数的行为就称为是「借用」(Borrowing)。

和变量一样,引用默认是不可变的。

如果我们尝试修改引用的内容,会得到如下错误:

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

fn change(some_string: &String) {
some_string.push_str(", world");
}
1
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference

可变引用

我们可以用可变引用(&mut)来达到修改引用的内容的目的。

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

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

可变引用有一个很重要的限制:对同一个数据,在同一个作用域内同时只能有一个可变引用。

这个限制让可变性在一个非常受控的范围内。Rust 的新手可能会很不习惯,因为其他语言通常允许随时修改。

这个限制的好处就是我们可以在编译期间规避掉「数据竞争」的问题。数据竞争的出现条件如下:

  1. 有多个指针指向同一片数据
  2. 至少有一个指针用于写数据
  3. 没有同步访问数据的机制

数据竞争可能导致非预期的行为,并且由于其不确定性,很难去定位和修复问题。Rust 可以在编译期发现数据竞争并产生编译错误。

可以通过创建块来使用多个可变引用:

1
2
3
4
5
6
7
let mut s = String::from("hello");

{
let r1 = &mut s;
} // r1在这里就走出作用域了,所以接下来可以创建另一个可变引用

let r2 = &mut s;

可变引用还有另一个限制:不能同时拥有可变引用和不可变引用。

因为当使用不可变引用时,我们不希望数据在使用过程中产生变化。因为不可变引用不会修改值,所以多个不可变引用是可以同时存在的。

悬挂引用

在有指针的语言里,很容易错误地得到「悬挂引用」,即指针指向一片已经释放掉的内存。而 Rust 的编译期会保证不存在悬挂引用,它会保证数据本身的释放不会发生在引用释放之前。

引用的规则

总结一下上述提及的引用的规则:

  1. 在任何时间点,只能拥有一个可变引用或者多个不可变引用
  2. 引用必须一直可用(不能出现数据先释放的情况)

切片

另一个没有所有权的类型是「切片」(Slice)。切片允许引用连续的集合元素而不是整个集合。

考虑这样一个问题:写一个函数从字符串中找到第一个单词,如果找不到空格,就返回整个字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 使用借用,因为我们不需要获取所有权
// 由于还不知道如何表达字符串的一部分,所以先返回单词结束的位置
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes(); // as_bytes可以将字符串转换为字节数组

// iter用于返回集合中的每个元素
// enumerate用于包装iter的结果,将每个元素转换为元组(下标 + 元素的引用)
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}

上述函数存在一些问题:

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

let word = first_word(&s); // = 5

s.clear(); // 清空了字符串的内容,变成了 ""

// 此处,单词结束的下标仍然是5,然而字符串的内容已经清空了
}

管理这个下标和字符串数据的同步关系是很头疼的一件事。

我们可以用 Rust 的字符串切片来解决这个事情:

字符串切片

字符串切片是对字符串的一部分的引用,看起来像这样:

1
2
3
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];

切片的语法与引用类似,多出了中括号的部分([0..5])。切片使用中括号表示的范围来指定范围,第一个数字代表开始的位置,第二个数字为切片结束的位置+1。

切片的数据结构中会存储切片的开始位置以及切片的长度。

使用 Rust 的范围语法(..),从第一个元素开始可以省略第一个数字 0;以最后一个元素结束可以省略第二个数字:

1
2
3
4
5
6
7
8
9
10
11
12
let s = String::from("hello");

// 下面每一组的表达式都是等价的

let slice = &s[0..2];
let slice = &s[..2];

let slice = &s[3..s.len()];
let slice = &s[3..];

let slice = &s[0..s.len()];
let slice = &s[..];

注意:字符串切片必须在 UTF-8 有效边界处切断,如果在多字节字符中间切断会产生运行时错误。

了解了以上关于字符串切片的内容后,我们重写一下刚刚的函数:

1
2
3
4
5
6
7
8
9
10
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();

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

现在我们使用 first_word 的时候,我们会得到对 s 的部分内容的切片。

有了这个更直接的 API,并且编译期还会为我们保证其安全性(因为切片也是引用的一种,要遵循上面的引用的规则),如果我们在持有切片的情况下尝试修改数据,会产生编译错误。

字符串常量是切片

回顾一下字符串常量。我们会发现它是字符串切片,引用了二进制程序数据的一部分。

字符串切片用于参数

如果我们把 first_word 的参数类型改为字符串切片 &str,我们就可以用于更广泛的场景了:

1
2
3
4
first_word("hello word");

let s = String::from("hello world");
first_word(&s[..]);

其他类型的切片

字符串切片只针对字符串。还有更加通用的切片类型:

1
2
let a = [1, 2, 3, 4, 5];
let s = &a[1..3]; // &[i32]

其他类型的切片可以表示为 &[item_type]。原理和字符串切片是一样的。可以在其他所有的集合中使用这些切片。以后介绍向量(vectors)时会详细介绍这些集合。

总结

所有权、借用、切片的概念用于在编译期保证内存安全。Rust 与其他系统编程语言一样允许控制内存,但是在所有者脱离作用域时自动清理数据的特性让我们不用再担心内存的释放问题。

所有权对 Rust 的其他部分影响深远,我们在介绍其他部分的时候再详细说明。

# Rust
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×