前幾章,我們討論了許多相當先進的技術,尤其是模式匹配和提取器。是時候來看一看 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 錯誤處理的慣用法。