前言
刚开始接触Scala,各种复杂的features真是有点难理解,特别是逆变和协变,网上其实很多资料都有详细介绍,但看完后总有一种:道理我都懂,但是为什么呢?
的感觉。好吧,自己试一下应该会帮助理解。
先放张图
{% asset_img covariant.png 来自知乎的回答-作者:夏梓耀https://zhihu.com/question/35339328/answer/62632559 %}首先感谢一下这位作者,这张图可以说是很好理解了。根据这张图,当你有一根竹子的时候,你可以把它给任意一个consumer,因为以能量和植物为食的consumer一样也可以食用他们的子类竹子。同时,
以竹子为食的consumer >: 植物为食的consumer >: 能量为食的consumer。但是这里有一个问题,常识上来说(以竹子为食的)熊猫(实际上熊猫是杂食动物,只不过图里面的那个长得像熊猫)应该是草食动物的子类,当我们应用了逆变以后,继承关系完全相反了,这点先按下不提。那么对于协变来说,情况就刚好相反…… 很好理解:一个流浪动物收容所专门接收流浪动物,一个流浪猫收容所专门接收流浪猫,流浪猫 is-a 流浪动物,流浪猫收容所 is-a 流浪动物收容所。
里氏替换原则
为了更好的理解逆变和协变,首先要知道什么是里氏替换原则。参考资料:
根据最后一个链接的栗子,根据常识,当我们需要定义一个正方形类的时候,我们可能希望它继承自长方形类,因为根据我们学过的小学数学,正方形的确是一种特殊的长方形,正方形 is-a 长方形。但是长方形类里有setWidth(x: Int)
和 setHeight(y: Int)
两个方法, which并不适用于正方形。那么这种继承关系违反了里氏替换原则:
Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
为了满足该原则,如第一个链接里所说的:
子类型中的fun函数参数类型必须是父类型中函数参数的超类(至少跟父类型中的参数类型一致),这样才能满足父类中fun方法可以做的事情,子类中fun方法也都可以做。
所以我们需要定义一个逆变的类型参数:
|
|
这样,假设我们有:
a1的func可以接收的类型范围为[ <: Animal], a2的func可以接收的类型范围为[ <: Cat]. 显然,a1的更大,那么所有调用a2.func的地方都可以用a1.func来代替,所以我们可以用a2的子类a1替换掉a2“with out knowing it”.
同时,根据协变的要求,子类的方法的的返回类型应该比父类的方法的返回类型更加具体。现在给A添加一个方法def func2(): T
,这时编译器会报错,因为我们把逆变类型放在了协变点上。更改一下方法:def func2[K :< T](): T
,这时没有问题。因为当我们用子类替代掉父类的时候,子类方法的返回类型不会比父类的返回类型在继承关系树上更高,符合里氏替换原则。
根据OOP和LSP,继承关系是取决于两个class是否有相同的行为,而不是他们是否有相同的属性。
那么对于正方形和长方形来说,长方形应该 is-a 正方形(如果非要有继承关系的话),虽然这违反直觉,但是可以保障我们的类型系统安全。
LSP in Scala
在Scala中,当你要定义一个有泛型参数的类,有协变,逆变和不变3种关系:
|
|
那么什么时候使用逆变或者协变呢?一个大概的通用的原则是:当这个容器类(Container)中的方法把泛型对象当做数据的消费者(我把一些数据塞给你,但是我并不需要知道你怎么处理这些数据)的时候,应该声明为逆变(即使接收数据的是子类,所接收的数据类型的范围也不会变小);当把泛型对象当做数据的提供者(我需要你返回一些数据)的时候,应该声明为协变(子类返回更加具体的数据类型)。当然现实情况是,很多时候我们既需要处理数据也需要返回数据,那么在逆变的情况下如果需要返回数据,应该通过一个上界来保证子类比父类的返回类型更加具体。在协变的情况下,通过下界来保证子类接受的参数比父类的范围要大。
值得注意的是,如果在协变中方法定义了下界使得接收参数范围类型变大以符合逆变点的要求,需要注意这时候方法理论上可以接受下界类型的任意父类型(上至AnyRef),这可能会导致出错。
反过来,如果定义了逆变,那么父类的Container就会变成子类的Container的子类,如果不注意到这一点,很可能也会出错。比如篮球
是运动
的子类,如果我们定义一个逆变的容器类运动员
,带有一个方法play(x: T)
接收任意运动类型,那么运动员[篮球]
是运动员[运动]
的父类,那么篮球运动员和运动员都可以play(篮球)
,而篮球运动员不能play(运动)
而运动员可以。根据LSP,这样的定义是对的。然而这时候如果我们再定义一个篮球队,招募篮球运动员。然而这时候运动员是篮球运动员的子类,我们很可能会不小心招募到一个不会篮球的运动员,gg。