如何在 Scala 中利用 ADT 良好地組織業務

本文由 Shaw 發表在 ScalaCool 團隊博客。javascript

在用 Scala 作業務開發的時候,咱們大都會用到 case class 以及「模式匹配」,本文將介紹在平常開發中如何利用 case class 模擬 ADT 去良好地組織業務。java

ADT(代數數據類型)

在計算機編程、特別是函數式編程與類型理論中,ADT 是一種 composite type(組合類型)。例如,一個類型由其它類型組合而成。兩個常見的代數類型是 product(積)類型 (好比 tuplesrecords )和sum(和)類型,它也被稱爲 tagged unionsvariant typegit

這裏簡單介紹一下常見的兩種代數類型 product(積)類型和 sum(和)類型github

計數(Counting)

在介紹兩種常見代數類型以前咱們先介紹一下 「計數」 的概念,方面理解後面所要介紹的內容。編程

爲了將某個類型與咱們熟悉的數字代數相關聯,咱們能夠計算該類型有多少種取值,例如 Haskell中的Bool 類型:數據結構

data Bool = true | false複製代碼

能夠看到 Bool 類型有兩種可能的取值,要麼是 false, 要麼是 true, 因此這裏咱們暫時將數字 2Bool 類型相關聯。函數式編程

若是 Bool 類型關聯的是 2,那麼何種類型是 1 呢,在 ScalaUnit 類型只有一種取值:函數

scala> val a = ()
a: Unit = ()複製代碼

因此這裏咱們將數字 1Unit 類型相關聯。fetch

有了 「計數」 這個概念,接下來咱們介紹常見的兩種代數類型。spa

product

product 能夠理解爲是一種 組合(combination),能夠經過咱們熟悉的 *(乘法) 操做來產生,對應的類型爲:

data Mul a b = Mul a b複製代碼

也就是說, a * b 類型是同時持有 ab 的容器。

Scala中,tuples(元組)就是這樣的,例如:

scala> val b = (Boolean, Boolean)
b: (Boolean.type, Boolean.type) = (object scala.Boolean,object scala.Boolean)複製代碼

咱們定義的元組 b 就是兩個 Boolean 類型的組合,也就是說,元組 b 是同時擁有兩個 Boolean 類型的容器,能夠經過咱們前面介紹的 「計數」 的概念來理解:

Boolean 類型有兩種取值,當 BooleanBoolean 經過 * 操做進行組合時:

2 * 2 = 4複製代碼

因此咱們定義的元組 b 有四種可能的取值,咱們利用 「模式匹配」 來列舉這四種取值:

b match {
  case (true, true) => ???
  case (true, false) => ???
  case (false, true) => ???
  case (false, false) => ???
}複製代碼

sum

sum 能夠理解爲是一種 alternation(選擇),能夠經過咱們熟悉的 + 操做來產生,對應的類型爲:

data Add a b = AddL a | AddR b複製代碼

a + b 是一個和類型,同時擁有 a 或者 b

注意這裏是 a 或者 b,不一樣於上面介紹的 *

這裏可能就會有疑惑了,爲何 + 操做對應的語義是「或者」 呢,咱們依然經過前面介紹的 「計數」 的概念來理解:

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

scala> val c = Option(false)
c: Option[Boolean] = Some(false)複製代碼

option[Boolean] 實際上是 BooleanNone 經過 + 操做獲得的,分析:

Boolean 有兩種取值,None 只有一種,那麼:

2 + 1 = 3複製代碼

因此咱們定義的 c: Option[Boolean] 有三種可能的取值,咱們利用 「模式匹配」 來列舉這三種取值:

c match {
  case Some(true) => ???
  case Some(false) => ???
  case None => ???
}複製代碼

咱們能夠看到,Option[Boolean] 類型的取值要麼是 Boolean 類型,要麼是 None 類型,這兩種類型是「不能同時」存在的,這一點與 product 類型不一樣。而且 sum 類型是一個「閉環」,類型的定義已經包含了全部可能性,絕無可能會出現非法狀態。

在業務中使用 ADT

咱們在利用 Scalacase class 組織業務的時候其實就已經用到了 ADT,例如:

sealed trait Tree
case class Node(left: Tree, right: Tree) extends Tree
case class Leaf[A](value: A) extends Tree複製代碼

在上面 「樹」 結構的定義中,NodeLeaf 經過繼承 Tree,經過這種繼承關係而獲得的類型就是 ADT 中的 sum,而構造 NodeLeaf 的時候則是 ADT 中的 product。你們能夠經過咱們前面所說的 「計數」的概念來驗證。

上面的代碼中出現了一個關鍵字 sealed,咱們先介紹一下這個關鍵字。

Sealed

前面咱們說過 sum 類型是一個 「閉環」,當咱們將「樣例類」的「超類」聲明爲 sealed 後,該超類就變成了一個 「密封類」,「密封類」的子類都必須在與該密封類相同的文件中定義,從而達到了上面說的「閉環」的效果。

好比咱們如今要爲上面的 Tree 添加一個 EmptyLeaf

case object EmptyLeaf extends Tree複製代碼

那這段被添加的代碼必須放在咱們上面聲明 Tree 的那個文件裏面,不然會報錯。

另外,sealed 關鍵字也可讓「編譯器」檢查「模式」語句的完整性,例如:

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 {
       ^複製代碼

「編譯器」會在編譯階段提早給咱們一個可能會出錯的「警告(warning)」

利用 ADT 來良好地組織業務

前面說了這麼多,終於進入正題了,接下來咱們以幾個例子來講明如何在開發中合理地利用 ADT

場景一

如今咱們要開發一個與「優惠券」有關的業務,通常狀況下,咱們可能會這麼去定義優惠券的結構:

case class Coupon ( id: Long, baseInfo: BaseInfo, `type`: String, ... )

object Coupon {

  //優惠券類型
  object Type {

    // 現金券

    final val CashType       = "CASH"

    //折扣券

    final val DiscountType   = "DISCOUNT"

    // 禮品券

    final val GiftType       = "GIFT"
  }
}複製代碼

分析:這樣去定義 「優惠券」 的結構也能解決問題,可是當 「優惠券」 類型增多的時候,會出現不少的冗餘數據。好比說,不一樣的優惠類型,會有不一樣優惠信息,這些優惠信息在結構中對應的字段也會有所不一樣:

case class Coupon ( id: Long, baseInfo: BaseInfo, `type`: String, // 僅在優惠券類型是代金券的時候使用 leastCost: Option[Long], reduceCost: Option[Long], //僅在優惠券類型是折扣券的時候使用 discount: Option[Int], //僅在優惠券是禮品券的時候使用 gift: Option[String] )複製代碼

從上定義的結構咱們能夠看到,當咱們使用 「禮品券」 的時候,有三個字段(leastCostreduceCostdiscount)的值是 None,由於咱們根本就用不到。由此能夠看出,當 「優惠券」 的結構比較複雜的時候,可能會產生大量的冗餘字段,從而使咱們的代碼看上去很是臃腫,同時增長了咱們的開發難度。

利用 ADT 從新組織:

分析:經過上面的討論,咱們知道 「優惠券」 可能有多種類型,因此,咱們利用 ADT 將不一樣的「優惠券」分離開來:

// 將每一種優惠券公共的部分抽離出來

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 咱們使每一種「優惠券」的結構更加清晰,同時也減小了字段的冗餘。而且,若是在業務後期咱們還要增長別的 「優惠券」類型,咱們不用修改原來的結構,只須要再從新建立一個新的 case class 就能夠了:

好比咱們在後期增長了一種叫 「團購券」 的優惠券,咱們不須要修改原來定義的結構,直接:

case class GroupCoupon ( id: Long, baseInfo: BaseInfo, `type`: String, status: Int, dealDetail: String )複製代碼

而且在利用「模式匹配」的時候,咱們能夠像操做代數那樣:

coupon match {
  case c: CashCoupon => ???       // 咱們能夠直接在匹配完成以後使用 coupon
  case c: DiscountCoupon => ???
  case c: GiftCoupon => ???
  case c: GroupCoupon => ???
}

// 若是是咱們用 ADT 改造前的數據結構,那模式匹配就會變成:

coupon.`type` match {
  case Coupon.Type.CashType => ???      // 咱們只能使用 coupon.`type`
  case Coupon.Type.GiftType => ???
  case Coupon.Type.DiscountType => ???
  case Coupon.Type.GroupCoupon => ???
}複製代碼

經過本例,咱們能夠看到,利用 ADT 從新組織以後的數據結構減小了數據的冗餘,而且在使用「模式匹配」的時候更加清晰,在功能上也更增強大。

場景二

針對上面的優惠券,用戶在使用這些優惠券的時候,優惠券會存在不一樣的幾種狀態:

  1. 未領取

  2. 已領取但暫未使用

  3. 已使用

  4. 過時優惠券

  5. 無效優惠券

咱們如今想要根據這幾種不一樣的狀態渲染出不一樣的結果頁面,要獲得這幾種狀態,咱們一般會:

def fetched(c: Coupon, user: User) = {
  //根據coupon信息以及user信息去查詢用戶是否已經領取了這張優惠券
  ???
}

def used(c: Coupon, user: User) = {
  //根據coupon信息以及user信息去查詢用戶是否已經使用了這張優惠券
  ???
}

def isExpired(c: Coupon) = {
  //根據優惠券信息來判斷優惠券是否已通過期
  ???
}

def isAviable(c: Coupon) = {
  //根據優惠券信息來判斷優惠券是否已經失效
  ???
}複製代碼

咱們如今就利用這些狀態去渲染頁面:

def f(c: Coupon, user: User) = {
  if (!isAviable(coupon)) {
    if (!isExpired(coupon)) {
      if (used(coupon, user)) {
        //已使用的優惠券
        ???
      } else {
        if (fetched(coupon, user)) {
          //已領取但未使用的優惠券
          ???
        } else {
          //未領取的優惠券
          ???
        }
      }
    } else {
      //已過時的優惠券
      ???
    }
  } else {
    //已失效的優惠券
    ???
  }
}複製代碼

上面的代碼可以完成咱們的需求,可是,當優惠券的狀態變多的時候,該方法傳入的參數也會有所變化,「if-else」語句層級也會越多,很是容易出錯,同時代碼錶達的意思也沒那麼明確,可讀性極差。

因此咱們可否從新組織一下數據結構,使之可以利用「模式匹配」?

利用 ADT 從新組織:

分析:咱們在使用優惠券的時候無非就是判斷這幾種「狀態」,那咱們就利用 ADT 將這些狀態抽象化:

sealed trait CouponStatus {

  //每種狀態共用的一些信息
  val base: CouponStatusBase
}

case class CouponStatusBase ( coupon: Coupon, ... )

//未領取
case class StatusNotFetched ( base: CouponStatusBase ) extends CouponStatus

//已領取但未使用
case class StatusFetched ( base: CouponStatusBase, user: User ) extends CouponStatus

//已使用
case class StatusUsed ( base: CouponStatusBase, user: User ) extends CouponStatus

//過時優惠券
case class StatusExpired ( base: CouponStatusBase ) extends CouponStatus

case object StatusUnAvilable extends CouponStatus複製代碼

咱們利用 ADT 將「狀態」抽象化了,而且將每種「狀態」所須要使用到的數據所有構造在了一塊兒,那如今咱們再根據不一樣的「狀態」去渲染頁面就變成了:

def f(status: CouponStatus) = status match {
  case StatusNotFetched(base) => ???
  case StatusFetched(base, user) => ???
  case StatusUsed(base, user) => ???
  case StatusExpired(base) => ???
  case StatusUnAvilable => ???
}複製代碼

能夠看到經過用 ADT 抽象以後的數據結構在「模式匹配」的時候很是清晰,而且咱們將不一樣狀態下所須要的數據所有構造在了一塊兒,也使得咱們在模式匹配以後能夠直接利用 status 去使用這些數據,不用再經過方法去獲取了。

經過本例,咱們能夠發現,經過 ADT 能夠將數據「高度抽象」,使得數據的「具體信息」變得簡潔,同時「歸納能力」變得更強,數據更加「完備」。

延伸閱讀

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

相關文章
相關標籤/搜索