[Scala] 比 Either[A, B] 再明確一點的 Try[T]

之前有稍微提到過 Scala 2.10 新增了 Try[T] 這個類別,可以用來進行錯誤處理,但沒有講得很細,所以還是照介紹 Option[T]Either[A, B] 的方式再寫一篇好了。

上次講到 Either[A, B]

上次說到如果我們需要回傳錯誤狀態給呼叫函式的人的話,可以使用 Either[A, B] 這個東西,然後如果是錯誤就回傳一個 Left[Exception] 的子類別物件,如果是正常取得結果的話,就回傳一個 Right[B] 的子類別物件回去。

但事實上 Either[A, B] 算是一個比較 Generic 的類別,他不只用來處理錯誤,在任何你想要回傳「A 類別的物件或 B 類別的物件」的時候,都可以使用 Either[A, B] 這個東西。

但也因為如此,所以他的兩個子類別名稱是 Left 和 Right,每當我用這個類別來處理錯誤的時候,最大的困擾就是忘記慣例上 Exception 應該要放左邊,要回傳 Left 型別的物件而不是 Right 型別的物件。

再來看看 Try[T] 這東西

相較之下,我們可以把 Try[T] 看成一個語意比較明確的 Either[A, B],他一樣有兩個子類別,分別就叫 Failure 和 Success,代表了失敗和成功(廢話)。

所以如果我們把 Option[T]Either[A, B] 還有 Try[T] 使用在可能會出錯的函式時,應該返回的值做一個表的話,會長得像下面這樣:

標頭宣告的類別 成功時返回 失敗時返回
Option[T] Some(value:T) None
Either[A, B] Right(value:B) Left(value: A)
Try[T] Success(value:T) Failure(exception: Throwable)

其中值得注意的是,在 Try[T] 裡面,我們只提供了成功時的返回值的型態 T,這是因為 Try 是專門用來處裡 Exception 的,而 Java / Scala 裡的 Exception 一定是繼承自 Throwable 這個類別,所以我們不需要多此一舉去宣告他。:)

但是 Try[T] 不用自己建立子物件

在我們之前的版本中,大部份都是使用一個 try / catch 區塊把可能出處的部份包起來,然後當正常執行時返回 Some[T] 或 Right[B] 物件,而出錯的時候則是返回 None 或 Left[A] 物件。

像我們之前的 Either 版本就是:

class NotInRangeException extends Exception("不在範圍內")

def convertToInt(str: String): Either[Exception, Int] =
{
     try {

         val resultInt = str.toInt // 等同 Integer.parseInt(str)

         // 值域檢查
         if (resultInt < 0 || resultInt > 100) {
             throw new NotInRangeException
         }

         Right(resultInt)

     } catch {
         case e: Exception => Left(e)
     }
}

由於這是一個非常常見的模式,為什麼我們還要每次一直重覆的寫這個 try / catch 區塊呢?

所以 Scala 2.10 的 Try 類別也幫我們解決掉這件事了!他提供了一個 Try.apply() 的函式,裡面就是幫我們做這個 try / catch 區塊,而託 Scala 語法的規則的福,我們上面的程式碼可以直接變成下面這樣:

class NotInRangeException extends Exception("不在範圍內")

def convertToInt(str: String): Try[Int] = Try {
    val resultInt: Int = str.toInt // 等同 Integer.parseInt(str)

    // 值域檢查
    if (resultInt < 0 || resultInt > 100) {
        throw new NotInRangeException
    }

    resultInt
}

你沒看錯,他看起來就像是一個沒有了 catch 區塊的 try 區塊,這是 Scala 的特色之一--他明明就只是個函式,就可以讓你使用時看起來像內建的語法一樣。

同樣的,我們直接在 REPL 裡試試看把各種字串丟進這個函式裡試試看,看他會返回什麼東西。

scala> convertToInt("123")
res2: scala.util.Try[Int] = scala.util.Failure@c552b5

scala> convertToInt("0")
res3: scala.util.Try[Int] = scala.util.Success@3b3833a7

scala> convertToInt("ABCD")
res4: scala.util.Try[Int] = scala.util.Failure@1faa05dd

你可以看到,雖然在上面的函式裡,我們寫的看起來像是返回的是一個整數,但實際上這個函式返回的會是 Success 和 Failure 這兩種類型的物件。

之前也提到過,在 Scala 的哲學裡,沒有任何一種工具或方法是萬能或最好或唯一的解法的,這個可以從 Option[T] / Either[A, B] 都提供了各種不同的操作方法看出一點端倪,而 Try[T] 當然也不例外。

不怎麼安全的 get 函式

同樣的,我們可以透過 get() 這個函式來取得 Try[T] 的結果,如果實際上你取得的 Try[T] 是一個 Success[T] 的物件的話,那就會返回那個 T 的值,例如下面這樣:

scala> convertToInt("15").get
res4: Int = 15

另一方面,如果你的 Try[T] 實際上是一個 Failure 的話,那 get() 函式會直接丟出原本 Failure 裡放的 Exception(這裡要注意的是,None.get 丟的是 NoSuchElement,但 Failure 丟的是原來的 Exception),例如下面的程式碼:

scala> convertToInt("1000").get
NotInRangeException: 不在範圍內
        at $anonfun$convertToInt$1.apply$mcI$sp(<console>:15)
        at $anonfun$convertToInt$1.apply(<console>:10)
        at $anonfun$convertToInt$1.apply(<console>:10)

因為 get() 是如此的不安全(我們用 Try 主要就是為了不要看見 Exception 直接被丟出來啊),所以如果你的定要用的話,記得先用 isSuccess 或 isFailure 這組函式進行判斷,看你取回的 Try[T] 到底是啥咪鬼,以免 Exception 直接被丟出來。

scala> convertToInt("1000").isSuccess
res6: Boolean = false

scala> convertToInt("1000").isFailure
res7: Boolean = true

scala> convertToInt("100").isSuccess
res8: Boolean = true

scala> convertToInt("100").isFailure
res9: Boolean = false

稍微安全一點的 getOrElse

有的時候,我們所需要的錯誤處理並不是太複雜,常見的一種就是「失敗的時候直接使用預設值」。

像這種很簡單的錯誤處情況,如果還要自己寫一大堆落落長的程式碼來做判斷和給值的動作的話,實在是很煩人的一件事,所幸 Try[T] 也幫我們把這件事幹掉了,他和 Option[T] 一樣,有一個 getOrElse(default) 的函式,你可以直接使用這個函式來做這樣的動作。

這個函式會去看你的 Try[T] 到底是 Success[T] 或是 Failure,如果是 Success 的話,那就返回 Success 容器裡的值,否則的話就返回你給的 default 值。

舉例來講,如果我們希望 convertToInt 如果失敗就一律當成 0 的話,那我們的程式可以這樣寫:

scala> convertToInt("100").getOrElse(0)
res10: Int = 100

scala> convertToInt("10000").getOrElse(0)
res11: Int = 0

Try[T] 一樣可以用 Pattern Matching

在之前講 Option[T] 和 Either[A, B] 的時候,我們已經看到在這種返回的子類別有好幾種情況下,Pattern Matching 可以方便的幫我們決定程式執行的路徑,當然這樣的技巧也是可以用在 Try[T] 身上的:

import scala.util.Success
import scala.util.Failure

val result = convertToInt(....)

result match {
    case Success(value)     => println("成功,返回的整數是:" + value)
    case Failure(exception) => println("失敗,返回的錯誤是:" + exception)
}

在上面的程式碼中應該是不言自明的,他會視 result 的型態,決定要印出什麼樣的訊息。

在這邊值得一提的是,上述的 match 述敘有幾個特點:

  1. 他不像 Java 裡的 switch 會往下掉,他的一個 case 就是一個 block,所以你不用加上 break。
  2. value 和 exception 會分別是 Int 和 Throwable 型態的物件。在 Scala 裡,放在容器裡的物件大部份可以像這樣直接用 Pattern Matching 取出來。
  3. Pattern Matching 是有返回值的。

上面的第一、二點應該很好理解,但什麼叫做 Pattern Matching 有返回值呢?我們可以用下面的例子來說明:

import scala.util.Success
import scala.util.Failure

val result = convertToInt(....)

// 等同於 val finalInt = result.getOrElse(0)
val finalInt = result match {
    case Success(value)     => value
    case Failure(exception) => 0
}

你可以看到,我們可以把 result match 這個敘述放在等後後面,而這隻程式事實上就等於剛剛的 getOrElse(0),如果是 Success 那就返回原來的值,否則就返回 0。

他是個容器,有 foreach 和 map 也是很合理的

和 Option[T] 一樣,Try[T] 也是個容器,既然他是個容器,那麼有個 foreach() 和 map() 函式也很合理的,而他這邊的 foreach() 還有 map() 的邏輯和 Option[T] 是一樣的:只有在成功的時候才會執行。

舉例來講,foreach() 裡的程式碼只有在他真的是 Success[T] 的時候才會被執行:

scala> convertToInt("100").foreach { finalInt => println("只會在成功時印:" + finalInt) }
只會在成功時印100

scala> convertToInt("200").foreach { finalInt => println("只會在成功時印:" + finalInt) }

scala>

至於 map 的話,則是如果他是 Success 那就套用上去,如果是 Failure 的話,那就維持原本的 Failure,例如我們要把取回的整數 double 的話,可以這樣寫:

scala> convertToInt("100").map(_ * 2)
res19: scala.util.Try[Int] = scala.util.Success@12135d34

scala> res19.get    // 裡面的值被加倍了
res20: Int = 200

scala> convertToInt("200").map(_ * 2)       // 如果是 Failure 則維持不變
res21: scala.util.Try[Int] = scala.util.Failure@50f72bc9

有 foreach() 就可以使用 for 迴圈

由於 Scala 裡的 for 迴圈,實際上就是被翻譯成 foreach() 這個函式,所以既然 Try 有 foreach,那我們就可以使用 Scala 的 for 迴圈。

他的邏輯和 foreach() 是一樣的--只有在 Try[T] 是 Success[T] 的時候才會執行:

val result = convertToInt("100")

for (finalInt <- result) {
    println("只有成功時才印:" + finalInt)
}

用 filter / filterNot 來加上其他的限制條件

Try[T] 和其他的 Scala 容器一樣,也提供了 filter 和 filterNot 函式,這兩個函式的用途是差不多的--替你拿到的 Try[T] 再做更嚴格的限制。

舉例來講,如果今天我們的 convertToInt() 是別人給的函式庫,我們沒有原始碼不能改,可是要希望再把可轉換的數字範圍做進一步的限縮,那要怎麼辦呢?

很簡單--用 filter 和 filterNot 就好啦!顧名思義,filter 就是把東西濾出來,所以假設我們今天希望把範圍限在 20 到 30 之間的話,那我們就用 filter 來把他濾出來就好了!

scala> convertToInt("25").filter(x => x >= 20 && x <= 30)
res28: scala.util.Try[Int] = scala.util.Success@57af135a

scala> convertToInt("50").filter(x => x >= 20 && x <= 30)
res29: scala.util.Try[Int] = scala.util.Failure@2da89e20

你會看到,本來 convertToInt("50") 應該會是 Success 的,但因為他不符合我們的 filter 的條件,所以他就變成 Failure 啦!

至於 filterNot,就只是條件相反而已,濾出的是不符合條件的東西,舉例來講上面的程式碼也可以寫成下面這樣:

scala> convertToInt("25").filterNot(x => !(x >= 20 && x <= 30))
res28: scala.util.Try[Int] = scala.util.Success@57af135a

scala> convertToInt("50").filterNot(x => !(x >= 20 && x <= 30))
res29: scala.util.Try[Int] = scala.util.Failure@2da89e20

兩種寫法的執行結果是相同的,不同的只是對於問題的想法和描述方式而已。

使用 Exception Handler 來處理錯誤

最後,Try[T] 除了提供上面的方式之外,也提供了 rescuerecover 這兩個函式,讓你可以用 Exception Handler 的方式,來處理錯誤。

至於他的 Exception Handler 的寫法很簡單,就是個 Partial Function,什麼是 Partial Function 我們可以暫時不去理他,只要知道他可以用 case 這個關鍵字組出來就好。

還記得我們的 convertToInt() 可能會丟出兩種 Exception 嗎?一個是我們自己訂的 NotInRangeException,另一個則是 Java 內建的 NumberFormatException。

如果今天我們希望在發生 NotInRange 的時候返回 0,但發生 NumberFormatException 維持 Failure,那我們可以這樣寫:

scala> val handler: PartialFunction[Throwable, Int] = {
     |     case e: NotInRangeException => 0
     | }
handler: PartialFunction[Throwable,Int] = <function1>

scala> convertToInt("100").recover(handler).foreach(println _)
100

scala> convertToInt("200").recover(handler).foreach(println _)
0

scala> convertToInt("ABCD").recover(handler)
res43: scala.util.Try[Int] = scala.util.Failure@67d757fb

你可以看到,使用 recover() 配上 Exception Handler 處理過後的 Try,一樣是會返回 Success[T] 或是 Failure 的,這是和 Java 還有 Scala 原本的 try / catch 區塊比較不同的地方。

由於他返回的還是一個 Try[T] 類別的物件,所以接下來我們一樣可以用 foreach / map / filter / filterNot 這些東西來針對他進行進一步的處理,甚至是再用 rescue 和 recover 再加掛其他的 Exception Handler 上去。

換句話說,也就是我們可以用「組合」、「各個擊破」的方式,來針對 Exception 進行處理,而不需要一股腦地把所有處理錯誤的東西,全都塞到 catch 區塊中。

至於 rescue() 也是相同的,只是他在 Exception Handler 的部份,返回的是 Try[T] 而不是 T,所以你可以在 Exception Handler 再用 Try 區塊包一些可能會丟 Exception 的程式碼進去。

結論

Try[T] 是 Scala 2.10 新引進的一個類別,他可以用類似容器操作的思維,去進行 Exception 的處理,而你唯一要做的就是把可能出錯的程式區塊用 Try {} 來包起來。

我自己是覺得這是個滿方便的功能的,而且比起用 Either[A, B] 來操作錯誤處理來說,更加地直覺一點,就希望 Scala 2.10 正式版快點出來囉~

回響