Scala 簡介
1.1 為什麼選擇Scala
Scala 是一門滿足現代軟體工程師需求的語言;它是一門靜態型別語言,支援混合正規化;它也是一門執行在 JVM 之上的語言,語法簡潔、優雅、靈活。Scala 擁有一套複雜的型別系統,Scala 方言既能用於編寫簡短的解釋指令碼,也能用於構建大型複雜系統。這些只是它的一部分特性,下面我們來詳細說明。
執行在 JVM 和 JavaScript 之上的語言
Scala 不僅利用了 JVM 的高效能以及最優化性,Java 豐富的工具及類庫生態系統也為其所用。不過 Scala 並不是只能執行在 JVM 之上! Scala.js(http://www.scala-js.org)正在嘗試將其遷移到 JavaScript 世界。
靜態型別
在 Scala 語言中,靜態型別(static typing)是構建健壯應用系統的一個工具。Scala 修正了 Java 型別系統中的一些缺陷,此外通過型別推演(type inference)也免除了大量的冗餘程式碼。
混合式程式設計正規化——物件導向程式設計
Scala 完全支援物件導向程式設計(OOP)。Scala 引入特徵(trait)改進了 Java 的物件模型。trait 能通過使用混合結構(mixin composition)簡潔地實現新的型別。在 Scala 中,一切都是物件,即使是數值型別。
混合式程式設計正規化——函數語言程式設計
Scala 完全支援函數語言程式設計(FP),函數語言程式設計已經被視為解決併發、大資料以及程式碼正確性問題的最佳工具。使用不可變值、被視為一等公民的函式、無副作用的函式、高階函式以及函式集合,有助於編寫出簡潔、強大而又正確的程式碼。
複雜的型別系統
Scala 對 Java 型別系統進行了擴充套件,提供了更靈活的泛型以及一些有助於提高程式碼正確性的改進。通過使用型別推演,Scala 編寫的程式碼能夠和動態型別語言編寫的程式碼一樣精簡。
簡潔、優雅、靈活的語法
使用 Scala 之後,Java 中冗長的表示式不見了,取而代之的是簡潔的 Scala 方言。Scala 提供了一些工具,這些工具可用於構建領域特定語言(DSL),以及對使用者友好的 API 介面。
可擴充套件的架構
使用 Scala,你能編寫出簡短的解釋性指令碼,並將其粘合成大型的分散式應用。以下四種語言機制有助於提升系統的擴充套件性:1) 使用 trait 實現的混合結構;2) 抽象型別成員和泛型;3) 巢狀類;4) 顯式自型別(self type)。
Scala 實際上是 Scalable Language 的縮寫,意為可擴充套件的語言。Scala 的發音為 scah-lah,像義大利語中的 staircase(樓梯)。也就是說,兩個 a 的發音是一樣的。
早在 2001 年,Martin Odersky 便開始設計 Scala,並在 2004 年 1 月 20 日推出了第一個公開版本(參見 http://article.gmane.org/gmane.comp.lang.scala/17)。Martin 是瑞士洛桑聯邦理工大學(EPFL)計算機與通訊科學學院的一名教授。在就讀研究生時,Martin 便加入了由 Niklaus Wirth1 領導的 PASCAL fame 專案組。Martin 曾任職於 Pizza 專案組,Pizza 是執行在 JVM 平臺上早期的函式式語言。之後與 Haskell 語言設計者之一 Philip Wadler 一起轉戰 GJ。GJ 是一個原型系統,最終演變為 Java 泛型。Martin 還曾受僱於 Sun 公司,編寫了 javac 的參考編譯器,這套系統後來演化成了 JDK 中自帶的 Java 編譯器。
1PASCAL 之父。——譯者注
1.1.1 富有魅力的Scala
自從本書第 1 版出版之後,Scala 使用者數量急劇上升,這也證實了我的觀點:Scala 適應當前時代。當前我們會遇到很多技術挑戰,如大資料、通過併發實現高擴充套件性、提供高可用並健壯的服務。Scala 語法簡潔但卻富有表現力,能夠滿足這些技術挑戰。在享受 Scala 最先進的語言特性的同時,你還可以擁有成熟的 JVM、庫以及生產工具給你帶來的便利。
在那些需要努力才能成功的領域裡,專家們往往都需要掌握複雜強大的工具和技術。也許掌握這些工具技能需要花費一些時間,但是掌握它們是你事業成功的關鍵,所以花費這些時間都是值得的。
我確信對於專家級開發者而言,Scala 就是這樣一門語言。並不是所有的使用者都能稱得上是專家,而 Scala 卻是屬於技術專家的語言。Scala 包含豐富的語言特性,具有很好的效能,能夠解決多種問題。雖然需要花一些時間才能掌握 Scala 語言,但是一旦你掌握了它,便不會被它束縛。
1.1.2 關於Java 8
自從 Java 5 引入泛型之後,再也沒有哪次升級能比 Java 8 引入更多的特性了。現在可以使用真正的匿名函式了,我們稱之為 Lambda。通過本書你將瞭解到這些匿名函式的巨大作用。Java 8 還改進了介面,允許為宣告的方法提供預設實現。這一變化使得我們能夠像使用混合結構那樣使用介面,這也使介面變得更有用了,而 Scala 則是通過 trait 實現這種用法的。在 Java 8 推出之前,Scala 便已為 Java 提供了這兩個被公認為 Java 8 最重要的新特性。現在是不是能說服自己切換到 Scala 了?
由於 Java 語言向後相容的緣故,Scala 新增了一些改進,而 Java 也許永遠不會包含。即便 Java 最終會擁有這些改進,那也需要漫長的等待。舉例來說,較 Java 而言,Scala 能提供更為強大的型別推演、強大的模式匹配(pattern matching) 和 for 推導式(for comprehension),善用模式匹配和 for 推導式能夠極大地減少程式碼量以及型別耦合。隨著深入學習,你會發現這些特性的巨大價值。
另外,一些組織對升級 JVM 設施抱有謹慎態度,這是可以理解的。對於他們而言,目前並不允許部署 Java 8 虛擬機器。為了使用這些 Java 8 特性,這些組織可以在 Java 6 或 Java 7 的虛擬機器上執行 Scala。
你也許因為當前使用 Java 8,就認為 Java 8 是最適合團隊的選擇。即便如此,本書仍然能給你傳授一些有用的技術,而且這些技術可以運用在 Java 8 中。不過,我認為 Scala 具有一些額外的特性,能夠讓你覺得值得為之改變。
好吧,讓我們開始吧!
1.2 安裝Scala
為了能夠儘可能快地安裝並執行 Scala,本節將講述如何安裝命令列工具,使用這些工具便能執行本書列舉的所有示例 2。本書示例中的程式碼使用了 Scala 2.11.2 進行編寫及編譯。這也是編寫本書時最新的版本。絕大多數程式碼無須修改便能執行在早期版本 2.10.4 上,而一些團隊也仍在使用這一版本。
2第 21 章會詳細講解這些工具。
相較於 2.10,Scala 2.11 引入了一些新的特性,不過此次釋出更側重於整體效能的提升以及庫的重構。Scala 2.10 與 2.9 版本相比,也引入了一些新的特性。也許你們部門正在使用其中的某一版本,而隨著學習的深入,我們會討論這些版本間最重要的差別。(參閱 http://docs.scala-lang.org/scala/2.11/ 瞭解 2.11 版本,參閱 http://www.scala-lang.org/download/2.10.4.html#Release_Notes 瞭解 2.10 版本。)
安裝步驟如下。
安裝 Java
針對 Scala 2.12 之前的版本,你可以選擇 Java 6、7、8 三個版本,在安裝 Scala 之前,你必須確認你的電腦上已經安裝了 Java。(Scala 2.12 計劃於 2016 年年初發布,該版本將只支援 Java 8。)假如你需要安裝 Java,請登入 Oracle 的網站(http://www.oracle.com/technetwork/java/javase/downloads/index.html),遵循指示安裝完整的 Java 開發工具包(JDK)。
安裝 SBT
請遵循 scala-sbt.org(http://www.scala-sbt.org/release/tutorial/Setup.html)網頁上的指示安裝 SBT,它是業內公認的構建工具。安裝完成後,便可以在 Linux、OS X 終端和 Windows 命令視窗中執行 sbt 命令。(你也可以選擇其他的構建工具,21.2.2 節將介紹這些工具。)
獲取本書原始碼
本書前言中描述瞭如何下載示例程式碼。壓縮包可以解壓到你電腦中的任何資料夾。
執行 SBT
開啟 shell 或命令列視窗,進入示例程式碼解壓後的目錄,敲入命令 sbt test,該命令會下載所有的依賴項,包括 Scala 編譯器及第三方庫,請確保網路連線正常,並耐心等待該命令執行。下載完畢後,sbt 會編譯程式碼並執行單元測試。此時你能看到很多的輸出資訊,該命令最後會輸出 success 資訊。再次執行 sbt test 命令,由於該命令不需要執行任何事情,你會發現命令很快就結束了。
祝賀你!你已經真正開始了 Scala 的學習。不過,你也許會想安裝其他一些有用的工具。
在學習本書的大多數時候,通過使用 SBT,你便能使用其他工具。SBT 會自動下載指定版本的 Scala 編譯器、標準庫以及需要的第三方資源。
不使用 SBT,也能很方便地單獨下載 Scala 工具。我們會提供一些 SBT 外使用 Scala 的例子。
請遵循 Scala 官方網站(http://www.scala-lang.org)中的連結安裝 Scala,還可以選擇安裝 Scaladoc。Scaladoc 是 Scala 版的 Javadoc(在 Scala 2.11 中,Scala 庫和 Scaladoc 被切分為許多較小的庫)。你也可以線上查閱 Scaladoc(http://www.scala-lang.org/api/current)。為了方便你使用,本書中出現的 Scala 庫中的型別,大部分都附上了連線到 Scaladoc 頁面的連結。
Scaladoc 在頁面左側型別列表上面提供了搜尋欄,這有助於快速查詢型別。同時,每個型別的入口處都提供了一個指向 Scala GitHub 庫中對應程式碼的連結(https://github.com/scala/scala),這能很好地幫助使用者學習這些庫的實現。這個連結位於型別概述討論的底部,連結所在行標註著 Source 字樣。
你可以選用任何文字編輯器或 IDE 來處理這些示例,也可以為這些主流編輯器或 IDE 安裝 Scala 支援外掛。具體方法,請參見 21.3 節。通常而言,訪問你所青睞的編輯器的社群,能最及時地發現 Scala 相關的支援資訊。
1.2.1 使用SBT
21.2.1 節將介紹 SBT 是如何工作的。下面,我們介紹當前需要掌握的一些基本指示。
當你啟動 sbt 命令時,假如不指定任何任務,SBT 將啟動一個互動式 REPL(REPL 是 Read、Eval、Print、Loop 的簡寫,代表了“讀取 – 求值 – 列印 – 迴圈”)。下面我們將執行該命令,並嘗試執行一些可用的任務。
下面列舉的程式碼中,$ 表示 shell 命令提示符(如 bash 命令提示符),你可以在該提示符下執行 sbt 命令;> 是 SBT 預設的互動提示符,可以在 # 符號後編寫 sbt 註釋。你可以以任意順序輸入下面列舉的大多數 sbt 命令。
$ sbt
help # 描述命令
tasks # 顯示最常用的、當前可用的任務
tasks -V # 顯示所有的可用任務
compile # 增量編譯程式碼
test # 增量編譯程式碼,並執行測試
clean # 刪除所有已經編譯好的構建
~test # 一旦有檔案儲存,執行增量編譯並執行測試# 適用於任何使用了~字首的命令
console # 執行Scala REPL
run # 執行專案的某一主程式
show x # 顯示變數X的定義
eclipse # 生成Eclipse專案檔案
exit # 退出REPL(也可以通過control-d的方式退出)
為了能編譯更新後的程式碼並執行對應測試,我通常會執行 ~test 命令。SBT 使用了增量的編譯器和測試執行器,因此每次執行時不用等待完全構建所需時間。假如你希望執行其他任務或退出 sbt,只需要按一下Enter鍵即可。
假如你使用安裝了 Scala 外掛的 Eclipse 進行開發,便能很方便地執行 eclipse 任務。執行 eclipse 任務將生成對應的專案檔案,這些生成的程式碼作為 Eclipse 專案檔案進行載入。如果你想使用 Eclipse 來處理示例程式碼,請執行 eclipse 任務。
假如你使用最近釋出的 Scala 外掛 IntelliJ IDEA 進行開發,直接匯入 SBT 專案檔案便能生成 IntelliJ 專案。
Scala 中已經包含了REPL環境,你可以執行 console 命令啟動該環境。如果你希望在 REPL 環境下執行本書中的程式碼示例,那麼通常情況下,你首先需要執行 console 命令:
$ sbt
console
[info] Updating {file:/…/prog-scala-2nd-ed/}prog-scala-2nd-ed…
[info] …
[info] Done updating.
[info] Compiling …
[info] Starting scala interpreter…
[info]
Welcome to Scala version 2.11.2 (Java HotSpot(TM) 64-Bit Server VM, Java …).
Type in expressions to have them evaluated .
Type :help for more information.
scala> 1 + 2
res0: Int = 3
scala> :quit
此處省去若干輸出,與 SBT REPL 一樣,你也可以使用 Ctrl-D 退出系統。
執行 console 時,SBT 首先會構建專案,並通過設定 CLASSPATH 使該專案可用。因此,你也可以使用 REPL 編寫程式碼進行試驗。
使用 Scala REPL 能有效地對你編寫的程式碼進行試驗,也可以通過 REPL 來學習 API,即便是 Java API 亦可。在 SBT 上使用 console 任務執行程式碼時,console 任務會很體貼地為你在 classpath 中新增專案依賴項以及編譯後的專案程式碼。
1.2.2 執行Scala命令列工具
如果你單獨安裝了 Scala 命令列工具,會發現與 Java 編譯器 javac 相似,Scala 編譯器叫作 scalac。我們會使用 SBT 執行編譯工作,而不會直接使用 scalac。不過如果你曾執行過 javac 命令,會發現 scalac 語法也很直接。
在命令列視窗中執行 -version 命令,便可檢視到當前執行的 scalac 版本以及命令列引數幫助資訊。與之前一樣,在 $ 提示符後輸入文字。之後生成的文字便是命令輸出。
$ scalac -version
Scala compiler version 2.11.2 — Copyright 2002-2013, LAMP/EPFL
$ scalac -help
Usage: scalac <options> <source files>
where possible standard options include:
-Dproperty=value Pass -Dproperty=value directly to the runtime system.
-J<flag> Pass <flag> directly to the runtime system.
-P:<plugin>:<opt> Pass an option to a plugin
…
與之類似,執行下列 scala 命令也可以檢視 Scala 版本及命令引數幫助。
$ scala -version
Scala code runner version 2.11.2 — Copyright 2002-2013, LAMP/EPFL
$ scala -help
Usage: scala <options> [<script|class|object|jar> <arguments>]
or scala -help
All options to scalac (see scalac -help) are also allowed.
…
有時我們會使用 scala 來執行 Scala“指令碼”檔案,而 java 命令列卻沒有提供類似的功能。下面將要執行的指令碼來源於我們的示例程式碼:
// src/main/scala/progscala2/introscala/upper1.sc
class Upper {
def upper(strings: String*): Seq[String] = {
strings.map((s:String) => s.toUpperCase())
}
}
val up = new Upper
println(up.upper(“Hello”, “World!”))
我們將呼叫 scala 命令執行該指令碼。也請讀者嘗試執行該示例。上述程式碼使用的檔案路徑適用於 Linux 和 Mac OS X 系統。我假設,當前的工作目錄位於程式碼示例所在的根目錄。如果使用 Windows 系統,請在路徑中使用反斜槓。
$ scala src/main/scala/progscala2/introscala/upper1.sc
ArrayBuffer(HELLO, WORLD!)
現在我們終於滿足了程式設計圖書或嚮導的一條不成文的規定:第一個程式必須列印“Hello World!”。
最後提一下,執行 scala 命令時,如果未指定主程式或指令碼檔案,那麼 scala 將進入 REPL 模式,這與在 sbt 中執行 console 命令類似。(不過,執行 scala 時的 classpath 與執行 console 任務的 classpath 不同。)下面列出的 REPL 會話中講解了一些有用的命令。(如果你未獨立安裝 Scala,在 sbt 中執行 console 任 務也能進入 Scala REPL 環境)。此時,REPL 提示符是 scala>(此處省略了一些輸出資訊)。
$ scala
Welcome to Scala version 2.11.2 (Java HotSpot(TM)…).
Type in expressions to have them evaluated.
Type :help for more information.
scala> :help
All commands can be abbreviated, e.g. :he instead of :help.
:cp <path> add a jar or directory to the classpath
:edit <id>|<line> edit history
:help [command] print this summary or command-specific help
:history [num] show the history (optional num is commands to show)
… 其他訊息
scala> val s = “Hello, World!”
s: String = Hello, World!
scala> println(“Hello, World!”)
Hello, World!
scala> 1 + 2
res3: Int = 3
scala> s.con<tab>
concat contains contentEquals
scala> s.contains(“el”)
res4: Boolean = true
scala> :quit
$ #返回shell提示符
我們為變數 s 賦予了 string 值 “Hello, World!”,通過使用 val 關鍵字,我們將變數 s 宣告成不可變值。println 函式(http://www.scala-lang.org/api/current/index.html#scala.Console$)將在控制檯中列印一個字串,並會在字串結尾處列印換行符。
println 函式與 Java 中的 System.out.println(http://docs.oracle.com/javase/8/docs/api/java/lang/System.html)作用一致。同樣,Scala 也使用了 Java 提供的 String 型別(http://docs.oracle.com/javase/8/docs/api/java/lang/String.html)。
接下來,請注意我們要將兩個數字相加,由於我們並未將運算的結果賦予任何一個變數,因此 REPL 幫我們將變數命名為 res3,我們可以在隨後的表示式中運用該變數。
REPL 支援 tab 補全。例子中顯示輸入命令 s.con<tab> 表示的是在 s.con 後輸入 tab 符。REPL 將列出一組可能會被呼叫的方法名。在本例中表示式最後呼叫了 contains 方法。
最後,呼叫 :quit 命令退出 REPL。也可以使用 Ctrl-D 退出。
接下來,我們將看到更多 REPL 命令,在 21.1 節中,我們將更深入地探索 REPL 的各個命令。
1.2.3 在IDE中執行Scala REPL
下面我們將討論另外一種執行 REPL 的方式。特別是當你使用 Eclipse、IntelliJ IDEA 或 NetBeans 時,這種方式會更加實用。Eclipse 和 IDEA 支援 worksheet 功能,當你編輯 Scala 程式碼時,感覺不到它與正常地編輯編譯程式碼或指令碼程式碼有什麼區別。不過一旦將該檔案儲存,程式碼便會立刻被執行。因此,假如你需要修改並重新執行重要的程式碼片段,使用這種開發方式比使用 REPL 更為方便。NetBeans 也提供了一種類似的互動式控制檯功能。
假如你想要使用上述的某個 IDE,可以參考 21.3 節,掌握 Scala 外掛、worksheet 以及互動式控制檯的相關資訊。
1.3 使用Scala
在本章的剩餘篇幅和之後的兩章中,我們將對 Scala 的一些特性進行快速講解。在學習這些內容時會涉及一些語言細節,這些細節僅用於理解這些內容,更多的細節會在後續章節中提供。你可以將這幾章內容視為 Scala 語法入門書,並從中感受 Scala 程式設計的魅力。
當提到某一 Scala 庫型別時,我們可以閱讀 Scaladoc 中的相關資訊進行學習。如果你想訪問當前版本的 Scala 對應的 Scaladoc 文件,請檢視 http://www.scala-lang.org/api/current/。請注意,左側型別列表區域的上方有一搜尋欄,應用該搜尋欄能很方便地快速查詢型別。與 Javadoc 不同,Scaladoc 按照 package 來排列型別,而不是按照字母順序全部列出。
本書多數情況下會使用 Scala REPL,因此我們在這兒再溫習一遍執行 REPL 的三種方式。你可以不指定指令碼或 main 引數直接輸入 scala 命令,也可以使用 SBT console 命令,還可以在那些流行的 IDE 中使用 worksheet 特性。
假如你不想使用任何 IDE,我建議你儘量使用 SBT,尤其是當你的工作固定在某一特定專案時。本書也將使用 SBT 進行講解,這些操作步驟同樣適用於直接執行 scala 命令或者在 IDE 中建立 worksheet 的情況。請自行選擇開發工具。事實上,即便你青睞於使用 IDE,我還是希望你能嘗試在命令列視窗執行一下 SBT,瞭解 SBT 環境。我個人很少使用 IDE,不過是否選擇 IDE 只是個人的偏好罷了。
開啟 shell 視窗,切換到程式碼示例所在的根資料夾並執行 sbt。在 > 提示符後輸入 console。從現在開始,本書將省略關於 sbt 和 scala 輸出的 一些“程式化”的語句。
在 scala> 提示符中輸入下列兩行:
scala> val book = “Programming Scala”
book: java.lang.String = Programming Scala
scala> println(book)
Programming Scala
第一行程式碼中的 val 關鍵字用於宣告不變變數 book。可變資料是錯誤之源,因此我推薦使用不變值。
請注意,直譯器返回值列出了 book 變數的型別和數值。Scala 從字面量 “Programming Scala” 中推匯出 book 屬於 java.lang.String 型別(http://docs.oracle.com/javase/8/docs/api/java/lang/String.html)。
顯示型別資訊或在宣告中顯式指明型別資訊時,這些型別標註緊隨冒號,出現在相關項之後。為什麼 Scala 不遵循 Java 的習慣呢? Scala 常常能推匯出型別資訊,因此,我們在程式碼中總是看不到顯式的型別標註。如果程式碼中省略了冒號和型別標註資訊,那麼與 Java 的型別習慣相比,item: type 這一模式更有助於編譯器正確地分析程式碼。
一般來說,當 Scala 語法與 Java 語法存在差異時,通常都會有一個充分的理由。比如說,Scala 支援了一個新的特性,而這個特性很難使用 Java 的語法表達出來。
REPL 中顯示了型別資訊,這有助於學習 Scala 是如何為特定表示式推導型別的。透過這個例子,可以瞭解到 REPL 提供了哪些功能。
僅使用 REPL 來編輯或提交大型的示例程式碼會比較枯燥,而使用文字編輯器或 IDE 來編寫 Scala 指令碼則會方便得多。編寫完成之後,你可以執行指令碼,也可以複製貼上大段程式碼再執行。
我們再回顧一下之前編寫的 upper1.sc 檔案。
// src/main/scala/progscala2/introscala/upper1.sc
class Upper {
def upper(strings: String*): Seq[String] = {
strings.map((s:String) => s.toUpperCase())
}
}
val up = new Upper
println(up.upper(“Hello”, “World!”))
本書的下載示例壓縮包中的每個示例的第一行均為註釋,該註釋列出了示例檔案在壓縮包中的路徑。Scala 遵循 Java、C#、C 等語言的註釋規則,// comment 只能作用到本行行尾,而 / comment / 則可以跨行。
我們再回顧一下前言中的內容。依照命名規範,指令碼檔案的副檔名為 .sc,而編譯後的檔案的副檔名為 .scala,這一命名規範僅適用於本書。通常,指令碼檔案往往也使用 .scala 副檔名。不過如果使用 SBT 構建專案,SBT 會嘗試編譯這些以 scala 命名的檔案,而這些指令碼檔案卻無法編譯(我們稍後便會講到這些)。
我們首先執行該指令碼,具體程式碼細節稍後討論。啟動 sbt 並執行 console 命令以開啟 Scala 環境。然後使用 :load 命令載入(編譯並執行)檔案:
scala> :load src/main/scala/progscala2/introscala/upper1.sc
Loading src/main/scala/progscala2/introscala/upper1.sc…
defined class Upper
up: Upper = Upper@4ef506bf // 呼叫Java的Object.toString方法。
ArrayBuffer(HELLO, WORLD!)
上述指令碼中,只有最後一行才是 println 命令的輸出,其他行則是 REPL 提供的一些反饋資訊。
那麼這些指令碼為什麼無法編譯呢?指令碼設計的初衷是為了簡化程式碼,無須將宣告(變數和函式)封裝在物件中便是一種簡化。而將 Java 和 Scala 程式碼編譯後,宣告必須封裝在物件中(這是 JVM 位元組碼的需求)。scala 命令通過一個聰明的技巧解決了衝突:將指令碼封裝在一個你看不到的匿名物件中。
假如你的確希望能將指令碼檔案編譯為 JVM 的位元組碼(一組 .class 檔案),可以在 scalac 命令中傳入 -Xscript <object> 引數,<object> 表示你所選中的 main 類,它是生成的 Java 應用程式的入口點。
$ scalac -Xscript Upper1 src/main/scala/progscala2/introscala/upper1.sc
$ scala Upper1
ArrayBuffer(HELLO, WORLD!)
執行完畢後檢查當前資料夾,你會發現一些命名方式有趣的 .class 檔案。(提示:一些匿名函式也被轉換成了物件!)我們稍後會再討論這些名字,Upper1.class 檔案中包含了主程式,我們將使用 javap 和 Scala 對應工具 scalap,對該檔案實施逆向工程!
$ javap -cp . Upper1
Compiled from “upper1.sc”
public final class Upper1 {
public static void main(java.lang.String[]);
}
$ scalap -cp . Upper1
object Upper1 extends scala.AnyRef {
def this() = { / compiled code / }
def main(argv : scala.Array[scala.Predef.String]) : scala.Unit =
{ /* compiled code */ }
}
最後,我們將對程式碼本身進行討論,程式碼如下:
// src/main/scala/progscala2/introscala/upper1.sc
class Upper {
def upper(strings: String*): Seq[String] = {
strings.map((s:String) => s.toUpperCase())
}
}
val up = new Upper
println(up.upper(“Hello”, “World!”))
Upper 類中的 upper 方法將輸入字串轉換成大寫字串,並返回一個包含這些字串的 Seq(Seq 表示“序列”,http://www.scala-lang.org/api/current/index.html#scala.collection.Seq)物件。最後兩行程式碼建立了 Upper 物件的一個例項,並呼叫這一例項將字串“Hello”和“World!”轉換為大寫字串,並最終列印出產生的 Seq 物件。
在 Scala 中定義類時需要輸入 class 關鍵字,整個類定義體包含在最外層的一對大括號中({…})。事實上,這個類定義體同樣也是這個類的主建構函式。假如需要將引數傳遞給這個建構函式,就要在類名 Upper 之後輸入引數列表。
下面這小段程式碼宣告瞭一個方法:
def upper(strings: String*): Seq[String] = …
定義方法時需要先輸入 def 關鍵字,之後輸入方法名稱以及可選的引數列表。再輸入可選的返回型別(有時候,Scala 能夠推匯出返回型別),返回型別由冒號加型別表示。最後使用等於號(=)將方法簽名和方法體分隔開。
實際上,圓括號中的引數列表代表了變長的 String 型別引數列表,修飾 strings 引數的 String 型別後面的 * 號指明瞭這一點。也就是說,你可以傳遞任意多的字串(也可以傳遞空列表),而這些字串由逗號分隔。在這個方法中,strings 引數的型別實際上是 WrapppedArray(http://www.scala-lang.org/api/current/index.html#scala.collection.mutable.WrappedArray),該型別對 Java 陣列進行了封裝。
引數列表後列出了該方法的返回型別 Seq[String],Seq(代表 Sequence)是集合的一種抽象,你可以依照固定的順序(不同於遍歷 Set 和 Map 物件那樣的隨機順序和未定義順序,遍歷那類容器無法保證遍歷順序)遍歷這類結合抽象。實際上,該方法返回的型別是 scala.collection.mutable.ArrayBuffer(http://www.scala-lang.org/api/current/#scala.collection.mutable.ArrayBuffer),不過絕大多數情況下,呼叫者無須瞭解這點。
值得一提的是,Seq 是一個引數化型別,就好象 Java 中的泛型型別。Seq 代表著“某類事物的序列”,上面程式碼中的 Seq 表示的是一個字串序列。請注意,Scala 使用方括號([…])表示引數型別,而 Java 使用角括號(<…>)。
Scala 的識別符號,如方法名和變數名,中允許出現尖括號,例如定義“小於”方法時,該方法常被命名為 <,這在 Scala 語言中是允許的,而 Java 則不允許識別符號中出現這樣的字元。因此,為了避免出現歧義,Scala 使用方括號而不是尖括號表示引數化型別,並且不允許在識別符號中使用方括號。
upper 方法的定義體出現在等號(=)之後。為什麼使用等號呢?而不像 Java 那樣,使用花括號表示方法體呢?
避免歧義是原因之一。當你在程式碼中省略分號時,Scala 能夠推斷出來。在大多數時候,Scala 能夠推匯出方法的返回型別。假如方法不接受任何引數,你還可以在方法定義中省略引數列表。
使用等號也強調了函數語言程式設計的一個準則:值和函式是高度對齊的概念。正如我們所看到的那樣,函式可以作為引數傳遞給其他函式,也能夠返回函式,還能被賦給某一變數。這與物件的行為是一致的。
最後提一下,假如方法體僅包含一個表示式,那麼 Scala 允許你省略花括號。所以說,使用等號能夠避免可能的解析歧義。
函式方法體中對字串集合呼叫了 map 方法(http://www.scala-lang.org/api/current/index.html#scala.collection.TraversableLike),map 方法的輸入引數為函式字面量(function literal)。而這些函式字面量便是“匿名”函式。在其他語言中,它們也被稱為 Lambda、閉包(closure)、塊(block)或過程(proc)。Java 8 最終也提供了真正的匿名方法 Lambda。但 Java 8 之前,你只能通過介面實現的方式實現匿名方法,我們通常會在介面中定義一個匿名的內部類,並在內部類中宣告執行真正工作的方法。因此,即便是在 Java 8 之前,你也能夠實現匿名函式的功能:通過傳入某些巢狀行為,將外部行為引數化。不過這些繁瑣的語法著實損害並掩蓋了匿名方法這門技術的優勢。
在這個示例中,我們向 map 方法傳遞了下列函式字面量:
(s:String) => s.toUpperCase()
此函式字面量的參數列中只包含了一個字串引數 s。它的函式體位於箭頭=>之後(UTF8 也允許使用 =>)。該函式體呼叫了 s 的 UpperCase() 方法。此次呼叫的返回值會自動被這個函式字面量返回。在 Scala 中,函式或方法中把最後一條表示式的返回值作為自己的返回值。儘管 Scala 中存在 return 關鍵字,但只能在方法中使用,上面這樣的匿名函式則不允許使用。事實上,方法中也很少用到這個關鍵字。
方法和函式
對於大多數的物件導向程式語言而言,方法指的是類或物件中定義的函式。當呼叫方法時,方法中的 this 引用會隱性地指向某一物件。當然,在大多數的 OOP 語言中,方法呼叫的語法通常是 this.method_name(other_args)。本書中的“方法”也滿足這一常用規範。我們提到的“函式”儘管不是方法,但在某些時候通常會將方法也歸入函式。當前上下文能夠認清它們的區別。
upper1.sc 中表示式 (s:String) => s.toUpperCase() 便是一個函式,它並不是方法。
我們對序列物件 strings 呼叫了 map 方法,該方法會把每個字串依次傳遞給函式字面量,並將函式字面量返回的值組成一個新的集合。舉個例子,假如在原先的列表中有五個元素,那麼新生成的列表也將包含五個元素。
繼續上面的示例,為了進一步練習程式碼,我們會建立一個新的 Upper 例項並將它賦給變數 up。與 Java、C# 等類似語言一樣,new Upper 語法將建立一個新的例項。由於主建構函式並不接受任何引數,因此並不需要傳遞引數列表。通過 val 關鍵字,up 引數被宣告為只讀值。up 的行為與 Java 中的 final 變數相似。
最後,我們呼叫 upper 方法,並使用 println(…) 方法列印結果。
我們可以進一步簡化程式碼,請思考下面更簡潔的版本。
// src/main/scala/progscala2/introscala/upper2.sc
object Upper {
def upper(strings: String*) = strings.map(_.toUpperCase())
}
println(Upper.upper(“Hello”, “World!”))
這段程式碼同樣實現了相同的功能,但使用的字元卻少了三分之一。
在第一行中,Upper 被宣告為單例物件,Scala 將單例模式視為本語言的第一等級成員。儘管我們宣告瞭一個類,不過 Scala 執行時只會建立 Upper 的一個例項。也就是說,你無法通過 new 建立 Upper 物件。就好像 Java 使用靜態型別一樣,其他語言使用類成員(class-level member),Scala 則使用物件進行處理。由於 Upper 中並不包含狀態資訊,所以我們此處的確不需要多個例項,使用單例便能滿足需求。
單例模式具有一些弊端,也因此常被指責。例如在那些需要將物件值進行 double 的單元測試中,如果使用了單例物件,便很難替換測試值。而且如果對一個例項執行所有的計算,會引發執行緒安全和效能的問題。不過正如靜態方法或靜態值有時適用於 Java 這樣的語言一樣,單例有時候在 Scala 中也是適用的。上述示例便是一個證明,由於無須維護狀態而且物件也不需要與外界互動,單例模式適用於上述示例。因此,使用 Upper 物件時我們沒有必要考慮測試雙倍值的問題,也沒有必要擔心執行緒安全。
Scala 為什麼不支援靜態型別呢?與那些允許靜態成員(或類似結構)的語言相比,Scala 更信奉萬物皆應為物件。相較於混入了靜態成員和例項成員的語言,採用物件結構的 Scala 更堅定地貫徹了這一方針。回想一下,Java 的靜態方法和靜態域並未繫結到型別的實際例項中,而 Scala 的物件則是某一型別的單例。
第二行中 upper 的實現同樣簡潔。儘管 Scala 無法推斷出方法的引數型別,卻常常能夠推斷出方法的返回型別,因此我們在此省略返回型別的顯式宣告。同時,由於方法體中僅包含了一句表示式,我們可以省略括號,並在一行內完成整個方法的定義。除了能提示讀者之外,方法體之前的等號也告訴編譯器方法體的起始位置。
Scala 為什麼無法推匯出方法引數型別呢?理論上型別推理演算法執行了區域性型別推導,這意味著該推導無法作用於整個程式全域性,而只能侷限在某一特定域內。因此,儘管無法分辨出引數所必須使用的型別,但由於能夠檢視整個函式體,Scala 大多數情況下卻能推匯出方法的返回值型別。遞迴函式是個例外,由於它的執行域超越了函式體的範圍,因此必須宣告返回型別。
任何時候,引數列表中的返回型別都為讀者提供了有用資訊。僅僅是因為 Scala 能推匯出函式的返回型別,我們就放棄為讀者提供返回型別資訊嗎?對於簡單的函式而言,讀者能夠很清楚地發現返回型別,顯式列出的返回型別也許還不是特別重要。不過有時候由於 bug 或某些特定輸入或函式體中的某些表示式所觸發的某些微妙行為,推匯出的型別可能並不是我們所期望的型別。顯式返回型別代表了你所期望的返回型別,它們同時還為讀者提供了有用資訊,因此我推薦新增返回型別,而不要省略它們。這尤其適用於 公有 API。
我們對函式字面量進行了進一步的簡化,之前我們的程式碼如下:
(s:String) => s.toUpperCase()
我們將其簡化為下列表示式:
_.toUpperCase()
map 方法接受單一函式引數,而單一函式也只接受單一引數。在這種情況下,函式體只使用一次該引數,所以我們使用佔位符 來替代命名引數。也就是說: 起到了匿名引數的作用,在呼叫 toUpperCase 方法之前,_ 將被字串替換。Scala 同時也為我們推斷出了該變數的型別為 String 型別。
最後一行程式碼中,由於使用了物件而不是類,此次呼叫變得更加簡單。無須通過 new Upper 程式碼建立例項,我們只需直接呼叫 Upper 物件的 upper 方法。呼叫語法與呼叫 Java 類靜態方法時的語法一樣。
最後,Scala 會自動載入一些像 println(http://www.scala-lang.org/api/current/index.html#scala.Console$)這樣的 I/O 方法,println 方法實際是 scala 包(http://www.scala-lang.org/api/current/scala/package.html)中 Console 物件(http://www.scala-lang.org/api/current/index.html#scala.Console$)的一個方法。與 Java 中的包一樣,Scala 通過包提供“名稱空間”並界定作用域。
因此,使用 println 方法時,我們無需呼叫 scala.Console.println 方法(http://www.scala-lang.org/api/current/index.html#scala.Console$),直接輸入 println 即可。println 方法只是眾多被自動載入的方法和型別中的一員,有一個叫作 Predef 的庫物件(http://www.scala-lang.org/api/current/index.html#scala.Predef$)對這些自動載入的方法和型別進行定義。
我們再進行一次重構,把這個指令碼轉化成編譯好的一個命令列工具。也就是說,我們將建立一個包含了 main 方法的更為經典的 JVM 應用程式。
// src/main/scala/progscala2/introscala/upper1.scala
package progscala2.introscala
object Upper {
def main(args: Array[String]) = {
args.map(_.toUpperCase()).foreach(printf("%s ",_))
println("")
}
}
回顧一下前面的內容,如果程式碼具有 .scala 副檔名,那就表示我們會使用 scalac 編譯它。現在 upper 方法被改名成了 main 方法。由於 Upper 是一個物件,main 方法就像是 Java 類的靜態 main 方法一樣。它就是 Upper 應用的入口點。
在 Scala 中,main 方法必須為物件方法。(在 Java 中,main 方法必須是類靜態方法。)應用程式的命令列引數將作為一組字串傳遞給 main 方法。舉例來說,輸入引數是 args: Array[String]。
upper1.scala 檔案中的第一行程式碼定義了名為 introscala 的包,用於裝載所定義的型別。在 Upper.main 方法中的表示式使用了 map 方法的簡寫形式,這與我們之前程式碼中出現的簡寫形式一致。
args.map(_.toUpperCase())…
map 方法會返回一個新的集合。對該集合我們將使用 foreach 方法進行遍歷。我們向 foreach 方法中傳遞另一個使用了 _ 佔位符的函式字面量。在這段程式碼中,集合中的每一個字串都將作為引數傳遞給 scala.Console.printf 方法(http://www.scala-lang.org/api/current/index.html#scala.Console$),該方法也是 Predef 物件匯入的方法,它會接受代表格式的字串引數以及一組將嵌入到格式字串的引數。
args.map(_.toUpperCase()).foreach(printf(“%s “,_))
在此澄清一下,上述程式碼有兩處使用了 ,這兩個 分別位於不同的作用域中,彼此之間沒有任何關聯。
你需要花一些時間才能掌握這樣的鏈式函式以及函式字面量中的一些簡寫方式,不過一旦熟悉了它們,你便能應用它們編寫出可讀性強、簡潔強大的程式碼,這些程式碼能最大程度地避免使用臨時變數和其他一些樣板程式碼。如果你是一名 Java 程式設計師,可以想象一下使用早於 Java 8 的 Java 版本編寫程式碼,這時你需要使用匿名內部類才能實現相同的功能。
main 方法的最後一行在輸出中增加了一個最終換行符。
為了執行程式碼,你必須首先使用 scalac,將程式碼編譯成一個能在 JVM 下執行的 .class 檔案(下文中的 $ 代表命令提示符)。
$ scalac src/main/scala/progscala2/introscala/upper1.scala
現在,你應該會看到一個名為 progscala2/introscala 的新資料夾,該資料夾裡包含了一些 .class 檔案,Upper.class 便是其中的一個檔案。Scala 生成的程式碼必須滿足 JVM 位元組程式碼的合法性要求,資料夾目錄必須與包結構吻合是要求之一。
Java 在原始碼級也遵循這一規定,Scala 則要更靈活一些。請注意,在我們下載的程式碼示例中,檔案 Upper.class 位於一個叫作 IntroScala 的資料夾中,這與它的包名並不一致。Java 同時要求必須為每一個最頂層類建立一個單獨的檔案,而 Scala 則允許在檔案中建立任意多個型別。雖然開發 Scala 程式碼可以不用遵循 Java 關於原始碼目錄結構的規範(原始碼目錄結構應吻合包結構,而且為每個頂層類建立一個單獨的檔案),不過一些開發團隊依然遵循這些規範,這主要因為他們熟悉這些 Java 規範,而且遵循這些規範有利於追蹤程式碼位置。
現在,你可以輸入任意長度的字串引數並執行命令,如下所示:
$ scala -cp . progscala2.introscala.Upper Hello World!
HELLO WORLD!
我們通過選項 -cp . 將當前目錄新增到查詢類路徑(classpath)中,不過本示例其實並不需要該選項。
請嘗試使用其他輸入引數來執行程式。另外,你可以檢視 progscala2/introscala 資料夾中還有哪些其他的類檔案,像之前例子那樣使用 javap 或 scalap 命令檢視這些類中包含了什麼定義。
最後,由於 SBT 會幫助我們編譯檔案,我們實際上並不需要手動編譯這些檔案。在 SBT 提示符下,我們可以使用下列命令執行程式。
run-main progscala2.introscala.Upper Hello World!
使用 scala 命令執行程式時,我們需要指明 SBT 生成的類檔案的正確路徑。
$ scala -cp target/scala-2.11/classes progscala2.introscala.Upper Hello World!
HELLO WORLD!
解釋執行 Scala 與編譯執行 Scala
概括地說,假如在命令列輸入 scala 命令時不指定檔案引數,REPL 將啟動。在 REPL 中輸入的命令、表示式和語句都會被直接執行。假如輸入 scala 命令時指定 Scala 原始檔,scala 命令將會以指令碼的形式編譯並執行檔案。另外,假如你提供了 JAR 檔案或是一個定義了 main 方法的類檔案,scala 會像 Java 命令那樣執行該檔案。
我們接下來對這些程式碼再進行最後一次重構:
// src/main/scala/progscala2/introscala/upper2.scala
package progscala2.introscala
object Upper2 {
def main(args: Array[String]) = {
val output = args.map(_.toUpperCase()).mkString(" ")
println(output)
}
}
將輸入引數對映為大寫格式字串之後,我們並沒有使用 foreach 方法迭代並依次列印每個詞,而是通過一個更便利的集合方法生成字串。mkString 方法(http://www.scala-lang.org/api/current/index.html#scala.collection.TraversableOnce)只接受一個輸入引數,該引數指定了集合元素間的分隔符。另外一個 mkString 方法(重構版本)則接受三個引數,分別表示最左邊的字首字串、分隔符和最右邊的字尾字串。你可以嘗試將程式碼修改為使用 mkSting(“[“, “, “, “]”),並觀察修改後程式碼的輸出。
我們把 mkString 方法的輸出儲存到一個變數之中,再呼叫 println 方法列印這個變數。我們本可以在整個 map 方法之外再封裝 println 方法進行列印,不過此處引入新變數能增強程式碼的可讀性。
1.4 併發
Scala 有許多誘人之處,能夠使用 Akka API 通過直觀的 actor 模式構建健壯的併發應用便是其中之一(請參考 http://akka.io)。
下面的示例有些激進,不過卻能讓我們體會到 Scala 的強大和優雅。將 Scala 與一套直觀的併發 API 相結合,便能以如此簡潔優雅的方式實現併發軟體。你之前研究 Scala 的一個原因可能是尋求更好的併發之道,以便更好地利用多核 CPU 和叢集中的伺服器來實現併發。使用 actor 併發模型便是其中的一種方法。
在 actor 併發模型中,actor 是獨立的軟體實體,它們之間並不共享任何可變狀態資訊。actor 之間無須共享資訊,通過交換訊息的方式便可進行通訊。通過消除同步訪問那些共享可變狀態,編寫健壯的併發應用程式變得非常簡單。儘管這些 actor 也許需要修改狀態,但是假如這些可變狀態對外不可訪問,並且 actor 框架確保 actor 相關程式碼呼叫是執行緒安全的,開發者就無須再費力編寫枯燥而又容易出錯的同步原語(synchronization primitive)了。
在這個簡單示例中,我們會將表示幾何圖形的一組類的例項傳送給一個 actor,該 actor 再將這組例項繪製到顯示器上。你可以想象這樣一個場景:渲染工廠(rendering farm)在為動畫生成場景。一旦場景渲染完畢,構成場景的幾何圖形便會被髮送給某一 actor 進行展示。
首先,我們將定義 Shape 類。
// src/main/scala/progscala2/introscala/shapes/Shapes.scala
package progscala2.introscala.shapes
case class Point(x: Double = 0.0, y: Double = 0.0) // ➊
abstract class Shape() { // ➋
/**
-
draw方法接受一個函式引數。每個圖形物件都會將自己的字元格式傳給函式f,
-
由函式f執行繪製工作。
*/
def draw(f: String => Unit): Unit = f(s”draw: ${this.toString}”) // ➌
}
case class Circle(center: Point, radius: Double) extends Shape // ➍
case class Rectangle(lowerLeft: Point, height: Double, width: Double) // ➎
extends Shape
case class Triangle(point1: Point, point2: Point, point3: Point) // ➏
extends Shape
❶ 此處宣告瞭一個表示二維點的類。
❷ 此處宣告瞭一個表示幾何形狀的抽象類 。
❸ 此處實現了一個“繪製”形狀的 draw 方法,該方法中僅輸出了一個格式化的字串。
❹ Circle 類由圓心和半徑組成。
❺ 位於左下角的點、高度和寬度這三個屬性構成了矩形。為了簡化問題,我們規定矩形的各條邊分別與橫座標或縱座標平行。
❻ 三角形由三個點所構成。
Point 類名後列出的引數列表就是類建構函式引數列表。在 Scala 中,整個類主體便是這個類的建構函式,因此你能在類名之後、類主體之前列出主建構函式的引數。在本示例中,Point 類並沒有類主體。由於我們在 Point 類宣告的前面輸入了 case 關鍵字,因此每一個建構函式引數都自動轉化為 Point 例項的某一隻讀(不可變)欄位。也就是說,假如要例項化一個名為 point 的 Point 例項,你可以使用 point.x 和 point.y 讀取 point 的欄位,但無法修改它們的值。嘗試執行 point.y = 3.0 會觸發編譯錯誤。
你也可以設定引數預設值。每個引數定義後出現 = 0.0 會把 0.0 設定為該引數的預設值。因此使用者無須明確給出引數值,Scala 便會推匯出引數值。不過這些引數值會按照從左到右的順序進行推導。下面我們運用 SBT 專案去進一步探索引數預設值:
$ sbt
…
compile
Compiling …
[success] Total time: 15 s, completed …
console
[info] Starting scala interpreter…
scala> import progscala2.intro.shapes._
import progscala2.intro.shapes._
scala> val p00 = new Point
p00: intro.shapes.Point = Point(0.0,0.0)
scala> val p20 = new Point(2.0)
p20: intro.shapes.Point = Point(2.0,0.0)
scala> val p20b = new Point(2.0)
p20b: intro.shapes.Point = Point(2.0,0.0)
scala> val p02 = new Point(y = 2.0)
p02: intro.shapes.Point = Point(0.0,2.0)
scala> p00 == p20
res0: Boolean = false
scala> p20 == p20b
res1: Boolean = true
因此,當我們不指定任何引數時,Scala 會使用 0.0 作為引數值。當我們只設定了一個引數值時,Scala 會把這個值賦予最左邊的引數 x,而剩下來的引數則使用預設值。我們還可以通過名字指定引數。對於 p02 物件,當我們想使用 x 的預設值卻為 y 賦值時,可以使用 Point(y = 2.0) 的語句。
由於 Point 類並沒有類主體,case 關鍵字的另一個特徵便是讓編譯器自動為我們生成許多方法,其中包括了類似於 Java 語言中 String、equals 和 hashCode 方法。每個點顯示的輸出資訊,如 Point(2.0,0.0),其實是 toString 方法的輸出。大多數開發者很難正確地實現 equals 方法和 hashCode 方法,因此自動生成這些方法具有實際的意義。
Scala 呼叫生成的 equals 方法,以判斷 p00 == p20 和 p20 == p20b 是否成立。這與 Java 的做法不同,Java 通過比較引用是否相同來判斷 == 是否成立。在 Java 中如果希望執行一次邏輯比較,你需要明確地呼叫 equals 方法。
現在我們要談論 case 類的最後一個特性,編譯器同時會生成一個伴生物件(companion object),伴生物件是一個與 case 類同名的單例物件(本示例中,Point 物件便是一個伴生物件)。
你可以自己定義伴生物件。任何時候只要物件名和類名相同並且定義在同一個檔案中,這些物件就能稱作伴生物件。
隨後可以看到,我們可以在伴生物件中新增方法。不過伴生物件中已經自動新增了不少方法,apply 方法便是其中之一。該方法接受的引數列表與建構函式接受的引數列表一致。
任何時候只要你在輸入物件後緊接著輸入一個引數列表,Scala 就會查詢並呼叫該物件的 apply 方法,這也意味著下面兩行程式碼是等價的。
val p1 = Point.apply(1.0, 2.0)
val p2 = Point(1.0, 2.0)
如果物件中未定義 apply 方法,系統將丟擲編譯錯誤。與此同時,輸入引數必須與預期輸入相符。
Point.apply 方法實際上是構建 Point 物件的工廠方法,它的行為很簡單;呼叫該方法就好像是不通過 new 關鍵字呼叫 Point 的建構函式一樣。伴生物件其實與下列程式碼生成的物件無異。
object Point {
def apply(x: Double = 0.0, y: Double = 0.0) = new Point(x, y)
…
}
不過,伴生物件 apply 方法也可以用於決定相對複雜的類繼承結構。父類物件需判斷引數列表與哪個字型別最為吻合,並依此選擇例項化的子型別。比方說,某一資料型別必須分別為元素數量少的情況和元素數量多的情況各提供一個不同的最佳實現,此時選用工廠方法可以遮蔽這一邏輯,為使用者提供統一的介面。
緊挨著物件名輸入引數列表時,Scala 會查詢並呼叫匹配該引數列表的 apply 方法。換句話說,Scala 會猜想該物件定義了 apply 方法。從句法角度上說,任何包含了 apply 方法的物件的行為都很像函式。
在伴生物件中安置 apply 方法是 Scala 為相關類定義工廠方法的一個便利寫法。在類中定義而不是在物件中定義的 apply 方法適用於該類的例項。例如,呼叫 Seq.apply(index:Int) 方法將獲得序列中指定位置的元素(從 0 開始計數)。
Shape 是一個抽象類。在 Java 中我們無法例項化一個抽象類,即使該抽象類中沒有抽象成員。該類定義了 Shape.draw 方法,不過我們只希望能夠例項化具體的形狀:圓形、矩陣或三角形。
請注意傳給 draw 方法的引數,該引數是一個型別為 String => Unit 的函式。也就是說,函式 f 接受字串引數輸入並返回 Unit 型別。Unit 是一個實際存在的型別,它的表現卻與 Java 中的 void 型別相似。在函數語言程式設計中,大家將 void 型別稱為 Unit 型別 .
具體做法是 draw 方法的呼叫者將傳入一個函式,該函式會接受表示具體形狀的字串,並執行實際的繪圖工作。
假如某函式返回 Unit 物件,那麼該函式肯定是有副作用的。Unit 物件沒有任何作用,因此該函式只能對某些狀態產生副作用。副作用可能會造成全域性範圍的影響,比如執行一次輸入或輸出操作(I/O),也可能只會影響某些區域性物件。
通常在函數語言程式設計中,人們更青睞於那些沒有任何副作用的純函式,這些純函式的返回值便是它們的工作成果。純函式容易闡述、易於測試,也很方便重用,而副作用往往是錯誤之源。不過最起碼現實中的程式離不開 I/O。
Shape.draw 闡明瞭這樣一個觀點:與 Strings、Ints、Points 和其他物件無異,函式也是第一等級的值。和其他值一樣,我們可以將函式賦給變數,將函式作為引數傳遞給其他函式,就好像 draw 方法一樣。函式還能作為其他函式的返回值。我們將利用函式這一特性構建可組合並且靈活的軟體。
假如某函式接受其他函式引數並返回函式,我們稱之為高階函式(higher-order function, HOF)。
我們可以認為 draw 方法定義了一個所有形狀類都必須支援的協議,而使用者可以自定義這個協議的實現。各個形狀類可以通過 toString 方法決定如何將狀態資訊序列化為字串。draw 方法會呼叫 f 函式,而 f 函式通過 Scala 2.10 引入的新特性插值字串(interpolated string)構建了最終的字串。
如果你忘了在“插值字串”前輸入 s 字元,draw: ${this.toString} 將原封不動地返回給你。也就是說,字串不會被竄改。
Circle、Rectangle 和 Triangle 類都是 Shape 類的具體子類。這些類並沒有類主體,這是因為 case 關鍵字為它們定義好了所有必須的方法,如 Shape.draw 所需要的 toString 方法。
為了簡化問題,我們規定矩形的各條邊平行於 x 或 y 軸。因此,我們使用一個點(左側最低點即可)、矩形的高度和寬度便能描述矩陣。而 Triangle 類(三角形)的建構函式則接受三個 Pointer 物件引數。
在簡化後的程式中,傳遞給 draw 方法的 f 函式只會在控制檯中輸出一條字串,不過你也許有機會構建一個真實的圖形程式,該程式將使用 f 函式將圖形繪製到顯示器上。
既然已經定義好了形狀型別,我們便可以回到 actor 上。其中,Typesafe(http://typesafe.com)貢獻的 Akka 類庫(http://akka.io)會被使用到。專案檔案 build.sbt 中已經將該類庫設定為專案依賴項。
下面列出 ShapesDrawingActor 類的實現程式碼:
// src/main/scala/progscala2/introscala/shapes/ShapesDrawingActor.scala
package progscala2.introscala.shapes
object Messages { // ➊
object Exit // ➋
object Finished
case class Response(message: String) // ➌
}
import akka.actor.Actor // ➍
class ShapesDrawingActor extends Actor { // ➎
import Messages._ // ➏
def receive = { // ➐
case s: Shape =>
s.draw(str => println(s"ShapesDrawingActor: $str"))
sender ! Response(s"ShapesDrawingActor: $s drawn")
case Exit =>
println(s"ShapesDrawingActor: exiting...")
sender ! Finished
case unexpected => // default. Equivalent to "unexpected: Any"
val response = Response(s"ERROR: Unknown message: $unexpected")
println(s"ShapesDrawingActor: $response")
sender ! response
}
}
❶ 此處宣告瞭物件 Messages,該物件定義了大多數 actor 之間進行通訊的訊息。這些訊息就好像訊號量一樣,觸發了彼此的行為。將這些訊息封裝在一個物件中是一個常見的 封裝方式。
❷ Exit 和 Finished 物件中不包含任何狀態,它們起到了標誌的作用。
❸ 當接收到傳送者傳送的訊息後,樣板類(case class)Response 會隨意構造字串訊息,並將訊息返回給傳送者。
❹ 匯入 akka.actor.Actor 型別(http://doc.akka.io/api/akka/current/#akka.actor.Actor)。Actor 型別是一個抽象基類,我們將繼承該類定義 actor。
❺ 此處定義了一個 actor 類,用於繪製圖形。
❻ 此處匯入了 Messages 物件中定義的三個訊息。Scala 支援巢狀匯入(nesting import),巢狀匯入會限定這些值的作用域。
❼ 此處實現了抽象方法 Actor.receive。該方法是 Actor 的子類必須實現的方法,定義瞭如何處理接收到的訊息。
包括 Akka 在內的大多數 actor 系統中,每一個 actor 都會有一個關聯郵箱(mailbox)。關聯郵箱中儲存著大量訊息,而這些訊息只有經過 actor 處理後才會被提取。Akka 確保了訊息處理的順序與接收順序相同,而對於那些正在被處理的訊息,Akka 保證不會有其他執行緒搶佔該訊息。因此,使用 Akka 編寫的訊息處理程式碼天生具有執行緒安全的特性。
需要注意的是,Akka 支援一種奇特的 receive 方法實現方式。該實現不接受任何引數,而實現體中也只包含了一組由 case 關鍵字開頭的表示式。
def receive = {
case first_pattern =>
first_pattern_expressions
case second_pattern =>
second_pattern_expressions
}
偏函式(PartialFunction,http://www.scala-lang.org/api/current/#scala.PartialFunction)是一類較為特殊的函式,上述函式體所用的語法就是典型的偏函式語法。偏函式實際型別是 PartialFunction[Any,Unit],這說明偏函式接受單一的 Any 型別引數並返回 Unit 值。Any 是 Scala 類層次級別的根類,因此該函式可以接受任何引數。由於該函式返回 Unit 物件,因此函式體一定會產生副作用。由於 actor 系統採用了非同步訊息機制,它必須依靠副作用。通常情況下由於傳遞訊息後無法返回任何資訊,我們的程式碼塊中便會傳送一些其他訊息,包括給傳送者的返回資訊。
偏函式中僅包含了一些 case 子句,這些子句會對傳遞給函式的訊息執行模式匹配。程式碼中並沒有任何表示訊息的函式引數,內部實現需要處理這些訊息。
當匹配上某一模式時,系統將執行從箭頭符(=>)到下一個 case 子句(也有可能是函式結尾處)之間的表示式。由於箭頭符和下一個 case 關鍵字能夠無誤地標識程式碼區間,因此無須使用大括號包住表示式。另外,假如 case 關鍵字後只有一句簡短的表示式,可以不用換行,直接將表示式放在箭頭後面。
儘管聽上去挺複雜,實際上偏函式是一個簡單的概念。單引數函式會接受某一型別的輸入值並返回相同或不同型別的值。而選用偏函式相當於明確地告訴其他人:“我也許無法處理所有你輸入給我的值。”除法 x/y 是數學上的一個經典偏函式例子,當分母 y 為 0 時,x/y 的值是不確定的。因此,除法是一個偏函式。
receive 方法會嘗試將接收到的各條訊息與這三個模式匹配表示式進行匹配,並執行最先被匹配上的表示式。接下來我們對 receive 方法進行分解。
def receive = {
case s: Shape => // ➊
...
case Exit => // ➋
...
case unexpected => // ➌
...
}
❶ 如果收到的資訊是 Shape 的一個例項,那說明該訊息匹配了第一條 case 子句。我們也會將 Shape 物件引用賦給變數 s。也就是說,雖然輸入訊息的型別為 Any,但 s 型別卻是 Shape。
❷ 判斷訊息是否為 Exit 訊息體。Exit 訊息用於標識已經完成。
❸ 這是一條“預設”子句,可以匹配任何輸入。該子句等同於 unexpected: Any 子句,對於那些未能與前兩個子句模式匹配的任何輸入,該子句都會匹配。而變數 unexpected 會被賦予訊息值。
最後一條匹配規則能匹配任何訊息,因此該規則必須放到最後一位。假如你嘗試將其放置到某些規則之前,你將看到 unreachable code 的錯誤資訊。這是因為這些後續的 case 表示式不可訪問。
值得注意的是,由於我們新增了“預設”子句,這個“偏”函式其實變成了“完整的”,這意味著該函式能正確處理任何輸入。
下面讓我們檢視每個匹配點呼叫的表示式:
def receive = {
case s: Shape =>
s.draw(str => println(s"ShapesDrawingActor: $str")) // ➊
sender ! Response(s"ShapesDrawingActor: $s drawn") // ➋
case Exit =>
println(s"ShapesDrawingActor: exiting...") // ➌
sender ! Finished // ➍
case unexpected =>
val response = Response(s"ERROR: Unknown message: $unexpected") // ➎
println(s"ShapesDrawingActor: $response")
sender ! response // ➏
}
❶ 呼叫了形狀 s 的 draw 方法並傳入一個匿名函式,該匿名函式了解如何處理 draw 方法生成的字串。在這段程式碼中,此匿名函式僅列印了生成的字串。
❷ 向“發信方”回覆了一個訊息。
❸ 列印了一條表示正在退出的訊息。
❹ 向“發信方”傳送了一條結束資訊。
❺ 根據錯誤資訊生成 Response 物件,並列印錯誤資訊。
❻ 向“發信方”回覆了這條資訊。
程式碼 sender ! Response(s”ShapesDrawingActor: $s drawn”) 建立了回覆資訊,並將該資訊傳送給了 shape 物件的傳送方。Actor.sender 函式返回了 actor 傳送訊息接收方的物件引用,而 ! 方法則用於傳送非同步訊息。是的,! 是一個方法名,使用 ! 遵循了之前 Erlang 的訊息傳送規範,值得一提的是,Erlang 是一門推廣 actor 模型的語言。
我們也可以在 Scala 允許範圍內使用一些語法糖。下面兩行程式碼是等價的:
sender ! Response(s”ShapesDrawingActor: $s drawn”)
sender.!(Response(s”ShapesDrawingActor: $s drawn”))
假如某一方法只接受單一引數,你可以省略掉物件後的點號和引數周邊的括號。請注意,第一行程式碼看起來更清晰,這也是 Scala 支援這種語法的原因。表示法 sender ! Response 被稱為中置表示法,這是因為操作符 ! 位於物件和引數中間。
Scala 的方法名可以是操作符。呼叫接受單一引數的方法時可以省略物件後的點號和引數周邊的括號。不過有時候省略它們會導致解析二義性,這時你需要保留點號或保留括號,有時候兩者都需要保留。
在進入最後一個 actor 之前還有最後一個值得注意的地方。使用物件導向程式設計時,有一條經常被人提及的原則:永遠不要在 case 語句上進行型別匹配。這是因為如果繼承層次結構發生了變化,case 表示式也會失效。作為替代方案,你應該使用多型函式。這是不是意味著我們之前談論的模式匹配程式碼只是一個反模式呢?
回顧一下,我們之前定義的 Shape.draw 方法呼叫了 Shape 類的 toString 方法,由於 Shape 類的那些子類是 case 類,因此這些子類中實現了 toString 方法。第一個 case 語句中的程式碼呼叫了多型的 toString 操作,而我們也沒有與 Shape 的某一具體子類進行匹配。這意味著即便修改了 Shape 類層次結構,我們的程式碼也不會失效。其他的 case 子句所匹配的條件也與類層次無關,即便這些條件真會發生變化,變化也不會頻繁。
由此,我們將物件導向程式設計中的多型與函數語言程式設計中的勞模——模式匹配結合到了一起。
這是 Scala 優雅地整合這兩種程式設計正規化的方式之一。
模式匹配與子型別多型
模式匹配在函數語言程式設計中扮演了重要的角色,而子型別多型(即重寫子型別中的方法)在物件導向程式設計的世界中同樣不可或缺。函數語言程式設計中的模式匹配的重要性和複雜度都要遠超過大多數命令式語言中對應的 swith/case 語句。我們將在第 4 章深入探討模式匹配。在此處的示例中,我們開始瞭解到函式風格的模式匹配和多型排程之間的結合會產生強大的組合效果,而這也是像 Scala 這樣的混合正規化語言能提供的一大益處。
最後,我將列出執行此示例的 ShapesDrawingDriver 物件的程式碼:
// src/main/scala/progscala2/introscala/shapes/ShapesActorDriver.scala
package progscala2.introscala.shapes
import akka.actor.{Props, Actor, ActorRef, ActorSystem}
import com.typesafe.config.ConfigFactory
// 僅用於本檔案的訊息:
private object Start // ➊
object ShapesDrawingDriver { // ➋
def main(args: Array[String]) { // ➌
val system = ActorSystem("DrawingActorSystem", ConfigFactory.load())
val drawer = system.actorOf(
Props(new ShapesDrawingActor), "drawingActor")
val driver = system.actorOf(
Props(new ShapesDrawingDriver(drawer)), "drawingService")
driver ! Start // ➍
}
}
class ShapesDrawingDriver(drawerActor: ActorRef) extends Actor { // ➎
import Messages._
def receive = {
case Start => // ➏
drawerActor ! Circle(Point(0.0,0.0), 1.0)
drawerActor ! Rectangle(Point(0.0,0.0), 2, 5)
drawerActor ! 3.14159
drawerActor ! Triangle(Point(0.0,0.0), Point(2.0,0.0), Point(1.0,2.0))
drawerActor ! Exit
case Finished => // ➐
println(s"ShapesDrawingDriver: cleaning up...")
context.system.shutdown()
case response: Response => // ➑
println("ShapesDrawingDriver: Response = " + response)
case unexpected => // ➒
println("ShapesDrawingDriver: ERROR: Received an unexpected message = "
-
unexpected)
}
}
❶ 定義僅用於本檔案的訊息(私有訊息),該訊息用於啟動。使用一個特殊的開始訊息是一個普遍的做法。
❷ 定義“驅動”actor。
❸ 定義了用於驅動應用的主方法。主方法先後構建了一個 akka.actor.ActorSystem 物件(http://doc.akka.io/api/akka/current/#akka.actor.ActorSystem)和兩個 actor 物件:我們之前討論過的 ShapesDrawingActor 物件和即將講解的 ShapesDrawingDriver 物件。我們暫時先不討論設定 Akka 的方法,在 17.3 節將詳細講述。現在只需要知道我們把 ShapesDrawingActor 物件傳遞給了 ShapesDrawingDriver 即可,事實上我們向 ShapesDrawingDriver 物件傳遞的物件屬於 akka.actor.ActorRef 型別(http://doc.akka.io/api/akka/current/#akka.actor.ActorRef,actor 的引用型別,指向實際的 actor 例項)。
❹ 向驅動物件傳送 Start 命令,啟動應用!
❺ 定義了 actor 類:ShapesDrawingDriver。
❻ 當 receive 方法接收到 Start 訊息時,它將向 ShapesDrawingActor 傳送五個非同步訊息:包含了三個形狀類物件,Pi 值(將被視為錯誤資訊)和 Exit 訊息。從這能看出,這是一個生命週期很短的 actor 系統!
❼ 假如 ShapesDrawingDriver 傳送 Exit 訊息後接收到了返回的 Finished 訊息(請回憶一下 ShapesDrawingActor 類處理 Exit 訊息的邏輯),那麼我們將訪問 Actor 類提供的 context 欄位來關閉 actor 系統。
❽ 簡單地列印出其他錯誤的回覆資訊。
❾ 與之前所見的預設子句一樣,該子句用於處理預料之外的訊息。
讓我們嘗試執行該程式!在 sbt 提示符後輸入 run,sbt 將按需編譯程式碼並列出所有定義了 main 方法的程式碼示例程式:
run
[info] Compiling …
Multiple main classes detected, select one to run:
[1] progscala2.introscala.shapes.ShapesDrawingDriver
…
Enter number:
輸入數字 1,之後我們便能看到下列輸出 ( 為了方便顯示,已對輸出內容進行排版 ):
…
Enter number: 1
[info] Running progscala2.introscala.shapes.ShapesDrawingDriver
ShapesDrawingActor: draw: Circle(Point(0.0,0.0),1.0)
ShapesDrawingActor: draw: Rectangle(Point(0.0,0.0),2.0,5.0)
ShapesDrawingActor: Response(ERROR: Unknown message: 3.14159)
ShapesDrawingActor: draw: Triangle(
Point(0.0,0.0),Point(2.0,0.0),Point(1.0,2.0))
ShapesDrawingActor: exiting…
ShapesDrawingDriver: Response = Response(
ShapesDrawingActor: Circle(Point(0.0,0.0),1.0) drawn)
ShapesDrawingDriver: Response = Response(
ShapesDrawingActor: Rectangle(Point(0.0,0.0),2.0,5.0) drawn)
ShapesDrawingDriver: Response = Response(ERROR: Unknown message: 3.14159)
ShapesDrawingDriver: Response = Response(
ShapesDrawingActor: Triangle(
Point(0.0,0.0),Point(2.0,0.0),Point(1.0,2.0)) drawn)
ShapesDrawingDriver: cleaning up…
[success] Total time: 10 s, completed Aug 2, 2014 7:45:07 PM
由於所有的訊息都是以非同步的方式傳送的,你可以看到驅動 actor 和繪圖 actor 的訊息交織在一起。不過處理訊息的順序與傳送訊息的順序相同。執行多次應用程式,你會發現每次輸出都會不同。
到現在為止,我們已經嘗試了基於 actor 的併發程式設計,同時也掌握了一些很有威力的 Scala 特性。
1.5 本章回顧與下一章提要
我們首先介紹了 Scala,之後分析了一些重要的 Scala 程式碼,其中包含一些 Akka 的 actor 併發庫相關程式碼。
你在學習 Scala 的過程中,也可以訪問 http://scala-lang.org 網站獲取其他一些有用的資源。在該網站上,能找到一些指向 Scala 類庫、教程以及一些描述這門語言特性相關文章的連結。
Typesafe 是一家為 Scala 以及包括 Akka(http://akka.io)、Play(http://www.playframework.com)在內的許多基於 JVM 的開發工具和框架提供支援的商業公司。在該公司的網站上(http://typesafe.com)也能找到一些有用的資源。尤其是 Typesafe Activator 工具(http://typesafe.com/activator),該工具會根據不同型別的 Scala 或 Java 應用程式模版,執行分析、下載和構建工作。Typesafe 公司還提供了訂購支援、諮詢及培訓服務。
在後續的部分,我們將繼續介紹 Scala 的特性,著重介紹如何使用 Scala 簡潔有效地完成工作。