Simple Scala: Option

简介

Java开发者都知道NullPointerException,通常是因为某个方法返回了null,但这并不是开发者所期望的,代码也不好去处理这种异常.

值null通常被滥用来表征一个可能缺失的值.不过,某些语言以一些特殊的方法对待null值,或者允许你安全的使用可能是null的值.比如说,Groovy有安全运算符用于访问属性,这样调用foo?.bar?.baz,不会再foo或bar为null时而引发异常,而是直接返回null值,然而,Groovy中并没有机制强制你使用此运算符,所以如果你忘了使用它,那就完蛋了.

Clojure对待nil基本上就像对待空字符串一样,也可以把它当成空列表或映射表一样去访问,这意味着,nil在调用层级中向上冒泡.很多时候这样是可行的,但有时候会导致异常出现在更高的调用层级中,而那里的代码没有对nil的处理进行考虑.

Scala试图通过摆脱null来解决这个问题,并提供自己的类型用来表示一个值是可选得,这就是Option[A]特质.

Option[A]是一个类型为A的可选值的容器:如果值存在,Option[A]就是一个Some[A],如果不存在,Option[A]就是对象None.

在类型层面上指出一个值是否存在,使用你的代码的开发者或者你自己,就会被编译器强制去处理这种可能性,而不能仅仅依赖于值存在的偶然性.

Option是强制的,不要使用null来表示一个值的缺失.

创建Option

通常,你可以直接实例化Some样例类来创建一个Option:

val greeting: Option[String] = Some("Hello world")

或者,在知道缺失的情况下直接使用None:

val greeting: Option[String] = None

然而,在实际工作中,你不可避免的要去操作一些Java库,或者其他将null做为缺失值的JVM语言代码.为此,Option半生对象提供了一个工厂方法,可以根据给定的参数创建相映的Option:

val absentGreeting: Option[String] = Option(null) // absentGreeting will be None
val presentGreeting: Option[String] = Option("Hello!") // presentGreeting will be Some("Hello!")

使用Option

想象一下,你正在为某个创业公司工作,要做的第一件事情就是实现一个用户的存储库,要求能够通过唯一的用户ID来查找他们.有时候请求会带来假的ID,这种情况,查找方法就需要返回Option[User]类型的数据,一个假想的实现可能如下:

case class User(
    id: Int,
    firstName: String,
    lastName: String,
    age: Int,
    gender: Option[String]
  )

object UserRepository {
    private val users = Map(1 -> User(1, "John", "Doe", 32, Some("male")),
                                2 -> User(2, "Johanna", "Doe", 30, None))
    def findById(id: Int): Option[User] = users.get(id)
    def findAll = users.values
}  

现在,假设从UserRepository接收到一个Option[User]实例,并需要那它做点什么,该怎么处理呢?

一个办法就是通过isDefined方法来检查是否有值,如果有,你就可以用get方法来获取该值:

val user1 = UserRepository.findById(1)
  if (user1.isDefined) {
    println(user1.get.firstName)
} // will print "John"

这和Guava库中的Optional使用方法类似.不过这种使用方式太过笨重,更重要的是,使用get之前,你可能会忘记使用isDefined方法做检查,这会导致运行期出现异常,这样一来,相对于null,使用Option就没有什么优势了.

提供一个默认值

很多时候,在值不存在时需要进行回退,或者提供一个默认值.Scala为Option提供了getOrElse方法,以应对这种情况:

val user = User(2, "Johanna", "Doe", 30, None)
println("Gender: " + user.gender.getOrElse("not specified")) // will print "not specified"

请注意,作为getOrElse参数的默认值是一个传名参数,这意味着,只有当这个Option确实是None时,传名参数才会被求值.因此,没有必要担心创建默认值的代价,它只有在需要时才会被执行.

模式匹配

Some是一个样例类,可以出现在模式匹配表达式或者其他允许模式出现的地方,上面的例子可以用模式匹配方式重写:

val user = User(2, "Johanna", "Doe", 30, None)
user.gender match {
  case Some(gender) => println("Gender: " + gender)
  case None => println("Gender: not specified")
}

或者,你想删除重复的println语句,并重点突出模式匹配表达式的使用:

val user = User(2, "Johanna", "Doe", 30, None)
val gender = user.gender match {
  case Some(gender) => gender
  case None => "not specified"
}
println("Gender: " + gender)

你可能已经发现使用模式匹配处理Option是非常啰嗦的,这也是它非惯用法的原因,所有还是使用其他方式进行类似的处理.

作为集合的Option

Option是类型A的容器,更确切的说,可以把它看成某种集合,这个特殊的集合要么只包含一个元素,要么就什么元素都没有.

虽然在类型层次上Option并不是Scala的集合类型,但,凡事你觉得Scala好用的方法,Option也有,你甚至可以将它转换成一个集合List.

执行一个副作用

如果想在Option值存在的时候执行某个副作用,foreach方法就派上用场了:

UserRepository.findById(2).foreach(user => println(user.firstName)) // prints "Johanna"

如果这个Option是一个Some,传递给foreach的函数就会被调用一次,且只有一次,如果是None,就不会被调用.

执行映射

Option表现的像集合,最棒的一点是,你可以用他来进行函数式编程,就像处理列表集合那样.

正如你可以将List[A]映射到List[B]一样,你也可以将Option[A]映射到Option[B],如果Option[A]实例是Some[A]类型,那映射结果就是Some[B]类型,否则,就是None.

如果将Option和List作对比,那None就相当于一个空列表:当你映射一个空的List[A],会得到一个空得List[B],而映射一个是None的Option[A]时,得到的Option[B]也是None.

让我们得到一个可能不存在的用户的年龄:

val age = UserRepository.findById(1).map(_.age) // age is Some(32)

Option与flatMap

也可以在gender上做map操作:

val gender = UserRepository.findById(1).map(_.gender) // gender is an Option[Option[String]]

所生成的gender时Option[Option[String]]类型,这又是为何?

可以这样想:你有一个装有user的Option容器,在容器里面,你将User映射到Option[String],必然得到一个嵌套的Option.

既然可以使用flatMap方法将List[List[A]]处理成List[B],Option[Option[A]]也可以调用flatMap方法处理成Option[B],这没有任何问题,Option提供了flatMap方法:

val gender1 = UserRepository.findById(1).flatMap(_.gender) // gender is Some("male")
val gender2 = UserRepository.findById(2).flatMap(_.gender) // gender is None
val gender3 = UserRepository.findById(3).flatMap(_.gender) // gender is None

现在结果就变成了Option[String]类型,如果user和gender都有值,那结果就会是Some类型,反之,就会得到一个None.

要理解是什么原理,让我们看一下flatMap操作List[List[A]时发生了什么:

val names: List[List[String]] =
 List(List("John", "Johanna", "Daniel"), List(), List("Doe", "Westheide"))
names.map(_.map(_.toUpperCase))
// results in List(List("JOHN", "JOHANNA", "DANIEL"), List(), List("DOE", "WESTHEIDE"))
names.flatMap(_.map(_.toUpperCase))
// results in List("JOHN", "JOHANNA", "DANIEL", "DOE", "WESTHEIDE")

如果我们使用flatMap,内部列表中的元素会被转换成一个扁平的字符串列表.显然如果内部列表是空得,则不会有任何东西留下.

现在回到Option类型,如果映射一个由Option组成的列表呢:

val names: List[Option[String]] = List(Some("Johanna"), None, Some("Daniel"))
names.map(_.map(_.toUpperCase)) // List(Some("JOHANNA"), None, Some("DANIEL"))
names.flatMap(xs => xs.map(_.toUpperCase)) // List("JOHANNA", "DANIEL")

如果只是map,那结果还是List[Option[String]],而如果使用flatMap时,内部集合的元素就会被放到一个扁平的列表里:任何一个Some[String]里的元素都会被解包,放入结果集中;而原列表中的None值由于不包含任何元素,就直接被过滤掉了.

过滤Option

也可以像过滤列表那样过滤Option,如果Option包含有值,而且传递给filter的谓词函数返回真,filter会返回Some实例,否则,返回值为None.

UserRepository.findById(1).filter(_.age > 30) // None, because age is <= 30
UserRepository.findById(2).filter(_.age > 30) // Some(user), because age is > 30
UserRepository.findById(3).filter(_.age > 30) // None, because user is already None

for语句

现在,你已经知道 Option 可以被当作集合来看待,并且有 map 、 flatMap 、 filter 这样的方法。 可能你也在想 Option 是否能够用在 for 语句中,答案是肯定的。 而且,用 for 语句来处理 Option 是可读性最好的方式,尤其是当你有多个 map 、flatMap 、filter 调用的时候。 如果只是一个简单的 map 调用,那 for 语句可能有点繁琐。

假如我们想得到一个用户的性别,可以使用for语句:

for {
  user <- UserRepository.findById(1)
  gender <- user.gender
} yield gender // results in Some("male")

可能你已经知道,这样的for语句等同于flatMap调用,如果UserRepository.findById(1)返回None,或者gender是None,那个for语句的结果就是None.不过这个例子里,gender含有值,所以返回结果是Some类型的.

如果我们想返回所有用户的性别,可以遍历用户,yield其性别:

for {
  user <- UserRepository.findAll
  gender <- user.gender
} yield gender
// result in List("male")

在生成器左侧使用

也许你还记得,前一章曾经提到过, for 语句中生成器的左侧也是一个模式。 这意味着也可以在 for 语句中使用包含选项的模式。
重写之前的例子:

for {
   User(_, _, _, _, Some(gender)) <- UserRepository.findAll
 } yield gender

在生产期左侧使用Some模式就可以在结果集中排除掉值为None的元素.

连接Option

Option 还可以被链接使用,这有点像偏函数的链接: 在 Option 实例上调用 orElse 方法,并将另一个 Option 实例作为传名参数传递给它。 如果一个 Option 是 None , orElse 方法会返回传名参数的值,否则,就直接返回这个 Option。

一个很好的使用案例是资源查找:对多个不同的地方按优先级进行搜索。 下面的例子中,我们首先搜索 config 文件夹,并调用 orElse 方法,以传递备用目录:

case class Resource(content: String)
val resourceFromConfigDir: Option[Resource] = None
val resourceFromClasspath: Option[Resource] = Some(Resource("I was found on the classpath"))
val resource = resourceFromConfigDir orElse resourceFromClasspath

如果想链接多个选项,而不仅仅是两个,使用 orElse 会非常合适。 不过,如果只是想在值缺失的情况下提供一个默认值,那还是使用 getOrElse 吧。