在上一篇文章中,我们围绕 “引用必然存在来源” 这一基本概念,介绍了Rust中引用之间的关系,以及生命周期标记的实际意义。我们首先从最简单的单参数方法入手,通过示例说明了返回引用与输入引用参数之间的逻辑关系;通过多引用参数的复杂场景,阐释了生命周期标注(本人给其命名为 “引用关系标记”)的必要性及其编译器检查机制。在上一篇文章的最后,我们还提到了关于包含引用的结构体,只不过由于篇幅原因以及文章结构原因,我们没有细讲。因此,在本文中,我们将继续通过实际示例出发,探讨包含引用的结构体的生命周期相关内容。
包含引用的结构体的本质
单从数据结构的角度来看,结构体本质上是具有类型安全的复合数据体,即结构体是一个可以包含多个数据字段的逻辑单元:- struct MyData {
- pub num: i32,
- pub is_ok: bool,
- }
复制代码 引用的本质也是一份包含了被引用者内存地址信息(以及其他上下文)的数据,因此,我们当然可以让结构体包含引用字段:- struct MyData<'a> {
- pub num_ref: &'a i32,
- pub is_ok: bool,
- }
复制代码 在这个例子中,生命周期参数标识的核心作用,是把func方法的输入引用参数num_ref和输出引用&i32建立依赖关联(它们都使用了相同的生命周期参数'a)。而正是由于该关联关系,我们可以分析出上述的res(返回的引用)本质上依赖num变量。因此,为了内存安全性,我们很显然不能让res这一引用的存活时间超过它的来源num。所以,一旦编译器发现num和res的生命周期不正确时,会予以编译错误。
添加参数标识的必要性
那为什么包含引用的结构体需要为其添加生命周期参数呢?在笔者看来,核心作用是为了让开发者通过引用关系标记来更加明确的指定相关的引用依赖关系。让我们用一个例子来更好的解释。
首先,让我们还是定义一个包含引用的结构体:- struct Data {
- pub num: i32
- }
- // main
- let num_val = 123;
- let data = Data { num: num_val } // <- Data实例化时,里面的字段的数据肯定早于实例化当前Data
复制代码 然后,我们定义如下签名的方法,该方法能够返回一个包含引用的结构体实例:- struct Data {
- pub num: Option<i32>
- }
- // main
- let mut data = Data { num: None };
- let num_val = Some(123);
- data.num = num_val;
复制代码 基于这个方法签名,无论其内部的代码怎样编写,我们都可以将其简化为如下的流程:- let num = 123;
- // ┍ data这个变量本质上一个引用
- let data: MyData2 = { num_ref: &num }
复制代码 MyData中的num_ref字段是一个引用,基于 “引用不可能凭空产生” ,一定要有一个来源,这里只能是num_ref1或者num_ref2。然而,究竟是num_ref1还是num_ref2呢?很显然我们(以及Rust编译器)是无法通过静态的代码就能分析出,毕竟这是一个运行时才能知道的结果,例如下面的伪代码就没法静态确定:- let num = 123;
- let other = #
复制代码 既然无法确定返回结构体中的引用字段究竟与哪个入参存在依赖关系,编译器可以做到的一种检查方式就是确保返回的MyData的实例的存活时间不能超过入参num_ref1和num_ref2这两个引用的来源变量存活时间最短的那一个,因为MyData持有的num_ref引用不管依赖哪一个,但只要其存活时间不超过num_ref1和num_ref2所对应的来源变量最先销毁的那个,MyData持有的num_ref就一定是合法的。
尽管这样的处理限制理论上来讲是“最保险最安全”的,但在某些场景下又过于严格了,比如如下的代码从内存安全的角度来看,也是合理的:- let num: i32 = 123;
- let val: bool = true;
- let data: MyData = {
- num_ref: &num,
- val_ref: &val,
- }
复制代码 面对上述定义的结构体,我们可以按照这样的理解思路来看:
- MyData放置参数列表的尖括号中的第一个位置是一个引用生命周期参数标识,这里写作'hello;
- MyData中的num_ref这个引用类型的字段的生命周期参数标识使用了参数列表中第一个位置上的的'hello,因此,在将来我们使用MyData的时候,填入的实际周期参数就对应了num_ref字段。
紧接着,我们不气上面的方法签名。此时,我们只需要在返回的MyData把实际的生命周期参数标识'a填入到尖括号中即可:
而此时的'a这个生命周期参数标识叫做“实际参数 ”,它放在了参数列表的第一位,指代了MyData在定义时的参数'hello:
至此,我们就完成了整个依赖的链路的确定。相信读者在阅读了上述的内容以后,能够理解对于包含引用的结构体添加需要添加生命周期参数标识的必要性了吧。记住,对于结构体上定义时的生命周期参数标识,是一种标记,它在参数列表(就是结构体名称后面的尖括号列表)中的位置用于在将来实际使用时传入到对应的位置来表达实际的意义。
注意结构体与结构体引用
关于包含结构体引用的实例还有一个需要读者注意点就是仔细区分结构体实例与其借用而来的引用。例如下面的代码:- struct MyData<'a> {
- pub num_ref: &'a i32
- }
复制代码 上述的方法有两个生命周期参数标识'a和'b,其中'a用于标记&MyData这个结构体实例的引用;而'b则用于标记MyData实例中的字段num_ref这个引用。注意它俩有着不同的概念,用依赖图可能更加直接:
data_ref依赖data,而data包含num_ref,即依赖于num,因此data_ref的生命周期存活时间,不能超过num的存活时间。
生命周期参数标记不改变客观存在的生命周期
很多Rust新手可能会有这样的误区,认为当修改了或者设置了方法的生命周期参数标记的时候,就会改变实际传入的变量的生命周期,这是很多新手无法掌握生命周期参数标记的典型问题。但实际上,生命周期参数标记的核心作用是通过语法约束向编译器提供引用关系的逻辑描述,而不会改变引用本身客观存在的生命周期范围。通常,我们需要从“客观生命周期事实”和“主观引用关系逻辑描述”两个方面来看待包含生命周期参数标记的代码。例如,如下的代码:- struct MyData<'a> {
- pub num_ref: &'a i32
- }
- struct MyDataWrapper<'a, 'b> {
- pub my_data: &'a MyData<'b>, // wtf!
- pub len: &'b i32,
- }
复制代码 从“客观生命周期事实”的角度来看,result这个&i32引用的生命周期是最长的,比起num_ref以及num都长;而“主观引用关系逻辑描述”来看,这个result是由func输出而来,而观察该方法的签名,我们知道通过'a引用生命周期参数标记,返回的引用生命周期依赖于入参,而入参是num_ref,来源于num,因此它不能超过num的生命周期。因此,我们(Rust编译器)能够根据其中的矛盾点而识别到错误。
写在最后
本文在编写过程中也是断断续续,修修改改了有小半个月才完成,虽然文章已经编写了完成了,但是笔者还有很多内容想说,就放在后续的文章讲吧。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |