一些关于生命周期的思考

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

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

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

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

这个方法借用了原矩阵 Matrix<T>,将其每一个元素的引用映射为 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 临时借用了矩阵元素,这个借用的生命周期小于方法的作用域。这让我有点摸不着头脑:对矩阵元素的借用 &i32 来源于对矩阵的借用 &self,由于后者的生命周期不小于方法的作用域,所以前者的生命周期也不应该小于方法的作用域。按理来说,这段代码是健全的(sound)。

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

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

从标题上来看,我预想的解决方案实在是南辕北辙。代码不能编译的原因很简单:编译器在函数调用处只看签名不看实现(否则静态分析分分钟突破递归深度),它无法得知闭包参数 &T 来源于对矩阵元素的借用,换句话说,闭包参数 &T 的生命周期与借用的生命周期毫不相干。解糖(desugaring)以后,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&'a i32 是拥有较短生命周期 'b&'b i32 的子类型。根据强制类型转换规则,子类型可以转换为父类型,所以 &'a i32 在闭包调用处转换为了 &'b i32

关于 subtyping 和 variance 的详细解释,参考 The Rustonomicon

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