Skip to main content

Command Palette

Search for a command to run...

Rust 生命周期浅谈

Published
•2 min read•View as Markdown
Rust 生命周期浅谈

1. 简述

image-20240504202148065

Rust 中的每一个引用都有其 生命周期(lifetime),也就是引用保持有效的作用域。大部分时候生命周期是隐含并可以推断的,正如大部分时候类型也是可以推断的一样。类似于当因为有多种可能类型的时候必须注明类型,也会出现引用的生命周期以一些不同方式相关联的情况,所以 Rust 需要我们使用泛型生命周期参数来注明他们的关系,这样就能确保运行时实际使用的引用绝对是有效的。

生命周期的概念从某种程度上说不同于其他语言中类似的工具,毫无疑问这是 Rust 最与众不同的功能。


2. 秒懂生命周期

生命周期就是一个用来避免出现悬垂引用的手段,本质上就是约束和说明变量作用域的作用关系,更好的避免哪些已经失效的数据再次被引用从而导致的一些列问题。

什么是非法引用呢?看下面这个例子:

fn main() {
    let r;
    {
        let x = 5;
        r = &x;
    }
        println!("r: {}", r);
    }
}
  • 实例代码中,我们在代码块的外部定义一个变量r,并在后续的代码块中定义一个变量x且赋值为5之后将变量x的引赋给前面r,到这里其实没什么问题。继续往下,在代码块之后将r的值打印输出,此时是无法通过编译的因为这已经出现了非法引用的问题,也就是所谓 悬垂引用。

  • 这是因为x变量在执行赋值之后,截至代码#6行开始,它的作用域就结束了,也就是说,x变量的生命周期到此为止,但由于后续还存在打印r的操作,而此时由于x的结束,r所指向的数据就是一个不存在的东西,那不得报错啊。那能编译通过的话就属于玄学了。

变量 x 并没有 “存在的足够久”。其原因是 x 在到达第 7 行内部作用域结束时就离开了作用域。不过 r 在外部作用域仍是有效的;作用域越大我们就说它 “存在的越久”。

那么Rust编译器是如何直判断这段代码不能通过编译的呢?其实很简单,看的就是哪个变量的作用域存在时间更长。当然,官方将这种方式起名叫做 借用检查器。

他的作用就是通过比较作用域来确保借用的合法性,避免悬垂。

image-20240504183054170

上图还是之前的示例,我使用不同的颜色以及生命周期标记来指出了变量x和r的作用域,或者说生命周期时长。

  • 'a'也就是红色部分表示r的生命周期;

  • 'b'也就是亮绿色的部分表示x的生命周期;

这样就可以直观的感受到内部的 'b 块要比外部的生命周期 'a 小得多。Rust的借用检查器在编译时就会发现r引用了一个生命周期小于自己的变量x,被引用的对象比它的引用者存在的时间更短。

假如r在后续还需要带着x一起干一番大事业。但是发现x在这之前就西天取经去了,r也只能放弃了这个想法,人生到此结束。

换句话说,在借用关系中,被借用的对象生命周期必须大于等于借用者的生命周期,否则会出现借用者借用之后被借用的对象挂了,那借用者借了个寂寞,Rust直接拒绝编译。

所以,依据上面的原理,将代码作适当的调整之后就可以正常编译了,像下面这样。此时被借用的x的生命周期为'b且大于借用者r的'a,不会出现非法借用的问题。

image-20240504184449312


3. 函数中泛型生命周期

故事还得从一个简单的方法讲起。

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

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

从上面的内容不难猜测,函数longest()的作用是返回两个切片中较长的一个,功能就这么简单!

参考下面的函数实现,这种写法能逃过编译器的考验成功通过编译吗?

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

乍一看没问题啊,不就是传入两个字符串引用比较长短返回吗,为了保留实参的所有权还特地将函数参数使用了引用方式传递呢。写的挺板正的啊,语法简洁,逻辑清晰。但还是禁不住编译器的百般拷打,终于还是露出了狐狸尾巴。

image-20240504185825600

函数尝试返回 x 或 y 的引用,但是这两个参数的生命周期并没有明确定义。在函数返回时,编译器无法确定返回的引用是否仍然有效。这和之前例子不太一样的地方就是我们没办法直观(抽象一点也可以啊)的看出来x,y的作用域,没办法确定生命周期时长,基于这个原理,Rust的借用检查器也做不到这一点。

为了解决这个问题,就需要使用泛型生命周期参数来明确指定返回引用的生命周期与输入参数的生命周期之间的关系。

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

此时代码正常执行:

image-20240504190734366

在修复后的代码中,我们使用了泛型生命周期参数 'a,这样可以确保返回的引用与输入参数的生命周期相匹配。这样编译器就能够正确推断返回引用的生命周期,避免悬垂引用或生命周期不匹配的问题。

  • 生命周期标注有着一个不太常见的语法:生命周期参数名称必须以撇号(')开头,其名称通常全是小写,类似于泛型其名称非常短。'a 是大多数人默认使用的名称。

  • 生命周期标注描述了多个引用生命周期相互的关系,而不影响其生命周期。

  • 生命周期参数标注位于引用的 & 之后,并有一个空格来将引用类型与生命周期标注分隔开。

&i32        // 引用
&'a i32     // 带有显式生命周期的引用
&'a mut i32 // 带有显式生命周期的可变引用

看到这儿,你大概还是一知半解、一头雾水、一脸懵逼、一愣一愣。不着急,等我去画个图先,人的脑子总是惯性的偏向于理解图像信息而不是文字,尽管我文采飞扬,满屏生花!


3.1 再论泛型生命周期

通过上面泛型生命周期的简单使用大概可以获取到下面这些信息:

  • 此时通过函数签名可以明确某些生命周期'a,在函数获取到的两个参数中他们的生命周期都是和'a保持一致,对于返回值也是一个道理,也就是说,此时不论是两个参数x,y还是返回值都保持了生命周期的大小同步。

  • 怎么理解这个 同步的含义是重点,这就又和上面所学的东西关联上了,所谓的同步,就是这个生命周期标识'a会保证参数和返回值将会是三者中生命周期的较小者,可以理解为三者的交集,这也是我们需要告知rust需要保证的某种约束条件。

  • 在函数执行时,当具体的引用被传入到该函数中,'a标记的生命周期就是两个引用参数x,y的较小者(为什么不是较大者,请回去再看一遍上一个目录的内容)。

  • 保证 了x,y的约束条件之后,最终函数在返回值时,还需要再次保证此时返回值的生命周期和之前两个引用参数的生命周期的较小者。

image-20240504193644091

如上图所示。

  • 我们假设两个参数的生命周期为其较小的一方(假设为z),那么z = min(x,y);

  • 用w表示返回值的生命周期,那么最终返回的生命周期为min(z,w)

  • 他们之间类似于数学概念上的交集的定义,只有保证了全部生命周期中的重叠部分一致,才能保证整个函数生命周期的有效性,但凡取一个较大或者较小的值,都可能会导致非法引用问题的出现。

需要注意的是,生命周期标识仅仅作为一种标识,它本身没有更多的实际意义,也不会直接影响某个函数的功能,仅作为一种约束关系的表示而已。

这些标注出现在函数签名中,而不存在于函数体中的任何代码中。这是因为 Rust 能够分析函数中代码而不需要任何协助,不过当函数引用或被函数之外的代码引用时,让 Rust 自身分析出参数或返回值的生命周期几乎是不可能的。这些生命周期在每次函数被调用时都可能不同。这也就是为什么我们需要手动标记生命周期的原因。


理论部分巴拉完了,下面通过两个具体的例子,来直观感受下如何通过传递拥有不同具体生命周期的引用来限制 longest 函数的使用。

函数还是之前的函数,请注意观察main方法中的内容:

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

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

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

输出: The longest string is long string is long

这个例子中,string1的作用域显然大于string2,所以它直到整个外部作用域结束都是有效的,string2则只在{}代码块中有效,作用域较小。

而result这是引用了哪些直到内部作用域结束时也还有效的值,这就相当于在string1和string2中取了交集部分,二者的较小值,此时借用检查器正常检查通过,所以会看到那段输出。

没有比对就没有对比,看看下面这个例子:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        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);
}

这个例子中:

  • string1直到外部作用域结束都是有效的

  • string2的作用域只在内部代码块中有效,显然在作用域范围上满足string2<string1

  • 与上一个例子比较,这里将result的声明移到了代码块之外,也即是内部作用域之外,但是它和string2的赋值操作还是留在代码块中

  • 并且打印result的代码也移到了代码块之外

通过上面的分析,这段代码显然是无法通过编译器拷打的,所以你才会看到下面的异常提示:

image-20240504195414309

  • 从人的角度读上述代码,我们可能会觉得这个代码是正确的。 string1 更长,因此 result 会包含指向 string1 的引用。因为 string1 尚未离开作用域,对于 println! 来说 string1 的引用仍然是有效的。然而,我们通过生命周期参数告诉 Rust 的是: longest 函数返回的引用的生命周期应该与传入参数的生命周期中较短那个保持一致。

  • 基于上面 保持一致 这一点,此时就应该取string2作为最终的生命周期,因为它显然比string1短,但由于此时string2在离开代码块之后就已经失效了,导致在 println! 中尝试使用 result 时,string2 已经被丢弃,从而产生了悬垂引用。是无法通过借用检查器的检查的,此时编译器收到了检查器的眼神之后,二话不说上来就是一大嘴巴子,并甩出了一句:"拒绝编译!!"


4. 参考&引用

  • 《Rust权威指南》

More from this blog

[c++]浅谈函数重载解析和不明确匹配

先简单回顾下函数重载需要借助函数中的哪些属性来作为是否是重载函数的依据: 函数的参数类型 函数的参数个数(参数长度) 但是,这只是我们定义函数重载概念中的一部分,编译器在实际的函数重载过程中,还会进行一系列的二次判定和相对繁琐的规则匹配,这, 就是本文的内容。 重载决策 对于非重载函数,也就是具有唯一名称的函数嘛,只有一个函数可能与调用匹配,可以说,这样的函数一调一个准,因为唯一,所以没有选择,因为没有选择,所以无需过多的匹配流程。所以这种情况下,调用该函数只有两种 结果: 匹配 ...

Jan 21, 20252 min read6
[c++]浅谈函数重载解析和不明确匹配

提问的智慧-转载

更新日志 2022-9-15 午时 于 杭州 在原文的基础结构上调整了文章目录结构 简单进行了一下md的格式化 修改封面配图 引 ​ 在黑客世界里,当提出一个技术问题时,你能得到怎样的回答?这取决于挖出答案的难度,同样取决于你提问的方法。本指南旨在帮助你提高发问技巧,以获取你最想要的答案。 首先你必须明白,黑客们只偏爱艰巨的任务,或者能激发他们思维的好问题。 如若不然,我们还来干吗?如果你有值得我们反复咀嚼玩味的好问题,我们自会对你感激不尽。好问题是激励,是厚礼,可以提高我们的...

Jan 19, 20252 min read2
提问的智慧-转载

邪恶的非常量全局变量

在编程过程中,避免使用全局变量是一个良好的编程实践建议。当然,这里的全局变量主要是指 非常量全局变量; 尽管在小型项目中,这一点似乎看起来人畜无害,但是在大型项目中往往会出现很多问题。 新手程序员往往比较喜欢使用大量的全局变量,因为这样使用起来方便直接,特别是当设计到不同函数的多次调用传递参数时。 若无特别说明,本文后续内容中提到的全局变量均指 非常量全局变量。 全局变量的潜在危险 到目前为止,全局变量危险的最大原因是因为他们的值可以在任何地方被任何调用的函数更改,并且程序员没有简单的方法...

Jan 12, 20252 min read4
邪恶的非常量全局变量

详解设计模式|单例的进化之路

概念 单例模式(Singleton Pattern)是设计模式中一个重要的模式之一,是确保一个类在任何情况下都绝对只有一个实例。单例模式一般会屏蔽构造器,单例对象提供一个全局访问点,属于创建型模式。 根据初始化时间的不同,可以将单例模式分为两类: 饿汉式单例 懒汉式单例 当然,除了上面的两个分类之外,处于对性能、安全等方面的考量,单例模式还演化出了各种实现版本,每一种版本的演进,都是单例的一次**进化与升级,**下面就来看看单例模式的进化之路上都经历了哪些挑战与对抗。 饿汉式单例 饿...

Jan 5, 20255 min read4
详解设计模式|单例的进化之路

SpringBoot Web开发精解

SpringMVC基础回顾 当在 Spring Boot 中引入 Web 模块时,SpringBoot 会帮我们自动配置 Web 相关的组件,其中 Spring MVC 便是最重要的部分。 组件介绍 上图是 SpringMVC 的工作原理图。先介绍一下原理图中涉及的各个组件。 DispatcherServlet:前端控制器,是整个流程的控制中心,由它调用其他组件处理用户请求。 HandlerMapping:处理器映射器,负责根据用户请求的URL找到相应的Handler处理器。 Handl...

Jan 5, 20256 min read5
SpringBoot Web开发精解

Xayla

26 posts

Simple is efficiency!