Rust 生命周期系统:类型系统
[2024-04-11 更新] 修正了一些错误,采纳了一些读者反馈可以增加的解释,在此表示感谢。
[2024-07-02 更新] 大更新!修改、增加了许多例子与阐释,读起来应该会更容易些。
Rust的生命周期应被视为类型系统的一部分。理解难免出现偏差,若发现恳请斧正!
以下几节试图渐进地、但从不同角度理解该问题;只要看懂一节也许就足够了,所以可以都看看?
引子
一开始看到生命周期和泛型写在一起也许会感到诧异:但事实上生命周期就是类型系统的一部分:泛型指明某一值能进行什么运算,生命周期指明某一值在什么区间内才有效、才可以参与运算。
既然能接受泛型加入类型系统并参与类型的运算,生命周期也应同理。
贴段代码大概感受一下:
1 | use std::fmt::Display; |
其中 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()->'long
和f2()->'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 T
和T
呢?答案见下文。 - 我们会在后文详细讨论何时会反过来。
运算后 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 | fn longer<'a>(s1: &'a str, s2: &'a str) -> &'a str { |
其实等效于在a、b固有的、不受约束的生命周期中添加了两条约束'a:'b
与'b:'a
:
1 | fn longer<'a, 'b>(s1: &'a str, s2: &'b str) -> &'a str where 'a:'b, 'b:'a { |
也就是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
很长:固然可以使得返回值生命周期长,然而这导致参数对生命周期要求过长,直接没法使用了……这隐含了一个约束:不能超过mystr1
和mystr2
中任意一个的生命周期。 - 让
'a
很短:我们知道f('short) <: f('long)
(逆变),因此此时推断出来的函数一定可以调用。然而这导致返回值的生命周期也很短!
简单来说,参数和返回值的生命周期一起增长。仍然使用之前理财产品的比喻:投入的越多,收益就越大。那么,最好的选择就是找一个投资金额恰好能让你把全部家当 All in 进去的。越多越好!
而这里的约束就是你手头的“资金”:正如调用函数时,实参的生命周期限制了生命周期推断系统,使其不能无限地增加生命周期。
当然,这是只有一种生命周期的情况。如果有更多的生命周期('a
, 'b
, 'c
, …)、更多的约束,我们就需要进一步地求解,这是编译器做的事。
总之,在这个例子中,确保满足约束的同时,编译器要找到最精确的匹配,使得生命周期推断完毕后的函数恰好能以mystr1
与mystr2
这两个实参调用,而此时的返回值生命周期也达到最大。这就是“力所能及”的含义。
可是为什么要精确的匹配?
想想 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 只是做的更细粒度。
更细粒度,更多信息,更多优化,更加安全。就是这样。