【Rust】004-Rust 所有权
文章目录
rust 语言很严谨!符合我严谨认真的性格!
一、预备知识
1、堆和栈
堆和栈都可以在程序运行时给程序提供内存空间,但是他们是有区别的。
想象一下,栈就像一叠盘子。先来的盘子在底部,新盘子则放在顶部。取用时,总是先取最上面的盘子,就像在餐馆里洗完的盘子先用先拿。这就是先进后出的原则。但是,盘子的大小必须是确定的,否则无法存放在这个“栈”里。
而堆则像一个大杂货仓库。你可以随时放入各种大小和形状的物品。为了方便找到它们,你需要贴上标签,这些标签就像指针,告诉你物品的位置。虽然这样存放物品灵活多变,但要找到合适的空间并读取它们就不如“栈”中的盘子方便了。
在性能的赛跑中,栈就像速度迅猛的跑车,堆则更像一辆载重卡车。栈的读写速度快,因为一切都井井有条,就像把盘子放在最上面那样简单。而堆要先找到合适的存放空间,读取时还要通过标签(指针)找到物品,就像在杂货仓库里寻找一个小小的零件一样,可能需要多花点时间。
2、String 类型
在 Rust 编程的世界中,字符串有点像我们现实生活中的名片(印好之后,不再改变)和便签(可以继续往上面写东西)。有两种类型让我们来认识它们:String和&str。
&str
存储逻辑
在 Rust 中,字符串字面量(如 "hello"
)的实际值通常存储在程序的只读数据段中。这个数据段是编译时确定的,并且在程序运行时是不可变的。这意味着字符串字面量在程序的生命周期内是常驻内存的,不会被修改或释放。
当你在代码中使用字符串字面量时,例如:
let s: &str = "hello";
这个 &str
类型的变量 s
是一个胖指针,包含了指向 "hello"
的内存地址和字符串的长度。这些信息本身存储在栈上,而 "hello"
的实际字符串数据则在程序的只读数据段中。
想象&str是一种名片,它被称为字符串字面值,就像是工厂里生产出来的名片,印好了,就再也不能改了。你可以这样给自己制造一张名片:
fn main() {
// 使用字符串字面量创建静态字符串
let hello = "hello world";
}
这张名片是固定的,不可变的,因为它是硬编码到程序中的,就像是直接印在墙上的字。
String
存储逻辑
- 堆上存储数据
String
的实际字符串数据存储在堆上。这意味着String
可以在程序运行时动态调整其大小,而不受栈大小的限制。
- 栈上存储元数据:
String
类型在栈上存储了一些元数据,包括一个指向堆上数据的指针、字符串的长度以及当前的容量(即分配的堆内存大小)。这些元数据使得String
可以管理堆上的内存。
- 容量与长度:
- 长度:表示当前存储在
String
中的字符数。 - 容量:表示已经分配的内存空间,允许在不重新分配的情况下扩展字符串。
String
可能会预先分配比实际需要更多的内存,以优化性能,减少频繁的内存分配和复制操作。
- 长度:表示当前存储在
- 增长策略:
- 当
String
的内容增加到超出其当前容量时,String
会自动分配更大的内存块,并将现有数据复制到新分配的内存中。这通常是通过倍增策略来实现的,以平衡内存使用和性能。
- 当
- 所有权与内存管理:
String
拥有其堆上数据的所有权,这意味着当String
被丢弃时,它会自动释放其占用的堆内存。这是 Rust 所有权模型和借用检查器的一个重要特性,确保了内存的安全管理。
- 可变性:
String
是可变的,可以通过方法如push
或push_str
来追加数据,或者通过truncate
来减少长度。
但生活中不是所有的信息都是确定的,有时候我们需要一些便签。这就是String
的角色了,比如你想要记录下用户的一些临时想法或命令。String
的数据存储在堆上,就像前面提到的杂货仓库,可以随时变动。你还记得堆和栈的故事吗?
创建一个String
就像是抓一张空白的便签纸,你可以从字符串字面值开始书写:
fn main() {
// 创建一个可变字符串
let mut hi = String::new();
// 写入文字
hi.push_str("hello");
hi.push_str(" world");
}
二、所有权规则
1、所有权系统的三条规则
- Rust 中每个值都有一个所有者;
- 一个值同时只能有一个所有者;
- 当所有者离开作用域范围,这个值将被丢弃。
2、代码示例
fn main() {
// 规则 1:Rust中每个值都有一个所有者
let s1 = String::from("hello"); // s1 是值 "hello" 的所有者
{
// 规则 2:一个值同时只能有一个所有者
let s2 = s1; // 所有权从 s1 转移到 s2(s1 不再有效)
// println!("{}", s1); // 这会导致编译时错误,因为 s1 不再有效
println!("{}", s2); // 这是允许的,因为 s2 现在拥有该值
} // s2在此处超出范围
// 规则 3:当所有者离开作用域范围,这个值将被丢弃
// 由于 s2 超出范围,因此为值 "hello" 分配的内存在此处自动释放。
}`
3、所有权转移
简单示例
i32
这样的简单类型,赋值的时候 Rust 会自动进行拷贝(Copy)。
let x = 5;
let y = x;
这段代码中,首先将 5 绑定到 x,接着再将 x 的值拷贝给 y。这两行执行完, x 和 y 都是 5,且都可以正常使用。稍稍改变一下这个例子。
而对于 String 这样的分配到堆上的复杂类型,发生的却是所有权的转移,而不是拷贝。
let s1 = String::from("hello");
let s2 = s1;
简单类型:自动拷贝(简单类型的实际数据内容存储在栈上的);
复杂类型:所有权转移(拷贝的是内存地址,实际数据内容在堆上,但会导致一个数据被两个变量同时拥有,离开作用域会出现双重释放的情况,进而导致安全问题,因此Rust设计了所欲全转移机制!)。
复杂类型的拷贝
这种拷贝不存在所有权的转移,他们是相互独立的!
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2); // s1 = hello, s2 = hello
4、函数的传值与返回
将值传给函数跟上面讲的赋值类似,都会进行转移或者拷贝的过程。**函数返回一个值的时候,也会经历所有权转移的过程。**我们用下面的例子来说明:
fn takes_ownership(s: String) {
println!("Received string: {}", s);
} // s 离开作用域,被丢弃
fn gives_ownership() -> String {
String::from("hello")
} // 返回了String的所有权
fn main() {
// s 拿到了"hello"的所有权
let s = String::from("hello");
// 所有权转移给了 takes_ownership 函数的参数:s
takes_ownership(s); // s转移到了函数内,不再可用
// s 不再可用
// 此处还可以声明一个 s ,是因为上面的 s 已经被回收了!
let s = gives_ownership(); // s 获得了返回值的所有权
}
三、引用与借用
1、借用
只使用变量,而不拿走所有权,叫“借用”!
2、不可变引用(只读)
fn main() {
// s1 拿到"hello"的所有权
let s1 = String::from("hello");
// 使用 &s1 而不是 s1,借出去,只是借出去,并不允许值被改变
// 使用 len 接收返回值
let len = calculate_length(&s1);
// s1 仍然具有"hello"的所有权
// len 是借用出去后所得到 len() 的返回值
println!("The length of '{}' is {}.", s1, len);
}
// 使用 &String 表示借用,是 String 类型的引用
fn calculate_length(s: &String) -> usize {
// 从借来的 s 取得 len() 的值,并返回
s.len()
}
3、可变引用(可读可写)
fn main() {
// s 拿到 "hello" 的所有权,s 本身是可修改的
let mut s = String::from("hello");
// 将 s 借出去,并允许被修改
change(&mut s);
// s 的值被修改了
println!("The updated string is: {}", s);
}
// 这里使用 &mut String 来接收,表示要求可被修改
fn change(s: &mut String) {
// 修改借来的数据
s.push_str(", world!");
}
4、重要规则
对于一个变量,同时只能存在一个可变引用或者多个不可变引用。
fn main() {
let mut s = String::from("hello");
// 多个不可变引用是允许的
let r1 = &s;
let r2 = &s;
println!("r1: {}, r2: {}", r1, r2);
// 在这里,多个不可变引用是允许的,因此打印不会引发错误。
// 一个可变引用
let r3 = &mut s;
println!("r3: {}", r3);
// 在这里,只有一个可变引用,因此打印不会引发错误。
// 不允许同时存在可变引用和不可变引用
// let r4 = &s; // 这会导致编译时错误
// 如果这里只打印 r4 是不会报错的,因为 r3 在上面已经释放,但此处打印了 r3 ,r3 就不会在此前被释放了!
// println!("r3: {}, r4: {}", r3, r4);
// 如果取消注释上面两行,同时存在可变引用和不可变引用将导致编译时错误。
}
5、NLL
在老版本的 Rust 编译器中(1.31之前),确实上述的r1
,r2
和r3
是会报错的。但是这样其实会带来很多麻烦,导致代码很难写。于是 Rust 编译器做了一项优化:引用的作用域结束的位置不再是花括号的位置,而是最后一次使用的位置。因此,在现在 Rust 的版本中,上面的例子并不会报错。
6、悬垂引用
悬垂引用指的是指针指向的是内存中一个已经被释放的地址**,这在其他的一些有指针的语言中是很常见的错误。而 Rust 则可以在编译阶段就保证不会产生悬垂引用。也就是说,如果有一个引用指向某个数据,编译器能保证在引用离开作用域之前,被指向的数据不会被释放。
错误代码
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
// 这里会报错,因为 s 已经被释放了!返回的地址是一个无效的地址!
// this function's return type contains a borrowed value, but there is no value for it to be borrowed from
// 该函数返回了一个借用的值,但是没有可以借用的来源
// 引用必须是有效的
&s
}
四、切片
1、概念
切片可以让我们引用集合中的一段连续空间。切片也是一种引用,因此没有所有权。
2、字符串切片
基本写法
fn main() {
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
println!("{}", s);
println!("{}", hello);
println!("{}", world);
}
简化写法
let s = String::from("hello");
let len = s.len();
// 以0开始时,0可以省略
let slice = &s[0..2];
let slice = &s[..2];
// 以最后一位结束时,len可以省略
let slice = &s[3..len];
let slice = &s[3..];
// 同时满足上述两条,那么两头都可以省略
let slice = &s[0..len];
let slice = &s[..];
3、其他切片
String 本身就是数组
#[derive(PartialEq, PartialOrd, Eq, Ord)] #[stable(feature = "rust1", since = "1.0.0")] #[cfg_attr(not(test), lang = "String")] pub struct String { vec: Vec<u8>, }
除了 String,数组类型也有切片。例如:
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);
五、总结
本节内容较多,主要包含了三部分的知识:所有权,借用和切片。所有权这套系统是 Rust 内存安全的重要保障。有了这套系统,我们既可以享受不需要手动释放内存的便利,又可以对内存使用有足够的控制,保证内存安全。