【Rust学习记录】4. 所有权

TwoSix Lv3

所有权和生命周期据说是Rust最难学也最核心的两个概念,也是Rust在没有垃圾回收的机制下确保内存安全的秘诀,现在就能开始接触这第一咯核心概念了。

什么是所有权

前言

一般内存管理就两种:1. 自动垃圾回收:在运行的时候定期检查并回收没有使用的内存;2. 程序员手动分配和释放;Rust提出了第三种规则,这套规则目的在于能让编译器在编译的过程中就检查内存问题,不需要在运行的时候花费代价去回收垃圾。


补充概念:

  • 栈:后进先出的内存分配结构,没有办法在中间插入存放数据,所以存放在栈里的数据需要已知且固定大小
  • 堆:堆的管理比较松散,你可以在堆里请求一个特定的大小的空间,操作系统就会找到一片足够大的地方,标记为已使用,分配给你,返回你一个指向这片地方的指针,因为是指针,所以也方便再申请一块地方,然后把这两块地方串起来,实现动态大小。但因为多了指针跳转,也要不断的寻找足够大的空间,所以在堆里存取数据会比栈里慢。

一般语言都不需要深入了解这两个概念,但书上说这两个概念和Rust的所有权紧密相关,所以我们暂且先看看。


所有权规则

暂时了解,后续会逐一解释

  1. 每一个都有一个对应的变量,作为值的所有者
  2. 在同一时间内,值有且仅有一个所有者
  3. 当所有者离开了自己的作用域,它持有的值就会被释放

变量作用域

这个和其他语言是一模一样的,不费口舌了。

简单来说就是变量只在作用域里变的有效,保持有效直到离开作用域

String类型——一个例子

String是一个存储在堆上的结构,用这个举例会能更好的说明所有权的作用,这一部分主要注重于所有权的部分,而不是去了解关注String

简单例子

我们定义一个动态可变长的字符串

1
2
3
4
5
fn main() {
let mut s = String::from("hello world");
s.push_str("!!");
println!("{}", s);
}

对于一个可变长度的String变量而言,内存管理主要分两个步骤

  1. 让操作系统给 String 分配一个堆空间
  2. 使用完之后,把内存交还给操作系统

第一步在大多数语言里都是一样的,那就是让程序员去发起请求,也就是定义一个变量。

第二步就不一样了,也就是上面介绍过的,要么定期检查自动回收,要么程序员自己来完成。定期回收吧,开销太大,自己完成吧,实现起来又很困难,一不小心回收晚了——内存泄漏,回收早了——非法变量,重复回收了,也可能有无法预知的后果。

所以 Rust 的解决方案是,在变量离开作用域后,立即释放内存。(其实我看到这里还觉得很普通啊,这不是很正常的操作吗?内存泄漏一般是不小心哪里弄了点跨文件的全局变量,一直被 hold 着不释放导致的吧)

Rust 回收是通过一个叫 drop 的函数进行的,也就是说,在 main 函数执行完后,其实 Rust 在花括号后面偷偷调用了一次 drop 函数。

但是,看一下复杂的例子,就能发现一点不一样的地方了

复杂例子

让我们试着定义两个存放在栈里的变量,两个存放在堆里的String变量

1
2
3
4
5
6
7
fn main() {
let x = 5;
let y = x;
let s1 = String::from("hello world");
let s2 = s1;
println!("{}", s1);
}

存放在栈里的变量,很符合正常逻辑,我们会创建一个值5给x,然后把 x 里的值拷贝一份,再给 y ,这样我们就有两个5了,互相修改互不影响。

但堆里的不一样,为了保证效率(之前也说了在堆里存取很浪费效率),Rust 在创建堆变量的时候,会附带一个指针,指向这个堆,就像这样

img

这说明什么,说明我们创建 s2 的时候其实只是拷贝了 s1 的内容,没有拷贝值的内容,只是拷贝了一份新的字段,以及一个新的指针,指向原来的内存块,所以修改的时候,是会相互影响的。好,这一部分也很好理解,毕竟不少语言也是这么干的。

但是!重点来了,之前说过,当重复释放一片内存的时候,可能会造成不可预计的错误,那我们 s2 和 s1 不就在同一个作用域吗,按 Rust 的所有权方法,在离开的时候不就同时释放了这块内存吗?

于是,Rust 用了一个很简单粗暴的方法,解决了这个问题。那就是,当两个变量同时指向了同一块内存的时候,上一个变量就没用了!(此处印证了第一条规则,值有且仅有一个所有者)

你可以尝试一下运行之前的代码,编译器是会报错的,也就是说,在定义了 s2 之后,无法输出 s1 Rust以此来保证没有一块内存是冗余的。奇葩!

img

报错提示你,你真的要用两个变量名的话,就给编译器说明,你确定是浪费内存,去要克隆一份。

1
2
3
4
5
6
fn main() {
let s1 = String::from("hello world");
let s2 = s1.clone();
println!("s1:{}, s2:{}", s1, s2);
// 这样就合法了
}

但也正如上面所说,栈是不受影响的,因为固定长度的变量在栈里操作很快,复制一份也无所谓,所以 x,y 是可以随便用的。

所有权与函数

基于上面的例子,我们就可以发现 Rust 这套规则的作用域,和别的语言完全不同的地方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn main() {
let s1 = String::from("hello");
string_test(s1);
let x:i32 = 5;
i_test(x);
println!("{}", x);
println!("{}", s1);
}
fn string_test(s: String) {
println!("{}", s);
}
fn i_test(i: i32){
println!("{}", i);
}

我们可以看一下这段代码,当 s1 被传入函数 string_test 的时候,其实也相当于完成了一次复制,也就是说,把 s1 的值复制给了函数参数 s,导致两者也指向了同一片空间。这代表什么?这段代码编译肯定不会通过,因为 s1 的作用域到执行函数 string_test 就已经结束了!而 x 不会受这个影响。

果然还是大受震撼,让人不禁产生疑问,那要让人怎么随心所欲调用函数了?

同时,函数的返回值也受这个所有权影响,也就是说,当执行返回值的时候,返回值的所有权回到函数上,再交由函数赋予变量上。

针对我提出的疑问,书上马上也给出了回答,如果要让我在调用函数之后保证变量的所有权,那就需要在函数的最后加个返回值,再把所有权返回给我的变量,也就是所用权的变更路线是 s1——参数——返回值——函数——s1

这也太麻烦了,这时候就需要引入另一个概念,让这个操作变得没那么繁琐,那就是——引用。

引用和借用

引用和所有权

既然复制和移动会转移所有权,导致变量有效性消失的问题,那不复制不移动不就完了?这个操作,就需要用到引用。

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

这段代码就完全没有问题了。

引用和别的语言概念也一样,应该不需要多说,创建一个新的引用,它的本质是这样的。

img

也就是说,什么也不拷贝,但是多了个新的指针,指向原变量,值的所有权还是在s1上,并且引用不会持有所有权,所以当 s 离开了作用域,它的值也不会被回收。

在 Rust 里,这种通过引用传递给函数参数的方法,称为借用,也就是你用完了别人的东西,要原封不动的还给人家。没错,原封不动,这又是和其他语言不太一样的地方。

1
2
3
4
5
6
7
8
9
fn main() {
let mut s1 = String::from("hello");
string_test(&s1);
println!("{}", s1);
}
fn string_test(s: &String) {
s.push_str(" world!");
println!("{}", s);
}

让我们把 s1 修改为可变,然后通过引用传给 s ,试图修改一下值,不出意外,编译器报错!引用是不可变的。

但其实很多时候,我们确实需要在函数里修改变量,怎么办?

所以又有了可变引用

可变引用

定义

1
2
3
4
5
6
7
8
9
fn main() {
let mut s1 = String::from("hello");
string_test(&mut s1);
println!("{}", s1);
}
fn string_test(s: &mut String) {
s.push_str(" world!");
println!("{}", s);
}

&mut 就是可变引用的关键字,这里我们把参数定义为了可变引入,传入的时候也改成了可变引用,代码就合法了。但 Rust 怎么可能让你这么自由的写代码?这不安全!所以可变引用有非常大的限制。那就是可变引用只能一次声明一个

1
2
3
4
5
6
fn main() {
let mut s1 = String::from("hello");
let s2 = &mut s1;
let s3 = &mut s1;
println!("{}, {}", s2, s3);
}

不出意外,这段代码必然报错。

可变引用与数据竞争

这么做的主要原因是让我们在编译的时候避免数据竞争,当指令在满足以下三种情况的时候,就会有数据竞争的情况:

  1. 两个或两个以上的指针同时访问一片空间
  2. 其中至少有一个,要往空间写入数据
  3. 而且又没有同步数据访问的机制

看了以上三种情况应该也能大概直到数据竞争是什么了,大概就是,写和读同步进行,可能导致另一个指针读到的数据不太对,导致你完全无法察觉的bug。

这种情况在 Rust 完全不会出现。因为可能产生数据竞争的代码编译这一关就通过不了哈哈哈。(同理,以上代码如果你不使用 s2, s3 的话其实不会报错,因为你定义了两个,但都没有使用,自然也没有数据竞争,只会有警告,告诉你定义了两个没用的变量)

基于这个理由,我们也可以知道,同时存在不可变引用+可变引用也是不合法的,因为一个只读,一个可能写,也会有数据竞争。而同时存在多个不可变引用的话,就没问题,因为它们都是只读,并不会修改数据。

悬垂引用

在别的语言里,有一个概念叫 悬垂指针,也就是说,一个指针指着块内存,但是内存被释放掉了,指针还指着这块内存,就叫悬垂。在 Rust 里,同样有一套规则确保引用不会进入悬垂状态,具体做法就是,确保引用的内存不会在引用离开自己的作用域时就被释放掉。也就是说,编译器保证引用在作用域内持续有效

先来创建一个悬垂引用

1
2
3
4
5
6
7
fn main() {
let test = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}

这里面我们返回了一个引用,但是引用的数据是 s 里的,s 在离开了函数后就会被销毁,引用自然也就悬垂了。

这时候编译会报错:expected named lifetime parameter

报错涉及到了我们之前说的两大最难学的核心概念之一,生命周期,这个会在后面学,现在不管。我们只要直到,Rust 又一次成功的通过报错拦截了我们的危险代码。

所以我们需要及时的去规范我们的代码,避免危险,这里也很简单,我们不返回引用,而是创建一个字符串变量,返回它的所有权就行了。

到这里,引用就讲完了,展开下一个概念,切片。(小声逼逼,这章好长,一边看书,一边写代码,一边写博客,看了我一下午了,没办法还是想一章一章的完整看完)

切片

之前说过,引用没有所有权,但没有所有权的类型还有一个,那就是——切片。用过 python 的应该很清楚这个概念。

切片在 Rust 的本质就是引用几何里一段连续的元素序列

书里举了一个例子说明切片的好处。

假设我们需要获取一个句子里面某个单词,怎么获取?最简单的方法的方法就是找到第一个单词的索引,知道单词的长度,这样就能随时的通过下标的方式访问到单词。

但这种设计方式有一个问题,那就是单词的索引,它的意义是和单词严格绑定的,当我的句子都已经被销毁的时候,其实索引也就没有了意义,但这时候我们可能用了一个变量来存这个索引,这个变量又不随着句子而销毁,这样就造成了一些冗余的问题,就连 Rust 的编译器也没办法给你挑出毛病来(你也有今天)。

所以就有了切片,我们一次性切出来一块引用,引用这个单词相关的所有字符,当原来的句子没有用了之后,引用也自然会被销毁(没被销毁的情况编译器就报错了)

1
2
3
4
5
6
fn main() {
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
println!("{} {}", hello, world);
}

使用方法也很简单,在 python 里是冒号,这里就是两个点,也同样是左闭右开,但是注意要写引用。

语法糖也和 python 一样,如果你想从一开始就切,也可以不写第一个数字,如果你想切到最后,也可以不写最后一个数字,例如:

1
2
3
4
5
6
fn main() {
let s = String::from("hello world");
let hello = &s[..5];
let world = &s[6..];
println!("{} {}", hello, world);
}

另外,有趣的是,之前不是说编译器会保证引用持续有效吗?那我在引用离开作用域前手动销毁会怎样?

1
2
3
4
5
6
7
fn main() {
let mut s = String::from("hello world");
let hello = &s[..5];
let world = &s[6..];
s.clear();
println!("{} {}", hello, world);
}

当然,肯定会报错。但它的解决方法比较有趣,之前说过,当你定义了不可变引用的时候,就没办法定义可变引用了对吧。而clear本质也是个函数,它清空 s 的内存的话,本质上是需要修改 s 的内容,所以它需要传入一个 s 的可变引用,来对齐进行清空,但我们之前还定义了不可变引用,不可变引用还没进行使用呢,你就没有办法定义可变引用去clear了。没错,根本上还是解决数据竞争的问题,清空本质上是一个写操作,我还要读呢,你就不能写!

当然,同样的,如果你不用读,它就不会报错了,例如:

1
2
3
4
5
6
fn main() {
let mut s = String::from("hello world");
let hello = &s[..5];
let world = &s[6..];
s.clear();
}

书上还提到了其他类型,例如数组也可以切片,此乃废话,不多说。


好勒,第4章到这里就终于结束了,这一章实在太长了,毕竟涉及到核心概念,看了我半天时间,今天就差不多到这吧。

  • 标题: 【Rust学习记录】4. 所有权
  • 作者: TwoSix
  • 创建于 : 2023-03-26 20:18:45
  • 更新于 : 2024-07-04 23:52:28
  • 链接: https://twosix.page/2023/03/26/【Rust学习记录】4-所有权/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论