Rust 所有权系统——所有权、借用和生命周期

所有权系统(Ownership System)是 Rust 语言最基本最独特也是最重要的特性之一。
其它编程语言管理内存的方式:

  • 程序员手动分配和释放内存,比如:C 语言。
  • 采用垃圾回收机制,比如:Java 语言的虚拟机提供垃圾回收器,程序员基本无需关心内存的分配和释放。

Rust 采用的是第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则(所有权规则)进行检查和管理内存。简单地说,编译器在编译的时候就能知道内存如何分配以及何时释放。

所有权系统,包括三个重要的组成部分:

  • 所有权(Ownership)
  • 借用(Borrowing)
  • 生命周期(Lifetime)

所有权规则

所有权规则:

  • Rust 中的每一个值都有一个被称为其所有者(owner)的变量。
  • 每个值(或者说这个值所占的内存)有且只有一个所有者。
  • 当所有者(变量)离开作用域,这个值将被丢弃(释放这个值所占的内存)。

绑定和作用域

在 Rust 中,let 关键字用来将变量和值做“绑定”,换句话说,let 关键字将一个变量和一段内存区域关联起来。这个变量就是这段内存的所有者

作用域是一个变量在程序中有效的范围,通常是变量所在大括号{}内的范围。当一个变量离开它的作用域之后,Rust 会将该变量销毁,并将其绑定的内存资源释放。如下面的示例:

fn main() {
    {
        let a: i32 = 100;
    }
    // 此时变量 a 及其绑定的内存已经被销毁,不能被打印
    println!("{}", a);
    //error[E0425]: cannot find value `a` in this scope
}

移动(Move)

看下面的例子,将变量 s1 的值赋给变量 s2

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

    println!("{}, world!", s1);
    println!("{}, world!", s2);
}

变量 s1s2 的类型是StringString 类型数据由三部分组成。如下图左侧所示:指向存放字符串内容内存的指针、长度和容量,这一组数据存储在栈上。右侧的内容则是存放在堆上的。
在这里插入图片描述

当我们将 s1 赋值给 s2时,s1 的数据被复制。复制的方式有两种:

  • 仅复制栈上的数据,包括指针、长度和容量(浅拷贝)。
  • 同时复制栈上和堆上的数据(深拷贝)。

很显然,第一种方式比较好,这样即可以节省内存空间,也能节省时间。但是采用这种方式会有问题。在上面的例子中,按照所有权规则,当变量 s1s2 离开作用域,它们都会尝试释放堆上的同一段内存。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。(为什么有安全漏洞?原理是什么?)

为此,Rust 引入了一个新的操作叫 移动(move),像上面的场景,变量 s1 赋值给 s2 时,会将s1 置为无效。相当于 s1 的值转移给了 s2,即值的所有权从 s1 转移到了 s2。这样就不会存在内存二次释放的问题了。

上面示例的运行结果:发生移动之后,s1 就不能再使用了。

error[E0382]: borrow of moved value: `s1`
 --> src\main.rs:6:28
  |
2 | let s1 = String::from("hello");
  | -- move occurs because `s1` has type `std::string::String`, which does not implement the `Copy` trait
3 |
4 | let s2 = s1;
  | -- value moved here
5 |
6 | println!("{}, world!", s1);
  | ^^ value borrowed here after move

error: aborting due to previous error

For more information about this error, try `rustc --explain E0382`.
error: could not compile `move_demo`.

To learn more, run the command again with --verbose.

克隆(Clone)

如果我们确实需要复制 String 堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的通用函数。如下面的例子:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();
    println!("s1 = {}, s2 = {}", s1, s2);
}

拷贝(Copy)

对于那些数据只存放在栈上的数据类型,就没有必要进行 移动(move)操作了。如下面的例子不会报错:

let x = 5;
let y = x;
// 此时 x 和 y 各自绑定了一段内存,并且内存里存的值都是 5
println!("x = {}, y = {}", x, y);

另外,Rust 提供了一个叫做 Copy 的特殊 Trait。如果一个数据类型实现了 Copy Trait,该类型变量在赋值给其他变量后,旧的变量仍然可用。换句话说,就是不会发生所有权转移。

常见的拷贝类型

  • 所有整数类型,比如 u32
  • 布尔类型,bool,它的值是 truefalse
  • 所有浮点数类型,比如 f64
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都是 Copy 的时候。比如,(i32, i32)Copy 的,但 (i32, String) 就不是。

总的来说,把一个变量的值赋给另一个变量,存在两种情况:移动拷贝。取决于变量的数据类型是否实现了 Copy Trait。

所有权和函数

将值传递给函数在语义上与给变量赋值类似。向函数传递值可能会移动或者拷贝,就像赋值语句一样。
示例:

fn main() {
    let s = String::from("hello"); // s 进入作用域

    takes_ownership(s); // s 的值移动到函数里 ...
                                    // ... 所以到这里不再有效

    let x = 5; // x 进入作用域

    makes_copy(x); // x 应该移动函数里,
                                    // 但 i32 是 Copy 的,所以在后面可继续使用 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 移出作用域。不会有特殊操作

借用(borrow)

不可变引用

假如有一个变量在传给函数之后,我们仍然想使用它。如下面的例子,计算 s1 的长度,并输出 s1 及其长度。按照之前的规则,s1 在传给 calculate_length 函数后就失效了。

若要重新使用 s1 的值,有两种方法:

方法一:由 calculate_length 函数将其返回,然后再定义一个变量 s2 接收

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1); // 此时 s1 已经是无效的了

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() 返回字符串的长度

    (s, length)
}

方法二:将 s1 拷贝一份传入函数中,这样 s1 就不会失效了

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(s1.clone());

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: String) -> usize {
    s.len() // len() 返回字符串的长度
}

很明显,方法一的做法非常麻烦,方法二的做法浪费内存空间。因此,针对这种情况 Rust 提供了一个新的功能:引用(reference)。

下面是运用引用功能,改进后的代码:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

& 符号表示这是一个引用,它允许你使用值但不获取其所有权。
在这里插入图片描述

&s1 语法让我们创建一个指向 s1 所拥有值的引用,但是并不拥有它。同理,函数签名使用 & 来表明参数 s 的类型是一个引用。

Rust 将获取引用作为函数参数的行为称为借用(borrowing)。默认情况下,借用的值是不能被修改的。

可变引用

默认情况下,引用的值是不允许修改的。若要想修改引用的值,需在 & 后加上 mut,如下面的例子:

fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

可变引用有一个很大的限制:在特定作用域中的特定数据有且只有一个可变引用。这个限制的好处是 Rust 可以在编译时就避免数据竞争。

悬垂引用

在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个悬垂指针(dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。
在 Rust 中编译器会对引用指向的值进行检查,确保引用不会变成悬垂状态。

示例

fn dangle() -> &String { // dangle 返回一个字符串的引用

    let s = String::from("hello"); // s 是一个新字符串

    &s // 返回字符串 s 的引用
} // 这里 s 离开作用域并被丢弃,其内存被释放。这个引用此时指向一个无效的 String,因此编译器会报错

总的来说,关于引用有以下两个规则:

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

切片(Slice)

引用类型可以使用引用的值,但不拥有该值的所有权。另一个没有所有权的数据类型是切片(Slice)。切片(Slice)用来引用集合中一段连续的元素序列,而不是引用整个集合。

切片可以是共享的或者可变的,共享的切片用 &[T] 表示,可变的切片用 &mut [T] 表示。

字符串切片

字符串切片(string slice)是对 String 中部分连续字符的引用,如下面的例子:

let s = String::from("hello world");

let hello = &s[0..5]; // hello
let world = &s[6..11]; // world

切片的语法格式:[starting_index..ending_index]。其中,starting_index 从 0 开始,ending_index 是最后一个位置的后一个索引。在其内部,slice 的数据结构存储了 slice 的开始位置和长度,长度对应于 ending_index 减去 starting_index 的值。
在这里插入图片描述

字符串字面值其实就是切片,如下面的例子:

let s = "Hello, world!";

这里 s 的类型是 &str:它是一个指向二进制文件特定位置的切片。这也就是为什么字符串字面值是不可变的,因为 &str 是一个不可变引用。

其它类型的切片

在数组中也可以使用切片,如下面的例子:

let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];

这个切片的类型是 &[i32]。它跟字符串切片的工作方式一样,通过存储第一个集合元素的引用和一个集合总长度来实现。你可以对其他所有集合使用切片类型。

Rust 建立在所有权之上的这一套机制,它要求一个资源同一时刻有且只能有一个拥有所有权的绑定或 &mut 引用,这在大部分的情况下保证了内存的安全。

生命周期

Rust 中的每一个引用都有其生命周期(lifetime),也就是引用保持有效的作用域。大部分时候生命周期是隐含并可以推断的,正如大部分时候类型也是可以推断的一样。类似于当因为有多种可能类型的时候必须注明类型,当 Rust 不能确定引用的生命周期时,Rust 需要我们使用泛型生命周期参数来注明。
生命周期则有助于确保引用在我们需要他们的时候一直有效。

生命周期的主要目标

生命周期的主要目标是避免悬垂引用。

Rust 编译器有一个借用检查器(borrow checker),它比较作用域来确保所有的借用(引用)都是有效的。

示例

{
    let r; // ---------+-- 'a
                          // |
    { // |
        let x = 5; // -+-- 'b|
        r = &x; // | |
    } // -+ |
                          // |
    println!("r: {}", r); // |
} // ---------+

这里将变量 r 的生命周期标记为 'a 并将变量 x 的生命周期标记为 'b。编译时会报错,因为生命周期 'b 比生命周期 'a 要小:被引用的对象比它的引用者存在的时间更短。

生命周期参数

对于简单的场景,Rust 编译器可以判断借用是否有效。但是在有些场景中,需要人工介入帮助 Rust 编译器进行检查。

示例:定义一个 longest 函数,它返回两个字符串切片中较长的那个

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x //explicit lifetime required in the type of `x`
    } else {
        y //explicit lifetime required in the type of `y`
    }
}

上面的例子是不能编译通过的,因为编译器不知道该函数到底是返回 x 还是返回 y。因此不能推断出返回值引用的生命周期是否大于输入参数的生命周期,也就不能保证引用的有效性。

为了帮助编译器进行生命周期检查,Rust 提供了泛型生命周参数来定义引用之间的关系。

生命周期参数并不改变任何引用的生命周期的长短。与当函数签名中指定了泛型类型参数后就可以接受任何类型一样,当指定了泛型生命周期后函数也能接受任何生命周期的引用。生命周期参数描述了多个引用生命周期相互的关系,而不影响其生命周期。

泛型生命周期参数语法规则:

  • 名称必须以撇号(’)开头,其名称通常全是小写,默认名称是:'a
  • 和泛型参数一样,把它放在 <> 中,并在泛型参数之前。
  • 修饰引用类型时,放在引用符 & 之后,并后接一个空格将其与数据类型隔开。

示例一:数据类型和生命周期参数

fn foo<'a, T>() {}
trait A<U> {}
struct Ref<'a, T> where T: 'a { r: &'a T }

示例二:给 longest 函数添加生命周期参数

// 告诉编译器,函数返回值的生命周期与两个参数的生命周期相关联。
// 实际上,编译器会取两个参数的交集,即两个参数中较小的那个生命周期作为 `'a` 的值。
// 编译器会检查返回值引用的生命周期,一旦返回值引用的生命周期大于 `'a`,则会报错。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
// 为什么 `'a` 不能取较大的那个生命周期?
// 若 `'a` = 较大的生命周期,返回值既可能是 x,也可能是 y,一旦返回具有较小生命周期的参数,则可能造成无效的引用(返回值引用比生命周期较小的参数引用活得长,当参数被销毁时,返回值的引用还在指向这个参数)。
// 只有`'a` 取较小的那个生命周期时,无论是返回 x,还是返回 y,才不会造成无效的引用。

示例三:调用 longest 函数(编译通过)

fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        // `result` 的生命周期 = `string2` 的生命周期
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result);
    }
}

示例四:调用 longest 函数(编译不通过)

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        // `result` 的生命周期 > `string2` 的生命周期
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {}", result);
}

示例五:包含引用的结构体,也需要添加生命周期参数

// `ImportantExcerpt` 的实例不能比 `part` 字段中的引用存在更久
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 };
}

总结

每个引用都有一个生命周期,并且我们需要为那些使用了引用的函数或者结构体等指定生命周期。

生命周期省略

我们不必为所有使用了引用的函数指定生命周期。Rust 在编译器中引入了一套生命周期省略规则,如果程序符合这个规则,那么编译器就不会强制你显式指定引用的生命周期。

编译器判断是否需要明确生命周期的三个规则(步骤):

  1. 每一个引用参数都有它自己的生命周期参数。
  2. 如果只有一个输入生命周期参数,那么其生命周期被赋予所有的输出生命周期参数。
  3. 如果方法中有多个输入生命周期参数并且其中一个参数是 &self 或者 &mut self,说明这是一个对象的方法,那么所有输出生命周期参数被赋予 self 的生命周期。

其中,函数或方法的参数的生命周期被称为输入生命周期(input lifetimes),而返回值的生命周期被称为输出生命周期(output lifetimes)。

示例:编译器对 longest 函数的分析过程

fn longest(x: &str, y: &str) -> &str {
    ...
}
// 按照规则一,给每个参数添加生命周期参数
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
  ...
}
// 然后再应用第二条和第三条规则,发现不适用,编译器无法计算出返回值的生命周期,因此报错。

静态生命周期

Rust 中有一种特殊的生命周期叫做静态生命周期,使用 'static 表示。所有的字符串字面量都拥有 'static 生命周期,它们存活于整个程序期间。

示例

// 定义一个拥有 `'static` 生命周期的常量。
static NUM: i32 = 18;

// 返回一个指向 `NUM` 的引用,该引用不取 `NUM` 的 `'static` 生命周期,
// 而是被强制转换成和输入参数的一样。
fn coerce_static<'a>(_: &'a i32) -> &'a i32 {
    &NUM
}

fn main() {
    {
        // 字符串字面量。
        let static_string = "I'm in read-only memory";
        println!("static_string: {}", static_string);

        // 当 `static_string` 离开作用域时,该引用不能再使用,
        // 不过,数据仍然存在于二进制文件里面。
    }

    {
        let lifetime_num = 9;

        // 将对 `NUM` 的引用强制转换成 `lifetime_num` 的生命周期
        let coerced_static = coerce_static(&lifetime_num);

        println!("coerced_static: {}", coerced_static);
    }

    println!("NUM: {} stays accessible!", NUM);
}

相关资料

The Rust Programming Language

Rust By Example

RustPrimer

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值