4.1.1 所有权
数据存储
与C++编程一样,Rust编程也必须考虑stack内存与Heap内存
Stack内存遵守后进先出的原则(LIFO)
- 所有Stack上的数据必须拥有已知的固定的大小
- 编译时大小未知的数据或运行时大小可能发生变化的数据必须存放在heap上
Heap内存的组织性较差
- 将数据存入heap时,需要请求一定数量的空间,随后操作系统会在heap中寻找一块足够大的空间并标记其为在用,并返回对应指针,这个过程一般被称作分配
- 由于指针是已知固定大小的,因此可以将指针存在stack上
将数据压到stack上比分配到heap上会快很多
操作系统不需要寻找新的内存空间,压的位置永远对应顶端
对于heap分配,操作系统首先要找一个足够大的空间来分配数据,并做好记录方便下次分配(耗时)
在访问数据层面,访问heap数据也会更慢,因为需要通过指针来定位
对于现代的处理器来说,由于缓存的缘故,如果指令在内存中跳转的次数越少,那么速度就越快
如果数据存放的距离比较近,那么处理器的处理速度就会更快一些 (stack上)
如果数据之间的距离比较远,那么处理速度就会慢一些 (heap 上)
在代码进行函数调用时
- 值(包括指向heap的指针)被传入到函数,函数本地的变量被压到stack上
- 当函数结束后,这些值会从stack上弹出
所有权的用处
所有权是Rust的核心特性,其使得Rust无GC也能保证内存安全
对有GC的语言如Java,程序运行时需要不停做GC,从而带来额外开销
对于其它无GC语言如C++,程序员需要显式控制内存,容易带来内存安全问题
Rust通过所有权系统管理内存,其不会带来任何额外的运行开销
所有权解决的问题
- 追踪代码中使用heap的数据
- 最小化heap的数据重复量
- 清理heap上未使用的数据来避免空间不足
有了所有权后,我们就不需要像C++编程一样去显式考虑stack和heap的问题
4.1.2 所有权规则、内存与分配
所有权规则
- 每个值都有一个变量,这个变量是该值的所有者
- 每个值同时只能有一个所有者
- 当所有者超出作用域(scope)时,该值将被删除
String类型
我们以String类型来作为例子来说明所有权和内存的问题,因为
- 它比标量类型更复杂,不同于字符类型Char
- 它被分配在heap上,可以存储未知数量的文本
- 这类字符串允许被修改
例如
1 | let mut s = String::from("hello "); |
String::from
就是程序运行时来请求在heap上分配内存- 但由于作用域的原则,虽然
s
被分配在堆上,但当超出作用域时,s
将被自动释放- 这个释放操作依赖于内部的
drop()
函数
- 这个释放操作依赖于内部的
我们先考虑整型的情况
1 | let x = 4; |
- 由于
x
和y
都是已知且固定大小的值,因此有两个4
会被压到stack中
但对于String来说,这是不一样的
1 | let s1 = String::from("abc"); |
一个String由3部分组成,这3部分都存在stack中
指向存放字符串内容的内存的指针(指向heap)
长度(存放字符串所需的字节数)
容量(该String从系统申请的总字节数)
上述操作String的数据复制一份(指针、长度、容量),但不复制heap里的字符串内容
这会带来一个问题
- 即当
s1
和s2
都离开作用域时,会出现对同一块heap内存的数据double free!
不过,为了保证内存安全,上述的情况在Rust中不会发生
Rust的机制是在s1
被拷贝给s2
后,就让s1
废弃,即这种赋值是一个Move操作,因此
1 | let a = String::from("abc"); |
会报错,因为a
已经废弃了
Move操作(heap数据)
在许多其它语言如Python中,会区分浅拷贝和深拷贝
但上面诸如s1
赋值给s2
这种操作Rust会废弃s1
,因此不属于深拷贝
这种操作被称作移动(Move),和C++中的std::move()
是类似功能
这里隐含Rust的设计原则:
即Rust不会自动创建深拷贝,任何自动赋值操作都是廉价的
若需要对上面的String数据进行深拷贝,则需要适用
clone
方法
Copy操作(stack数据)
对于诸如整数这样的stack上的数据,赋值会采用Copy这个trait,例如
1 | let mut x = 2; |
会输出x = 4, y = 2
- 如果一个类型或该类型的一部分实现了Drop trait,那么出于double free的问题,Rust就不会允许其实现Copy trait
所有的简单标量的组合类型都是可以Copy的,而所有涉及内存分配的数据是不可以的
其它类似例子,诸如元组
(i32, i32)
是可以Copy的(i32, String)
则不可以
4.1.3 所有权和函数
将值传递给函数与传递给变量是类似的,会发生Move或者Copy操作
例如
1 | let x: i32 = 2; |
会发生Copy操作
但是
1 | let s = String::from("hello world"); |
会发生Move操作,导致最后一行的s
失效,报错
对于
1 | fn move_string(the_string: String) { |
当值传递进这个函数的时候(Move),the_string
进入该作用域,当函数结束时就离开作用域,并被Drop
类似的,函数返回值也会有Move或者Copy操作,例如
1 | fn main() { |
4.2 引用与借用
Reference
如何在函数中使用一个值,但不获取其所有权?
一个粗暴的方法是将所有权再次返回,例如
1 | fn main() { |
为了处理这种场景,我们可以使用引用&
,例如
1 | fn main() { |
这里
&String
是一个引用类型,它将引用某些值而不获取其所有权在内存中,引用指向的是
String
的指针,并进一步由该指针寻找相应heap中的值上述中的
&s1
就是一个引用,它走出作用域的时候(下面的函数),不会Drop掉s1
Borrow
在Rust中,我们将引用作为函数参数的行为称作借用(borrow)
- 不可以修改借用东西(借用相当于是只读模式)
Mutable Borrow
由于变量可以是mutable的,因此对应的引用也可以是mutable,比如
1 | fn main() { |
但可变引用有如下限制
- 在特定作用域内,每一块数据,同时只能由一个可变引用,这个限制的目的是防止数据竞争
- 以下三种行为下会发生数据竞争
- 两个或多个指针同时访问同一个数据
- 至少有一个指针用于写入数据
- 没有使用任何机制来同步对数据的访问
例如
1 | let mut s = String::from("Rust"); |
就会报错
如果要创建多个可变引用,我们必须要在多个作用域中来实现,例如
1 | fn main() { |
- 此外,可变引用和不可变引用在一个作用域内也不可同时出现,不过允许多个不可变引用存在
悬空引用(Dangling References)
在许多其他语言里(没错就是说你C++),可能出现一个指针引用了内存某个地址,但该内存已经被释放的情况,这就是Dangling References,也就是C++里的野指针
Rust确保了这种情况不可能发生
- 编译器会保证引用在离开作用域前,数据不离开作用域
例如
1 | fn main() { |
在编译时会报错
4.3 切片
字符串切片
字符串切片是指向字符串一部分内容的引用
示例
1 | fn main() { |
借助语法糖,也可以将
&s[0..5]
写作&s[..5]
,或者将&s[6..s.len()]
写作&s[6..]
类似的,完整切片为
&[..]
字符串切片的类型为
&str
实现一个功能,将字符串的第一个单词返回
1 | fn main() { |
- 此处
s.clear()
如果使用则会报错,因为其对应可变引用,而前面已经出现了不可变引用
字符串的字面值事实上也是切片,例如
1 | fn main() { |
s
的类型就是&str
,因此其是不可变的
在实际开发中,一般在函数签名中选择&str
而不是&String
,因为前者能够包含后者
- 即当实际传入
&String
时,&str
会创建完整切片来调用函数 - 这种方式不会损失任何功能
其它类型的切片
1 | fn main() { |