類型 Either

2018-02-24 16:00 更新

上一章介紹了 Try,它用函數(shù)式風(fēng)格來(lái)處理程序錯(cuò)誤。這一章我們介紹一個(gè)和 Try 相似的類型 - Either,學(xué)習(xí)如何去使用它,什么時(shí)候去使用它,以及它有什么缺點(diǎn)。

不過(guò)首先得知道一件事情:在寫(xiě)作這篇文章的時(shí)候,Either 有一些設(shè)計(jì)缺陷,很多人都在爭(zhēng)論到底要不要使用它。既然如此,為什么還要學(xué)習(xí)它呢?因?yàn)?,在理?Try 這個(gè)錯(cuò)綜復(fù)雜的類型之前,不是所有人都會(huì)在代碼中使用 Try 風(fēng)格的異常處理。其次,Try 不能完全替代 Either,它只是 Either 用來(lái)處理異常的一個(gè)特殊用法。Try 和 Either 互相補(bǔ)充,各自側(cè)重于不同的使用場(chǎng)景。

因此,盡管 Either 有缺陷,在某些情況下,它依舊是非常合適的選擇。

Either 語(yǔ)義

Either 也是一個(gè)容器類型,但不同于 Try、Option,它需要兩個(gè)類型參數(shù):Either[A, B] 要么包含一個(gè)類型為 A 的實(shí)例,要么包含一個(gè)類型為 B 的實(shí)例。這和 Tuple2[A, B] 不一樣, Tuple2[A, B] 是兩者都要包含。

Either 只有兩個(gè)子類型: Left、 Right,如果 Either[A, B] 對(duì)象包含的是 A 的實(shí)例,那它就是 Left 實(shí)例,否則就是 Right 實(shí)例。

在語(yǔ)義上,Either 并沒(méi)有指定哪個(gè)子類型代表錯(cuò)誤,哪個(gè)代表成功,畢竟,它是一種通用的類型,適用于可能會(huì)出現(xiàn)兩種結(jié)果的場(chǎng)景。而異常處理只不過(guò)是其一種常見(jiàn)的使用場(chǎng)景而已,不過(guò),按照約定,處理異常時(shí),Left 代表出錯(cuò)的情況,Right 代表成功的情況。

創(chuàng)建 Either

創(chuàng)建 Either 實(shí)例非常容易,Left 和 Right 都是樣例類。要是想實(shí)現(xiàn)一個(gè) “堅(jiān)如磐石” 的互聯(lián)網(wǎng)審查程序,可以直接這么做:

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

調(diào)用 getContent(new URL("http://danielwestheide.com")) 會(huì)得到一個(gè)封裝有scala.io.Source 實(shí)例的 Right,傳入 new URL("https://plus.google.com") 會(huì)得到一個(gè)含有 String 的 Left。

Either 用法

Either 基本的使用方法和 Option、Try 一樣:調(diào)用 isLeft (或 isRight )方法詢問(wèn)一個(gè) Either,判斷它是 Left 值,還是 Right 值??梢允褂媚J狡ヅ?,這是最方便也是最為熟悉的一種方法:

getContent(new URL("http://google.com")) match {
 case Left(msg) => println(msg)
 case Right(source) => source.getLines.foreach(println)
}

立場(chǎng)

你不能,至少不能直接像 Option、Try 那樣把 Either 當(dāng)作一個(gè)集合來(lái)使用,因?yàn)?Either 是 無(wú)偏(unbiased) 的。

Try 偏向 Success:map 、 flatMap 以及其他一些方法都假設(shè) Try 對(duì)象是一個(gè) Success 實(shí)例,如果是 Failure,那這些方法不做任何事情,直接將這個(gè) Failure 返回。

但 Either 不做任何假設(shè),這意味著首先你要選擇一個(gè)立場(chǎng),假設(shè)它是 Left 還是 Right,然后在這個(gè)假設(shè)的前提下拿它去做你想做的事情。調(diào)用 leftright 方法,就能得到 Either 的 LeftProjectionRightProjection實(shí)例,這就是 Either 的 立場(chǎng)(Projection) ,它們是對(duì) Either 的一個(gè)左偏向的或右偏向的封裝。

映射

一旦有了 Projection,就可以調(diào)用 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!)

這個(gè)例子中,無(wú)論 Either[String, Source] 是 Left 還是 Right,它都會(huì)被映射到 Either[String, Iterator[String]] 。如果,它是一個(gè) Right 值,這個(gè)值就會(huì)被 _.getLines() 轉(zhuǎn)換;如果,它是一個(gè) Left 值,就直接返回這個(gè)值,什么都不會(huì)改變。

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)

現(xiàn)在,如果 Either 是個(gè) Left 值,里面的值會(huì)被轉(zhuǎn)換;如果是 Right 值,就維持原樣。兩種情況下,返回類型都是 Either[Iterator[String, Source] 。

請(qǐng)注意, map 方法是定義在 Projection 上的,而不是 Either,但其返回類型是 Either,而不是 Projection。

可以看到,Either 和其他你知道的容器類型之所以不一樣,就是因?yàn)樗臒o(wú)偏性。接下來(lái)你會(huì)發(fā)現(xiàn),在特定情況下,這會(huì)產(chǎn)生更多的麻煩。而且,如果你想在一個(gè) Either 上多次調(diào)用 map 、 flatMap 這樣的方法,你總需要做 Projection,去選擇一個(gè)立場(chǎng)。

Flat Mapping

Projection 也支持 flat mapping,避免了嵌套使用 map 所造成的令人費(fèi)解的類型結(jié)構(gòu)。

假設(shè)我們想計(jì)算兩篇文章的平均行數(shù),下面的代碼可以解決這個(gè) “富有挑戰(zhàn)性” 的問(wèn)題:

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

運(yùn)行上面的代碼,會(huì)得到什么?會(huì)得到一個(gè)類型為 Either[String, Either[String, Int]] 的玩意兒。當(dāng)然,你可以調(diào)用 joinRight 方法來(lái)使得這個(gè)結(jié)果 扁平化(flatten) 。

不過(guò)我們可以直接避免這種嵌套結(jié)構(gòu)的產(chǎn)生,如果在最外層的 RightProjection 上調(diào)用 flatMap 函數(shù),而不是 map ,得到的結(jié)果會(huì)更好看些,因?yàn)槔飳?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)

現(xiàn)在, content 值類型變成了 Either[String, Int] ,處理它相對(duì)來(lái)說(shuō)就很容易了。

for 語(yǔ)句

說(shuō)到 for 語(yǔ)句,想必現(xiàn)在,你應(yīng)該已經(jīng)愛(ài)上它在不同類型上的一致性表現(xiàn)了。在 for 語(yǔ)句中,也能夠使用 Either 的 Projection,但遺憾的是,這樣做需要一些丑陋的變通。

假設(shè)用 for 語(yǔ)句重寫(xiě)上面的例子:

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

這個(gè)代碼還不是太壞,畢竟只需要額外調(diào)用 left 、 right

但是你不覺(jué)得 yield 語(yǔ)句太長(zhǎng)了嗎?現(xiàn)在,我就把它移到值定義塊中:

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

試著去編譯它,然后你會(huì)發(fā)現(xiàn)無(wú)法編譯!如果我們把 for 語(yǔ)法糖去掉,原因可能會(huì)清晰些。展開(kāi)上面的代碼得到:

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

問(wèn)題在于,在 for 語(yǔ)句中追加新的值定義會(huì)在前一個(gè) map 調(diào)用上自動(dòng)引入另一個(gè) map 調(diào)用,前一個(gè) map 調(diào)用返回的是 Either 類型,不是 RightProjection 類型,而 Scala 并沒(méi)有在 Either 上定義 map 函數(shù),因此編譯時(shí)會(huì)出錯(cuò)。

這就是 Either 丑陋的一面。要解決這個(gè)例子中的問(wèn)題,可以不添加新的值定義。但有些情況,就必須得添加,這時(shí)候可以將值封裝成 Either 來(lái)解決這個(gè)問(wèn)題:

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

認(rèn)識(shí)到這些設(shè)計(jì)缺陷是非常重要的,這不會(huì)影響 Either 的可用性,但如果不知道發(fā)生了什么,它會(huì)讓你感到非常頭痛。

其他方法

Projection 還有其他有用的方法:

  1. 可以在 Either 的某個(gè) Projection 上調(diào)用 toOption 方法,將其轉(zhuǎn)換成 Option。

假如,你有一個(gè)類型為 Either[A, B] 的實(shí)例 ee.right.toOption 會(huì)返回一個(gè) Option[B] 。如果 e 是一個(gè) Right 值,那這個(gè) Option[B] 會(huì)是 Some 類型,如果 e 是一個(gè) Left 值,那 Option[B] 就會(huì)是 None 。調(diào)用 e.left.toOption 也會(huì)有相應(yīng)的結(jié)果。

  1. 還可以用 toSeq 方法將 Either 轉(zhuǎn)換為序列。

Fold 函數(shù)

如果想變換一個(gè) Either(不論它是 Left 值還是 right 值),可以使用定義在 Either 上的 fold 方法。這個(gè)方法接受兩個(gè)返回相同類型的變換函數(shù),當(dāng)這個(gè) Either 是 Left 值時(shí),第一個(gè)函數(shù)會(huì)被調(diào)用;否則,第二個(gè)函數(shù)會(huì)被調(diào)用。

為了說(shuō)明這一點(diǎn),我們用 fold 重寫(xiě)之前的一個(gè)例子:

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

這個(gè)示例中,我們把 Either[String, String] 變換成了 Iterator[String] 。當(dāng)然,你也可以在變換函數(shù)里返回一個(gè)新的 Either,或者是只執(zhí)行副作用。fold 是一個(gè)可以用來(lái)替代模式匹配的好方法。

何時(shí)使用 Either

知道了 Either 的用法和應(yīng)該注意的事項(xiàng),我們來(lái)看看一些特殊的用例。

錯(cuò)誤處理

可以用 Either 來(lái)處理異常,就像 Try 一樣。不過(guò) Either 有一個(gè)優(yōu)勢(shì):可以使用更為具體的錯(cuò)誤類型,而 Try 只能用 Throwable 。(這表明 Either 在處理自定義的錯(cuò)誤時(shí)是個(gè)不錯(cuò)的選擇)不過(guò),需要實(shí)現(xiàn)一個(gè)方法,將這個(gè)功能委托給 scala.util.control 包中的 Exception 對(duì)象:

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 提供的方法允許你捕獲某些類型的異常,但編譯期產(chǎn)生的類型總是 Throwable ,因此需要使用 asInstanceOf 方法強(qiáng)制轉(zhuǎn)換。

有了這個(gè)方法,就可以把期望要處理的異常類型,放在 Either 里了:

import java.net.MalformedURLException
def parseURL(url: String): Either[MalformedURLException, URL] =
  handling(classOf[MalformedURLException])(new URL(url))

handling 的第二個(gè)參數(shù) block 中可能還會(huì)有其他產(chǎn)生錯(cuò)誤的情形,而且并不是所有情形都會(huì)拋出異常。這種情況下,沒(méi)必要為了捕獲異常而人為拋出異常,相反,只需定義你自己的錯(cuò)誤類型,最好是樣例類,并在錯(cuò)誤情況發(fā)生時(shí)返回一個(gè)封裝了這個(gè)類型實(shí)例的 Left。

下面是一個(gè)例子:

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)

應(yīng)該避免使用 Either 來(lái)封裝意料之外的異常,使用 Try 來(lái)做這種事情會(huì)更好,至少它沒(méi)有 Either 這樣那樣的缺陷。

處理集合

有些時(shí)候,當(dāng)按順序依次處理一個(gè)集合時(shí),里面的某個(gè)元素產(chǎn)生了意料之外的結(jié)果,但是這時(shí)程序不應(yīng)該直接引發(fā)異常,因?yàn)檫@樣會(huì)使得剩下的元素?zé)o法處理。Either 也非常適用于這種情況。

假設(shè),在我們 “行業(yè)標(biāo)準(zhǔn)般的” Web 審查系統(tǒng)里,使用了某種黑名單:

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 表示黑名單里的網(wǎng)站 URL,外加試圖訪問(wèn)這個(gè)網(wǎng)址的公民集合。

現(xiàn)在我們想處理這個(gè)黑名單,為了標(biāo)識(shí) “有問(wèn)題” 的公民,比如說(shuō)那些試圖訪問(wèn)被屏蔽網(wǎng)站的人。同時(shí),我們想確定可疑的 Web 網(wǎng)站:如果沒(méi)有一個(gè)公民試圖去訪問(wèn)黑名單里的某一個(gè)網(wǎng)站,那么就必須假定目標(biāo)對(duì)象因?yàn)橐恍┪覀儾恢赖脑蚶@過(guò)了篩選器,需要對(duì)此進(jìn)行調(diào)查。

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

al checkedBlacklist: List[Either[URL, Set[Citizen]]] =
  blacklist.map(resource =>
    if (resource.visitors.isEmpty) Left(resource.url)
    else Right(resource.visitors))

我們創(chuàng)建了一個(gè) Either 序列,其中 Left 實(shí)例代表可疑的 URL, Right 是問(wèn)題市民的集合。識(shí)別問(wèn)題公民和可疑網(wǎng)站變得非常簡(jiǎn)單。

val suspiciousResources = checkedBlacklist.flatMap(_.left.toOption)
val problemCitizens = checkedBlacklist.flatMap(_.right.toOption).flatten.toSet

Either 非常適用于這種比異常處理更為普通的使用場(chǎng)景。

總結(jié)

目前為止,你應(yīng)該已經(jīng)學(xué)會(huì)了怎么使用 Either,認(rèn)識(shí)到它的缺陷,以及知道該在什么時(shí)候用它。鑒于 Either 的缺陷,使用不使用它,全都取決于你。其實(shí)在實(shí)踐中,你會(huì)注意到,有了 Try 之后,Either 不會(huì)出現(xiàn)那么多糟糕的使用情形。

不管怎樣,分清楚它帶來(lái)的利與弊總沒(méi)有壞處。

以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)