掌握Rust:从零开始的所有权之旅

文章目录

  1. 1. 移动?拷贝?
  2. 2. 作用域和销毁
  3. 3. 借用
  4. 4. 修改
  5. 5. 可变借用
  6. 6. 所有权原则
  7. 7. 内部可变性
  8. 8. 生命周期
  9. 9. 总结

所有权是 Rust 很有意思的一个语言特性,但对于初学者却是一个比较有挑战的内容。

今天尝试用代码示例来聊聊 Rust 的所有权是什么,以及为什么要有所有权。希望能给初学的朋友一点帮助。

Tips:文中代码有相应注释,建议可以先不用纠结细节,关注整体。后边可以再挨个去研究具体代码细节

移动?拷贝?

先来试试常规的赋值语句在Rust有什么样的表现

1
2
3
4
5
6
7
8
9
10
println!("start");
// code 1:
let a = 1;
let _b = a;
let _c = a;

// code 2:
let d = String::from("hello");
let _e = d;
let _f = d;

结果是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
error[E0382]: use of moved value: `d`
--> src/main.rs:12:10
|
10 | let d = String::from("hello");
| - move occurs because `d` has type `String`, which does not implement the `Copy` trait
11 | let _e = d;
| - value moved here
12 | let _f = d;
| ^ value used here after move
|
help: consider cloning the value if the performance cost is acceptable
|
11 | let _e = d.clone();
| ++++++++

为什么 code 2 出错了? code 1 没有?

看起来都是初始化赋值操作,分别将数字 a 和字符串 d 多次赋值给别的变量
为什么字符串的赋值失败了。

这里要引出 Rust 世界里对值拷贝所有的区分

对于一切变量,当把他传递给别的变量或函数,如果他可以拷贝(Copy)就复制一份;否则就将值的所有权移动(Move)过去。

这里a是数字,数字是可以拷贝的,所以 code 1 是可以编译通过的。
d是字符串,字符串是不可以拷贝的,第一次赋值就将所有权move给了_e,只能move一次,所以 code 2 编译不通过。

为什么要拷贝或移动?先剧透下 Rust 没有内存垃圾回收器(GC),它对内存的管理就是依赖所有权,谁持有(Own)变量,谁可以在变量需要销毁时释放内存。

我们拿代码看看它如何销毁变量

作用域和销毁

这里我们关注在何时销毁的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 因为孤儿原则,包装原生string类型,来支持添加drop trait实现,来观察销毁
#[derive(Debug)]
struct MyString(String);
impl MyString {
fn from(name: &str) -> Self {
MyString(String::from(name))
}
}
struct MyData {
data: MyString,
}
// 销毁时打印字符串
impl Drop for MyString {
fn drop(&mut self) {
println!("Dropping MyString with value: {:?}", self.0);
}
}
// 销毁时打印包含字符串的结构体
impl Drop for MyData {
fn drop(&mut self) {
println!("Dropping MyData with value: {:?}", self.data);
}
}

fn main() {
{
let _ = MyData {
data: MyString::from("not used"),
};
let _wrapper = MyData {
data: MyString::from("used as variable"),
};
println!("End of the scope inside main.");
}

println!("End of the scope.");
}

运行结果是:

1
2
3
4
5
6
Dropping MyData with value: MyString("not used")
Dropping MyString with value: "not used"
End of the scope inside main.
Dropping MyData with value: MyString("used as variable")
Dropping MyString with value: "used as variable"
End of the scope.

代码分了两个作用域(Scope

Tips: 其实有多个,每个let也可以看做是一个作用域,这里为了方便理解,只分了两个

  • main函数自身的scope

  • main函数内的scope

    在此作用域内_变量的结构体及包含的字符串就销毁了。
    这里let _代表这个变量被忽略,也无法再被别人使用,所以当即销毁

    离开此作用域时,局部变量_wrapper也被销毁

结合之前字符串不能多次移动,这里就展示Rust对内存管理的两个原则:

  • 值只能有一个所有者,当离开作用域,值将被丢弃
  • 所有权可以转移

嗯,这么搞确实很利于内存管理。

那要只是想引用一个变量,不想移动怎么办?(毕竟移动只能一次)

借用

先来看看常规的“引用”

1
2
3
4
5
6
7
println!("start");
let a = String::from("hello");
let d = &a;
// 等效于
// let ref d = a;
let _e = d;
let _f = d;

这段代码是可以编译通过的

Tips,Rust在编译阶段就能分析出很多代码问题,这也是为什么前边的错误里没有打印“start”,因为编译就失败了

Rust里对“引用”有细分,这里叫借用(Borrow),至于为什么,我们后边讲

从目前的代码看,如果一个变量借用了字符串变量,这个借用是可以赋值给多个变量的。

这样对于不需要Move整个字符串,只是要借用值来说,使用确实方便多了,那借用什么时候回收呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 增加一个借用结构体
struct MyDataRef<'a> {
reference: &'a MyData,
}

// 对应的drop trait实现
impl Drop for MyDataRef<'_> {
fn drop(&mut self) {
println!("Dropping MyDataRef");
}
}

fn main() {
{
let a = MyData {
data: MyString::from("used as variable"),
};
let b = MyDataRef { reference: &a };
let c = MyDataRef { reference: &a };
println!("End of the scope inside main.");
}

println!("End of the scope.");
}

结果是:

1
2
3
4
5
6
End of the scope inside main.
Dropping MyDataRef
Dropping MyDataRef
Dropping MyData with value: MyString("used as variable")
Dropping MyString with value: "used as variable"
End of the scope.

在销毁借用的变量前,先销毁了所有的借用。哈哈,你可以有多个借用(准确说是不可变借用immutable borrow),后边在展开),但销毁变量时,所有借用都会被一起销毁,这样保证你不是借用一个已经销毁的变量(use after free

修改

到这里我们都没有修改过一个变量

Rust能像别的语言这样赋值修改么?

1
2
let d = String::from("hello");
d = String::from("world");

结果是不行

1
2
3
4
5
6
7
8
9
10
error[E0384]: cannot assign twice to immutable variable `d`
--> src/main.rs:33:5
|
32 | let d = String::from("hello");
| -
| |
| first assignment to `d`
| help: consider making this binding mutable: `mut d`
33 | d = String::from("world");
| ^ cannot assign twice to immutable variable

Rust对读取和修改是有区分的,像错误提示那样

需要mut关键字来声明变量可修改

1
2
let mut d = String::from("hello");
d = String::from("world");

那对应的销毁时什么样的呢?

1
2
3
4
5
6
7
8
9
10
11
fn main() {
{
let mut wrapper = MyData {
data: MyString::from("used as mut variable1"),
};
wrapper.data = MyString::from("used as mut variable2");
println!("[Mutable] End of the scope inside main.");
}

println!("End of the scope.");
}

结果是

1
2
3
4
5
Dropping MyString with value: "used as mut variable1"
[Mutable] End of the scope inside main.
Dropping MyData with value: MyString("used as mut variable2")
Dropping MyString with value: "used as mut variable2"
End of the scope.

基本和之前不可变(immutable)变量销毁类似,唯一不同是赋值后,赋值前的值要被销毁,内存的管理很是细致啊。

现在说了借用,说了可变,我们可以来看看前边提到借用是有区分的:还有一个可变借用(mutable borrow

可变借用

对于可变变量,是可以有对应的可变借用的

1
2
3
let mut d = String::from("hello");
let g = &mut d;
*g = "world".to_string();

那如果同时有可变借用和不可变借用,下边的代码可以么?

1
2
3
4
5
6
7
8
fn main() {
let mut d = String::from("hello");
let e = &d;
let f = &d;
let g = &mut d;
*g = "world".to_string();
println!("{f}");
}

答案是不可以

1
2
3
4
5
6
7
8
9
10
error[E0502]: cannot borrow `d` as mutable because it is also borrowed as immutable
--> src/main.rs:5:13
|
4 | let f = &d;
| -- immutable borrow occurs here
5 | let g = &mut d;
| ^^^^^^ mutable borrow occurs here
6 | *g = "world".to_string();
7 | println!("{f}");
| --- immutable borrow later used here

编译器明确告诉我们,可变借用的时候不能同时有不可变借用。

为什么,如果拿读写互斥锁来类比,就很好理解了,我有可变借用,就像拿到写锁,这个时候是不允许有读锁的,不然我修改和你读取不一致怎么办。

这是就得出了所有权里借用的规则:

  • 不可变借用可以有多个
  • 可变借用同一时间只能有一个,且和不可变借用互斥

所有权原则

到此,所有权的三条原则就全部出来了

  • 值有且只有一个所有者, 且所有者离开作用域时, 值将被丢弃
  • 所有权可转移
  • 借用
    • 不可变借用可以有多个
    • 可变借用同一时间只能有一个

这些规则,规范了对于一个变量谁持有,离开作用域是否可以释放,变量的修改和借用有什么样要求,避免释放后的内存被借用,也防止修改和读取的内容不一致有race condition的问题。

最厉害的是这些都是编译阶段就分析保证了的,提前暴露了问题,不然等到代码上线了遇到问题再crash,追查起来就滞后太久了。

到这所有权就结束了么?还没有,快了,再耐着性子往下看

内部可变性

目前为止,一个借用要么是只读的要么是可写的,限制都很严格,万一我想需要写的时候再可写,平时只要一个只读的借用就可以,能搞定么?

能!

Rust提供了Cell(针对实现Copy的简单类型)
RefCell(针对任何类型,运行时做借用检查)
Arc(多线程安全的引用计数类型)等类型,来支持内部可变性。
MutexRwLock也是内部可变性的一种实现,只不过是在多线程场景下的。

Tips: 本质上可以理解为对读写互斥的不同粒度下的封装,不需要显式声明可变借用,但内部有可变的能力

RefCell为例,来看看内部可变性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::cell::RefCell;
let value = RefCell::new(5);
// Mutate the value using an immutable reference
// 读取
let borrowed = value.borrow();
println!("Before mutation: {}", *borrowed);
drop(borrowed);
// Interior mutation
{
// 修改
let mut borrowed_mut = value.borrow_mut();
*borrowed_mut += 1;
}
// 读取
let borrowed = value.borrow();
println!("After mutation: {}", *borrowed);

生命周期

终于到了最后一个话题,生命周期

下边一段简单的字符串切片的长度比较函数

你能想到它为什么编译不通过么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn longest(str1:  &str, str2: &str) -> &str {
if str1.len() > str2.len() {
str1
} else {
str2
}
}

fn main() {
let str1 = "hello";
let str2 = "world!";

let result = longest(str1, str2);
println!("The longest string is: {}", result);
}

错误是:

1
2
3
4
5
6
7
8
9
10
11
error[E0106]: missing lifetime specifier
--> src/main.rs:1:39
|
1 | fn longest(str1: &str, str2: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `str1` or `str2`
help: consider introducing a named lifetime parameter
|
1 | fn longest<'a>(str1: &'a str, str2: &'a str) -> &'a str {
| ++++ ++ ++ ++

编译器再一次友好的提示我们,函数入参两个借用,返回值一个借用,无法确定返回值是用了哪个入参的生命周期。

一个新的概念出现了:生命周期

生命周期是Rust用来标注引用存活周期,借此标识变量的借用与作用域是否合法,即借用是否在作用域内还有效,毕竟不能悬空指针(dangling pointer, 借用一个失效的内存地址)啊。

就像这里,函数返回一个借用,那返回的借用是否在作用域内合法,和入参的两个引用的关系是什么,靠的就是生命周期标注。如果入参和出参都是一个生命周期,即出参的借用在入参的借用作用域内,只要入参的生命周期合法,那出参的就是合法的。不然如果出参用了只是借用函数内部变量的生命周期,那函数返回后,函数内部变量就被销毁了,出参就是悬空指针了。

你可以简单理解为给借用多增加了一个参数,用来标识其借用在一个scope内使用是否合法。

题外话,其实你如果了解Golang的逃逸分析,比如当函数内部变量需要返回给函数外部继续使用,其实是要扩大内部变量的作用域(即内部变量的生命周期),不能只依靠当前函数栈来保存变量,就会把它逃逸到堆上。 它做的其实也是变量的生命周期分析,用增加堆的内存开销来避免悬空指针。
只不过那是在gc基础上一种优化,而Rust则是在编译期就能通过生命周期标注就能确定借用是否合法。
对于想把内部变量返回给外部使用的情况,Rust也提供了Box来支持,这里就不展开了。

那是不是每个借用都要标注?

也不是,rust默认会对所有借用自动标注,只有出现冲突无法自动标注的时候才需要程序员手动标注。如果感兴趣的话,可以深入看下Subtyping and Variance,了解下生命周期的一些约束。

最后我们看下下边编译不通过的代码,从编译期的报错你就应该能明白,为什么要生命周期标注了,它对于让编译期做借用的作用域合法性检查很有用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn get_longest<'a>(str1: &'a str, str2: &'a str) -> &'a str {
if str1.len() > str2.len() {
str1
} else {
str2
}
}

fn main() {
let result;
{
let str1 = String::from("hello");
let str2 = "world!";
result = get_longest(str1.as_str(), str2);
}

println!("The longest string is: {}", result);
}

错误是:

1
2
3
4
5
6
7
8
9
10
11
12
13
error[E0597]: `str1` does not live long enough
--> src/main.rs:15:30
|
13 | let str1 = String::from("hello");
| ---- binding `str1` declared here
14 | let str2 = "world!";
15 | result = get_longest(str1.as_str(), str2);
| ^^^^^^^^^^^^^ borrowed value does not live long enough
16 | }
| - `str1` dropped here while still borrowed
17 |
18 | println!("The longest string is: {}", result);
| ------ borrow later used here

总结

好了,收个尾吧:

  • 所有权关注的是值的拥有和管理
  • 借用检查器在编译时保证借用的有效性和安全性
  • 生命周期关注的是借用的有效范围和引用的合法性

他们配合在一起,构建起了Rust强大的内存管理能力。避免了内存泄漏和悬空指针的问题,也避免了GC带来的性能问题。

怎么样?是不是感觉Rust的所有权设计还挺有意思的?一个所有权把内存管理的清晰又明了!

欢迎有问题的朋友留言讨论。

如有疑问,请文末留言交流或邮件:newbvirgil@gmail.com 本文链接 : https://newbmiao.github.io/2023/08/13/master-rust-ownership-from-scratch.html