泛型上,一个 协变类型参数 是一个允许在子类化的过程中向下变化的类型参数(或者说随着子类型而变化,因此带有前缀 "co-")。更具体地说:
trait List[+A]
List[Int]
是 List[AnyVal]
的子类型,因为 Int
是 AnyVal
的子类型。这意味着当需要一个 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
上的"-"变量注释。这个声明整体上意味着Function1
在P
上是逆变的,在R
上是协变的。因此,我们可以得出以下公理:
T1' <: T1
T2 <: T2'
Function1[T1, T2] <: Function1[T1', T2']
注意,
T1'
必须是
T1
的子类型(或相同类型),而对于
T2
和
T2'
则恰好相反。用英语表达为:
一个函数 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)。协变适用于不可变的数据,因为唯一可能的操作是访问器,可以给予协变返回类型。
var
可以被设置值,而val
不能。这也是为什么Scala的不可变集合是协变的,但可变集合不是的原因。 - oxbow_lakes