5.7 参数化类型的可变性

至此,我们已经见识过许多Scala的惯用法,但是还有一件事,我想在这章的最后介绍一下。也许,本节会让你觉得有点头痛,但是我相信你可以搞定的。好,让我们系紧安全带,以防发生意外!

我们见识过,Scala会如何阻止可能引发运行时失败的赋值。例如,以下代码无法通过编译:

  1. var arr1 = new Array[Int](3)
  2. var arr2: Array[Any] = null
  3. arr2 = arr1 // Compilation ERROR

上面的约束是件好事。想象一下,假如Scala——跟Java一样——不这么限制会出现什么结果。下面是一段会让我们陷入麻烦的Java代码:

ScalaIdioms/Trouble.java

  1. Line 1 //Java code
  2. - class Fruit {}
  3. - class Banana extends Fruit {}
  4. - class Apple extends Fruit {}
  5. 5
  6. - public class Trouble {
  7. - public static void main(String[] args) {
  8. - Banana[] basketOfBanana = new Banana[2];
  9. - basketOfBanana[0] = new Banana();
  10. 10
  11. - Object[] basketOfFruits = basketOfBanana; // Trouble
  12. - basketOfFruits[1] = new Apple();
  13. -
  14. - for(Banana banana : basketOfBanana) {
  15. 15 System.out.println(banana);
  16. - }
  17. - }
  18. - }

上面这段代码没有编译错误。不过,运行它,会报出如下的运行时错误:

  1. Exception in thread "main" java.lang.ArrayStoreException: Apple
  2. at Trouble.main(Trouble.java:12)

让我们公平一些,其实Java也是不允许下面这种做法的:

  1. //Java code
  2. ArrayList<Integer> list = new ArrayList<Integer>();
  3. ArrayList<Object> list2 = list; // Compilation error

不过,在Java里很容易绕开这点像这样:

  1. ArrayList list3 = list;

将子类实例的容器赋给基类容器的能力称为协变(covariance)。将超类实例的容器赋给子类容器的能力称为逆变(contravariance)。默认情况下,Scala对二者都不支持。

虽然Scala的默认行为总的来说是好的,但是在某些实际情况下,我们希望能够慎重地把派生类型的容器(比如Dog类型的容器)当作它的基类型的容器(比如pet的容器)。考虑下面的例子:

ScalaIdioms/PlayWithPets.scala

  1. class Pet(val name: String) {
  2. override def toString() = name
  3. }
  4. class Dog(override val name: String) extends Pet(name)
  5. def workWithPets(pets: Array[Pet]) {}

这里定义了两个类——Pet类,以及继承自它的Dog类。还有一个方法workWith-Pets(),接收一个Pet数组,其实什么都没做。现在,创建一个Dog数组:

ScalaIdioms/PlayWithPets.scala

  1. val dogs = Array(new Dog("Rover"), new Dog("Comet"))

如果把dogs传给上面的方法,会报出编译错误:

  1. workWithPets(dogs) // Compilation ERROR

调用workWithPets()时,Scala会提示错误——Dog数组不能传给接收Pet数组的方法。但是,对这个方法而言,这么做没有任何不良后果,对吧?不过,Scala不知道这一点,它在试图保护我们。我们需要告诉 Scala,这是可行的,可以这么做。下面这个例子告诉我们如何做到这一点:

ScalaIdioms/PlayWithPets.scala

  1. def playWithPets[T <: Pet](pets: Array[T]) =
  2. println("Playing with pets: " + pets.mkString(", "))

这里用特殊的语法定义了playWithPets()T<:Pet表示T所代表的类派生自Pet。通过使用这种有上界的语法⑥,我们告诉Scala,具有参数化类型T的参数数组必须至少是Pet的数组,也可以是任何Pet派生类的数组。这样一来,就可以进行这样的调用了:

⑥如果将对象层次结构可视化,可以看到Pet定义了类型T的上界,T可以是Pet或是层次结构中任意更低的类型。

ScalaIdioms/PlayWithPets.scala

  1. playWithPets(dogs)

对应的输出如下:

  1. Playing with pets: Rover, Comet

如果试图传入一个Object数组或是其他非派生自Pet类型的对象数组,就会报出编译错误。

现在,我们想复制pet。编写了一个名为copy()的方法,接收两个参数,类型是Array[Pet]。不过,这样依然不能传入Dog数组,但我们也知道,Dog数组是应该可以复制给Pet数组的,换句话说,接收的数组可以是一个容器,其元素类型是源数组元素类型的超类型。所以,这里我们需要的是一个下界:

ScalaIdioms/PlayWithPets.scala

  1. def copyPets[S, D >: S](fromPets: Array[S], toPets: Array[D]) = { //...
  2. }
  3. val pets = new Array[Pet](10)
  4. copyPets(dogs, pets)

将目的数组的参数化类型(D)限制为源数组的参数化类型(S)的超类型。换句话说,S(源类型,如Dog)设置了类型D(目的类型,如DogPet)的下界——它可以是类型S或其超类的任意类型。

在上面两个例子里,在方法定义里控制了方法的参数。如果你是容器的作者,你也可以控制这一行为——也就是说,如果你愿意的话,把派生类的容器当作基类的容器,也是可行的。要做到这一点,可以将参数化类型标记为+T,而不是T,就像下面这个例子一样:

ScalaIdioms/MyList.scala

  1. class MyList[+T] //...
  2. var list1 = new MyList[int]
  3. var list2 : MyList[Any] = null
  4. list2 = list1 // OK

这里,+T告诉Scala允许协变;换句话说,在类型检查期间,让Scala接收某个类型或者其基类型。这样,就可以将MyList[Int]赋给MyList[Any]。记住,对于Array[Int]而言,这是不可以的。不过,对于List——Scala程序库中实现的函数式list——而言,这是可以的。我们会在第8章“使用容器”讨论这些内容。

类似的,参数化类型用-T替换T,就可以让Scala支持类型逆变。

默认情况下,Scala编译器会严格限制类型变化。你也见识了如何请求更为宽松的协变或逆变。在任何情况下,Scala编译器都会根据类型变化的标记检查类型正确性,然后把结果告诉我们。

在本章里,我们讨论了Scala的静态类型及其类型推演,见识了如何通过它使代码简洁。在下一章中,带着对类型、类型推演和如何编写方法的理解,让我们准备学习和享受Scala带来的更多简洁概念。