文章目录
- 什么是所有权
- Stack vs Heap
- 所有权规则
- 变量作用域
- String类型
- 内存与分配
- 所有权与函数
- 引用与借用
- 可变引用
- 悬垂引用
- 引用的规则
- 切片
- 字符串切片
- 其他类型的切片
什么是所有权
什么是所有权
所有程序在运行时都必须管理其使用计算机内存的方式:
- 一些语言中具有垃圾回收机制,在程序运行时有规律地寻找不再使用的内存,比如C#和Java。
- 在另一些语言中,程序员必须自行分配和释放内存,比如C/C++。
而Rust则是通过所有权系统管理内存:
- 所有权是Rust最独特的特性,它让Rust无需GC就可以保证内存安全,这也是Rust的核心特性。
- 通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查,如果违反了所有权规定,则程序不能通过编译。
- 在程序运行时,所有权系统不会减慢程序的运行速度,因为所有权规则的检查是在编译时进行的。
Stack vs Heap
在很多语言中,程序员不需要经常考虑到堆和栈,但在Rust这样的系统编程语言中,一个值存储在heap上还是stack上,会很大程度上影响语言的行为,所以这里先对堆和栈进行简单介绍。
分配内存
栈(stack):
- stack按值的接收顺序来存储,按相反的顺序将它们移除,即后进先出(LIFO)。
- 所有存储在stack上的数据必须拥有已知的固定大小,添加数据叫做入栈,移除数据叫做出栈。
- 将数据存放在stack时不需要寻找用来存储数据的空间,因为那个位置永远在stack的顶端。
堆(heap):
- 在编译时大小未知的数据或运行时大小可能发生变化的数据,必须存放在heap上。
- 当把数据放入heap时,需要先在heap上分配对应大小的空间,即操作系统在heap中找到一块足够大的空间把它标记为在用,并将该空间的地址返回,后续访问heap上的数据时,需要通过指针来定位。
访问数据
- 访问heap中的数据比访问stack中的数据慢,因为需要通过指针才能找到heap中的数据,属于间接访问。
- 对于现代的处理器来说,由于缓存的缘故,如果指令在内存中的跳转的次数越少,那么速度就越快。
- stack中数据存放的距离比较近,而heap中数据存放的距离比较远,因此访问heap中的数据比访问stack中的数据慢。
所有权存在的原因
所有权存在的原因,就是为了管理存放在heap上的数据:
- 跟踪代码的哪些部分正在使用heap上的哪些数据。
- 最小化heap上的重复数据量。
- 清理heap上未使用的数据,以避免内存泄露。
所有权规则
所有权规则
所有权的规则如下:
- Rust中的每一个值都有一个对应的变量作为它的所有者。
- 在同一时间内,每个值有且只有一个所有者。
- 当所有者离开自己的作用域时,它持有的值就会被释放掉。
变量作用域
变量作用域
- 作用域(scope)指的是程序中一个项,在程序中的有效范围。
在下面的代码中,变量s从第三行声明开始变得可用,在第五行代码块结束时离开作用域变得不可用。如下:
fn main() {//s不可用let s = "hello"; //s可用//可以对s进行相关操作
} //s作用域到此结束,s不再可用
String类型
String类型
为了后续讲解Rust的所有权,我们需要借助一个管理的数据存储在heap上的类型,这里选择String类型。
- Rust中基础的标量类型的数据是存储在stack上的,而String类型比这些类型更加复杂,它管理的数据是存储在heap上。
- String类型管理的数据存储在heap上,因此String类型能够存储在编译时未知大小的文本,即String类型是可变的。
- Rust中有两种字符串类型,一种是字符串字面值,它是不可变的,另一种就是String类型,其管理的字符串是可变的。
String类型由三部分组成:
- ptr:指向存放字符串内容的指针。
- len:表示字符串的长度。
- capacity:表示字符串的容量。
String类型的这三部分数据存储在stack上,而String管理的字符串则存储在heap上。如下:
创建String字符串
创建String字符串可以使用from函数,该函数可以基于字符串字面值来创建String字符串。如下:
fn main() {let mut s = String::from("Hello");s.push_str(" String");println!("{}", s); //Hello String
}
说明一下:
- 代码中的
::
,表示from是String类型的命名空间下的函数。 - String类型的push_str方法,可以将指定的字符串插入到String字符串的后面。
内存与分配
内存与分配
- 对于字符串字面值来说,在编译时就知道它的内容了,其文本内容会直接被硬编码到最终的可执行文件中,因此访问字符串字面值快速且高效。
- 而String类型为了支持可变性,需要在heap上分配内存来保存编译时未知的文本内容,其必须在运行时向内存分配器请求内存,当我们处理完String时再将内存返回给分配器。
注:在Rust中,当某个值离开作用域时,会自动调用drop函数释放内存。
变量与数据交互的方式:移动(Move)
在Rust中,多个变量可以采取不同的方式与同一数据进行交互。如下:
fn main() {let x = 10;let y = x;println!("x = {}", x); //x = 10println!("y = {}", y); //y = 10
}
说明一下:
- 代码中先将整数字面值10绑定到了变量x,接着生成了变量x的拷贝,并将其绑定到变量y。
- 因为整数是已知固定大小的简单值,因此x和y都被放入到了栈中,在赋值后两个变量都有效。
如果将代码中的整数换成String,那么运行程序将会产生报错。如下:
fn main() {let x = String::from("hello");let y = x;println!("x = {}", x); //errorprintln!("y = {}", y);
}
报错的原因就是我们借用了已经被移动的值x。如下:
现在我们来分析一下代码,刚开始声明变量x的时候,整体布局如下:
当把变量x赋值给变量y时,String的数据被拷贝了一份,但拷贝的仅仅是stack上的String元数据,而并没有拷贝指针所指向的heap上的数据。如下:
当变量离开作用域时,Rust会自动调用drop函数释放内存,为了避免这种情况下heap上的数据被二次释放,因此Rust会让赋值后的变量x失效,此时当x离开作用域时就不会释放内存。如下:
这就是为什么在赋值后访问变量x就会产生报错的原因,因为此时变量x已经失效了。
说明一下:
- stack上的拷贝可以视为浅拷贝,heap上的拷贝可以视为深拷贝。
- 由于深拷贝的成本比较高,因此Rust不会自动进行数据的深拷贝。
变量与数据交互的方式:克隆(Clone)
如果确实需要对String的heap上的数据进行拷贝,那么可以使用String的clone方法。如下:
fn main() {let x = String::from("hello");let y = x.clone(); //深拷贝println!("x = {}", x); //x = helloprintln!("y = {}", y); //y = hello
}
拷贝后变量x和变量y都是有效的,因为String的clone方法会将stack和heap上的数据都进行拷贝。如下:
stack上的数据:拷贝(Copy)
- Copy trait可以用在类似整型这样存储在栈上的类型,如果一个类型实现了Copy trait,那么旧的变量在赋值给其他变量后仍然可用。
- 任何简单标量的组合类型都可以实现Copy trait,任何不需要分配内存或某种形式资源的类型也都可以实现Copy trait。
- 所有整数类型、浮点类型、布尔类型、字符类型都实现了Copy,此外,如果元组中所有字段都实现了Copy,那么这个元组也是可Copy的,比如
(i32, i32)
是可Copy的,而(i32, String)
是不可Copy的。
说明一下:
- 如果一个类型或该类型的一部分实现了Drop trait,那么Rust不允许它再实现Copy trait。
- 如果一个类型要实现Copy trait,那么该类型也必须实现Clone trait。
- String赋值后变量会失效,就是因为String没有实现Copy trait,在赋值时会发生移动。
所有权与函数
所有权与函数
将值传递给函数和给变量赋值的原理类似:
- 对于没有实现Copy trait类型的变量来说,将值传递给函数时会发生移动,调用函数后变量失效。
- 对于实现了Copy trait类型的变量来说,将值传递给函数时会发生拷贝,调用函数后变量仍然有效。
例如,下面代码中变量s传入函数时将发生移动,后续不再有效,而变量x传入函数时将发生拷贝,后续仍然有效。如下:
fn main() {let s = String::from("hello world");take_ownership(s); //发生移动//println!("s = {}", s); //errorlet x = 10;makes_copy(x); //发生拷贝println!("x = {}", x); //x = 10;
}fn take_ownership(some_string: String) {println!("{}", some_string); //hello world
}fn makes_copy(some_number: i32) {println!("{}", some_number); //10
}
返回值与作用域
函数在返回值的过程中同样会发生所有权的转移。如下:
fn main() {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
}
代码说明:
- gives_ownership函数的作用是,创建了一个String,并将其所有权返回。
- takes_and_gives_back函数的所用是,取得了一个String的所有权,然后再将其所有权返回。
引用与借用
引用与借用
- 对于String类型来说,&String就是String类型的引用,我们将创建一个引用的行为称为借用。
- 一个类型的引用不会取得该类型变量的所有权,因此当引用离开作用域时不会释放对应的空间。
例如,下面代码中的calculate_length函数的参数类型是&String,该函数返回传入String的长度但不获取其所有权,函数调用后传入的String变量仍然有效。如下:
fn main() {let s1 = String::from("hello world");let len = calculate_length(&s1);println!("'{}'的长度是{}", s1, len); //'hello world'的长度是11
}fn calculate_length(s: &String) -> usize {s.len()
}
实际calculate_length函数的参数s就是一个指针,它指向了传入的实参s1。如下:
说明一下:
- 与
&
引用相反的操作是解引用,解引用运算符是*
。
可变引用
可变引用
引用和变量一样默认也是不可变的,要让引用变得可变,同样需要使用mut关键字。如下:
fn main() {let mut s1 = String::from("hello world");let len = calculate_length(&mut s1);println!("'{}'的长度是{}", s1, len); //'hello world!!!'的长度是14
}fn calculate_length(s: &mut String) -> usize {s.push_str("!!!"); //修改了引用的变量s.len()
}
但可变引用有一个重要的限制就是,在特定作用域内,一个变量只能有一个可变引用,否则会产生报错。如下:
fn main() {let mut s = String::from("hello world");let s1 = &mut s;let s2 = &mut s; //errorprintln!("s1 = {}, s2 = {}", s1, s2);
}
Rust这样做可以在编译时就防止数据竞争,但可以通过创建新的作用域来允许非同时的创建多个可变引用,因为只要保证同一个作用域下一个变量只有一个可变引用即可。如下:
fn main() {let mut s = String::from("hello world");{let s1 = &mut s;}let s2 = &mut s;
}
可变引用的其他限制
Rust中不允许一个变量同时拥有可变引用和不可变引用,否则会产生报错。如下:
fn main() {let mut s = String::from("hello world");let r1 = &s;let r2 = &s;let s1 = &mut s; //errorprintln!("{} {} {}", r1, r2, s1);
}
原因: 不可变引用的要求其引用的值不能发生改变,而可变引用却可以改变其引用的值,因此一个变量同时拥有可变引用和不可变引用,就是的不可变引用的作用失效了,但一个变量同时拥有多个不可变引用是可以的。
悬垂引用
悬垂引用(Dangling References)
悬垂引用指的是,一个指针引用了内存中的某个地址,但这块内存可能已经释放了。如下:
fn main() {let r = dangle();
}fn dangle() -> &String {let s = String::from("hello world");&s //悬垂引用
}
在Rust中编译器确保引用永远不会变成悬垂状态,因为编译器会确保数据不会在其引用之前离开作用域,因此上述代码会编译报错。如下:
说明一下: 报错内容说缺少一个生命周期说明符,生命周期相关的内容会在后续博客中讲解。
引用的规则
引用的规则
引用的规则如下:
- 在任何时刻,一个变量只能有一个可变引用,或任意数量的不可变引用。
- 引用必须一直有效。
切片
字符串切片
字符串切片
- 除了引用之外,Rust还有另一种不持有所有权的数据类型,叫做切片(slice)。
- 字符串切片就是指向字符串中一部分值的引用,切片形式为:
&字符串变量名[开始索引..结束索引]
。 - 字符串切片中的开始索引指的是切片起始位置的索引值,结束索引指的是切片终止位置的下一个索引值。
- 如果切片的起始位置是0,那么开始索引可以省略,如果切片的终止位置是字符串的长度,那么结束索引可以省略。
- 字符串切片的类型是&str,字符串切片是不可变的。
例如,下面分别创建了字符串hello world
的hello
的切片和world
的切片。如下:
fn main() {let s = String::from("hello world");let hello = &s[0..5];let world = &s[6..11];println!("hello = {}", hello); //hello = helloprintln!("world = {}", world); //world = world
}
切片中包含一个指针和一个长度,比如上述的world
切片,其指针指向字符串索引为6的位置,其长度就是5。如下:
切片在Rust中是非常有用的,比如获取字符串中的第一个单词,那么借助字符串切片可以编写出如下代码:
fn main() {let s = String::from("hello world");let word = first_word(&s);//s.clear(); //error: s已经存在一个不可变引用println!("word = {}", word); //word = hello
}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[..]
}
说明一下:
- 如果不使用字符串切片,也可以通过返回字符串中第一个空格的索引来间接表示字符串中第一个单词的位置,但此时这个索引是独立于这个字符串存在的,当字符串中的内容被清除后这个索引就没有意义了。
- 而切片是不可变的,因此如果一个字符串存在一个切片,那么在这个切片没有离开作用域之前,这个字符串中的内容是无法被修改的,因为Rust不允许一个变量同时拥有可变引用和不可变引用,否则会产生报错。
- as_bytes方法的作用是将String转化为字节数组,以方便遍历String的每一个字节来与空格进行比较。
- iter方法的作用是在字节数组上创建一个迭代器,它将会返回字节数组中的每一个元素,而enumerate方法的作用是对iter的结果进行包装,将这些元素作为元组的一部分来返回。enumerate返回的元组中,第一个元素是索引,第二个元素是集合中元素的引用。
- 在for循环中,通过模式对enumerate返回的元组进行解构,由于元组中第二个元素是集合中元素的引用,因此item需要使用
&
。
注意:
- 字符串切片的范围索引必须发生在有效的UTF-8字符边界内。
- 如果尝试从一个多字节的字符中创建字符串切片,程序会报错并退出。
字符串字面值就是切片
字符串字面值的类型实际上就是字符串切片&str
,这就是为什么字符串字面值不可变的原因,因为字符串切片&str
就是不可变的。如下:
fn main() {let s = "hello world"; //s的类型是&str
}
将字符串切片作为参数
如果要将字符串切片作为函数的参数,那么最好将函数的参数类型定义为&str
,而不是&String
,这样就能同时接收&String和&str的参数了,能够使我们的API更加通用且不会损失任何功能。如下:
fn main() {let my_string = String::from("hello world");let word = first_word(&my_string); //接收&Stringlet my_string_literal = "hello world";let word = first_word(my_string_literal); //接收&str
}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[..]
}
说明一下:
- &String等价于整个String的切片,因此可以用&str接收,而字符串字面值的类型本来就是&str。
其他类型的切片
其他类型的切片
与字符串切片类似,其他类型也可以有切片,比如对于下面代码中的数组来说,其切片类型就是&[i32]
。如下:
fn main() {let a = [1, 2, 3, 4, 5];let slice = &a[1..3]; //&[i32]类型
}