讀古今文學網 > Java 8實戰 > 第15章 面向對像和函數式編程的混合:Java 8和Scala的比較 >

第15章 面向對像和函數式編程的混合:Java 8和Scala的比較

本章內容

  • 什麼是Scala語言

  • Java 8與Scala是如何相生相承的

  • Scala中的函數與Java 8中的函數有哪些區別

  • 類和trait

Scala是一種混合了面向對像和函數式編程的語言。它常常被看作Java的一種替代語言,程序員們希望在運行於JVM上的靜態類型語言中使用函數式特性,同時又期望保持Java體驗的一致性。和Java比較起來,Scala提供了更多的特性,包括更複雜的類型系統、類型推斷、模式匹配(我們在14.4節提到過)、定義域語言的結構等。除此之外,你可以在Scala代碼中直接使用任何一個Java類庫。

你可能會有這樣的疑惑,我們為什麼要在一本介紹Java 8的書裡特別設計一章討論Scala。本書的絕大部分內容都在介紹如何在Java中應用函數式編程。Scala和Java 8極其類似,它們都支持對集合的函數式處理(類似於對Stream的操作)、一等函數、默認方法。不過Scala將這些思想向前又推進了一大步:它為實現這些思想提供了大量的特性,這方面它領先了Java 8一大截。我們相信你會發現,對比Scala和Java 8在實現方式上的不同以及瞭解Java 8目前的局限是非常有趣的。通過這一章,我們希望能針對這些問題為你提供一些線索,解答一些疑惑。

請記住,本章的目的並非讓你掌握如何編寫純粹的Scala代碼,或者瞭解Scala的方方面面。不少的特性,比如模式匹配,在Scala中是天然支持的,也非常容易理解,不過這些特性在Java 8中卻並未提供,這部分內容我們在這裡不會涉及。本章著重對比Java 8中新引入的特性和該特性在Scala中的實現,幫助你更全面地理解該特性。比如,你會發現,用Scala重新實現原先用Java完成的代碼更簡單,可讀性也更好。

本章從對Scala的介紹入手:讓你瞭解如何使用Scala編寫簡單的程序,以及如何處理集合。緊接著我們會討論Scala中的函數式,包括一等函數、閉包以及科裡化。最後,我們會一起看看Scala中的類,以及一種名為trait的特性,它是Scala中帶默認方法的接口。

15.1 Scala簡介

本節會簡要地介紹Scala的一些基本特性,讓你有一個比較直觀的感受:到底簡單的Scala程序怎麼編寫。我們從一個略微改動的Hello World示例入手,該程序會以兩種方式編寫,一種以命令式的風格編寫,另一種以函數式的風格編寫。接著,我們會看看Scala支持哪些數據結構——ListSetMapStreamTuple以及Option——並將它們與Java 8中對應的數據結構一一進行比較。最後,我們會介紹trait,它是Scala中接口的替代品,支持在對像實例化時對方法進行繼承。

15.1.1 你好,啤酒

讓我們看一個簡單的例子,這樣你能對Scala的語法、語言特性,以及它與Java的差異有一個比較直觀的認識。我們對經典的Hello World示例進行了微調,讓我們來點兒啤酒。你希望在屏幕上打印輸出下面這些內容:

Hello 2 bottles of beer
Hello 3 bottles of beer
Hello 4 bottles of beer
Hello 5 bottles of beer
Hello 6 bottles of beer

  

1. 命令式Scala

下面這段代碼中,Scala以命令式的風格打印輸出這段內容:

object Beer {
  def main(args: Array[String]){
    var n : Int = 2
    while( n <= 6 ){
      println(s"Hello ${n} bottles of beer")    ←─在字符串中插值
      n += 1
    }
  }
}

  

如何運行這段代碼的指導信息可以在Scala的官方網站找到1 。這段代碼看起來和你用Java編寫的程序相當類似。它的結構和Java程序幾乎一樣:它包含了一個名為main的方法,該方法接受一個由參數構成的數組(類型註釋遵循這樣的語法s : String,不像Java那樣用String s)。由於main方法不返回值,所以使用Scala不需要像Java那樣聲明一個類型為void的返回值。

1參見http://www.scala-lang.org/documentation/getting-started.html。

注意 通常而言,在Scala中聲明非遞歸的方法時,不需要顯式地返回類型,因為Scala會自動地替你推斷生成一個。

轉入main的方法體之前,我們想先討論下對象的聲明。不管怎樣,Java中的main方法都需要在某個類中聲明。對象的聲明產生了一個單例的對象:它聲明了一個對象,比如Bear,與此同時又對其進行了實例化。整個過程中只有一個實例被創建。這是第一個以經典的設計模式(即單例模式)實現語言特性的例子——盡量不拘一格地使用它!此外,你可以將對像聲明中的方法看成靜態的,這也是main方法的方法簽名中並未顯式地聲明為靜態的原因。

現在讓我們看看main的方法體。它看起來和Java非常類似,但是語句不需要再以分號結尾了(它成了一種可選項)。方法體中包含了一個while循環,它會遞增一個可修改變量n。通過預定義的方法println,你可以打印輸出n的每一個新值。println這一行還展示了Scala的另一個特性:字符串插值。字符串插值在字符串的字面量中內嵌變量和表達式。前面的這段代碼中,你在字符串字面量s"Hello ${n} bottles of beer"中直接使用了變量n。字符串前附加的插值操作符s,神奇地完成了這一轉變。而在Java中,你通常需要使用顯式的連接操作,比如"Hello " + n + " bottles of beer",才能達到同樣的效果。

2. 函數式Scala

那麼,Scala到底能帶來哪些好處呢?畢竟我們在本書裡主要討論的還是函數式。前面的這段代碼利用Java 8的新特性能以更加函數式的方式實現,如下所示:

public class Foo {
    public static void main(String args) {
        IntStream.rangeClosed(2, 6)
                 .forEach(n -> System.out.println("Hello " + n +
                                                  " bottles of beer"));
    }
}

  

如果以Scala來實現,它是下面這樣的:

object Beer {
  def main(args: Array[String]){
    2 to 6 foreach { n => println(s"Hello ${n} bottles of beer") }
  }
}

  

這種實現看起來和基於Java的版本有幾分相似,不過Scala的實現更加簡潔。首先,你使用表達式2 to 6創建了一個區間。這看起來相當特別: 2在這裡並非原始數據類型,在Scala中它是一個類型為Int的對象。Scala語言裡,任何事物都是對像;不像Java那樣,Scala沒有原始數據類型一說了。通過這種方式,Scala被轉變成為了純粹的面向對像語言。Scala語言中Int對像支持名為to的方法,它接受另一個Int對象,返回一個區間。所以,你還可以通過另一種方式實現這一語句,即2.to(6)。由於接受一個參數的方法可以採用中綴式表達,所以你可以用開頭的方式實現這一語句。緊接著,我們看到了foreach(這裡的e採用的是小寫),它和Java 8中的forEach(使用了大寫的E)也很類似。它是對一個區間進行操作的函數(這裡你可以再次使用中綴表達式),它可以接受Lambda表達式做參數,對區間的每一個元素順次執行操作。這裡Lambda表達式的語法和Java 8也非常類似,區別是箭頭的表示用=>替換了->2。前面的這段代碼是函數式的:因為就像我們早期使用while循環時示例的那樣,你並未修改任何變量。

2注意,在Scala語言中,我們使用“匿名函數”或者“閉包”(可以互相替換)來指代Java 8中的Lambda表達式。

15.1.2 基礎數據結構:ListSetMapTupleStream以及Option

幾杯啤酒之後,你一定已經止住口渴,精神一振了吧?大多數的程序都需要操縱和存儲數據,那麼,就讓我們一起看看如何在Scala中操作集合,以及它與Java 8中操作的不同。

1. 創建集合

在Scala中創建集合是非常簡單的,這主要歸功於它對簡潔性的一貫堅持。比如,創建一個Map,你可以用下面的方式:

val authorsToAge = Map("Raoul" -> 23, "Mario" -> 40, "Alan" -> 53)

  

這行代碼中,有幾件事情是我們首次碰到的。首先,你使用->語法輕而易舉地創建了一個Map,並完成了鍵到值的映射,整個過程令人吃驚地簡單。你不再需要像Java中那樣手工添加每一個元素:

Map<String, Integer> authorsToAge = new HashMap<>;
authorsToAge.put("Raoul", 23);
authorsToAge.put("Mario", 40);
authorsToAge.put("Alan", 53);

  

關於這一點,也有一些討論,希望在未來的Java版本中添加類似的語法糖,不過在Java 83中暫時還沒有這樣的特性。第二件讓人耳目一新的事是你可以選擇不對變量authorsToAge的類型進行註解。實際上,你可以編寫val authorsToAge : Map[String, Int]這樣的代碼,顯式地聲明變量類型,不過Scala可以替你推斷變量的類型(請注意,即便如此,代碼依舊是靜態檢查的!所有的變量在編譯時都具有確定的類型)。我們會在本章後續部分繼續討論這一特性。第三,你可以使用val關鍵字替換var。這二者之間存在什麼差別嗎?關鍵字val表明變量是只讀的,並由此不能被賦值(就像Java中聲明為final的變量一樣)。而關鍵字var表明變量是可以讀寫的。

3參見http://openjdk.java.net/jeps/186。

聽起來不錯,那麼其他的集合類型呢?你可以用同樣的方式輕鬆地創建List(一種單向鏈表)或者Set(不帶冗餘數據的集合),如下所示:

val authors = List("Raoul", "Mario", "Alan")
val numbers = Set(1, 1, 2, 3, 5, 8)

  

這裡的變量authors包含3個元素,而變量numbers包含5個元素。

2. 不可變與可變的比較

Scala的集合有一個重要的特質我們應該牢記在心,那就是我們之前創建的集合在默認情況下都是只讀的。這意味著它們從創建開始就不能修改。這是一種非常有用的特性,因為有了它,你知道任何時候訪問程序中的集合都會返回包含相同元素的集合。

那麼,你怎樣才能更新Scala語言中不可變的集合呢?回到前面章節介紹的術語,Scala中的這些集合都是持久化的:更新一個Scala集合會生成一個新的集合,這個新的集合和之前版本的集合共享大部分的內容,最終的結果是數據盡可能地實現了持久化,避免了圖14-3和圖14-4中那樣由於改變所引起的問題。由於具備這一屬性,你代碼的隱式數據依賴更少: 對你代碼中集合變更的困惑(比如在何處更新了集合,什麼時候做的更新)也會更少。

讓我們看一個實際的例子,具體分析下這一思想是如何影響你的程序設計的。下面這段代碼中,我們會為Set添加一個元素:

val numbers = Set(2, 5, 3);
val newNumbers = numbers + 8    ←─這裡的操作符+會將8添加到Set中,創建並返回一個新的Set對像
println(newNumbers)    ←─(2, 5, 3, 8)
println(numbers)    ←─(2, 5, 3)

  

這個例子中,原始Set對像中的數字沒有發生變更。實際的效果是該操作創建了一個新的Set,並向其中加入了一個新的元素。

注意,Scala語言並未強制你必須使用不可變集合,它只是讓你能更輕鬆地在你的代碼中應用不可變原則。scala.collection.mutable包中也包含了集合的可變版本。

不可修改與不可變的比較

Java中提供了多種方法創建不可修改的(unmodifiable)集合。下面的代碼中,變量newNumbers是集合Set對像numbers的一個只讀視圖:

Set<Integer> numbers = new HashSet<>;
Set<Integer> newNumbers = Collections.unmodifiableSet(numbers);

  

這意味著你無法通過操作變量newNumbers向其中加入新的元素。不過,不可修改集合僅僅是對可變集合進行了一層封裝。通過直接訪問numbers變量,你還是能向其中加入元素。

與此相反,不可變(immutable)集合確保了該集合在任何時候都不會發生變化,無論有多少個變量同時指向它。

我們在第14章介紹過如何創建一個持久化的數據結構:你需要創建一個不可變數據結構,該數據結構會保存它自身修改之前的版本。任何的修改都會創建一個更新的數據結構。

3. 使用集合

現在你已經瞭解了如何創建結合,你還需要瞭解如何使用這些集合開展工作。我們很快會看到Scala支持的集合操作和Stream API提供的操作極其類似。比如,在下面的代碼片段中,你會發現熟悉的filtermap,圖15-1對這段代碼邏輯進行了闡釋。

val fileLines = Source.fromFile("data.txt").getLines.toList
val linesLongUpper
  = fileLines.filter(l => l.length > 10)
             .map(l => l.toUpperCase)

 

圖 15-1 使用Scala的List實現類Stream操作

不用擔心第一行的內容,它實現的基本功能是將文件中的所有行轉換為一個字符串列表(類似Java 8提供的Files.readAllLines)。第二行創建了一個由兩個操作構成的流水線:

  • filter操作會過濾出所有長度超過10的行

  • map操作會將這些長的字符串統一轉換為大寫字符

這段代碼也可以用下面的方式實現:

val linesLongUpper
  = fileLines filter (_.length > 10) map(_.toUpperCase)

  

這段代碼使用了中綴表達式和下劃線(_),下劃線是一種佔位符,它按照位置匹配對應的參數。這個例子中,你可以將_.length解讀為l =>l.length。在傳遞給filtermap的函數中,下劃線會被綁定到待處理的line參數。

Scala的集合API提供了很多非常有用的操作。我們強烈建議你抽空瀏覽一下Scala的文檔,對這些API有一個大致的瞭解4。注意,Scala的集合類提供的功能比Stream API提供的功能還豐富很多,比如,Scala的集合類支持壓縮操作,你可以將兩個列表中的元素整合到一個列表中。通過學習,一定能大大增強你的功力。這些編程技巧在將來的Java版本中也可能會被Stream API所引入。

4www.scala-lang.org/api/current/#package中既包含了著名的包,也包含一些不那麼有名的包的介紹。

最後,還記得嗎?Java 8中你可以對Stream調用parallel方法,將流水線轉化為並行執行。Scala提供了類似的技巧;你只需要使用方法par就能實現同樣的效果:

val linesLongUpper
  = fileLines.par filter (_.length > 10) map(_.toUpperCase)

  

4. 元組

現在,讓我們看看另一個特性,該特性使用起來通常異常繁瑣,它就是元組。你可能希望使用元組將人的名字和電話號碼組合起來,同時又不希望額外聲明新的類,並對其進行實例化。你希望元組的結構就像:(“Raoul”,“+ 44 007007007”)、 (“Alan”,“+44 003133700”),諸如此類。

非常不幸,Java目前還不支持元組,所以你只能創建自己的數據結構。下面是一個簡單的Pair類定義:

public class Pair<X, Y> {
    public final X x;
    public final Y y;
    public Pair(X x, Y y){
        this.x = x;
        this.y = y;
    }
}

  

當然,你還需要顯式地實例化Pair對像:

Pair<String, String> raoul = new Pair<>("Raoul", "+ 44 007007007");
Pair<String, String> alan = new Pair<>("Alan", "+44 003133700");

  

好了,看起來一切順利,不過如果是三元組呢?如果是自定義大小的元組呢?這個問題就變得相當繁瑣,最終會影響你代碼的可讀性和可維護性。

Scala提供了名為元組字面量的特性來解決這一問題,這意味著你可以通過簡單的語法糖創建元組,就像普通的數學符號那樣:

val raoul = ("Raoul", "+ 44 887007007")
val alan = ("Alan", "+44 883133700")

  

Scala支持任意大小5的元組,所以下面的這些聲明都是合法的:

5元組中元素的最大上限為23。

val book = (2014, "Java 8 in Action", "Manning")    ←─元組類型為(Int, String, String)
val numbers = (42, 1337, 0, 3, 14)    ←─元組類型為(Int, Int, Int, Int, Int)

  

你可以依據它們的位置,通過存取器(accessor) _1_2(從1開始的一個序列)訪問元組中的元素,比如:

println(book._1)    ←─打印輸出2014
println(numbers._4)    ←─打印輸出3

  

是不是比Java語言中現有的實現方法簡單很多?好消息是關於將元組字面量引入到未來Java版本的討論正在進行中(我們會在第16章圍繞這一主題進行更深入的討論)。

5. Stream

到目前為止,我們討論的集合,包括ListSetMapTuple都是即時計算的(即在第一時間立刻進行計算)。當然,你也已經瞭解Java 8中的Stream是按需計算的(即延遲計算)。通過第5章,你知道由於這一特性,Stream可以表示無限的序列,同時又不消耗太多的內存。

Scala也提供了對應的數據結構,它採用延遲方式計算數據結構,名稱也叫Stream!不過Scala中的Stream提供了更加豐富的功能,讓Java中的Stream有些黯然失色。Scala中的Stream可以記錄它曾經計算出的值,所以之前的元素可以隨時進行訪問。除此之外,Stream還進行了索引,所以Stream中的元素可以像List那樣通過索引訪問。注意,這種抉擇也附帶著開銷,由於需要存儲這些額外的屬性,和Java 8中的Stream比起來,Scala版本的Stream內存的使用效率變低了,因為Scala中的Stream需要能夠回溯之前的元素,這意味著之前訪問過的元素都需要在內存“記錄下來”(即進行緩存)。

6. Option

另一個你熟悉的數據結構是Option。我們在第10章中討論過Java的OptionalOption是Java 8中Optional類型的Scala版本。我們建議你在設計API時盡可能地使用Optional,這種方式下,接口用戶只需要閱讀方法簽名就能瞭解他們是否應該傳遞一個optional值。我們應該盡量地用它替代null,避免發生空指針異常。

第10章中,你瞭解了我們可以使用Optional返回客戶的保險公司名稱——如果客戶的年齡超過設置的最低值,就返回該客戶對應的保險公司名稱,具體代碼如下:

public String getCarInsuranceName(Optional<Person> person, int minAge) {
    return person.filter(p -> p.getAge >= minAge)
                 .flatMap(Person::getCar)
                 .flatMap(Car::getInsurance)
                 .map(Insurance::getName)
                 .orElse("Unknown");
}

  

在Scala語言中,你可以使用Option使用Optional類似的方法實現該函數:

def getCarInsuranceName(person: Option[Person], minAge: Int) =
  person.filter(_.getAge >= minAge)
        .flatMap(_.getCar)
        .flatMap(_.getInsurance)
        .map(_.getName).getOrElse("Unknown")

  

這段代碼中除了getOrElse方法,其他的結構和方法你一定都非常熟悉,getOrElse是與Java 8中orElse等價的方法。你看到了嗎?在本書中學習的新概念能直接應用於其他語言!然而,不幸的是,為了保持同Java的兼容性,在Scala中依舊保持了null,不過我們極度不推薦你使用它。

注意 在前面的代碼中,你使用的是_.getCar(並未使用圓括號),而不是_.getCar (帶圓括號)。Scala語言中,執行方法調用時,如果不需要傳遞參數,那麼函數的圓括號是可以省略的。

15.2 函數

Scala中的函數可以看成為了完成某個任務而組合在一起的指令序列。它們對於抽像行為非常有幫助,是函數式編程的基石。

對於Java語言中的方法,你已經非常熟悉了:它們是與類相關的函數。你也已經瞭解了Lambda表達式,它可以看成一種匿名函數。跟Java比較起來,Scala為函數提供的特性要豐富得多,我們在這一節中會逐一講解。Scala提供了下面這些特性。

  • 函數類型,它是一種語法糖,體現了Java語言中函數描述符的思想,即,它是一種符號,表示了在函數接口中聲明的抽像方法的簽名。這些內容我們在第3章中都介紹過。

  • 能夠讀寫非本地變量的匿名函數,而Java中的Lambda表達式無法對非本地變量進行寫操作。

  • 對科裡化的支持,這意味著你可以將一個接受多個參數的函數拆分成一系列接受部分參數的函數。

15.2.1 Scala中的一等函數

函數在Scala語言中是一等值。這意味著它們可以像其他的值,比如Integer或者String那樣,作為參數傳遞,可以作為結果值返回。正如我們在前面章節所介紹的那樣,Java 8中的方法引用和Lambda表達式也可以看成一等函數。

讓我們看一個例子,看看Scala中的一等函數是如何工作的。我們假設你現在有一個字符串列表,列表中的值是朋友們發送給你的消息(tweet)。你希望依據不同的篩選條件對該列表進行過濾,比如,你可能想要找出所有提及Java這個詞或者短於某個長度的消息。你可以使用謂詞(返回一個布爾型結果的函數)定義這兩個篩選條件,代碼如下:

def isJavaMentioned(tweet: String) : Boolean = tweet.contains("Java")

def isShortTweet(tweet: String) : Boolean = tweet.length < 20

  

Scala語言中,你可以直接傳遞這兩個方法給內嵌的filter,如下所示(這和你在Java中使用方法引用將它們傳遞給某個函數大同小異):

val tweets = List(
    "I love the new features in Java 8",
    "How's it going?",
    "An SQL query walks into a bar, sees two tables and says 'Can I join you?'"
)

tweets.filter(isJavaMentioned).foreach(println)
tweets.filter(isShortTweet).foreach(println)

  

現在,讓我們一起審視下內嵌方法filter的函數簽名:

def filter[T](p: (T) => Boolean): List[T]

  

你可能會疑惑參數p到底代表的是什麼類型(即(T) => Boolean),因為在Java語言裡你期望看到的是一個函數接口!這其實是一種新的語法,Java中暫時還不支持。它描述的是一個函數類型。這裡它表示的是這樣一個函數,它接受類型為T的對象,返回一個布爾類型的值。Java語言中,它被編碼為Predicate<T>或者Function<T, Boolean>。所以它實際上和isJavaMentionedisShortTweet具有類似的函數簽名,所以你可以將它們作為參數傳遞給filter方法。Java 8語言的設計者們為了保持語言與之前版本的一致性,決定不引入類似的語法。對於一門語言的新版本,引入太多的新語法會增加它的學習成本,帶來額外學習負擔。

15.2.2 匿名函數和閉包

Scala也支持匿名函數。匿名函數和Lambda表達式的語法非常類似。下面的這個例子中,你將一個匿名函數賦值給了名為isLongTweet的變量,該匿名函數的功能是檢查給定的消息長度,判斷它是否超長:

val isLongTweet : String => Boolean    ←─這是一個函數類型的變量,它接受一個String參數,返回一個布爾類型的值
    = (tweet : String) => tweet.length > 60    ←─一個匿名函數

  

在新版的Java中,你可以使用Lambda表達式創建函數式接口的實例。Scala也提供了類似的機制。前面的這段代碼是Scala中聲明匿名類的語法糖。Function1(只帶一個參數的函數)提供了apply方法的實現:

val isLongTweet : String => Boolean
    = new Function1[String, Boolean] {
        def apply(tweet: String): Boolean = tweet.length > 60
      }

  

由於變量isLongTweet中保存了類型為Function1的對象,你可以調用它的apply方法,這看起來就像下面的方法調用:

isLongTweet.apply("A very short tweet")    ←─返回false

  

如果用Java,你可以採用下面的方式:

Function<String, Boolean> isLongTweet = (String s) -> s.length > 60;
boolean long = isLongTweet.apply("A very short tweet");

  

為了使用Lambda表達式,Java提供了幾種內置的函數式接口,比如PredicateFunctionConsumer。Scala提供了trait(你可以暫時將trait想像成接口,我們會在接下來的一節介紹它們)來實現同樣的功能: 從Function0(一個函數不接受任何參數,並返回一個結果)到Function22(一個函數接受22個參數),它們都定義了apply方法。

Scala還提供了另一個非常酷炫的特性,你可以使用語法糖調用apply方法,效果就像一次函數調用:

isLongTweet("A very short tweet")    ←─返回false

  

編譯器會自動地將方法調用f(a)轉換為f.apply(a)。更一般地說,如果f是一個支持apply方法的對象(注,apply可以有任意數目的參數),對方法f(a1, ..., an)的調用會被轉換為f.apply(a1, ..., an)

閉包

第3章中我們曾經拋給大家一個問題:Java中的Lambda表達式是否是借由閉包組成的。溫習一下,那麼什麼是閉包呢?閉包是一個函數實例,它可以不受限制地訪問該函數的非本地變量。不過Java 8中的Lambda表達式自身帶有一定的限制:它們不能修改定義Lambda表達式的函數中的本地變量值。這些變量必須隱式地聲明為final。這些背景知識有助於我們理解“Lambda避免了對變量值的修改,而不是對變量的訪問”。

與此相反,Scala中的匿名函數可以取得自身的變量,但並非變量當前指向的變量值。比如,下面這段代碼在Scala中是可能的:

def main(args: Array[String]) {
    var count = 0
    val inc =  => count+=1    ←─這是一個閉包,它捕獲並遞增count
    inc
    println(count)    ←─打印輸出1
    inc
    println(count)    ←─打印輸出2
}

  

不過在Java中,下面的這段代碼會遭遇編譯錯誤,因為count隱式地被強制定義為final

public static void main(String args) {
    int count = 0;
    Runnable inc =  -> count+=1;    ←─錯誤:count必須為final或者在效果上為final
    inc.run;
    System.out.println(count);
    inc.run;
}

  

我們在第7、13以及14章多次提到你應該盡量避免修改,這樣你的代碼更加易於維護和並發運行,所以請在絕對必要時才使用這一特性。

15.2.3 科裡化

第14章中,我們描述了一種名為科裡化的技術:帶有兩個參數(比如xy)的函數f可以看成一個僅接受一個參數的函數g,函數g的返回值也是一個僅帶一個參數的函數。這一定義可以歸納為接受多個參數的函數可以轉換為多個接受一個參數的函數。換句話說,你可以將一個接受多個參數的函數切分為一系列接受該參數列表子集的函數。Scala為此特別提供了一個構造器,幫助你更加輕鬆地科裡化一個現存的方法。

為了理解Scala到底帶來了哪些變化,讓我們先回顧一個Java的示例。你定義了一個簡單的函數對兩個正整數做乘法運算:

static int multiply(int x, int y) {
  return x * y;
}
int r = multiply(2, 10);

  

不過這種定義方式要求向其傳遞所有的參數才能開始工作。你可以人工地對multiple方法進行切分,讓其返回另一個函數:

static Function<Integer, Integer> multiplyCurry(int x) {
    return (Integer y) -> x * y;
}

  

multiplyCurry返回的函數會捕獲x的值,並將其與它的參數y相乘,然後返回一個整型結果。這意味著你可以像下面這樣在一個map中使用multiplyCurry,對每一個元素值乘以2:

Stream.of(1, 3, 5, 7)
      .map(multiplyCurry(2))
      .forEach(System.out::println);

  

這樣就能得到計算的結果2、6、10、14。這種方式工作的原因是map期望的參數為一個函數,而multiplyCurry的返回結果就是一個函數。

現在的Java語言中,為了構造科裡化的形式需要你手工地切分函數(尤其是函數有非常多的參數時),這是極其枯燥的事情。Scala提供了一種特殊的語法可以自動完成這部分工作。比如,正常情況下,你定義的multiply方法如下所示:

def multiply(x : Int, y: Int) = x * y

val r = multiply(2, 10);

  

該函數的科裡化版本如下:

def multiplyCurry(x :Int)(y : Int) = x * y    ←─定義一個科裡化函數

val r = multiplyCurry(2)(10)    ←─調用該科裡化函數

  

使用語法(x: Int)(y: Int),方法multiplyCurry接受兩個由一個Int參數構成的參數列表。與此相反,multiply接受一個由兩個Int參數構成的參數列表。當你調用multiplyCurry時會發生什麼呢?multiplyCurry的第一次調用使用了單一整型參數(參數x),即multiplyCurry(2),返回另一個函數,該函數接受參數y,並將其與它捕獲的變量x(這裡的值為2)相乘。正如我們在14.1.2節介紹的,我們稱這個函數是部分應用的,因為它並未提供所有的參數。第二次調用對xy進行了乘法運算。這意味著你可以將對multiplyCurry的第一次調用保存到一個變量中,進行復用:

val multiplyByTwo : Int => Int = multiplyCurry(2)
val r = multiplyByTwo(10)                    ←─20

  

和Java比較起來,在Scala中你不再需要像這裡這樣手工地提供函數的科裡化形式。Scala提供了一種方便的函數定義語法,能輕鬆地表示函數使用了多個科裡化的參數列表。

15.3 類和trait

現在我們看看類與接口在Java和Scala中的不同。這兩種結構在我們設計應用時都很常用。 你會看到相對於Java的類和接口,Scala的類和接口提供了更多的靈活性。

15.3.1 更加簡潔的Scala類

由於Scala也是一門完全的面向對像語言,你可以創建類,並將其實例化生成對象。最基礎的形態上,聲明和實例化類的語法與Java非常類似。比如,下面是一個聲明Hello類的例子:

class Hello {
  def sayThankYou{
    println("Thanks for reading our book")
  }
}
val h = new Hello
h.sayThankYou

  

getter方法和setter方法

一旦你定義的類具有了字段,這件事情就變得有意思了。你碰到過單純只定義字段列表的Java類嗎?很明顯,你還需要聲明一長串的getter方法、setter方法,以及恰當的構造器。多麻煩啊!除此之外,你還需要為每一個方法編寫測試。在企業Java應用中,大量的代碼都消耗在了這樣的類中。比如下面這個簡單的Student類:

public class Student {

    private String name;
    private int id;

    public Student(String name) {
        this.name = name;
    }

    public String getName {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getId {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }
}

  

你需要手工定義構造器對所有的字段進行初始化,還要實現2個getter方法、2個setter方法。一個非常簡單的類現在需要超過20行的代碼才能實現!有的集成開發環境或者工具能幫你自動生成這些代碼,不過你的代碼庫中還是需要增加大量額外的代碼,而這些代碼與你實際的業務邏輯並沒有太大的關係。

Scala語言中構造器、getter方法以及setter方法都能隱式地生成,從而大大降低你代碼中的冗余:

class Student(var name: String, var id: Int)
val s = new Student("Raoul", 1)          ←─初始化Student對像
println(s.name)                          ←─取得名稱,打印輸出Raoul
s.id = 1337                      ←─設置id 
println(s.id)          ←─打印輸出1337

  

15.3.2 Scala的trait與Java 8的接口對比

Scala還提供了另一個非常有助於抽像對象的特性,名稱叫trait。它是Scala為實現Java中的接口而設計的替代品。trait中既可以定義抽像方法,也可以定義帶有默認實現的方法。trait同時還支持Java中接口那樣的多繼承,所以你可以將它們看成與Java 8中接口類似的特性,它們都支持默認方法。trait中還可以包含像抽像類這樣的字段,而Java 8的接口不支持這樣的特性。那麼,trait就類似於抽像類嗎?顯然不是,因為trait支持多繼承,而抽像類不支持多繼承。Java支持類型的多繼承,因為一個類可以實現多個接口。現在,Java 8通過默認方法又引入了對行為的多繼承,不過它依舊不支持對狀態的多繼承,而這恰恰是trait支持的。

為了展示Scala中的trait到底是什麼樣,讓我們看一個例子。我們定義了一個名為Sized的trait,它包含一個名為size的可變字段,以及一個帶有默認實現的isEmpty方法:

trait Sized{
  var size : Int = 0    ←─名為size的字段
  def isEmpty = size == 0    ←─帶默認實現的isEmpty方法
}

  

你現在可以使用一個類在聲明時構造它,下面這個例子中Empty類的size恆定為0

class Empty extends Sized    ←─一個繼承自trait Sized的類

println(new Empty.isEmpty)    ←─打印輸出true

  

有一件事非常有趣,trait和Java的接口類似,也是在對像實例化時被創建(不過這依舊是一個編譯時的操作)。比如,你可以創建一個Box類,動態地決定到底選擇哪一個實例支持由trait Sized定義的操作:

class Box
val b1 = new Box with Sized    ←─在對像實例化時構建trait
println(b1.isEmpty)    ←─打印輸出true
val b2 = new Box
b2.isEmpty    ←─編譯錯誤:因為Box類的聲明並未繼承Sized

  

如果一個類繼承了多個trait,各trait中聲明的方法又使用了相同的簽名或者相同的字段,這時會發生什麼情況?為了解決這些問題,Scala中定義了一系列限制,這些限制和我們之前在第9章介紹默認方法時的限制極其類似。

15.4 小結

下面是這一章中介紹的關鍵概念和你應該掌握的要點。

  • Java 8和Scala都是整合了面向對像編程和函數式編程特性的編程語言,它們都運行於JVM之上,在很多時候可以相互操作。

  • Scala支持對集合的抽像,支持處理的對象包括ListSetMapStreamOption,這些和Java 8非常類似。不過,除此之外Scala還支持元組。

  • Scala為函數提供了更加豐富的特性,這方面比Java 8做得好,Scala支持:函數類型、可以不受限制地訪問本地變量的閉包,以及內置的科裡化表單。

  • Scala中的類可以提供隱式的構造器、getter方法以及setter方法。

  • Scala還支持trait,它是一種同時包含了字段和默認方法的接口。