天天看點

[Scala基礎]--Either介紹

類型 Either

上一章介紹了 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​

在語義上,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​

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​

​但 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()​

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​

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​

+

假設用 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​

  1. 假如,你有一個類型為 

​Either[A, B]​

  1.  的執行個體 

​e​

  1.  , 

​e.right.toOption​

  1.  會傳回一個 

​Option[B]​

  1.  。 如果 

​e​

  1.  是一個 Right 值,那這個 

​Option[B]​

  1.  會是 Some 類型, 如果 

​e​

  1.  是一個 Left 值,那 

​Option[B]​

  1.  就會是 

​None​

  1.  。 調用 

​e.left.toOption​

  1. 還可以用 

​toSeq​

Fold 函數

如果想變換一個 Either(不論它是 Left 值還是 right 值),可以使用定義在 Either 上的 ​

​fold​

​為了說明這一點,我們用 ​

​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​

下面是一個例子:

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​

現在我們想處理這個黑名單,為了辨別 “有問題” 的公民,比如說那些試圖通路被屏蔽網站的人。 同時,我們想确定可疑的 Web 網站:如果沒有一個公民試圖去通路黑名單裡的某一個網站, 那麼就必須假定目标對象因為一些我們不知道的原因繞過了篩選器,需要對此進行調查。

下面的代碼展示了該如何處理黑名單的:

val 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 不會出現那麼多糟糕的使用情形。