Scala講座:類型系統(tǒng)和相關(guān)功能
本文節(jié)選自最近在日本十分流行的Scala講座系列的第六篇,由JavaEye的fineqtbull翻譯。本系列的作者牛尾剛在日本寫過不少有關(guān)Java和Ruby的書籍,相當(dāng)受歡迎。
概要
到上次為止由羽生田先生介紹了Scala語法的特點(diǎn),這一講我作為嘉賓來介紹一下Scala的類型系統(tǒng)和相關(guān)功能。本次介紹的重點(diǎn)是Java與Scala之間類層次的差異、范型的協(xié)變與逆變、實(shí)存類型(Existential Type)、結(jié)構(gòu)類型(Structural Type)和復(fù)合類型(Compound Type)。
與Java相似之處
Scala類型系統(tǒng)的基礎(chǔ)部分是與Java非常相像的。Scala與Java一樣有單一的根類,Java通過接口來實(shí)現(xiàn)多重繼承,而Scala則通過特征(trait)來實(shí)現(xiàn)(Scala的特征可以包含實(shí)現(xiàn)代碼,這當(dāng)然是與Java接口不同的。不過由于特征自己具有類型的功能,所以對(duì)于沒有包含實(shí)現(xiàn)代碼的特征,可以認(rèn)為與Java的接口是等價(jià)的)。
不過在幾點(diǎn)上面Scala具有與Java不同的部分,或者相比Java增加的功能部分。下文將以與Java相比的不同點(diǎn)或增加點(diǎn)為重點(diǎn)來說明一下Scala的類型系統(tǒng)。
Scala的類層次(1) - Any、AnyVal、AnyRef
本連載的第四回也提到過,Scala的類型層次與Java的相應(yīng)部分是非常相似的。首先,Scala中存在單一的根類Any,所有類型都直接或間接地由Any類繼承而來。這與Java中的所有引用類型的根類是java.lang.Object是一樣的。
另一方面也有與Java不同的部分。首先Scala不存在類似于Java中的基礎(chǔ)(Primitive)類型。那是
怎么一回事呢?Scala中所有與基礎(chǔ)類型相當(dāng)?shù)念愋投汲蔀榱祟悾⑶疫@些類都是繼承了Any的AnyVal類的子類。
另外,所有的引用類型都成AnyRef類的間接或直接子類。前面說了Any類類似于Java中的java.lang.Object類,但是從實(shí)際意義上來看,因該說對(duì)應(yīng)于java.lang.Object的因該是AnyRef(図 3-1的a)。
不過離題一下,對(duì)于Scala中相當(dāng)于基礎(chǔ)類型的類,可以把整數(shù)的(Byte、Short、Int、Long)和浮點(diǎn)數(shù)的(Float、Double)看作是相鄰的兄弟類關(guān)系,那Java中可以默認(rèn)轉(zhuǎn)換的比如byte->int,在Scala中是否需要顯示的類型轉(zhuǎn)換呢,如果需要的話那大家會(huì)覺得好麻煩呀。實(shí)際上Scala中具有使用戶能夠定義自己的默認(rèn)類型轉(zhuǎn)換功能的隱式轉(zhuǎn)換(implicit conversion)功能。對(duì)于類似于Java中的byte->int、float->double等默認(rèn)轉(zhuǎn)換功能,在Scala標(biāo)準(zhǔn)庫(kù)中定義了與此相當(dāng)?shù)碾[式轉(zhuǎn)換功能,所以用戶是不需要顯示轉(zhuǎn)換的。這個(gè)隱式轉(zhuǎn)換是非常有趣的功能,在以后的連載里可能會(huì)有詳細(xì)說明。
Scala的類層次(2) - Nothing、Null
與Java不同的是,Scala中存在所謂的底(bottom)類型,那就是Nothing類。Nothing是所有類型的子類,也就是說可以將Nothing類型賦值給任意類型的變量,但是Nothing類型的值并不存在。
大家可能認(rèn)為“沒有值的類型有什么用呢?”。但是Nothing類型絕對(duì)在,表示沒有返回值的函數(shù)的返回類型,或者在后述的范型中表示空的集等方面發(fā)揮著重要的作用。
另外還存在是所有的引用類型(AnyRef)的子類,可以賦值給所有引用類型變量的類型Null。Null類型的值只有null(實(shí)際上Java中也有Null類型,擔(dān)負(fù)著與Scala中Null類型相似的任務(wù)。與Scala不同的是,Java中沒有顯示定義Null類型的機(jī)會(huì),所以基本上沒有人會(huì)意識(shí)得到的)。(図 3-1的b)表示了上述類型間的關(guān)系。
范型基礎(chǔ)
一句話來說,范型就是定義以類型為參數(shù)的類或接口(Scala中為特征)的功能。Java里從JDK5開始就有了范型,想必知道的人應(yīng)該比較多了,下面就簡(jiǎn)單舉例說明一下。
例如,假設(shè)有如下的代碼片段。這里java.util.List是范型接口,String就是賦給它的類型參數(shù)。
- java.util.List< String> strs = new java.util.ArrayList< String>();
這樣,就可以用如下方法將String類型(或子類型)的對(duì)象加入List中了。
- strs.add("hoge");
如下所示,如將String以外的對(duì)象加入List則會(huì)發(fā)生編譯錯(cuò)誤。
- strs.add(new java.util.Date());
這樣一來,就可以開發(fā)類型安全的通用集(collection)庫(kù)了。在Java5之前的集庫(kù)是用Object來實(shí)現(xiàn)的。但是向集中加入元素時(shí)并沒有進(jìn)行正確的類型檢查,而且從集中取出元素時(shí)還要做強(qiáng)制的類型轉(zhuǎn)換,導(dǎo)致舊的集庫(kù)在類型安全方面有一些問題。進(jìn)一步來說,光從類型定義看不出該集包含的是何種元素,所以在可讀性方面也有不足。
Scala的范型與Java是非常相似的,基本上可以同樣地使用,只是在標(biāo)記方法上有些區(qū)別。以下是同剛才Java代碼基本相同的Scala代碼。
- var strs: java.util.List[String] = new java.util.ArrayList
Scala中用[..]來代替了Java中的< ..>來表現(xiàn)類型參數(shù)表。附帶提一下,與Java有一點(diǎn)小的不同,Scala在new ArrayList時(shí)不需要指定String類型參數(shù),這是編譯器的類型推斷起了效用(顯示指定也是可以的)。
Scala中定義范型類的方法也基本與Java相同。下面是通過范型用Java定義的不可變單方向列表類。這里在類名Link后聲明了用< >括著的類型參數(shù)T。這個(gè)類型參數(shù)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])
從此可知,除了一些細(xì)微的標(biāo)識(shí)差別,Scala中也可以方便地使用范型。
范型的協(xié)變與逆變
光從到此為止的說明來看,可能有人會(huì)以為Scala是僅僅把Java中的范型改變了一下標(biāo)識(shí)符號(hào)。但是Scala中的范型有幾個(gè)與Java不同的明顯差異,其中之一就是這里提到的協(xié)變與逆變。
協(xié)變
范型中所謂的協(xié)變大致來說是這樣的東西。首先假設(shè)有類G(或者接口和特征)和類型T1、T2。在T1是T2的子類的情況下如果G< T1>也是G< T2>的子類,那么類G就是協(xié)變的。
僅如此說明的話比較難以理解,那就舉例說明一下。如下所示,假設(shè)有類型為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,將會(huì)產(chǎn)生編譯錯(cuò)誤。因此,雖然String是Object的子類,但是java.util.List< String>并不是java.util.List< Object>的子類,所以用Java的范型所定義的類或接口并不是協(xié)變的。這并不是由于Java范型的靈活性不好,而是因?yàn)閰f(xié)變的范型在保證類型的安全性上有一些問題。
假定允許s1=s2;。s1是容納Object類型的元素的,所以如下所示可以加入java.util.Date類型的對(duì)象。
- s1.add(new java.util.Date());
但是由于語句s1=s2;,s1被指向了s2,這樣容納String元素的List變量s2就可以加入java.util.Date對(duì)象了。這樣好不容易通過范型來保證的類型安全性(java.util.List< String>里只有String)就被破壞了。正因?yàn)橛腥绱藛栴}所以Java的范型不是協(xié)變的。
附帶提一下,對(duì)于Java5之前就存在的數(shù)組來說,數(shù)組的元素類型A如果是數(shù)組元素類型B的子類,那么A的數(shù)組類型也是B的數(shù)組類型的子類,也就是說Java中的數(shù)組是協(xié)變的。這樣一來,如下所示即使是違背了類型安全性的數(shù)組之間的賦值(沒有強(qiáng)制類型轉(zhuǎn)換)代碼也能通過編譯器檢查。
- String[] s2 = new String[1];
- Object[] s1 = s2;
- s1[0] = new java.util.Date(); //執(zhí)行時(shí)拋出ArrayStoreException異常
如上所述,Java中的范型不是協(xié)變的是有理由的,但是有些情況下這種限制表現(xiàn)得過于強(qiáng)了。比如,以使用前述的不可變Link類為例。這種情況下,一旦創(chuàng)建不可變Link的實(shí)例之后,與Java的List不同,對(duì)于該實(shí)例是不能進(jìn)行寫操作(如add)的,這樣的話將Link< String>賦值給Link< Object>也就可以認(rèn)為沒有問題了,但是在Java中這是不允許的。
Scala的范型,在沒有特定指定的情況下也是和Java一樣,是非協(xié)變的。例如使用前述的Link類編寫如下代碼后將會(huì)出現(xiàn)編譯錯(cuò)誤。
- val link: Link[Any] = new Link[String]("FOO", null)
- ...
錯(cuò)誤提示如下。敘述的錯(cuò)誤原因是在應(yīng)該出現(xiàn)Ling[Any]的地方但是出現(xiàn)了Link[String],而這正是Link不是協(xié)變的結(jié)果。
- 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的類或特征的范型定義中,如果在類型參數(shù)前面加入+符號(hào),就可以使類或特征變?yōu)閰f(xié)變了。下面是在Scala中定義協(xié)變類的實(shí)驗(yàn)。題材是前述的Link類,在類型參數(shù)T前加了一個(gè)+符號(hào)。
- class Link[+T](val head: T, val tail: Link[T])
把Link類如此定義之后,前面出現(xiàn)編譯錯(cuò)誤的代碼就可以順利通過編譯了。另外,如果試圖定義不能保證類型安全的協(xié)變范型將會(huì)出現(xiàn)編譯錯(cuò)誤。例如在定義非可變的數(shù)據(jù)結(jié)構(gòu)時(shí),這種限制就會(huì)帶來一些問題。例如對(duì)于前面的Link類,追加一個(gè)將作為參數(shù)傳入的元素放在列表頭并返回新列表的方法prepend。
- class Link[+T](val head: T, val tail: Link[T]) {
- def prepend(newHead: T): Link[T] = new Link(newHead, this)
- }
prepend方法并沒有改變?cè)瓉鞮ink類實(shí)例的狀態(tài),因該是沒有問題的。但是,編譯之后會(huì)產(chǎn)生如下編譯錯(cuò)誤。
- 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)
實(shí)際上,范型變?yōu)閰f(xié)變之后就不能把類型參數(shù)不加修改的放在成員方法的參數(shù)上(這里是newHead)了。但是,通過將成員方法定義為范型,并按照如下所示描述后就可以避免該問題了(具體原因這里略而不談)。
- class Link[+T](val head: T, val tail: Link[T]) {
- def prepend[U >: T](newHead: U): Link[U] = new Link(newHead, this)
- }
在Java里也可以定義范型方法,正如范型類型定義,通過用類型參數(shù)來參數(shù)化方法,從而定義了類型安全的范型方法。例如連載第五回出場(chǎng)的List類的map方法就是范型方法。
- verride final def map[B](f : (A) => B) : List[B]
map方法將以參數(shù)形式傳入的函數(shù)f應(yīng)用于List的所有元素,并將函數(shù)的應(yīng)用結(jié)果組成列表后返回。但是參數(shù)函數(shù)f的返回結(jié)果是什么在定義map方法是不知道的,所以用類型參數(shù)B來使map成為范型方法,從而使它可以通用于各種類型了。
范型方法是通過在方法名后直接用[..]來括住類型參數(shù)方式來定義的。用[]括住的類型參數(shù)在方法中可以作為一般類型來使用。而且在類型參數(shù)之后加上>:或< :符號(hào)后,可以將類型參數(shù)所表示的類型限制為某一類型子類或父類。例如,[U< :T]的情況下,U必須是T的子類;[U>:T]的情況下,U必須是T的父類。
逆變
另一方面,范型中的逆變是這樣的東西。首先假設(shè)有類G(或者接口和特征)和類型T1、T2。在T1是T2的子類的情況下如果G< T2>也是G< T1>的子類(注意左右與協(xié)變是相反的),那么類G就是逆變的。
與協(xié)變一樣,下面舉例說明一下。首先假設(shè)有類型為java.util.List< Object>的變量s1,類型為java.util.List< String>的變量s2。
- java.util.List< Object> s1 = ...;
- java.util.List< String> s2 = ...;
String是Object的子類,由于Java的范型規(guī)則不允許表達(dá)式s2=s1,所以將會(huì)出現(xiàn)編譯錯(cuò)誤。這里雖然String是Object的子類,但是java.util.List< Object>并不是java.util.List< String>的子類,所以Java的范型并不是逆變的。如果Java的范型是逆變的話,那同協(xié)變時(shí)情況一樣,將會(huì)產(chǎn)生類型安全上的問題。
假設(shè)允許表達(dá)式s2=s1。由于s2的元素類型是String,所以從列表中取出元素后返回的類型因該是String。因此,如下代碼因該是成立的。
- String str = s2.get(0);
但是,s2所指的列表s1的元素類型是Object,所以s1列表中的取出的元素并不僅限于String,這在類型安全性上就有問題了。
對(duì)于Scala的范型,如果沒有特別指示,與Java一樣也不是逆變的。假設(shè)有如下含有apply方法的LessTan類(apply方法的邏輯是當(dāng)a小于b時(shí)返回true,否則返回false)。
- abstract class LessThan[T] {
- def apply(a: T, b: T): Boolean
- }
如下使用了LessThan類的方法將會(huì)出現(xiàn)編譯錯(cuò)誤。
- val hashCodeLt: LessThan[Any] = new LessThan[Any] {
- def apply(a: Any, b: Any): Boolean = a.hashCode < b.hashCode
- }
- val strLT: LessThan[String] = hashCodeLt
- ...
編譯錯(cuò)誤的文本如下。顯示的錯(cuò)誤原因是在因該出現(xiàn)LessThan[String]的地方出現(xiàn)了LessThan[Any],由此看見LessThan類不是逆變的。
- (fragment of Comparator.scala):5: error: type mismatch;
- found : this.LessThan[Any]
- required: this.LessThan[String]
- val strLT: LessThan[String] = hashCodeLt
但是,在類或特征的定義中,在類型參數(shù)之前加上一個(gè)-符號(hào),就可定義逆變范型類和特征了。下面嘗試一下定義Scala的逆變類。題材是前面的LessThan類,如下所示在LessThan定義的類型參數(shù)前加上-符號(hào)。
- abstract class LessThan[-T] { def apply(a: T, b: T): Boolean }
將LessThan類如此定義之后,前面錯(cuò)誤代碼的編譯就可以通過了。另外,如果將類型定義為逆變后會(huì)發(fā)生類型安全性問題,則編譯器將報(bào)編譯錯(cuò)誤。
實(shí)存(Existantial)類型
前面說過了Java范型沒有協(xié)變和逆變特性,但是通過使用Java的通配符功能后可以獲得與協(xié)變與逆變相近的效果。通配符不是標(biāo)記在類型定義的地方,而是在類型使用的地方,可以在使用類型處加上G< ? extends T1>或G< ? super T1>。
前者與協(xié)變相對(duì)應(yīng),當(dāng)T2是T1的子類時(shí),G< T2>是G< ? exnteds T1>的子類。后者與逆變相對(duì)應(yīng),T1是T2的子類時(shí),G< T2>是G< ? super T1>的子類。因此,以下的代碼將能正常編譯。
- java.util.List< String> s1 = ...;
- java.util.List< ? extends Object> s2 = s1; //對(duì)應(yīng)協(xié)變
- java.util.List< Object> s3 = ...;
- java.util.List< ? super String> s4 = s3; //對(duì)應(yīng)逆變
- ...
由于通配符是標(biāo)記在使用類型的地方,所以每次定義協(xié)變或逆變的變量時(shí)都要使用它,缺點(diǎn)是比較麻煩。另一方面,即使是沒有定義為協(xié)變或逆變的范型類型,也可以將其以協(xié)變或逆變的方式處理是它的優(yōu)點(diǎn)。
Scala中也可以通過使用實(shí)存類型方法類實(shí)現(xiàn)與Java中通配符相同的功能。例如,下述Scala代碼可以實(shí)現(xiàn)與上述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
- ...
結(jié)構(gòu)(Structural)類型
在類似于Java的語言中只有在類定義好之后才能確定他們的繼承關(guān)系。假設(shè)有如下A、B、C三個(gè)Java類定義。
- class A {
- void call() {}
- }
- class B extends A {
- void call() {}
- }
- class C {
- void call() {}
- }
這時(shí)假如有方法void foo(A a),那么類型A和B的實(shí)例可以作為參數(shù)傳遞給它,但是類型C卻不能傳遞(雖然C中同樣定義了方法call)。
這是由于,相比于類B通過exnteds A語句明確標(biāo)識(shí)了與A的繼承關(guān)系,而C則沒有明確標(biāo)示出與A的繼承關(guān)系,這樣C就不是A的子類了。這對(duì)于Java和C#(C++的情況特殊除外)這類靜態(tài)語言來說是理所當(dāng)然的事,所以意識(shí)到的人因該不多吧。另一方面,在動(dòng)態(tài)語言社區(qū)中,把這稱為duck typing,較普遍的看法是“無論是否有繼承關(guān)系,只要在對(duì)象中存在需要的方法就可以了?!薄R詣偛诺拇a為例,A、B和C中都定義了call方法,如果foo方法中只調(diào)用call方法的話,那么類C的實(shí)例也可以作為參數(shù)傳給foo。
時(shí)常聽到這種說法“正是由于動(dòng)態(tài)語言中沒有靜態(tài)類型檢查所以才可以使用duck typing功能?!?。但是稍微仔細(xì)想一下,雖然是靜態(tài)語言,但是在編譯時(shí)還是知道類型持有了哪些方法的。理論上,即使不犧牲靜態(tài)類型檢查,也應(yīng)該可以描述只要含有某一方法集合就OK的類型的。
Scala中通過結(jié)構(gòu)類型來描述這種類型。結(jié)構(gòu)類型的使用方法比較簡(jiǎn)單,只要在類型聲明的地方,用{}將所需方法的聲明括起來就可以了。
- def foo(callable: { def call: Unit }) = ...
另外在列舉方法的個(gè)數(shù)比較多的情況下,可以如下所示來定義別名,這樣就不用每次都列出所有方法了,只需使用結(jié)構(gòu)類型的別名即可。
- type Callable = { def call: Unit }
復(fù)合(Compound)類型
在使用Java時(shí),是否有過想用“即繼承了類型A又實(shí)現(xiàn)了接口B”的類型的想法呢?Java中除了類型參數(shù)的限制之外,對(duì)于這種類型也沒有定義方法。然而,Scala中則可以非常簡(jiǎn)單地描述這種類型。描述方法就是在一般的類名稱之后用with來連接附加的類型。
例如對(duì)于如下的變量f來說,只要是實(shí)現(xiàn)了java.io.Closeable和Readable接口的對(duì)象,誰都可以賦值給變量f。
- var f: java.io.Closeable with Readable = ..
結(jié)束語
雖然稍顯簡(jiǎn)略,本文介紹了一下Scala的類型系統(tǒng)相關(guān)的功能,怎么樣?。肯衤穭乓蕾囶愋偷龋疚闹羞€有一些功能沒有能夠介紹完,如果有興趣的話,大家可以閱覽一下Scala的語言規(guī)范或者Scala官方網(wǎng)站上的論文。
【編輯推薦】