上一章介紹了 Try,它用函數式風格來處理程序錯誤。這一章我們介紹一個和 Try 相似的類型 - Either,學習如何去使用它,什么時候去使用它,以及它有什么缺點。
不過首先得知道一件事情:在寫作這篇文章的時候,Either 有一些設計缺陷,很多人都在爭論到底要不要使用它。既然如此,為什么還要學習它呢?因為,在理解 Try 這個錯綜復雜的類型之前,不是所有人都會在代碼中使用 Try 風格的異常處理。其次,Try 不能完全替代 Either,它只是 Either 用來處理異常的一個特殊用法。Try 和 Either 互相補充,各自側重于不同的使用場景。
因此,盡管 Either 有缺陷,在某些情況下,它依舊是非常合適的選擇。
### Either 語義
Either 也是一個容器類型,但不同于 Try、Option,它需要兩個類型參數:`Either[A, B]` 要么包含一個類型為 `A` 的實例,要么包含一個類型為 `B` 的實例。這和 `Tuple2[A, B]` 不一樣, `Tuple2[A, B]` 是兩者都要包含。
Either 只有兩個子類型: Left、 Right,如果 `Either[A, B]` 對象包含的是 `A` 的實例,那它就是 Left 實例,否則就是 Right 實例。
在語義上,Either 并沒有指定哪個子類型代表錯誤,哪個代表成功,畢竟,它是一種通用的類型,適用于可能會出現兩種結果的場景。而異常處理只不過是其一種常見的使用場景而已,不過,按照約定,處理異常時,Left 代表出錯的情況,Right 代表成功的情況。
### 創建 Either
創建 Either 實例非常容易,Left 和 Right 都是樣例類。要是想實現一個 “堅如磐石” 的互聯網審查程序,可以直接這么做:
~~~
import scala.io.Source
import java.net.URL
def getContent(url: URL): Either[String, Source] =
if(url.getHost.contains("google"))
Left("Requested URL is blocked for the good of the people!")
else
Right(Source.fromURL(url))
~~~
調用 `getContent(new URL("http://danielwestheide.com"))` 會得到一個封裝有`scala.io.Source` 實例的 Right,傳入 `new URL("https://plus.google.com")` 會得到一個含有 `String` 的 Left。
### Either 用法
Either 基本的使用方法和 Option、Try 一樣:調用 `isLeft` (或 `isRight` )方法詢問一個 Either,判斷它是 Left 值,還是 Right 值。可以使用模式匹配,這是最方便也是最為熟悉的一種方法:
~~~
getContent(new URL("http://google.com")) match {
case Left(msg) => println(msg)
case Right(source) => source.getLines.foreach(println)
}
~~~
#### 立場
你不能,至少不能直接像 Option、Try 那樣把 Either 當作一個集合來使用,因為 Either 是 **無偏(unbiased)** 的。
Try 偏向 Success:`map` 、 `flatMap` 以及其他一些方法都假設 Try 對象是一個 Success 實例,如果是 Failure,那這些方法不做任何事情,直接將這個 Failure 返回。
但 Either 不做任何假設,這意味著首先你要選擇一個立場,假設它是 Left 還是 Right,然后在這個假設的前提下拿它去做你想做的事情。調用 `left` 或 `right` 方法,就能得到 Either 的 `LeftProjection` 或 `RightProjection`實例,這就是 Either 的 _立場(Projection)_ ,它們是對 Either 的一個左偏向的或右偏向的封裝。
#### 映射
一旦有了 Projection,就可以調用 `map` :
~~~
val content: Either[String, Iterator[String]] =
getContent(new URL("http://danielwestheide.com")).right.map(_.getLines())
// content is a Right containing the lines from the Source returned by getContent
val moreContent: Either[String, Iterator[String]] =
getContent(new URL("http://google.com")).right.map(_.getLines)
// moreContent is a Left, as already returned by getContent
// content: Either[String,Iterator[String]] = Right(non-empty iterator)
// moreContent: Either[String,Iterator[String]] = Left(Requested URL is blocked for the good of the people!)
~~~
這個例子中,無論 `Either[String, Source]` 是 Left 還是 Right,它都會被映射到 `Either[String, Iterator[String]]` 。如果,它是一個 Right 值,這個值就會被 `_.getLines()` 轉換;如果,它是一個 Left 值,就直接返回這個值,什么都不會改變。
LeftProjection也是類似的:
~~~
val content: Either[Iterator[String], Source] =
getContent(new URL("http://danielwestheide.com")).left.map(Iterator(_))
// content is the Right containing a Source, as already returned by getContent
val moreContent: Either[Iterator[String], Source] =
getContent(new URL("http://google.com")).left.map(Iterator(_))
// moreContent is a Left containing the msg returned by getContent in an Iterator
// content: Either[Iterator[String],scala.io.Source] = Right(non-empty iterator)
// moreContent: Either[Iterator[String],scala.io.Source] = Left(non-empty iterator)
~~~
現在,如果 Either 是個 Left 值,里面的值會被轉換;如果是 Right 值,就維持原樣。兩種情況下,返回類型都是 `Either[Iterator[String, Source]` 。
> 請注意, `map` 方法是定義在 Projection 上的,而不是 Either,但其返回類型是 Either,而不是 Projection。
> 可以看到,Either 和其他你知道的容器類型之所以不一樣,就是因為它的無偏性。接下來你會發現,在特定情況下,這會產生更多的麻煩。而且,如果你想在一個 Either 上多次調用 `map` 、 `flatMap` 這樣的方法,你總需要做 Projection,去選擇一個立場。
#### Flat Mapping
Projection 也支持 flat mapping,避免了嵌套使用 map 所造成的令人費解的類型結構。
假設我們想計算兩篇文章的平均行數,下面的代碼可以解決這個 “富有挑戰性” 的問題:
~~~
val part5 = new URL("http://t.co/UR1aalX4")
val part6 = new URL("http://t.co/6wlKwTmu")
val content = getContent(part5).right.map(a =>
getContent(part6).right.map(b =>
(a.getLines().size + b.getLines().size) / 2))
// => content: Product with Serializable with scala.util.Either[String,Product with Serializable with scala.util.Either[String,Int]] = Right(Right(537))
~~~
運行上面的代碼,會得到什么?會得到一個類型為 `Either[String, Either[String, Int]]` 的玩意兒。當然,你可以調用 `joinRight` 方法來使得這個結果 **扁平化(flatten)** 。
不過我們可以直接避免這種嵌套結構的產生,如果在最外層的 RightProjection 上調用 `flatMap` 函數,而不是 `map` ,得到的結果會更好看些,因為里層 Either 的值被解包了:
~~~
val content = getContent(part5).right.flatMap(a =>
getContent(part6).right.map(b =>
(a.getLines().size + b.getLines().size) / 2))
// => content: scala.util.Either[String,Int] = Right(537)
~~~
現在, `content` 值類型變成了 `Either[String, Int]` ,處理它相對來說就很容易了。
#### for 語句
說到 for 語句,想必現在,你應該已經愛上它在不同類型上的一致性表現了。在 for 語句中,也能夠使用 `Either` 的 Projection,但遺憾的是,這樣做需要一些丑陋的變通。
假設用 for 語句重寫上面的例子:
~~~
def averageLineCount(url1: URL, url2: URL): Either[String, Int] =
for {
source1 <- getContent(url1).right
source2 <- getContent(url2).right
} yield (source1.getLines().size + source2.getLines().size) / 2
~~~
這個代碼還不是太壞,畢竟只需要額外調用 `left` 、 `right` 。
但是你不覺得 yield 語句太長了嗎?現在,我就把它移到值定義塊中:
~~~
def averageLineCountWontCompile(url1: URL, url2: URL): Either[String, Int] =
for {
source1 <- getContent(url1).right
source2 <- getContent(url2).right
lines1 = source1.getLines().size
lines2 = source2.getLines().size
} yield (lines1 + lines2) / 2
~~~
試著去編譯它,然后你會發現無法編譯!如果我們把 for 語法糖去掉,原因可能會清晰些。展開上面的代碼得到:
~~~
def averageLineCountDesugaredWontCompile(url1: URL, url2: URL): Either[String, Int] =
getContent(url1).right.flatMap { source1 =>
getContent(url2).right.map { source2 =>
val lines1 = source1.getLines().size
val lines2 = source2.getLines().size
(lines1, lines2)
}.map { case (x, y) => x + y / 2 }
}
~~~
問題在于,在 for 語句中追加新的值定義會在前一個 `map` 調用上自動引入另一個 `map` 調用,前一個 `map` 調用返回的是 Either 類型,不是 RightProjection 類型,而 Scala 并沒有在 Either 上定義 `map` 函數,因此編譯時會出錯。
這就是 Either 丑陋的一面。要解決這個例子中的問題,可以不添加新的值定義。但有些情況,就必須得添加,這時候可以將值封裝成 Either 來解決這個問題:
~~~
def averageLineCount(url1: URL, url2: URL): Either[String, Int] =
for {
source1 <- getContent(url1).right
source2 <- getContent(url2).right
lines1 <- Right(source1.getLines().size).right
lines2 <- Right(source2.getLines().size).right
} yield (lines1 + lines2) / 2
~~~
認識到這些設計缺陷是非常重要的,這不會影響 Either 的可用性,但如果不知道發生了什么,它會讓你感到非常頭痛。
#### 其他方法
Projection 還有其他有用的方法:
1.
可以在 Either 的某個 Projection 上調用 `toOption` 方法,將其轉換成 Option。
假如,你有一個類型為 `Either[A, B]` 的實例 `e` , `e.right.toOption` 會返回一個 `Option[B]` 。如果 `e` 是一個 Right 值,那這個 `Option[B]` 會是 Some 類型,如果 `e` 是一個 Left 值,那 `Option[B]` 就會是 `None` 。調用 `e.left.toOption` 也會有相應的結果。
1. 還可以用 `toSeq` 方法將 Either 轉換為序列。
#### Fold 函數
如果想變換一個 Either(不論它是 Left 值還是 right 值),可以使用定義在 Either 上的 `fold` 方法。這個方法接受兩個返回相同類型的變換函數,當這個 Either 是 Left 值時,第一個函數會被調用;否則,第二個函數會被調用。
為了說明這一點,我們用 `fold` 重寫之前的一個例子:
~~~
val content: Iterator[String] =
getContent(new URL("http://danielwestheide.com")).fold(Iterator(_), _.getLines())
val moreContent: Iterator[String] =
getContent(new URL("http://google.com")).fold(Iterator(_), _.getLines())
~~~
這個示例中,我們把 `Either[String, String]` 變換成了 `Iterator[String]` 。當然,你也可以在變換函數里返回一個新的 Either,或者是只執行副作用。`fold` 是一個可以用來替代模式匹配的好方法。
### 何時使用 Either
知道了 Either 的用法和應該注意的事項,我們來看看一些特殊的用例。
#### 錯誤處理
可以用 Either 來處理異常,就像 Try 一樣。不過 Either 有一個優勢:可以使用更為具體的錯誤類型,而 Try 只能用 `Throwable` 。(這表明 Either 在處理自定義的錯誤時是個不錯的選擇)不過,需要實現一個方法,將這個功能委托給 `scala.util.control` 包中的 `Exception` 對象:
~~~
import scala.util.control.Exception.catching
def handling[Ex <: Throwable, T](exType: Class[Ex])(block: => T): Either[Ex, T] =
catching(exType).either(block).asInstanceOf[Either[Ex, T]]
~~~
這么做的原因是,雖然 `scala.util.Exception` 提供的方法允許你捕獲某些類型的異常,但編譯期產生的類型總是 `Throwable` ,因此需要使用 `asInstanceOf` 方法強制轉換。
有了這個方法,就可以把期望要處理的異常類型,放在 Either 里了:
~~~
import java.net.MalformedURLException
def parseURL(url: String): Either[MalformedURLException, URL] =
handling(classOf[MalformedURLException])(new URL(url))
~~~
`handling` 的第二個參數 `block` 中可能還會有其他產生錯誤的情形,而且并不是所有情形都會拋出異常。這種情況下,沒必要為了捕獲異常而人為拋出異常,相反,只需定義你自己的錯誤類型,最好是樣例類,并在錯誤情況發生時返回一個封裝了這個類型實例的 Left。
下面是一個例子:
~~~
case class Customer(age: Int)
class Cigarettes
case class UnderAgeFailure(age: Int, required: Int)
def buyCigarettes(customer: Customer): Either[UnderAgeFailure, Cigarettes] =
if (customer.age < 16) Left(UnderAgeFailure(customer.age, 16))
else Right(new Cigarettes)
~~~
應該避免使用 Either 來封裝意料之外的異常,使用 Try 來做這種事情會更好,至少它沒有 Either 這樣那樣的缺陷。
#### 處理集合
有些時候,當按順序依次處理一個集合時,里面的某個元素產生了意料之外的結果,但是這時程序不應該直接引發異常,因為這樣會使得剩下的元素無法處理。Either 也非常適用于這種情況。
假設,在我們 “行業標準般的” Web 審查系統里,使用了某種黑名單:
~~~
type Citizen = String
case class BlackListedResource(url: URL, visitors: Set[Citizen])
val blacklist = List(
BlackListedResource(new URL("https://google.com"), Set("John Doe", "Johanna Doe")),
BlackListedResource(new URL("http://yahoo.com"), Set.empty),
BlackListedResource(new URL("https://maps.google.com"), Set("John Doe")),
BlackListedResource(new URL("http://plus.google.com"), Set.empty)
)
~~~
`BlackListedResource` 表示黑名單里的網站 URL,外加試圖訪問這個網址的公民集合。
現在我們想處理這個黑名單,為了標識 “有問題” 的公民,比如說那些試圖訪問被屏蔽網站的人。同時,我們想確定可疑的 Web 網站:如果沒有一個公民試圖去訪問黑名單里的某一個網站,那么就必須假定目標對象因為一些我們不知道的原因繞過了篩選器,需要對此進行調查。
下面的代碼展示了該如何處理黑名單的:
~~~
al checkedBlacklist: List[Either[URL, Set[Citizen]]] =
blacklist.map(resource =>
if (resource.visitors.isEmpty) Left(resource.url)
else Right(resource.visitors))
~~~
我們創建了一個 Either 序列,其中 `Left` 實例代表可疑的 URL, `Right` 是問題市民的集合。識別問題公民和可疑網站變得非常簡單。
~~~
val suspiciousResources = checkedBlacklist.flatMap(_.left.toOption)
val problemCitizens = checkedBlacklist.flatMap(_.right.toOption).flatten.toSet
~~~
`Either` 非常適用于這種比異常處理更為普通的使用場景。
### 總結
目前為止,你應該已經學會了怎么使用 Either,認識到它的缺陷,以及知道該在什么時候用它。鑒于 Either 的缺陷,使用不使用它,全都取決于你。其實在實踐中,你會注意到,有了 Try 之后,Either 不會出現那么多糟糕的使用情形。
不管怎樣,分清楚它帶來的利與弊總沒有壞處。