子类型替换
假设我们有这样一个给水果榨汁的函数:
juicing :: Fruit -> Juice
下列哪种调用是正确的?
|
|
根据里氏替换原则(Liskov Substitution principle) ,我们可以知道:
派生类(子类)对象可以在程序中代替其基类(超类)对象
所以前两个调用是正确的。也就是说如果S是T的子类型(写作S <: T
),在应当使用T的地方我们可以安全地使用S,对于上边水果的例子,我们可以写成:
Apple <: Fruit <: Object
协变(Covariance)
协变与逆变是容器的一种属性,这是理解协变与逆变的关键,它不是某个数据的属性,而是封装数据的容器(或者说盒子)的属性。
如果某个容器类型C :: Type -> Type
具有协变的属性,那就意味着:
对于A <: B
,有 CA <: CB
我们熟悉的容器类型,大都是协变容器,比如:
- Maybe a :
juicingMaybeFruit (Just Apple)
是合法的 - List a :
juicingListOfFruit [Banana, Apple]
是合法的
逆变(Contravariance)
逆变可能没有那么显而易见,先来看一个实现挑选水果的函数:
|
|
pickFruit
函数的第一个参数是一个(Fruit -> Bool)
函数,我们可以怎么传呢?下面三个参数哪些是合法的:
|
|
可能不太直观,我们来试着实现一下这个pickFruit
函数:
|
|
即使不熟悉Haskell
也没关系,上边这段代码很简单,就是pickFruit
接受一个过滤函数f和一个list,然后用这个函数f对list中的元素进行过滤。
这里我们要将f作用在list中的每个元素上,也就是说传给f的元素x必须是合法的,反过来说,f需要能接收list中的任何元素,那么:
-
objectFilter :: Object -> Bool
这个函数接收Object,任何一个Fruit元素都可以传给这个函数,所以这个函数是一个合法的f
-
FruitFilter :: Fruit -> Bool
这个函数接收Fruit,任何一个Fruit元素都可以传给这个函数,它也是合法的f
-
appleFilter :: Apple -> Bool
这个函数只接收Apple,其他Fruit就不行了,所以它不合法
因此pickFruit
的第一个参数可以是objectFilter
和FruitFilter
,但是appleFilter
却不行,即:
(Object -> Bool) <: (Fruit -> Bool) <: (Apple -> Bool)
之前提到协变和逆变都是容器的属性,当一个容器C :: Type -> Type
具有逆变的属性,就意味着:
对于 A <: B,有 CB <: CA
对于上边的例子,函数构造器 -> Bool
就是逆变容器(这里的Bool可以替换成人意类型),换句话说:函数类型A -> B
在参数A上逆变。
函数返回值的属性
我们现在知道了函数类型 A->B
在参数A上逆变,那在返回值B上呢?我们延续Fruit的例子,这回我们固定输入参数类型,改变返回值类型:
|
|
然后我们定义一个从文件中读取内容的函数:
|
|
对于getFruitFromFile
函数的第一个reader参数,上边三个readXXX
函数哪个是合法的呢?
-
getFruitFromFile readApp
readApple
将文件中读出的String转换成了Apple,是合法的Fruit -
getFruitFromFile readFruit
readFruit
将文件中读出的String转换成了Fruit,也是合法的Fruit -
getFruitFromFile readObject
readObject
将文件中读出的String转换成了Object,它不一定是Fruit,所以是不合法的
于是可以得到:
(String -> Apple) <: (String -> Fruit) <: (String -> Object)
即:函数类型A->B
在其返回值B上协变
不难发现,如果有 A <: B <: C
,则:
- 若B在一个函数的协变的位置,则可以将其换成C,但是不能换成A
- 若B在一个函数的逆变的位置,则可以将其换成A,但是不能换成C
一道有趣的面试题
这里有一个有趣的面试题 :
|
|
这段代码里面到底哪一行错了?为什么?如果某个 Java 版本能顺利运行这段代码,那么如何让这个错误暴露得更致命一些?
注意这里所谓的「错了」是本质上,原理上的,而不一定是 Java 编译器,IDE 或者运行时报给你的。也就是说,你用的 Java 实现,IDE 都可能是错的,没找对真正错误的地方,或者没告诉你真正的原因。
我们可以利用上面的知识尝试分析一下:
显然: String <: Object
读取操作
数组a和b的读取操作对应于:
|
|
由于:
- 函数类型
A->B
在其返回值B上协变 - 若
A <: B <: C
,且B在一个函数的协变的位置,则可以将其换成C,但是不能换成A
所以在这里两个读取函数都是合法的。
赋值操作
数组a和b的赋值操作对应于:
|
|
由于:
- 函数类型
A -> B
在参数A上逆变 - 若
A <: B <: C
,且B在一个函数的逆变的位置,则可以将其换成A,但是不能换成C
也就是这里的writeB
是不合法的。
可变与不可变
由上边的面试题我们可以引申出另一个问题:我们可以把List<Apple>
传给需要List<Fruit>
的函数吗?
这个答案就比较有趣了 – 不一定:
如果List是不可变的(immutable),那么就可以;如果List是可变的(mutable),就不可以。
为什么?比如我们需要一个List<Fruit>
,然后我们传了一个List<Apple>
,如果List<Apple>
是不可变的,那没问题;但是如果是可变的,我们要的是一个List<Fruit>
,那么在list里insert一个Banana是完全有可能且合理的,这时候就会有问题,类型系统就会报错了。
Conclusion
协变与逆变是非常重要且有用的概念,它被用在FP的很多地方,也是我们理解类型系统的一个工具。