这一篇介绍Rust的第一个核心:所有权,建议在忘了所有权的基础知识的时候多看本文两遍~
所有权是Rust最独特的特性,它让Rust无需GC就可以保证内存安全。
这里讨论的不是数据结构中的栈和堆,而是内存管理中的栈和堆。相信有其它编程语言基础的同学一看到这两个概念,DNA就动了,因为栈和堆在程序运行中一定会涉及到。
以表格的形式对比stack和heap数据存储的区别:
对比项 | stack | heap |
---|---|---|
数据存放方式 | stack按值的接收顺序来存储,按相反的顺序将它们移除(后进先出,LIFO): 1. 添加数据叫压栈; 2. 移除数据叫出栈; |
操作系统首先需要找到一个足够大的空间来存放数据,然后要做好记录方便下次分配 操作系统在heap里找到一块足够大的空间,把它标记为在用,并返回一个指针,即这个空间的地址; |
数据大小 | 所有存储在stack上的数据必须拥有已知的固定大小 |
存储的数据多大就申请多大的空间存放 |
数据存放空间申请速度对比 | 快,因为创建stack的时候就申请了一批空间,后续新数据写入时不需要重新申请空间,数据直接放stack的顶端 |
慢,因为每次写入新数据时都要进行一次申请 |
空间限制 | stack的大小编译时已经决定了,一个stack大小往往比机器内存上限小得多 |
理论上操作系统分配多少就有多少 |
内存组织情况 | 好 | 差 |
其它:
以表格的形式对比stack和heap数据访问的区别:
对比项 | stack | heap |
---|---|---|
访问速度 | 快,因为stack比heap小且每一个数据能直接一步访问到 |
慢,因为需要先获得指向heap数据的指针(指针有可能在stack上也有可能在heap上),然后再通过指针找到heap的数据 |
其它:
当代码调用函数时,stack上的数据值会被传入到函数(其中也会包括指向heap的指针);函数本地的变量被压到stack上。当函数结束后,这些值会从stack上弹出;
传统的编程语言需要使用stack和heap完成不同的工作,stack中保存一些编译时能确定大小的数据,heap中保存一些编译时无法确定大小的数据;但这个过程往往会出现下列问题:
Rust根据过去这些编程语言的内存管理特点总结出了内存使用规则(即所有权),只要按照这个使用规则去管理内存,就能免去手动管理的繁琐和使用垃圾回收的低效(当然,这还是需要代价的,那就是付出脑力去适应);
所有权规则并不是内存管理的最优策略,它实际上是牺牲了一些内存使用灵活性以换来内存使用安全和相对高效的内存使用体验;
但从上面三条规则看起来比较抽象,这里给一个比较通俗的理解
值与值之间有如下两种关系:完全无关和从属关系;
a: i32
和b: str
,就像是一盒巧克力中的两颗巧克力;v: vec
,和v[0]
,一个是动态数组,一个是动态数据中的一个元素,就像v
是一盒巧克力,v[0]
是这盒巧克力中的其中一颗巧克力,那么当有人问巧克力v[0]
此时此刻在哪里的时候,你只能说v[0]
在v
里面,或者把v[0]
取出来放到另一盒巧克力中(比如一个结构体s: MyStruct
的某个字段或者一个HashMap中),总不能说这颗巧克力同时在两个盒子中吧(抖机灵的或者想讨论哲学的乖乖站好给我叉出去[○・`Д´・ ○],这里只讨论巧克力在宏观世界中的物理位置)。我猜你会觉得上面这个关系很好理解(毕竟现实生活就是这样吗),可是当你试图把其它编程语言的设计带入到这个“巧克力场景”中,你就会发现你问其它语言一个朴素的问题,其它语言给你讲哲学(比如可以有多个指针指向内存中同一块数据,这不就相当于它回答你“这块巧克力即在我的嘴里,也在你的心里”一样嘛[○・`Д´・ ○])。
“哲学”设计能赋予开发者灵活性,而“朴素”设计会在一定程度上限制了开发者“灵活的思维”,但却保证了数据是可控的(不会存在即吃了又还没有吃的叠加态~)
根据上面的说明可发现值与值之间能构成树,每个值只有一个持有者(默认自己是自己的持有者),如下所示:
|-a:i32 # 从属自己
|-b:str # 从属自己
|-hash_map: HashMap<str, str> # 从属自己
|-entry<str, i32> # entry从属于hash_map
|-key:str # key从属于entry
|-value:i32 # value从属于entry
|-v:vec<i32> # 从属自己
|-1 # 从属于v
|-100 # 从属于v
|-200 # 从属于v
当一个值更改持有者时,相当于更改了子树,比如v[0]
取出来后设置到hash_map
中,不可能v[0]
的数据同时属于两棵子树(那就变成图了)。
当值从这个“值的森林”中移除时,应该把它和它的子树一并移除,不然如果它移除了,但是它的子树(假如叫sub_tree)没有移除,为了满足上面讲到持有者的条件,就会想到两种可能发成的情况:
因此此时就可以确定这些值都是可以被清除。
s
的作用域:fn main() {
// s 不可用
let s = "hello"; // s 可用
// 可以对 s 进行相关操作
} // s 作用域到此结束,s 不再可用
为什么使用String类型解释所有权:
这里只重点将String类型在所有权上的知识点。
字符串字面值:在程序里手写的那些字符串值,它们是不可变的。
针对字符串字面值无法满足开发需求的问题,Rust提供第二种字符串类型:String:
指针:
from
函数从字符串字面值创建出String类型:let s = String::from("hello");
fn main() {
let mut s = String::from("Hello");
s.push_str(", World");
println!("{}", s);
}
多个变量与同一个数据使用一种独特的方式来交互——移动。
var1 绑定了 value_a
当进行var1的值赋予var2时
if value_a是一个胖指针 {
完整复制value_a给var2
var1失效
} else {
完整复制value_a给var2 // var1也有一份一模一样的数据
}
Rust不会自动创建数据的深拷贝,默认的所有操作都是移动。
clone()
方法。给定一个值,根据值的构成考虑在进行多变量交互时的行为:
如果一个类型实现了Copy trait,那么旧的变量跟在赋值后仍然可用。
如果一个类型或者该类型的一部分实现了Drop trait,那么Rust不允许它再实现Copy trait。(即一个类型实现Copy trait,必须要保证它的每一个元素都实现Copy trait)
使用Copy trait的要求:
一些属于Copy的类型:
drop
函数清理,除非数据的所有权移动到另一个变量上;引用操作符&
语法:
说明 | 真实数据类型 | 表示该类型的引用 | 获取某个值的引用 |
---|---|---|---|
不可变引用 | MyType |
&MyType |
let my_ref: &MyType = &my_value; |
可变引用 | MyType |
&mut MyType |
let my_ref: &mut MyType = &mut my_value; |
引用可变性和变量可变性的关系:
说明 | 声明 | 变量的不可变引用 | 变量的可变引用 |
---|---|---|---|
不可变变量 | let x = ...; |
&x |
没有 |
可变变量 | let mut y = ...; |
&y |
&mut y |
数据竞争:以下三种行为同时满足的话会发生数据竞争
为了防止数据竞争,针对上述三个发生数据竞争的条件,Rust对可变引用做出两个限制:
上述两个限制可理解为读锁和写锁。
可以通过创建新的作用域,来允许非同时的创建多个可变引用。
fn main() {
let mut s1 = String::from("Hello");
let r1 = &mut s1;
{
// 在这个作用域中,r1和r2同时生效,违反可变引用的限制
let r2 = &mut s1;
println!("s1 {} ", r2);
}
println!("s1 {}", r1);
}
```
fn main() {
let mut s1 = String::from("Hello");
{
// 在这个作用域中,只有r2
let r2 = &mut s1;
println!("s1 {} ", r2);
}
// 在这个作用域中,只有r1,r2已经被回收了
let r1 = &mut s1;
println!("s1 {}", r1);
}
```
fn main() {
let mut s1 = String::from("Hello");
let r1 = &mut s1;
let r2 = &mut s1; // 报错:超过一个s1的可变引用
println!("s1 {} and s1 {}", r1, r2);
}
fn main() {
let mut s1 = String::from("Hello");
let r1 = &s1;
let r2 = &s1;
println!("{} {}", r1, r2);
// 屏蔽下面两行则没有错误
let r3 = &mut s1;
println!("{} {} {}", r1, r2, r3);
}
fn main() {
let r = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s // 因为s的销毁比&s要早,所以编译报错
}
引用可变性和变量可变性的关系:
说明 | 声明 | 变量的不可变引用 | 变量的可变引用 |
---|---|---|---|
不可变变量 | let x = ...; |
&x |
没有 |
可变变量 | let mut y = ...; |
&y |
&mut y |
字符串切片是指向字符串中一部分内容的引用。
字符串切片形式:&str_name[start_index..end_index];
&str_name
表示切片是来自str_name
的一个部分引用。start_index
表示切片的起始索引值。end_index
表示切片的终止位置的下一个索引值。[start_index, end_index)
。字符串切片类型:&str
。
&str
是一个不可变引用。语法糖:
st
到字符串结尾:&str_name[st..]
。ed
:&str_name[..ed]
。&str_name[..]
。注意:
给出例子,在下面的代码中,有字符串s
以及两个字符串切片hello
和world
。其中s
和world
的内存关系如下图所示。可以看出:
s
拥有三个元素:指向heap数据的指针、字符串长度、字符串容量。world
拥有两个元素:指向heap数据起始位置的指针、切片长度。fn main() {
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
println!("{}, {}", hello, world);
}
fn main() {
let s = String::from("hello world");
let world = first_word(&s);
println!("{}", world);
}
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
&str
是一个不可变引用,所以字符串字面值也是不可变的。&str
,这样可以保证函数既可以接收到&String
类型的参数,也可以接收&str
类型的参数。&String
和&str
是两个不同的类型:
&String
是字符串值的引用。&str
是字符串值的切片。&str
为入参的函数:fn first_word(s: &str) -> &str { /*...*/ }
&String
类型时,会创建一个完整的切片来调用该函数,即&str_name[..]
。&str
)时,会直接调用该函数。类型 | 声明切片类型 | 获取值的切片 |
---|---|---|
不可变切片 | &[MyCollectionType] |
let x: &[MyCollectionType] = &my_value[st..ed]; |
可变切片 | &mut [MyCollectionType] |
let x: &mut [MyCollectionType] = &mut my_value[st..ed]; // 需要保证my_value是可变的 |