Freewind @ Thoughtworks scala java javascript dart 工具 编程实践 月结 math python english [comments admin] [feed]

(2014-05-02) Scala类型系统之:来自星星的猫

广告: 云梯:翻墙vpn (省10元) 土行孙:科研用户翻墙http proxy (有优惠)

注:本文是我这段时间学习Scala类型系统时的一些所得,可能有很多地方不严谨或者不正确,欢迎指出。在些期间,得到了老猪、大魔头、教主等多位群友,以及无敌的stackoverflow的帮助,特此感谢!

在«Scala in programming»这本书中,关于协变、逆变这一章,藏着一只非常奇特的猫,我叫它“来自星星的猫”。

下面是原书内容,见“Scala编程”第19章“参数类型化”第258页:

下面是个显得有点生编硬造的例子,我们考虑如下的类型定义,其中若干位置的变化型被标了+(正的)或-(负的):

abstract class Cat[-T, +U] {

  def meow[W-](volue: T-, listener: Cat[U+, T-]-) :Cat[Cat[U+,T-]-, U+]+

}

类型参数W,以及两个值参数,volume和listener的位置都是负的。注意meow的结果类型,第一个Cat[U,T]参数的位置是负的,因为Cat的第一个类型参数T被标了-号。这个参数中的类型U重新转为正的位置(两次翻转),而参数中的类型T仍然是负的位置。

从讨论中你可以发现想要跟上变化型位置的变化是非常困难的。因此Scala编译器自动帮你完成这项工作,从而减轻了你的负担。

计算变化型的过程中,编译器检查每个类型参数是否仅用于分类正确的位置。这个例子中,T仅用在负的位置上,而U仅用于正的位置上。因此Cat类是类型正确的。

​本文的目的,就是要看懂这只猫。

类型位置

在一个类(class/triat)及方法中,有一些位置可以声明或使用类型,比如,以这只简单的猫为例:

trait Cat[A] {
  def meow[B](volume: C): D
}

如上所示,共有4个位置是跟类型相关。其中A、B处可以声明类型,C、D处可以使用类型。

有一些位置有正负之分,比如B、C处是负的,D是正的。还有一些是中立的,在这里没有体现出来。

在这里需要强调一下:如果下文里提到“A位置”、“B位置”、“C位置”、“D位置”,如果上下文不顺畅,那么就是指这里的这四个位置。

正负是什么意思

书上提到了类型位置的正负,但是没有给它一个准确的定义,只是大概说了一下:“负”的位置只能用逆变,“正”的位置只能用“协变”。我查阅了很多资料,也问了很多人,都没有得到满意的答案。

以下是我自己的理解,可能对也可能不对,请小心阅读:

“类型”是我们的程序与编译器交流沟通的工具,它就像一些神秘的指令,我们利用它向编译器告诉我们的意图,编译器会试着理解它,生成更高效的代码,同时找出可能存在错误或矛盾的地方。

我们的“与类型相关的意图”反映在代码层面(类、方法),可以分为这三种:

这三种意图互相独立,并且当类型不是“无变”时,对类型的变化型还有不同的要求。我认为,如果一个位置是为了“对外提供”而声明和使用类型的,则是“正”的;如果是为了“处理”而声明或使用的,则为“负”的。“正”与“负”只是为了对两种意图进行区分和表达的。

这样D处就是正的,C处就是负的。而B处声明的类型通常用于C处,所以它也是负的。而A处声明的参数,并不确定是用在哪个地方,所以它的正负需要看具体情况而定。

变化型

我们知道,在scala中有三种变化型,分别是无变(invariant),协变(covariant),和逆变(contravariant)。

其中无变的情况比较特殊。如果一个参数被声明为无变,则它可以无视类型位置的正负,出现在任何地方。

而“协变”和“逆变”的目的在于,用于让编译检查:当泛型对象被看作它原本类型的父类型时,它的原有功能(“向外提供”或“处理外界传入数据”)的类型要求是否被破坏。

上下界

当以一个协变或逆变的类型作为上下界来声明一个新类型时,需要注意,新类型本身是“无变”的。比如:

trait Cat[+A] {
  def meow[B >: A](x: B): B
}

trait Cat[-A] {
  def meow[B <: A](x: B): B
}

上面的代码是可以通过编译的。这里的定义出来的B类型本身并不是逆变的,它实际上是“无变”的,只是它依赖于另一个协变或者逆变的A

而无变的类型可以出现在任何位置,所以它可以放在参数和返回值位置。

再来个例子:

trait Cat[-A, T>:A] {
  def meow(x: T): T
}

这里通过下界声明了一个类型T,它可以同时用于方法参数和返回值处,说明它也是无变的。

如果我们给它加上变化型:

trait Cat[-A, +T>:A] {
  def meow(x: T): T  // !!! can't compile
}

它会报错说T是协变的,不能出现在参数位置那里(因为那里是负位置)

参数与返回值

上面那只奇特的猫,实际上是很复杂的。它成功的把很多问题和难点,以夸张的形式组合在了一起,让人很难一下子看懂。所以我们要减化问题,从易到难,一点点剖析它。

先以这只简化的猫为例:

trait Cat[-C, +D] {
     def meow(volume: C): D
}

我们要回答两个问题:

再次强调,本文的讨论都是基于各类型是协变或者逆变的前提,不考虑“无变”的情况。

返回值必须是协变的

在下面更加简化的代码里,我们定义了一个协变的类型T,它被用于返回值。

trait Cat[+T] {
    def meow: T
}

它是可以通过编译的。但是如果T是逆变的,就无法通过编译:

trait Cat[-T] {
    def meow:T   // !! can't be compiled
}

会提示错误:

error: contravariant type T occurs in covariant position in type => T of method meow
           def meow:T
               ^

这是为什么呢?

如果返回值是协变的

首先看这只协变猫Cat[+T]

trait Cat[+T] {
    def meow: T
}

val child = new Cat[String] {
     def meow:String = "miaomiao~~"
}
val parent: Cat[Any] = child

由于T是协变的,所以Cat[String]Cat[Any]的子类型,所以上面的赋值是成立的。

然后取返回值:

val voice:Any = parent.meow

由于parent的类型是Cat[Any],所以在编译器看来,parent.meow返回值是Any。同时不要忘了,parent指向的是child实例,所以voice的值实际上是child.meow返回的miaomiao~~这个String。

由于String实例总是Any类型的,所以不会有任何编译错误。

如果返回值是逆变的

那么是只逆变猫Cat[-T]呢?

trait Cat[-T] {
    def meow: T
}

根据逆变的定义,我们可以写出下面的赋值:

val child = new Cat[Any] {
     def meow:Any = 123
}
val parent: Cat[String] = child

然后对返回值赋值:

val voice:String = parent.meow  // error !!!

问题出来了。

由于parentCat[String]类型,所以我们可以期望parent.meow的返回值也是String类型。但是不要忘了,parent指向的是child这个实例,而它的meow返回值是123这个Integer,显然不能赋给String。

编译器帮我们检查到了错误,所以这段代码不能通过编译。

综合起来就是,方法的返回值必须是协变的,其位置是正的。

参数必须是逆变的

再来定义两只带参数的猫。

 trait Cat[+T] {
     def meow(voice: T) // !!! can't compile
}

trait Cat[-T] {
     def meow(voice: T)
}

第一只是协变的,第二只是逆变的,为了简单,只定义了参数部分。其中前者是无法通过编译的,后者没有问题。

如果参数是协变的

如果这是一只协变猫,让我们跟前面一样,来个转型赋值:

trait Cat[+T] {
     def meow(voice: T)
}

val child = new Cat[String] {
     def meow(voice: String) { println(voice.toLowerCase) }
}
val parent: Cat[Any] = child

根据协变的定义,上面的赋值是正确的。

然后传参数:

parent.meow(123)  // error !!!

由于parent的类型是Cat[Any],所以它的meow方法的类型是这样的:

def meow(voice: Any) 

因为参数类型是Any,所以我们可以向它传入一个Integer。但是不要忘了,parent引用的实际上是child,而child的meow只能接受String类型,所以这个调用会报错。

编译器帮我们检查到了这个错误,所以这段代码不能编译。

如果参数是逆变的

如果这是一只逆变猫,根据逆变的定义,我们可以写出下面的赋值:

trait Cat[-T] {
     def meow(voice: T)
}

val child = new Cat[Any] {
     def meow(voice: Any) { println(voice) }
}
val parent: Cat[String] = child

由于parent的类型是Cat[String],所以它对应的meow方法的类型是这样的:

def meow(voice: String)

所以我们可以传个String进去:

parent.meow("miaomiao~~")

会不会出问题呢?由于parent引用的实际上是child对象,而child.meow方法可以授受一个Any类型的参数,所以这个调用是没有任何问题的。

综上所述,参数类型必须是逆变的,参数类型位置是负的。

方法中声明的参数类型

这里要说的是:

trait Cat[A] {
     def meow[B](volume: C): D
}

中的B位置,即我们在一个单独的方法上声明的类型。由于在这里定义的类型通常是给参数使用的,所以这个位置是负的。

一个负位置,可以接受“逆变”和“无变”的类型。在这里,我们没有办法声明一个“逆变”的类型,下面的代码是不能通过编译的:

trait Cat[A] {
     def meow[-B]
}

会报语法错误。那么我们只能声明“无变”类型的。

最简单的方式是声明一个简单类型,比如

trait Cat[+A] {
     def meow[B](x:B)
}

这里的B跟协变参数A之间没有任何关系,不管Cat对象如何向上转型,meow都可以接受任意类型的参数。

实际上这种定义没多少用,因为B可以是任意类型,所以我们在meow里,只能把它当作Any来看待。所以更多的情况是以A作为上界或下界,对新类型进行限定。

由于A可能是协变、也可能是逆变,而B可能以A为上界、也可能以A为下界,所以就会有下面四种情况:

trait Cat1[+A] {
  def meow[B >: A](x: B)
}
trait Cat2[+A] {
  def meow[B <: A](x: B)  // can't compile
}
trait Cat3[-A] {
  def meow[B >: A](x: B)  // can't compile
}
trait Cat4[-A] {
  def meow[B <: A](x: B)
}

上面这四种情况,只有Cat1Cat4是可以通过编译的,而Cat2Cat3不能。为了解释这一点,我们需要知道三点:

  1. B位置是负的
  2. 当使用下界符号>:时,会翻转右边类型位置的正负
  3. 当使用上界符号<:时,不会翻转右边类型位置的正负

后面两点后面会讨论,这里先知道这个结果就可以了。

结合这三点,我们就可以得出结论,只有Cat1Cat4是符合要求的。

我们以Cat3为例,看看违反以后,会出现什么问题。

trait Cat3[-A] {
  def meow[B >: A](x: B)  // can't compile
}

val child:Cat3[Any] = new Cat3[Any] {
  def meow[B >: Any](x: B) = x
}
val parent: Cat3[String] = child

然后我们调用:

parent.meow("miaomiao~~")

这时会出现错误,因为parent实际上指向的是child,所以我们向child.meow()传入了一个String类型的参数。而child的meow的类型是:

  def meow[B >: Any](x: B) = x

意思是说,“我只能处理Any类型的父类”,但是传过来的String是Any的子类,无法处理。

为什么下界会翻转正负

先看这只逆变猫:

trait Cat[-A] {
  def meow(a: A)
}

当我们把参数类型指明为A时,我们隐藏的意思是说:meow可以接受A的任意子类型。

与下面代码等价:

trait Cat[-A] {
  def meow[B <: A](a: B)
}

这段代码可以这么理解:如果我有一个对象是Cat[Any],那么它的meow实际处理能力是所有Any的子类。同时因为A是逆变的,所以它可以看作是Cat[String]的子类,那么展示出来的meow就变成了meow[B:<String],宣称自己只能够处理所有String的子类。由于宣称的处理能力比实际处理能力弱,所以可以轻松处理传入的各String子类对象,不会有任何问题。

如果A是协变的,即:

trait Cat[+A] {
  def meow[B >: A](a: B)
}

此时对于给定的类型Ameow只能处理它的父类型。如果我有一个对象是Cat[String],那么meow的实际处理能力就是“所有String的父类型”。同时因为A是协变的,所以它可以看作是Cat[Any]的子类,所以宣称的处理能力就变成了“所有Any的父类型”。由于宣称的处理能力比实际处理能力弱,所以也不会有问题。

对于负位置来说,它定义的类型是用于参数,让方法“处理输入”的。而下界符号>:会把类型的处理能力由默认的“处理子类”变成“处理父类”,这两个正好是相反的,所以会翻转位置的正负。

所以,如果某个位置[X >: Y]是“正”的,那么Y所在的位置就是“负”的,需要传入一个逆变的Y。如果某个位置[X >: Y]是“负”的,那么Y所在位置就是正的,需要传入一个协变的Y。

泛型参数

如果我们的猫的meow参数或者返回值,是一个拥有协变或逆变参数的泛型呢?

定义一只老鼠先:

trait Mouse[+T] 

再来一只吃老鼠的猫:

trait Cat[-A] {
  def eat(mouse: Mouse[A])
}

这段代码是可以通过编译的。需要注意的是,Mouse[+T]中的T是协变的,其位置为正,但是在eat里,它却可以接受一个逆变类型A。

让我们试一下:

val child: Cat[Any] = new Cat[Any] {
   def eat(mouse: Mouse[Any]) = mouse
}
val parent: Cat[String] = child

然后传一只老鼠进去:

parent.eat(new Mouse[String])

由于Mouse[+T]是协变的,所以我们传入的Mouse[String]Mouse[Any]的子类,满足了child.eat(Mouse[Any])的类型要求。

但是如果我们的猫是协变的:

trait Cat[+A] {
  def eat(mouse: Mouse[A])
}

val child: Cat[String] = new Cat[String] {
   def eat(mouse: Mouse[String]) = mouse
}

val parent: Cat[Any] = child

由于parent的eat是:

   def eat(mouse: Mouse[Any])    

且Mouse是协变的,那么我们可以传一只Mouse[Int]进去:

parent.eat(new Mouse[Int])

消化不良。。。

同样的道理,我们还可以验证当Mouse是逆变,以及放在返回值位置的情况。

我们可以从这个例子中感受一下,但是很难证明对于更复杂的情况(如嵌套类型Mouse[Mouse[Mouse[T]]])也一样,但是好在scala规范中已经明确告诉了我们结论:

即:

来自星星的猫

最后再来看这只猫:

abstract class Cat[-T, +U] {
  def meow[W-](volue: T-, listener: Cat[U+, T-]-) :Cat[Cat[U+,T-]-, U+]+
}

现在,是否可以看懂了?

一些评论

Lambda47

“一个泛型拥有的变化型位置的正负,跟它所在的变化型位置正好相反"这是怎么理解的?

这句话是翻译的,我觉得这样写可能更准确(如果没有理解错误的话):一个泛型如果处于一个负的位置,那么它拥有的变化型位置要反转(即正变负,或负变正);如果处于一个正的位置,则不变。可参考文章开始引用的那个Cat的例子理解

杨博

我处理逆变逆变一般是先用+A,如果编译器报错就改成-A,如果再报错就改成_ : A,再报错就用普通的A。

comments powered by Disqus