本文分为两部分,第一部分解释Scala中型变注解检查规则,第二部分解释该规则的原因。本文所有参考来自Scala2.13规范手册第四章:基本声明与定义(Basic Declaration & Defination),以及《Scala编程(第四版)》第19章:类型参数化。
名词解释
首先明确几个用词:
1. 类型参数(type parameter)是作为参数输入进来的类型,比如:
class A[T]{//类A的类型参数T
def func [U]:Unit//方法func的类型参数U
}
2. 类型声明(type declaration)以type开头,是内部声明的类型,可以给它加一些规定。比如:
type T >: A//声明了一个类型T,并规定它必须是A的超类
3.参数语句(parameter clause)是指括号之间的声明参数的语句。包括小括号(值参数)和中括号(类型参数)。注意语句和参数在概念上的区别,即使只有一个参数,语句和参数仍然是两回事。比如:
//中括号里T是语句也是参数(但概念不同),小括号里“param:T”是语句,T是类型
def func[T](param:T):Unit
4. 外部(enclosing)理解为嵌套(nest)的反义词,比如:
//func1就是func2的enclosing函数
def func1(){
def func2(){}
}
//param:T这句语句(clause)是T的closing clause
def func[T](param:T):Unit
5. 类型构造器:类型构造器就是带类型参数的类型,输入不同参数就能得到不同类型,比如:
//这个抽象类中,不变量a的类型就是一个类型构造器
abstract class[T]{
val a : List[T]
}
6.型变类型:型变类型是对于类型参数而言的,类型参数列表处定义,该参数在类中可以多次使用
//在类型参数列表处定义了类型T的型变类型为协变
class A[+T]
7.型变点类型:型变点类型是对于点位而言的,除了顶层类的类型参数和值参数列表外,每个用到参数的地方就有一个点位。型变点类型规定了这个点位能填的类型的型变类型。型变点位有诸如类型参数列表、值参数列表、变量的类型、方法的结果类型等等。以下这些都是型变点位:
class A[+T,-S] (param1:T,param2:S){
val a :/*这是点位*/ = param1
def func[/*这是点位*/,/*这是点位*/](param3:/*这是点位*/):/*这是点位*/ ={}
}
形变注解检查规则
明确了这些用词之后,现在来看规范手册中的型变注解检查规则:
The variance position of a type parameter in a type or template is defined as follows. Let the opposite of covariance be contravariance, and the opposite of invariance be itself. The top-level of the type or template is always in covariant position. The variance position changes at the following constructs.
- The variance position of a method parameter is the opposite of the variance position of the enclosing parameter clause.
- The variance position of a type parameter is the opposite of the variance position of the enclosing type parameter clause.
- The variance position of the lower bound of a type declaration or type parameter is the opposite of the variance position of the type declaration or parameter.
- The type of a mutable variable is always in invariant position.
- The right-hand side of a type alias is always in invariant position.
- The prefix S of a type selection
S#T
is always in invariant position.- For a type argument T of a type
S[...T...]
: If the corresponding type parameter is invariant, then T is in invariant position. If the corresponding type parameter is contravariant, the variance position of T is the opposite of the variance position of the enclosing typeS[...T...]
.
我先用中文复述一下(翻得不好,非常拗口,建议直接读上面的英文):
首先最外部的型变点总是协变点,然后逐步向内判断每个型变点的类型。一般而言型变类型不会变,仅当7种情况时改变:
1.方法参数处的型变点类型是其外部的参数语句处的型变点类型取反
2.类型参数处的型变点类型也是其外部的参数语句处的型变点类型取反
3.类型声明的下界或类型参数的下界处的型变点类型是那个类型声明或类型参数处的型变点类型取反
4.var处型变点类型是不变
5.作为右值的类型别名处的型变点类型是不变
6.路径依赖类型中外部类处的型变点类型是不变
7.对于一个类型构造器的参数,它的每个参数处的型变点类型取决于定义该类型时对这个位置的参数给的型变类型,如果是不变则这个型变点类型是不变,是逆变则该点处型变点类型是构造器处的型变点类型取反
还是来解释几点:
1.所谓参数的型变点相对于参数语句的型变点反转,是把参数语句看成比参数更外部的存在(尽管可能参数语句就写作那个参数)
2.所谓顶层是指类的大括号内,类的类型参数和值参数都不算
3.函数嵌套不会导致型变类型反转,只有嵌套的参数语句才会不停反转,换言之,嵌套函数的大括号内的型变类型跟最顶层一样都是协变点。
我们通过《Scala编程》中的两个例子来进一步说明。先来看第一个,这是一个有很多嵌套的例子:
abstract class Cat[-T, +U] {
def meow[Wˉ](volume: Tˉ, listener: Cat[U+, Tˉ]ˉ): Cat[Cat[U+, Tˉ]ˉ, U+]+
}
其中类型参数左边的符号表示其型变类型,右边的符号表示其所在点位的型变点类型(右边的符号是我们自己加上去的,不是Scala语法)。我们一点点来分析:
1.首先在顶层之外是类型参数列表,其中规定了T是逆变,U是协变,还规定了作为类型构造器的Cat[]的第一个参数是逆变的,第二个参数是协变的。
2.然后是顶层,顶层是协变的,在顶层定义了meow,meow的参数语句、函数体、返回类型语句处的点位都是协变的。但是其类型参数相对于其参数语句反转,因此W参数所在的点位是逆变,volume和listener同理。对于listener的类型处放的是一个类型构造器cat[],因此要继续往下看,cat的参数的型变类型在顶层之外定义,第一个参数是逆变,第二个是协变,根据规则,第一个点位类型反转(为正),第二个不变(为负)。
3.然后看meow的结果类型,如前所述,moew的结果类型本身在顶层,但它也是一个构造器Cat[],所以依旧如此推导:Cat参数列表第一个参数逆变,第一个点位从正转负,第二个参数协变,点位保持为正。
4.最后,结果类型的类型参数的第一个点位仍然是一个构造器,所以再来一遍,其第一个点位反转为正,第二个点位保持正。
然后看看是否将正确的参数填入了位置。T是逆变的,U是协变的,W是不变的(不变类型可以填在任意位置)。检查后发现没有冲突,因此这个例子可以编译通过。
刚刚的例子有很多嵌套,但是没有下界,所以再来一个例子:
class A [+T](init:T){
def func[U>:T]:Unit = {}
}
在这个例子里,其他的没什么好说,我们关心的是func方法类型参数列表中的T处的点位。它首先是顶层的类型参数列表中的参数位置,因此翻转一次,但同时,因为它是一个下界的位置,它又要相对于这个参数本身再翻转一次,于是这个点位是正的,故可以把T放进去。
规则解释
好了,现在弄清了规则,接下来解释这些规则的原因。
这个规则的目的很明确,是为了满足Liskov 替换原则(就是确保一个子类实例能当作父类实例来使用)。但这个规则如何保证了它呢?以下是我的两条理解:
1.首先,凡遇见参数语句中的参数点位(包括值参数和类型参数)就要取反(实际上就是逆变,因为函数嵌套不改变型变类型,所以所有参数列表都在顶层,顶层取反一定是逆变),这是为了让子类函数的输入范围大于包含父类(因为参数本质上就是输入)。注意这条把类型构造器的取反规则也包含进来了,因为类型构造器正是因为出现了类型参数而要取反。
2.第二,作为下界的点位要再取反。事实上,参数的类型,如Int,天然表示的以Int为上界的所有子类型。因此父类表示的范围大于子类。而一个类型作为下界时情况恰好相反,它代表的范围是其超类的范围,那么子类代表的范围就会大于父类。(注意:实际上以下界声明的参数,接受的范围显然是Any,所以不论父类和子类,对于一个设置了参数下界的方法,其接受范围是一样的,子类可以做到的是更精确的识别入参类型)
3.第三,考虑一个问题。当一个方法接受类型参数,并将它作为返回类型,如下:
class A[+T] (init:T){
val a:T = init
def func[U](param:U):U = param//该方法的返回类型取决于类型参数
}
是否会出现子类的方法的返回类型不属于父类的情况,从而违反Liskov原则呢?答案是不会。因为我们的前提是输入相同的参数。此时子类和父类输出结果是相同的。
如果有下界,由于子类可以更精确地识别入参类型,因此也还能输出更精确的结果,如下:
class A[+T] (init:T){
val a:T = init
def func[U>:T](param:U):U = param//有下界
}