在面向对象的编程里,提高代码复用率的一个重要方法就是泛型。泛型是一种重要的多态,称为“全类型多态”或“参数多态”。在某些容器类里,通常需要存储其它类型的对象,但是具体是什么类型,事先并不知道。倘若对每种可能包含的类型都编写一个新类,那么这完全不现实。一是工作量巨大,二是自定义类型是什么完全无法预知。例如,列表的元素可以是基本类型,也可以是自定义的类型,不可能在编写列表类时把自定义类型也考虑进去。更重要的是,这些容器类仅仅需要知道一个具体的类型,其它成员完全是一样的。既然这样,那完全可以编写一个泛型的类,它独立于成员的类型存在,然后把类型也作为一个参数,实例化生成不同的类对象。
既然与定义类型相关,那么可以泛型的自然是类和特质。在前面讲解集合时,就已经初步了解了这样的类和特质。例如,Array[T]、List[T]、Map[T, U]等等。本章将深入讲解Scala有关类型参数化的内容。
一、var类型的字段
对于可重新赋值的字段,可执行两个基本操作:获取字段值或者设置为一个新值。在JavaBeans库里,这两个操作分别由名为“getter”和“setter”的方法来完成。Scala遵循了Java的惯例,只不过实现两个基本操作的方法的名字不一样:如果在类中定义了一个var类型的字段,那么编译器会隐式地把这个变量限制成private[this]的访问权限,同时隐式地定义一个名为“变量名”的getter方法,和一个名为“变量名_=”的setter方法。默认的getter方法返回变量的值,而默认的setter方法接收外部传入的参数来直接赋给变量。例如:
class A {
var aInt: Int = _
}
// 相当于
class A {
// 这个变量名“a”是随意取的,只要不与两个方法名冲突即可
private[this] var a: Int = _
// getter,方法名与原来的变量名相同
def aInt: Int = a
// setter,注意名字里的“_=”
def aInt_=(x: Int) = a = x
}
注意,字段必须被初始化,即“= _”不能省略,它将字段初始化为零值(具体零值是什么取决于字段的类型,数值类型的零值是0,布尔类型是false,引用类型是null),也可以初始化为某个具体值。如果不初始化,就是一个抽象字段。还有前面讲解的private[this],表明该成员只能用“this.a”或“a”来访问,句点前面不能是其它任何对象。
实际上定义的var类型字段并不是用private[this]修饰的,只不过被编译器隐式转换了,所以外部仍然可以读取和修改该字段,但编译器会自动转换成对getter和setter方法的调用。也就是说,“对象.变量”会调用getter方法,而“对象.变量 = 新值”会调用setter方法。而且,这两个方法的权限与原本定义的var字段的权限相同,如果原本的var字段是公有的,那么这两个方法就是公有的;如果原本的var字段是受保护的,那么这两个方法也是受保护的;依此类推。当然,也可以逆向操作,自定义getter和setter方法,以及一个private[this]修饰的var类型字段,只要注意方法与字段的名字不冲突。
另一方面,这也说明字段与方法没有必然联系。如果定义了“var a”这样的语句,那么必然有隐式的“a”和“a_=”方法,并且无法显式修改这两个方法(名字冲突);如果自定义了“b”和“b_=”这样的方法,却不一定要相应的var字段与之对应,这两个方法也可以操作类内的其他成员,而且仍然可以通过“object.b”和“object.b = value”来调用。例如:
class A {
private[this] var a: Int = _
// 默认的getter和setter
def originalValue: Int = a
def originalValue_=(x: Int) = a = x
// 自定义的getter和setter,且没有对应的var字段
def tenfoldValue: Int = a * 10
def tenfoldValue_=(x: Int) = a = x / 10
}
scala> val a = new A
a: A = A@19dac2d6scala> a.originalValue = 1
a.originalValue: Int = 1scala> a.originalValue
res0: Int = 1scala> a.tenfoldValue
res1: Int = 10scala> a.tenfoldValue = 1000
a.tenfoldValue: Int = 1000scala> a.originalValue
res2: Int = 100
二、类型构造器
scala> abstract class A[T] {
| val a: T
| }
defined class A
像上述例子所示的“A”是一个类,但它不是一个类型,因为它接收一个类型参数。A也被称为“类型构造器”,因为它可以接收一个类型参数来构造一个类型,就像普通类的构造方法接收值参数构造实例对象一样。比如A[Int]是一种类型,A[String]是另一种类型,等等。也可以说A是一个泛型的类。在指明类型时,不能像普通类那样只写一个类名,而必须在方括号里给出具体的类型参数。例如:
scala> def doesNotCompile(x: A) = {}
<console>:12: error: class A takes type parameters
def doesNotCompile(x: A) = {}
^scala> def doesCompile(x: A[AnyRef]) = {}
doesCompile: (x: A[AnyRef])Unit
除了泛型的类和特质需要在名字后面加上方括号和类型参数,如果某个成员方法的参数也是泛型的,那么方法名后面也必须加上方括号和类型参数。字段则不需要,只要直接用类型参数指明类型即可。
三、型变注解
像A[T]这样的类型构造器,它们的类型参数T可以是协变的、逆变的或者不变的,这被称为类型参数的“型变”。“A[+T]”表示类A在类型参数T上是协变的,“A[-T]”表示类A在类型参数T上是逆变的。其中,类型参数的前缀“+”和“-”被称为型变注解,没有就是不变的。
如果类型S是类型T的子类型,那么协变表示A[S]也是A[T]的子类型,而逆变表示A[T]反而是A[S]的子类型,不变则表示A[S]和A[T]是两种没有任何关系的不同类型。
四、检查型变注解
标注了型变注解的类型参数不能随意使用,类型系统设计要满足“里氏替换原则”:在任何需要类型为T的对象的地方,都能用类型为T的子类型的对象替换。里氏替换原则的依据是子类型多态。类型为超类的变量是可以指向类型为子类的对象,因为子类继承了超类所有非私有成员,能在超类中使用的成员,一般在子类中均可用。有关里氏替换原则的详细解释,这里不再展开。
假设类型T是类型S的超类,如果类型参数是协变的,导致A[T]也是A[S]的超类,那么“val a: A[T] = new A[S]”就合法。此时,如果类A内部的某个方法funcA的入参的类型也是这个协变类型参数,那么方法调用“a.funcA(b: T)”就会出错,因为a实际指向的是一个子类对象,子类对象的方法funcA接收的入参的类型是S,而子类S不能指向超类T,所以传入的b不能被接收。但是a的类型是A[T]又隐式地告诉使用者,可以传入类型是T的参数,这就产生了矛盾。相反,funcA的返回类型是协变类型参数就没有问题,因为子类对象的funcA的返回值的类型虽然是S,但是能被T类型的变量接收,即“val c: T = a.funcA()”合法。a的类型A[T]隐式地告诉使用者应该用T类型的变量接收返回值,虽然实际返回的值是S类型,但是子类型多态允许这样做。也就是说,要保证不出错,生产者产生的值的类型应该是子类,消费者接收的值的类型应该是超类(接收者本来只希望使用超类的成员,但是实际给出的子类统统都具备,接收者也不会去使用多出来的成员,所以子类型多态才正确)。基于此,方法的入参的类型应该是逆变类型参数,逆变使得“val a: A[S] = new A[T]”合法,也就是实际引用的对象的方法想要一个T类型的参数,但传入了子类型S的值,符合里氏替换原则。同理,方法的返回类型应该是协变的。
既然类型参数的使用有限制,那么就应该有一个规则来判断该使用什么类型参数。Scala的编译器把类或特质中任何出现类型参数的地方都当作一个“点”,点有协变点、逆变点和不变点之分,以声明类型参数的类和特质作为顶层开始,逐步往内层深入,对这些点进行归类。在顶层的点都是协变点,例如顶层的方法的返回类型就在协变点。默认情况下,在更深一层的嵌套的点与在包含嵌套的外一层的点被归为一类。该规则有一些例外:①方法的值参数所在的点会根据方法外的点进行一次翻转,也就是把协变点翻转成逆变点、逆变点翻转成协变点、不变点仍然保持不变。②方法的类型参数(即方法名后面的方括号)也会根据方法外的点进行一次翻转。③如果类型也是一个类型构造器,比如以C[T]为类型,那么,当T有“-”注解时就根据外层进行翻转,有“+”注解时就保持与外层一致,否则就变成不变点。
协变点只能用“+”注解的类型参数,逆变点只能用“-”注解的类型参数。没有型变注解的类型参数可以用在任何点,也是唯一一种能用在不变点的类型参数。所以对于类型Q[+U, -T, V]而言,U处在协变点,T处在逆变点,而V处在不变点。
以如下例子为例进行解释:
abstract class Cat[-T, +U] {
def meow[Wˉ](volume: Tˉ, listener: Cat[U+, Tˉ]ˉ): Cat[Cat[U+, Tˉ]ˉ, U+]+
}
右上角的正号表示协变点,负号表示逆变点。首先,Cat类声明了类型参数,所以它是顶层。方法meow的返回值属于顶层的点,所以返回类型的最右边是正号,表示协变点。因为方法的返回类型也是类型构造器Cat,并且第一个类型参数是逆变的,所以这里相对协变翻转成了逆变,而第二个类型参数是协变的,所以保持协变属性不变。继续往里归类,返回类型嵌套的Cat处在逆变点,所以第一个类型参数的位置相对逆变翻转成协变,第二个类型参数的位置保持逆变属性不变。两个值参数volume和listener都相对协变翻转成了逆变点,并且listener的类型是Cat,所以和返回类型嵌套的Cat一样。方法的类型参数W,也相对协变翻转成了逆变点。
虽然型变注解的检查很麻烦,但这些工作都被编译器自动完成了。编译器的检查方法也很直接,就是查看顶层声明的类型参数是否出现在正确的位置。比如,上例中,T都出现在逆变点,U都出现在协变点,所以可以通过检查。至于W是什么,则不关心。
五、类型构造器的继承关系
因为类型构造器需要根据类型参数来确定最终的类型,所以在判断多个类型构造器之间的继承关系时,也必须依赖类型参数。对于只含单个类型参数的类型构造器而言,继承关系很好判断,只需要看型变注解是协变、逆变还是不变。当类型参数不止一个时,该如何判断呢?尤其是函数的参数是一个函数时,更需要确定一个函数的子类型是什么样的函数。
以常用的单参数函数为例,其特质Function1的部分定义如下:
trait Function1[-S, +T] {
def apply(x: S): T
}
类型参数S代表函数的入参的类型,很显然应该是逆变的。类型参数T代表函数返回值的类型,所以是协变的。
假设类A是类a的超类,类B是类b的超类,并且定义了一个函数的类型为Function1[a, B]。那么,这个函数的子类型应该是Function1[A, b]。解释如下:假设在需要类型为Function1[a, B]的函数的地方,实际用类型为Function1[A, b]的函数代替了。那么,本来会给函数传入a类型的参数,但实际函数需要A类型的参数,由于类A是类a的超类,这符合里氏替换原则;本来会用类型为B的变量接收函数的返回值,但实际函数返回了b类型的值,由于类B是类b的超类,这也符合里氏替换原则。综上所述,用Function1[A, b]代替Function1[a, B]符合里氏替换原则,所以Function1[A, b]是Function1[a, B]的子类型。
因此,对于含有多个类型参数的类型构造器,要构造子类型,就是把逆变类型参数由子类替换成超类、把协变类型参数由超类替换成子类。
六、上界和下界
对于类型构造器A[+T],倘若没有别的手段,很显然它的方法的参数不能泛化,因为协变的类型参数不能用作函数的入参类型。如果要泛化参数,必须借助额外的类型参数,那么这个类型参数该怎么定义呢?因为可能存在“val x: A[超类] = new A[子类]”这样的定义,导致方法的入参类型会是T的超类,所以,额外的类型参数必须是T的超类。Scala提供了一个语法——下界,其形式为“U >: T”,表示U必须是T的超类,或者是T本身(一个类型既是它自身的超类,也是它自身的子类)。
通过使用下界标定一个新的类型参数,就可以在A[+T]这样的类型构造器里泛化方法的入参类型。例如:
scala> abstract class A[+T] {
| def funcA[U >: T](x: U): U
| }
defined class A
现在,编译器不会报错,因为下界的存在,导致编译器预期参数x的类型是T的超类。实际运行时,会根据传入的实际入参确定U是什么。返回类型定义成了U,当然也可以是T,但是动态地根据U来调整类型显得更自然。
与下界对应的是上界,其形式为“U <: T”,表示U必须是T的子类或本身。通过上界,就能在A[-T]这样的类型构造器里泛化方法的返回类型。例如:
scala> abstract class A[-T] {
| def funcA[U <: T](x: U): U
| }
defined class A
注意,编写上、下界时,不能写错类型的位置和开口符号。
七、方法的类型参数
除了类和特质能一开始声明类型参数外,方法也可以带有类型参数。如果方法仅仅使用了包含它的类或特质已声明的类型参数,那么方法自己就没必要写出类型参数。如果出现了包含它的类或特质未声明的类型参数,则必须写在方法的类型参数里。注意,方法的类型参数不能有型变注解。例如:
scala> abstract class A[-T] {
| def funcA(x: T): Unit
| }
defined class Ascala> abstract class A[-T] {
| def funcA(x: T, y: U): Unit
| }
<console>:12: error: not found: type U
def funcA(x: T, y: U): Unit
^scala> abstract class A[-T] {
| def funcA[U](x: T, y: U): Unit
| }
defined class A
方法的类型参数不能与包含它的类和特质已声明的类型参数一样,否则会把它们覆盖掉。例如:
scala> class A[-T] {
| def funcA[T](x: T) = x.getClass
| }
defined class Ascala> val a = new A[Int]
a: A[Int] = A@3217aadascala> a.funcA("Hello")
res0: Class[_ <: String] = class java.lang.String
八、对象私有数据
var类型的字段,其类型参数不能是协变的,因为隐式的setter方法需要一个入参,这就把协变类型参数用作入参。其类型参数也不能是逆变的,因为隐式的getter方法的返回类型就是字段的类型。例如:
scala> class A[-T] {
| var a: T = _
| }
<console>:12: error: contravariant type T occurs in covariant position in type => T of variable a
var a: T = _
^scala> class A[+T] {
| var a: T = _
| }
<console>:12: error: covariant type T occurs in contravariant position in type T of value a_=
var a: T = _
^
但是也有例外,如果var字段是对象私有的,即用private[this]修饰,那么它只能在定义该类或特质时被访问。由于外部无法直接访问,也就不可能在运行时违背里氏替换原则。因此隐式的getter和setter方法可以忽略对型变注解的检查。如果想在内部自定义getter或setter方法来产生一个错误,假设当前类型参数T是协变的,尽管可以通过下界来避免setter方法的型变注解错误,但是赋值操作又会发生类型匹配错误。连类型检查都无法通过,更不可能在运行时发生错误。同样,逆变类型参数也是如此。例如:
scala> class A[+T] {
| private[this] var a: T = _
| }
defined class Ascala> class A[+T] {
| private[this] var a: T = _
| def set[U >: T](x: U) = a = x
| }
<console>:14: error: type mismatch;
found : x.type (with underlying type U)
required: T
def set[U >: T](x: U) = a = x
^scala> class A[-T] {
| private[this] var a: T = _
| }
defined class Ascala> class A[-T] {
| private[this] var a: T = _
| def get[U <: T](): U = a
| }
<console>:14: error: type mismatch;
found : T
required: U
def get[U <: T](): U = a
^
所以,Scala的编译器会忽略对private[this] var类型的字段的检查。
九、总结
本章的内容也是比较抽象、难理解。其应用在于阅读Chisel的源码,理解语言的工作机制,读懂API。如果是实际编写硬件电路,也是用不到这些语法的。
本文深入探讨Scala中的类型参数化,包括var类型的字段、类型构造器、型变注解、检查型变注解、类型构造器的继承关系、上界和下界、方法的类型参数、对象私有数据。内容涵盖类型参数化的各个方面,帮助读者理解Scala泛型的使用和限制,特别强调了类型参数化在Chisel和RISC-V等领域的应用。

459

被折叠的 条评论
为什么被折叠?



