本文节选自最近在日本十分流行的Scala讲座系列的第六篇,由JavaEye的fineqtbull翻译。本系列的作者牛尾刚在日本写过不少有关Java和Ruby的书籍,相当受欢迎。
概要
到上次为止由羽生田先生介绍了Scala语法的特点,这一讲我作为嘉宾来介绍一下Scala的类型系统和相关功能。本次介绍的重点是Java与Scala之间类层次的差异、范型的协变与逆变、实存类型(Existential Type)、结构类型(Structural Type)和复合类型(Compound Type)。
与Java相似之处
Scala类型系统的基础部分是与Java非常相像的。Scala与Java一样有单一的根类,Java通过接口来实现多重继承,而Scala则通过特征(trait)来实现(Scala的特征可以包含实现代码,这当然是与Java接口不同的。不过由于特征自己具有类型的功能,所以对于没有包含实现代码的特征,可以认为与Java的接口是等价的)。
不过在几点上面Scala具有与Java不同的部分,或者相比Java增加的功能部分。下文将以与Java相比的不同点或增加点为重点来说明一下Scala的类型系统。
Scala的类层次(1) - Any、AnyVal、AnyRef
本连载的第四回也提到过,Scala的类型层次与Java的相应部分是非常相似的。首先,Scala中存在单一的根类Any,所有类型都直接或间接地由Any类继承而来。这与Java中的所有引用类型的根类是java.lang.Object是一样的。
另一方面也有与Java不同的部分。首先Scala不存在类似于Java中的基础(Primitive)类型。那是
怎么一回事呢?Scala中所有与基础类型相当的类型都成为了类,并且这些类都是继承了Any的AnyVal类的子类。
另外,所有的引用类型都成AnyRef类的间接或直接子类。前面说了Any类类似于Java中的java.lang.Object类,但是从实际意义上来看,因该说对应于java.lang.Object的因该是AnyRef(図 3-1的a)。
不过离题一下,对于Scala中相当于基础类型的类,可以把整数的(Byte、Short、Int、Long)和浮点数的(Float、Double)看作是相邻的兄弟类关系,那Java中可以默认转换的比如byte->int,在Scala中是否需要显示的类型转换呢,如果需要的话那大家会觉得好麻烦呀。实际上Scala中具有使用户能够定义自己的默认类型转换功能的隐式转换(implicit conversion)功能。对于类似于Java中的byte->int、float->double等默认转换功能,在Scala标准库中定义了与此相当的隐式转换功能,所以用户是不需要显示转换的。这个隐式转换是非常有趣的功能,在以后的连载里可能会有详细说明。
Scala的类层次(2) - Nothing、Null
与Java不同的是,Scala中存在所谓的底(bottom)类型,那就是Nothing类。Nothing是所有类型的子类,也就是说可以将Nothing类型赋值给任意类型的变量,但是Nothing类型的值并不存在。
大家可能认为“没有值的类型有什么用呢?”。但是Nothing类型绝对在,表示没有返回值的函数的返回类型,或者在后述的范型中表示空的集等方面发挥着重要的作用。
另外还存在是所有的引用类型(AnyRef)的子类,可以赋值给所有引用类型变量的类型Null。Null类型的值只有null(实际上Java中也有Null类型,担负着与Scala中Null类型相似的任务。与Scala不同的是,Java中没有显示定义Null类型的机会,所以基本上没有人会意识得到的)。(図 3-1的b)表示了上述类型间的关系。
范型基础
一句话来说,范型就是定义以类型为参数的类或接口(Scala中为特征)的功能。Java里从JDK5开始就有了范型,想必知道的人应该比较多了,下面就简单举例说明一下。
例如,假设有如下的代码片段。这里java.util.List是范型接口,String就是赋给它的类型参数。
- java.util.List< String> strs = new java.util.ArrayList< String>();
这样,就可以用如下方法将String类型(或子类型)的对象加入List中了。
- strs.add("hoge");
如下所示,如将String以外的对象加入List则会发生编译错误。
- strs.add(new java.util.Date());
这样一来,就可以开发类型安全的通用集(collection)库了。在Java5之前的集库是用Object来实现的。但是向集中加入元素时并没有进行正确的类型检查,而且从集中取出元素时还要做强制的类型转换,导致旧的集库在类型安全方面有一些问题。进一步来说,光从类型定义看不出该集包含的是何种元素,所以在可读性方面也有不足。
Scala的范型与Java是非常相似的,基本上可以同样地使用,只是在标记方法上有些区别。以下是同刚才Java代码基本相同的Scala代码。
- var strs: java.util.List[String] = new java.util.ArrayList
Scala中用[..]来代替了Java中的< ..>来表现类型参数表。附带提一下,与Java有一点小的不同,Scala在new ArrayList时不需要指定String类型参数,这是编译器的类型推断起了效用(显示指定也是可以的)。
Scala中定义范型类的方法也基本与Java相同。下面是通过范型用Java定义的不可变单方向列表类。这里在类名Link后声明了用< >括着的类型参数T。这个类型参数T在Link类的定义中可以像一般类型那样使用。
- class Link< T> {
- final T head;
- final Link< T> tail;
- Link(T head, Link< T> tail) {
- this.head = head;
- this.tail = tail;
- }
- }
同样可以用Scala来定义与上述完全相同的范型列表。
- class Link[T](val head: T, val tail: Link[T])
从此可知,除了一些细微的标识差别,Scala中也可以方便地使用范型。
范型的协变与逆变
光从到此为止的说明来看,可能有人会以为Scala是仅仅把Java中的范型改变了一下标识符号。但是Scala中的范型有几个与Java不同的明显差异,其中之一就是这里提到的协变与逆变。
协变
范型中所谓的协变大致来说是这样的东西。首先假设有类G(或者接口和特征)和类型T1、T2。在T1是T2的子类的情况下如果G< T1>也是G< T2>的子类,那么类G就是协变的。
仅如此说明的话比较难以理解,那就举例说明一下。如下所示,假设有类型为java.util.List< Object>的变量s1和类型为java.util.List< String>的变量s2。
- java.util.List< Object> s1 = ...;
- java.util.List< String> s2 = ...;
String是Object的子类,Java中并不允许将s2赋值给s1,将会产生编译错误。因此,虽然String是Object的子类,但是java.util.List< String>并不是java.util.List< Object>的子类,所以用Java的范型所定义的类或接口并不是协变的。这并不是由于Java范型的灵活性不好,而是因为协变的范型在保证类型的安全性上有一些问题。
假定允许s1=s2;。s1是容纳Object类型的元素的,所以如下所示可以加入java.util.Date类型的对象。
- s1.add(new java.util.Date());
但是由于语句s1=s2;,s1被指向了s2,这样容纳String元素的List变量s2就可以加入java.util.Date对象了。这样好不容易通过范型来保证的类型安全性(java.util.List< String>里只有String)就被破坏了。正因为有如此问题所以Java的范型不是协变的。
附带提一下,对于Java5之前就存在的数组来说,数组的元素类型A如果是数组元素类型B的子类,那么A的数组类型也是B的数组类型的子类,也就是说Java中的数组是协变的。这样一来,如下所示即使是违背了类型安全性的数组之间的赋值(没有强制类型转换)代码也能通过编译器检查。
- String[] s2 = new String[1];
- Object[] s1 = s2;
- s1[0] = new java.util.Date(); //执行时抛出ArrayStoreException异常
如上所述,Java中的范型不是协变的是有理由的,但是有些情况下这种限制表现得过于强了。比如,以使用前述的不可变Link类为例。这种情况下,一旦创建不可变Link的实例之后,与Java的List不同,对于该实例是不能进行写操作(如add)的,这样的话将Link< String>赋值给Link< Object>也就可以认为没有问题了,但是在Java中这是不允许的。
Scala的范型,在没有特定指定的情况下也是和Java一样,是非协变的。例如使用前述的Link类编写如下代码后将会出现编译错误。
- val link: Link[Any] = new Link[String]("FOO", null)
- ...
错误提示如下。叙述的错误原因是在应该出现Ling[Any]的地方但是出现了Link[String],而这正是Link不是协变的结果。
- fragment of Link.scala):2: error: type mismatch;
- found : this.Link[String]
- required: this.Link[Any]
- val link: Link[Any] = new Link[String]("FOO", null)
但是,Scala的类或特征的范型定义中,如果在类型参数前面加入+符号,就可以使类或特征变为协变了。下面是在Scala中定义协变类的实验。题材是前述的Link类,在类型参数T前加了一个+符号。
- class Link[+T](val head: T, val tail: Link[T])
把Link类如此定义之后,前面出现编译错误的代码就可以顺利通过编译了。另外,如果试图定义不能保证类型安全的协变范型将会出现编译错误。例如在定义非可变的数据结构时,这种限制就会带来一些问题。例如对于前面的Link类,追加一个将作为参数传入的元素放在列表头并返回新列表的方法prepend。
- class Link[+T](val head: T, val tail: Link[T]) {
- def prepend(newHead: T): Link[T] = new Link(newHead, this)
- }
prepend方法并没有改变原来Link类实例的状态,因该是没有问题的。但是,编译之后会产生如下编译错误。
- ink.scala:2: error: covariant type T occurs in contravariant position in type T
- of value newHead
- def prepend(newHead: T): Link[T] = new Link(newHead, this)
实际上,范型变为协变之后就不能把类型参数不加修改的放在成员方法的参数上(这里是newHead)了。但是,通过将成员方法定义为范型,并按照如下所示描述后就可以避免该问题了(具体原因这里略而不谈)。
- class Link[+T](val head: T, val tail: Link[T]) {
- def prepend[U >: T](newHead: U): Link[U] = new Link(newHead, this)
- }
在Java里也可以定义范型方法,正如范型类型定义,通过用类型参数来参数化方法,从而定义了类型安全的范型方法。例如连载第五回出场的List类的map方法就是范型方法。
- verride final def map[B](f : (A) => B) : List[B]
map方法将以参数形式传入的函数f应用于List的所有元素,并将函数的应用结果组成列表后返回。但是参数函数f的返回结果是什么在定义map方法是不知道的,所以用类型参数B来使map成为范型方法,从而使它可以通用于各种类型了。
范型方法是通过在方法名后直接用[..]来括住类型参数方式来定义的。用[]括住的类型参数在方法中可以作为一般类型来使用。而且在类型参数之后加上>:或< :符号后,可以将类型参数所表示的类型限制为某一类型子类或父类。例如,[U< :T]的情况下,U必须是T的子类;[U>:T]的情况下,U必须是T的父类。
逆变
另一方面,范型中的逆变是这样的东西。首先假设有类G(或者接口和特征)和类型T1、T2。在T1是T2的子类的情况下如果G< T2>也是G< T1>的子类(注意左右与协变是相反的),那么类G就是逆变的。
与协变一样,下面举例说明一下。首先假设有类型为java.util.List< Object>的变量s1,类型为java.util.List< String>的变量s2。
- java.util.List< Object> s1 = ...;
- java.util.List< String> s2 = ...;
String是Object的子类,由于Java的范型规则不允许表达式s2=s1,所以将会出现编译错误。这里虽然String是Object的子类,但是java.util.List< Object>并不是java.util.List< String>的子类,所以Java的范型并不是逆变的。如果Java的范型是逆变的话,那同协变时情况一样,将会产生类型安全上的问题。
假设允许表达式s2=s1。由于s2的元素类型是String,所以从列表中取出元素后返回的类型因该是String。因此,如下代码因该是成立的。
- String str = s2.get(0);
但是,s2所指的列表s1的元素类型是Object,所以s1列表中的取出的元素并不仅限于String,这在类型安全性上就有问题了。
对于Scala的范型,如果没有特别指示,与Java一样也不是逆变的。假设有如下含有apply方法的LessTan类(apply方法的逻辑是当a小于b时返回true,否则返回false)。
- abstract class LessThan[T] {
- def apply(a: T, b: T): Boolean
- }
如下使用了LessThan类的方法将会出现编译错误。
- val hashCodeLt: LessThan[Any] = new LessThan[Any] {
- def apply(a: Any, b: Any): Boolean = a.hashCode < b.hashCode
- }
- val strLT: LessThan[String] = hashCodeLt
- ...
编译错误的文本如下。显示的错误原因是在因该出现LessThan[String]的地方出现了LessThan[Any],由此看见LessThan类不是逆变的。
- (fragment of Comparator.scala):5: error: type mismatch;
- found : this.LessThan[Any]
- required: this.LessThan[String]
- val strLT: LessThan[String] = hashCodeLt
但是,在类或特征的定义中,在类型参数之前加上一个-符号,就可定义逆变范型类和特征了。下面尝试一下定义Scala的逆变类。题材是前面的LessThan类,如下所示在LessThan定义的类型参数前加上-符号。
- abstract class LessThan[-T] { def apply(a: T, b: T): Boolean }
将LessThan类如此定义之后,前面错误代码的编译就可以通过了。另外,如果将类型定义为逆变后会发生类型安全性问题,则编译器将报编译错误。
实存(Existantial)类型
前面说过了Java范型没有协变和逆变特性,但是通过使用Java的通配符功能后可以获得与协变与逆变相近的效果。通配符不是标记在类型定义的地方,而是在类型使用的地方,可以在使用类型处加上G< ? extends T1>或G< ? super T1>。
前者与协变相对应,当T2是T1的子类时,G< T2>是G< ? exnteds T1>的子类。后者与逆变相对应,T1是T2的子类时,G< T2>是G< ? super T1>的子类。因此,以下的代码将能正常编译。
- java.util.List< String> s1 = ...;
- java.util.List< ? extends Object> s2 = s1; //对应协变
- java.util.List< Object> s3 = ...;
- java.util.List< ? super String> s4 = s3; //对应逆变
- ...
由于通配符是标记在使用类型的地方,所以每次定义协变或逆变的变量时都要使用它,缺点是比较麻烦。另一方面,即使是没有定义为协变或逆变的范型类型,也可以将其以协变或逆变的方式处理是它的优点。
Scala中也可以通过使用实存类型方法类实现与Java中通配符相同的功能。例如,下述Scala代码可以实现与上述Java代码相同的功能。
- //java.util.List[_ < : Any] (省略形式)
- var s1: java.util.List[String] = new java.util.ArrayList
- var s2: java.util.List[T] forSome { type T < : Any } = s1
- //java.util.List[_ >: String] (省略形式)
- var s3: java.util.List[Any] = new java.util.ArrayList
- var s4: java.util.List[T] forSome { type T >: String} = s3
- ...
结构(Structural)类型
在类似于Java的语言中只有在类定义好之后才能确定他们的继承关系。假设有如下A、B、C三个Java类定义。
- class A {
- void call() {}
- }
- class B extends A {
- void call() {}
- }
- class C {
- void call() {}
- }
这时假如有方法void foo(A a),那么类型A和B的实例可以作为参数传递给它,但是类型C却不能传递(虽然C中同样定义了方法call)。
这是由于,相比于类B通过exnteds A语句明确标识了与A的继承关系,而C则没有明确标示出与A的继承关系,这样C就不是A的子类了。这对于Java和C#(C++的情况特殊除外)这类静态语言来说是理所当然的事,所以意识到的人因该不多吧。另一方面,在动态语言社区中,把这称为duck typing,较普遍的看法是“无论是否有继承关系,只要在对象中存在需要的方法就可以了。”。以刚才的代码为例,A、B和C中都定义了call方法,如果foo方法中只调用call方法的话,那么类C的实例也可以作为参数传给foo。
时常听到这种说法“正是由于动态语言中没有静态类型检查所以才可以使用duck typing功能。”。但是稍微仔细想一下,虽然是静态语言,但是在编译时还是知道类型持有了哪些方法的。理论上,即使不牺牲静态类型检查,也应该可以描述只要含有某一方法集合就OK的类型的。
Scala中通过结构类型来描述这种类型。结构类型的使用方法比较简单,只要在类型声明的地方,用{}将所需方法的声明括起来就可以了。
- def foo(callable: { def call: Unit }) = ...
另外在列举方法的个数比较多的情况下,可以如下所示来定义别名,这样就不用每次都列出所有方法了,只需使用结构类型的别名即可。
- type Callable = { def call: Unit }
复合(Compound)类型
在使用Java时,是否有过想用“即继承了类型A又实现了接口B”的类型的想法呢?Java中除了类型参数的限制之外,对于这种类型也没有定义方法。然而,Scala中则可以非常简单地描述这种类型。描述方法就是在一般的类名称之后用with来连接附加的类型。
例如对于如下的变量f来说,只要是实现了java.io.Closeable和Readable接口的对象,谁都可以赋值给变量f。
- var f: java.io.Closeable with Readable = ..
结束语
虽然稍显简略,本文介绍了一下Scala的类型系统相关的功能,怎么样啊?像路劲依赖类型等,本文中还有一些功能没有能够介绍完,如果有兴趣的话,大家可以阅览一下Scala的语言规范或者Scala官方网站上的论文。
【编辑推荐】