一些关于生命周期的思考

我写了个玩具级别的矩阵库 matreex,其中一个方法的签名是这样的:

1
2
3
pub fn map<U, F>(self, f: F) -> Matrix<U>
where
F: FnMut(T) -> U,

顾名思义,这个方法将原矩阵的每一个元素都映射为 U 类型,然后返回新矩阵 Matrix<U>。没有借用,没有生命周期,完美无比,精妙绝伦。但移动语义并不总是我想要的,所以我还写了一个方法:

1
2
3
pub fn map_ref<U, F>(&self, f: F) -> Matrix<U>
where
F: FnMut(&T) -> U,

这个方法将原矩阵的每一个元素的引用都映射为 U 类型,然后返回新矩阵 Matrix<U>。目前为止一切正常,单元测试也顺利通过:

1
2
3
let matrix = matrix![[1, 2, 3], [4, 5, 6]];
let output = matrix.map_ref(|x| *x as f64);
assert_eq!(output, matrix![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]);

于是我用它来辅助其他的单元测试:

1
2
3
4
let matrix = matrix![[1, 2, 3], [4, 5, 6]];
let matrix_ref = matrix.map_ref(|x| x);

// -- test snippet --

这段代码很符合直觉,唯一的缺点是不能编译。它的报错信息如下:

1
2
3
4
5
|         let matrix_ref = matrix.map_ref(|x| x);
| -- ^ returning this value requires that `'1` must outlive `'2`
| ||
| |return type of closure is &'2 i32
| has type `&'1 i32`

大概意思是:闭包 |x| x 返回了对矩阵元素的临时引用,这个引用的生命周期小于方法的作用域,因此不能进一步返回到方法外部。这让我有点摸不着头脑:对矩阵元素的引用来源于对矩阵的引用,而对矩阵的引用来源于方法外部,其生命周期不小于方法的作用域。换句话说,离开方法的作用域时,对矩阵元素的引用一定不会垂悬。这段代码理应是健全的。

与编译器缠斗良久,我最终选择了场外求助:

Is there a way to bound U in Fn(&T) -> U with lifetime that &T has when U is a reference?

从标题就可以看出,我连错误的原因都没有弄明白:在函数的调用处,借用检查只分析签名,不深入实现,它无从得知闭包参数 &T 来源于对矩阵元素的引用。换句话说,闭包参数 &T 的生命周期与矩阵元素毫不相干。解糖以后,map_ref 的签名实际上是这样的:

1
2
3
pub fn map_ref<'a, U, F>(&'a self, f: F) -> Matrix<U>
where
F: for<'b> FnMut(&'b T) -> U,

其中,'b 是任意小的生命周期。

回顾编译失败的代码,对矩阵元素的引用 &'a i32 在闭包的调用处发生了强制类型转换,变成了具有较小生命周期的 &'b i32,然后闭包原样返回 U = &'b i32,返回值的生命周期小于 map_ref 的作用域,从而被借用检查拒绝。要解决这个编译错误,只需要约束闭包参数 &T 的生命周期,让它与 &self 的生命周期保持一致就行了:

1
2
3
pub fn map_ref<'a, U, F>(&'a self, f: F) -> Matrix<U>
where
F: FnMut(&'a T) -> 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

为什么实际代码中,mapmap_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)矩阵是可能的,因为它不需要分配内存。与其让它在调用 mapmap_ref 时因为内存耗尽而崩溃,不如直接返回 Error::CapacityOverflow