RUST死灵书-子类型和型变
原文地址¶
3.8 Subtyping与Variance¶
Subtyping是类型之间的关系,它能够让强类型语言稍微灵活一些.
RUST中的subtyping和其他语言中的subtyping稍有不同.这样很难给出简单的例子,会使得subtyping variance难以恰当的理解.甚至有时候编译器作者也容易搞混.
为了让事情简单,这节从一个小的语言扩展层面开始,增加一个新的简单的subtyping的关系.等概念和议题建立起来以后,在把它联系到真正的RUST中的subtyping是如何发生的.
下面是一个简单的例子,*Objective Rust,* 增加3个新类型.
与正常的trait不同,我们可以将他们视为具体的有大小的类型,就像struct一样.
现在假设我们有一个很简单的函数,输入一个Animal:
默认情况下,静态类型必须精确匹配,这样程序才能运行.像下面这样是运行不了的
mr_snuggles是一个Cat, Cat不是准确的Animal, 所以我们不能调用love函数. *这很烦躁,因为Cat是Animal. Cat理应支持Animal的所有操作防范.所以直觉上,如果我们传进Cat, love函数应该不管.我们应该忘掉Cat中不是Animal的部分,它也不是love函数关心的.*
这就是subtyping意图修复的点.因为Cat是Animal加更多的东西,我们称Cat是Animal的subtype.(因为Cat是所有Animal的子类型). 同样我们也可以说Animal是Cat的supertype.有了subtype, 我们可以稍微调整一下我们国语严格的静态类型检查系统.把检查规则改成这样:只要需要一个T的值,我们也接受T的subtypes.
具体点就是只要能接受Animal的地方,Cat和Dog都应该可以工作的很好.
我们往后会看到,subtyping其实更加复杂和微妙.当然这个简单的规则能在99%的情况下工作的都很好.除非你要写unsafe的代码,编译器会自动的帮你处理所有的边角情况.
但是这是死灵书,我们正在写unsafe代码,所以我们需要理解事情是如何工作的,以及如何就把事情搞砸了.
核心问题在于,这个规则如果直接草率的使用,会导致猫叫的狗.我们可以说服别人这个Dog其实是一个Cat.这样会彻底破坏了我们静态类型系统的结构.把它变得无济于事.(有可能导致无法定义的行为)
如果我们做了简单的查找替换,那么以下就是个简单的例子描述了这件事是如何发生的
显然,我们需要一个更健壮的系统,而不是简单的查找替换.这个系统就是variance.它就是一套规则来控制subtyping如何组合.更重要的是,variance定义了哪些情况subtyping应该禁用.
在进入variance之前,我们快速的窥视以下subtyping在Rust中是如何实际发生的,生命周期!
生命周期其实就是代码中的一段区域.这个区域可以部分按照包含关系来排序.liftetime层面的sutyping的意思是 如果 'big: 'small('big包含了small, 或者'big 比small活的长), 那么'big是'small的subtype. 这块经常容易搞混,因为它看起来反常,大的区域是小区域的subtype.但是如果你考虑Animal的那个例子就合理了.Cat是Animal加更多,'big 是'small加更多.
从另一个角度说,如果一个人说它需要一个活了'small的引用,他其实的意思是它想要一个至少能活'small的引用.他并不关心具体的生命周期是否精确匹配.所以我们忘记'big只记住它活了'small是ok的.
猫叫的狗的问题从生命周期的角度来说,会导致我们可以将一个短的生命周期的引用存入一个需要长生命周期的地方,会产生一个悬垂指针,也会导致use-after-free的漏洞.
值得注意的是static, 一个永远的生命周期,是所有生命周期的subtype. 因为从定义上来说它超越一切的存活时间.这个关系在后面的例子中会用到,来是事情变得尽可能简单.
说了这么多,我们还是不知道如何使用lifetime的subtype, 因为没有东西具有类型'a. 生命周期只存在于一些更大的结构,例如&'a u32或IterMut<'a, u32>.为了使用lifetime的subtyping.我们需要知道如何组合subtyping. 再一次,我们需要variance.
Variance
Variance是一个让事情变得复杂的点.
variance是一个类型构造器(type constructors)具有的一个属性,它与它的参数相关.Rust中的类型构造器是一个泛型,该泛型的参数是无约束的.例如Vec是一个类型构造器,它接受一个类型T的输入,返回Vec***
一个类型构造器F的variance代表的意思是它的输入在subtyping层面上如何影响它的输出.有三种variance类型.给定Sub和Super, 其中Sub是Super的subtype,则:
- 称F是covariant, 如果F是F***
** *的subtype (subtype直通) - 称F是contravariant, 如果F***
** * 是F的subtype (subtype反转) - 称F是invariant, 其他情况(二者没关系)
如果F有多个类型参数,我们讨论每一个独立的参数对应的variance.例如F
请记住实际中称variance一般指的是covariant.几乎所有的关于variance的讨论都是到底应该是covariant还是invariant.contravariance非常难见到,但其实是存在的.
这有一个重要的variance的表,剩余章节我们会致力于把它讲清楚.
带*号的我们重点关注,他们某种意义上来说更为基础.其他的都可以类比的去理解.
- Vec***
** *和其他拥有pointers和集合的跟Box***** *逻辑类似 - Cell***
** *和其他内部可变性的类型与UnsafeCell***** *逻辑类似 - *const T与&T逻辑类似
- mut T与&mut T(或UnsafeCell*
**)逻辑
其他更多类型,参见Variance节
注意:contravariance的源头只有一个,那就是函数中的参数,它也是为啥实际中不会出现太多.激活contravariance需要高阶编程,并且需要函数指针来引用一个特定的lifetime(并不是任意的lifetime, 也会导致高阶的lifetime, 独立于subtype工作)
好了,理论学够了,让我们来应用variance的概念看些Rust例子.
首先,让我们重看一下meowing doge的例子
如果我们查variance的表,会看到&mut T关于T是invariant的.其实,这就完全修复了这个问题!有了invariance, Cat是Animal的subtype不重要,&mut Cat不再是&mut Animal了.静态检查器正确的阻止了我们讲Cat传入eval_feeder.
这个想法的subtyping的基础是,忘记无关的细节可以.但如果是引用,总有人记住那些细节:那些真正被引用的值里面.这个值期望这些细节确保正确.如果这些期望被违背了,则会表现出不正确的行为.
如果将&mut T与T视为covariant的关系的问题在于,他给与我们修改原始值的权利.但是我们并不记得所有的关于它的约束.这样,我们就可以让某人拥有一个狗,但其实他们还是猫.
当这个建立了以后,我们可以轻松看到,&T与T的covariant是可靠的.它不允许你修改值,只能看它.没有任何办法修改,就没有办法把任何细节搞乱.我也同时也能看到为什么UnsafeCell和其他所有的内部可变的类型必须是invariant: 他们将&T视作&mut T一样.
那么引用上的lifetime又如何呢?为什么它在两种情况下可以视为covariant.当然,这里面有两面性论据.
首先也是最重要的,对引用基于lifetime的subtyping是整个Rust的观点.唯一原因是我们能够将活的时间长的东西传入只需要较短的存活时间的地方,所以最好能工作.
第二,更严谨一点,lifetime只是reference的一部分,reference的类型是一个共享的知识.这就是为什么如果只在一处修改(这个引用的地方)的时候会出问题.但是当你传给别人的时缩短了reference的lifetime,liftetime的共享问题不存在了.这时候出现了两个独立的reference拥有独立的lifetime.没有可能用另外一个把原始reference的lifetime搞乱.
或者更正确点,唯一可以搞乱某人的lifetime的方法是构建一个meowing dog. 但是当你尝试创建meowing dog的时候,lifetime被包装成了invariant type.阻止了lifetime被缩短.为了更好的理解这点,让我们迁移meawing dog问题到真实的Rust中.
在meowing dog问题中,我们接受一个subtype(Cat), 转换成一个supertype(Animal), 然后用这个事实来操作,用满足supertype条件但是不是原始subtype的类型(Dog)来覆盖输入的subtype(Cat)
所以从lifetime的角度说,我们接受一个活得长的东西,转成活的短的东西,然后用它来往里面写点活的也不够长的东西,来覆盖这个原本活得长的地方.
那么如果我们运行它会发生什么?
赞,它没有编译通过!让我们分解一下看看究竟发生了什么?
首先我们看evil_feeder函数:
它干的所有事就是接受一个可变的ref, 和一个值,然后覆盖这个ref.这个函数重要的地方时它创建了类型相等的约束.它清晰地表达出来,在这个函数签名中,ref和value的类型必须严格相等
于此同时,在调用端我们传入了'&mut &'static str 和&'spike_str str
因为&mut T关于T是invariant的,编译器推断它不能对第一个参数做任何subtyping的动作,所以T必须是&'static str.
另一个参数只是&'a str, 它对于'a来说是covariant的.所以编译器应用了这个条件,&'spike_str str必须是&'static str类型的subtype. 也就是说,'spike_str必须包含'static, 但是唯一能包含'static的东西只有它自己! *这就是为什么当我们尝试去讲&spike赋值到spike_str时会得到错误.编译器逆推出来spike_str必须永久的活着,但是&spike不能活那么长.*
所以即使ref对于lifetime是covariant的,但当他们放到一个可以做坏事的环境中时,他们继承了invariance.这个例子中,当我们讲ref放到&mut T中的时候,我们就继承了这个invariance.
结果是,Box(Vec, Hashmap等)为什么covariant就可以,跟lifetime是covaiant类似,只要你尝试者把他么放到类似于可变引用的地方时,他们就继承了invariance,然后你不允许做任何坏事.
然而,从ref传值的角度解释Box的问题会更容易一些.
不同于大部分语言允许值可以别名,Rust有一个非常严格的规则:如果你允许改变或者移动一个值,你必须确保是唯一一个可以访问到它的人.
考虑下面的代码:
这块没有任何问题,我们忘了mr_snuggles是一个Cat或者我们将它覆盖为Dog. 那是因为,当我们将mr_snuggles挪到一个只知道Animal的变量中的时候,我们删除了这个宇宙中唯一记得它是Cat的那个地方.
不同于不可变引用是可靠地covariant的论断,因为它们不允许你修改任何东西.所有权的值可以是covariant的,因为它让你改变一切.旧的点位和新的点位没有任何关系.通过传值的subtyping是一个不可逆的知识析构的动作.然后没有任何记录能记得事情原来是什么样子,这样没有人可以耍花招来操作旧的信息.
最后一个剩下需要解释的:函数指针.
为了看清为什么 fn(T)->U应该关于U是covariant, 考虑下面的签名
这个函数声明他可以产生一个Animal.所以提供一个下面的函数签名实现也是完全合法的
毕竟Cat是Animal, 所以永远产生Cat是一个完全合理的可以产生Animal的方式.或者回归到真实的Rust中.如果我们需要产生一个获得短的东西'short,如果我们产生一个活的长的东西是完全ok的.我们不关心,也完全可以忘记这个事实.
但是同样的逻辑不能适用于函数参数,考虑满足
而使用
第一个函数可以接受Dog, 但是第二个函数绝对不行.Covariance在这里不能工作.但是如果我们调换,实际上是工作的!如果我们需要一个函数能处理Cat, 一个能处理任何Animal的函数当然可以工作.或者联系到真实的Rust: 如果我们需要一个函数可以处理任何至少能活'long的函数,能处理任何获得'short的函数也是可以完美兼容的.
这就是函数类型为什么不像其他类型一样,是关于函数参数contravariant的
现在,这就是对于标准库中提供的类型都讨论好了.但你自己定义的类型怎么决定variance.非正式的说,struct继承它域的variance.如果struct MyType有一个类型参数A, 用在a的域中,那么MyType的关于A的variance等价于a关于A的variance.
但是如果A被用于多个域,则:
- 如果A的使用全是covariant的,那么MyType关于A也是covariant的
- 如果A的使用全是contravariant的,那么MyType关于A也是contraviant的
- 其他情况,MyType关于A invariant