源地址
这玩意儿水还是有一点的,看着 Scala 代码中到处都在飞翔着“+/-”号,挠的你心里痒痒的。
所以花点时间来认真研究一下所谓的 co/contra variance。
sealed abstract class List[+A] {
def head : A
def ::[B >: A] (x:B) : List[B} = ...
...
+A
表示 A
是一个协变体类型参数(covariant type parameter)。要知道就算你知道了这个术语,仍然是一头雾水。所以往下看。
List
是一个泛型(generic)类型。就是说,你可以有很多不同的 List
类型——可以 List[Int]
可以 List[MyClass]
等等等等。换句话说呢,List[_]
就是一个类型构造器(type constructor),如同函数那样接受另一个具体的类型并产出一个新的类型。所以,如果你已经拥有了一个类型 X
,那么你就可以使用 List
类型构造器来产生一个新的类型,List[X]
。
看一点点范畴论
为了从本质上了解这个裤裤的东西,我们需要从范畴作为起点来讨论。不用怕这里我们并不会涉及太过吓人的范畴论的结果。范畴 C
就是一些对象和一些箭头(称作是“函数”)。箭头从一个对象指向另一个对象,对于范畴来说唯一的要求就是你会对箭头有一些二元操作(通常叫做“复合”),这样会产生新的箭头从正确的地方出发到另一个该去的地方;并且对每个对象都有一个“自指”箭头。[1] 对范畴我们最感兴趣的其实是类型范畴:类型就是 Int
、Person
和 Map[Foo, Bar]
这些东东,而箭头就是那些函数。
另一个需要的概念是函子(functor)。函子 F: C->D
是范畴之间的映射。不过,我们可以拥有范畴到自身的函子——endofunctors,这些就是我们感兴趣的东西。函子必须将源范畴的对象转换为目标范畴的对象,同样他们还需要将箭头转换为新的箭头。还有,函子必须遵守某些规则,这里不要太过关心。[2]
好了,谁最关心函子呢?答案是类型构造器是在类型的范畴上的基本的函子。他们将类型(我们关心的对象)转换为其他的类型:检查一下!但是那些箭头(也就是那些函数)呢。函子不是同样需要将这些箭头进行映射么?是的,这也是必须的,但是在 Scala 中我们不会叫来自 List
函子的函数为 List[f]
,我们称之为 map(f)
。[3]
还没有到正文!!!好好好,最后还有一个真的很相关的概念要提一下。范畴间的一些映射看起来非常像函子,除了他们会反置箭头的方向。所以不会得到 F(f): FX->FY。这样的东东有个神奇的名字,逆变 (contravariant) 函子。为了区分他们,正常的函子被称为协变 (covariant) 函子。
看看我们要的东西出来了吧。但是逆变函子和 Scala 究竟什么关系呢?
好问题。
子类型
Scala 关键特性就是拥有子类型。类(类型)可以是其他类(类型)的子类型或者超类型。这给了我们一个关于类层次结构的想法。从数学上看看这个结构,我们可以给出一个类型之间的偏序关系 <:
。这里就出现了一个范畴论的第一技巧:我们可以将任何偏序集看做一个范畴!对象就是对象,如果 A <:B
那么 A -> B
就有一个箭头。这有点奇怪,因为我们只会在对象间产生一个箭头,他们并不再是函数,但是其他的形式化结果仍然成立。[4]
现在某些类型构造器仍旧看起来像函子。他们将对象映射到其他的对象上,如果这些对象中的一个是另一个的子类型,那么他们可能会也可能不会产生映射的对象之间的关系。
这里就是 Scala 类型注释出现的地方。当我们声明 List[+A]
,就表示 List 由参数 A
covariant 的。[5] List
会接受一个类型比如说 Parent
得到一个新的类型 List[Parent]
,如果 Child
是 Parent
的子类型,那么 List[Child]
将会是 List[Parent]
的子类型。如果我们声明 List
是 contravariant 的 List[-A]
,那么 List[Child]
会成为 List[Parent]
超类型。
还剩下一个可能性。因为子类型是偏序,我们会遇到两个类型互不为子类型关系。原则上,一个类型构造器 T 不能够以 Parent 和 Child 产生新的完全没有关系的类型是没有道理的。在 Scala 中,可能会有一种情况就是你没有在声明中给出类型的注解;这样的构造器就是由那个参数 invariant。例如 Array 就有这个特性。
所以这就是根本上的结论。也就是这些符号 +/- 对类型参数的作用。现在你应该懂了吧。
class GParent
class Parent extends GParent
class Child extends Parent
class Box[+A]
class Box2[-A]
def foo(x : Box[Parent]) : Box[Parent] = identity(x)
def bar(x : Box2[Parent]) : Box2[Parent] = identity(x)
foo(new Box[Child]) // success
foo(new Box[GParent]) // type error
bar(new Box2[Child]) // type error
bar(new Box2[GParent]) // success
但是这些神秘的破玩意儿又是啥?
class Box[+A] {
def set(x : A) : Box[A]
} // won't compile
Scala 中的这些错误其实是变体和函数(或者说,方法)的关联的微妙之处。我们可以看到 Function trait 的声明其实有些奇怪的地方:
trait Function1[-T1, +R] {
def apply(t : T1) : R
...
}
这实际上非常奇怪。不仅是因为这个 Function1 trait 有两个类型参数,其中一个还是 contravariant 的。真怪异。让我们来仔细看看这个。
我们有函数 Function1[A,B],这是一种从类型 A 映射到类型 B 一元参数函数的类型。因此,这可以是其他(函数)类型的子类型或者超类型。例如,
Function1[GParent, Child] <: Function1[Parent, Parent]
我怎么知道这个结果的呢?因为在函数 Function1 的变体注解给出这个答案。第一个参数是 contravariant 的,所以可以向上变换,第二个参数是 covariant 的,所以就可以向下变换。
Function1 这样的行为有点微妙,但是如果你思考一下在有子类型的时候需要有这样的替换的话,也是有道理的。如果你有一个函数从 A 映射到 B,你可以进行什么样的替换呢?这里任何可以放置的东西都必须给出最少的输入类型要求;因为例如,如果函数必须调用仅仅存在在 A 类型的子类型上的方法。另外,它必须返回一个最少和 B 类型相容的类型,因为函数的调用者可能会需要 B 上的所有方法都可以调用。
Function Functors
这里实际上是范畴论中的一个关于为何要这样设计的有意思的结果。一般来说,对任何的范畴 C 我们同样可以构造一个 C 的 Hom-set 的范畴。在这些集合中的函数就会变成更高阶的函数可以将函数转换成不同的函数。所以,这就引出来一个自然的函子,Hom(-, -) 以两个对象 A 和 B 为输入,输出 Hom(A, B)。这个 Hom-函子 特别之处是 bifunctor:两个参数。最简单的处理方式是部分作用 (partially apply) 然后观察其在每个参数上作用的行为。
所以 Hom(A, -) 以对象 B 为输入,映射到从 A 到 B 的函数集合上。如何在函数上进行作用呢?如果我们有一个同态 f:B -> B' 我们需要一个函数 Hom(A, f): Hom(A, B) -> Hom(A, B')。其定义如下:
Hom(A, f)(g) = f . g
首先作用 g 得到 A 到 B,然后 f 从 B 到 B'。所以 Hom(A, -) 行为就像 covariant 函子。
另一方面,如果你尝试并做出 Hom(-, B) 为一个 covariant 函子,祝你好运!类型并不会直接连接起来就可以工作。实际上是按照下面的方式进行的:
Hom(f, B)(g) = g . f
这里 g 在 Hom(B', B) 中,而不是 Hom(A, B)。所以 Hom(-, B) 实际上像一个 contravariant 的函子。[6] 这使得 Hom(A, B) 是按照 A contravariant 而按照 B covariant —— 就像 Function1 中那样![7]
这实际上是一个更加一般的结果,因为这可以用在任何的范畴上,并不仅仅是拥有子类型的类型的范畴上。cool!
回到实际问题
所以 Scala 中的函数有这些奇怪的变体属性。但是从一个理论角度看,方法实际上就是函数,所以会有同样的变体属性,即使我们还没有看到这些具体的例子都能知道(在 Scala 中方法 method 并没有一个 trait)
所以我们现在可以看到为什么我们会得到这样的奇怪的编译错误。我们声明了 A 在这个类中是 covariant 的,并且还使用了类型 A 的参数。但是,对某些 B <: A,我们可以使用 Box[B] 的实例来替换 Box[A] 的实例,因此也能够用 Box[B].set(x) 来替换 Box[A].set(x),其中 x 是 B 类型的。但是 set[A] 并不能用 set[B] 作为参数替换,因为上面的原因;最好是 contravariant 的。所以这回允许我们做到本不该做到的事情。类似地,如果我们声明 A 是 contravariant 的,那么我们可能会得到跟集合的返回类型矛盾的境况。所以看起来我们需要将 A 设置为 invariant 的。
另外,这其实是为何 Java 的 array 是 covariant 的绝对不好的原因。这意味着你可以写出下面的代码:
Integer[] ints = [1,2]
Object[] objs = ints
objs[0] = "I'm an integer!"
这样写编译能够通过,但是在运行时会抛出 ArrayStoreException。很好!
实际上,我们不需要让包含有类似 append 方法的 container 类型 invariant。Scala 同样让我们对这些进行类型绑定。所以如果我们将 Box 改成下面这样:
class BoundedBox[+A] {
set[B >: A](x:B) : Box[B]
}
就可以进行编译了。这样也确保 set 方法的输入类型是正确地 contravariant 的。
这就是所有的讲解。对 Scala 需要记住的一点是:任何东西都是一个方法。所以如果你弄出来什么特别的变体错误,可能是哪里的特别的方法要加上一个 lower 类型绑定(lower bound)。
-
In full, the requirements are:A class of objects: CFor every pair of objects, a class of morphisms between them: Hom(A, B)A binary operation . : Hom(B, C) x Hom(A, B) -> Hom(A, C), which is associative and has the identity morphism as its identity. ↩
-
These are:F(id{X}) = id{FX}F(f.g) = F(f).F(g) ↩
-
The astute reader will have noticed that not all type constructors come with a map function. This does indeed mean that not all type constructors are functors. But pretend that they are for now. ↩
-
Crucially, we can use the relation to give us our arrows because it’s transitive, and hence composition will work properly. ↩
-
Yes, there can be more than one parameter. Don’t worry about it for now. ↩
-
If you’re wondering whether there couldn’t be some other way of mapping the functions that would work, it turns out that there can’t be one that also makes the functor laws work. You can try it yourself if you don’t believe me! ↩
-
We actually need to do a little bit more work to show that Hom(-, -) is a true bifunctor (functor on the product category), but it’s not terribly interesting. ↩