對於學習 Scala 的 Java™ 開發人員來說,對象是一個比較自然、簡單的入口點。在 本系列 前幾期文章中,我介紹了 Scala 中一些面向對象的編程方法,這些方法實際上與 Java 編程的區別不是很大。我還向您展示了 Scala 如何重新應用傳統的面向對象概念,找到其缺點,並根據 21 世紀的新需求重新加以改造。Scala 一直隱藏的一些重要內容將要現身:Scala 也是一種函數語言(這裡的函數性是與其他 dys 函數語言相對而言的)。
Scala 的面向函數性非常值得探討,這不僅是因為已經研究完了對象內容。Scala 中的函數編程將提供一些新的設計結構和理念以及一些內置構造,它們使某些場景(例如並發性)的編程變得非常簡單。
本月,您將首次進入 Scala 的函數編程領域,查看大多數函數語言中常見的四種類型:列表(list)、元組(tuple)、集合(set)和 Option 類型。您還將了解 Scala 的數組,後者對其他函數語言來說十分新鮮。這些類型都提出了編寫代碼的新方式。當結合傳統面向對象特性時,可以生成十分簡潔的結果。
使用 Option(s)
在什麼情況下,“無” 並不代表 “什麼也沒有”?當它為 0 的時候,與 null 有什麼關系。
對於我們大多數人都非常熟悉的概念,要在軟件中表示為 “無” 是一件十分困難的事。例如,看看 C++ 社區中圍繞 NULL 和 0 進行的激烈討論,或是 SQL 社區圍繞 NULL 列值展開的爭論,便可知曉一二。NULL 或 null 對於大多數程序員來說都表示 “無”,但是這在 Java 語言中引出了一些特殊問題。
考慮一個簡單操作,該操作可以從一些位於內存或磁盤的數據庫查找程序員的薪資:API 允許調用者傳入一個包含程序員名字的 String,這會返回什麼呢?從建模角度來看,它應該返回一個 Int,表示程序員的年薪;但是這裡有一個問題,如果程序員不在數據庫中(可能根本沒有雇用她,或者已經被解雇,要不就是輸錯了名字……),那麼應該返回什麼。如果返回類型是 Int,則不能返回 null,這個 “標志” 通常表示沒有在數據庫中找到該用戶(您可能認為應該拋出一個異常,但是大多數時候數據庫丟失值並不能視為異常,因此不應該在這裡拋出異常)。
在 Java 代碼中,我們最終將方法標記為返回 java.lang.Integer,這迫使調用者知道方法可以返回 null。自然,我們可以依靠程序員來全面歸檔這個場景,還可以依賴程序員讀取 精心准備的文檔。這類似於:我們可以要求經理傾聽我們反對他們要求的不可能完成的項目期限,然後經理再進一步把我們的反對傳達給上司和用戶。
Scala 提供了一種普通的函數方法,打破了這一僵局。在某些方面,Option 類型或 Option[T],並不重視描述。它是一個具有兩個子類 Some[T] 和 None 的泛型類,用來表示 “無值” 的可能性,而不需要語言類型系統大費周折地支持這個概念。實際上,使用 Option[T] 類型可以使問題更加清晰(下一節將用到)。
在使用 Option[T] 時,關鍵的一點是認識到它實質上是一個大小為 “1” 的強類型集合,使用一個不同的值 None 表示 “nothing” 值的可能性。因此,在這裡方法沒有返回 null 表示沒有找到數據,而是進行聲明以返回 Option[T],其中 T 是返回的原始類型。那麼,對於沒有查找到數據的場景,只需返回 None,如下所示:
清單 1. 准備好踢足球了嗎?
@Test def simpleOptionTest =
{
val footballTeamsAFCEast =
Map("New England" -> "Patriots",
"New York" -> "Jets",
"Buffalo" -> "Bills",
"Miami" -> "Dolphins",
"Los Angeles" -> null)
assertEquals(footballTeamsAFCEast.get("Miami"), Some("Dolphins"))
assertEquals(footballTeamsAFCEast.get("Miami").get(), "Dolphins")
assertEquals(footballTeamsAFCEast.get("Los Angeles"), Some(null))
assertEquals(footballTeamsAFCEast.get("Sacramento"), None)
}
注意,Scala Map 中 get 的返回值實際上並不對應於傳遞的鍵。相反,它是一個 Option[T] 實例,可以是與某個值有關的 Some(),也可以是 None,因此可以很清晰地表示沒有在 map 中找到鍵。如果它可以表示 map 上存在某個鍵,但是有對應的 null 值,這一點特別重要了。比如清單 1 中 Los Angeles 鍵。
通常,當處理 Option[T] 時,程序員將使用模式匹配,這是一個非常函數化的概念,它允許有效地 “啟用” 類型和/或值,更不用說在定義中將值綁定到變量、在 Some() 和 None 之間切換,以及提取 Some 的值(而不需要調用麻煩的 get() 方法)。清單 2 展示了 Scala 的模式匹配:
清單 2. 巧妙的模式匹配
@Test def optionWithPM =
{
val footballTeamsAFCEast =
Map("New England" -> "Patriots",
"New York" -> "Jets",
"Buffalo" -> "Bills",
"Miami" -> "Dolphins")
def show(value : Option[String]) =
{
value match
{
case Some(x) => x
case None => "No team found"
}
}
assertEquals(show(footballTeamsAFCEast.get("Miami")), "Dolphins")
}
元組和集合
在 C++ 中,我們將之稱為結構體。在 Java 編程中,我們稱之為數據傳輸對象或參數對象。在 Scala 中,我們稱為元組。實質上,它們是一些將其他數據類型收集到單個實例的類,並且不使用封裝或抽象 — 實際上,不 使用任何抽象常常更有用。
在 Scala 創建一個元組類型非常的簡單,這只是主體的一部分:如果首先將元素公開給外部,那麼在類型內部創建描述這些元素的名稱就毫無價值。考慮清單 3:
清單 3. tuples.scala
// JUnit test suite
//
class TupleTest
{
import org.junit._, Assert._
import java.util.Date
@Test def simpleTuples() =
{
val tedsStartingDateWithScala = Date.parse("3/7/2006")
val tuple = ("Ted", "Scala", tedsStartingDateWithScala)
assertEquals(tuple._1, "Ted")
assertEquals(tuple._2, "Scala")
assertEquals(tuple._3, tedsStartingDateWithScala)
}
}
創建元組非常簡單,將值放入一組圓括號內,就好象調用一個方法調用一樣。提取這些值只需要調用 “_n” 方法,其中 n 表示相關的元組元素的位置參數:_1 表示第一位,_2 表示第二位,依此類推。傳統的 Java java.util.Map 實質上是一個分兩部分的元組集合。
元組可以輕松地實現使用單個實體移動多個值,這意味著元組可以提供在 Java 編程中非常重量級的操作:多個返回值。例如,某個方法可以計算 String 中字符的數量,並返回該 String 中出現次數最多的字符,但是如果程序員希望同時 返回最常出現的字符和 它出現的次數,那麼程序設計就有點復雜了:或是創建一個包含字符及其出現次數的顯式類,或將值作為字段保存到對象中並在需要時返回字段值。無論使用哪種方法,與使用 Scala 相比,都需要編寫大量代碼;通過簡單地返回包含字符及其出現次數的元組,Scala 不僅可以輕松地使用 “_1”、“_2” 等訪問元組的各個值,還可以輕松地返回多個返回值。
如下節所示,Scala 頻繁地將 Option 和元組保存到集合(例如 Array[T] 或列表)中,從而通過一個比較簡單的結構提供了極大的靈活性和威力。
數組帶您走出陰霾
讓我們重新審視一個老朋友 — 數組 — 在 Scala 中是 Array[T]。和 Java 代碼中的數組一樣,Scala 的 Array[T] 是一組有序的元素序列,使用表示數組位置的數值進行索引,並且該值不可以超過數組的總大小,如清單 4 所示:
清單 4. array.scala
object ArrayExample1
{
def main(args : Array[String]) : Unit =
{
for (i <- 0 to args.length-1)
{
System.out.println(args(i))
}
}
}
盡管等同於 Java 代碼中的數組(畢竟後者是最終的編譯結果),Scala 中的數組使用了截然不同的定義。對於新手,Scala 中的數組實際上就是泛型類,沒有增加 “內置” 狀態(至少,不會比 Scala 庫附帶的其他類多)。例如,在 Scala 中,數組一般定義為 Array[T] 的實例,這個類定義了一些額外的有趣方法,包括常見的 “length” 方法,它將返回數組的長度。因此,在 Scala 中,可以按照傳統意義使用 Array,例如使用 Int 在 0 到 args.length - 1 間進行迭代,並獲取數組的第 i 個元素(使用圓括號而不是方括號來指定返回哪個元素,這是另一種名稱比較有趣的方法)。
擴展數組
事實證明 Array 擁有大量方法,這些方法繼承自一個非常龐大的 parent 層次結構:Array 擴展 Array0,後者擴展 ArrayLike[A],ArrayLike[A] 擴展 Mutable[A],Mutable[A] 又擴展 RandomAccessSeq[A],RandomAccessSeq[A] 擴展了 Seq[A],等等。實際上,這種層次結構意味著 Array 可以執行很多操作,因此與 Java 編程相比,在 Scala 中可以更輕松地使用數組。
例如,如清單 4 所示,使用 foreach 方法遍歷數組更加簡單並且更貼近函數的方式,這些都繼承自 Iterable 特性:
清單 5. ArrayExample2
object
{
def main(args : Array[String]) : Unit =
{
args.foreach( (arg) => System.out.println(arg) )
}
}
看上去您沒有節省多少工作,但是,將一個函數(匿名或其他)傳入到另一個類中以便獲得在特定語義下(在本例中指遍歷數組)執行的能力,是函數編程的常見主題。以這種方式使用更高階函數並不局限於迭代;事實上,還得經常對數組內容執行一些過濾 操作去掉無用的內容,然後再處理結果。例如,在 Scala 中,可以輕松地使用 filter 方法進行過濾,然後獲取結果列表並使用 map 和另一個函數(類型為 (T) => U,其中 T 和 U 都是泛型類型),或 foreach 來處理每個元素。我在清單 6 中采取了後一種方法(注意 filter 使用了一個 (T) : Boolean 方法,意味著使用數組持有的任意類型的參數,並返回一個 Boolean)。
清單 6. 查找所有 Scala 程序員
class ArrayTest
{
import org.junit._, Assert._
@Test def testFilter =
{
val programmers = Array(
new Person("Ted", "Neward", 37, 50000,
Array("C++", "Java", "Scala", "Groovy", "C#", "F#", "Ruby")),
new Person("Amanda", "Laucher", 27, 45000,
Array("C#", "F#", "Java", "Scala")),
new Person("Luke", "Hoban", 32, 45000,
Array("C#", "Visual Basic", "F#")),
new Person("Scott", "Davis", 40, 50000,
Array("Java", "Groovy"))
)
// Find all the Scala programmers ...
val scalaProgs =
programmers.filter((p) => p.skills.contains("Scala") )
// Should only be 2
assertEquals(2, scalaProgs.length)
// ... now perform an operation on each programmer in the resulting
// array of Scala programmers (give them a raise, of course!)
//
scalaProgs.foreach((p) => p.salary += 5000)
// Should each be increased by 5000 ...
assertEquals(programmers(0).salary, 50000 + 5000)
assertEquals(programmers(1).salary, 45000 + 5000)
// ... except for our programmers who don't know Scala
assertEquals(programmers(2).salary, 45000)
assertEquals(programmers(3).salary, 50000)
}
}
創建一個新的 Array 時將用到 map 函數,保持原始的數組內容不變,實際上大多數函數性程序員都喜歡這種方式:
清單 7. Filter 和 map
@Test def testFilterAndMap =
{
val programmers = Array(
new Person("Ted", "Neward", 37, 50000,
Array("C++", "Java", "Scala", "C#", "F#", "Ruby")),
new Person("Amanda", "Laucher", 27, 45000,
Array("C#", "F#", "Java", "Scala")),
new Person("Luke", "Hoban", 32, 45000,
Array("C#", "Visual Basic", "F#"))
new Person("Scott", "Davis", 40, 50000,
Array("Java", "Groovy"))
)
// Find all the Scala programmers ...
val scalaProgs =
programmers.filter((p) => p.skills.contains("Scala") )
// Should only be 2
assertEquals(2, scalaProgs.length)
// ... now perform an operation on each programmer in the resulting
// array of Scala programmers (give them a raise, of course!)
//
def raiseTheScalaProgrammer(p : Person) =
{
new Person(p.firstName, p.lastName, p.age,
p.salary + 5000, p.skills)
}
val raisedScalaProgs =
scalaProgs.map(raiseTheScalaProgrammer)
assertEquals(2, raisedScalaProgs.length)
assertEquals(50000 + 5000, raisedScalaProgs(0).salary)
assertEquals(45000 + 5000, raisedScalaProgs(1).salary)
}
注意,在清單 7 中,Person 的 salary 成員可以標記為 “val”,表示不可修改,而不是像上文一樣為了修改不同程序員的薪資而標記為 “var”。
Scala 的 Array 提供了很多方法,在這裡無法一一列出並演示。總的來說,在使用數組時,應該充分地利用 Array 提供的方法,而不是使用傳統的 for ... 模式遍歷數組並查找或執行需要的操作。最簡單的實現方法通常是編寫一個函數(如果有必要的話可以使用嵌套,如清單 7 中的 testFilterAndMap 示例所示),這個函數可以執行所需的操作,然後根據期望的結果將該函數傳遞給 Array 中的 map、filter、foreach 或其他方法之一。
函數性列表
函數編程多年來的一個核心特性就是列表,它和數組在對象領域中享有相同級別的 “內置” 性。列表對於構建函數性軟件非常關鍵,因此,您(作為一名剛起步的 Scala 程序員)必須能夠理解列表及其工作原理。即使列表從未形成新的設計,但是 Scala 代碼在其庫中廣泛使用了列表。因此學習列表是非常必要的。
在 Scala 中,列表類似於數組,因為它的核心定義是 Scala 庫中的標准類 List[T]。並且,和 Array[T] 相同,List[T] 繼承了很多基類和特性,首先使用 Seq[T] 作為直接上層基類。
基本上,列表是一些可以通過列表頭或列表尾提取的元素的集合。列表來自於 Lisp,後者是一種主要圍繞 “LISt 處理” 的語言,它通過 car 操作獲得列表的頭部,通過 cdr 操作獲得列表尾部(名稱淵源與歷史有關;第一個可以解釋它的人有獎勵)。
從很多方面來講,使用列表要比使用數組簡單,原因有二,首先函數語言過去一直為列表處理提供了良好的支持(而 Scala 繼承了這些支持),其次可以很好地構成和分解列表。例如,函數通常從列表中挑選內容。為此,它將選取列表的第一個元素 — 列表頭部 — 來對該元素執行處理,然後再遞歸式地將列表的其余部分傳遞給自身。這樣可以極大減少處理代碼內部具有相同共享狀態的可能性,並且,假如每個步驟只需處理一個元素,極有可能使代碼分布到多個線程(如果處理是比較好的)。
構成和分解列表非常簡單,如清單 8 所示:
清單 8. 使用列表
class ListTest
{
import org.junit._, Assert._
@Test def simpleList =
{
val myFirstList = List("Ted", "Amanda", "Luke")
assertEquals(myFirstList.isEmpty, false)
assertEquals(myFirstList.head, "Ted")
assertEquals(myFirstList.tail, List("Amanda", "Luke")
assertEquals(myFirstList.last, "Luke")
}
}
注意,構建列表與構建數組十分相似;都類似於構建一個普通對象,不同之處是這裡不需要 “new”(這是 “case 類” 的功能,我們將在未來的文章中介紹到)。請進一步注意 tail 方法調用的結果 — 結果並不是列表的最後一個元素(通過 last 提供),而是除第一個元素以外的其余列表元素。
當然,列表的強大力量部分來自於遞歸處理列表元素的能力,這表示可以從列表提取頭部,直到列表為空,然後累積結果:
清單 9. 遞歸處理
@Test def recurseList =
{
val myVIPList = List("Ted", "Amanda", "Luke", "Don", "Martin")
def count(VIPs : List[String]) : Int =
{
if (VIPs.isEmpty)
0
else
count(VIPs.tail) + 1
}
assertEquals(count(myVIPList), myVIPList.length)
}
注意,如果不考慮返回類型 count,Scala 編譯器或解釋器將會出現點麻煩 — 因為這是一個尾遞歸(tail-recursive)調用,旨在減少在大量遞歸操作中創建的棧幀的數量,因此需要指定它的返回類型。即使是這樣,也可以輕松地使用 List 的 “length” 成員獲取列表項的數量,但關鍵是如何解釋列表處理強大的功能。清單 9 中的整個方法完全是線程安全的,因為列表處理中使用的整個中間狀態保存在參數的堆棧上。因此,根據定義,它不能被多個線程訪問。函數性方法的一個優點就是它實際上與程序功能截然不同,並且仍然創建共享的狀態。
列表 API
列表具有另外一些有趣的特性,例如構建列表的替代方法,使用 :: 方法(是的,這是一種方法。只不過名稱比較有趣)。因此,不必使用 “List” 構造函數語法構建列表,而是將它們 “拼接” 在一起(在調用 :: 方法時),如清單 10 所示:
清單 10. 是 :: == C++ 嗎?
@Test def recurseConsedList =
{
val myVIPList = "Ted" :: "Amanda" :: "Luke" :: "Don" :: "Martin" :: Nil
def count(VIPs : List[String]) : Int =
{
if (VIPs.isEmpty)
0
else
count(VIPs.tail) + 1
}
assertEquals(count(myVIPList), myVIPList.length)
}
在使用 :: 方法時要小心 — 它引入了一些很有趣的規則。它的語法在函數語言中非常常見,因此 Scala 的創建者選擇支持這種語法,但是要正確、普遍地使用這種語法,必須使用一種比較古怪的規則:任何以冒號結束的 “名稱古怪的方法” 都是右關聯(right-associative)的,這表示整個表達式從它的最右邊的 Nil 開始,它正好是一個 List。因此,可以將 :: 認定為一個全局的 :: 方法,與 String 的一個成員方法(本例中使用)相對;這又表示您可以對所有內容構建列表。在使用 :: 時,最右邊的元素必須是一個列表,否則將得到一個錯誤消息。
在 Scala 中,列表的一種最強大的用法是與模式匹配結合。由於列表不僅可以匹配類型和值,它還可以同時綁定變量。例如,我可以簡化清單 10 的列表代碼,方法是使用模式匹配區別一個至少具有一個元素的列表和一個空列表:
清單 11. 結合使用模式匹配和列表
@Test def recurseWithPM =
{
val myVIPList = "Ted" :: "Amanda" :: "Luke" :: "Don" :: "Martin" :: Nil
def count(VIPs : List[String]) : Int =
{
VIPs match
{
case h :: t => count(t) + 1
case Nil => 0
}
}
assertEquals(count(myVIPList), myVIPList.length)
}
在第一個 case 表達式中,將提取列表頭部並綁定到變量 h,而其余部分(尾部)則綁定到 t;在本例中,沒有對 h 執行任何操作(實際上,更好的方法是指明這個頭部永遠不會被使用,方法是使用一個通配符 _ 代替 h,這表明它是永遠不會使用到的變量的占位符)。但是 t 被遞歸地傳遞給 count,和前面的示例一樣。還要注意,Scala 中的每一個表達式將隱式返回一個值;在本例中,模式匹配表達式的結果是遞歸調用 count + 1,當達到列表結尾時,結果為 0。
考慮到相同的代碼量,使用模式匹配的價值體現在哪裡?實際上,對於比較簡單的代碼,模式匹配的價值不很明顯。但是對於稍微復雜的代碼,例如擴展示例以匹配特定值,那麼模式匹配非常有幫助。
清單 12. 模式匹配
@Test def recurseWithPMAndSayHi =
{
val myVIPList = "Ted" :: "Amanda" :: "Luke" :: "Don" :: "Martin" :: Nil
var foundAmanda = false
def count(VIPs : List[String]) : Int =
{
VIPs match
{
case "Amanda" :: t =>
System.out.println("Hey, Amanda!"); foundAmanda = true; count(t) + 1
case h :: t =>
count(t) + 1
case Nil =>
0
}
}
assertEquals(count(myVIPList), myVIPList.length)
assertTrue(foundAmanda)
}
示例很快會變得非常復雜,特別是正則表達式或 XML 節點,開始大量使用模式匹配方法。模式匹配的使用同樣不局限於列表;我們沒有理由不把它擴展到前面的數組示例中。事實上,以下是前面的 recurseWithPMAndSayHi 測試的數組示例:
清單 13. 將模式匹配擴展到數組
@Test def recurseWithPMAndSayHi =
{
val myVIPList = Array("Ted", "Amanda", "Luke", "Don", "Martin")
var foundAmanda = false
myVIPList.foreach((s) =>
s match
{
case "Amanda" =>
System.out.println("Hey, Amanda!")
foundAmanda = true
case _ =>
; // Do nothing
}
)
assertTrue(foundAmanda)
}
如果希望進行實踐,那麼嘗試構建清單 13 的遞歸版本,但這不用在 recurseWithPMAndSayHi 范圍內聲明一個可修改的 var。提示:需要使用多個模式匹配代碼塊(本文的 代碼下載 中包含了一個解決方案 — 但是建議您在查看之前首先自己進行嘗試)。
結束語
Scala 是豐富的集合的集合(雙關語),這源於它的函數歷史和特性集;元組提供了一種簡單的方法,可以很容易地收集松散綁定的值集合;Option[T] 可以使用簡單的方式表示和 “no” 值相對的 “some” 值;數組可以通過增強的特性訪問傳統的 Java 式的數組語義;而列表是函數語言的主要集合,等等。
然而,需要特別注意其中一些特性,特別是元組:學會使用元組很容易,並且會因為為了直接使用元組而忘記傳統的基本對象建模。如果某個特殊元組 — 例如,名稱、年齡、薪資和已知的編程語言列表 — 經常出現在代碼庫中,那麼將它建模為正常的類類型和對象。
Scala 的優點是它兼具函數性和 面向對象特性,因此,您可以在享受 Scala 的函數類型的同時,繼續像以前一樣關注類設計。
本文配套源碼