如何在 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