Scala 之旅

协方差

语言

协方差允许你控制类型参数在子类型方面的行为。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

我们可以说 CatAnimal 的子类型,Dog 也是 Animal 的子类型。这意味着以下内容是类型良好的

val myAnimal: Animal = Cat("Felix")

那么盒子呢?Box[Cat] 是否是 Box[Animal] 的子类型,就像 CatAnimal 的子类型一样?乍一看,这似乎是合理的,但如果我们尝试这样做,编译器会告诉我们出现错误

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!

由此,我们必须得出结论,即使 CatAnimal 存在子类型关系,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

我们说 ImmutableBoxA 中是协变的,这由 A 之前的 + 表示。

更正式地说,这给了我们以下关系:给定一些 class Cov[+T],那么如果 AB 的子类型,则 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"))

我们说 SerializerA 中是逆变的,这由 A 之前的 - 表示。更通用的序列化器是更具体序列化器的子类型。

更正式地说,这给了我们相反的关系:给定一些 class Contra[-T],那么如果 AB 的子类型,则 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] 的子类型,尽管 IntAny 的子类型,因此无法将 bufInt 指定为 bufAny 的值。

与其他语言的比较

与 Scala 类似的一些语言以不同的方式支持方差。例如,Scala 中的方差注释与 C# 中的注释非常相似,其中在定义类抽象时添加注释(声明站点方差)。然而,在 Java 中,方差注释是在使用类抽象时由客户端给出的(使用站点方差)。

Scala 倾向于不可变类型,这使得协变和逆变类型比其他语言中更常见,因为可变泛型类型必须是不变的。

此页面的贡献者