一些关于生命周期的思考
我写了个玩具级别的矩阵库 matreex,其中一个方法的签名是这样的:
1 | pub fn map<U, F>(self, f: F) -> Matrix<U> |
顾名思义,这个方法将原矩阵的每一个元素都映射为 U 类型,然后返回新矩阵 Matrix<U>。没有借用,没有生命周期,完美无比,精妙绝伦。但移动语义并不总是我想要的,所以我还写了一个方法:
1 | pub fn map_ref<U, F>(&self, f: F) -> Matrix<U> |
这个方法将原矩阵的每一个元素的引用都映射为 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 返回了对矩阵元素的临时引用,这个引用的生命周期小于方法的作用域,因此不能进一步返回到方法外部。这让我有点摸不着头脑:对矩阵元素的引用来源于对矩阵的引用,而对矩阵的引用来源于方法外部,其生命周期不小于方法的作用域。换句话说,离开方法的作用域时,对矩阵元素的引用一定不会垂悬。这段代码理应是健全的。
与编译器缠斗良久,我最终选择了场外求助:
Is there a way to bound
UinFn(&T) -> Uwith lifetime that&Thas whenUis a reference?
从标题就可以看出,我连错误的原因都没有弄明白:在函数的调用处,借用检查只分析签名,不深入实现,它无从得知闭包参数 &T 来源于对矩阵元素的引用。换句话说,闭包参数 &T 的生命周期与矩阵元素毫不相干。解糖以后,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 i32 是拥有较短生命周期的 &'b i32 的子类型。根据强制类型转换规则,子类型可以转换为父类型,所以 &'a i32 在闭包的调用处转换为了 &'b i32。
关于 subtyping 和 variance 的详细解释,参考 The Rustonomicon。
为什么实际代码中,map 和 map_ref 返回的是 Result<Matrix, Error>?
Rust 的 alloc 库规定单次分配的内存不得大于 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 时因为内存耗尽而崩溃,不如直接返回 Error::CapacityOverflow。