1_V2JkFboEX1ugIJCq3nfpiw 1_B-jHadn2Gg6IAjltosdOug

子类型替换

假设我们有这样一个给水果榨汁的函数:

juicing :: Fruit -> Juice

下列哪种调用是正确的?

1
2
3
juicing Apple
juicing Fruit
juicing Object

根据里氏替换原则(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)

逆变可能没有那么显而易见,先来看一个实现挑选水果的函数:

1
pickFruit :: (Fruit -> Bool) -> [Fruit] -> [Fruit]

pickFruit函数的第一个参数是一个(Fruit -> Bool)函数,我们可以怎么传呢?下面三个参数哪些是合法的:

1
2
3
someAppleFilter  :: Apple  -> Bool
someFruitFilter  :: Fruit  -> Bool
someObjectFilter :: Object -> Bool

可能不太直观,我们来试着实现一下这个pickFruit函数:

1
2
3
4
5
pickFruit :: (Fruit -> Bool) -> [Fruit] -> [Fruit]
pickFruit f []     = []
pickFruit f (x:xs) = if f x
                     then x:(filterFruit f xs)
                     else    filterFruit f xs

即使不熟悉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的第一个参数可以是objectFilterFruitFilter,但是appleFilter却不行,即:

(Object -> Bool) <: (Fruit -> Bool) <: (Apple -> Bool)

之前提到协变和逆变都是容器的属性,当一个容器C :: Type -> Type具有逆变的属性,就意味着:

对于 A <: B,有 CB <: CA

对于上边的例子,函数构造器 -> Bool就是逆变容器(这里的Bool可以替换成人意类型),换句话说:函数类型A -> B在参数A上逆变

函数返回值的属性

我们现在知道了函数类型 A->B在参数A上逆变,那在返回值B上呢?我们延续Fruit的例子,这回我们固定输入参数类型,改变返回值类型:

1
2
3
readApple  :: String -> Apple
readFruit  :: String -> Fruit
readObject :: String -> Object

然后我们定义一个从文件中读取内容的函数:

1
2
getFruitFromFile :: (String -> Fruit) -> FilePath -> Fruit
getFruitFromFile reader path = reader (readFild path)

对于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

一道有趣的面试题

这里有一个有趣的面试题 :

1
2
3
4
5
6
public static void f() {
    String[] a = new String[2];
    Object[] b = a;
    a[0] = "hi";
    b[1] = Integer.valueOf(42);
}

这段代码里面到底哪一行错了?为什么?如果某个 Java 版本能顺利运行这段代码,那么如何让这个错误暴露得更致命一些?

注意这里所谓的「错了」是本质上,原理上的,而不一定是 Java 编译器,IDE 或者运行时报给你的。也就是说,你用的 Java 实现,IDE 都可能是错的,没找对真正错误的地方,或者没告诉你真正的原因。

我们可以利用上面的知识尝试分析一下:

显然: String <: Object

读取操作

数组a和b的读取操作对应于:

1
2
readA :: [String] -> String
readB :: [String] -> Object

由于:

  • 函数类型A->B在其返回值B上协变
  • A <: B <: C,且B在一个函数的协变的位置,则可以将其换成C,但是不能换成A

所以在这里两个读取函数都是合法的。

赋值操作

数组a和b的赋值操作对应于:

1
2
writeA :: String -> [String] -> [String]
writeB :: Object -> [String] -> [String]

由于:

  • 函数类型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的很多地方,也是我们理解类型系统的一个工具。