天天看点

Rust学习笔记(五):所有权、引用、借用和生命周期

作者:TechSavantX
Rust学习笔记(五):所有权、引用、借用和生命周期

Rust的所有权系统是其最重要的特性之一,它使得Rust能够避免内存管理错误,同时提供了高性能和内存安全。但是,所有权系统也是初学者最容易被困惑的地方。在本文中,我们将深入探讨Rust的所有权、引用、借用和生命周期,帮助你更好地理解Rust的内存管理。

所有权

所有权(ownership)是Rust中最基本的概念之一,它指的是对内存的管理。在Rust中,每个值都有一个被称为“所有者”的变量,它是唯一能够释放值所占内存的变量。当所有者离开作用域时,它所拥有的值也会被销毁。这种方式使得Rust能够自动管理内存,避免了像C++那样需要手动管理内存的问题。它让 Rust 在编译时就能保证内存安全,而无需垃圾回收或手动管理内存。

所有权的基本规则如下:

  • Rust 中的每个值都有一个变量,称为其所有者(owner)。
  • 一次只能有一个所有者。
  • 当所有者离开作用域时,该值将被丢弃(drop)。

举个例子:

fn main() {
    let s = String::from("hello"); // s 是一个 String 类型的值,它从字面量 "hello" 创建
    // s 是该值的所有者
    // do something with s
} // 这里 s 离开了作用域,所以它会被丢弃,释放其占用的内存           

注意:这里我们使用了 String 类型而不是 str 类型。String 是一个可变、可增长的文本类型,在堆上分配内存。str 是一个不可变、固定长度的文本类型,在编译时确定,在栈上分配内存。我们可以使用 String::from() 函数从 str 创建 String。

当我们把一个值赋给另一个变量时,会发生什么呢?例如:

let s1 = String::from("hello");
let s2 = s1;           

这里我们可能会认为 s1 和 s2 都指向同一个堆上分配的字符串 "hello"。但实际上,在 Rust 中,这样做会使 s1 失去对该值的所有权,并转移给 s2。这被称为移动(move)。也就是说,在赋值后,只有 s2 可以使用该值,而 s1 就无效了。

let s1 = String::from("hello");
let s2 = s1;

println!("{}, world!", s1); // 这里会报错:value borrowed after move           

为什么要这样设计呢?这是为了避免双重释放(double free)错误。如果两个变量都指向同一个堆上分配的值,并且都在离开作用域时试图释放它们占用的内存,就会导致程序崩溃或数据损坏。Rust 通过让每个值只有一个所有者来防止这种情况发生。

那么如果我们想要复制一个堆上分配的值呢?我们可以使用 .clone() 方法来显式地创建一个新的堆分配,并把原始值复制过去。例如:

let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2); // 这里不会报错:两个变量都有效,并指向不同的堆分配           

引用和借用

引用(reference)是 Rust 中的一种指针类型,它可以让我们借用(borrow)一个值而不取得其所有权。在Rust中,引用通过&符号来创建,可以将引用作为函数参数来传递。在函数中,通过引用,函数可以访问该值,但是不拥有它。借用是一种更广义的引用,通过&mut符号来创建,表示可变的引用。借用使得多个变量可以共享一个值,并在不获取值所有权的情况下对其进行修改,例如:

let s = String::from("hello");
let r = &s; // r 是一个指向 s 的引用           

这里,我们创建了一个变量 s 并赋值为一个字符串 "hello",然后我们创建了一个变量 r 并赋值为 s 的引用。这意味着 r 可以访问 s 所指向的堆上分配的字符串,但它并不拥有它。也就是说,当 r 离开作用域时,它不会释放该字符串占用的内存。

引用有两种类型:可变引用(mutable reference)和不可变引用(immutable reference)。可变引用允许我们修改所借用的值,而不可变引用只允许我们读取所借用的值。例如:

let mut s = String::from("hello");
let r1 = &s; // r1 是一个不可变引用
let r2 = &mut s; // r2 是一个可变引用

println!("r1 = {}, r2 = {}", r1, r2); // 这里会报错:cannot borrow `s` as mutable because it is also borrowed as immutable           

这里,我们创建了一个可变变量 s 并赋值为一个字符串 "hello",然后我们创建了两个引用:一个不可变引用 r1 和一个可变引用 r2。但是在打印它们的值时,编译器会报错,因为这违反了 Rust 的借用规则:

  • 在任意给定时间,要么只能有一个可变引用,要么只能有多个不可变引用。
  • 引用必须总是有效。

这些规则是为了保证数据的一致性和安全性。如果允许同时存在多个可变引用,那么就可能出现数据竞争(data race)的问题,即多个线程同时修改同一份数据,导致数据不一致或者内存不安全。Rust 的借用规则可以在编译时检查并防止这种问题。

生命周期

生命周期(lifetime)是指在程序中某些代码块内变量的有效期。在Rust中,生命周期是一种对引用的限制,用于确保引用只能在其指向的值仍然有效的情况下使用。

生命周期是 Rust 中的一种标注方式,它可以让我们指定引用的有效范围。生命周期可以使用单引号和一个小写字母来表示,例如 'a、'b、'static 等。生命周期的作用是帮助编译器检查引用是否有效,避免出现悬垂指针(dangling pointer)等问题。

在 Rust 中,每个变量都有一个生命周期,即它在内存中存在的时间段。当变量离开作用域时,它就被销毁,并释放其占用的内存。例如:

{
    let x = 42; // x 的生命周期开始
    println!("x = {}", x);
} // x 的生命周期结束           

这里,我们创建了一个变量 x 并赋值为 42,然后打印它的值。当代码块结束时,x 就离开了作用域,并被销毁。

当我们使用引用时,我们需要保证引用所指向的值在整个引用期间都是有效的。也就是说,引用不能比其所借用的值活得更久。例如:

let r;
{
    let s = String::from("hello");
    r = &s; // r 是一个指向 s 的引用
} // s 离开作用域并被销毁
println!("r = {}", r); // 这里会报错:borrowed value does not live long enough           

这里,我们创建了一个变量 r 和一个代码块,在代码块中我们创建了一个字符串 s 并将其引用赋给 r。但是当代码块结束时,s 就离开了作用域,并被销毁。这样一来,r 就变成了一个悬垂指针,它指向了一个已经不存在的值。这是非常危险的,因为如果我们试图访问 r 的值,就可能触发未定义行为(undefined behavior)。Rust 的编译器会在编译时检查并报错,防止我们使用无效的引用。

下面是一个关于Rust生命周期的复杂一点的例子:

// 定义一个结构体 Foo,它有两个字段 x 和 y,都是引用类型
struct Foo<'a> {
    x: &'a i32,
    y: &'a i32,
}

// 定义一个函数 foo,它接受两个参数 r 和 s,都是 Foo 类型的引用
// 这里使用了生命周期参数 'a 和 'b 来标记 r 和 s 的生命周期
fn foo<'a, 'b>(r: &'a Foo<'a>, s: &'b Foo<'b>) -> i32 {
    // 返回 r 的 x 字段和 s 的 y 字段之和
    r.x + s.y
}

fn main() {
    // 在 main 函数中创建两个变量 a 和 b,并赋值为 5 和 6
    let a = 5;
    let b = 6;

    // 创建一个变量 c,并赋值为 Foo 类型的实例,它的 x 字段指向 a,y 字段指向 b
    let c = Foo { x: &a, y: &b };

    // 创建一个变量 d,并赋值为 Foo 类型的实例,它的 x 字段指向 b,y 字段指向 a
    let d = Foo { x: &b, y: &a };

    // 调用 foo 函数,并传入 c 和 d 的引用作为参数
    // 这里 c 的生命周期被标记为 'a,d 的生命周期被标记为 'b
    println!("{}", foo(&c, &d));
}           

代码运行结果是 11。代码解析如下:

  • 首先定义了一个结构体 Foo ,它有两个字段 x 和 y ,都是引用类型。因为引用需要有明确的生命周期,所以这里使用了 'a 来表示 Foo 实例中引用的对象的生命周期。
  • 然后定义了一个函数 foo ,它接受两个参数 r 和 s ,都是 Foo 类型的引用。因为函数也需要知道参数和返回值的生命周期,所以这里使用了 'a 和 'b 来表示 r 和 s 的生命周期。注意这里 'a 和 'b 不一定和结构体中定义的 'a 相同。
  • 接着在 main 函数中创建了两个变量 a 和 b ,并赋值为 5 和 6 。这两个变量在栈上分配内存空间,并且拥有静态('static)生命周期。
  • 然后创建了一个变量 c ,并赋值为 Foo 类型的实例,它的 x 字段指向 a ,即 5 ,而其 y 字段指向 b, 即 6 。这里由于结构体中定义了 'a 来表示字段引用对象的生命周期,所以编译器会推断出这个实例中所有字段必须拥有相同或更长(即子类型)的生命周期。因此,在这里编译器会推断出 'static 是满足条件('static 是所有生命周期中最长且最特殊)且唯一可选项。
  • 接着创建了另一个变量 d ,并赋值为 Foo 类型的实例,它的 x 字段指向 b ,即 6 ,而其 y 字段指向 a, 即 5 。这里同样由于结构体中定义了 'a 来表示字段引用对象的生命周期,所以编译器会推断出这个实例中所有字段必须拥有相同或更长(即子类型)的生命周期。因此,在这里编译器也会推断出 'static 是满足条件('static 是所有生命周期中最长且最特殊)且唯一可选项。
  • 最后调用了 foo 函数,并传入 c 和 d 的引用作为参数。这里由于函数中定义了 'a 和 'b 来表示参数的生命周期,所以编译器会推断出这两个参数必须拥有相同或更短(即超类型)的生命周期。因此,在这里编译器会推断出 'static 是满足条件('static 是所有生命周期中最短且最特殊)且唯一可选项。
  • 函数内部返回了 r 的 x 字段和 s 的 y 字段之和,即 5 + 6 = 11 。因为返回值没有引用任何参数或局部变量,所以它不需要有明确的生命周期。

在Rust学习笔记的第五篇中,我们深入探讨了所有权、引用、借用和生命周期的概念,并通过实例来加深理解。这些概念是Rust语言中非常重要的基础知识,对于理解Rust的编程范式和解决内存安全问题至关重要。

在下一篇文章中,我们将继续探索Rust的基础知识,重点介绍结构体、枚举和模块化编程的概念和用法。这些内容也是Rust语言中非常重要的基础知识,有助于我们更好地组织代码、实现抽象和重用性,从而编写出高质量的Rust代码。让我们一起继续深入学习Rust!

继续阅读