XiaoboTalk

结合Rust生命周期类型说说协变/逆变

上一篇文章:
Rust 生命周期基础
提到,Rust 中生命周期也是一种类型。类型系统是一个编程语言的基础,Rust 也是一样,甚至更激进:
  • Rust 中可以说一切皆类型。
  • Rust 又面向表达式,每个表达式都会产生某种类型。
  • 整个 Rust 代码就是表达式产生类型,再进行类型转换的大集合。
  • 基于这个大的类型集合,就可以做类型检查推断,借用检查,从而实现所有权机制。
 
类型系统中有个重要的概念:subtyping and variance即子类型和变换性。这其实是一些群理论和抽象代数的概念。我们先来从抽象代数层面讲清楚概念,然后结合实际 Rust 代码来理解。

子类型 subtyping

先说一个在数学上显然成立的集合关系:如果集合 B ≤ A (这里读作 B 包含于 A)。那么 B 就可以替代 A,这个是显然成立的。并把 B 称为 A 的子类型 (Subtyping)。或者 A 称为 B 的父类型 (Super-typing):
notion image
例如 Integer ≤ Float,所有的浮点数是包含整数的(这里认为 2.0 = 2,在数值上相等),那么显然有任何一个 Int 都可以被当做 Float 来看待。但是反过来就不成立 (例如:2.2 不是一个整数)。子类型也可以看做是更具体的类型,父类型可以看做更抽象的类型。这部分需要记住一个结论:B ≤ A ⇒ B 可以代替 A。

变化性 Variance

变化性也是群论中演化概念,通常有三种变化:
  • 协变 covariance: 若 B ≤ A ,能推出 f(B) ≤ f(A); 则称 A 在 f 上产生了协变,或者称 f(A) 是 A 的协变体。即在 f 作用后的新群中,依然保持着和原始集合相同的集合序:B ≤ Af(B) ≤ f(A) ,此时有 f(B) 可以替代 f(A)
  • 逆变 cotravariance: 若 B ≤ A ,能推出 f(A) ≤ f(B); 则称 A 在 f 上产生了逆变,或者称 f(A) 是 A 的逆变体。在 f 作用后,产生了相反的集合序:B ≤ Af(A) ≤ f(B) ,此时有 f(A) 可以替代 f(B)。刚好和原集合序:B 可以替代 A 相反。
  • 不变 invariance: 既不是协变,也不是逆变;子类型系统不存在任何联系,即 f(A) ∩ f(B) = ∅
图示:
notion image
注意 f 是种高度抽象的映射关系,在编译语言中可以是容器包裹:
List<A> // f表示: List
也可以是任何其他的抽象映射,例如给一个函数指针类型
fn (a: &'a mut A); // f表示:fn(&'a) 这个函数签名,后文还会提到
fn(&'a) 是函数签名,整体是一种映射关系,把这个函数签名作用在 A 上,反过来即 A 是这个函数的入参。

Rust 生命周期子类型系统上的变化性

上一篇博客的结尾,有这样的代码:
fn s1_or_s2<'a>(s1: &'a str, s2: &'a str) -> &'a str { if s1.len() > s2.len() { s1 } else { s2 } } fn main() { let ret; { // 某处代码作用域 let s1 = String::from("hello"); let s2 = String::from("world"); ret = s1_or_s2(&s1, &s2); // 编译报错❌ // s1, s2 即将超出作用域,堆内存 hello 和 world 都将被释放 } ret; // ret 变为悬垂指针 }
s1_or_s2函数中的 ‘a 是 Rust 要求开发者必须声明的生命周期参数,’a 是一种代码作用域的限定,实际为一种类型,这类型就是 {} 。这种类型也支持子类型:
'long <= 'short; // 实际代码中往往是 'static <= 'a
这里稍微有点绕,Rust 中更大的作用域是小作用域的子类型(前提是大作用域包含了小作用域)。其实这也好理解,因为更大的作用域一定包含了更小作用域的所有范围,所以从替代性上来说,’long 是可以替代 ‘short 的。这和开头提到的,B ≤ A ⇒ B 可以替代 A 是相吻合的。Rust 中最大的作用域是 ‘static ,它表示整个程序运行都存活的生命周期。所以 ‘static 是一切其他作用域的子类型 subtyping。即对任何作用域 ‘f ,Rust 中总有:’static ≤ ‘f
我们稍加修改上边的错误代码,来说明作用域子类型的概念:
fn s1_or_s2<'a>(s1: &'a str, s2: &'a str) -> &'a str { if s1.len() > s2.len() { s1 } else { s2 } } fn main() { let ret; let s1: &'static str = "hello"; // s1 为 'static 作用域 { let s2 = String::from("world"); let s2 = &s2; // &s2 编译报错❌,因为 s2 在 {} 之后就被会被释放 ret = s1_or_s2(s1, s2); } println!("{ret}"); }
对于 s2 的提前释放,这是个经典的在释放后访问内存的野指针;但是只有 s1_or_s2 函数触发 else 逻辑,即返回 s2 的时候才会暴漏这个问题。
但是这个问题可以被很简单的修复,把println!("{ret}"); 放入到{} 内,代码就正常执行了:
fn s1_or_s2<'a>(s1: &'a str, s2: &'a str) -> &'a str { if s1.len() > s2.len() { s1 } else { s2 } } fn main() { let ret; let s1: &'static str = "hello"; // s1 为 'static 作用域 { let s2 = String::from("world"); let s2 = &s2; ret = s1_or_s2(s1, s2); println!("{ret}"); // ✅ 正确输出 world } }
问题究竟出在哪里呢?答案是,协变规则在做限定。Rust 会用协变规则来检查是否能正常实现子类型替代。我们先来简单的推导这里的协变规则:
  • ‘static ≤ ‘a 所以 ‘static‘a 的子类型,即‘static 可以替代 ‘a
  • 上述示例中,实际的映射体是 &’a strf(’a) = &'a strf = & str我们用泛型参数 T 来表示 str 即表示为:&’a T
  • Rust 在 &’a T 上即支持 ‘a 的协变,也支持 T 的协变。
  • 结论:’static ≤ ‘a 经过 f = & str 的映射后,依然有:&'static str&‘a str ,即&'static str 可以替代 &’a str
我们用上述结论来解释这个报错的代码:
fn main() { let ret; let s1: &'static str = "hello"; // s1 为 'static 作用域 { let s2 = String::from("world"); let s2 = &s2; ret = s1_or_s2(s1, s2); // s2 报错,存活的不够长,作用域为 'b } println!("{ret}"); }
为了便于理解,先将代码语法糖去掉,增加上 MIR 的作用域代码:
fn main() { let ret; 'static: { // 'static 作用域 let s1: &'static str = "hello"; // s1 为 'static 作用域 'a: { // 'a 作用域 'b: { // 'b 作用域 let s2 = String::from("world"); let s2 = &'b s2; ret = s1_or_s2(s1, s2); // s2 报错,存活的不够长,作用域为 'b } // 在 'a 作用域下打印了 ret println!("{ret}"); } } }
所以显然有: ’static ≤ ‘a ≤ ‘b,则:
第一处协变的地方:
  • s1 是 &’static str ,传给了s1_or_s2 函数的第一个参数,该参数类型为 &’a str
  • 由于 ‘a 支持在 & str 下的协变,即依然有:&’static str&’a str
  • 所以有 &’static str 可以替代 &’a str 。所以 s1 传入符合协变规则。✅
第二处不符合协变的地方(也是产生编译报错的原因):
  • s2 为 &’b str 类型,
  • 由协变规则,&’a str&’b str ,即 &’a str 可以替代 &’b str ,但是反过来不可以。
  • 而代码 s1_or_s2(s1, s2) 的 s2 赋值就是违反了规则,试图用 &’b str 替代 &’a str 导致报错❌。事实上如何这被允许,就变为了逆变规则。
你可能在其他地方看到,函数的入参支持逆变,返回值支持协变。笼统的讲,是这么回事,但并不是说函数入参这个类型本身是逆变的。协变/逆变是对类型而言的,和什么地方的参数没有关系。Rust 允许这个函数指针类型对原来类型做逆变,注意是函数指针,也就是说把整个函数签名作为一种类型,这个函数签名对入参类型 T,支持了逆变;对返回值类型 U 支持了协变。这部分后文会展开做深入讨论。
如果使用 Box 来验证,能直观的看懂协变:
fn main() { // b1 为 &'static str 类型 let b1: Box<&'static str> = Box::new("hello"); { // 'a 作用域 // b2 为: &'a str 作用域类型 let mut b2: Box<&str>; // 由于 'static <= 'a; 'a 支持对 &'a T 的协变,所以 &'static str <= &'a str b2 = b1; } }
这段代码是可以正常编译的,也验证了 Box<T> 也支持 T 上的协变。
Rust 官方文档给出了协变和逆变的具体规则表:
'a
T
U
&'a T
covariant
covariant
&'a mut T
covariant
invariant
Box<T>
covariant
Vec<T>
covariant
UnsafeCell<T>
invariant
Cell<T>
invariant
fn(T) -> U
contravariant
covariant
*const T
covariant
*mut T
invariant
  • covariant 表示协变体,例如 &'a T‘a 的协变体,也是 T 的协变体。前一个代码示例已经验证了这个问题
  • contravariant 表示逆变体,Rust 中唯一支持逆变的地方就是函数指针作用在某种类型 T 上的时候,且 T 为函数入参时候,该函数指针类型所发生的逆变;即 fn(T) → U 这个整体是 T 的逆变体,是 U 的协变体。注意,Rust 中一切皆类型,fn(T) → U 整体也是一个类型。按照数学上的群映射,f(T) = fn(T) → U ,这里的 f = fn() → U ;注意把握这一点。

函数签名的入参逆变和返回值协变

接下来,来讨论函数指针 fn(T) → U 对 T 的逆变,和对 U 的协变。先来理解对 U 的协变,来看下边的函数签名:
fn get_str() -> &'a str;
示例代码中返回值 U 的具体类型是 &'a str 。一个声明了生命周期是 ‘a 的 str 切片。那么,显然这个函数(整体看做一种类型)可以被下列函数替代:
fn get_static() -> &'static str;
原函数get_str 的返回值期望得到一个至少是 ‘a 生命周期的 &str ,至具体生命周期是不是比 ‘a 更长,无所谓。而 ‘static 是最长的生命周期,显然满足要求。来看推导:
  • ‘static ≤ ‘a ,即 ‘static 可以替代 ‘a
  • 得出: fn() -> &'static strfn() -> &'a str 成立,即 fn() -> &'static str 可以替代 fn() -> &'a str 的函数签名。
  • 所以是 fn() -> &'a str&‘a str 的协变体。
  • &‘a str = U 。所以 fn() → UU 的协变体。
但是相同的事,对函数入参,就是不同的场景:
fn store_ref(&'a str);
fn store_static(&'static str);
第一个函数指针,不妨简称 store_ref ,可以接受一个 &‘a str ,也就是函数内部只能保证能正常处理 ‘a 的生命周期。
如果某处代码试图使用 fn store_static(&'static str) 函数指针代替 fn store_ref(&'a str) 函数指针,那么当发生真正的函数调用的时候,依然允许函数传入 ‘a 生命周期的参数,而实际的函数实现是按照 ‘static 来处理的,显然会出现安全问题;
但是反过来fn store_static(&'static str) 可以处理 ‘static 生命周期的逻辑,那么肯定也能处理 ‘a 生命周期的逻辑;所以这里其实反过来的:
fn store_ref(&'a str)fn store_static(&'static str) ,也就是fn store_ref(&'a str) 可以替代:fn store_static(&'static str) 。推导一下:
  • ‘static ≤ ‘a ,即 ‘static 可以替代 ‘a
  • 得到:fn store_ref(&'a str)fn store_static(&'static str)
  • 所以 fn(&’a)‘a 的逆变体。
先来简单的验证这个逻辑:
fn static_print(input: &'static str) { println!("{:?}", input); } fn a_print<'a>(input: &'a str) { println!("{:?}", input); } fn main() { let static_fp: fn(&'static str); let a_fp: for <'a> fn(&'a str); // a_print 是一个 fn(&'a str) 类型,可以成功赋值给 fn(&'static str) // 即 static_fp = a_print; a_fp = static_print; // 编译错误❌ }
报错信息如下:
--> src/main.rs:14:12 | 14 | a_fp = static_print; | ^^^^^^^^^^^^ one type is more general than the other | = note: expected fn pointer `for<'a> fn(&'a str)` found fn item `fn(&'static str) {static_print}`
具体是最后一会代码:a_fp = static_print; 赋值报错,原因是 static_print 是一个 fn(&’static str) 函数指针,是更具体的,生命周期更长的函数签名。a_fp 被定义为一个 fn(&’a str) 生命周期为 ‘a ,更抽象的函数指针。当发生真正函数调用的时候,依然可以给 a_fp 传入 ‘a 生命周期的参数,因为 let a_fp: for <'a> fn(&'a str); 在初始化的时候就被指定为了可以传入 ‘a 的入参。而实际的函数实现指向的是 static_print ,也就是 fn(&’static str) ,此时函数会把一个 ‘a 当成是一个 ‘static 来处理,显然会发生不可预知的错误。
 
来看更具体的使用示例:
use std::cell::RefCell; thread_local! { // StaticVecs 显然是 static 生命周期 pub static StaticVecs: RefCell<Vec<&'static str>> = RefCell::new(Vec::new()); } /// 由于 StaticVecs 显然是 static 生命周期 /// 所以 input 必须是 static 生命周期 fn store(input: &'static str) { StaticVecs.with(|v| { v.borrow_mut().push(input); }) } /// 'a 限定了 input 和 f 的参数必须是相同的生命周期 fn demo<'a>(input: &'a str, f: fn(&'a str)) { f(input); } fn main() { demo("hello", store); // "hello" 是 'static. 第二个参数也会被认为是 fn(&'static str),匹配;✅执行 { let smuggle = String::from("smuggle"); demo(&smuggle, store); // 编译报错 ❌ } StaticVecs.with(|v| { println!("{:?}", v.borrow()); // use after free 😿 }); }
第一处:来看
fn main() { demo("hello", store); { //... } }
由于 “hello” 是 static 生命周期,所以
fn demo<'a>(input: &'a str, f: fn(&'a str)) 会被确定为:
fn demo(input: &'static str, f: fn(&'static str)) 。而 demo 函数内部 f 的调用,完全符合 fn store(input: &'static str) 对入参 ‘static 的生命周期要求,没有问题。
第二处:
fn main() { // ... 'a: { let smuggle = String::from("smuggle"); demo(&smuggle, store); // 编译报错 ❌ } // ... }
smuggle 不是 ‘static,而是 ‘a 生命周期,所以 fn demo<'a>(input: &'a str, f: fn(&'a str)) 就是最终生命周期的样子。而传入的第二个参数 store 函数指针(实参)为:
fn store(input: &'static str) ,显然程序尝试用 fn store(input: &'static str) 来替代 demo 的第二个参数:f: fn(&'a str) ,这是不被允许的。因为这里是逆变的。而实际的业务逻辑表现为:smuggle 的生命周期不够长(为 ‘a)。只能在 f: fn(&'a str) 的函数下被正确处理,而如果在 f: fn(&'static str) (实际为 fn store(input: &'static str))的逻辑下显然无法被正确处理。
至此,已经完全解释了逆变和协变,在函数指针上的实际理解。函数指针的入参逆变有点绕,可以反复琢磨一下,关键地方是把 fn(T) 整体看做一种新的类型。
(全文完)