之所以想学习下Rust,就是想了解其处理内存的机制,了解下非GC开发语言的特性。其中所有权是很重要的一点,它界定了代码执行到超过所有权区域,那么其所占内存中的堆将释放。单独写一篇文章做记录,就是因为其重要性需要重点理解,不理解的话结合代码多看几遍。
所有权 (ownership)是 Rust 用于如何管理内存的一组规则。一些语言中具有垃圾回收机制,例如JAVA和C#在程序运行时,有规律地寻找不再使用的内存;在另一些语言中,例如C++程序员必须亲自分配和释放内存。Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查,确保当你的代码执行调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈(内存释放)。所有权的主要目的就是管理堆数据。
所有权规则:
- Rust 中的每一个值都有一个 所有者 (owner)。
- 值在任一时刻有且只有一个所有者。
- 当所有者离开作用域,这个值将被丢弃。
我的理解,以固有的程序开发知识来看,其接近于作用域的有效区域及生命周期
初步的疑问可能在如下Rust代码会报错:
rust
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}, world!");
结合前面,S1和S2如果都拥有hello字符串的所有权,将错误地造成二次释放。如果要这样处理代码逻辑的话,Rust提供了叫做 clone 的常用方法实现深度拷贝的理念:
rust
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {s1}, s2 = {s2}");
不过在此之上,作为一个通用的规则,任何一组简单标量值的组合都可以实现上面clone的 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。你可以理解成值类型,其不存在在栈上分配引用内存空间,堆上分配实际占用的内存空间,如下是一些 Copy 的类型:
- 所有整数类型,比如
u32。 - 布尔类型,
bool,它的值是true和false。 - 所有浮点数类型,比如
f64。 - 字符类型,
char。 - 元组,当且仅当其包含的类型也都实现
Copy的时候。比如,(i32, i32)实现了Copy,但(i32, String)就没有。
因此,以下类似代码在Rust是有效的:
rust
let s1 = 5;
let s2 = s1;
println!("s1 = {s1}, s2 = {s2}");
对于所有权与函数,以下这个例子很能说明其与变量赋值的原理相似,理解其中注释就明白了:
rust
fn main() {
let s = String::from("hello"); // s 进入作用域
takes_ownership(s); // s 的值移动到函数里 ...
// ... 所以到这里不再有效
let x = 5; // x 进入作用域
makes_copy(x); // x 应该移动函数里,
// 但 i32 是 Copy 的,
println!("{}", x); // 所以在后面可继续使用 x
} // 这里,x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
// 没有特殊之处
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 移出作用域。没有特殊之处
引用 (reference )有点像指针,因为它是一个地址,我们可以沿着它访问存储在该地址中的数据,而这些数据归其他变量所有。与指针不同,引用在其生命周期内保证会指向某个特定类型的有效值。在代码上体现的话,就是函数的参数类型前和调用都添加&符号(指定其提供变量值,但不给所有权 ),这些 & 符号表示 **引用,**它们让你引用某个值而不取得它的所有权,如下代码:
rust
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{s1}' is {len}.");
}
fn calculate_length(s: &String) -> usize {
s.len()
}
rust中将这种创建一个引用的行为称为 借用 (borrowing ),但我们又要注意,不能尝试修改借用的变量,这也不允许将会报错。**正如变量默认是不可变的,引用默认也是不可变的。**此代码将编译失败:
rust
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{r1}, {r2}");
刚接触 Rust 的人往往不太适应这一点,因为大多数语言都允许你随时修改变量和变量引用。
可以使用大括号来创建一个新的作用域:
rust
let mut s = String::from("hello");
{
let r1 = &mut s;
} // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用
let r2 = &mut s; //其跟r1已不在一个作用域
接下来,还有另一个知识点,在同一作用域内,不能同时拥有对同一数据的不可变引用(&T)和可变引用(&mut T) 。并且,可变引用具有排他性------在可变引用存在期间,不能有任何其他引用(无论可变还是不可变)指向同一数据。代码如下:
rust
let mut s = String::from("hello");
let r1 = &s; // ✅ 不可变引用,允许
let r2 = &s; // ✅ 多个不可变引用可以共存
let r3 = &mut s; // ❌ 尝试获取可变引用,但此时 r1、r2 仍然存活
println!("{r1}, {r2}, and {r3}"); // 所有引用都被使用
以上代码可以这样理解:
r1 和 r2 是不可变引用,可以同时存在多个(它们只读,不会修改数据)。
r3 是可变引用,它要求独占访问 s,但在它被创建时,r1 和 r2 还没有被释放
为什么这样设计?Rust 通过借用规则在编译期保证内存安全,避免数据竞争 :
不可变引用保证读取时数据不会被修改。
可变引用保证写入时没有其他读取或写入者。
如果允许同时存在不可变和可变引用,那么通过不可变引用读取时,数据可能被可变引用突变,导致未定义行为(如悬垂指针、数据不一致等)。Rust 坚决杜绝这种情况。
注意**一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。**所以以上的代码改成以下能编译通过:
rust
let mut s = String::from("hello");
let r1 = &s; // 没问题
let r2 = &s; // 没问题
println!("{r1} and {r2}");
// 此位置之后 r1 和 r2 不再使用
let r3 = &mut s; // 没问题
println!("{r3}");
r1 和 r2 的作用域在 println! 最后一次使用之后结束,这发生在可变引用 r3 被创建之前。它们的作用域没有重叠,Rust 的借用检查器会分析引用的最后一次使用位置,所以代码是可以编译的。
悬垂引用不存在: 在带有指针的语言中,如果释放了一块内存,却保留了指向它的指针,就很容易错误地制造出一个悬垂指针 (dangling pointer):这个指针指向的内存位置可能已经被分配作其他用途。在 Rust 中将会报错,Rust编译器会保证引用永远不会变成悬垂引用。如下常见悬垂引用:
rust
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String { // dangle 返回一个字符串的引用
let s = String::from("hello"); // s 是一个新字符串
&s // 返回字符串 s 的引用
} // 这里 s 离开作用域并被丢弃。其内存被释放。危险!
因为 s 是在 dangle 函数内部创建的,所以当 dangle 的代码执行完毕后,s 就会被释放。但我们却尝试返回对它的引用。直接改此函数如下则可以:
rust
fn no_dangle() -> String {
let s = String::from("hello");
s //直接返回 String
}
概括一下之前对引用的讨论:
- 在任意给定时间,要么 只能有一个可变引用,要么只能有多个不可变引用。
- 引用必须总是有效的。
切片 (slice)允许你引用集合中一段连续的元素序列,而不用引用整个集合。slice 是一种引用,所以它不拥有所有权。我理解像是:指定字符串或集合指定范围索引的地址引用。
所有权、借用和 slice 这些概念让 Rust 程序在编译时确保内存安全。是很重要的新概念,可通过后期具体的项目代码结合这里的记录加深理解,有新的理解也会在此更新相关描述。