协方差允许你控制类型参数在子类型方面的行为。Scala 支持 泛型类 的类型参数的协方差注释,允许它们在未使用注释时协变、逆变或不变。在类型系统中使用协方差允许我们在复杂类型之间建立直观的联系。
class Foo[+A] // A covariant class
class Bar[-A] // A contravariant class
class Baz[A] // An invariant class
不变性
默认情况下,Scala 中的类型参数是不变的:类型参数之间的子类型关系不会反映在参数化类型中。为了探究它为何以这种方式工作,我们来看一个简单的参数化类型,即可变框。
class Box[A](var content: A)
我们将把 Animal
类型的变量放入其中。此类型定义如下
abstract class Animal {
def name: String
}
case class Cat(name: String) extends Animal
case class Dog(name: String) extends Animal
abstract class Animal:
def name: String
case class Cat(name: String) extends Animal
case class Dog(name: String) extends Animal
我们可以说 Cat
是 Animal
的子类型,Dog
也是 Animal
的子类型。这意味着以下内容是类型良好的
val myAnimal: Animal = Cat("Felix")
那么盒子呢?Box[Cat]
是否是 Box[Animal]
的子类型,就像 Cat
是 Animal
的子类型一样?乍一看,这似乎是合理的,但如果我们尝试这样做,编译器会告诉我们出现错误
val myCatBox: Box[Cat] = new Box[Cat](Cat("Felix"))
val myAnimalBox: Box[Animal] = myCatBox // this doesn't compile
val myAnimal: Animal = myAnimalBox.content
val myCatBox: Box[Cat] = Box[Cat](Cat("Felix"))
val myAnimalBox: Box[Animal] = myCatBox // this doesn't compile
val myAnimal: Animal = myAnimalBox.content
这可能有什么问题?我们可以从盒子里取出猫,它仍然是动物,不是吗?嗯,是的。但这并不是我们所能做的全部。我们还可以用另一种动物替换盒子里的猫
myAnimalBox.content = Dog("Fido")
现在动物盒子里有一只狗。这很好,你可以把狗放在动物盒子里,因为狗是动物。但我们的动物盒子是猫盒子!你不能把狗放在猫盒子里。如果我们能这样做,然后尝试从猫盒子里取出猫,它就会变成一只狗,破坏类型健全性。
val myCat: Cat = myCatBox.content //myCat would be Fido the dog!
由此,我们必须得出结论,即使 Cat
和 Animal
存在子类型关系,Box[Cat]
和 Box[Animal]
也不能具有子类型关系。
协方差
我们在上面遇到的问题是,因为我们可以将狗放入动物盒中,所以猫盒不能是动物盒。
但是,如果我们不能将狗放入盒中呢?那么我们就可以把猫拿出来,这不是问题,因此它可以遵循子类型关系。事实证明,这确实是我们能做的事情。
class ImmutableBox[+A](val content: A)
val catbox: ImmutableBox[Cat] = new ImmutableBox[Cat](Cat("Felix"))
val animalBox: ImmutableBox[Animal] = catbox // now this compiles
class ImmutableBox[+A](val content: A)
val catbox: ImmutableBox[Cat] = ImmutableBox[Cat](Cat("Felix"))
val animalBox: ImmutableBox[Animal] = catbox // now this compiles
我们说 ImmutableBox
在 A
中是协变的,这由 A
之前的 +
表示。
更正式地说,这给了我们以下关系:给定一些 class Cov[+T]
,那么如果 A
是 B
的子类型,则 Cov[A]
是 Cov[B]
的子类型。这允许我们使用泛型建立非常有用且直观的子类型关系。
在以下不太牵强的示例中,方法 printAnimalNames
将接受一个动物列表作为参数,并在新行上打印它们的名称。如果 List[A]
不是协变的,则最后两个方法调用将无法编译,这将严重限制 printAnimalNames
方法的实用性。
def printAnimalNames(animals: List[Animal]): Unit =
animals.foreach {
animal => println(animal.name)
}
val cats: List[Cat] = List(Cat("Whiskers"), Cat("Tom"))
val dogs: List[Dog] = List(Dog("Fido"), Dog("Rex"))
// prints: Whiskers, Tom
printAnimalNames(cats)
// prints: Fido, Rex
printAnimalNames(dogs)
逆变
我们已经看到,我们可以通过确保我们不能将某些东西放入协变类型,而只能取出某些东西来实现协变。如果我们有相反的情况,即你可以放入某些东西,但不能取出呢?如果我们有序列化器之类的内容,它获取类型为 A 的值并将其转换为序列化格式,就会出现这种情况。
abstract class Serializer[-A] {
def serialize(a: A): String
}
val animalSerializer: Serializer[Animal] = new Serializer[Animal] {
def serialize(animal: Animal): String = s"""{ "name": "${animal.name}" }"""
}
val catSerializer: Serializer[Cat] = animalSerializer
catSerializer.serialize(Cat("Felix"))
abstract class Serializer[-A]:
def serialize(a: A): String
val animalSerializer: Serializer[Animal] = new Serializer[Animal]():
def serialize(animal: Animal): String = s"""{ "name": "${animal.name}" }"""
val catSerializer: Serializer[Cat] = animalSerializer
catSerializer.serialize(Cat("Felix"))
我们说 Serializer
在 A
中是逆变的,这由 A
之前的 -
表示。更通用的序列化器是更具体序列化器的子类型。
更正式地说,这给了我们相反的关系:给定一些 class Contra[-T]
,那么如果 A
是 B
的子类型,则 Contra[B]
是 Contra[A]
的子类型。
不变性和方差
不变性构成了使用方差背后的设计决策的重要组成部分。例如,Scala 的集合系统地区分可变和不可变集合。主要问题是协变可变集合会破坏类型安全性。这就是 List
是协变集合,而 scala.collection.mutable.ListBuffer
是不变集合的原因。 List
是包 scala.collection.immutable
中的集合,因此保证它对每个人都是不可变的。然而, ListBuffer
是可变的,也就是说,你可以更改、添加或删除 ListBuffer
的元素。
为了说明协变和可变性的问题,假设 ListBuffer
是协变的,那么以下有问题的示例将编译(实际上它无法编译)
import scala.collection.mutable.ListBuffer
val bufInt: ListBuffer[Int] = ListBuffer[Int](1,2,3)
val bufAny: ListBuffer[Any] = bufInt
bufAny(0) = "Hello"
val firstElem: Int = bufInt(0)
如果上面的代码可行,那么求值 firstElem
将失败,并出现 ClassCastException
,因为 bufInt(0)
现在包含一个 String
,而不是一个 Int
。
ListBuffer
的不变性意味着 ListBuffer[Int]
不是 ListBuffer[Any]
的子类型,尽管 Int
是 Any
的子类型,因此无法将 bufInt
指定为 bufAny
的值。
与其他语言的比较
与 Scala 类似的一些语言以不同的方式支持方差。例如,Scala 中的方差注释与 C# 中的注释非常相似,其中在定义类抽象时添加注释(声明站点方差)。然而,在 Java 中,方差注释是在使用类抽象时由客户端给出的(使用站点方差)。
Scala 倾向于不可变类型,这使得协变和逆变类型比其他语言中更常见,因为可变泛型类型必须是不变的。