Rust从入门到摔门而出- 值的所有权

170 阅读9分钟

Rust从入门到摔门而出- rust的内存管理模型中的 “值” 所有权

Rust 的内存管理模型以其安全性和性能著称,主要通过所有权系统、借用(borrowing)和生命周期(lifetimes)来实现。本章说明和总结下所有权系统,如有不足欢迎大家补充和说明。

所有权系统

  1. 所有权(Ownership):每个值在任意时刻都有一个所有者(Owner),即某个变量。一旦所有者超出作用域,该值会被自动删除,从而避免内存泄漏。

  2. 移动(Move):当变量赋值给另一个变量时,所有权会转移(Move)给新变量,旧变量不再拥有该值,从而防止多次释放同一内存。这种机制避免了悬空指针(Dangling Pointer)和二次释放(Double Free)。

  3. 借用(Borrowing):允许多个变量临时借用值的引用,分为可变借用和不可变借用。在一个时间点上,不能同时存在多个可变借用或一个可变借用和一个不可变借用。这保证了数据竞争不会发生。

所有权

在Rust中,每个值都有一个变量作为其所有者。所有者变量负责管理该值的内存

1. Rust 中的每一个值都有一个 所有者(owner)。

2. 值在任一时刻有且只有一个所有者。

3. 当所有者(变量)离开作用域,这个值将被丢弃。

fn main() {
    let s1 = String::from("Hello Rust!");
    let s2 = s1; // s1的所有权被转移给s2,s1不再有效
    // println!("1.{}", s1); // 编译错误,s1不再有效
    println!("2.{}", s2); // 打印 2.Hello Rust!
}

在上述代码中,s1 的所有权被转移给了 s2,因此在 s2 赋值后,s1 变得无效。

说明:所有权在任一时刻只能存在一个: 同一个值在任何时刻只能有一个所有者,这意味着在某一时刻只能有一个变量拥有该值。

所有权移动
fn main() {
    let s1 = String::from("Hello Rust!");
    {
        let s2 = s1; 
        println!(1. {}", s2);
    } // 这里 s2 超出作用域并被释放
    // println!("2: {}", s1); // 编译错误,因为 s1 已经无效
}

{}s1的所有权被转移给了 s2, 意味着 s2 现在是该 String 的新所有者。s1 在此之后变得无效,不能再被使用。 当离开 {} 时, s2 超出其作用域时,Rust 自动调用 drop 函数释放 s2 所拥有的资源,即 String 类型实例所占用的堆内存。

在函数中所有权也会被转移

fn main() {
    let s1 = String::from("Hello Rust!");
    take_and_give_back(s1); // s1 的所有权被转移到函数,
    // println!("{}", s1); // 编译错误,因为 s1 已经转移函数中
}

fn take_and_give_back(s: String) -> String {
     println!("{}", s); // 打印 Hello Rust!
} // 离开函数 超出作用域并 被释放

在 Rust 中 一旦所有权从一个变量转移到另一个变量,原变量将不再拥有该值,并且不能再直接使用原变量来访问该值。所有权不会自动“回到”原变量。

下面示例:所有权转移后再返回
fn main() {
    let s1 = String::from("Hello Rust!");
    let s1 = take_and_give_back(s1); // s1 的所有权被转移到函数,并在函数中返回
    println!("{}", s1); // s1 重新获得所有权,可以继续使用
}

fn take_and_give_back(s: String) -> String {
    s // 返回传入的字符串,所有权回到调用者
}

通过函数调用可以实现所有权的转移和返回,但这并不是真正的“回到最初”,而是通过显式的所有权转移来实现

借用(Borrowing)

借用允许多个变量临时访问一个值而不转移所有权,分为不可变借用和可变借:

不可变借用 & 和可变借用 &mut. Rust 能够让多个变量访问相同的数据,而无需转移所有权。这在编译时通过借用检查器进行验证,确保数据的安全访问。

  1. 不可变借用:多个不可变借用是允许的,但在有不可变借用时不能有可变借用。
fn main() {
    let s = String::from("hello");
    let r1 = &s;
    let r2 = &s;
    println!("{}, {}", r1, r2); // 允许多个不可变借用
}
  1. 可变借用:同一时刻只能有一个可变借用,且不能同时存在不可变借用。
fn main() {
    let mut s = String::from("hello");
    let r1 = &mut s;
    // let r2 = &mut s; // 编译错误,不允许同时有多个可变借用
    println!("{}", r1);
}

下面代码 正确使用了 Rust 的借用规则,在每次可变借用之前确保了先前的可变借用已经结束,因此它能够正确编译和运行。

fn main() {
    let mut s1 = String::from("Hello Rust!");
    {
        let s2 = &mut s1; 
        println!("1.{}", s2); // 输出 1.Hello Rust!
    } // s2 在这里离开了作用域,释放了对 s1 的可变借用
    
    let s3 = &mut s1;
    println!("2.{}", s3); // 输出 2.Hello Rust!
}

生命周期(Lifetimes)

Rust 的生命周期(Lifetimes)是其内存安全机制中的一个关键概念,用于管理引用的有效性和防止悬垂引用。生命周期通过编译时的静态分析,确保引用在其持有的数据的生命周期内有效,从而避免了常见的内存错误。下面详细说明 Rust 的生命周期及其原理和作用。

生命周期的基本概念
  1. 生命周期(Lifetimes):
    • 生命周期是一个引用保持有效的时间范围(存在时间创建到回收时间)。
    • 每个引用都有一个生命周期,表示引用可以合法使用的范围。

示例:

fn main(){
    let s1 = String::from("我是S1");
    {//                                       
       let s2 =  String::from("我是S1");    
       println!("1.{}", s2);               
    }//s2 的生命周期至此销毁结束
    println!("2.{}", s1);  
}//s1 的生命周期至此销毁结束
  1. 悬垂引用(Dangling References):
    • 悬垂引用是指引用超出了其指向的数据的生命周期,从而引用了无效的数据。Rust 的生命周期机制可以防止这种情况发生。

示例:

// ❌ 编译错误
fn main(){
    let s1;
    {                                      
       let s2 =  String::from("我是 S2");    
        s1 = &s2   // 返回对局部变量 s2 的引用           
    }//s2 的生命周期至此销毁结束
    println!("2.{}", s1);  // 
}

为了避免悬垂引用,可以使用所有权转移:

fn main(){
    let s1;
    {                                      
       let s2 =  String::from("我是 S2");    
        s1 = s2; // 所有权转移            
    }
    println!("2.{}", s1);  //  输出  我是 S2
}

悬垂引用是指引用指向的对象已经被销毁,导致引用指向无效的内存。在Rust中,编译器通过借用检查器严格禁止这种情况,确保引用的安全性。通过理解和遵循Rust的所有权和借用规则,可以有效地避免悬垂引用的问题。

生命周期标注

生命周期标注使用撇号 (') 语法来指定引用的生命周期。例如,'a 是一个生命周期标识符。

fn example<'a>(x: &'a str) -> &'a str {
    x
}

在这个例子中,函数 example 接受一个引用 x并返回一个具有相同生命周期的引用。生命周期标注 'a表示参数和返回值具有相同的生命周期。

生命周期的原理

Rust 编译器通过静态分析在编译时检查生命周期,确保引用的安全性。生命周期的主要原理包括:

  1. 生命周期的推断:

    • 在大多数情况下,Rust 编译器能够自动推断生命周期,而不需要显式标注。
    • 只有在复杂的引用关系中,才需要显式标注生命周期。
  2. 生命周期的规则

    • 函数参数的生命周期与返回值的生命周期有关联。
    • 生命周期标注仅用于表示引用之间的相对关系,不会改变引用的实际生命周期。
生命周期的作用

生命周期的主要作用是确保内存安全,防止悬垂引用和数据竞争。具体作用包括:

1.防止悬垂引用

  • 确保引用在其持有的数据的生命周期内有效,避免引用超出数据的作用。

2.数据竞争的防止

  • 通过静态分析,确保引用在多线程环境下的安全访问,防止数据竞争

3.内存安全的保证

  • Rust 的生命周期机制确保所有引用在有效范围内使用,从而避免了内存访问错误。

生命周期的实际应用

简单的生命周期:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

在这个例子中,函数 longest 接受两个字符串引用,并返回其中一个更长的字符串引用。生命周期标注 'a 表示参数和返回值共享相同的生命周期。

结构体中的生命周期 结构体中的引用也需要生命周期标注,以确保结构体实例在引用数据的有效范围内使用。

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    let i = ImportantExcerpt {
        part: first_sentence,
    };
    println!("{}", i.part);
}

在这个例子中,ImportantExcerpt 结构体包含一个引用 part,其生命周期 'a 确保结构体实例在 part 引用的数据的生命周期内有效。

生命周期省略规则

在某些情况下,Rust 编译器可以自动推断生命周期,省略显式标注。以下是常见的生命周期省略规则:

  1. 每个引用参数都有一个独立的生命周期。
  2. 如果只有一个引用参数,编译器会推断返回值的生命周期与该参数相同。
  3. 如果有多个引用参数且返回值引用其中一个参数,编译器无法确定时,需要显式标注。
fn first_word(s: &str) -> &str {
    &s[..s.find(' ').unwrap_or(s.len())]
}

在这个例子中,编译器根据省略规则推断出 s 和返回值具有相同的生命周期。

生命周期示例:复杂情况

struct Book<'a> {
    title: &'a str,
    author: &'a str,
}

impl<'a> Book<'a> {
    fn get_title(&self) -> &str {
        self.title
    }

    fn get_author(&self) -> &str {
        self.author
    }
}

fn main() {
    let book = Book {
        title: "Rust Programming",
        author: "Steve",
    };

    println!("Title: {}", book.get_title());
    println!("Author: {}", book.get_author());
}

在这个示例中,Book 结构体和其方法 get_title get_author 都使用了生命周期标注 'a,确保结构体实例和引用数据的生命周期一致。

生命周期总结

Rust 的生命周期机制通过编译时的静态分析,确保引用在有效范围内使用,从而避免悬垂引用和数据竞争。生命周期标注用于表示引用之间的相对关系,不改变实际生命周期。通过推断和省略规则,Rust 编译器在大多数情况下能够自动处理生命周期,确保代码的内存安全和高效执行。