接触Rust之所有权

之所以想学习下Rust,就是想了解其处理内存的机制,了解下非GC开发语言的特性。其中所有权是很重要的一点,它界定了代码执行到超过所有权区域,那么其所占内存中的堆将释放。单独写一篇文章做记录,就是因为其重要性需要重点理解,不理解的话结合代码多看几遍。

所有权ownership)是 Rust 用于如何管理内存的一组规则。一些语言中具有垃圾回收机制,例如JAVA和C#在程序运行时,有规律地寻找不再使用的内存;在另一些语言中,例如C++程序员必须亲自分配和释放内存。Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查,确保当你的代码执行调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈(内存释放)。所有权的主要目的就是管理堆数据。

所有权规则:

  1. Rust 中的每一个值都有一个 所有者owner)。
  2. 值在任一时刻有且只有一个所有者。
  3. 当所有者离开作用域,这个值将被丢弃。

我的理解,以固有的程序开发知识来看,其接近于作用域的有效区域及生命周期

初步的疑问可能在如下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,它的值是 truefalse
  • 所有浮点数类型,比如 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}");

r1r2 的作用域在 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 程序在编译时确保内存安全。是很重要的新概念,可通过后期具体的项目代码结合这里的记录加深理解,有新的理解也会在此更新相关描述。