RustLifeTimeKata学习记录
最近闲下来有点空,于是决定在玩 compose 之外重新看点 Rust 相关的内容,也因为我的 Rust 理解基本就是半吊子水平,一涉及到并发/协程 etc 就歇菜了。。。所以我决定从生命周期开始重新学起来,之后再看看 Tokio 的相关实现,这次学习的参考资料就是一本有关 Rust 生命周期实践的小册——《Rust LIfetimeKata》。除了一些概念上的讲解外,这个项目还有配套的一些习题,我也会把我的回答和理解放在这篇文章里。
开始之前的闲聊
在开始之前我们还是照例聊聊一些边角料,对于非 CPP
主力的大多数开发者来说,生命周期应该是一个相当陌生的东西,注意我说的不是业务层面的生命周期函数等,而是指引用等变量的存活时间段。
因为有 GC
兜底,无论是前端还是后端,都不用考虑到如此细枝末节的东西,Runtime
会帮助我们处理一切,真是太棒了!。。吗?
即使不考虑系统级编程或者量化交易等对性能有极端要求的场景,传统的后端业务在部分场景中也会因为 GC
的各种缺陷导致性能瓶颈[1],此时你就不得不用 Rust/CPP
去重写对应的实现。
但是没有 GC
的保护,去写内存安全的程序犹如在钢丝上行走,很多 CPP
开发的程序,尤其是那些没有引入 Modern CPP
的项目,就会很容易出现各种各样的内存安全问题。
生命周期是一种更加折中的方式,通过手动指定引用的生命周期约束的方式,编译器在大多数情况下可以正确的实现并获得与手动析构相同的性能,再配合各类智能指针,几乎可以覆盖大多数场景。
不过你真的需要生命周期吗?假设你所有的类型都使用值类型并保证不变性,再配合智能指针的使用,可以在保证一定性能的前提下同时避免生命周期的使用,但是如果这样玩的话,那还不如写 Erlang
呢😂。
生命周期标注
前几章的内容比较简单,这里就放下 exercise 的答案。
exercise
1 |
|
1 |
|
1 |
|
函数的生命周期省略规则
在实际编写代码时,你可能会发现很多情况下并不需要为每个函数的引用参数都添加生命周期标注,这是因为 rust 编译器在这几年的演进过程中已经包含了很多对生命周期的改善,对于一些常见的模式,编译器可以自动推断出其生命周期约束。然而,这种自动推断有些时候会推导出完全不符合我们意图的生命周期。
参考相关文档,对于函数的生命周期省略规则如下。
- 函数参数中每个省略周期标注的参数都会被给予一个独立的生命周期参数。
- 如果参数中只使用了一个生命周期,则将其作为所有省略的输出生命周期。
- 对于方法签名, Self 引用的生命周期会被作为省略的输出生命周期。
exercise
1 |
|
可变引用和容器
其实可变引用这个词就很微妙,语义上说起是 mutable
是完全正确的,但是从实际应用角度来看,将其称之为独占引用会更恰当,与之相对的,常规的引用应该被称为共享引用。
之所以会这么称呼是因为 rust
限制一个变量的引用在同一时间只能有以下三种情况:
- 不存在任何引用
- 任意个共享引用
- 一个独占引用(
mut reference
)
所以 mut reference
相较于常规的 reference
,会有很多不同之处,尤其是在类型系统上的差异,这块的内容我们会在之后的章节讨论。
现在我们来看下列例子。
1 |
|
这段代码看似没有什么问题,但是实际上没办法通过编译。
在这个例子中,函数签名将入参的所有生命周期都被约束到了 'a
一个生命周期上,这是一个非常严格的限制。
&val1
和 &val2
在这个例子中生命周期一直持续到了 main
函数结束,第一次调用 insert_value
函数时,创建了一个 mut reference ,由于函数签名的约束,这几个入参至少有着同样长的生命周期,所以此处使用的临时引用也被约束至少活到 main 函数结束,第二次调用 insert_value
时也是同样的情况,此时就违背了引用的规则——同一时间只能有一个可变引用。
解决问题的方法同样也在函数签名之上,就像刚才提到的,所有入参都被约束到 'a
是一个极其严苛的约束,为了解决这个问题,我们首先来分析 insert_value
的意图。
这个函数通过一个类型为 &mut Vec<&i32>
的引用,并接受另一个类型为 &i32
的引用,并将其插入到前一个引用指向的 Vec
中。这里涉及到三类引用,对容器的引用,容器内的引用类型的值,以及一个待插入容器的引用,为了分析方便,我们分别将其称为 'r
,'container
以及 'val
。
实际上这里在分析生命周期的时候还应该加上容器本身的生命周期,但这是多余的!因为容器的有效性依赖与其内部值的有效性,所以在这里,容器的生命周期至多与其所持有的值的生命周期一样长,也就是说,只要容器本身没被析构,其所包含的所有值都应该是有效的。所以,为了分析方便,我们在此处只需要考虑容器持有的值的共有最短生命周期 'container
。
在插入操作后,value
的生命周期同样也受到了原容器 my_vec
的关联,所以 'val
的生命周期肯定是比容器的生命周期以及容器的生命周期长的,其次,容器内的值都有着至少为 'container
的生命周期,所以有 'val: 'container
这层关系。也就是说我们可以将其简化到如下的函数签名。
1 |
|
where 子句的约束 'val: 'container
读作 'val
长于 'container
。
代码修改到这里其实已经可以通过编译了,但是其还可以进一步简化。
别忘了在插入后 value
已经成为容器所持有的值的一员,所以它们都有着至少一样长的生命周期!所以上述函数签名可以进一步修改如下。
1 |
|
是不是更加清爽了?当然之所以能这么修改,是因为生命周期是泛型参数,也就是说其满足里氏替换规则,换言之就是编译时每个入参和返回值的生命周期满足函数签名中对类型的约束就完全没问题。
在这例子中 &val1
明显长于 &val2
,但是其都满足修改后的 insert_value 对生命周期的约束 aka 对应生命周期是函数签名中对应部分的子类型。
关于生命周期和类型系统的联系,我们会在后续章节详细讨论。
exercise
1 |
|
生命周期与类型系统
注意,这一部分并不是来自于 LifetimeKata,但是我认为讨论类型系统对于理解生命周期的应用非常有必要,所以我额外插入了此章节。
在开始之前我们先介绍一些之后会用到的术语。
里氏替换原则:
子类可以替代基类工作,由于 Rust 没有常规面向对象的类设计,所以在这里我们指的是子类型。
型变:
此处指的是通过某些方式派生后的子类型相较派生后父类型的关系,可以参考Wik
好的,有了上述概念的支撑,我们可以继续来研究生命周期的问题了。
根据 Reference 的文档页面,对于常见的类型而言,其型变规则如下
Type | Variance in 'a |
Variance in T |
---|---|---|
&'a T |
covariant | covariant |
&'a mut T |
covariant | invariant |
*const T |
covariant | |
*mut T |
invariant | |
[T] and [T; n] |
covariant | |
fn() -> T |
covariant | |
fn(T) -> () |
contravariant | |
std::cell::UnsafeCell<T> |
invariant | |
std::marker::PhantomData<T> |
covariant | |
dyn Trait<T> + 'a |
covariant | invariant |
其中 covariant 指的是协变,invariant 不变,contravariant 逆变。
在这张表格中,只要属性上是允许 mutable 的,对于 T
来说,其都是不变的,反之则是协变的,而函数的入参相较于 T
来说则是逆变的。
我们引用 Nomicon 的例子来说明这些规则。
1 |
|
所以对于一个 &'a T
来说,接受一个 &'b T where 'b: 'a
是完全没有问题的,这完全符合直觉,因为我们使用了一个生命周期长于约束的引用。
不加证明的,&'a U
也能接受一个 &'a T
,其中 T
为 U
的子类型(里氏替换规则)。
但是对于 &mut 'a T'
,情况就变得特殊起来了,我们来看下列例子。
1 |
|
这段代码无法通过编译,很明显我们的 hello 变成了一个无效的悬垂引用,但是编译器是怎么知道的呢?
首先 assign 函数是没有任何问题的,这只是一个重新赋值的常见操作而已,问题应该发生在 main 函数的调用中。
对于 1
fn assign<T>(input: &mut T, val: T)
其在编译时被单例化成如下形式(&String
可以被 Deref 成 &str
)
1 |
|
而我们传入的 input
是 &mut &'static str
,val
是 &'b str
但是显然在调用的时候不可能满足 'b: 'static
,故无法编译。
还有另外一个角度来说明,但是我不确定这样解释是正确的,我们在此假设对于泛型函数,若其调用能通过编译检查则有 rustc 的单例化对参数顺序无关。
那么可以有以下单例化形式:
1 |
|
其中不满足 'b: 'static
,那么在调用时 input
的类型必须为其标注的类型或其子类型。
但 &mut T
对于 T
是不变的,所以 &mut &'static str
不是 &mut &'b str
的子类型,故编译无法通过。
到此我们已经初步讨论了生命周期和类型系统相结合的情形,但这是显然不够的,如果需要进一步的了解相关信息,可以看看下列资料。
- 《The Rustonomicon》 死灵书,the black art of unsafe rust 🦀
- 《Common Rust Lifetime Misconceptions》
- 《The Reference》 参考手册,大而全
复合类型上的生命周期
就像生命周期标注会出现在函数签名上,声明一个使用到引用的枚举或者结构体也会要求生命周期标注。
1 |
|
这没什么稀奇的,就像编写带有常规泛型参数的结构体和枚举一样,不过变成了单引号开头的生命周期。
exercise
1 |
|
impl 块中的生命周期
当我们给复合类型加上了生命周期标注后,对应的 impl
语句也要带上对应的生命周期参数。
1 |
|
上述例子还可以继续简化
1 |
|
或者你可以让编译器帮你推断生命周期,但是我个人感觉这已经没什么必要了。。。
1 |
|
Exercise
1 |
|
Example 1
1 |
|
这个可以编译,但是不太符合实际需求。因为独占引用和返回值都有着同样的生命周期约束,所以在后者没有被 drop
掉之前你甚至都不能再次调用这个函数。
Example 2
1 |
|
这个是正确的,返回值拥有和 self
一样的生命周期约束。
Example3
1 |
|
在完整展开后这个和 Example2 的语义是一致的。
Example4
1 |
|
在展开后这个和 Example1 的语义是一致的。
特殊的生命周期们
'static
这是一个极其具有迷惑性的生命周期,因为按照其他语言的理解来看,static
就是静态的变量,所以 'static
就意味着始终有效。。。吗?
尽管通过 const
修饰的或者字面量等都满足 'static
的约并且在程序运行期间始终有效,但是这并不意味着满足上述约束的所有变量都是始终有效的,关于这个问题的详细分析,可以参考这篇文章,以及其对应的译文。
'_
这个特殊的生命周期表示让编译器自动推断,在有些 impl
的实现中,若其不依赖于 self
之外的生命周期,通常可以标注 '_
,另外在某些 trait object
的类型中也会用到,不过这块比较特殊,我们会在之后的章节中讨论。
Exercise
1 |
|
trait object 的生命周期省略规则
与在 Rust
中我们遇到的其他情形不同,trait object
的生命周期有着特殊的规则。
在没有显式指定生命周期时,trait object
的默认生命周期为 'static
,当使用 '_
时,编译器使用常规的生命周期省略规则推断。
trait object
作为泛型参数的一部分时。省略规则就变得更为复杂了,完整的解释和示例可以参考 Reference以及其译文。
- 事实上这种场景非常罕见,我见到的例子只有 cloudflare 和 reddit 用 rust 重写自己相关业务用于获取性能提升的案例。 ↩︎