Rust类型系统中的生命周期

views
Word count: 2.7k (~9 mins to read) Last updated:

Rust类型系统中的生命周期

[2024-04-11 更新] 修正了一些错误,采纳了一些读者反馈可以增加的解释,在此表示感谢。

Rust的生命周期应被视为类型系统的一部分。这是我的粗浅理解,理解错误难免,若发现恳请斧正!
以下几节试图渐进地、但从不同角度理解该问题;只要看懂一节也许就足够了,所以可以都看看?
提示:如果您有函数式编程基础,这篇文章看起来会很幼稚。“不就是一堆monad吗?”


一开始看到生命周期和泛型写在一起也许会感到诧异:但事实上生命周期就是类型系统的一部分,泛型指明某一值能进行什么运算,生命周期指明某一值在什么区间内才有效、才可以参与运算。既然能接受泛型加入类型系统并参与类型的运算,生命周期也应同理。
贴段代码大伙大概感受一下:

1
2
3
4
5
6
7
8
9
10
11
12
use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str
where T: Display
{
println!("Announcement! {}", ann);
if x.len() > y.len() {
x
} else {
y
}
}

x,y是字符串slice,在C/C++中对应字符串指针;在C语言中,这样的指针在字符串失效后有可能仍被调用(悬垂指针),就带来危险。而Rust如何解决这一问题呢?答案是通过所有权机制和生命周期,确认值的有效期究竟何时开始,何时结束(这就称为生命周期),禁止在值的生命周期外进行引用、读取、修改等操作。
Rust里没有“赋值”,只有“绑定”:我们把一个变量名绑定到一个值上,因此这些生命周期注解,仍旧针对的是值:它是一些实际存在的值(字面量、变量储存的值、函数、闭包……)的固有属性,不是虚无缥缈的。

📘 根据现代Rust自带的一些约束推导规则,~95%的情况下生命周期注解能被编译器自动推断。但手动的注解有时无法避免:尤其是结构体中。

在这个例子中,由于这个if的存在,Rust编译器无法判断返回值的有效期到底和x一样还是和y一样。
我们要添加类型推断(是的,生命周期也是一种泛型),x、y具有同名的生命周期注解’a,根据规则’a取x和y生命周期的交集:x,y都有效时,返回值一定有效!这样就避免了C/C++中悬垂指针带来的危险。


也许有人会问,为什么调用该函数时,如果传入的参数生命周期不同,会取较短的?
我们要在集合论的角度考虑:我们说想要一只动物,那么给与可爱的猫是可以接受的,因为猫是动物的子集。事实上,子集($\subseteq$)一种偏序关系。而生命周期也存在一种偏序关系:我们把这种关系叫subtype。假设对于两个生命周期L1和生命周期L2,且有L1是L2的subtype,那我们就记作'L1<:'L2vice versa.
显然,生命周期越长越“好”(也就是泛用)。所以一开始我们就知道'long <: 'short,也就是长寿的可以自裁(cast过去)变成短命的,但是短命的没法强行续命变成长命的(类比上面猫与动物的例子,我们要求一个参数需要有特定生命周期时,更长的甚至static都是可以接受的,但绝不能更短:否则Rust布道者赖以为生的内存安全要出事了)。
当然,还有一个显然:'static是所有类型的subtype.
(不用太纠结为什么是'long <: 'short,偏序关系倒过来没有本质区别,这边只是强调'long更好,物以稀为贵,所以“更少”就放左边了)
什么意思呢?简而言之,L1的约束比L2更松。我们看一个例子,讨论函数间的偏序关系:

  • 假设有函数f1(x)和f2(x),唯一的区别是f1对传进来的x的生命周期的最低要求更长,那么能用f1的地方就能用f2,但能用f2的地方不一定能用f1,因此f2更泛用,f2≼f1
  • 假设有函数f1()->x和f2()->x,唯一的区别是f1的返回值x的生命周期比f2的长,那么需要其返回值的地方能用f2就一定能用f1,反之则不然,因此f1更泛用,f1≼f2
  • 思考题:那mut&'a TT呢?见下文
  • 就像int32可以转int64(数据范围更难满足->数据范围更易满足)而反之不行一样单向,subtype可以向右侧cast(长命百岁->至少活10年);但反之是不安全的。
  • 因此,约束关系推导出来的最终生命周期是最严格的,宁可错杀一千也不会放过一个;就是可能有些人工检查过,可以保证安全的操作会被笨笨的生命周期约束拦下:这时就需要你动用unsafe黑魔法了

所以现在我们要把生命周期理解为类型系统的一部分时,困惑可能来源于为什么不像整形一样规则简单(小int永远是大int的subtype),而包含生命周期的类型系统则有时并不如此:可能不存在这个偏序关系,也可能反过来下克上。
我们要明确,函数签名之类的是对类型进行的一种运算。你可以理解为函数对值运算,而函数签名对类型进行运算。是不是有点像物理上的量纲分析!
换言之,这些带泛型或者生命周期的东西(不管是Vec<T>还是<T>T->T,别忘了函数也是类型一种类型,也属于T),本质上是类型之间的Functor.
因此,我们可以把上一节的f1 f2都拆开来看,当作一种运算,看看他对生命周期做了什么。
例如,对于函数参数,参数受较短生命周期的约束——如上一节分析的那样——我们发现'long <: 'short => F('short) <: F('long) (颠倒了!剧透:这叫逆变(contravariance)),而长寿者可以降级为短命者;而对返回值来说,恰恰相反:我们只知道至少可以活多么久,活更长对于使用返回值的表达式来说也一定可以接受,此时'long <: 'short => (F()->'long) <: (F()->'short)(关系保持!剧透:这叫协变(covariance))……其中,<:左右两侧的表达式——或者说函数签名——正对应上一节的f1 f2,我们只是把换成了<:

1
颠倒了的就是contravariant,不变就是covariant,根本没法推出关系的就是invariant.

invariant就是上面思考题那种。至于原因,最后一节会有个特别轻松易懂的解释:)


为什么要搞得这么数学(离散数学/范畴论)?因为当类型系统的偏序关系网浮出水面,系统里隐含的约束也就能被自然推导。
如果对于用到的类型,这个偏序集合是格(有上下确界),那么编译器会开心的帮你标记好一切生命周期
如果没有办法cast导致编译器在约束求解中无法找出某些类型的上下确界,甚至出现了环——不好意思,还是得另请高明(程序员)来标清楚。
例如上面那段代码为两个参数都标注'a其实等效于在a、b固有的、不受约束的生命周期(远古版本反人体工程学rust必须写明所有生命周期,现在能自动推导绝大部分)中添加了两条约束:

1
2
3
4
5
6
7
fn longer<'a, 'b>(s1: &'a str, s2: &'b str) -> &'a str where 'a:'b, 'b:'a {
if s2.len() > s1.len() {
s2
} else {
s1
}
}

也就是对x和y分别绑定的值,其生命周期’a与’b有{‘a≼’b,’b≼’a},也就是{‘a:’b, ‘b:’a}(显然可以推出’a==’b)。
因此在手动指定后,在需要用到生命周期的地方,这两个参数以及返回值的生命周期就可以被推导啦:为了让他们相等,编译器要根据基本法把长的生命周期cast到短的上。
最终效果就是取传参生命周期较短者。注意这时我们在谈论的是参数,不是整个函数发生了什么质变。
……是的,其实日常根本就没有多少情况需要你操心函数参数的那个contravariant!可以松口气了。
当然,根据上文所述,这样推导出来的生命周期约束是怎么严格怎么来,但记住:如果你被搞晕了,那就相信你的直觉;程序是人设计为人服务的,因此绝大部分情况下生命周期的限制是符合直觉的(也就是你可以使用“显然”这一词)。
What a relief.


有没有想过一些运算过后,偏序关系不一定会保留/颠倒,还有可能会直接丢失!
那么对这种情况,非常推荐阅读关于subtyping, variance(covariant, invariant, contravariant) 的参考资料:
Subtyping and Variance - The Rustonomicon
我将文中的表格附在此处:

Type ‘a T U
&’a T covariant covariant
&’a mut T covariant invariant
Box covariant
Vec covariant
UnsafeCell invariant
Cell invariant
fn(T) -> U contravariant covariant
*const T covariant
*mut T invariant

然后你会发现倒过来(contravariant, 逆变)的其实就只有这个函数参数。
然后你还会注意到,那些invariant的不变量是不是都提供内部不变性,换言之,本质上都是一种指针?
我可以提供一个绝妙的角度:想想C/C++的指针类型定义, 指针本身是不是const,这和指针的内容(指向的对象)是不是const有啥关系吗?当然没有!
所以某种意义上,你可以把const理解为很粗糙的生命周期约束,我们Rust更精细的系统里,他叫'static.