讀古今文學網 > Java程序員修煉之道 > 9.3 讓代碼因Scala重新綻放 >

9.3 讓代碼因Scala重新綻放

我們在這一節會先介紹一下Scala編譯器和交互環境(REPL)。然後討論類型推斷,接著是方法聲明(跟你所熟悉的Java方式不太一樣)。這兩個特性能幫你減少大量的套路化代碼,從而提高生產力。

我們會談到Scala的代碼封包方式和更強大的import語句,然後詳細講解一下Scala中的循環和控制結構。這些特性植根於跟Java差異巨大的編程傳統,所以我們會借此機會討論一下Scala的函數式編程,包括函數式的循環結構、match表達式和函數字面值。

看過這些之後,本章剩下的大部分內容對你來說都沒什麼問題了,你可以自信地說自己有能力成為一名Scala程序員了。來吧,現在我們就開始討論編譯器和內置的交互環境。

9.3.1 使用編譯器和REPL

Scala是編譯型語言,所以執行Scala程序通常要把它們先編譯成.class文件,然後在類路徑上有scala-library.jar(Scala運行時類庫)的JVM環境中執行。

如果你還沒裝Scala,請在繼續閱讀之前參見附錄C,瞭解如何安裝Scala。樣例程序(9.1.1中的HelloWorld)可以用scalac HelloWorld.scala編譯(如果你正好在HelloWorld.scala文件所在的目錄中)。

一旦得到.class文件,就可以用命令scala HelloWorld執行它了。這個命令會啟動帶著Scala運行時環境的JVM,然後進入類文件指定的main方法。

除了編譯和運行,Scala還有個內置的交互環境,有點像第8章講的Groovy控制台。但不像Groovy,Scala是在命令行環境裡實現的。這就是說在典型的Unix/Linux環境(Path設置正確)中,你可以敲入scala,它就會在終端窗口內打開,而不會再彈出一個新窗口。

注意 這類交互環境有時被稱為讀入—計算—輸出(Read-Eval-Print)循環,或簡稱為REPL。這在動態語言中很常見。在REPL環境中,前面輸入的那些行的計算結果還在,在後面的表達式和計算中還可以用。在本章的剩餘部分,我們偶爾會用REPL環境來演示Scala語法。

現在我們開始討論下一個大特性:Scala的高級類型推斷。

9.3.2 類型推斷

在讀前面的代碼時你可能已經注意到了,我們在聲明變量helloval時,沒有指明它是什麼類型。因為它很「明顯」是個字符串。表面上來看這有點像Groovy,變量沒有類型(Groovy是動態類型語言),但其實Scala代碼中所發生的事情完全不同。

Scala是靜態類型語言(所以變量確實有明確的類型),但它的編譯器能分析源碼,並且一般都能根據上下文推斷出應該是什麼類型。如果Scala自己能確定是什麼類型,就不用你親自告訴它了。

這就是類型推斷,我們已經提過好幾次了。Scala在這方面的能力非常突出—以致於開發人員經常在行雲流水一樣的代碼中忘記靜態類型。這經常讓Scala更有動態語言的「感覺」。

Java中的類型推斷

Java也有類型推斷的能力,雖然有限,但確實有。最明顯的例子就是我們在第1章見到的泛型鑽石語法。Java的類型推斷通常是用在賦值語句等號右邊的值上。Scala通常是推斷變量而不是值的類型,但它的確也能推斷值的類型。

你已經見過其中最簡單的例子了:關鍵字varval,Scala根據賦給變量的值來推斷它們的類型。Scala類型推斷的另一個重要應用是方法聲明。我們來看個例子(Scala的AnyRef就是Java中的Object):

def len(obj : AnyRef) = {
  obj.toString.length
}
  

這是一個類型推斷的方法。通過檢查它返回代碼中的java.lang.String#length的類型(int),編譯器知道這個方法要返回Int類型的值。注意,這個方法沒有顯式指定返回類型,我們也不需要用return關鍵字。實際上,如果你放了一個顯式的return在這裡,像這樣:

def len(obj : AnyRef) = {
  return obj.toString.length
}
  

會得到一個編譯時錯誤:

error:  method len has return statement; needs result type
       return obj.toString.length
       ^
  

如果你連def中的=也省略了,編譯器會假定這個方法會返回Unit(就跟Java裡返回void一樣)。

除了前面那些限制,還有兩個類型推斷受限的區域:

  • 方法聲明中參數的類型——傳給方法的參數必須指定類型;
  • 遞歸函數——Scala編譯器不能推斷遞歸函數的返回類型。

關於Scala的方法,我們討論的東西已經不少了,但還算不上系統化的討論,所以我們來鞏固一下你已經學過的東西。

9.3.3 方法

你已經見過怎麼用def關鍵字定義方法了。隨著你對Scala越來越熟悉,關於Scala的方法,還有些你應該知道的重要事實。

  • Scala沒有static關鍵字。跟Java中的static方法對應的方法必須放在Scala的object(單例)結構中。稍後我們會向你介紹相關概念:伴生對象。

  • 跟Groovy(或Clojure)相比,Scala語言的運行時要重得多。Scala類中可能會有很多由平台自動生成的額外方法。

  • 方法調用是Scala的核心概念。在Scala中沒有Java中那種意義的操作符。

  • 對於哪些字符可以出現在方法的名稱中,Scala比Java更靈活。特別是那些在其他語言中作為操作符的字符,在Scala中可能是合法的方法名(比如加號+)。

間接方法調用(前面講過)中有Scala把方法調用和操作符合併到一起的線索。舉個例子,比如要把兩個整型相加。在Java中,應該是寫一個a+b這樣的表達式。在Scala中你也可以這樣寫,但不止這樣,還可以寫成a.+(b)。換句話說,你調用了a上的+方法,並把b作為參數傳給它。這就是Scala不再把操作符當做一個獨立概念的秘密。

注意 你可能已經注意到了,a.+(b)是在a上調用方法。但原始類型的變量a怎麼會有方法呢?9.4節會給出完整的解釋。但現在,你只要知道Scala的類型系統認為所有東西都是對象,所以你可以在任何東西上調用方法,即便是Java裡的原始類型變量也行。

你已經見過一個用def關鍵字聲明方法的例子了。我們再來看一個例子,一個實現階乘函數的簡單遞歸方法:

def fact(base : Int) : Int = {
  if (base <= 0)
    return 1
  else
    return base * fact(base - 1)
}
  

對於所有負數,這個函數都返回1,這算是作弊吧。實際上,負數的階乘是不存在的,但大家都是朋友嘛。它看起來有點像Java:有返回類型(Int),並用return關鍵字表明把哪個值交回給調用者。唯一需要注意的就是在函數體代碼塊定義之前額外符號=

Scala中還有另外一個Java中沒有概念:局部函數。它是在另外一個函數內部(並且僅在這一作用域內有效)定義的函數。如果開發人員想要一個輔助函數,又不想把實現細節暴露給外部,這是一個簡單的辦法。在Java中除了用private方法之外別無選擇,但這個函數對於同一類的其他方法都是可見的。但在Scala中,你只要這樣寫就行了:

def fact2(base : Int) : Int = {
    def factHelper(n : Int) : Int = {
        return fact2(n-1)
    }
    if (base <= 0)
      return 1
    else
      return base * factHelper(base)
}
  

factHelperfact2的封閉作用域之外絕對是不可見的。

接下來,我們去看看Scala如何處理代碼的組織和導入。

9.3.4 導入

Scala對包的使用跟Java一樣,關鍵字也一樣,分別是packageimport。Scala可以毫無障礙地導入和使用Java的包和類。Scala的varval變量可以引用任何Java類的實例,不需要任何特殊的語法或處理:

import java.io.File
import java.net._
import scala.collection.{Map, Seq}
import java.util.{Date => UDate}
  

頭兩行代碼跟Java裡的標準導入和通配符導入一樣。第三行用一行導入一個包裡的多個類。最後一行在導入時指定了類的別名(避免縮寫衝突出現)。

跟Java不一樣,Scala中的import可以出現在代碼中的任何位置(不僅限於文件頂部),這樣你就可以把import當做文件的一部分分離出來。Scala也有默認導入,即所有.scala文件默認都會導入scala._。這裡有很多有用的函數,包括我們已經討論過的一些,比如println。對於所有默認導入的完整細節,請參見www.scala-lang.org/上的API文檔。

我們接下來討論怎麼控制Scala程序的執行流。這可能和你熟悉的Java跟Groovy有些差異。

9.3.5 循環和控制結構

Scala在控制和循環結構上引入了幾個有點繞的創新。在我們向你介紹這些不熟悉的形式之前,先來看幾個老朋友,比如標準的while循環:

var counter = 1
while (counter <= 10) {
  println(\".\" * counter)
  counter = counter + 1
}
 

還有do-while形式:

var counter = 1
do {
  println(\".\" * counter)
  counter = counter + 1
} while (counter <= 10)
  

另一個是基本的for循環:

for (i <- 1 to 10) println(i)
  

看起來都很好。但Scala更靈活,比如條件for循環:

for (i <- 1 to 10; if i %2 == 0) println(i)
  

還能在多個變量上循環,比如:

for (x <- 1 to 5; y <- 1 to x)
  println(\" \" * (x - y) + x.toString * y)
  

這些多出來的形式源於Scala實現這些結構的根本性差異。Scala用函數式編程中的概念(列表推導式)來實現for循環。

列表推導式的一般概念是對一個列表中的元素進行轉換(或過濾,比如在用條件for循環時)。這會產生一個新列表,然後在其中的每個元素上逐次運行for循環體中的代碼。

甚至把要過濾的列表和for代碼塊分開都是有可能的,用yield關鍵字。比如下面這段代碼:

val xs = for (x <- 2 to 11) yield fact(x)
for (factx <- xs) println(factx)
  

這段代碼先設置新集合xs,然後用第二個for循環逐一輸出其中的值。如果你需要一個創建一次、使用多次的集合,這個極其好用。

這一結構能成立是因為Scala支持函數式編程,我們接下來就去看看Scala如何實現函數式思想。

9.3.6 Scala的函數式編程

我們在7.5.2節提起過,Scala把函數當做內置的值。這就是說函數可以放進varval中,並和其他任何值所受的對待毫無二致。這被稱為函數字面值(或匿名函數),它們是Scala世界觀的重要組成部分。

在Scala中寫函數字面值非常簡單。其中的關鍵是箭頭=>,Scala用它來表示取得參數列表並傳遞到代碼塊中:

(<函數參數列表>) => { ... 作為代碼塊的函數體 ... }
  

我們用Scala的交互環境來演示一下。下面這個例子中定義的函數接受一個Int參數,然後乘以2:

scala> val doubler = (x : Int) => { 2 * x }
doubler: (Int) => Int = <function1>

scala> doubler(3)
res4: Int = 6

scala> doubler(4)
res5: Int = 8
  

注意看Scala怎麼推斷doubler的類型。它的類型是「接受一個Int並返回Int的函數」。這樣的類型用Java的類型系統還不能以令人完全滿意的方式表示。你看,調用doubler就是用標準的調用語法。

我們把這個概念再向前推進一點。在Scala中,函數字面值只是值。並且是函數返回的值。這就是說你可以寫一個生產函數的函數——接受一個值並返回一個新的函數字面值。

比如說,可以定義一個命名為adder的函數字面值。adder能生產一個給它們的參數加上一個常量的函數:

scala> val adder = (n : Int) => { (x : Int) => x + n }
adder: (Int) => (Int) => Int = <function1>
scala> val plus2 = adder(2)
plus2: (Int) => Int = <function1>
scala> plus2(3)
res2: Int = 5
scala> plus2(4)
res3: Int = 6
  

看到了吧,Scala對函數字面值支持得很好。實際上,Scala代碼一般都能用非常函數的世界觀來編寫,同時也能用更加命令式的風格編寫。現在我們所做的不過是剛剛涉足Scala的函數式編程能力,但重要的是知道它們在那裡。

在下一節中,我們會討論Scala的對象模型和面向對像方式的細節。在一些重要方面,Scala的一些先進的特性使得它對面向對象的處理方式跟Java差異很大。