如何在 Scala 中利用 ADT 良好地組織業(yè)務(wù)

在用 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(積)類型 (比如 tuplesrecords )和sum(和)類型,它也被稱為 tagged unionsvariant 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ù)字 2Bool 類型相關(guān)聯(lián)。

如果 Bool 類型關(guān)聯(lián)的是 2,那么何種類型是 1 呢,在 ScalaUnit 類型只有一種取值:

scala> val a = ()
a: Unit = ()

所以這里我們將數(shù)字 1Unit 類型相關(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í)持有 ab 的容器。

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) BooleanBoolean 通過 * 操作進(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ù)」 的概念來理解:

ScalaOption 就是一種 sum 類型,例如:

scala> val c = Option(false)
c: Option[Boolean] = Some(false)

option[Boolean] 其實(shí)是 BooleanNone 通過 + 操作得到的,分析:

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

我們在利用 Scalacase 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)造 NodeLeaf 的時(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):

  1. 未領(lǐng)取

  2. 已領(lǐng)取但暫未使用

  3. 已使用

  4. 過期優(yōu)惠券

  5. 無效優(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ù)更加「完備」。

延伸閱讀

Algebraic data type

The Algebra of Algebraic Data Types, Part 1

The Algebra of Algebraic Data Types, Part 2

The Algebra of Algebraic Data Types, Part 3

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容