深入浅出理解 Rust 生命周期系统

views
Word count: 4.4k (~16 mins to read) Last updated:

Rust 生命周期系统:类型系统

[2024-04-11 更新] 修正了一些错误,采纳了一些读者反馈可以增加的解释,在此表示感谢。
[2024-07-02 更新] 大更新!修改、增加了许多例子与阐释,读起来应该会更容易些。

Rust的生命周期应被视为类型系统的一部分。理解难免出现偏差,若发现恳请斧正!
以下几节试图渐进地、但从不同角度理解该问题;只要看懂一节也许就足够了,所以可以都看看?


引子

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

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<:'L2.

显然,生命周期越长越“好”(也就是更泛用)。
所以一开始我们就知道'long <: 'short,也就是长寿的可以自裁(cast过去)变成短命的,但是短命的没法强行续命变成长命的。

形如'L1 <: 'L2的偏序关系中,左侧可以向右侧cast;但反之是不安全的:

  • 在 OOP 中,派生类Cat可以 cast (narrow down) 成基类Animal,而反过来则不一定可行。
  • int32可以转int64, 而反之不行。
  • 对于 subtype 也是同理。更长的生命周期 ‘L1 对程序员在何处、何范围使用值的施加的约束比更短的生命周期 ‘L2 更松。

我们要求一个参数需要有特定生命周期时,更长的甚至static都是可以接受的,但绝不能更短:否则 Rust 布道者赖以为生的内存安全要出事了。
当然,我们做的永远是保守估计:约束关系推导出来的最终生命周期是很严格的,宁可错杀一千也不会放过一个。
因此,一定存在一些人工检查过、可以保证安全的操作会被笨笨的生命周期系统拦下:这时就需要你动用unsafe黑魔法了。

当然,还有一个事实是显然的:'static是所有类型的subtype, 因为没有人比他更长。


生命周期的运算

类型int可以产生f(int)f()->int这样的函数签名。这其实是对类型本身进行运算得到一个新类型(所以泛型也可以理解为一种类型构造器)。
我们前面说过,生命周期应该视为类型系统的一部分,自然地,我们也可以通过声明周期'a产生一些新类型:比如函数类型f('a)或者f()->'a.

我们看一个例子,来看看这些生命周期被“运算”后,偏序关系发生了怎样的变化:

  • 现有生命周期:'long <: 'short
  • 假设有函数f1(x:'long)和f2(x:'short)
    • 唯一的区别是 f1 相比 f2 对参数 x 要求更长的生命周期
    • 能用 f1 的地方就能用 f2
    • 但能用 f2 的地方不一定能用 f1
    • 因此 f2 更泛用,f2≼f1
    • 换句话说,f('short) <: f('long) (和一开始反过来了)
  • 假设有函数f1()->'longf2()->'short,唯一的区别是f1的返回值x的生命周期比f2的长
    • 那么需要其返回值时,能用f2就一定能用f1
    • 但能用 f1 时不一定能用 f2
    • 因此 f1 更泛用,f1≼f2
    • 换句话说,f()->'long <: f()->'short (和一开始一致)
  • 我们可以这样理解:假设这是个理财产品,f(x)->y, x 是投资金额,y 是收益。什么样的理财产品更好,可以用于替换当前的呢?
    • f(x')->y: 收益一样但投资更少!
    • f(x)->y': 投资一样但收益更高!
  • 思考:那mut&'a TT呢?答案见下文。
  • 我们会在后文详细讨论何时会反过来。

运算后 subtype 偏序关系的变化

我们要明确,函数签名之类的是对类型进行的一种运算。函数对值运算,而函数签名对类型进行运算。是不是有点像物理上的量纲分析
换言之,这些带泛型或者生命周期的东西(不管是Vec<T>还是f(T,T,T[])->T什么的。别忘了函数也是一种类型,也属于T),本质上是类型之间的 Functor.

因此,我们可以把f()->'a当作一种对'a的运算。
看看不同的运算都对生命周期'a做了什么,偏序关系在这一运算中又是如何变化的:

  • 对于函数参数,参数受较短生命周期的约束:我们发现'long <: 'short => F('short) <: F('long)
    • 运算后原先的偏序关系颠倒了!
    • 剧透:这叫逆变(contravariance).
  • 对返回值来说,恰恰相反:我们只知道至少可以活多么久,如果返回值能活更长使用者一定能欣然接受。则此时'long <: 'short => (F()->'long) <: (F()->'short)
    • 运算后原先的偏序关系保持!
    • 剧透:这叫协变(covariance).

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

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


让我们数学一点……

首先,为什么要搞得这么数学(离散数学/范畴论)?因为当类型系统的偏序关系网浮出水面,系统里隐含的约束也就能被自然推导。

如果对于用到的各个生命周期,构成偏序关系集合是格(有上下确界),那么编译器会开心的帮你标记好一切生命周期。
否则,如果编译器在约束求解中无法找出某些生命周期的上下确界,甚至出现了环(互为 subtype)——不好意思,还是得另请高明(程序员,也就是你)来帮忙标清楚。
其实远古版本 Rust 就是必须写明所有生命周期,非常反人体工程学,而现在能自动推导绝大部分的。

例如上面那段代码为两个参数都标注'a:

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

其实等效于在a、b固有的、不受约束的生命周期中添加了两条约束'a:'b'b:'a

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。
因此在手动指定后,在需要用到生命周期的地方,这两个参数以及返回值的生命周期就可以被推导啦。

生命周期系统会在满足这些约束的前提下,为这些参数争取尽量长的生命周期(就像一个高级的泛型系统)。
比如,如果传的两个参数生命周期不一样,一长一短;为了让'a'b相等,编译器要根据基本法把这两个生命周期都推断为较短的那个。
这是安全的,因为对函数体中的代码来说,'long <: 'short.
如果你真的领悟了“生命周期类似泛型,是类型系统的一部分”这句话,你就知道这和传int32进入f(int64)是一样的:编译器需要帮你安全地 cast 一下。


生命周期、泛型与安全

说到底,编译器进行了一系列生命周期的推断,为的就是在它“力所能及的范围”内让返回值生命周期最长。
请允许我再展开说说:来看看这个函数签名吧。longer('a, 'a)->'a, 现在有了一个函数调用longer(mystr1, mystr2)我们要推断'a是什么:

  • 'a很长:固然可以使得返回值生命周期长,然而这导致参数对生命周期要求过长,直接没法使用了……这隐含了一个约束:不能超过mystr1mystr2中任意一个的生命周期。
  • 'a很短:我们知道f('short) <: f('long)(逆变),因此此时推断出来的函数一定可以调用。然而这导致返回值的生命周期也很短!

简单来说,参数和返回值的生命周期一起增长。仍然使用之前理财产品的比喻:投入的越多,收益就越大。那么,最好的选择就是找一个投资金额恰好能让你把全部家当 All in 进去的。越多越好!
而这里的约束就是你手头的“资金”:正如调用函数时,实参的生命周期限制了生命周期推断系统,使其不能无限地增加生命周期。
当然,这是只有一种生命周期的情况。如果有更多的生命周期('a, 'b, 'c, …)、更多的约束,我们就需要进一步地求解,这是编译器做的事。

总之,在这个例子中,确保满足约束的同时,编译器要找到最精确的匹配,使得生命周期推断完毕后的函数恰好能以mystr1mystr2这两个实参调用,而此时的返回值生命周期也达到最大。这就是“力所能及”的含义。
可是为什么要精确的匹配?
想想 C++: 几个个有重载关系的函数:

  • int8_t square(int8_t x)
  • int32_t square(int32_t x)
  • int64_t square(int64_t x)

我们当然希望传入的参数类型越大越好,而返回值的类型越小越好,但这是一厢情愿。
如果你以int32_t实参调用,一定会调用第二个函数。这是因为int8_t的版本参数装不下;而int64_t, int32_t的版本虽然都能处理,但int64_t的版本返回值类型太大,再也没办法 cast 为int32_t这种较小的类型了,导致返回值后续处理的要求随之提高——换句话说,可用性更差了,此时完全是int32_t版本的下位替代。
从 subtype 的角度看,这归根结底是因为int8_t <: int32_t <: int64_t.

所以,一定会调用第二个函数,因为这是最精确的匹配。
看,生命周期系统又展现出了他和泛型系统相似的一面。

为什么更安全?

话说回来,为什么给函数添加生命周期约束就更安全了呢?我们可以试着用类似 Rust 的方式描绘一下 C/C++ 中,类似的函数如果也有生命周期是什么样的:

C/C++ 的类似实现等价于: longer(s1: 'any, s2: 'any) -> 'static.

不得不说现在看来这真可怕!首先,我们不知道 s1, s2 能存活多久,也许调用完成的一瞬间就被破坏了;其次,我们假设返回值能永远存活下去!尽管有可能立刻就被破坏了……
而 Rust 版本 longer(s1: 'a, s2: 'a) -> 'a 增加了对传入参数的生命周期最低要求(你可以说 C/C++ 版本也有最低要求——只不过是 0 罢了),也缩短了返回值的生命周期。
无论是传参的改变还是返回值的改变,都告诉我们 Rust 版本的函数可用范围变小了。如果还用理财产品做比喻,那就是要求投资的更多但收益更少了,完全的下位替代……

看起来这是坏事?不!正如那些高收益的理财产品往往蕴含着难以预料的风险:一个到处都能使用的 C/C++ 版本的 longer 函数中,“悬垂指针” “use-after-free” 这些恶魔正潜伏着准备在不经意间吞噬你的程序。
而 Rust 版本的函数的可用范围则是生命周期系统为你划出的一片安全区:小小的,但很安心。

好吧,这就是 Rust 在干的事:不仅传参多了生命周期的要求,返回值还不再能活到永远,这也不能用那也不能用,怪不得新手总是觉得:好麻烦!
但是他安全,你能奈他何呢?


运算后 subtype 偏序关系的变化(续)

有没有想过一些运算过后,偏序关系不一定会保留/颠倒,还有可能会直接丢失!
那么对这种情况,非常推荐阅读关于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, 逆变)的其实就只有这个函数参数。
……说到底,其实日常根本就没有多少情况需要你操心函数参数的那个contravariant! 可以松口气了。
当然,根据上文所述,这样推导出来的生命周期约束是怎么严格怎么来,但记住:如果你被搞晕了,那就相信你的直觉;程序是人设计为人服务的,因此绝大部分情况下生命周期的限制是符合直觉的(也就是你可以使用“显然”这一词)。
What a relief.

结语:重新看待 Const

然后你还会注意到,那些 invariant 的项是不是都提供内部不变性:换言之,本质上都是一种指针?
一个绝妙的角度:想想C/C++的指针类型定义, 指针本身是不是 const,这和指针的内容(指向的对象)是不是const有啥关系吗?当然没有!

  • const int*
  • const int* const
  • int* const
  • int*

从这几种指针就能看出:指针指向的对象的可变性,与指针自身的可变性完全独立,毫无关系。

所以某种意义上,对于一个 const 的全局变量,你可以把这里的 const 理解为很粗糙的生命周期约束,在Rust更精细的系统里,他叫'static.
本质上,Rust 只是做的更细粒度。
更细粒度,更多信息,更多优化,更加安全。就是这样。