译者注:原文出处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的嵌套,我们可以调用j
oinRight来解开嵌套(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