DEV Community

zerix
zerix

Posted on • Edited on

如何理解 Rust 语言生命周期管理

前言

前面我们了解了 Rust 语言的内存模型,知道了 Rust 语言精妙的无 GC 自动内存回收设计。那么,Rust 语言变量在何时开始回收,就属于这节我们需要了解的内容。所谓的生命周期,即一个变量从出现到销毁的过程,对应就是计算机内存的申请和释放。我们将在此节讲解 Rust 语言中局部变量,全局变量,函数变量等等变量生命周期的管理方式,深入理解 Rust 语言对该问题解决思路。

基础概念

在前面一节中,我们知道了 Rust 所有权机制,那么,Rust 语言中是否是所有变量的赋值都涉及到所有权的变更呢?答案是否定的。在 Rust 语言中,涉及到赋值所有权相关的,主要有三种操作类型: MOVE、COPY 和 BORROW 。MOVE 操作即转移所有权,COPY 操作相当于直接复制对象,所有权并不变更,BORROW 操作相当于借用所有权,使用完毕是要归还所有权的。

所有权的变更,都发生在赋值操作时。那么,什么时候的赋值操作是 MOVE,什么时候是 COPY,什么时候是 BORROW 呢?

MOVE & COPY 操作

MOVE 操作,实际上就是转移对象的所有权。注意,这里对象的所有权转移,即是把对象的特征值拷贝过来,并释放原有对象特征值内存。这里的特征值可以理解成指针,不同类型的对象特征值不同,可能会是一些胖指针。MOVE 操作,是 RUST 语言变量赋值的默认操作,但是当对象实现了 COPY 特征,或者对象类型非常简单,比如整数,浮点数,字符,布尔,以及 COPY 类型的元组和固定大小数组等。这些对象的赋值,就直接是 COPY 操作了,相当于直接内存拷贝过去,目标对象和原有对象都有相同的值,类似 C++ 语言中的深度拷贝。
在这里,如何确定哪些类型是 COPY 类型操作,有一条规则,就是任何在值被清除后需要特殊处理的类型都不能是 COPY 类型。比如,Vec 需要释放其元素,File 需要关闭其文件句柄,而 MutexGuard 需要解锁其互斥量。
对于用户自定义的类型,比如 struct 和 enum,都不是 COPY 类型,但是,用户可以通过在自定义类型上添加 #[derive(COPY, Clone)] 宏来把类型标注成 COPY 类型,注意,该标注只有该自定义类型内所有字段都是 COPY 类型时才有效,不然编译会报错。
注意,将某种类型实现为 COPY 类型,对于实现者而言意味着庄严的承诺:若日后有必要将其改为非 COPY 类型,那用到他的代码,很多可能需要重写。

COPY 和 CLONE 都属于特性(trait)的例子,后续文章会专门分析 trait 的实现。

对于对象的生命周期而言,MOVE 意味着原始对象生命周期的终结,赋值对象初始化,获得了所有权,意味着什么周期开始。而 COPY 操作则直接复制了值内容,不涉及到原始对象生命周期的变更。

BORROW 操作

BORROW 操作只针对的是引用类型,引用类型是一种指针类型。在引用类型赋值时,相当于把对象所有权借用给了目的对象,在目的对象解引用时,会归还对象的所有权给原始对象。

生命周期

了解了 MOVE、COPY 和 BORROW 操作,我们来看看一个变量的什么周期管理是如何影响 Rust 代码编写的。

1{
2    let r;                // ---------+-- 'a
3                          //          |
4    {                     //          |
5        let x = 5;        // -+-- 'b  |
6        r = &x;           //  |       |
7    }                     // -+       |
8                          //          |
9    println!("r: {}", r); //          |
10} 
Enter fullscreen mode Exit fullscreen mode

这段代码中,如果编译,会发现第9行报错。主要原因是,第6行将x的引用赋值给r,相当于讲所有权借用给了r,到第7行,退出作用域,x销毁,成为悬停指针,那么第9行打印r,相当于引用了一个空值,所以报错。

注意,实际上面的解释存在瑕疵,x赋值为5的数值常量,是存放在静态区的,他的生命周期是跟随全程序,第7行只是调用了drop trait把x值变成不可用。

从上面例子可以看出来,Rust 语言可以在编译阶段推断出对象的生命周期,并且括号在 Rust 语言中表示作用域范围。在 Rust 语言中,包括且不限于以下几种结构中的大括号都有自己的作用域:

  1. if、while等流程控制语句中的大括号
  2. match模式匹配的大括号
  3. 单独的大括号
  4. 函数定义的大括号
  5. mod定义模块的大括号

除了括号,引用的生命周期还受到解引用的影响。

那么,Rust 语言是否能做到所有对象各种场景下的生命周期推断呢?答案是否定的,在一些复杂情况下,我们需要手动标注生命周期来解决该问题。

生命周期标注

Rust 语言中,自动推断对象的生命周期需要遵循下面规则:

  1. 每一个引用参数都会获得独自的生命周期。
  2. 若只有一个输入生命周期(函数参数中只有一个引用类型),那么该生命周期会被赋给所有的输出生命周期,也就是所有返回值的生命周期都等于该输入生命周期。 3.若存在多个输入生命周期,且其中一个是 &self 或 &mut self,则 &self 的生命周期被赋给所有的输出生命周期。

当超出上面3种推断规则,我们就需要进行生命周期标注。需要注意的是,生命周期标注的作用是向编译器告知引用之间的 drop 关系,而非扩展或延长引用的存在时间。

我们来看一个例子:

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
Enter fullscreen mode Exit fullscreen mode

上面的代码主要功能是找出最长的一个字符串,我们编译就会发现该段代码会报错。根据自动推断的三条规则,我们发现longest函数有两个入参,那么,编译器就会推断不出返回值到底是和哪个参数的生命周期一致了,这个时候,我们对longest的参数进行生命周期标注,就可以解决该问题。

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

标记语法需要注意下:

  1. 和泛型一样,使用生命周期参数,需要先声明 <'a>。
  2. x、y 和返回值至少活得和 'a 一样久(因为返回值要么是 x,要么是 y)。 代码标记了两个入参的生命周期和返回值的生命周期一致,那么在后续使用过程中,返回值的实际生命周期是和两个入参生命周期中最短的一个一致,所以一定要注意这一点,标记生命周期只是告诉编译器不要报错,实际调用的适合需要了解返回值的生命周期是两个入参中最短的一个。可以看下面代码:
fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {}", result);
}
Enter fullscreen mode Exit fullscreen mode

该段代码就会报错,实际上string2在括号外已经失效了,那么再去访问result返回参数,就会报错。

当然,在实际编码过程中,在很复杂的情况下,如果发现编译器推断的生命周期有问题,我们还可以使用杀手锏来屏蔽掉编译错误(当然,首先你要确认代码没问题)。使用 'static 生命周期标注,拥有该生命周期的引用可以和整个程序活得一样久。可以把他理解为 C 语言里面的全局变量,当然也符合 C 语言开发实践里的“非必要不要使用全局变量”箴言。

let s: &'static str = "持续到整个程序结束,活得久";
Enter fullscreen mode Exit fullscreen mode

Top comments (0)