类型 Option
最后更新于:2022-04-01 02:13:27
前几章,我们讨论了许多相当先进的技术,尤其是模式匹配和提取器。是时候来看一看 Scala 另一个基本特性了: Option 类型。
可能你已经见过它在 `Map` API 中的使用;在实现自己的提取器时,我们也用过它,然而,它还需要更多的解释。你可能会想知道它到底解决什么问题,为什么用它来处理缺失值要比其他方法好,而且可能你还不知道该怎么在你的代码中使用它。这一章的目的就是消除这些问号,并教授你作为一个新手所应该了解的 `Option` 知识。
### 基本概念
Java 开发者一般都知道 `NullPointerException`(其他语言也有类似的东西),通常这是由于某个方法返回了 `null` ,但这并不是开发者所希望发生的,代码也不好去处理这种异常。
值 `null` 通常被滥用来表征一个可能会缺失的值。不过,某些语言以一种特殊的方法对待 `null` 值,或者允许你安全的使用可能是 `null` 的值。比如说,Groovy 有 _安全运算符(Safe Navigation Operator)_ 用于访问属性,这样 `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
目前为止,所有的这些都很简洁,不过该怎么使用 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 库](https://code.google.com/p/guava-libraries) 中的 `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 上使用模式确实是有一个相当优雅的方式,在下面的 for 语句一节中,你就会学到。
### 作为集合的 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]`( `User` 类上的属性 `gender` 是 `Option[String]` 类型的)。得到的必然是嵌套的 Option。
既然可以 `flatMap` 一个 `List[List[A]]` 到 `List[B]` ,也可以 `flatMap` 一个 `Option[Option[A]]` 到 `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]` 时,会发生什么?(要记得, Option 就像一个集合,比如列表)
~~~
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` 值由于不包含任何元素,就直接被过滤出去了。
记住这一点,然后再去看看 `faltMap` 在 `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` 返回 `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` 吧。
### 总结
在这一章里,你学到了有关 Option 的所有知识,这有利于你理解别人的代码,也有利于你写出更可读,更函数式的代码。
这一章最重要的一点是:列表、集合、映射、Option,以及之后你会见到的其他数据类型,它们都有一个非常统一的使用方式,这种使用方式既强大又优雅。
下一章,你将学习 Scala 错误处理的惯用法。