Rust 子类型和变性

子类型是类型之间的一种关系,可以让静态类型语言更加地灵活自由。

理解这一概念最简单的方法就是参考一些支持继承特性的语言。比如说一个Animal类型有一个eat()方法,Cat类型继承了Animal并且添加了一个meow()方法。如果没有子类型机制,那么要写一个feed(Animal)函数,我们就不能给它传递Cat类型的参数,因为Cat并不是一个Animal。但是把Cat传递给需要Animal类型的地方似乎非常的合理。毕竟,Cat就是一个Animal外加一些自己的特性。这些特性完全可以被忽略,不应该妨碍我们在这里使用它!

这就是子类型机制允许我们做的事情。因为Cat是一个Animal外加一些特性,我们就可以说Cat是Animal的子类型。任何需要某种类型的地方,我们都可以传递一个那种类型的子类型。很好!虽然实际情况会稍微复杂和微妙一点,但这种基本的理解足够你应对99%的应用场景了。我们在本章的后面会说明剩下的1%是什么。

尽管Rust没有结构体继承的概念,它却有子类型机制。在Rust中,子类型是针对生命周期存在的。生命周期是代码的作用域,所以我们可以根据它们相互包含的关系判断他们的继承关系。

生命周期的子类型指的是:如果'big: 'small(big包含small,或者big比small长寿),那么'big就是'small的子类型。这一点很容易弄错,因为它和我们的直觉是相反的:大的范围是小的范围的子类型。不过如果你对比一下我们举的Animal的例子就清楚了:Cat是一个Animal外加一些独有的东西,而'big'small外加一些独有的东西。

换一个角度想,如果需要一个在'small内有效的引用,实际指的是至少在'small中有效的引用。我们并不在乎生命周期是不是完全的一致。从这点上来说,永久生命周期'static是所有生命周期的子类型。

高阶生命周期也是所有具体生命周期的子类型。这是因为一个随意变化的生命周期比特定的一个生命周期更通用。

(将生命周期类型化是一个过于自由的设计,以至于一些人并不赞同它。但是,把生命周期看做一种类型,这确实简化了我们的分析。)

当然你不能写一个接收'a类型的值的函数!生命周期只是别的类型的一部分,所以我们需要一些办法来处理它。这里,就要涉及到变性。

变性

变性显得有一点复杂。

变性是类型构造函数与它的参数相关的一个属性。Rust中的类型构造函数是一个带有无界参数的通用类型。比如,Vec是一个构造函数,它的参数是T,返回值是vec<T>&&mut也是构造函数,它们有两个类型:一个生命周期,和一个引用指向的类型。

构造函数F的变性表示了它的输入的子类型如何影响它输出的子类型。Rust中有三种变性:

  • 如果当TU的子类型时,F<T>也是F<U>的子类型,则F对于T协变
  • 如果当TU的子类型时,F<U>F<T>的子类型,则F对于T逆变
  • 其他情况(即子类型之间没有关系),则F对于T不变

注意,在Rust中协变性远比逆变性要普遍和重要。逆变性的存在几乎可以忽略。

一些重要的变性(下文会详细描述):

  • &'a T对于'aT是协变的
  • &'a mut T对于'a是协变的,对于T是不变的
  • fn(T) -> U对于T是逆变的,对于U是协变的
  • BoxVec以及所有的集合类对于它们保存的类型都是协变的
  • UnsafeCell<T>Cell<T>,RefCell<T>,Mutex<T>和其他的内部可变类型对于T都是不变的

我们举几个例子说明这些变性为什么是正确且必要的。

在介绍子类型的时候,其实已经包括了为什么&'a T'a是协变的。当需要一个较短的生命周期时,我们需要能够传递一个更长的生命周期。

类似的理由也可以解释为什么它对于T是协变的:给一个要求&&'a str的地方传递&&'static T是很合理的。这种间接的引用并不影响对生命周期长度的要求。

但是同样的逻辑并不适用于&mut。下面的代码演示了为什么&mut对于T是不变的:

fn overwrite<T: Copy>(intput: &mut T, new: &mut T) {
    *input = *new;
}

fn main() {
    let mut forever_str: &'static str = "hello";
    {
        let string = String::from("world");
        overwrite(&mut forever_str, &mut &*string);
    }
    // 不好!在打印被释放的内存数据
    println!("{}", forever_str);
}

overwrite的签名显然是合法的,它接受两个相同类型的可变引用,然后用一个覆盖另外一个。

但是,如果&mut T对于T是协变的,&mut &'static str将会是&mut &'a str的子类型,这是因为&'static str&'a str的子类型。这时forever_str的生命周期就缩减到和string一样短,overwrite也可以被正常调用。接下来string被释放,等到打印的时候forever_str实际指向了一块释放后的内存空间!所以&mut必须是不变的。

这是变性的一个基本原则:如果生命周期较短的内容有可能存储在生命周期更长的变量里,这时必须要求变性是不变的。

更一般的解释是,子类型和变性可用的前提是我们可以安全地忘掉类型的细节。但对于可变引用,总有一些地方(被引用的原始值)记着类型的信息并且假设它们不会改变。如果我们改变了这些信息,原始值的位置就可能出现异常。

但是,&'a mut T对于'a却是协变的。'aT最关键的区别是'a是引用自身的属性,而T则是引用借用的。如果改变了T的类型,T的原始值依然记着它的类型。可如果改变的是生命周期的类型,只有引用自己知道这一变化,因此这是安全的。换句话说,&'a mut T拥有'a,但是仅仅借用T。

BoxVex的情况就很有趣了,他们是协变的,可是你可以在里面存储值。Rust的类型系统允许它们比其他的类型更聪明。为了理解为什么拥有数据所有权的容器类型对于它们的内容是协变的,我们需要考虑两种可能发生子类型变化的方式:通过值和通过引用。

如果子类型通过值发生变化,原有的记录类型信息的位置会被移除,也意味着容器再也不能使用原有的值了。所以我们也就不用担心有其他的地方记录着类型的信息。换言之,通过值使用子类型的特性会彻底销毁原有类型的信息。例如,这段代码可以编译并正常运行:

fn get_box<'a>(str: &'a str) -> Box<&'a str> {
    // 字符串字面量是&'static str类型,但是我们完全可以“忘掉”这一点,
    // 就让调用者认为这个字符串的生命周期只有这么短
    Box::new("hello")
}

如果子类型通过引用发生变化,那么容器类会以&mut Vec<T>类型传递。可是&mut对于它引用的值是不变的,所以&mut Vec<T>对于T实际也是不变的。那么Vec<T>对于T协变这件事在引用的情况下就完全不重要了。

不过,BoxVec的协变性在不可变引用的情况下依然有用。所以你可以将&Vec<&'static str>传递给需要&Vec<&'a str>的地方。

cell类型的不变性可以这样理解:对于cell来说&就是&mut,因为你可以通过&储存值。所以cell必须是不变的,以避免生命周期缩短的问题。

fn是最怪异的,因为它具有混合变性,而且它也是唯一用到了逆变性的地方。下面的函数签名展示了为什么fn(T) -> U对于T是逆变的:

// 'a来自父作用域
fn foo(&'a str) -> usize;

这个签名表明函数可以接受任何生命周期不小于'a&str。如果函数对于&'a str是协变的,那么这个函数

fn foo(&'static str) -> usize;

就是它的子类型并且可以使用。但是,这个函数的要求其实更严格,它只能接受&'static str,不能接受其他类型。给它传递一个&'a str是错误的,因为我们不能假设传递给它的值会永远存在。所以,函数对于它的参数类型肯定不能使协变的。

如果我们反过来应用逆变性,就万事大吉了!需要一个函数来处理永远存在的字符串,而我们提供了一个处理有限生命周期字符串的函数,这也是完全合理的。所以,

fn foo(&'a str) -> usize;

可以被传递给需要

fn foo(&'static str) -> usize;

的地方。

fn(T) -> U对于U怎么又是协变的了呢?看看下面这个函数签名:

// 'a来自父作用域
fn foo(usize) -> &'a str;

这个函数声明它将返回一个生命周期长于'a的引用。那么下面这个函数

fn foot(usize) -> &'static str;

用在这里是完全可以的,因为它的的确确返回了一个生命周期长于'a的引用。所以函数对于它的返回值是协变的。

*const&有着完全一样的语义,所以变性也是一样的。*mut正相反,它可以解引用出一个&mut,所以和cell一样,它也是不变的。

以上规则都是针对标准库提供的类型,那么自己定义的类型又如何确定变性呢?简单点说,结构体会继承它的成员的变性。如果结构体Foo有一个成员a,它使用了结构体的泛型参数A,那么Foo对于A的变性就等于a对于A的变性。可如果A被用在了多个成员中:

  • 如果所有用到A的成员都是协变的,那么Foo对于A就是协变的
  • 如果所有用到A的成员都是逆变的,那么Foo对于A也是逆变的
  • 其他的情况,Foo对于A是不变的
use std::cell::Cell;

struct Foo<'a, 'b, A: 'a, B: 'b, C, D, E, F, G, H, In, Out, Mixed> {
    a: &'a A,     // 对于'a和A协变
    b: &'b mut B, // 对于'b协变,对于B不变

    c: *const C,  // 对于C协变
    d: *mut D,    // 对于D不变

    e: E,         // 对于E协变
    f: Vec<F>,    // 对于F协变
    g: Cell<G>,   // 对于G不变

    h1: H,        // 对于H本该是可变的,但是……
    h2: Cell<H>,  // 其实对H是不变的,发生变性冲突的都是不变的

    i: fn(In) -> Out,       // 对于In逆变,对于Out协变

    k1: fn(Mixed) -> usize, // 对于Mix本该是逆变的,但是……
    k2: Mixed,              // 其实对Mixed是不变的,发生变性冲突的都是不变的
}

下一章:Rust Drop检查

我们已经知道生命周期给我们提供了一些很简单的规则,以保证我们永远不会读取悬垂引用。但是,到目前为止我们提到生命周期的长短时,指的都是非严格的关系。也就是说,当我们写'a: 'b的时候,'a其实也可以和'b一样长。乍一 ...