對於Scala我一直把它當成進階版本的Java,當越來越了解Functional Programming的精神,才發現之前想法還蠻天真的.
可以從如何正確使用 Option
,讓我們慢慢來進入Functional Programming的世界.
先從一個簡單的API實例來開始: 假如有一個API,Admin使用者可以找出某個國家的所有使用者;不是Admin使用者就回傳空字串.
使用者會帶上 userName
, password
和 country
呼叫此API,這個API會
- 檢查格式是否正確
- 查詢資料庫是否有使用者
- 若有,檢查
userType
使否為Admin
- 根據
country
去資料庫撈取該國家的所有使用者 - 將結果轉成
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
21sealed 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
AccountWithCredential
和AccountWithProfile
,為Account
的Sum Type
- 各種不同 subtype 有它們的參數,也稱之
Product Type
這種定義方式有點像Java世界中的 Value Object
,把資料和行為分開是有好處的,避免資料和行為耦合過緊.Design Pattern 也是透過pattern去分離資料和行為.
再來我們來定義行為:1
2
3
4
5
6
7
8
9
10
11trait 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
之前的版本會定義找不到會回傳 Null
,Null
會造成語意不清和程式碼充斥著一堆檢查是否為 Null
的程式碼.
我們可以透過 Null Object pattern 來解決這個問題.
在 Scala
或 Java 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
48object 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
9private 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
裡面的值,Option
的 flatMap
的 signature 為 A => Option[B]
.
例如: getAccountsByCountry
需依賴 login
的結果,才可以進行之後的動作.我們是透過 flatMap
將這兩個動作串接起來,
這樣就可以避免一堆冗餘 if else
檢查.
再舉一個抽象的例子:1
2
3
4
5
6
7def 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裡面,在使用時候並不會將值取出來,而是透過 flatMap
或 map
來轉換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
28object 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 | object Api extends App { |
比較第一個版本和最後一個版本的程式碼,最後一個版本可以很清楚表達整個程式意圖,而不會被一堆 if else
檢查而干擾.
透過正確使用 Option
我們可以學習到:
- 分離資料和行為
- 將資料放入context
- 利用
flatMap
,map
轉換context裡面的值
衍生需求
最後一個版本可以發現
payload不正確或某個國家的使用者為零
結果竟然都是None
,這樣會造成使用者體驗不佳和維護上的困難.那我們可以怎麼改善呢?
可以改用 Try
或 Either
來表達錯誤,在這邊使用 Option
來表示parse完的結果不太洽當,主要是展示可以透過compse方式來串接多個function,下一篇將改用 Try
或 Either
,讓前端可以清楚知道錯誤訊息.