为什么这个示例无法编译,即协变、逆变和不变性如何工作?

148

这个问题的基础上,有人能解释一下Scala中以下内容吗:

class Slot[+T] (var some: T) { 
   //  DOES NOT COMPILE 
   //  "COVARIANT parameter in CONTRAVARIANT position"

}

我了解类型声明中+TT之间的区别(如果使用T编译,则可以编译)。但是,如何编写一个在其类型参数中是协变的类而不必创建未参数化的东西呢?我如何确保以下内容只能通过T的实例创建?

class Slot[+T] (var some: Object){    
  def get() = { some.asInstanceOf[T] }
}

编辑 - 现在将其简化为以下内容:

abstract class _Slot[+T, V <: T] (var some: V) {
    def getT() = { some }
}

这很好,但我现在有两个类型参数,而我只想要一个。我将重新提出问题:

如何编写一个 不可变的 Slot 类,并使其在其类型上是协变的

编辑2: 哎呀!我使用了 var 而不是 val。以下是我想要的:

class Slot[+T] (val some: T) { 
}

6
因为 var 可以被设置值,而 val 不能。这也是为什么Scala的不可变集合是协变的,但可变集合不是的原因。 - oxbow_lakes
在这个背景下,这可能是有趣的内容:http://www.scala-lang.org/old/node/129 - user573215
4个回答

305

泛型上,一个 协变类型参数 是一个允许在子类化的过程中向下变化的类型参数(或者说随着子类型而变化,因此带有前缀 "co-")。更具体地说:

trait List[+A]

List[Int]List[AnyVal] 的子类型,因为 IntAnyVal 的子类型。这意味着当需要一个 List[AnyVal] 类型的值时,你可以提供一个 List[Int] 的实例。这种泛型工作方式真的非常直观,但事实证明,在存在可变数据的情况下使用它是不安全的(会破坏类型系统)。这就是为什么 Java 中泛型是不变的原因。以下是使用 Java 数组(错误地协变)演示不安全性的简短示例:

Object[] arr = new Integer[1];
arr[0] = "Hello, there!";

我们刚刚将一个 String 类型的值赋给了一个 Integer[] 类型的数组。显而易见,这是个问题。在 Java 的类型系统中,编译时实际上允许这种做法。但是 JVM 会在运行时抛出一个 ArrayStoreException 异常以提示开发者。Scala 的类型系统通过使 Array 类型参数不变(声明为 [A] 而非 [+A])来解决此类问题。

请注意,还有另一种被称为逆变的方差。它非常重要,因为它解释了为什么协变可能会导致一些问题。逆变与协变正好相反:随着子类型的出现,参数向上变化。它比较少见,部分原因是因为它看起来很反直觉,但它确实有一个非常重要的应用:函数。

trait Function1[-P, +R] {
  def apply(p: P): R
}

注意在类型参数P上的"-"变量注释。这个声明整体上意味着Function1P上是逆变的,在R上是协变的。因此,我们可以得出以下公理:

T1' <: T1
T2 <: T2'
---------------------------------------- S-Fun
Function1[T1, T2] <: Function1[T1', T2']
注意,T1' 必须是 T1 的子类型(或相同类型),而对于 T2T2' 则恰好相反。用英语表达为:
一个函数 A 是另一个函数 B 的子类型, 如果 A 的参数类型是 B 的超类型, 且 A 的返回类型是 B 的子类型。
这个规则的原因留给读者自己思考(提示:想一下函数被子类型化的不同情况,就像我之前提到的数组例子)。通过对协变性和逆变性的新发现,你应该能够明白为什么以下示例不能编译:
trait List[+A] {
  def cons(hd: A): List[A]
}
问题在于A是协变的,而cons函数期望其类型参数为不变型。因此,A的变化方向是错误的。有趣的是,我们可以通过使List逆变于A来解决这个问题,但是这样返回类型List[A]将无效,因为cons函数期望其返回类型是协变的。
这里我们只有两个选择:a) 使A不变型,失去了协变的良好、直观的子类型属性;或者b) 在cons方法中添加一个局部类型参数,将A定义为下限。
def cons[B >: A](v: B): List[B]

现在这是合法的。你可以想象 A 向下变化,但是由于 A 是它的下界,B 可以向上变化相对于 A。通过这种方法声明,我们可以让 A 成为协变的,一切正常。

请注意,这个技巧仅适用于返回一个特定于不太具体类型 B 的列表实例的情况。如果尝试使 List 可变,会出现问题,因为您最终会尝试将类型为 B 的值分配给类型为 A 的变量,这是编译器不允许的。每当涉及可变性时,您需要某种形式的 mutator ,这需要一种特定类型的方法参数,这意味着不变性(invariance)。协变适用于不可变的数据,因为唯一可能的操作是访问器,可以给予协变返回类型。


4
可以用简单的英语表述为:You can use something simpler as an input and get something more complicated as an output. - Phil
1
Java编译器(1.7.0)无法编译“Object[] arr = new int[1];”,而是会给出错误信息:“java:不兼容的类型,需要:java.lang.Object[],找到:int[]”。我认为你的意思是“Object[] arr = new Integer[1];”。 - Emre Sevinç
2
当你提到:“这个规则的原因留给读者自己思考(提示:考虑不同情况下函数作为子类型的情况,比如我上面举的数组例子)。”你能否给出一些例子? - perryzheng
2
根据@perryzheng在此处的回答,考虑到trait Animaltrait Cow extends Animaldef iNeedACowHerder(herder: Cow => Unit, c: Cow) = herder(c)def iNeedAnAnimalHerder(herder: Animal => Unit, a: Animal) = herder(a)。因此,iNeedACowHerder({ a: Animal => println("I can herd any animal, including cows") }, new Cow {})是OK的,因为我们的动物牧人可以牧马,但是iNeedAnAnimalHerder({ c: Cow => println("I can herd only cows, not any animal") }, new Animal {})会产生编译错误,因为我们的牛牧人不能牧所有的动物。 - Lasf
输入输出抽象有助于编程。如果您将输入从外部包装器(子函数)传递到内部目标(函数),则只能对其进行细化和改进,而且只能将来自内部目标(函数)的输出泛化和概括回包装器(子函数)。进入-细化,出去-粗略化。 - Vladimir Nabokov
显示剩余2条评论

28

@Daniel已经很好地解释了它。简单来说,如果允许:

  class Slot[+T](var some: T) {
    def get: T = some   
  }

  val slot: Slot[Dog] = new Slot[Dog](new Dog)   
  val slot2: Slot[Animal] = slot  //because of co-variance 
  slot2.some = new Animal   //legal as some is a var
  slot.get ??

slot.get方法在运行时会抛出一个错误,因为它无法将Animal类型转换为Dog类型(显然不符合预期)。

通常情况下,可变性与协变性和逆变性并不兼容。这也是为什么所有Java集合都是不变的原因。


7

请参见Scala by example第57页及之后的内容,对此进行全面讨论。

如果我理解你的评论正确,你需要重新阅读第56页底部开始的段落(基本上,我认为你所要求的不是类型安全的,没有运行时检查,Scala也无法做到,所以你只能凭运气了)。将他们的示例转换为使用你的构造:

val x = new Slot[String]("test") // Make a slot
val y: Slot[Any] = x             // Ok, 'cause String is a subtype of Any
y.set(new Rational(1, 2))        // Works, but now x.get() will blow up 

如果你觉得我没有理解你的问题(这是很可能的),请尝试在问题描述中添加更多的解释/背景,我会再次尝试。

针对您的编辑:不可变槽是一种完全不同的情况……*微笑* 我希望上面的示例有所帮助。


我已经阅读了,但不幸的是,我仍然不明白如何做到我上面所问的(即实际编写一个参数化类,在T上具有协变性)。 - oxbow_lakes
我撤销了我的downmark,因为我意识到这有点严厉。我应该在问题中明确指出我已经阅读了Scala by example的部分内容;我只是希望以“非正式”的方式解释一下。 - oxbow_lakes
@oxbow_lakes 微笑 我担心《Scala By Example》是更不正式的解释。 最好的办法,我们可以尝试使用具体的例子在这里进行演示... - MarkusQ
抱歉 - 我不想让我的插槽可变。我刚意识到问题是我声明了var而不是val。 - oxbow_lakes

3

你需要对参数应用一个下限。我很难记住语法,但我认为它应该像这样:

class Slot[+T, V <: T](var some: V) {
  //blah
}

Scala by Example这本书有点难以理解,加入一些具体的例子会更有帮助。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接