所有权
前言
本章节主要介绍所有权,这也是 Rust 提供安全编码所依赖的重要功能。
Stack Vs Heap
- 在 Rust/C++/C 等这样的系统级编程语言里,一个值是在 stack 上还是在 heap
上对语言的行为和你为什么要做某些决定是有更大的影响的。 - 这两者在数据存储结构上有很大的不同:
- Stack 按值的接收顺序来进行存储,按相反的顺序将其移除(后进先出,LIFO)。
- 添加数据叫做压入栈,移除数据叫做弹出栈。
- 所有存储在 Stack
上的数据必须拥有已知的固定大小。编译时大小未知的数据或运行时大小可能发生变化的数据必须存放在
heap 上。
- Heap 内存的组织性差一些:
- 当把数据存放在 heap 时,会请求一定数量的空间。
- 操作系统在 heap
里找到一块足够大的空间,并将其标记为在用,返回一个指针,也就是这个空间的地址。 - 这个过程叫做在 heap 上分配,有时仅仅称为
分配
。
- Stack 按值的接收顺序来进行存储,按相反的顺序将其移除(后进先出,LIFO)。
- 访问数据:
- 访问 heap 中的数据要比防伪 stack 中的慢得多,因为要通过指针才能找到 heap
中的数据。- 对于现代的处理器来说,由于缓存的原因如果指令在内存中跳转的次数越少,那么速度就越快。
- 如果数据存放的距离比较近,那么处理器的处理速度就会更快一些(Stack 上)。
- 访问 heap 中的数据要比防伪 stack 中的慢得多,因为要通过指针才能找到 heap
- 函数调用:
- 当代码调用函数时,值被传入到函数(也包括指向 heap 的指针)。函数本地的变量被压到 stack 上,当函数结束后,这些值会从 stack 上弹出。
- 所有权存在的原因:
- 跟踪代码中哪些部分正在使用 heap 的哪些数据。
- 最小化 heap 上的重复数据量。
- 清理 heap 上未使用的数据以避免空间不足。
所有权的规则
- 每个值都有一个变量,这个变量是该值的所有制。
- 每个值同时只能有一个所有者。
- 当所有者超出作用域(scope)时,该值将被删除。
String 类型
这里以 Sting 举例来说明所有权,String 比那些基础数据类型更复杂。
字符串字面值即程序里手写的那些字符串值,它是不可变的。
Rust 还有第二种字符串类型:String
- 在 heap 上分配。能够存储在编译时未知数量的文本。
使用 from 函数从字符串字面值创建出 String 类型。
let s = String::from("hello);
。- String 字符串时可以被修改的。
let mut s = String::from("Hello"); s.push_str(", World"); println!("{}", s);
Rust 中对于某个值来说,当拥有它的变量走出作用域后,内存会立即释放交换给操作系统。
- 离开作用域后 Rust 会自动调用 drop 函数来进行释放。
变量与数据交互的方式:移动 Move
Rust 中,以 String 为例,当进行赋值是默认是浅拷贝不会复制 heap 上的数据,这时当离开作用域释放时存在二次释放问题。因此 Rust 中的拷贝默认为 move 操作,即源失效。
let mut s = String::from("Hello"); s.push_str(", World"); println!("{}", s); let s2 = s; println!("s = {}", s); ///////////// error[E0382]: borrow of moved value: `s` --> src/main.rs:7:24 | 2 | let mut s = String::from("Hello"); | ----- move occurs because `s` has type `String`, which does not implement the `Copy` trait ... 6 | let s2 = s; | - value moved here 7 | println!("s = {}", s); | ^ value borrowed here after move | = note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-bac ktrace for more info)
如果需要对 heap 上的 String 数据进行深度拷贝,可以使用 clone 方法。
let s2 = s.clone(); println!("s = {}", s);
Stack 上的数据进行复制:
- Copy trait,可以用于像整数这样的完全存放在 stack 上的类型,如果一个类型实现了 Copy 这个 trait,那么旧的变量在赋值后仍然可用。
- 如果一个类型或者该类型的一部分实现了 Drop trait,那么 Rust 就不允许它再去实现 Copy trait 了。
所有权与函数
在语义上,将值传递给函数和把值赋给变量是类似的。
- 将值传递给函数将要发生的移动或复制。
fn test() { let s = String::from("Hello, World"); take_ownership(s); //println!("{}", s); err let x = 5; make_copy(x); println!("{}", x); } fn take_ownership(some_string: String) { println!("{}", some_string); } fn make_copy(some_number: i32) { println!("{}", some_number); }
返回值与作用域
函数在返回值的过程中同样也会发生所有权的转移:
fn test2() { let s1 = gives_ownership(); let s2 = String::from("hello"); let s3 = takes_and_gives_back(s2); } fn gives_ownership() -> String { let some_string = String::from("hello"); some_string } fn takes_and_gives_back(a_string: String) -> String { a_string }
一个变量的所有权总是遵循同样的模式:
- 把一个值赋给其他变量时就会发生移动。
- 当一个包含 heap 数据的变量离开作用域时,它的值就会被 drop 函数清理,除非数据的所有权移动到了另一个变量上了。
引用与借用
如何能使用某个变量而不获取其所有权?
如下这种属于传递了所有权的方式比较麻烦,Rust 中用一种引用方式。
fn test3() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{}' is {}.", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length)
}
引用
参数类型时 &String 而不是 String。
& 符号就表示引用:允许你引用某些值而不取得其所有权。
fn test3() { 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() }
借用
- 如上一节的示例代码,将引用作为函数参数的行为就叫做借用。
- 默认的借用是无法修改的。
- 使用 mut 进行修饰即可修改。
fn test4() {
let mut s1 = String::from("hello");
let len = calculate_length_and_append(&mut s1);
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length_and_append(s: &mut String) -> usize {
s.push_str(", world");
s.len()
}
可变引用
可变引用有一个重要的限制:在特定作用域内,对某一块数据,只能有一个可变的引用。
- 这样可以编译时防止数据竞争,数据竞争的条件:
- 两个或多个指针同时访问同一个数据。
- 至少有一个指针用于写入数据。
- 没有使用任何机制来同步对数据的访问。
let s2 = &mut s1; // let s3 = &mut s1; err println!("The length of {} {}", s2, s3);
- 这样可以编译时防止数据竞争,数据竞争的条件:
可以通过创建新的作用域,来允许非同时的创建多个可变引用。
let mut s = String::from("hello"); { let s1 = &mut s; } let s2 = &mut s;
不可以同时拥有一个可变引用和一个不可变的引用。
- 多个不可变的引用是允许的。
悬空引用 Dangling References
悬空指针:一个指针引用来内存中的某个地址,而这块地址可能已经释放并分配给其他人使用。
在 Rust 里,编译器可以保证引用永远都不是悬空引用:
- 如果你引用了某些数据,编译器将保证在引用离开此作用域之前数据不会离开作用域。
fn test6() { let r = dangle(); } fn dangle() -> &String { // err let s = String::from("Hello"); &s }
切片
Rust 的另外一种不支持所有权的数据类型:切片(slice)。
一道题,编写一个函数:
- 它接收字符串作为参数。
- 返回它在这个字符串里找到的第一个单词。
- 如果函数没找到任何空格,那么整个字符串就被返回。
fn main() { let s = String::from("hello world"); let word_index = first_world(&s); println!("{}", word_index); } fn first_world(s: &String) -> usize { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return i; } } s.len() }
上面这种是基础的实现方法,但是存在
word_index
与 String 不匹配的情况(例如 string 被其他函数 clear 掉了,相当于迭代器失效)。因此 Rust 提供了 切片来解决此类问题。字符串切片是指向字符串中的一部分内容的引用。
- 形式:[开始索引。. 结束索引]
- 开始索引就是切片的起始位置的索引值。
- 结束索引是切片中止位置的下一个索引值。
let hello = &s[0..5]; let world = &s[6..11]; println!("hello = {}, world = {}", hello, world);
其他注意,字符串切片的范围索引必须发生在有效的 UTF-8 字符边界内。如果尝试从一个多字节的字符中创建字符串切片,程序会保存并退出。
- 语法糖:
let hello = &s[..5]; let world = &s[6..]; let whole = &s[..];
重新编写一开始的问题:
fn first_world_v2(s: &String) -> &str { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return &s[..i]; } } &s[..] }
将字符串切片作为参数传递:
fn test(s:&String) -> &str{}
- 有经验的开发者会采用 &str 作为参数类型,因为这样就可以同时接收 String 和 &str 类型的参数了。
fn test(s:&str) -> &str{}
- 使用字符串切片,直接调用该函数,使用 String, 可以创建一个完整的 String 切片来调用该函数。
- 定义函数时使用字符串切片来代替字符串引用会使 API 更加通用,且不会损失任何功能。