1. 程式人生 > >詳解Scala的集合與高階函式搭配

詳解Scala的集合與高階函式搭配

前言


Java 裡的 Null Pointer Exception
寫過一陣子的Java後, 應該會對NullPointerException (NPE)這種東西很熟悉,基本上會碰到這種異常,就是你有一個變數是 null,但你卻呼叫了它的方法,或是取某個的值。
舉例而言,下面的 Java 程式碼就會丟擲NPE異常:


例1:
String s1 = null;
System.out.println("length:" + s1.length());
當然,一般來說,我們很少會寫出這麼明顯的錯誤程式碼。
但另一方,在 Java 的使用習慣說,我們常常以「返回 null」這件事,來代表一個函式的返回值是不是有意義。


例2:
//就是在 Java 裡 HashMap 的 get() 方法,如果找不到對應的 key 值,就會反回 null:
HashMap<String, String> myMap = new HashMap<String, String>();
myMap.put("key1", "value1");
String value1 = myMap.get("key1");  // 返回 "value1"
String value2 = myMap.get("key2");  // 返回 null
 
System.out.println(value1.length()); // 沒問題,答案是 6
System.out.println(value2.length()); // 拋 NullPointerException


在上面的例子中,myMap 裡沒沒有對應的key值,那麼get()會傳回null。
如果你像上面一樣沒有做檢查,那很可能就會丟擲 NullPointerException,所以我們要像下面一樣,先判斷得到的是不是 null 才可以呼叫算字串長度的方法。


例3:
HashMap<String, String> myMap = new HashMap<String, String>();
 
myMap.put("key1", "value1");
 
String value1 = myMap.get("key1");  // 返回 "value1"
String value2 = myMap.get("key2");  // 返回 null
 
if (value1 != null) {
    System.out.println(value1.length()); // 沒問題,答案是 6
}
 
if (value2 != null) {
    System.out.println(value2.length()); // 沒問題,如果 value2 是 null,不會被執行到
}
那我們要怎麼知道一個 Java 裡某個函式會不會返回null 呢?


答案是你只能依靠 JavaDoc 上的說明、去檢視那個函式的原始碼來看,再不然就是靠黑盒測試(如果你手上根本沒有原始碼),又或者直接等他哪天爆掉再來處理。


Scala 裡的 Option[T] 的概念
相較之下,如果你去翻 Scala 的 Map 這個類別,會發現他的回傳值型別是個 Option[T],但這個有什麼意義呢?


我們還是直接來看程式碼吧:


例4:
// 雖然 Scala 可以不定義變數的型別,不過為了清楚些,我還是
// 把他顯示的定義上了
 
val myMap: Map[String, String] = Map("key1" -> "value")
val value1: Option[String] = myMap.get("key1")
val value2: Option[String] = myMap.get("key2")
 
println(value1) // Some("value1")
println(value2) // None
在上面的程式碼中,myMap 一個是一個 Key 的型別是 String,Value 的型別是 String 的 hash map,但不一樣的是他的 get() 返回的是一個叫 Option[String] 的類別。


但在各個Option 類別代表了什麼意思呢?答案是他在告訴你:我很可能沒辦法回傳一個有意義的東西給你喔!


像上面的例子裡,由於 myMap 裡並沒有 key2 這筆資料,get() 自然要想辦法告訴你他找不到這筆資料,在 Java 裡他只告訴你他會回傳一個 String,而在 Scala 裡他則是用 Option[String] 來告訴你:「我會想辦法回傳一個 String,但也可能沒有 String 給你」。


至於這是怎麼做到的呢?很簡單,Option 有兩個子類別,一個是 Some,一個是 None,當他回傳 Some 的時候,代表這個函式成功地給了你一個 String,而你可以透過 get() 這個函式拿到那個 String,如果他返回的是 None,則代表沒有字串可以給你。


當然,在返回 None,也就是沒有 String 給你的時候,如果你還硬要呼叫 get() 來取得 String 的話,Scala 一樣是會報告一個 Exception 給你的。


至於怎麼判斷是 Some 還是 None 呢?我們可以用 isDefined 這個函式來判別,所以如果要和 Java 版的一樣,列印 value 的字串長度的話,可以這樣寫:


例5:
// 雖然 Scala 可以不定義變數的型別,不過為了清楚些,我還是
// 把他顯示的定義上了
 
val myMap: Map[String, String] = Map("key1" -> "value")
val value1: Option[String] = myMap.get("key1")
val value2: Option[String] = myMap.get("key2")
 
if (value1.isDefined) {
    println("length:" + value1.get.length)
}
 
if (value2.isDefined) {
    println("length:" + value2.get.length)
}
還是改用 Pattern Matching 好了
我知道你要翻桌了,這和我們直接來判斷反回值是不是 null 還不是一樣?!如果沒檢查到一樣會出問題啊,而且這還要多做一個 get 的動作,反而更麻煩咧!


不過就像我之前說過的,Scala 比較像是工具箱,他給你各式的工具,讓你自己選擇適合的來用。


所以既然上面那個工具和原本的 Java 版本比起來沒有太大的優勢,那我們就換下一個 Scala 提供給我們的工具吧!


Scala 提供了 Pattern Matching,也就是類似 Java 的 switch-case 加強版,所以我們上面的程式也可以改寫成像下面這樣:


例6:
// 雖然 Scala 可以不定義變數的型別,不過為了清楚些,我還是
// 把他顯示的定義上了
val myMap: Map[String, String] = Map("key1" -> "value")
val value1: Option[String] = myMap.get("key1")
val value2: Option[String] = myMap.get("key2")
 
value1 match {
    case Some(content) => println("length:" + content.length)
    case None => // 啥都不做
}
 
value2 match {
    case Some(content) => println("length:" + content.length)
    case None => // 啥都不做
}
上面是另一個使用 Option 的方式,你用 Pattern Matching 來檢查 value1 和 value2 是不是 Some,如果是的話就把 Some 裡面的值抽成一個叫 content 的變數,然後再來看你要做啥。


在大多數的情況下,比起上面的方法,我會更喜歡這個做法,因為我覺得 Pattern Matching 在視覺上比 if 來得更容易理解整個程式的流程。


但話說回來,其實這還是在測試返回值是不是 None,所以充其量只能算是 if / else 的整齊版而已




Option[T] 是個容器,所以可以用 for 迴圈
之前有稍微提到,在 Scala 裡 Option[T] 實際上是一個容器,就像陣列或是 List 一樣,你可以把他看成是一個可能有零到一個元素的 List。


當你的 Option 裡面有東西的時候,這個 List 的長度是一(也就是 Some),而當你的 Option 裡沒有東西的時候,他的長度是零(也就是 None)。


這就造成了一個很有趣的現象--如果我們把他當成一般的 List 來用,並且用一個 for 迴圈來走訪這個 Option 的時候,如果 Option 是 None,那這個 for 迴圈裡的程式程式碼自然不會執行,
於是我們就達到了「不用檢查 Option 是否為 None」這件事。


於是下面的程式程式碼可以就達成和我們上面用 if 以及 Pattern Matching 的程式程式碼相同的效果:


例7:
// 雖然 Scala 可以不定義變數的型別,不過為了清楚些,我還是
// 把他顯示的定義上了
 
val myMap: Map[String, String] = Map("key1" -> "value")
val value1: Option[String] = myMap.get("key1")
val value2: Option[String] = myMap.get("key2")
 
for (content <- value1) {
    println("length:" + content.length)
}
 
for (content <- value2) {
    println("length:" + content.length)
}
我們可以換個想法解決問題


話說上面的幾個程式,我們都是從「怎麼做」的角度來看,一步步的告訴計算機,如果當下的情況符合某些條件,就去做某些事情。


但之前也說過,Scala 提供了不同的工具來達成相同的功能,這次我們就來換個角度來解決問題--我們不再問「怎麼做」,而是問「我們要什麼」。


我們要的結果很簡單,就是在取出的 value 有東西的時候,印出「length: XX」這樣的字樣,而 XX 這個數字是從容器中的字串算出來的。


在 Functional Programming 中有一個核心的概念之一是「轉換」,所以大部份支援 Functional Programming 的程式語言,都支援一種叫 map()
的動作,這個動作是可以幫你把某個容器的內容,套上一些動作之後,變成另一個新的容器。


舉例而言,在 Scala 裡面,如果有們有一個 List[String],我們希望把這個 List 裡的字串,全都加上" World" 這個字串的話,可以像下面這樣做:


例8:
scala> val xs = List("Hello", "Goodbye", "Oh My")
xs: List[String] = List(Hello, Goodbye, Oh My)
scala> xs.map(_ + " World!")
res0: List[String] = List(Hello World!, Goodbye World!, Oh My World!)
你可以看到,我們可以用 map() 來替 List 內的每個元素做轉換,產生新的東西。


所以我們現在可以開始思考,在我們要達成的 length: XX 中,是怎麼轉換的:


先算出 Option 容器內字串的長度
然後在長度前面加上 "length:" 字樣
最後把容器走訪一次,印出容器內的東西
有了上面的想法,我們就可以寫出像下面的程式:


例9:
// 雖然 Scala 可以不定義變數的型別,不過為了清楚些,我還是
// 把他顯示的定義上了
 
val myMap: Map[String, String] = Map("key1" -> "value")
val value1: Option[String] = myMap.get("key1")
val value2: Option[String] = myMap.get("key2")
 
// map 兩次,一次算字數,一次加上訊息
value1.map(_.length).map("length:" + _).foreach(println _)
 
// 把算字數和加訊息全部放在一起
value2.map("length:" + _.length).foreach(pritlnt _)
透過這樣「轉換」的方法,我們一樣可以達成想要的效果,而且同樣不用去做「是否為 None」的判斷。


再稍微強大一點的 for 迴圈組合
上面的都是隻有單一一個 Option[T] 操作的場合,不過有的時候你會需要「當兩個值都是有意義的時候才去做某些事情」的狀況,這個時候 Scala 的 for 迴圈配上 Option[T] 就非常好用。


同樣直接看程式程式碼:


例10:
val option1: Option[String] = Some("AA")
val option2: Option[String] = Some("BB");
 
for (value1 <- option1; value2 <- option2) {
    println("Value1:" + value1)
    println("Value2:" + value2)
}
在上面的程式程式碼中,只有當 option1 和 option2 都有值的時候,才會印出來。如果其中有任何一個是 None,那 for 迴圈裡的程式程式碼就不會被執行。


當然,這樣的使用結構不只限於兩個 Option 的時候,如果你有更多個 Option 變數,也只要把他們放到 for 迴圈裡去,就可以讓 for 迴圈只有在所有 Option 都有值的時候才能執行。


但我其實想要預設值耶……
有的時候,我們會希望當函式沒辦法返回正確的結果時,可以有個預設值來做事,而不是什麼都不錯。


就算是這樣也沒問題!


因為 Option[T] 除了 get() 之外,也提供了另一個叫 getOrElse() 的函式,這個函式正如其名--如果 Option 裡有東西就拿出來,不然就給個預設值。


舉例來講,如果我用 Option[Int] 存兩個可有可無的整數,當 Option[Int] 裡沒東西的時候,我要當做 0 的話,那我可以這樣寫:


例11:
val option1: Option[Int] = Some(123)
val option2: Option[Int] = None
 
val value1 = option1.getOrElse(0) // 這個 value1 = 123
val value2 = option2.getOrElse(0) // 這個 value2 = 0
所以 Option[T] 萬無一失嗎?
當然不是!由於 Scala 要和 Java 相容,所以還是讓 null 這個東西繼續存在,所以你一樣可以產生 NullPointerException,而且如果你不注意,對一個空的 Option 做 get,Scala 一樣會爆給你看。


例12:
val option1: Option[Int] = null
val option2: Option[Int] = None
 
option1.foreach(println _) // 爆掉,因為你的 option1 本來就是 null 啊
option2.get()              // 爆掉,對一個 None 做 get 是一定會炸的
我自己是覺得 Option[T] 比較像是一種保險裝置,而且這個保險需要一些時間來學習,也需要在有正確使用方式(例如在大部份的情況下,你都不應該用 Option.get() 這個東西),才會顯出他的好處來。


只是當習慣了之後,就會發現 Option[T] 真的可以替你避掉很多錯誤,至少當你一看到某個 Scala API 的回傳值的型態是 Option[T] 的時候,你會很清楚的知道自己要小心。