0x0 前言
Rust 的所有权系统是一种内存管理机制,用于在编译时检查内存访问的安全性。它的核心思想是:每个值都有一个所有者,并且每个值在任意时刻只能有一个所有者。这种机制有助于避免常见的内存安全问题,如空指针、野指针、数据竞争。Rust中没有垃圾回收器,所有权便保证内存安全
首先需要了解栈和堆的工作原理有助于理解系统编程语言中的所有权概念。
- 栈是一种后进先出(LIFO)的数据结构,用于存储函数调用、局部变量等数据。栈中的所有数据必须占用已知且固定的大小,因此适合存储固定大小的数据。
- 堆是一种无序的内存区域,用于存储动态分配的数据,大小在编译时不确定。在堆上分配内存时,需要请求一定大小的空间,并由内存分配器找到合适的位置。堆上的数据访问需要通过指针,因此相对于栈上的数据访问速度较慢。
0x1 所有权规则
- 每个值都有一个称为“所有者”的变量。
- 同一时间只能有一个所有者。
- 当所有者超出作用域时,该值将被销毁。
下面是一个具体的例子来说明 Rust 中所有权的概念:
fn main() { // s1,s2 在这里无效, 它尚未声明
let s1 = String::from("hello"); // 创建一个新的 String,从此处起s1开始有效
let s2 = s1; // 将 s1 移动到 s2,从此处起s2开始有效,s1所有权移动了s1无效了
println!("{}", s2); // 这行代码可以正常运行
println!("{}", s1); // 这行代码会导致编译错误,因为 s1 的所有权已经移动到 s2
}// 此作用域已结束,s2 不再有效
这张图可以看出s1
在栈上的内存空间仍然存在,但它不再包含有效的数据。Rust不会自动"出栈"这部分内存,但它会被认为是未初始化的,并且不能再次被使用。
当变量超出作用域时,Rust 会调用其所有权的 drop
方法来释放内存。在这种情况下,虽然 s1
和 s2
的所有权已经移动,但它们的内存将在超出作用域时被自动释放。
在这个例子中,我们首先创建了一个 String 类型的变量 s1,然后将它的所有权移动给了变量 s2。在 Rust 中,对于拥有堆分配内存的类型(比如 String、Vec 等),移动操作会导致原始变量失效,因为它的所有权已经被转移。因此,当我们尝试在打印 s1 时,编译器会报错。
这个错误是因为在 Rust 中,String 类型是不具备 Copy trait
的,当你将 s1 赋值给 s2 时,实际上是将 s1 的所有权(ownership)
转移到了 s2,因此 s1 不再拥有对 String 对象的所有权。在接下来的 println!
宏中,你仍然在尝试访问 s1,这是不允许的,因为 s1 已经不再有效。
0x2 内存与分配
要解决上面的这个问题,可以使用引用来避免所有权转移,或者使用 s2 的克隆来创建一个新的 String 对象。
以下是两种解决方法的示例:
- 使用引用:
let s1 = String::from("hello");
let s2 = &s1; // 使用引用来借用 s1 的所有权
println!("{}", s1); // 这里可以正常访问 s1
- 使用克隆:
let s1 = String::from("hello");
let s2 = s1.clone(); // 使用 clone 方法创建一个新的 String 对象
println!("{}", s1); // 这里可以正常访问 s1
这里堆上的数据 确实 被复制了,接着看
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
没有调用 clone
,不过 x
依然有效且没有被移动到 y
中,因为它的值已经被复制到了新的变量中。。原因是在 Rust 中,某些类型是通过在栈上直接存储它们的值来管理的,这包括基本的数值类型(如整数和浮点数)、布尔值和字符类型。这些类型的一个共同特点是它们的大小在编译时是已知的,并且相对较小,这使得复制它们的值非常快速和高效。
在 Rust 中,整数、浮点数、布尔值和字符类型等基本类型的值是在栈上直接存储的,它们的大小在编译时已知且相对较小。因此,复制这些类型的值非常快速和高效,而不需要显式地调用 clone 方法。这使得对这些类型的变量进行赋值或传递时,值会被快速复制到新的变量中,而不会发生所有权转移。
Rust 中有一个特殊的 trait
(类似于接口或协议)叫做 Copy
。这个 trait 可以被实现在那些“复制其值”是安全且不会导致意外行为的类型上。如果一个类型实现了 Copy
trait,当你将一个变量赋值给另一个变量时,原变量不会失效,你可以在之后继续使用它。这就是为什么基本类型可以“复制”它们的值而不会失去所有权。
然而,并非所有类型都可以实现 Copy
trait。例如,如果一个类型或其任何部分实现了 Drop
trait,它就不能实现 Copy
。Drop
trait 是 Rust 中用于自定义变量离开作用域时执行的清理代码的方式。通常,那些需要显式资源释放的类型(如文件句柄或网络连接)会实现 Drop
。因此,它们不能是 Copy
的,因为复制这样的资源可能会导致二次释放。
0x3 String的内存模型中的存储方式如下
let mut s = String::from("Hello");
-
堆(Heap):
String
类型的数据实际上存储在堆上。这意味着字符串的内容(在本例中是字符H
、e
、l
、l
、o
)被分配一块连续的内存空间,而这块空间的大小可以动态变化以容纳更多的字符。 -
栈(Stack):变量
s
本身存储在栈上,它包含指向堆上字符串数据的指针、字符串的长度(在本例中是5,因为"Hello"有5个字符),以及字符串的容量(即堆上分配给字符串的内存大小,至少也是5,可能更大以便于未来的扩展而不需要重新分配内存)。
在编译时,一切无法确定大小或者大小可以改变的数据,都无法安全地放在栈上,最好放在堆上。
栈上存放的数据是静态的,固定大小,固定生命周期;堆上存放的数据是动态的,不固定大小,不固定生命周期。
Stack:
+-----+-----+------+
| ptr | len | cap |
+-----+-----+------+
| | |
| | | Heap:
v v v +-----+-----+-----+-----+-----+
[Heap memory] | 'H' | 'e' | 'l' | 'l' | 'o' |
+-----+-----+-----+-----+-----+
ptr
是一个指针,指向堆上存储字符串数据的地方。len
是字符串的当前长度。cap
是堆上为这个String
分配的容量。
0x4 所有权与函数
fn main() {
let s = String::from("hello"); // s 进入作用域
takes_ownership(s); // s 的值移动到函数里 ...
//print!("{}", s); 所以s到这里不再有效,打开注释的话会编译报错
let x: i32 = 5; // x 进入作用域
makes_copy(x); // x 应该移动函数里,
// 但 i32 是 Copy 的,所以在后面可继续使用 x
let s1 = String::from("word");
let s2 = takes_and_gives_back(s1);//s2 则拥有了原来 s1 的所有权。
} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走,所以不会有特殊操作
// s2 移出作用域
fn takes_ownership(some_string: String) { // some_string 进入作用域
println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放
fn makes_copy(some_integer: i32) { // some_integer 进入作用域
println!("{}", some_integer);
} // 这里,some_integer 移出作用域。不会有特殊操作
// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String {//a_string 进入作用域,接收了s1的所有权
a_string // 返回 a_string 并移出给调用的函数,意味着它的所有权被移动到了函数外部,赋值给了 s2
}
变量的所有权总是遵循相同的模式:将值赋给另一个变量时移动它。当持有堆中数据值的变量离开作用域时,其值将通过 drop 被清理掉,除非数据被移动为另一个变量所有。
如果我们想要函数使用一个值但不获取所有权该怎么办呢?如果我们还要接着使用它的话,我们也可以返回函数体中产生的一些数据。
fn main() {
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(); // len() 返回字符串的长度
(s, length)
}
要让函数使用值而不获取所有权,可以使用借用。通过传递引用,你可以让函数读取或修改值而不取得所有权。这样就不需要每次都把值传来传去。如果函数需要返回新数据,它仍然可以这么做,而不影响原始值。
0x5 引用和借用
引用和借用是一种让你使用值但不获得其所有权的方式。
5.1 引用
在 Rust 中,可以通过在变量前使用 &
符号来创建一个引用。这个引用指向变量的值,但并不拥有它。这意味着当引用离开其作用域时,它不会删除其指向的数据。
fn main() {
let mut s1 = String::from("hello");
let len = calculate_length(&s1);
change(&mut s1);// 可变引用
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize {
s.len()
}// 结束时,s1 不会被删除。
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
+------+-------+ +------+-------+ +-------+-------+
| name | value | | name | value | | index | value |
+------+-------+ +------+-------+ +-------+-------+
| ptr | ----|-----> | ptr | ----|----> | 0 | h |
+------+-------+ +------+-------+ +-------+-------+
s | len | 5 | | 1 | e |
+------+-------+ +-------+-------+
| cap | 5 | | 2 | l |
+------+-------+ +-------+-------+
s1 | 3 | l |
+-------+-------+
| 4 | o |
+-------+-------+
Rust引用和C++引用的区别:
- 生命周期检查:Rust 的引用包含了生命周期信息,这使得编译器能够在编译期间检查引用的有效性,避免了悬垂指针和内存安全问题。C++ 的引用没有生命周期的概念,因此在某些情况下可能会导致悬垂引用或空引用的问题。
- 可变性:在 Rust 中,引用的可变性是通过
&
和&mut
来区分的,这使得在编译期间能够进行更严格的可变性检查。而在 C++ 中,引用的可变性是通过const
修饰符来区分的,但在实际使用中可能会更加灵活,这可能导致一些潜在的问题。 - 所有权系统:Rust 的引用是基于所有权系统的,因此在引用中也会涉及所有权的转移和借用等概念。而 C++ 的引用则是基于指针的概念,不涉及所有权系统。
5.2 借用borrow
“借用” 是 Rust 中一个重要的概念。当你创建一个引用时,你实际上是在"借用"一个值。借用有两种形式:一种是你只读取值,不改变它;另一种是你可以改变值。
Rust 通过一系列的规则来管理借用,从而在编译时就避免数据竞争等问题。这些规则包括:
- 你可以有任意数量的不可变引用;
- 同一时间只能有一个可变引用;
- 你不能同时拥有可变和不可变引用。
//同一时间只能有一个可变引用
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;//编译期报错
println!("{}, {}", r1, r2);
可以使用大括号来创建一个新的作用域,以允许拥有多个可变引用,只是不能 同时 拥有:
let mut s = String::from("hello");
{
let r1 = &mut s;
} // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用
let r2 = &mut s;
//你不能同时拥有可变和不可变引用
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 没问题
let r2 = &s; // 没问题
// 编译报错 cannot borrow `s` as mutable because it is also borrowed as immutable
let r3 = &mut s;
println!("{}, {}, and {}", r1, r2, r3);
}//不能在拥有不可变引用的同时拥有可变引用。不可变的引用可不希望在他们的眼皮底下值就被意外的改变了
这些规则确保了在任何时候,只有一个可变引用可以访问特定的数据,或者只有多个不可变引用可以访问。这样就在编译期消除了数据竞争的可能性,避免运行时出问题。
5.3 悬垂引用(Dangling References)
具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个 悬垂指针(dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String { // dangle 返回一个字符串的引用
let s = String::from("hello"); // s 是一个新字符串
&s // 返回字符串 s 的引用
} // 因为引用没有所有权,这里 s 离开作用域并被丢弃。其内存被释放。
解决方法:返回非引用的s即可,将所有权被移动出去,不让值被释放。
0x6 切片 slice
在 Rust 中,切片(slice)是对集合中一段连续元素的引用。切片允许你访问集合的一部分,而不是整个集合,这在处理数组或向量(vector)时特别有用。切片是不可变的,但你也可以创建可变切片,这取决于你如何借用它们的原始数据。
另一个没有所有权的数据类型是 slice。slice 允许你引用集合中一段连续的元素序列,而不用引用整个集合。
6.1创建切片
假设我们有一个数组,我们想要创建一个切片来引用这个数组的一部分:
fn main() {
let arr = [1, 2, 3, 4, 5];
// 创建一个切片
// 切片默认是,左闭右开
// 包含 arr 的第二个和第三个元素
let slice = &arr[1..3]; // 这里,slice 的类型是 &[i32]
println!("{:?}", slice);
}
{:?}
可以用来打印切片,因为切片类型实现了std::fmt::Debug
这个 trait。当使用{:?}
作为格式化字符串时,它会调用Debug
trait 的fmt
方法来获取切片的字符串表示,以便于调试输出。Rust 标准库中几乎所有的基本类型都实现了
Debug
,包括基本的数组和切片类型,不需要自己编写任何额外的代码来格式化输。
6.2 切片语法
切片使用 &
加上 [starting_index..ending_index]
的语法创建。这里的 starting_index
是切片开始的位置,而 ending_index
是切片结束的下一个位置(左闭右开)。所以 &arr[1..3]
实际上包含 arr
中索引为 1 和 2 的元素。
如果你省略了起始索引,切片将从集合的开头开始;如果省略了结束索引,切片将延续到集合的末尾。例如:
let slice_from_start = &arr[..2]; // 从开始到索引 2(不包括索引 2)
let slice_to_end = &arr[2..]; // 从索引 2 到结束
let full_slice = &arr[..]; // 整个数组的切片
6.3 可变切片
如果你拥有一个可变的集合,你可以创建一个可变切片来改变集合中的元素。例如:
fn main() {
let mut arr = [1, 2, 3, 4, 5];
// 创建一个可变切片,包含整个数组
let slice_mut = &mut arr[..];
// 改变切片中的元素
slice_mut[0] = 10;
println!("{:?}", arr);//[10, 2, 3, 4, 5]
}
6.4 使用切片作为函数参数
切片经常用作函数参数,以便函数可以接受任何长度的数组或向量。例如:
fn sum(slice: &[i32]) -> i32 {
slice.iter().fold(0, |acc, &x| acc + x)
}
fn main() {
let arr = [1, 2, 3, 4, 5];
let slice = &arr[1..4];
let result = sum(slice);
println!("The sum of the slice is {}", result);
}
在这个例子中,sum
函数接受一个整数类型的切片,并返回它们的和。你可以传递一个数组的任何部分作为切片。
fn sum(slice: &[i32]) -> i32 {
slice.iter().fold(0, |acc, &x| acc + x)
}
fn sum(slice: &[i32]) -> i32 { ... }
: 这定义了一个名为sum
的函数,它接收一个参数slice
,这是一个对整数数组的不可变引用(即一个切片),并返回一个i32
类型的整数。slice.iter()
: 这将切片转换为一个迭代器,迭代器会依次产生切片中的每个元素的引用。.fold(0, |acc, &x| acc + x)
:fold
方法是一个迭代器适配器,用于累积迭代器中的元素。它开始于初始累积值0
(在这里是整数的和的起始值),然后遍历迭代器中的每个元素。对于迭代器中的每个元素,它调用闭包|acc, &x| acc + x
:|...|
:闭包的参数被包围在一对竖线中,这标志着闭包的开始。这与函数定义中使用的圆括号(...)
不同。竖线告诉 Rust 这是一个闭包的参数列表。acc
:这是闭包的第一个参数,它代表累积值,即迄今为止所有元素的和。&x
:这是闭包的第二个参数,它是对当前迭代元素的引用。&
表示我们想要匹配元素的引用而不是值本身,这样我们就可以在不获取元素所有权的情况下使用它的值。acc + x
:这是闭包体,它描述了如何将当前元素x
的值与累积值acc
相加。&x
是对当前迭代元素的引用,前面的&
表示模式匹配,即解引用,这样x
就是当前元素的值。acc + x
是闭包的主体,它将当前元素的值加到累积值上。
- 在迭代完所有元素后,
fold
返回最终的累积值,即切片中所有元素的和。
fn main() {
let arr = [1, 2, 3, 4, 5];
let slice = &arr[1..4];
let result = sum(slice);
println!("The sum of the slice is {}", result);
}
let slice = &arr[1..4];
: 这行代码创建了一个切片slice
,它是数组arr
的一个部分,从索引1
(包含)到索引4
(不包含)的元素的引用。因此,slice
包含元素2, 3, 4
。
//输出
The sum of the slice is 9
6.5 字符串切片
字符串切片(str
)是 Rust 中另一种常见的切片类型,它是对某个 String
的引用。例如:
fn main() {
let mut str = String::from("Hello Word");
let hello = &str[0..5];
let word = &str[6..str.len()];// 切片中的0和len() 可以省略不写
println!("{}, {}", hello, word);
let whole = &str[..]; //获取整个字符串切片
println!("{}", whole);
let result = first_word_slice(&str);
println!("result is {}", result);
}
fn first_word_slice(strs: &String) -> &str {
let str_bytes = strs.as_bytes();
// enumerate返回一个元组
for (i, &item) in str_bytes.iter().enumerate() {
//字符串在底层是以字节的形式存储的,因此当你想要操作字符串的单个字节时,
//需要使用字节字面量,它们以 b 前缀开头。
if item == b' ' {
return &strs[..i];
}
}
&strs[..]
}
6.6 注意事项
切片的一个重要方面是它们不拥有它们所引用的数据。当原始数据(如数组或字符串)离开作用域并被丢弃时,任何指向它的切片都将变得无效。因此,切片的安全性和生命周期是 Rust 保证内存安全的关键部分。