4.1 所有权,Stack vs Heap
4.1.1 什么是所有权
所有权是 Rust 最独特的特性,它让 Rust 无需 GC就可以保证内存安全。
- Rust 的核心特性就是所有权
- 所有程序在运行时都必须管理它们使用计算机内存的方式
- 有些语言有垃圾收集机制,在程序运行时,它们会不断地寻找不再使用的内存,比如Java
- 在其他语言中,程序员必须显式地分配和释放内存,比如C,C+
- Rust 采用了第三种方式
- 内存是通过一个所有权系统来管理的,其中包含一组编译器在编译时检查的规则。当程序运行时,所有权特性不会减慢程序的运行速度。
Stack vs Heap栈内存VS堆内存
- 在像 Rust 这样的系统级编程语言里,一个值是在 stack 上还是在 heap 上对语言的行为和你为什么要做某些决定是有更大的影响
- 在你的代码运行的时候,Stack 和 Heap 都是你可用的内存,但他们的结构很不相司
存储数据
-
Stack按值的接收顺序来存储,按相反的顺序将他们移除(后进先出,LIFO)
- 添加数据叫做压入栈
- 移除数据叫做弹出栈
-
所有存储在 Stack 上的数据必须拥有已知的固定的大小
- 编译时大小未知的数据或运行时大小可能发生变化的数据必须存放在 heap 上
-
Heap 内存组织性差一些
- 当你把数据放入 heap 时,你会请求一定数量的空间
- 操作系统在 heap 里找到一块足够大的空间,把它标记为在用,并返回一个指针,也就是这个空间的地址
- 这个过程叫做在 heap 上进行分配,有时仅仅称为“分配
-
把值压到 stack 上不叫分配
-
因为指针是已知固定大小的,可以把指针存放在 stack 上。
- 但如果想要实际数据,你必须使用指针来定位。
-
把数据压到 stack 上要比在 heap 上分配快得多:
- 因为操作系统不需要寻找用来存储新数据的空间,那个位置永远都在 stack 的顶端
-
在 heap 上分配空间需要做更多的工作:
- 操作系统首先需要找到一个足够大的空间来存放数据,然后要做好记录方便下次分配
访问数据
- 访问 heap 中的数据要比访问 stck 中的数据慢,因为需要通过指针才能找到heap 中的数据
- 对于现代的处理器来说,由于缓存的缘故,如果指令在内存中跳转的次数越少,那么速度就越快
- 如果数据存放的距离比较近,那么处理器的处理速度就会更快一些 (stack 上)
- 如果数据之间的距离比较远,那么处理速度就会慢一些 (heap 上)
- 在 heap 上分配大量的空间也是需要时间的
函数调用
当你的代码调用函数时,值被传入到函数(也包括指向 heap 的指针)。函数本地的变量被压到 stack 上。当函数结束后,这些值会从 stack 上弹出
所有权存在的原因
- 所有权解决的问题:
- 跟踪代码的哪些部分正在使用 heap 的哪些数据
- 最小化 heap 上的重复数据量
- 清理 heap 上未使用的数据以避免空间不足。
- 一旦你懂的了所有权,那么就不需要经常去想 stack 或 heap 了。
- 但是知道管理 heap 数据是所有权存在的原因,这有助于解释它为什么会这样工作
4.1.2所有权内存规则与分配
所有权规则
- 每个值都有一个变量,这个变量是该值的所有者
- 每个值同时只能有一个所有者
- 当所有者超出作用域 (scope)时,该值将被删除
String类型
- String 比那些基础标量数据类型更复杂
- 字符串字面值:程序里手写的那些字符串值。它们是不可变的
- Rust 还有第二种字符串类型: String。
- 在 heap 上分配。能够存储在编译时未知数量的文本。
创建 String 类型的值
- 可以使用 from 函数从字符串字面值创建出 String 类型
let mut s=String :: from("hello");
- “::”表示from是String 类型下的函数
- 这类字符串是可以被修改的
fn main() {
let mut s=String :: from("hello");
s.push_str(", world");
println!("{}",s);
}
- 为什么 String 类型的值可以修改,而字符串字面值却不能修改?
- 因为它们处理内存的方式不同
内存和分配
- 字符串字面值,在编译时就知道它的内容了,其文本内容直接被硬编码到最终的可执行文件里
- 速度快、高效。是因为其不可变性。
- String 类型,为了支持可变性,需要在 heap 上分配内存来保存编译时未知的文本
内容:- 操作系统必须在运行时来请求内存
- 这步通过调用 String::from 来实现
- 当用完 String 之后,需要使用某种方式将内存返回给操作系统
- 这步,在拥有 GC 的语言中,GC 会跟踪并清理不再使用的内存
-没有 GC,就需要我们去识别内存何时不再使用,并调用代码将它返回。- 如果忘了,那就浪费内存。
- 如果提前做了,变量就会非法
- 如果做了两次,也是 Bug。必须一次分配对应一次释放
- 这步,在拥有 GC 的语言中,GC 会跟踪并清理不再使用的内存
- 操作系统必须在运行时来请求内存
- Rust 采用了不同的方式:对于某个值来说,
- 当拥有它的变量走出作用范围时,内
存会立即自动的交还给操作系统。(例子) - 当变量走出作用域,Rust会调用drop这个函数
变量和数据交瓦的方式
移动 (Move)
- 多个变量可以与同一个数据使用一种独特的方式来交互
let x=5;
let y=x;
- 整数是已知且固定大小的简单的值,这两个 5 被压到了 stack 中
String 版本
let s1 = String::from("hello");
let s2 = s1;
- 一个String 由3部分组成:
- 一个指向存放字符串内容的内存的指针
- 一个长度
- 一个容量
- 上面这些东西放在 stack 上。
- 存放字符串内容的部分在 heap 上
- 长度len,就是存放字符串内容所需的字节数
- 容量 capacity 是指 String 从操作系统总共获得内存的总字节数
let s1 = String::from("hello");
let s2 = s1:
- 当把S1赋给S2,String的数据假复制一份
- 在 stack 上复制了一份指针、长度、容量
- 并没有复制指针所指向的 heap 上的数据
- 当变量离开作用域时,Rust 会自动调用drop 函数,并将变量使用的 heap 内存释放。
- 当s1、s2 离开作用域时,它们都会尝试释放
相同的内存:- 二次释放 (double free)bug
- 为了保证内存安全:
- Rust 没有尝试复制被分配的内存
- Rust让s1 失效。
- 当 s1 离开作用域的时候,Rust 不需要释放任何东西
- 二次释放 (double free)bug
- 看当S2 创建以后 再使用 s 是什么效果
克隆Clone
如果真想对 heap上面的 String 数据进行
深度拷贝,而不仅仅是 staek 上的数据,
可以使用 clone 方法(以后再细说,先看
个例子)
4.1.3 所有权与函数
所有权与函数
- 在语义上,将值传递给函数和把值赋给变量是类似的:
- 将值传递给函数将发生移动或复制
返回值与作用域
-
函数在返回值的过程中同样也会发生所有权的转移
-
函数在返回值的过程中同样也会发生所有权的转移
-
一个变量的所有权总是遵循同样的模式:
- 把一个值赋给其它变量时就会发生移动
- 当一个包含 heap 数据的变量离开作用域时,它的值就会被 drop 函数清理,除非数据的所有权移动到另一个变量上了
-
如何让函数使用某个值,但不获得其所有权?
-
Rust 有一个特性叫做“引用 (Reference)”
4.2 引用和借用
fn main() {
let s1 =String::from("hello");
let len=calculate_length(&s1);
println!("The length of {} is {}",s1,len);
}
fn calculate_length(s:&String) ->usize{
s.len()
}
- 参数的类型是 &String 而不是 String
- & 符号就表示引用:允许你引用某些值而不取得其所有权
借用
- 我们把引用作为函数参数这个行为叫做借用
- 是否可以修改借用的东西?(例子
- 不行
- 和变量一样,引用默认也是不可变的
可变引用
- 可变引用有一个重要的限制:在特定作用域内,对某一块数据,只能有一个可变的
引用。- 这样做的好处是可在编译时防止数据竞争。
- 以下三种行为下会发生数据竞争:
- 两个或多个指针同时访问同一个数据
- 至少有一个指针用于写入数据
- 没有使用任何机制来同步对数据的访问
- 可以通过创建新的作用域,来允许非同时的创建多个可变引用
另外一个限制
- 不可以同时拥有一个可变引用和一个不变的引用
- 多个不变的引用是可以的
悬空引用 Dangling References
- 悬空指针(Dangling Pointer):一个指针引用了内存中的某个地址,而这块内存
可能已经释放并分配给其它人使用了。 - 在Rust 里,编译器可保证引用永远都不是悬空引用:
- 如果你引用了某些数据,编译器将保证在引用离开作用域之前数据不会离开作用域
- 如果你引用了某些数据,编译器将保证在引用离开作用域之前数据不会离开作用域
引用的规则
- 在任何给定的时刻,只能满足下列条件之一:
- 一个可变的引用
- 任意数量不可变的引用
- 引用必须一直有效
切片
- Rust 的另外一种不持有所有权的数据类型:切片 (slice)
- 一道题,编写一个函数:
- 它接收字符串作为参数
- 返回它在这个字符串里找到的第一个单词
- 如果函数没找到任何空格,那么整个字符串就被返回
fn main() {
let mut s=String::from("Hello, world");
let word_index =first_word(&s);
println!("{}",word_index);
}
fn first_word(s:&String) ->usize{
let bytes =s.as_bytes();
for(i,&item) in bytes.iter().enumerate(){
if item ==b' '{
return i;
}
}
s.len()
}
但是,要关注word_index的有效性,确保word_index和s的同步性
字符串切片
- 字符串切片是指向字符串中一部分内容的引用
- (形式:[开始索引…结束索引]
- 开始索引就是切片起始位置的索引值
- 结束索引是切片终止位置的下一个索引值
let mut s=String::from("Hello, world");
let hello =&s[0..5];//hello
let hello1 =&s[..5];//hello
let world =&s[6..];//world
let world2 =&s[6..11];//world
let whole=&[..];
- 字符串切片的范围索引必须发生在有效的 UTF-8 字符边界内。
- 如果尝试从一个多字节的字符中创建字符串切片,程序会报错并退出
使用字符串切片重写例子
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[..]
}
字符串字面值是切片
- 字符串字面值被直接存储在二进制程序中。
- let s =“Hello,World!"。
- 变量 s的类型是 &str,它是一个指向二进制程序特定位置的切片
将字符串切片作为参数传递
- fn first word(s:&String)-> &str
- 有经验的 Rust 开发者会采用 &str 作为参数类型,因为这样就可以同时接收 String和 &str 类型的参数了:
- fn first_word(s: &str) -> &str
- 使用字符串切片,直接调用该函数
- 使用 String,可以创建一个完整的 String 切片来调用该函数
fn main() {
let mut s=String::from("Hello, world");
let word_index=first_word(&s[..]);
}
fn first_word(s:&str) ->&str{
let bytes =s.as_bytes();
for(i,&item) in bytes.iter().enumerate(){
if item ==b' '{
return &s[..i];
}
}
&s[..]
}