在上一篇文章中,我们围绕 “引用必然存在来源” 这一基本概念,介绍了Rust中引用之间的关系,以及生命周期标记的实际意义。我们首先从最简单的单参数方法入手,通过示例说明了返回引用与输入引用参数之间的逻辑关系;通过多引用参数的复杂场景,阐释了生命周期标注(本人给其命名为 “引用关系标记”)的必要性及其编译器检查机制。在上一篇文章的最后,我们还提到了关于包含引用的结构体,只不过由于篇幅原因以及文章结构原因,我们没有细讲。因此,在本文中,我们将继续通过实际示例出发,探讨包含引用的结构体的生命周期相关内容。
包含引用的结构体的本质
单从数据结构的角度来看,结构体本质上是具有类型安全的复合数据体,即结构体是一个可以包含多个数据字段的逻辑单元:
1 |
|
引用的本质也是一份包含了被引用者内存地址信息(以及其他上下文)的数据,因此,我们当然可以让结构体包含引用字段:
1 |
|
在这里我们先暂且不考虑具体的语法(添加生命周期参数标记),而是思考一下一个包含引用的结构体相比于没有包含任何引用的结构体究竟有什么特殊之处。首先,一个结构体一旦被创建出来,就意味着它内部的数据字段此时都是合法的数据,并且,结构体中的字段数据一定不可能晚于这个结构体创建时刻。
1 |
|
有的读者可能会给出这样的反例:
1 |
|
请注意,这里结构体中的num
字段类型是Option<i32>
,而不是i32
,因此,我们需要在创建Data结构体实例数据的时候,把Option<i32>
类型数据准备好,这里我们用的是None
。这里并没有违背我们上面说的“结构体中的字段数据一定不可能晚于这个结构体创建时刻”。
在笔者看来,一个包含了引用的结构体有如下两个信息点:
- 本身可以作为一种引用类型来看。
- 可以将其创建的实例等价为一个引用。
我们先看第1点。我们知道,i32
是一种类型,&i32
也是一种类型。同样的,像上述的MyData
这个结构体同样是一种类型。同时,因为该结构体包含了引用,所以我们可以将其等价理解为某种引用类型:
上图中,笔者将MyData1
归为了普通类型,而将MyData2
归为了引用类型。它俩区别在于,MyData1
不包含任何的引用字段,而MyData2
包含引用字段。
对于第2点,当我们创建一个包含引用的结构体的实例以后,这个实例本身也可以理解为一个引用:
1 |
|
这里的data
变量,可以等价为一个引用,它类似于这样的代码:
1 |
|
只不过在结构体形式下,我们把这个所谓的&num
赋值给了结构体内部某个字段而已。
单个引用的结构体
在大体上能够理解包含引用的结构体的本质以后,我们就可以按照之前的思路,来理解这种含引用的结构体实例变量的其生命周期相关内容了。
首先,一个创建出来的含引用的结构体的实例本身就成为了一个引用数据,而不是普通数据了,那这个引用必然有其来源,而这个引用的来源自然是先前另一个变量借用而来的引用:
注意看上图,我将num_ref
和data
圈在了一起,并用“等价”相连接,是因为num_ref
一旦设置到了MyData
结构体的字段中,就意味着num_ref
这个引用被转移到了MyData
内部,成为了其一部分,此时data: MyData
尽管看起来就是一个普通的数据,但此时它就是一个引用数据。
从上面的关系图我们很容易知道,如果要满足正确的生命周期,很显然,data
(num_ref
的 “代名词”)不能存活的比其来源num
久。
始终牢记:”引用必然有其来源,且不能活的比其来源更久“
多个引用的结构体
事实上,包含多个引用的结构体本质上和包含单个引用的结构体的理解思路一致的,即结构体中多个引用字段都有其来源,唯一需要注意的为了保证包含多引用的结构体实例在运行时合法,很显然这个结构体实例的存活时间不能超过结构体所包含的多个引用字段的各自存活时间。还是用来源关系图来表达如下的代码:
1 |
|
data
来包含了num_ref
和val_ref
,也就是说,data
此时应该视为num_ref
和val_ref
这两个引用的“结合体”。而num_ref
和val_ref
又各自来源于num
和val
,那么为了满足内存安全的要求,我们只有让data
的存活时间同时不能超过num_ref
和val_ref
各自所引用的源头数据num
和val
的存活时间。如果随时都要同时满足,就只有让data
的存活时间不能超过num
和val
其中距离销毁时刻最近的那一个:
结构体的生命周期参数标识
目前为止,我们基本理解了包含引用的结构体究竟是一个什么“东西”以及它的存活要求,但Rust中让很多新手难以理解的,其实是结构体中的生命周期参数标识,比如:
1 |
|
甚至有一些“丧心病狂”的代码:
1 |
|
但请不要担心,在阅读了本文以后,我相信你能够很轻松的理解上面这些代码的意义。在继续之前,让我们回顾一下在《理解Rust引用及其生命周期标识(上)》一个例子:
1 |
|
在这个例子中,生命周期参数标识的核心作用,是把func
方法的输入引用参数num_ref
和输出引用&i32
建立依赖关联(它们都使用了相同的生命周期参数'a
)。而正是由于该关联关系,我们可以分析出上述的res
(返回的引用)本质上依赖num
变量。因此,为了内存安全性,我们很显然不能让res
这一引用的存活时间超过它的来源num
。所以,一旦编译器发现num
和res
的生命周期不正确时,会予以编译错误。
添加参数标识的必要性
那为什么包含引用的结构体需要为其添加生命周期参数呢?在笔者看来,核心作用是为了让开发者通过引用关系标记来更加明确的指定相关的引用依赖关系。让我们用一个例子来更好的解释。
首先,让我们还是定义一个包含引用的结构体:
1 |
|
然后,我们定义如下签名的方法,该方法能够返回一个包含引用的结构体实例:
1 |
|
基于这个方法签名,无论其内部的代码怎样编写,我们都可以将其简化为如下的流程:
1 |
|
MyData
中的num_ref
字段是一个引用,基于 “引用不可能凭空产生” ,一定要有一个来源,这里只能是num_ref1
或者num_ref2
。然而,究竟是num_ref1
还是num_ref2
呢?很显然我们(以及Rust编译器)是无法通过静态的代码就能分析出,毕竟这是一个运行时才能知道的结果,例如下面的伪代码就没法静态确定:
1 |
|
既然无法确定返回结构体中的引用字段究竟与哪个入参存在依赖关系,编译器可以做到的一种检查方式就是确保返回的MyData
的实例的存活时间不能超过入参num_ref1
和num_ref2
这两个引用的来源变量存活时间最短的那一个,因为MyData
持有的num_ref
引用不管依赖哪一个,但只要其存活时间不超过num_ref1
和num_ref2
所对应的来源变量最先销毁的那个,MyData
持有的num_ref
就一定是合法的。
尽管这样的处理限制理论上来讲是“最保险最安全”的,但在某些场景下又过于严格了,比如如下的代码从内存安全的角度来看,也是合理的:
1 |
|
上述func
返回的MyData
实例所包含的引用只会来自于num_ref1
,永远不会来自num_ref2
,也就是说,返回的MyData
只需要保证其存活时间不超过num_ref1
的来源变量的存活时间即可。但如果按照上述“最安全最保险”的方式进行生命周期检查,Rust编译器是不会给我们通过的。为了即可以保证内存安全,又不过于严格限制引用关系(例如此时这种情况),Rust做法是要求开发者通过显式的生命周期参数标识来告诉告知编译器:返回的MyData
中的num_ref
字段只会和入参num_ref1
产生关系。
对于func
的入参,只需要给num_ref1
和num_ref2
分别给予不同的生命周期参数来区分它们:
但是对于MyData
来说,我们应该如何的将入参num_ref1
的生命周期参数'a
与MyData
中的num_ref
这个引用字段进行关联呢?Rust语言规范给出的答案就是对于包含引用的结构体在定义时必须要增加生命周期“形式”参数。比如MyData
我们可以这样定义:
1 |
|
面对上述定义的结构体,我们可以按照这样的理解思路来看:
MyData
放置参数列表的尖括号<xxx>
中的第一个位置是一个引用生命周期参数标识,这里写作'hello
;MyData
中的num_ref
这个引用类型的字段的生命周期参数标识使用了参数列表中第一个位置上的的'hello
,因此,在将来我们使用MyData
的时候,填入的实际周期参数就对应了num_ref
字段。
紧接着,我们不气上面的方法签名。此时,我们只需要在返回的MyData
把实际的生命周期参数标识'a
填入到尖括号中即可:
而此时的'a
这个生命周期参数标识叫做“实际参数 ”,它放在了参数列表的第一位,指代了MyData
在定义时的参数'hello
:
至此,我们就完成了整个依赖的链路的确定。相信读者在阅读了上述的内容以后,能够理解对于包含引用的结构体添加需要添加生命周期参数标识的必要性了吧。记住,对于结构体上定义时的生命周期参数标识,是一种标记,它在参数列表(就是结构体名称后面的尖括号列表<xxx, xxx>
)中的位置用于在将来实际使用时传入到对应的位置来表达实际的意义。
注意结构体与结构体引用
关于包含结构体引用的实例还有一个需要读者注意点就是仔细区分结构体实例与其借用而来的引用。例如下面的代码:
1 |
|
上述的方法有两个生命周期参数标识'a
和'b
,其中'a
用于标记&MyData
这个结构体实例的引用;而'b
则用于标记MyData
实例中的字段num_ref
这个引用。注意它俩有着不同的概念,用依赖图可能更加直接:
data_ref
依赖data
,而data
包含num_ref
,即依赖于num
,因此data_ref
的生命周期存活时间,不能超过num
的存活时间。
生命周期参数标记不改变客观存在的生命周期
很多Rust新手可能会有这样的误区,认为当修改了或者设置了方法的生命周期参数标记的时候,就会改变实际传入的变量的生命周期,这是很多新手无法掌握生命周期参数标记的典型问题。但实际上,生命周期参数标记的核心作用是通过语法约束向编译器提供引用关系的逻辑描述,而不会改变引用本身客观存在的生命周期范围。通常,我们需要从“客观生命周期事实”和“主观引用关系逻辑描述”两个方面来看待包含生命周期参数标记的代码。例如,如下的代码:
1 |
|
从“客观生命周期事实”的角度来看,result
这个&i32
引用的生命周期是最长的,比起num_ref
以及num
都长;而“主观引用关系逻辑描述”来看,这个result
是由func
输出而来,而观察该方法的签名,我们知道通过'a
引用生命周期参数标记,返回的引用生命周期依赖于入参,而入参是num_ref
,来源于num
,因此它不能超过num
的生命周期。因此,我们(Rust编译器)能够根据其中的矛盾点而识别到错误。
写在最后
本文在编写过程中也是断断续续,修修改改了有小半个月才完成,虽然文章已经编写了完成了,但是笔者还有很多内容想说,就放在后续的文章讲吧。