【Rust】Rust中的子类型机制(Subtyping)以及型变(Variance)


Rust中的子类型机制(Subtyping)以及型变(Variance)

原文链接https://doc.rust-lang.org/nomicon/subtyping.html

最近正在学习Rust语言的一些相关特性,读到一篇关于lifetime并且比较难理解的文档,所以静下心来好好梳理了一遍,最后把其中比较重要的内容整理成博客发表在这里。

子类型机制(Subtyping)

Subtyping的存在是为了使得编写静态类型的语言时可以享受相对较高的灵活性和自由度,由于Rust中不存在继承这一概念,所以我们很难直观地给出一个Rust中Subtype的定义。

原文中借助了一些extension来进行说明,这里直接引用原文中的例子,首先考虑如下三个trait:

trait Animal {
   fn snuggle(&self);
   fn eat(&mut self);
}

trait Cat: Animal {
   fn meow(&self);
}

trait Dog: Animal {
   fn bark(&self);
}

需要注意的是,尽管从逻辑的角度来讲,Dog和Cat作为trait应该是Animal的子类型,并且这种冒号的语法特征很容易让人回想起C++中关于继承的定义,但我们应该始终牢记Rust中并没有继承这一概念,Dog和Cat实际上并没有从Animal中获得任何的信息,这种语法更多的是对开发人员的一种约束,它要求开发人员在实现Dog或Cat之前必须先为该类型实现Animal。

随后我们考虑如下函数:

fn love(pet: Animal) {
   pet.snuggle();
}

这里pet:Animal这种声明参数的方式无法通过Rust编译器的检查,因为Trait并不是一个具体的实例,他只有在作为Trait Object时才能作为参数传入一个函数中。但为了更好地简介地说明这个例子,我们假设Rust中允许Trait作为一个单独的实例存在。

根据Rust语言对类型的限制,这个函数中只接受Animal作为参数,但是这显然是一种违反直觉的做法,既然Cat和Dog是Animal的一种,那么他们显然可以"被love",我们没有理由也没有必要为了Animal、Cat和Dog分别实现各自版本的love函数。所以Subtyping机制的存在实际上就是允许我们将parameter的子类型作为argument传入函数中,这样可以大大优化代码的复用能力。

原文中将Rust中的Subtyping机制总结为如下一句话:

Anywhere a value of type T is expected, we will also accept values that are subtypes of T.

尽管可能存在一些额外的情况,但对于编写Safe Rust的程序员而言,这一原则适用于绝大部分的场景。

但对于Unsafe Rust而言,情况会变得尤为复杂,许多错误也随之而来。

如下是一个Meowing Dogs的案例:

fn evil_feeder(pet: &mut Animal) {
   let spike: Dog = ...;

   // `pet` is an Animal, and Dog is a subtype of Animal,
   // so this should be fine, right..?
   *pet spike;
}

fn main() {
   let mut mr_snuggles: Cat = ...;
   evil_feeder(&mut mr_snuggles);  // Replaces mr_snuggles with a Dog
   mr_snuggles.meow();             // OH NO, MEOWING DOG!
}

在上述的pseudo-code中,在先前提到的约束条件下,evil_feeder函数被允许接受Cat类型(Animal的子类型)作为参数传入函数中。而在函数的内部逻辑中,Cat被视为和Animal同等的存在,于是,将一个Dog类型赋值给Animal变量的操作也将被允许。这样做的最终结果即是我们得到了一只会喵喵叫的狗。

上述案例尽管相对很直观,但其实这种问题永远不会发生在Rust实际的编写过程中,因为Trait并不可以被视为一个单独的实例看待。事实上,Subtyping机制在Rust中真正的应用场景是在lifetime中,由于lifetime这一概念本身并不直观,所以原文借助先前的例子作为一个引入。

接下来我们对上述Meowing Dog问题的讨论将全部基于lifetime展开。

但在此之前,我们需要先澄清lifetime是基于何种方式反应Subtyping的思想的:概括地来讲,lifetime实际上就是一块一块的代码区域,并且这些区域之间存在着偏序关系,如果引用big的lifetime完全覆盖了引用small,那么我们说big比small活得更长(a outlives b),或者说,big是small的一个subtype。对于一些刚刚接触lifetimesubtype的novice来说这种定义方式多少有一些counterintuitive。从如下的角度来思考可能更有助于理解一些:猫的定义等于动物的定义附加一些额外的信息,那么同样的,引用big也是引用small附加上额外lifetime后的结果。

换一种说法,当我们的函数想要一个存活期为small的引用作为参数时,接受一个存活期为big的引用作为参数显然是可以接受的。二者的lifetime并不需要完全匹配。

于是对于lifetime而言,Meowing Dog问题就转变成了试图向一个存活期长的引用中存入一个存活期短的引用,进而导致悬挂指针等问题的出现。

此外,还需要要特别注明一点,由于static可以存活于整个程序运行过程的上下文中,所以lifetime为static的引用是任何引用的subtype

在接下来的部分我们将探讨Rust中用于处理上述问题的机制:Variance

型变(Variance)

Variance在Rust中的作用即在于规定了一套限制类型与类型间是否存在subtype关系的评判标准,从而限制了subtyping机制的适用场景。

所以简单来说,Variancesubtyping不同,后者是作用于lifetime的机制,而前者是作用于type constructor的机制。所谓type constructor就是可以被赋予任何类型的泛型,如Vec<T>, &, &mut等,其中对于&&mut来说,不仅接受类型T,也可以接受lifetime,在接下来的讨论中,我们将以F<T>来表示一个type constructor

一个type constructorvariance解释了其输入的subtyping机制是如何影响输出的subtyping机制的。

Rust中一共有三种variance,假设SubSupersubtype,那么:

  • 如果F<Sub>仍然是F<Super>subtype,那么F服从covariant(协变),即subtyping机制被传递了。

  • 如果F<Super>F<Sub>subtype,那么F服从contravariant(逆变),即subtyping机制被逆转了。

  • 在其他的情况下则Finvariant(不变),即subtypying机制不复存在。

如果一个F接受多个类型作为参数,如F<U, V>, 则我们可以对每一个类型单独讨论,如F<U, V>对于U服从协变,对于V不变。

事实上,我们完全可以把covariance直接视为的variance,因为covarianceinvariance已经覆盖了Rust中绝大多数的场景,我们只会在很有限的情况下遇到contravariance

如下是常见的一些型变情况,我们将会在接下来的介绍中涉及到其中标注了*号的部分:

在进入介绍部分之前,让我们先回顾一下Meowing Dog问题:

fn evil_feeder(pet: &mut Animal) {
   let spike: Dog = ...;

   // `pet` is an Animal, and Dog is a subtype of Animal,
   // so this should be fine, right..?
   *pet = spike;
}

fn main() {
   let mut mr_snuggles: Cat = ...;
   evil_feeder(&mut mr_snuggles);  // Replaces mr_snuggles with a Dog
   mr_snuggles.meow();             // OH NO, MEOWING DOG!
}

结合先前的表格再来讨论evil_feeder函数,我们会发现&mut T对于T来说是不变的。即&mut Cat&mut Animal之间并不存在我们先前所假定的subtype关系,如此一来Meowing Dog的问题就解决了,静态类型检查器将阻止我们将&mut mr_snuggles作为参数传入evil_feeder中。

subtyping机制的优越性在于对于某些特定的问题,我们可以忽略一些不必要的细节(如忽略一个Animal作为Cat的细节)。但在引用的场景下,即使我们只引用了一部分细节,所有的细节却始终被被引用对象所持有着,所以必须保证被引用对象所维护的那些对于本次引用而言“不重要的细节”不会被篡改。具体来说,在evil_feeder中,参数Animal作为Cat的细节被忽略了,该函数同样也不会关心这个Animal已经被加入了Dog的细节。然而,被引用的对象mr_snuggles始终相信自己所持有的是Cat,于是错误发生了。

这也正是Rust将%mut TT的型变定义为invariance的原因,对待一个可以被修改的的对象的时候,我们总是需要十分小心,因为&mut Sub所关注的仅仅是一部分的细节,但它却拥有着篡改所有来自&mut Super的细节的权限。

在此基础上,Rust将&T定义为covariance的理由也变得显而易见:&T没有修改细节的权限,自然也不会导致被引用者持有的细节遭到破坏。而UnsafeCell一类的内部可变数据类型也因为类似的原因被定义为invariance

搞清楚如上的概念以后,我们就可以把所有的注意力放在lifetime上,即Rust中真正实现了subtyping机制的对象上。

Subtyping对于lifetime的优化主要体现在如下两个方面:

首先,基于lifetimesubtyping是Rust中唯一实现的subtyping,这一种subtyping使得Rust程序员可以将存活期长的引用传递给一个存活期较短的引用,从而优化了代码的复用性。

第二,lifetimes属性只属于某一个特定的引用,对于同一数据的不同引用可以有不同的lifetime。而被引用对象所持有的是一些被这些引用所共享的信息,这也导致了对于某一引用的修改会带来一系列的问题。但如果subtyping机制传递了了某一次引用到另一个不同lifetime的引用中是不会对传递前的引用产生任何影响的。这将会是两次互相独立的引用。

但凡事都有例外,Meowing Dog问题的存在使得传入前的引用的lifetime被缩短了,所以接下来就让我们讨论lifetime下的Meowing Dog问题是如何产生的。

在猫和狗的例子中,我们接受了一个subtype(Cat)作为参数,并把它转换为了一个supertype(Animal),随后使用一个同为AnimalSubtypeDog覆盖了原先的Cat,而这一切行为在我们最初所讨论的规则下都是合法的。

于是在lifetime的例子中,我们将会接受一个存活期较长的argument,并把它转化为一个存活期较短的parameter传入函数中,随后传给这个新的parameter一个没有原先的argument那么长存活期的引用,代码实现如下所示:

fn evil_feeder<T>(input: &mut T, val: T) {
   *input = val;
}

fn main() {
   let mut mr_snuggles: &'static str "meow! :3";  // mr. snuggles forever!!
  {
       let spike = String::from("bark! >:V");
       let spike_str: &str = &spike;                // Only lives for the block
       evil_feeder(&mut mr_snuggles, spike_str);    // EVIL!
  }
   println!("{}", mr_snuggles);                     // Use after free?
}

如上的代码显然是无法通过编译检查的,错误信息如下所示:

error[E0597]: `spike` does not live long enough
--> src/main.rs:9:32
  |
9 |         let spike_str: &str = &spike;
  |                               ^^^^^ borrowed value does not live long enough
10 |         evil_feeder(&mut mr_snuggles, spike_str);
11 |     }
  |     - borrowed value only lives until here
  |
  = note: borrowed value must be valid for the static lifetime...

而我们讨论的重点在于编译器是如何做出编译不通过的判断的。

首先让我们考虑evil_feeder函数的签名:

fn evil_feeder<T>(input: &mut T, val: T) {
   *input = val;
}

函数的参数列表告诉我们,它只接受两个相同类型的数据作为参数,但在main函数中,我们传入了&mut &'static str&'spike_str str作为argument,需要注意的是,在这里&'static str'是作为一个完整的类型来看待的,所以它适用于&mut T中对T的型变规则,并且,此时该泛型函数的T已经被monomorphizeevil_feeder<&'static str>(关于monomorphization的内容请参考https://doc.rust-lang.org/stable/book/ch10-01-syntax.html#performance-of-code-using-generics)。所以此时val的类型也应该为‘static str,而根据subtypingvariance机制,此时val只能接受&'static str或其subtype作为参数。于是我们找到了错误的来源!

正如我们先前提到的那样,&'static str是所有&str类型的subtype,所以除非我们传入的第二个argument同样具有'staticlifetime,否则就违背了&'a T的协变机制,因此编译失败。

另外,对于BoxHashmapVec等类型来说,设计为协变并不会造成严重的错误,因为每当这些类型被放在一个可能带来危险的上下文中时,他们会天生地“继承”invariance,如进行一次&mut Vec<T>时。

最后,对于函数指针类型fn(T) -> U,情况变得有一些不一样:

首先,对于函数的返回值U来说,满足协变条件是很容易理解的,如果我们希望一个函数返回一个动物,那么我们并不在意它返回的是猫还是狗,但对于函数的参数来说,原文中使用了如下的类比来进行说明:

If we need a function that can handle Cats, a function that can handle any Animal will surely work fine. Or to relate it back to real Rust: if we need a function that can handle anything that lives for at least 'long, it's perfectly fine for it to be able to handle anything that lives for at least 'short.


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM