Scala新手指南中文版 -第七篇 The Either type(Either类型)

译者注:原文出处http://danielwestheide.com/blog/2013/01/02/the-neophytes-guide-to-scala-part-7-the-either-type.html,翻译:Thomas

 

前一篇中,我们讨论了用Try来进行函数式的错误处理,我也提到了另一个方案的存在,那就是我们这一章要讲到的Either类型。在这里你会学到如何使用它,何时使用它以及你应该避免的陷阱。

知道我写这篇文章时,Either还是存在一些设计上的缺陷应该引起你的注意,既然如此你也需要问了,它是否还能用呢,我还需要来弄懂这个Either吗?

其中一个理由就是,不是所有人的代码里都是用Try来处理异常的(毕竟Try是直到Scala2.10之后才有的),所以就算为了能读懂别人的代码,也应该知道一下这类型的复杂性。

进一步来讲,Try并不能完全替代Either,而只是在异常处理时才是。实际上,Try和Either是互补的,各自有不同的使用场景。虽然Either有一些缺陷,但它仍然非常适用某些特定场景。

定义

和Option和Try一样,Either也是一种容器。不一样的是,它包含两个属性而不是一个:一个Either[A,B]可以包含类型A的实例或类型B的实例。这和Tuple2[A,B]又不一样,Tuple2同时包含A和B两个类型。

Either仅有两个子类型,Left和Right。如果一个Either[A,B]对象保存的时A实例,那么这个Either就是Left,反之就是Right类型。

语义上来说,并没有指定它的子类型必须分别表示成功或失败。事实上,Either是用来实现结果可能是两种类型中的一种的通用用途。错误处理也是这种用途的场景,并且在习惯上,Left代表错误,Right保存成功的值。

创建一个Either

创建一个Either实例是很容易的。Left和Right都是case class。所以如果我们实现一个牛叉的过滤互联网垃圾信息的功能,可以这样做:

 

import scala.io.Source
import java.net.URL
def getContent(url: URL): Either[String, Source] =
  if (url.getHost.contains("baidu"))   //垃圾
    Left("珍爱生命,远离垃圾网站!")
  else
    Right(Source.fromURL(url))

 

看,我们调用getContent(new URL("http://danielwestheide.com"))后,得到包含在Right里的scala.io.Source。如果我们传入new URL("https://www.baidu.com")时, 将会返回一个包含在Left中的字符串。

使用Either

像Option和Try一样,Either也有一些最基础的用法:你可以询问一个Either实例它isLeft或isRight(译者注:就像问一个Option是否isDefined,问Try是否isSuccess一样)。模式匹配在这里也适用,这是这种类型的最常见和最便利的用法:

 

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

 

你无法像Option和Try一样将Either当做一个集合来使用,因为Either是一种无偏设计。

Try不是无偏设计,它是偏向“Success”的:它提供的map,flatMap和其它一些方法都是基于Try为Success的假设,如果一个Try不是Success,这些方法实际上啥也不干,仅仅返回Failure。

Either是无偏设计意味着你首先需要决定假定它是Left或是Right。调用Either的left或right,你分别得到LeftProjection和RightProjection,它们分别封装了Either的左偏或右偏的状态。

Mapping

一旦你获得了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://baidu.com")).right.map(_.getLines)
// moreContent is a Left, as already returned by getContent

 

不管本例中的 Either[String, Source]是Left还是Right,它都会被Map到一个Either[String, Iterator[String]]。如果它是一个Right, 其所包含的值将被map。如果它是个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://baidu.com")).left.map(Iterator(_))
// moreContent is a Left containing the msg returned by getContent in an Iterator
 现在如果Either是一个Left,它所包含的值将被map转化,如果是Right,它将被原封不动的返回,不管哪种情况,返回类型都是 Either[Iterator[String], Source]。

 

请注意,map方法是定义在Projection类型里的,而不是在Either类型里,不过它返回Either类型,而不是Projection,这种情况让Either和你所知道的其它容器类型的行为不一致,Either不得不这样实现,因为它是无偏设计,不过在某些场景下,这也会带来一些让人不快的问题,等下你就会看到。同时这也意味着如果你想要串联多个map,flatMap等的调用,在各调用之间你总是不得不决定要用哪个Projection。

Flat mapping

Projections也支持flatMap,它可以避免因嵌套调用map而导致的嵌套的Either结构。

下面的例子可能完全没有啥实用价值,仅仅为了能说明问题而已。假设我们想要计算我的两篇文章的平均行数。下面就是一段代码:

 

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的类型会是Either[String, Either[String, Int]],是两个Right的嵌套,我们可以调用joinRight来解开嵌套(joinLeft方法用来解开多个Left的嵌套)。

无论如何,我么应该避免产生这种嵌套类型。这就需要用到flatMap了:

 

val content = getContent(part5).right.flatMap(a =>
  getContent(part6).right.map(b =>
    (a.getLines().size + b.getLines().size) / 2))

 

现在content的类型变成Either[String, Int]了, 后续使用content时会变得方便得多,比如可以直接用于模式匹配。 

For语句

到目前为止,你可能已经喜欢上了for语句了,它以一种一致的方式来处理一些不同的数据类型。你其实也可以把for用在Either的Projection上,不过遗憾的是,这种用法不完美,你必须借助一些难看的变通。

我们用for语句来重写上面的flatMap的例子:

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

看起来还行,你应该注意到for里的generators调用了right。

现在,我们试着来重构这个for例子,因为yield表达式看上去略显啰嗦,我们想把部分表达式放到for语句里面的常量定义去:

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语句实际转换的代码,你就会明白为什么它无法被编译。这个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。Either里没有map方法,所以编译器会报错。

这是Either“邪恶”的一面,在我们的例子里,常量定义其实不是必须的。如果它们是必须的,你仍然可以有解决办法,就是把常量定义替换成generator,类似下面的例子:

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类型还有其它些有用的方法:

你可以通过调用Either实例的一个Projection的toOption方法将Either转化成Option。例如,存在一个Either[A, B], 那么 e.right.toOption返回Option[B],如果这个Either[A, B]是一个Right,那么Option[B]将会是一个Some;如果Option是Left,Option就会是 None. 反之亦然。如果你想要将Either转化成一个包含0到1个元素的序列类型,用toSeq来做。

摺叠

如果无论Either是Left还是Right,你都想要转化它,你可以调用它的fold方法,这方法需要传入两个转换函数,这两个函数必须返回同样类型,如果Either是Left,第一个函数会被用来做转换,否则用第二个函数。下面的例子组合了前面例子中对LeftProjection和RightProjection做map:

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, Source]转化成Iterator[String]。你也可以用来返回一个新的Either或执行一段副作用代码并返回Unit类型。类似这样的用法,让fold成为模式匹配的一个好替代。

适用Either的场景

现在你已经知道如何使用Either以及应该注意的地方,现在我们来看看一些特定的使用场景。

错误处理

你可以像类似Try一样用Either来做错误处理。Either比Try多一个优点:你可以在编译时指定更精准的错误类型,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的catching方法让你可以仅仅捕捉指定类型的异常,编译时的异常类型总是Throwable.

有了上面这个方法,你就可以在Either中传递特定的异常类型:

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

你还会遇到另一种已知的错误条件,这些错误不总是类似上面的代码一样抛出异常来表明错误。在这样场景下,你也没有必要抛出自己的异常,相反,用case class来定义自己的错误类型,当错误发生时将错误包含在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就很适合拿来处理集合。我们假设需要用黑名单实现一个过滤系统:

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和试图访问的人。

现在我们需要来处理这个list,标示出那些想要访问被阻止网站的人员。同时我们想要识别出没有任何人访问的黑名单网站,这些网站需要进一步检查是否阻止没有生效:

下面是处理的代码:

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的使用场景并不是那么的多。尽管如此,你还是应该了解这个类型,在某些情形下它会是你最好的工具,而且在2.10之前的代码也用它来处理错误。

作者:Daniel Westheide,2013/1/2

你可能感兴趣的:(scala)