[Scala] How to use Option correctly

對於Scala我一直把它當成進階版本的Java,當越來越了解Functional Programming的精神,才發現之前想法還蠻天真的.

可以從如何正確使用 Option ,讓我們慢慢來進入Functional Programming的世界.

先從一個簡單的API實例來開始: 假如有一個API,Admin使用者可以找出某個國家的所有使用者;不是Admin使用者就回傳空字串.

使用者會帶上 userName , passwordcountry 呼叫此API,這個API會

  1. 檢查格式是否正確
  2. 查詢資料庫是否有使用者
  3. 若有,檢查 userType 使否為 Admin
  4. 根據 country 去資料庫撈取該國家的所有使用者
  5. 將結果轉成 json 放到 http response

先定義 Data Type

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
sealed trait Name
final case class UserName(name: String) extends Name

sealed trait Password
final case class MD5Password(plainText: String) extends Password

sealed trait Country
final case object TAIWAN extends Country
final case object CHINA extends Country

sealed trait UserType
final case object ADMIN extends UserType
final case object EMAIL extends UserType
final case object PHONE extends UserType


sealed trait Account {
val name: String
}
final case class AccountWithCredential(name: UserName, password: Password, userType: UserType) extends Account
final case class AccountWithProfile(name: UserName, country: Country, age: Int) extends Account

這樣的 Data Type 也是所謂的 Algebraic Type
Account 為例:

  • 它有兩種 subtype AccountWithCredentialAccountWithProfile,為 AccountSum Type
  • 各種不同 subtype 有它們的參數,也稱之 Product Type

這種定義方式有點像Java世界中的 Value Object,把資料和行為分開是有好處的,避免資料和行為耦合過緊.Design Pattern 也是透過pattern去分離資料和行為.

再來我們來定義行為:

1
2
3
4
5
6
7
8
9
10
11
trait Repo {
def login(name: UserName, password: Password): Option[AccountWithCredential]

def getAccountsByCountry(country: Country): Option[List[AccountWithProfile]]
}

object Repo extends Repo {
def login(name: UserName, password: Password): Option[AccountWithCredential] = ???

def getAccountsByCountry(country: Country): Option[List[AccountWithProfile]] = ???
}

login 的回傳值為一個 Option context,裡面裝著 AccountWithCredential

  • 若有找到就回傳 Some[AccountWithCredential]
  • 若沒有找就回傳 None

Java 7 之前的版本會定義找不到會回傳 NullNull 會造成語意不清和程式碼充斥著一堆檢查是否為 Null 的程式碼.
我們可以透過 Null Object pattern 來解決這個問題.
ScalaJava 8 則是透過 Option / Optional data type來解決之.

我們可以實作API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
object Api extends App {
val name: Option[UserName] = parse(UserName(args(0)))
val password: Option[MD5Password] = parse(MD5Password(args(1)))

// Step1: login
// Step2: check isAdmin == true
// Step3: get accounts by country
// Step4: convert accounts to json
// Step5: add json to HttpResponse

if (name.isEmpty) throw new RuntimeException("bad id")
if (password.isEmpty) throw new RuntimeException("bad password")
val json: Option[String] = getJsonProfile(name.get, password.get, CHINA)
if (json.isDefined) {
returnHttpResponse(json.get)
} else {
returnHttpResponse("")
}

private def parse[A](a: => A): Option[A] = ???

private def getJsonProfile(name: UserName,
password: Password,
country: Country): Option[String] = {
val accountWithCredential: Option[AccountWithCredential] =
Repo.login(name, password)
if (accountWithCredential.isDefined) {
accountWithCredential.get.userType match {
case ADMIN =>
val accounts: Option[List[AccountWithProfile]] =
Repo.getAccountsByCountry(country)
if (accounts.isDefined) {
listToJson(accounts.get)
} else {
None
}
case _: UserType => None
}
} else {
None
}
}

// convert a list of Account to json String
private def listToJson[A](list: List[A]): Option[String] = ???

private def returnHttpResponse(message: String): Unit = ???
}

這個版本利用 isDefined 去判定是否為空值,這樣的寫法跟使用 Null 當回傳值一樣,在這個版本完全看不出使用 Option 好處,反而顯得冗餘.

我們改寫另外一個版本 getJsonProfile

1
2
3
4
5
6
7
8
9
private def getJsonProfile(name: UserName, password: Password, country: Country): Option[String] = {
Repo.login(name, password).flatMap(
account => {
if (account.userType == ADMIN) {
Repo.getAccountsByCountry(country).flatMap(profiles => listToJson(profiles))
}
else None
})
}

我們利用 flatMap 來幫串接,而不是嘗試取得 Option 裡面的值,OptionflatMap 的 signature 為 A => Option[B]

例如: getAccountsByCountry 需依賴 login 的結果,才可以進行之後的動作.我們是透過 flatMap 將這兩個動作串接起來,
這樣就可以避免一堆冗餘 if else 檢查.

再舉一個抽象的例子:

1
2
3
4
5
6
7
def A(a: A): Option[B] = ???
def B(b: B): Option[C] = ???
def C(c: C): Option[D] = ???

def total(input: Option[A]): Option[D] = {
input.flatMap(A(_).flatMap(B(_).flatMap(C(_))))
}

可以透過 flatMap 串接 A, B, C 這三個function,假如用 isDefined 來串接,程式碼的可讀性和維護性會大幅下降.
Function Programming中,常見的pattern會將值放到一個context裡面,在使用時候並不會將值取出來,而是透過 flatMapmap 來轉換context裡面的值.

我們可以傳入 parse 過後的結果,將所有的function串接在一起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
object Api extends App {
val name: Option[UserName] = parse(UserName(args(0)))
val password: Option[MD5Password] = parse(MD5Password(args(1)))


val json: Option[String] = getJsonProfile(name, password, CHINA)
if (json.isDefined) {
returnHttpResponse(json.get)
} else {
returnHttpResponse("")
}

private def getJsonProfile(name: Option[UserName], password: Option[Password], country: Country): Option[String] = {
name.flatMap(name1 =>
password.flatMap(password1 =>
Repo.login(name1, password1).flatMap(
account => {
if (account.userType == ADMIN) {
Repo.getAccountsByCountry(country).flatMap(profiles => listToJson(profiles))
}
else None
})
))
}

// other functions
// ...
}

可以發現串接的function越多,會越寫越右邊,有點類似 Callback hell ,或許也可以稱為 flatMap hell
好險Scala有提供很好的 syntax sugar for comprehension 來解決 flatMap hell

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
object Api extends App {
val name: Option[UserName] = parse(UserName(args(0)))
val password: Option[MD5Password] = parse(MD5Password(args(1)))

for {
name1 <- name
password1 <- password
account <- Repo.login(name1, password1).filter(a => a.userType == ADMIN)
profiles <- Repo.getAccountsByCountry(CHINA)
json <- listToJson(profiles)
} yield {
returnHttpResponse(json)
}

// other functions
// ...
}

比較第一個版本和最後一個版本的程式碼,最後一個版本可以很清楚表達整個程式意圖,而不會被一堆 if else 檢查而干擾.
透過正確使用 Option 我們可以學習到:

  1. 分離資料和行為
  2. 將資料放入context
  3. 利用 flatMap, map 轉換context裡面的值

衍生需求

最後一個版本可以發現
payload不正確或某個國家的使用者為零

結果竟然都是None,這樣會造成使用者體驗不佳和維護上的困難.那我們可以怎麼改善呢?

可以改用 TryEither 來表達錯誤,在這邊使用 Option 來表示parse完的結果不太洽當,主要是展示可以透過compse方式來串接多個function,下一篇將改用 TryEither ,讓前端可以清楚知道錯誤訊息.

參考:

  1. Introduction to Algebraic Types in Scala