一些关于生命周期的思考
我写了一个玩具级别的矩阵库 matreex
,其中一个方法的签名是这样的:
1 | pub fn map<U, F>(self, f: F) -> Matrix<U> |
顾名思义,这个方法消耗了原矩阵 Matrix<T>
,将其每一个元素映射为 U
类型,然后返回新矩阵 Matrix<U>
。没有借用,没有生命周期,完美无比,精妙绝伦。但移动语义并不总是我想要的,所以我还写了一个方法:
1 | pub fn map_ref<U, F>(&self, f: F) -> Matrix<U> |
这个方法借用了原矩阵 Matrix<T>
,将其每一个元素的引用映射为 U
类型,然后返回新矩阵 Matrix<U>
。目前为止一切正常,单元测试也顺利通过:
1 | let matrix = matrix![[1, 2, 3], [4, 5, 6]]; |
于是我开始用它来辅助编写其他的单元测试:
1 | let matrix = matrix![[1, 2, 3], [4, 5, 6]]; |
这段代码简单明了,符合直觉,唯一的缺点是:它不能编译。报错信息如下:
1 | | let matrix_ref = matrix.map_ref(|x| x); |
翻译一下:闭包 |x| x
临时借用了矩阵元素,这个借用的生命周期小于方法的作用域。这让我有点摸不着头脑:对矩阵元素的借用 &i32
来源于对矩阵的借用 &self
,由于后者的生命周期不小于方法的作用域,所以前者的生命周期也不应该小于方法的作用域。按理来说,这段代码是健全的(sound)。
与编译器缠斗良久,我最终选择了场外求助:
Is there a way to bound
U
inFn(&T) -> U
with lifetime that&T
has whenU
is a reference?
从标题上来看,我预想的解决方案实在是南辕北辙。代码不能编译的原因很简单:编译器在函数调用处只看签名不看实现(否则静态分析分分钟突破递归深度),它无法得知闭包参数 &T
来源于对矩阵元素的借用,换句话说,闭包参数 &T
的生命周期与借用的生命周期毫不相干。解糖(desugaring)以后,map_ref
的签名实际上是这样的:
1 | pub fn map_ref<'a, U, F>(&'a self, f: F) -> Matrix<U> |
其中,'b
是任意小的生命周期。矩阵元素的引用 &'a i32
在闭包调用处发生了强制类型转换,变成了 &'b i32
,于是闭包原样返回 U = &'b i32
,返回值的生命周期小于 map_ref
的作用域,从而导致编译失败。要解决这个问题,只需要约束闭包参数 &T
的生命周期,让它与 &self
的生命周期保持一致就行了:
1 | pub fn map_ref<'a, U, F>(&'a self, f: F) -> Matrix<U> |
嗯,这就是语言原神的魅力。
Q & A
强制类型转换是如何发生的?
对于生命周期 'long: 'short
,'long
是 'short
的子类型。因为 &'a T
关于 'a
协变,所以 &'long T
是 &'short T
的子类型。具体到代码里,拥有较长生命周期 'a
的 &'a i32
是拥有较短生命周期 'b
的 &'b i32
的子类型。根据强制类型转换规则,子类型可以转换为父类型,所以 &'a i32
在闭包调用处转换为了 &'b i32
。
关于 subtyping 和 variance 的详细解释,参考 The Rustonomicon。
为什么实际代码中,map
和 map_ref
返回的是 Result<Matrix, Error>
?
Rust 标准库规定单次内存分配不得大于 isize::MAX
字节,这意味着当 size_of::<U>() > size_of::<T>()
时,Matrix<U>
可能分配失败。例如,一个拥有 isize::MAX
个元素的 Matrix<u8>
无法映射为一个拥有相同数量元素的 Matrix<u16>
,因为它要求 2 * isize::MAX
字节的内存分配。
这似乎是一个毫无必要的 corner case,因为即使是上文提到的 Matrix<u8>
,在 64 位系统上也需要大约 8.59 亿 GB 的内存,目前没有任何一台计算机能够做到这一点。然而,构造一个拥有 isize::MAX
甚至 usize::MAX
个元素的零大小类型(zero-sized type)矩阵是可行的,因为它不需要内存分配。与其让它在调用 map
或 map_ref
时 panic,不如直接返回 Error::CapacityOverflow
。