Loading

Rust生命周期的理解

前言

这篇文章的目的是让读者最快最直观的了解什么是生命周期,以及为什么有生命周期,为了达到这个目的——即降低复杂性,本篇文章的用词可能不够严谨,见谅。

引用和所有者

所有者

为了保证一个值会在它作用域结束时被销毁,Rust引入了所有权机制。

fn noname() {
	let a = String::from("ABCDEFG");
	b = a;
}

上面的代码会发生什么?

  • let a = String::from("ABCDEFG");将后面的String值给到变量a,现在,可以说这个a就是字符串值的所有者
  • b = a;,将a中的字符串值的所有权转移给b,现在,b是字符串值的所有者,a失效了。

所有者负责该变量的销毁,在Rust中,当所有者所在的作用域结束,其值就会被自动销毁,所以:

fn noname() {
	let a = String::from("ABCDEFG");
	b = a;
} // b的值在这里被销毁,a由于已经不拥有值,所以无需处理

但大多数时候,你都希望只是简单的访问或者修改下值,而不是拿到它的所有权,比如,你不想在方法调用时把一个变量传进去,然后它就不能用了:

fn func(string: String) { // 参数值的所有权被转移给变量string
    // do something
} // string脱离作用域,值被销毁

fn main() {
	let a = String::from("ABCDEFG");
    func(a); // a对其值的所有权转移到函数中,a不再拥有任何值
    println!("{}", a); // 访问没有值的变量,无法通过编译
}

这个时候,你需要用到引用。

引用

fn func(string: &String) { // string是一个String类型的引用,并不拥有其值
    // do something
} // string引用被销毁,不影响值

fn main() {
	let a = String::from("ABCDEFG");
    func(&a); // 产生a的一个引用
    println!("{}", a); // 正常编译!
}

我们在这篇文章里不涉及引用到底是什么样的一个东西,它和指针有什么区别,引用在堆上还是栈上等问题,这些问题,你可以去看《Rust In Action》,你可以暂时把它想成一个指向原值的指针,但这个指针不同于C的指针,引用有额外的安全机制,即Rust会保证引用永远不会指向失效的值

引用的生命周期

引用有额外的安全机制,即Rust会保证引用永远不会指向失效的值

问题就出在这句话上,有了这句话,才有了生命周期这个小碧7。还是刚刚的例子:

fn func(string: &String) {
    // Rust保证引用不会指向失效的值,所以在该方法中你可以放心大胆的访问string而不会出现任何内存错误
    // 也就是说,string的原值在方法调用期间必须一直是有效的
}

为了满足上面的保证,Rust必须确保,传入func的引用,其原值的存活时间大于等于func的作用域,只有这样,引用才不会指向失效的值(实际上作为参考的并不是func的作用域,不过在这个例子上先这样理解):

img

我们可以把这个存活时间理解为生命周期。

当引用被创建,一个引用的生命周期开始,当引用结束最后一次使用,该引用的生命周期结束。更复杂一点的话,引用的生命周期可以是分离开的多个区域,这通常在有流程控制结构时发生,在这篇文章里我们不会触碰这些复杂的结构,可以在《Rust for Rustaceans》一书的第一章中找到。

引用的生命周期,必须小于等于其值的生命周期

我来举一个反例:

fn invaild_ret_ref() -> &String {
	&String::from("Hello")
}

这个函数返回一个String的引用,它在函数中使用String::from创建了一个String值,然后试图将它的引用返回,可是在invaild_ret_ref函数结束后,这个String值就已经被销毁了,该引用不可能能够指向有效的数据,Rust编译器会拒绝这个代码。

我很喜欢张汉东老师对《Rust for Rustaceans》的翻译样章中把生命周期(lifetime)翻译成生存期,生存期确实比生命周期更加生动,但为了和已有的术语对应,这里我还是使用生命周期。

生命周期标记

正如我们看到的,Rust的引用代表对值的一次借用,它们有着种种限制,所以,在函数中、在结构体中、在方法等等位置上使用引用时,你都要给Rust编译器一些关于引用的提示,这种提示,就是生命周期标记

考虑下面的例子:

fn longer_one(a: &String, b: &String) -> &String {
	if a.len() > b.len() {
		a
	} else {
		b
	}
}

我不是要故意把例子写的复杂,因为对于简单的情况,Rust编译器已经足够聪明到让你能够省略生命周期标识符,稍后我们会谈到。

这个函数接收两个String引用,返回其中较长的那个引用。首先先说结论,它无法通过编译。

为什么呢?考虑下面的调用:

fn main() {
	let str_1 = String::from("Hello");
	let longer: &String;

	{
		let str_2 = String::from("A");
		longer = longer_one(&str_1, &str_2);
	} // str_2的值被销毁

	println!("{}", longer);
} // str_1的值被销毁

这个调用有什么问题呢?str_1的值Hello,它的生命周期是整个main函数,直到main结束,它才会被销毁,而str_2的值A,它在它所在的代码块结束后就被销毁。所以,它们的引用有效的生命周期范围也是不同的,str_2的引用的生命周期就是中间的那个只有两行代码的代码块。

引用类型变量longer接收longer_one函数的返回结果,该函数可能返回str_1的引用和str_2的引用其中之一,如果恰好str_1比较长,那么longer变量就是str_1的引用,我们可以在后面的println语句中访问它,因为它仍处在自己的生命周期中,而如果是str_2较长,我们稍后如果访问它就是在访问一个无效的内存值,这是很危险的。

也就是说,在这种情况下,Rust编译器无法确定哪个引用是被返回的,如果Rust放任这种情况,让这种模棱两可的代码通过编译,那么你的程序偶尔会正常运行,偶尔会出现内存错误。

如果你已经感到晕乎乎的了,不妨休息一下。

如果只返回a呢?

上面的代码从本质上就有问题,无论你使用还是不用生命周期标记,都无法解决这个问题,生命周期标记也不是为了处理这个问题而存在的。不过,我们考虑下面的代码:

fn longer_one(a: &String, b: &String) -> &String {
	a
}

现在,该方法被修改了,它只会返回第一个引用,也就是a,这次,按理说上面的调用不会出现问题了,因为str_2的引用将永远不可能被赋值给longer,后面的访问也没有风险。

但是Rust编译器无法识别这种情况,该函数还是无法通过编译,而生命周期标记,本质目的就是为了在你能够确保你的代码正常的情况下,提示下Rust编译器,我不会出错,放我过去吧

给编译器关于引用生命周期的提示

Rust的生命周期标识是通过类似泛型的语法来定义的,不得不说,看起来很丑,且没有道理:

fn longer_one<'a>(a: &'a String, b: &String) -> &'a String {
	a
}

在这里,我们定义了一个生命周期标记'a,生命周期必须以引号开头。我们告诉编译器,参数a这个引用的生命周期是'a,并且我返回值的生命周期和它的生命周期相同,也是'a

此时,你再进行编译,代码已经可以通过编译了,外面的代码清楚的知道在第二个参数位置上的str_2是不会被返回并赋值给longer的,所以,编译器可以放过我们的代码。

关于为什么使用泛型语法,下面是我自己的猜测:

考虑这样的函数签名:fn test<T>(a: T) -> T,泛型允许我们传入任何类型,T可以是任意类型,但是,最主要的,泛型约束了该函数的参数a和返回值的类型必须是相同的。换句话说,当a的类型确定了,返回值类型就确定了。

回想刚刚的生命周期泛型标记,'a可以是任意生命周期(实际上仍有一些限制,我们稍后会说),但是,泛型生命周期约束了该函数的返回值必须和a的生命周期一致,当a的生命周期确定了,返回值的生命周期就确定了。

你可以先按上面的理解,但在生命周期中,事情会稍微有些不一样,我们马上会看到。

和泛型差在哪?

我们可以使用比要求更大的生命周期

为了方便演示,且不引入&str类型(因为对于初学者来说,String可能会和&str混淆),我们把例子换成i32类型,还是之前一样的代码:

fn first<'a>(a: &'a i32, b: &i32) -> &'a i32 {
	a
}

我应该不用再解释该代码的意思以及为什么它能够通过编译,而去掉了生命周期标记'a就不能了,你可以自己试着讲下。

现在,我们看一个神奇的东西:

static GLOBAL: &i32 = &6i32;
fn first<'a>(a: &'a i32, b: &i32) -> &'a i32 {
	GLOBAL
}

该代码的返回值并不和参数'a具有相同生命周期,但是该代码也能通过编译。为什么呢?我们上面的解释站不住脚了吗?

确实,站不住脚了!!!!上面,为了让你理解为什么Rust使用泛型语法来标记生命周期描述符,我给你讲了一些并非事实的东西,虽然无伤大雅。

在泛型中:

fn test<T>(a: T) -> T {
    // dosomething
    a
}

当参数a传入,它的类型被确定,T就等于这个类型了,你不可以再使用一个其它类型来代表它。

而在泛型生命周期上:

static GLOBAL: &i32 = &6i32;
fn first<'a>(a: &'a i32, b: &i32) -> &'a i32 {
	GLOBAL
}

泛型生命周期标记'a不是这样,当你传入a,你可以在需要a的生命周期时使用大于等于参数a的生命周期,而并非必须和它一致。

回到上面的代码,GLOBAL是一个static变量,它的值是一个引用类型,static变量的生命周期是'static,它是贯穿整个程序始终的。也许后面我们会详细介绍它。

如果考虑类型兼容性,如继承和实现关系,其实泛型也有这种特性,当然Rust里没有。

这一特性也称作生命周期的强制转换,即一个较长的生命周期可以转换成一个较短的生命周期以进行使用。见:https://www.yuanmadesign.com/rustexample/scope/lifetime/lifetime_coercion.html

插曲:防止你理解歪了因为我就理解歪了

虽然你可以在需要一个生命周期时,使用另一个更大的生命周期去代表它,但是,从上面代码的角度说,外界并不知道你返回的引用具有'static生命周期,它依然会认为,你返回的是第一个参数的生命周期。

static GLOBAL: &i32 = &6i32;
fn first<'a>(a: &'a i32, b: &i32) -> &'a i32 {
	GLOBAL
}

你在这样使用它时,它还会拒绝你的代码:

let ret: &i32;
{
    let a: i32 = 12;
    let b: i32 = 14; 
    ret = first(&a, &b);  // 已经被借用的a的值的存活时间不够
} // a在这里被销毁,但它仍被借用
println!("{}", ret); // 它会在这里被借用

代表更小的一个生命周期

下面的代码的生命周期如何解释?:

fn longer_one<'a>(a: &'a String, b: &'a String) -> &'a String {
	if a.len() > b.len() {
		a
	} else {
		b
	}
}

参数ab都具有生命周期'a,并且返回值也具有生命周期'a,如果参数a和参数b这两个引用的生命周期一致,显然就无需解释了,'a就是它们两个的生命周期,可万一它们的生命周期不一致呢?

假如a的更大,b的更小,那么很自然的,返回值代表更小的那个生命周期,外部会将返回值的生命周期当作ab中更小的那个来用。

生命周期标记作用于多个引用时,其代表最小的那个引用的生命周期

总结

  • 生命周期是一个引用的生存期,在代码编译时,Rust会确保引用的生命周期不会超过其值的有效范围
  • 在Rust编译器感到迷惑的时候,你得给它一些有关生命周期的提示,这种提示叫做生命周期标注
  • 生命周期标注使用泛型语法声明,比如<'a>
  • 在引用上使用生命周期标注的语法是&'a Type
  • 生命周期和泛型不一样的地方在于,在需要一个生命周期的位置,你可以使用一个更大的生命周期代表,这称作大生命周期到小生命周期的强制转换。在泛型生命周期应用于多个引用上时,其取最小的那个引用的生命周期。

如果你已经感到晕乎乎的了,不妨休息一下。

在结构体中使用生命周期标注

你可以在结构体中使用生命周期标注

#[derive(Debug)]
struct NamedBorrowed<'a> {
    x: &'a i32,
    y: &'a i32,
}

如果你已经理解了上面所有内容,把它用在结构体里也没什么稀奇的。

这个生命周期标注约束了几点:

  1. 两个引用的生命周期必须大于等于结构体的生命周期,否则就有可能出现内存错误
  2. 生命周期标记'a,代表a和b中更小的那个,那么该结构体的生命周期也必须小于等于xy两个引用的生命周期里最小的那个
fn main() {
	let a: i32 = 1;
	let my_struct: NamedBorrowed;
	{
		let b: i32 = 4;
		my_struct = NamedBorrowed {
			x: &a,
			y: &b
		}
	}
}

结构体my_struct,只能用于main其中b被定义的代码块中。

trait中使用生命周期标注

// 带有生命周期标注的结构体。
#[derive(Debug)]
 struct Borrowed<'a> {
     x: &'a i32,
 }

// 给 impl 标注生命周期。
impl<'a> Default for Borrowed<'a> {
    fn default() -> Self {
        Self {
            x: &10,
        }
    }
}

在这里,Borrowed结构体需要一个引用,该引用的生命周期必须比结构体存活时间长,即结构体不能比'a所代表的生命周期活得更久。

你为它实现了Default trait,并且在其方法中,你为其中的字段x设置了一个引用值,你使用了一个'static生命周期的常量,它显然符合'a的要求。

和泛型共同使用

struct Ref<'a, T: 'a>(&'a T);

T: 'a,代表泛型中所有引用的生命周期至少为'a,而Ref中存储了T的引用,所以它也被限制了,它的生命周期不能比'a更长。

T: Trait + 'a代表T类型必须实现Traittrait,并且在T 中的所有引用都必须比'a活得更长。

生命周期默认省略规则

每一个是引用的参数都有它自己的生命周期参数

fn func(string: &String) {}

这个函数会被Rust编译器认为是

fn func<'a>(string: &'a String) {}

fn func(string: &String, string2: &String) {}

会被认为:

fn func<'a, 'b>(string: &'a String, string2: &'b String) {}

依此类推

若你的需求恰好如此,就不用手动声明生命周期

如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数

即,下面两个函数

fn func<'a>(string: &'a String) -> &String {}
fn func(string: &String) -> &String {}

会被认为是

fn func<'a>(string: &'a String) -> &'a String {}

上面第二个先应用规则1,再应用规则2

方法的输出生命周期是self的生命周期

Rust中,方法是带有&self&mut self参数的函数,其输出生命周期被设置成self(结构体本身)的生命周期

参考

  1. 生命周期确保引用有效 - Rust程序设计语言
  2. 生命周期 - 通过例子学Rust
posted @ 2022-12-30 15:18  yudoge  阅读(349)  评论(0编辑  收藏  举报