昨天我们学完了控制流if
表达式和循环,第三章也就学完了。
今天我们进入第四章。
ownership
)所有权是rust
和别的语言最不同的特点,它使得rust
在不需要垃圾回收器(garbage collector
)的情况下也能保证内存安全,并且是在编译时就确定下来了。 其实在实现猜数游戏的时候我也有说过,我个人把这样的行为叫做垃圾处理。
在解释之前,我们先来说下其它语言中处理无用内存方法,拿我们熟悉的js
来说,他自身有一套垃圾回收机制(google
的v8
引擎的垃圾回收器),他们会在runtime
时一直运行,当某块内存的所有引用失效时就会被回收,但这也就占据了一些资源,毕竟需要在runtime
的时候的执行回收。
而另外一些语言并没有垃圾回收机制,也就是说内存需要开发者自身去注意,如果是很厉害的开发者,那么确实是能比垃圾回收机制强,但是很难,比如c/c++
。
而rust
另辟蹊径,定义了一系列的所有权规则,通过这些规则,rust
能在编译的时候就确定内存的回收,不会有任何的runtime
资源占用。
stack
)和堆(heap
)内存其他语言不会强求你去理解这两个概念,但是rust
需要,因为它们和所有权相关。其实我们之前的文章里也说到过了。。。
栈
栈相信大家都懂,后进先出,在内存中占用空间是连续的,每个数据存储的空间也是固定的。
举个栗子:一个u32
类型的数字,不管数字是多少,范围已经是确定的了0 ~ 2^32 - 1
,你撑死也就2^32 - 1
了,所以它是固定的。
再举个例子:一个指针指向堆内存,而这个指针就是一个固定的值,表示堆内存的地址,所以它也会被放到栈内存里。
所以我们使用的标量类型:number、float-point-numbers、bool、char
包括堆内存的指针都会被放到栈空间里。
而原始复合类型tuple、array
自己就是一个stack
,tuple
和array
都不允许拓展,并且需要在声明的时候就确定好长度,而数组里面的元素要么是指针,要么都是标量类型,所以他们都是大小已经固定的了。
堆
堆内存存放的是一些不可预测的数据,需要动态分配空间,所以系统会寻找一块比你需要的内存还大很多空间,这样才能保证你的数据够放。
比如rust
中的字符串类型,它是没有上限的,比如用户输入的内容,就是无法预测的,它可能有上万行那么长(吐槽:愚蠢的甲方和用户)。
指针
而堆空间就需要有指针指向它,为什么需要指针呢?
因为这样可以省下很多空间和提高传输速度,比如a
和b
都要这块内存,那就意味着需要两块堆内存,而从a
传给c
,那就是将整块内存传给c
,这样明显是不合理的。
所以需要有指针,复制的时候也是复制指针,传的时候也是传指针,这样就快很多也省下很多内存。
但是这也就意味着可能存在多个指针同时指向某块内存。
在没有垃圾回收机制的系统里可能就会导致内存泄露,某块内存用法被释放。
而在有垃圾回收机制的系统里,可能多个指针先后被释放,导致一块内存被释放多次,而这里面可能原来的内存已经变成另一个数据了,这时候就会导致数据意外释放,也就是“二次释放”问题(当然,基本不会出现)。
针对指针这个问题,rust
引入所有权概念。
rust
中任何的值(value
)都有一个拥有者(owner
,或者叫主人?!)。变量超出作用域就会被释放,那么啥叫作用域呢?其实前面的文章也说到过了。。。。
我们来看下例子
这个{}
之间就是一块作用域。
而变量a
就是这个作用域里的变量,但这个函数被调用时它生效了(进调用栈),而调用结束后(出调用栈),这个变量就被释放掉了。
另外,你可以在大部分地方,随便写一个{}
,这样就创建了一个作用域,你可以在里面写需要特殊声明变量的逻辑,比如
由于原始复合类型和标量类型都是存放于stack
中的,并不能满足作为heap
的例子,所以我们选一个我们用过并且需要用到堆空间的类型--”字符串“。
再继续往下分析之前,我们需要简单的了解下字符串类型要怎么用和声明。
相信大家也清楚为啥选字符串了,前面也多次提到了,这就不多说了。
在rust
中,除了常量或者能被编译确定下来的字符串之外,我们尽量用这种方式创建字符串变量。
其中::
表示调用String
里面的东西,这是一种调用对方作用域里的东西的方式。
先来看下两个变量
a
变量是一个&str
类型而b
变量是一个String
类型。
然后我们调用一个字符串的方法push_str
,可以给字符串加上数据,当然前提是声明的变量得是mut
即是可变的。
a
报错了,当前作用域没这方法,&str
上也没有。
是不是很奇怪? 他俩明明都是字符串,为啥类型不同。
这就涉及到另一个概念“切片[4]”了,后面再说。
上边这个如果a
要调用push_str
方法,那就得手动去实现一个push_str
方法,而在字符串String
类型中官方已经实现了这个方法。
所以对于这种字面量(literal
) 字符串来说,写法虽然简单,但是没有任何方法可以调用,就会很痛苦。
而通过String::from
获得的数据就有很多封装好的方法,这也就是为什么我们在rust
中尽量使用使用这种方式创建字符串的原因,除非这个字符串是一个常量,或者确定下来可以在编译过程中就处理的。
allocation
)上面提到的字符串字面量编译的时候会直接确定下来,这也就意味着他们的内存分配以及内存回收是确定下来的,所以这也就是为什么字面量字符串执行很快的原因,得益于它的不可变性(相对的)。
与之相反的是字符串类型,由于不确定性,并不能在编译过程中确定下来,这也就意味着存在以下两个问题需要解决:
runtime
的时候才能确定。来看下rust
是如何处理的。
这俩问题在其它语言也是有的。
第一个问题和其它语言一样,直接是在调用的时候就给它寻找一块空的内存,然后分配给这个实现的方法。
而第二个问题就不一样了,rust
有自己的方式:变量拥有的那块内存(所有权)会在变量离开作用域的时候被释放。
先来看下例子
一个简单的字符串类型变量声明
对于第一个问题来说,let a = String::from("test");
语句执行的时候内存就会去寻找一块空的内存,然后分配给这个String::from("test")
。
而对于第二个问题来说,当main
这个方法执行完之后(离开调用栈),这个变量没了,这块空间理论上也被释放掉了(自动调用一个名叫drop
的方法)。
为啥说是理论上呢。。。上面说堆内存的时候已经说过这个问题了:指针。
生成内存的同时也会生成一个指针指向这个内存,而变量实际上拥有的是这个指针。
同一时刻只能有一块相同堆内存,但是可能存在多个指针指向这个堆内存,毕竟指针是放到栈内存里的。
然后和其它语言差不多,当没有指针指向这个堆内存的时候,这个内存就被释放掉了。别的语言有GC
,但是得在runtime
的时候不停的跑,而rust
不需要,因为所有权规则使得不存在这种多指针场景。
还记得规则不?不记得请往回看~
move
)没想到吧。。。还是没说到为什么不会同时存在多个指针。。。
要说的话我们还有一个概念没有说到,所以这里又得补充一下: move
。
官方用词是**move
,**我这里按个人理解说成:数据迁移。
先来看下例子
通过上边几个小章节,大家应该已经a
和b
都是标量类型,数据都是放到stack
上去的。
而b = a
的过程是这样的:stack
上找到a
的数据,然后直接copy
一份再push
到stack
里,这也就是说a
和b
的数据实际上不是同一个,只是长得一样而已。
我们再来看下复杂类型String
的例子
我们已经知道了String::from
是分配的堆内存,而s1
变量拥有的一个指针。
ptr
:指针指向的路径len
:表示指向的内存的长度(以字节为单位)capacity
: 容量,表示这块堆内存的容量注意len
和capacity
不一定相同。
扯远了,回到我们的例子中。
在别的语言中 ,s1
将自己的指针copy
一份传给s2
,而堆内存不会被copy
至于原因,堆内存这个小章节里说过了这里就不说了。
这里就有个问题啦,前面也是多次提到了,那就是由于我们没有GC
,所以这里如果方法调用完毕后就意味着要释放内存了,但这里有两个指针指向同一块内存,也就是说这块内存会执行两次释放,这是不对的。因为第一次释放之后可能就被用来存放别的变量的数据了,这时再释放一次,对方就被意外释放了。这个就是著名的:双重释放问题(double free
)。
那么怎么办呢?
这个时候就要点题啦:**move**
迁移而不是复制copy
。
在rust
中,s2 = s1
表示将s1
的指针传给s2
,而不是copy
一份传给s2
。也就是说,这个时候s1
已经失去了它的拥有(挚爱?突然ntr
),而它的挚爱(误)被s2
拥有了(妥妥的ntr
)。s1
善心欲绝,直接魂飞魄散了。。。
好吧,说人话就是在赋值给s2
之后,s1
提前释放。
这也就符合我们的规则:一个值同一时刻有且只有一个拥有者。也就是不存在多个指针指向同一块内存。
这种指针复制一般叫做浅复制(shallow copy
),而深复制(deep copy
)自然就是连指向的内存也复制了。
而rust
中就不存在这俩概念了。不过考虑到确实是有这种内存复制的需求,所以rust
提供了一个方法允许copy
一份目标内存,当然也会生成一份指针,不过这俩指针指向不同的内存地址,只是数据一样而已。
为什么这里要说这个呢?因为上面说的这是一个简单的场景,只有一个作用域,而真实开发的情况下会有非常非常多的作用域,一个变量可能在n
个作用域中来回跑,被n
个function
以参数的形式接收然后传给另一个function
作为参数(比如递归)。
我们先来看下例子
可以看到i32
标量类型没事,而String
字符串类型报错了。
这就是因为s
的指针被move
给 takes_ownerShip
方法的some_string
参数了,所以main
方法中的s
自然就失效了。
而标量类型是直接复制(上面说的没有深复制概念指的是堆内存的,这里复制的栈内存里的,没有深浅之说),自然没有问题。
那么要如何避免上面的问题呢?
有两种方式,先说我们很容易想到的,让takes_ownerShip
方法把some_thing
返回出来,然后重新在main
函数中声明s
变量,这样指针又回来了。
但是这种方式很繁琐,还需要开发者们去深入代码理清链路,这是很费力气的。
所以一般我们都用第二种方式:引用(references
)
references
)和借用(borrowing
)引用和借用概念差不多。。rust
中定义:创建一个引用就是在借用。。。
上个章节最后我们说到的关于多作用域变量传输(transform
)问题的第二种处理方式。我们先来看下如何实现
在rust
中,传入参数加上&
符号表示这个指针是借给对方函数使用,既然是借的,那就说明借的对象并不用有这个指针的所有权。
这也就表示:当借用的函数退出调用栈(也就是超出作用域)时,这个指针不会被释放掉,而指针的拥有者也不会被释放掉,这个也并不违反:同一时刻一个值只有一个拥有者 的规则,毕竟是借用,由始至终都只有原来的变量拥有着这个指针,包括指针指向的内存。
实际上你可以理解为生成一个新指针指向这个旧的指针。
不过还有点问题你应该注意到了,那就是上面的变量其实都是不可变的,那么怎么让他在借用的情况下可变呢?
变量默认不可变,引用自然默认也不可变(小声bb:真是安全呢~~)
想要引用可变,其实很简单:
需要满足以下条件:
&mut variable
规范: &mut type
规范不过这里面还有问题,来看个例子
这样是错误的,因为同时存在两个可变引用指向s
的指针,违反了有且只有一个的规则。
为什么要强调有且只有一个的规则?
因为这样做可以避免数据竞争,数据竞争是一个很麻烦的问题,主要有以下三种情况会引起:
这些问题会导致未知情况,并且很难在runtime
时去跟踪诊断。
而在rust
中,通过这种限制方式来确保不会引发上述场景,不然你压根无法编译成功。比如以下这种场景:
而同时存在多个不可变的引用是可以的,比如
为什么不可变的就可以呢?因为不可变安全,是不可改的、确定的。
但是问题来了,如果真有需要呢?
还记得我们的作用域吗?没错,你完全可以把你想要引用的语句逻辑放到一个scope
中。比如:
这样在r1
所在的scope block
执行结束后,r1
就被释放掉了,自然就可以声明其它可变引用变量了。
另外需要注意的是可变和不可变的引用不能同时存在,比如:
不过还有一种情况是允许可变借用和不可变借用同时存在于当前作用域的,比如下面的这种场景
如果r1、r2
保证在后面可变的r3
声明之后不再调用,那么是可行的,反过来也一样,也就是如果代码执行时之前的可变或者不可变的借用不再被调用,这时候就不会有问题。
这些全都是编译的时候就能确定下来的。
如果大家学过其他语言,应该也接触过这种问题。就是一个指针指向的地址已经被释放掉了,这个时候这个指针就是空指针。
那么什么情况下会有悬浮引用呢?来看下例子
dangle
方法返回了一个引用,但是s
指向的内存空间在dangle
函数退出调用栈的时候就已经被释放掉了。那么这个reference_to_nothing
就是一个空/悬浮引用。
rust
会在编译的时候就帮你指出这个问题
报错是缺少生命周期标识符,生命周期现在我们还不清楚是啥,后面会学到。
至于上面的问题要如何处理,那自然是把返回引用改为返回指针即可,如果还有需要引用的话,就引用reference_to_nothing
就行了。
slice type
)看着名字就知道是用来切的。。。
slices
也就是切片可以让你引用一块集合中的相邻的元素序列,当然,既然是引用,那自然就没有所有权。
在使用切片前,我们先来实现一个方法。
这个方法的逻辑是这样的:将字符串按space
也就是空格切割,返回切割后的第一块字符串,如果没有空格的话,就返回整个字符串。
如果在js
中,我们嘎嘎俩三行就给写完了,但这是rust
。。。就不说实现过程了,直接上代码
我这里就没按照官网的例子搞了,直接就将字符串切割了。。。
如果没有切片的话,我们这里就需要定义一个可变变量,让它+=
字符,最后返回。这样可以,但是有些繁琐,其实我们没必要去创建一个变量然后拼接,我们可以直接从原来的字符串中切下来这一块。
来改下我们的代码
enumerate
是创建一个元组,(index, el)
则是元组解构。
&variable[start..end]
这就是创建一个切片,一个原数据的部分引用[10]。
其中start
和end
可以不写,默认是0
和s.len()
,另外需要注意这里end
是你想截取的字符最后一个+1
当然你还可以这样写[start..=end]
,这样就不需要去考虑到底要不要+1
了。
至于我的代码里为什么没加=
也是对的,因为split
分割的是""
,所以第一个元素自然就就会是空字符""
我们来比对下两次输出
两个的时间差,明显是创建引用切片快些,这是因为切片过程中并没有新变量生成。
我们来看下一个例子
切片实际上是在原来的数据内存上去创建一个新指针,这个指针有两个字段,ptr
自然就是地址起始位置,而len
则表示切的长度,当然能切的肯定都是连续的。
回过头来,我们看下字符串字面量literal
的类型
其实返回的是一个字符串切片的引用。
这也就是为什么这玩意儿是immutable
的,因为&str
就是不可变的引用。
现在,我们可以把之前的函数传入参数类型&String
改为&str
以及返回参数改为&str
,这么做会比较方面,后面我们就会体会到了。
那肯定得是数组啊是吧~doge
我们学了所有权、引用借用以及切片类型。
套用迅哥儿的一句话:
*“我翻开rust
的文档一查,这文档莫得感情,歪歪斜斜的每页上都写着 “简单方便” 几个字。我横竖睡不着,仔细看了半夜,才从字缝里看出字来,满本都写着两个字是 “安全”!”*
最后,如果觉得对您有用的话麻烦点个赞,谢谢~~
发布于 2022-12-03 23:49・IP 属地广东