Skip to content

19.5 协变

19.5.1 什么是协变

Rust 的生命周期参数是一种泛型类型参数。比如,我们可以这样理解共享引用:


rust
type StrRef<'a> = &'a str;

这是一个指向字符串的借用指针。它是一个泛型类型,接受一个泛型参数,之后形成一个完整类型。它跟Vec<T>很像,只不过 Rust 里面泛型类型参数既有生命周期,又有普通类型。下面是一个示例:

rust
type StrRef<'a> = &'a str;

fn print_str<'b>(s: StrRef<'b>) {
    println!("{}", s);
}

fn main() {
    let s : StrRef<'static> = "hello";
    print_str(s);
}

这个例子中演示了一种有意思的现象。大家看一下,print_str接受的参数类型是StrRef<'b>,而实际上传进来的参数类型是StrRef<'static>,这两个类型并不完全一致,因为'b != 'static。但是 Rust 可以接受。这种现象在类型系统中被称为“协变”(covariance)和“逆变”(contravariance)。

协变和逆变的定义如下。 我们用<:符号记录子类型关系,T1 <: T2意味着 A 是 B 的子类型。那么对于泛型类型C<T>

  • 协变

T1 <: T2时满足C<T1> <: C<T2>,则 C 对于参数 T 是协变关系。

  • 逆变

T1 <: T2时满足C<T2> <: C<T1>,则 C 对于参数 T 是逆变关系。

  • 不变

上述两种都不成立。

总结起来就是,如果类型构造器保持了参数的子类型关系,就是协变;如果逆转了参数的子类型关系,就是逆变。其他情况,就是不变。

Rust 不支持普通泛型参数类型的协变和逆变,只对生命周期泛型参数存在协变和逆变。

在 Rust 中,泛型类型支持针对生命周期的协变是一个重要功能。大家试想一下,下面这条语句为什么能成立:

rust
let s : &str = "hello";

"hello"是一个字符串字面量,它的类型是&'static str。而 s 是一个局部变量,它的类型是&'s str。其中泛型参数在源码中省略掉了,这个生命周期泛型参数代表的是这个局部变量从声明到结束的这段区域。在这句话中,我们把一个生命周期更长的引用&'static str,赋值给了一个生命周期更短的引用&'a str,这是没问题的。原因在于,既然这边被指向的目标在更长生命周期内都是合法的,那么它在一个较短生命周期内也一定是合法的。所以,我们可以说引用类型&对生命周期参数具有协变关系。(此处有些争论,有人认为这里应该理解为逆变关系,主要的争议来自于我们很难说清两个生命周期,究竟谁是谁的子类型。本书中为了行文的方便,继续使用“协变”,但主要意思是“协变 or 逆变”,是跟“不变”的概念相对立的。)

接下来,我们可以通过一些示例继续理解其他一些泛型类型的协变关系。示例如下:

rust
fn test<'a>(s : &'a &'static str) {
    let local : &'a &'a str = s;
}

从这个示例我们可以看到,&'a &'static str类型可以安全地赋值给&'a &'a str类型。由于&'static str<:&'a str以及&'a &'static str <: &'a &'a str关系成立,这说明引用类型针对泛型参数 T 也是具备协变关系的。

把上面的示例改一下,试试&'a mut T 型指针:

rust
fn test<'a>(s : &'a mut &'static str) {
    let local : &'a mut &'a str = s;
}

编译,可见出现了生命周期错误。这说明从&'a mut &'static str类型到&'a mut &'a str类型的转换是不安全的。此事可以说明,&mut型指针针对泛型 T 参数是不变的。

下面再试试 Box 类型:

rust
fn test<'a>(s : Box<&'static str>) {
    let local : Box<&'a str> = s;
}

这段代码可以编译通过,说明从Box<&'static str>类型到Box<&'a str>类型的转换是安全的。所以Box<T>类型针对 T 参数是具备协变关系的。

下面再试试函数 fn 类型。注意 fn 类型有两个地方可以使用泛型参数,一个是参数那里,一个是返回值那里。我们写两个测试用例:

rust
fn test_arg<'a>(f : fn(&'a str)) {
    let local : fn(&'static str) = f;
}

fn test_ret<'a>(f : fn()->&'a str) {
    let local : fn()->&'static str = f;
}

test_arg可以通过编译,test_ret不能通过。意思是,fn(&'a str)类型可以转换为fn(&'static str)类型,而fn() -> &'a str类型不能转换为fn() -> 'static str类型。这意味着类型fn(T)-> U对于泛型参数 T 具备逆变关系,对于 U 不具备协变关系。如果我们把这个测试改一下,尝试把生命周期参数换个位置:

rust
fn test_ret<'a>(f : fn()->&'a str) {
    f();
}

fn main() {
    fn s() -> &'static str { return ""; }

    test_ret(s);
}

上面这段代码可以编译通过。这意味着fn() -> &'static str类型可以安全地转换为fn() -> &'a str类型。那我们可以说,类型fn(T)-> U对于参数 T 具备协变关系。

再换成具备内部可变性的类型试验:

rust
use std::cell::Cell;

fn test<'a>(s : Cell<&'static str>) {
    let local : Cell<&'a str> = s;
}

编译出现了生命周期不匹配的错误。这说明Cell<T>类型针对 T 参数不具备协变关系。至于为什么要这样设计,前面已经讲过了,如果具备内部可变性的类型还有生命周期协变关系,可以构造出悬空指针的情况。所以需要编译器提供的UnsafeCell来表达针对类型参数具备“不变”关系的泛型类型。

同样,我们可以试试裸指针:

rust
fn test<'a>(s : *mut &'static str) {
    let local : *mut &'a str = s;
}

可以得出结论,*const T针对 T 参数具备协变关系,而*mut T针对 T 参数是不变关系。比如标准库里面的Box<T>。它的内部包含了一个裸指针,这个裸指针就是用的*const T而不是*mut T。这是因为我们希望Box<T>针对 T 参数具备协变关系,而*mut T无法提供。

在写 unsafe 代码的时候,特别是涉及泛型的时候,往往需要我们手动告诉编译器,这个类型的泛型参数究竟应该是什么协变关系。很多情况下,我们需要使用 PhantomData 类型来表达这个信息。

19.5.2 PhantomData

在写 unsafe 代码的时候,我们经常会碰到一种情况,那就是一个类型是带有生命周期参数的,它表达的是一种借用关系。可是它内部是用裸指针实现的。请注意,裸指针是不带生命周期参数的。于是就发生了下面这样的情况:

rust
struct Iter<'a, T: 'a> {
    ptr: *const T,
    end: *const T,
}

然而,在 Rust 中,如果一个类型的泛型参数从来没有被使用过,那么就是一个编译错误。请参考 RFC 0738-variance。如果一个泛型参数从来没有使用过,那么编译器就不知道这个泛型参数对于这个类型是否具备协变逆变关系,那么就可能在生命周期分析的时候做出错误的结论。所以编译器禁止未使用的泛型参数。在这种情况下,我们可以使用 PhantomData 类型来告诉编译器协变逆变方面的信息。

PhantomData 没有运行期开销,它只在类型系统层面有意义。比如,一个自定义类型有一个泛型参数'a没有被使用,为了表达这个类型对于泛型参数'a具备协变关系,那我们可以为它加一个成员,并且把类型制定为PhantomData<&'a T>即可。因为前面我们已经说了&'a T类型对于泛型参数'a具备协变关系,所以编译器就可以推理出来这个自定义类型对于泛型参数'a具备协变关系。其他用法与此类似,你要表达一个什么样的协变逆变关系,就找一个现存的类似的类型,拿它当作 PhantomData 的泛型参数即可。

PhantomData 定义在std::marker模块中:

rust
#[lang = "phantom_data"]
#[stable(feature = "rust1", since = "1.0.0")]
pub struct PhantomData<T:?Sized>;

它是一个0大小的、特殊的泛型类型。它上面有#[lang=…]属性标记,这说明它是编译器特殊照顾的类型。它主要是用于写 unsafe 代码时,告诉编译器这个类型的语义信息。

如果你想表达这个类型对 T 类型成员有拥有关系,那么可以使用PhantomData<T>。例如std::core::ptr::Unique

rust
pub struct Unique<T: ?Sized> {
    pointer: NonZero<*const T>,
    _marker: PhantomData<T>,
}

如果你想表达这个类型对 T 类型成员有借用关系,那么可以使用PhantomData<&'a T>

你还可以用它来表明当前这个类型不可 Send、Sync,示例如下:

rust
struct MyStruct {
    data: String,
    _marker: PhantomData<*mut ()>,
}

下面同样用比较完整的示例来演示一下这个类型的具体作用。假设我们现在有两个类型:

rust
use std::fmt::Debug;

#[derive(Clone, Debug)]
struct S;

#[derive(Debug)]
struct R<T: Debug> {
    x: *const T
}

其中 R 类型想表达一种借用关系,它内部需要用裸指针实现。上面这种简单的写法是有问题的,因为我们可以很容易制造出悬空指针:

rust
fn main() {
    let mut r = R { x: std::ptr::null() };
    {
        let local = S{};
        r.x = &local;
    }
    // r.x now is dangling pointer
}

为了让编译器使用 borrow checker 检查这种内存错误,我们可以给 R 类型添加一个生命周期参数,并且利用 PhantomData 使用这个生命周期参数,避免“未使用泛型参数”的错误。同时给 R 类型增加一个成员方法,在成员方法中改变指针的地址,并且通过模块系统禁止外部用户直接访问 R 的内部成员。完整代码如下所示:

rust
use std::fmt::Debug;
use std::ptr::null;
use std::marker::PhantomData;

#[derive(Clone, Debug)]
struct S;

#[derive(Debug)]
struct R<'a, T: Debug + 'a> {
    x: *const T,
    marker: PhantomData<&'a T>,
}

impl<'a, T: Debug> Drop for R<'a, T> {
    fn drop(&mut self) {
        unsafe { println!("Dropping R while S {:?}", *self.x) }
    }
}

impl<'a, T: Debug + 'a> R<'a, T> {
    pub fn ref_to<'b: 'a>(&mut self, obj: &'b T) {
        self.x = obj;
    }
}
fn main() {
    let mut r = R { x: null(), marker: PhantomData };
    {
        let local = S { };
        r.ref_to(&local);
    }
}

再编译,我们可以看到,这次编译器就可以成功发现生命周期错误,禁止悬挂指针的产生。在写 FFI 给 C 代码做封装的时候,需要经常使用裸指针,这时就可以用类似的技巧来处理生命周期的问题。

Released under the MIT License