前言
話說昨天寫了那篇 Scala 2.10 新提供的錯誤處理方式,然後噗浪上的 COLDTURNIP 兄提了之後,才發現自己沒提到 Try 其實是個類似 Scala 原有的 Option 和 Either 這兩個類別的 Monad。
但想補上去的時候,才發現還真不知道要怎麼解釋到底 Monad 是什麼,然後翻了一下自己過去的文章,好像也沒仔細提過 Option[T] 這個最簡單的東西,而 Google 出來的大部份資料又都是英文的,就想說乾脆自己來寫一篇簡單的介紹好了。
Java 裡的 Null Pointer Exception
寫過一陣子的 Java 後,應該會對 NullPointerException (NPE) 這個東西很熟悉,基本上會炸出這個錯誤,就是你有一個變數是 null,但你卻對他呼叫了方法,或是取某個欄位的值。
舉例而言,下面的 Java 程式碼就會丟出 NPE 錯誤:
String s1 = null;
System.out.println("length:" + s1.length());
當然,一般來說,我們很少會寫出有這麼明顯的錯誤的程式碼,或者不對變數進行有意義的初始化。
但另一方,在 Java 的使用習慣上,我們常常以「返回 null」這件事,來代表一個函數的返回值是不是具有意義。
一個常被舉出來的例子,就是在 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 才可以做算字串長度的動作。
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 上的說明、去挖那個函式的原始碼來看,再不然就是靠黑箱測試(如果你手上根本沒有廠商給的 Library 原始碼),又或者直接等他哪天爆掉再來處理(大誤)。
Scala 裡的 Option[T] 的概念
相較之下,如果你去翻 Scala 的 Map 這個類別,會發現他的回傳值型態是個 Option[T],但這個是什麼意義呢?
我們還是直接來看程式碼好了:
// 雖然 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 的字串長度的話,可以這樣寫:
// 雖然 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 加強版,所以我們上面的程式也可以改寫成像下面這樣:
// 雖然 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 的程式碼相同的效果:
// 雖然 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" 這個字串的話,可以像下面這樣做:
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:" 字樣
- 最後把容器走訪一次,印出容器內的東西
有了上面的想法,我們就可以寫出像下面的程式:
// 雖然 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] 就非常好用。
同樣直接看程式碼:
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 的話,那我可以這樣寫:
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 一樣會爆給你看。
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] 的時候,你會很清楚的知道自己要小心。:p
回響