在用 Scala 做業(yè)務(wù)開發(fā)的時(shí)候,我們大都會用到 case class 以及「模式匹配」,本文將介紹在日常開發(fā)中如何利用 case class 模擬 ADT 去良好地組織業(yè)務(wù)。
ADT(代數(shù)數(shù)據(jù)類型)
在計(jì)算機(jī)編程、特別是函數(shù)式編程與類型理論中,
ADT是一種composite type(組合類型)。例如,一個(gè)類型由其它類型組合而成。兩個(gè)常見的代數(shù)類型是product(積)類型 (比如tuples和records)和sum(和)類型,它也被稱為tagged unions或variant type。
這里簡單介紹一下常見的兩種代數(shù)類型 product(積)類型和 sum(和)類型
計(jì)數(shù)(Counting)
在介紹兩種常見代數(shù)類型之前我們先介紹一下 「計(jì)數(shù)」 的概念,方便理解后面所要介紹的內(nèi)容。
為了將某個(gè)類型與我們熟悉的數(shù)字代數(shù)相關(guān)聯(lián),我們可以計(jì)算該類型有多少種取值,例如 Haskell中的Bool 類型:
data Bool = true | false
可以看到 Bool 類型有兩種可能的取值,要么是 false, 要么是 true, 所以這里我們暫時(shí)將數(shù)字 2 與 Bool 類型相關(guān)聯(lián)。
如果 Bool 類型關(guān)聯(lián)的是 2,那么何種類型是 1 呢,在 Scala 中 Unit 類型只有一種取值:
scala> val a = ()
a: Unit = ()
所以這里我們將數(shù)字 1 與 Unit 類型相關(guān)聯(lián)。
有了 「計(jì)數(shù)」 這個(gè)概念,接下來我們介紹常見的兩種代數(shù)類型。
product
product 可以理解為是一種 組合(combination),可以通過我們熟悉的 *(乘法) 操作來產(chǎn)生,對應(yīng)的類型為:
data Mul a b = Mul a b
也就是說, a * b 類型是同時(shí)持有 a 和 b 的容器。
在 Scala中,tuples(元組)就是這樣的,例如:
scala> val b = (Boolean, Boolean)
b: (Boolean.type, Boolean.type) = (object scala.Boolean,object scala.Boolean)
我們定義的元組 b 就是兩個(gè) Boolean 類型的組合,也就是說,元組 b 是同時(shí)擁有兩個(gè) Boolean 類型的容器,可以通過我們前面介紹的 「計(jì)數(shù)」 的概念來理解:
Boolean 類型有兩種取值,當(dāng) Boolean 和 Boolean 通過 * 操作進(jìn)行組合時(shí):
2 * 2 = 4
所以我們定義的元組 b 有四種可能的取值,我們利用 「模式匹配」 來列舉這四種取值:
b match {
case (true, true) => ???
case (true, false) => ???
case (false, true) => ???
case (false, false) => ???
}
sum
sum 可以理解為是一種 alternation(選擇),可以通過我們熟悉的 + 操作來產(chǎn)生,對應(yīng)的類型為:
data Add a b = AddL a | AddR b
a + b 是一個(gè)和類型,同時(shí)擁有 a 或者 b。
注意這里是 a 或者 b,不同于上面介紹的 *。
這里可能就會有疑惑了,為什么 + 操作對應(yīng)的語義是「或者」 呢,我們依然通過前面介紹的 「計(jì)數(shù)」 的概念來理解:
在 Scala 中 Option 就是一種 sum 類型,例如:
scala> val c = Option(false)
c: Option[Boolean] = Some(false)
option[Boolean] 其實(shí)是 Boolean 與 None 通過 + 操作得到的,分析:
Boolean 有兩種取值,None 只有一種,那么:
2 + 1 = 3
所以我們定義的 c: Option[Boolean] 有三種可能的取值,我們利用 「模式匹配」 來列舉這三種取值:
c match {
case Some(true) => ???
case Some(false) => ???
case None => ???
}
我們可以看到,Option[Boolean] 類型的取值要么是 Boolean 類型,要么是 None 類型,這兩種類型是「不能同時(shí)」存在的,這一點(diǎn)與 product 類型不同。并且 sum 類型是一個(gè)「閉環(huán)」,類型的定義已經(jīng)包含了所有可能性,絕無可能會出現(xiàn)非法狀態(tài)。
在業(yè)務(wù)中使用 ADT
我們在利用 Scala 的 case class 組織業(yè)務(wù)的時(shí)候其實(shí)就已經(jīng)用到了 ADT,例如:
sealed trait Tree
case class Node(left: Tree, right: Tree) extends Tree
case class Leaf[A](value: A) extends Tree
在上面 「樹」 結(jié)構(gòu)的定義中,Node、Leaf 通過繼承 Tree,通過這種繼承關(guān)系而得到的類型就是 ADT 中的 sum,而構(gòu)造 Node 和 Leaf 的時(shí)候則是 ADT 中的 product。大家可以通過我們前面所說的 「計(jì)數(shù)」的概念來驗(yàn)證。
上面的代碼中出現(xiàn)了一個(gè)關(guān)鍵字 sealed,我們先介紹一下這個(gè)關(guān)鍵字。
Sealed
前面我們說過 sum 類型是一個(gè) 「閉環(huán)」,當(dāng)我們將「樣例類」的「超類」聲明為 sealed 后,該超類就變成了一個(gè) 「密封類」,「密封類」的子類都必須在與該密封類相同的文件中定義,從而達(dá)到了上面說的「閉環(huán)」的效果。
比如我們現(xiàn)在要為上面的 Tree 添加一個(gè) EmptyLeaf:
case object EmptyLeaf extends Tree
那這段被添加的代碼必須放在我們上面聲明 Tree 的那個(gè)文件里面,否則會報(bào)錯(cuò)。
另外,sealed 關(guān)鍵字也可以讓「編譯器」檢查「模式」語句的完整性,例如:
sealed trait Answer
case object Yes extends Answer
case object No extends Answer
val x: Answer = Yes
x match {
case Yes => println("Yes")
}
<console>: warning: match may not be exhaustive.
It would fail on the following input: No
x match {
^
「編譯器」會在編譯階段提前給我們一個(gè)可能會出錯(cuò)的「警告(warning)」
利用 ADT 來良好地組織業(yè)務(wù)
前面說了這么多,終于進(jìn)入正題了,接下來我們以幾個(gè)例子來說明如何在開發(fā)中合理地利用 ADT。
場景一
現(xiàn)在我們要開發(fā)一個(gè)與「優(yōu)惠券」有關(guān)的業(yè)務(wù),一般情況下,我們可能會這么去定義優(yōu)惠券的結(jié)構(gòu):
case class Coupon (
id: Long,
baseInfo: BaseInfo,
`type`: String,
...
)
object Coupon {
//優(yōu)惠券類型
object Type {
// 現(xiàn)金券
final val CashType = "CASH"
//折扣券
final val DiscountType = "DISCOUNT"
// 禮品券
final val GiftType = "GIFT"
}
}
分析:這樣去定義 「優(yōu)惠券」 的結(jié)構(gòu)也能解決問題,但是當(dāng) 「優(yōu)惠券」 類型增多的時(shí)候,會出現(xiàn)很多的冗余數(shù)據(jù)。比如說,不同的優(yōu)惠類型,會有不同優(yōu)惠信息,這些優(yōu)惠信息在結(jié)構(gòu)中對應(yīng)的字段也會有所不同:
case class Coupon (
id: Long,
baseInfo: BaseInfo,
`type`: String,
// 僅在優(yōu)惠券類型是代金券的時(shí)候使用
leastCost: Option[Long],
reduceCost: Option[Long],
//僅在優(yōu)惠券類型是折扣券的時(shí)候使用
discount: Option[Int],
//僅在優(yōu)惠券是禮品券的時(shí)候使用
gift: Option[String]
)
從上定義的結(jié)構(gòu)我們可以看到,當(dāng)我們使用 「禮品券」 的時(shí)候,有三個(gè)字段(leastCost、reduceCost、discount)的值是 None,因?yàn)槲覀兏揪陀貌坏健S纱丝梢钥闯?,?dāng) 「優(yōu)惠券」 的結(jié)構(gòu)比較復(fù)雜的時(shí)候,可能會產(chǎn)生大量的冗余字段,從而使我們的代碼看上去非常臃腫,同時(shí)增加了我們的開發(fā)難度。
利用 ADT 重新組織:
分析:通過上面的討論,我們知道 「優(yōu)惠券」 可能有多種類型,所以,我們利用 ADT 將不同的「優(yōu)惠券」分離開來:
// 將每一種優(yōu)惠券公共的部分抽離出來
sealed trait Coupon {
val id: Long
val baseInfo: BaseInfo
val status: Int
val `type`: String
...
}
case class CashCoupon (
id: Long,
baseInfo: BaseInfo,
`type`: String = Coupon.Type.CashType,
status: Int,
leastCost: Long,
reduceCost: Long,
...
) extends Coupon
case class DiscountCoupon (
id: Long,
baseInfo: BaseInfo,
`type`: String = Coupon.Type.DiscountType,
status: Int,
discount: Int,
...
) extends Coupon
case class GiftCoupon (
id: Long,
baseInfo: BaseInfo,
`type`: String = Coupon.Type.GiftType,
status: Int,
gift: String,
...
) extends Coupon
同過合理地利用 ADT 我們使每一種「優(yōu)惠券」的結(jié)構(gòu)更加清晰,同時(shí)也減少了字段的冗余。并且,如果在業(yè)務(wù)后期我們還要增加別的 「優(yōu)惠券」類型,我們不用修改原來的結(jié)構(gòu),只需要再重新創(chuàng)建一個(gè)新的 case class 就可以了:
比如我們在后期增加了一種叫 「團(tuán)購券」 的優(yōu)惠券,我們不需要修改原來定義的結(jié)構(gòu),直接:
case class GroupCoupon (
id: Long,
baseInfo: BaseInfo,
`type`: String,
status: Int,
dealDetail: String
) extends Coupon
并且在利用「模式匹配」的時(shí)候,我們可以像操作代數(shù)那樣:
coupon match {
case c: CashCoupon => ??? // 我們可以直接在匹配完成之后使用 coupon
case c: DiscountCoupon => ???
case c: GiftCoupon => ???
case c: GroupCoupon => ???
}
// 如果是我們用 ADT 改造前的數(shù)據(jù)結(jié)構(gòu),那模式匹配就會變成:
coupon.`type` match {
case Coupon.Type.CashType => ??? // 我們只能使用 coupon.`type`
case Coupon.Type.GiftType => ???
case Coupon.Type.DiscountType => ???
case Coupon.Type.GroupCoupon => ???
}
通過本例,我們可以看到,利用 ADT 重新組織之后的數(shù)據(jù)結(jié)構(gòu)減少了數(shù)據(jù)的冗余,并且在使用「模式匹配」的時(shí)候更加清晰,在功能上也更加強(qiáng)大。
場景二
針對上面的優(yōu)惠券,用戶在使用這些優(yōu)惠券的時(shí)候,優(yōu)惠券會存在不同的幾種狀態(tài):
未領(lǐng)取
已領(lǐng)取但暫未使用
已使用
過期優(yōu)惠券
無效優(yōu)惠券
我們現(xiàn)在想要根據(jù)這幾種不同的狀態(tài)渲染出不同的結(jié)果頁面,要得到這幾種狀態(tài),我們通常會:
def fetched(c: Coupon, user: User) = {
//根據(jù)coupon信息以及user信息去查詢用戶是否已經(jīng)領(lǐng)取了這張優(yōu)惠券
???
}
def used(c: Coupon, user: User) = {
//根據(jù)coupon信息以及user信息去查詢用戶是否已經(jīng)使用了這張優(yōu)惠券
???
}
def isExpired(c: Coupon) = {
//根據(jù)優(yōu)惠券信息來判斷優(yōu)惠券是否已經(jīng)過期
???
}
def isAviable(c: Coupon) = {
//根據(jù)優(yōu)惠券信息來判斷優(yōu)惠券是否已經(jīng)失效
???
}
我們現(xiàn)在就利用這些狀態(tài)去渲染頁面:
def f(c: Coupon, user: User) = {
if (!isAviable(coupon)) {
if (!isExpired(coupon)) {
if (used(coupon, user)) {
//已使用的優(yōu)惠券
???
} else {
if (fetched(coupon, user)) {
//已領(lǐng)取但未使用的優(yōu)惠券
???
} else {
//未領(lǐng)取的優(yōu)惠券
???
}
}
} else {
//已過期的優(yōu)惠券
???
}
} else {
//已失效的優(yōu)惠券
???
}
}
上面的代碼能夠完成我們的需求,但是,當(dāng)優(yōu)惠券的狀態(tài)變多的時(shí)候,該方法傳入的參數(shù)也會有所變化,「if-else」語句層級也會越多,非常容易出錯(cuò),同時(shí)代碼表達(dá)的意思也沒那么明確,可讀性極差。
所以我們能否重新組織一下數(shù)據(jù)結(jié)構(gòu),使之能夠利用「模式匹配」?
利用 ADT 重新組織:
分析:我們在使用優(yōu)惠券的時(shí)候無非就是判斷這幾種「狀態(tài)」,那我們就利用 ADT 將這些狀態(tài)抽象化:
sealed trait CouponStatus {
//每種狀態(tài)共用的一些信息
val base: CouponStatusBase
}
case class CouponStatusBase (
coupon: Coupon,
...
)
//未領(lǐng)取
case class StatusNotFetched (
base: CouponStatusBase
) extends CouponStatus
//已領(lǐng)取但未使用
case class StatusFetched (
base: CouponStatusBase,
user: User
) extends CouponStatus
//已使用
case class StatusUsed (
base: CouponStatusBase,
user: User
) extends CouponStatus
//過期優(yōu)惠券
case class StatusExpired (
base: CouponStatusBase
) extends CouponStatus
case object StatusUnAvilable extends CouponStatus
我們利用 ADT 將「狀態(tài)」抽象化了,并且將每種「狀態(tài)」所需要使用到的數(shù)據(jù)全部構(gòu)造在了一起,那現(xiàn)在我們再根據(jù)不同的「狀態(tài)」去渲染頁面就變成了:
def f(status: CouponStatus) = status match {
case StatusNotFetched(base) => ???
case StatusFetched(base, user) => ???
case StatusUsed(base, user) => ???
case StatusExpired(base) => ???
case StatusUnAvilable => ???
}
可以看到通過用 ADT 抽象之后的數(shù)據(jù)結(jié)構(gòu)在「模式匹配」的時(shí)候非常清晰,并且我們將不同狀態(tài)下所需要的數(shù)據(jù)全部構(gòu)造在了一起,也使得我們在模式匹配之后可以直接利用 status 去使用這些數(shù)據(jù),不用再通過方法去獲取了。
通過本例,我們可以發(fā)現(xiàn),通過 ADT 可以將數(shù)據(jù)「高度抽象」,使得數(shù)據(jù)的「具體信息」變得簡潔,同時(shí)「概括能力」變得更強(qiáng),數(shù)據(jù)更加「完備」。
延伸閱讀
The Algebra of Algebraic Data Types, Part 1