1. 程式人生 > >Scala Try 與錯誤處理

Scala Try 與錯誤處理

一.概述

當你在嘗試一門新的語言時,可能不會過於關注程式出錯的問題, 但當真的去創造可用的程式碼時,就不能再忽視程式碼中的可能產生的錯誤和異常了。 鑑於各種各樣的原因,人們往往低估了語言對錯誤處理支援程度的重要性。

事實會表明,Scala 能夠很優雅的處理此類問題, 這一部分,我會介紹 Scala 基於 Try 的錯誤處理機制,以及這背後的原因。 我將使用一個在 Scala 2.10 新引入的特性,該特性向 2.9.3 相容, 因此,請確保你的 Scala 版本不低於 2.9.3。

二.異常丟擲與捕獲

2.1 其他語言的錯誤處理機制

在介紹 Scala 錯誤處理的慣用法之前,我們先看看其他語言(如,Java,Ruby)的錯誤處理機制。 和這些語言類似,Scala 也允許你丟擲異常:

case class Customer(age: Int)
class Cigarettes
case class UnderAgeException(message: String) extends Exception(message)
def buyCigarettes(customer: Customer): Cigarettes =
  if (customer.age < 16)
    throw UnderAgeException(s"Customer must be older than 16 but was ${customer.age}")
  else new Cigarettes

被丟擲的異常能夠以類似 Java 中的方式被捕獲,雖然是使用偏函式來指定要處理的異常型別。 此外,Scala 的 try/catch 是表示式(返回一個值),因此下面的程式碼會返回異常的訊息:

val youngCustomer = Customer(15)
try {
  buyCigarettes(youngCustomer)
  "Yo, here are your cancer sticks! Happy smokin'!"
} catch {
    case UnderAgeException(msg) => msg
}

2.2 函式式的錯誤處理

現在,如果程式碼中到處是上面的異常處理程式碼,那它很快就會變得醜陋無比,和函式式程式設計非常不搭。 對於高併發應用來說,這也是一個很差勁的解決方式,比如, 假設需要處理在其他執行緒執行的 actor 所引發的異常,顯然你不能用捕獲異常這種處理方式, 你可能會想到其他解決方案,例如去接收一個表示錯誤情況的訊息。

一般來說,在 Scala 中,好的做法是通過從函式裡返回一個合適的值來通知人們程式出錯了。 別擔心,我們不會回到 C 中那種需要使用按約定進行檢查的錯誤編碼的錯誤處理。 相反,Scala 使用一個特定的型別來表示可能會導致異常的計算,這個型別就是 Try。

Try 的語義

解釋 Try 最好的方式是將它與 Option 作對比。

Option[A] 是一個可能有值也可能沒值的容器, Try[A] 則表示一種計算: 這種計算在成功的情況下,返回型別為 A 的值,在出錯的情況下,返回 Throwable 。 這種可以容納錯誤的容器可以很輕易的在併發執行的程式之間傳遞。

Try 有兩個子型別:

  • Success[A]:代表成功的計算。
  • 封裝了 Throwable 的 Failure[A]:代表出了錯的計算。

如果知道一個計算可能導致錯誤,我們可以簡單的使用 Try[A] 作為函式的返回型別。 這使得出錯的可能性變得很明確,而且強制客戶端以某種方式處理出錯的可能。

假設,需要實現一個簡單的網頁爬取器:使用者能夠輸入想爬取的網頁 URL, 程式就需要去分析 URL 輸入,並從中建立一個 java.net.URL :

import scala.util.Try
import java.net.URL
def parseURL(url: String): Try[URL] = Try(new URL(url))

正如你所看到的,函式返回型別為 Try[URL]: 如果給定的 url 語法正確,這將是 Success[URL], 否則, URL 構造器會引發 MalformedURLException ,從而返回值變成 Failure[URL] 型別。

上例中,我們還用了 Try 伴生物件裡的 apply 工廠方法,這個方法接受一個型別為 A 的 傳名引數, 這意味著, new URL(url) 是在 Tryapply 方法裡執行的。

apply 方法會捕獲任何非致命的異常,返回一個包含相關異常的 Failure 例項。

因此, 會返回一個 Success[URL] ,包含了解析後的網址, 而 parseULR("garbage") 將返回一個含有 MalformedURLExceptionFailure[URL]

三. 使用 Try

3.1 初步使用 Try

使用 Try 與使用 Option 非常相似,在這裡你看不到太多新的東西。

你可以呼叫 isSuccess 方法來檢查一個 Try 是否成功,然後通過 get 方法獲取它的值, 但是,這種方式的使用並不多見,因為你可以用 getOrElse 方法給 Try 提供一個預設值:

val url = parseURL(Console.readLine("URL: ")) getOrElse new URL("http://duckduckgo.com")

如果使用者提供的 URL 格式不正確,我們就使用 DuckDuckGo 的 URL 作為備用。

3.2 鏈式操作

Try 最重要的特徵是,它也支援高階函式,就像 Option 一樣。 在下面的示例中,你將看到,在 Try 上也進行鏈式操作,捕獲可能發生的異常,而且程式碼可讀性不錯。

Mapping 和 Flat Mapping

將一個是 Success[A]Try[A] 對映到 Try[B] 會得到 Success[B] 。 如果它是 Failure[A] ,就會得到 Failure[B] ,而且包含的異常和 Failure[A] 一樣。

parseURL("http://danielwestheide.com").map(_.getProtocol)
// results in Success("http")
parseURL("garbage").map(_.getProtocol)
// results in Failure(java.net.MalformedURLException: no protocol: garbage)

如果連結多個 map 操作,會產生巢狀的 Try 結構,這並不是我們想要的。 考慮下面這個返回輸入流的方法:

import java.io.InputStream
def inputStreamForURL(url: String): Try[Try[Try[InputStream]]] = parseURL(url).map { u =>
 Try(u.openConnection()).map(conn => Try(conn.getInputStream))
}

由於每個傳遞給 map 的匿名函式都返回 Try,因此返回型別就變成了 Try[Try[Try[InputStream]]] 。 這時候, flatMap 就派上用場了。 Try[A] 上的 flatMap 方法接受一個對映函式,這個函式型別是 (A) => Try[B]。 如果我們的 Try[A] 已經是 Failure[A] 了,那麼裡面的異常就直接被封裝成 Failure[B] 返回, 否則, flatMapSuccess[A] 裡面的值解包出來,並通過對映函式將其對映到 Try[B] 。 這意味著,我們可以通過連結任意個 flatMap 呼叫來建立一條操作管道,將值封裝在 Success 裡一層層的傳遞。 現在讓我們用 flatMap 來重寫先前的例子:

def inputStreamForURL(url: String): Try[InputStream] =
 parseURL(url).flatMap { u =>
   Try(u.openConnection()).flatMap(conn => Try(conn.getInputStream))
 }

這樣,我們就得到了一個 Try[InputStream], 它可以是一個 Failure,包含了在 flatMap 過程中可能出現的異常; 也可以是一個 Success,包含了最後的結果。 過濾器和 foreach

過濾器和 foreach

當然,你也可以對 Try 進行過濾,或者呼叫 foreach ,如果你已經學過 Option,對於這兩個方法也不會陌生。

當一個 Try 已經是 Failure 了,或者傳遞給它的謂詞函式返回假值,filter 就返回 Failure (如果是謂詞函式返回假值,那 Failure 裡包含的異常是 NoSuchException ), 否則的話, filter 就返回原本的那個 Success ,什麼都不會變:

def parseHttpURL(url: String) = parseURL(url).filter(_.getProtocol == "http")
parseHttpURL("http://apache.openmirror.de") // results in a Success[URL]
parseHttpURL("ftp://mirror.netcologne.de/apache.org") // results in a Failure[URL]

當一個 TrySuccess 時, foreach 允許你在被包含的元素上執行副作用, 這種情況下,傳遞給 foreach 的函式只會執行一次,畢竟 Try 裡面只有一個元素:

 parseHttpURL("http://danielwestheide.com").foreach(println)

當 Try 是 Failure 時, foreach 不會執行,返回 Unit 型別。

for 語句中的 Try

既然 Try 支援 flatMapmapfilter ,能夠使用 for 語句也是理所當然的事情, 而且這種情況下的程式碼更可讀。 為了證明這一點,我們來實現一個返回給定 URL 的網頁內容的函式:

import scala.io.Source
def getURLContent(url: String): Try[Iterator[String]] =
  for {
   url <- parseURL(url)
   connection <- Try(url.openConnection())
   is <- Try(connection.getInputStream)
   source = Source.fromInputStream(is)
  } yield source.getLines()

這個方法中,有三個可能會出錯的地方,但都被 Try 給涵蓋了。 第一個是我們已經實現的 parseURL 方法, 只有當它是一個 Success[URL] 時,我們才會嘗試開啟連線,從中建立一個新的 InputStream 。 如果這兩步都成功了,我們就 yield 出網頁內容,得到的結果是 Try[Iterator[String]]

當然,你可以使用 Source#fromURL 簡化這個程式碼,並且,這個程式碼最後沒有關閉輸入流, 這都是為了保持例子的簡單性,專注於要講述的主題。

在這個例子中,Source#fromURL可以這樣用:

import scala.io.Source
def getURLContent(url: String): Try[Iterator[String]] =
  for {
    url <- parseURL(url)
    source = Source.fromURL(url)
  } yield source.getLines()

用 is.close() 可以關閉輸入流。

模式匹配

程式碼往往需要知道一個 Try 例項是 Success 還是 Failure,這時候,你應該想到模式匹配, 也幸好, SuccessFailure 都是樣例類。

接著上面的例子,如果網頁內容能順利提取到,我們就展示它,否則,列印一個錯誤資訊:

import scala.util.Success
import scala.util.Failure
getURLContent("http://danielwestheide.com/foobar") match {
  case Success(lines) => lines.foreach(println)
  case Failure(ex) => println(s"Problem rendering URL content: ${ex.getMessage}")
}
從故障中恢復

如果想在失敗的情況下執行某種動作,沒必要去使用 getOrElse, 一個更好的選擇是 recover ,它接受一個偏函式,並返回另一個 Try。 如果 recover 是在 Success 例項上呼叫的,那麼就直接返回這個例項,否則就呼叫偏函式。 如果偏函式為給定的 Failure 定義了處理動作, recover 會返回 Success ,裡面包含偏函式執行得出的結果。

下面是應用了 recover 的程式碼:

import java.net.MalformedURLException
import java.io.FileNotFoundException
val content = getURLContent("garbage") recover {
  case e: FileNotFoundException => Iterator("Requested page does not exist")
  case e: MalformedURLException => Iterator("Please make sure to enter a valid URL")
  case _ => Iterator("An unexpected error has occurred. We are so sorry!")
}

現在,我們可以在返回值 content 上安全的使用 get 方法了,因為它一定是一個 Success。 呼叫 content.get.foreach(println) 會列印 Please make sure to enter a valid URL。

四. 總結

Scala 的錯誤處理和其他正規化的程式語言有很大的不同。 Try 型別可以讓你將可能會出錯的計算封裝在一個容器裡,並優雅的去處理計算得到的值。 並且可以像操作集合和 Option 那樣統一的去操作 Try。

Try 還有其他很多重要的方法,鑑於篇幅限制,這一章並沒有全部列出,比如 orElse 方法, transformrecoverWith 也都值得去看。