上一篇文章:Rust 生命周期基础 提到,Rust 中生命周期也是一种类型。类型系统是一个编程语言的基础,Rust 也是一样,甚至更激进:
- Rust 中可以说一切皆类型。
- Rust 又面向表达式,每个表达式都会产生某种类型。
- 整个 Rust 代码就是表达式产生类型,再进行类型转换的大集合。
- 基于这个大的类型集合,就可以做类型检查推断,借用检查,从而实现所有权机制。
子类型 subtyping
先说一个在数学上显然成立的集合关系:如果集合 B ≤ A (这里读作 B 包含于 A)。那么 B 就可以替代 A,这个是显然成立的。并把 B 称为 A 的子类型 (Subtyping)。或者 A 称为 B 的父类型 (Super-typing):
例如 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 ≤ A
⇒f(B) ≤ f(A)
,此时有f(B)
可以替代f(A)
。
- 逆变 cotravariance: 若
B ≤ A
,能推出f(A) ≤ f(B)
; 则称 A 在 f 上产生了逆变,或者称 f(A) 是 A 的逆变体。在 f 作用后,产生了相反的集合序:B ≤ A
⇒f(A) ≤ f(B)
,此时有f(A)
可以替代f(B)
。刚好和原集合序:B 可以替代 A 相反。
- 不变 invariance: 既不是协变,也不是逆变;子类型系统不存在任何联系,即
f(A) ∩ f(B) = ∅
。
图示:
注意 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 str
即f(’a) = &'a str
⇒f = & 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 str
≤fn() -> &'a str
成立,即fn() -> &'static str
可以替代fn() -> &'a str
的函数签名。
- 所以是
fn() -> &'a str
是&‘a str
的协变体。
- 另
&‘a str
=U
。所以fn() → U
是U
的协变体。
但是相同的事,对函数入参,就是不同的场景:
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)
整体看做一种新的类型。(全文完)