函数范式
REA的Ken Scambler认为函数范式的主要特征为:模块化(Modularity),抽象化(Abstraction)和可组合(Composability)。这三个特征可以帮助我们编写简单的程序。
通常,为了降低系统的复杂度,都需要将系统分解为多个功能的组成部分,每个组成部分有着清晰的边界。模块化的编码范式需要支持实现者能够轻易地对模块进行替换,这就要求模块具有隔离性,避免在模块之间出现太多的纠缠。函数范式以“函数”为核心,作为模块化的重要组成部分。函数范式要求函数均为没有副作用的纯函数(pure function)。在推断每个函数的功能时,由于函数没有产生副作用,就可以不考虑该函数当前所处的上下文,形成清晰的隔离边界。这种相互隔离的纯函数使得模块化成为可能。
函数的抽象能力不言而喻,因为它本质上是一种将输入类型转换为输出类型的转换行为。任何一个函数都可以视为一种转换(transform),这是对行为的最高抽象,代表了类型(type)之间的某种动作。极端情况下,我们甚至不用考虑函数的名称和类型,只需要关注其数学本质:f(x) = y。其中,x是输入,y是输出,f就是极度抽象的函数。
函数范式领域模型的核心要素为代数数据类型(Algebraic Data Type, ADT)和纯函数。代数数据类型表达领域概念,纯函数表达领域行为。由于二者皆被定义为不变的、原子的,因此在类型的约束规则下可以对它们进行组合。可组合的特征使得函数范式建立的领域模型可以由简单到复杂,利用组合子来表现复杂的领域逻辑。
代数数据类型
代数数据类型借鉴了代数学中的概念,作为一种函数式数据结构,体现了函数范式的数学意义。通常,代数数据类型不包含任何行为。它利用和类型(Sum Type)来展示相同抽象概念的不同组合,使用积类型(Product Type)来展示同一个概念不同属性的组合。
和与积是代数中的概念,它们在函数范式中体现了类型的两种组合模式。和就是加,用以表达一种类型是它的所有子类型之和。例如表达时间单位的TimeUnit类型:
sealed trait TimeUnit case object Days extends TimeUnit case object Hours extends TimeUnit case object Minutes extends TimeUnit case object Seconds extends TimeUnit case object MilliSeconds extends TimeUnit case object MicroSeconds extends TimeUnit case object NanoSeconds extends TimeUnit
在上述模型中,TimeUnit是对时间单位概念的一个抽象。定义为和类型,说明它的实例只能是以下的任意一种:Days、Hours、Minutes、Seconds、MilliSeconds、MicroSeconds或NanoSeconds。这是一种逻辑或的关系,用加号来表示:
type TimeUnit = Days + Hours + Minutes + Seconds + MilliSeconds + MicroSeconds + NanoSeconds
积类型体现了一个代数数据类型是其属性组合的笛卡尔积,例如一个员工类型:
case class Employee(number: String, name: String, email: String, onboardingDate: Date)
它表示Employee类型是(String, String, String, Date)组合的集合,也就是这四种数据类型的笛卡尔积,在类型语言中可以表达为:
type Employee = (String, String, String, Date)
也可以用乘号来表示这个类型的定义:
type Employee = String * String * String * Date
和类型和积类型的这一特点体现了代数数据类型的组合(combinable)特性。代数数据类型的这两种类型并非互斥的,有的代数数据类型既是和类型,又是积类型,例如银行的账户类型:
sealed trait Currency case object RMB extends Currency case object USD extends Currency case object EUR extends Currency case class Balance(amount: BigDecimal, currency: Currency) sealed trait Account { def number: String def name: String } case class SavingsAccount(number: String, name: String, dateOfOpening: Date) extends Account case class BilledAccount(number: String, name: String, dateOfOpening: Date, balance: Balance) extends Account
代码中的Currency被定义为和类型,Balance为积类型。Account首先是和类型,它的值要么是SavingsAccount,要么是BilledAccount;同时,每个类型的Account又是一个积类型。
代数数据类型与对象范式的抽象数据类型有着本质的区别。前者体现了数学计算的特性,具有不变性。使用Scala的case object或case class语法糖会帮助我们创建一个不可变的抽象。当我们创建了如下的账户对象时,它的值就已经确定,不可改变:
val today = Calendar.getInstance.getTime val balance = Balance(10.0, RMB) val account = BilledAccount("980130111110043", "Bruce Zhang", today, balance)
数据的不变性使得代码可以更好地支持并发,可以随意共享值而无需承受对可变状态的担忧。不可变数据是函数式编程中实践的重要原则之一,它可以与纯函数更好地结合。
代数数据类型既体现了领域概念的知识,同时还通过和类型和积类型定义了约束规则,从而建立了严格的抽象。例如类型组合(String, String, Date)是一种高度的抽象,但它却丢失了领域知识,因为它缺乏类型标签,如果采用积类型方式进行定义,则在抽象的同时,还约束了各自的类型。和类型在约束上更进了一步,它将变化建模在一个特定数据类型内部,并限制了类型的取值范围。和类型与积类型结合起来,与操作代数数据类型的函数放在一起,然后利用模式匹配来实现表达业务规则的领域行为。
我们以Robert Martin在《敏捷软件开发》一书中给出的薪资管理系统需求为例,利用函数范式的建模方式来说明代数数据类型的优势。需求描述如下:
公司雇员有三种类型。一种雇员是钟点工,系统会按照雇员记录中每小时报酬字段的值对他们进行支付。他们每天会提交工作时间卡,其中记录了日期以及工作小时数。如果他们每天工作超过8小时,超过部分会按照正常报酬的1.5倍进行支付。支付日期为每周五。月薪制的雇员以月薪进行支付。每个月的最后一个工作日对他们进行支付。在雇员记录中有月薪字段。销售人员会根据他们的销售情况支付一定数量的酬金(Commssion)。他们会提交销售凭条,其中记录了销售的日期和数量。在他们的雇员记录中有一个酬金报酬字段。每隔一周的周五对他们进行支付。
我们现在要计算公司雇员的薪资。从需求看,我们需要建立的领域模型是雇员,它是一个积类型。注意,需求虽然清晰地勾勒出三种类型的雇员,但实则它们的差异体现在收入的类型上,这种差异体现为和类型不同的值。于是,可以得到由如下代数数据类型呈现的领域模型:
// ADT定义,体现了领域概念 // Amount是一个积类型,Currency则为前面定义的和类型 calse class Amount(value: BigDecimal, currency: Currency) { // 实现了运算符重载,支持Amount的组合运算 def +(that: Amount): Amount = { require(that.currency == currency) Amount(value + that.value, currency) } def *(times: BigDecimal): Amount = { Amount(value * times, currency) } } // 以下类型皆为积类型,分别体现了工作时间卡与销售凭条领域概念 case class TimeCard(startTime: Date, endTimeDate) case class SalesReceipt(date: Date, amount: Amount) // 支付周期是一个隐藏概念,不同类型的雇员支付周期不同 case class PayrollPeriod(startDate: Date, endDate: Date) // Income的抽象表示成和类型与乘积类型的组合 sealed trait Income case class WeeklySalary(feeOfHour: Amount, timeCards: List[TimeCard], payrollPeriod: PayrollPeriod) extends Income case class MonthlySalary(salary: Amount, payrollPeriod: PayrollPeriod) extends Income case class Commission(salary: Amount, saleReceipts: List[SalesReceipt], payrollPeriod: PayrollPeriod) // Employee被定义为积类型,它组合的Income具有不同的抽象 case class Employee(number: String, name: String, onboardingDate: Date, income: Income)
在定义了以上由代数数据类型组成的领域模型之后,即可将其与领域行为结合起来。
例如计算每个雇员的收入,由于Income被定义为和类型,它表达的是一种逻辑或的关系,因此它的每个子类型(称为ADT变体)都将成为模式匹配的分支。和类型的组合有着确定的值(类型理论的术语将其称之为inhabitant),例如Income和类型的值为3,则模式匹配的分支就应该是3个,这就使得Scala编译器可以检查模式匹配的穷尽性。
如果模式匹配缺少了对和类型的值表示,编译器都会给出警告。倘若和类型增加了一个新的值,编译器也会指出所有需要新增ADT变体来更新模式匹配的地方。针对Income积类型,可以利用模式匹配结合业务规则对它进行解构,代码如下所示:
def calculateIncome(employee: Employee): Amount = employee.income match { case WeeklySalary(fee, timeCards, _) => weeklyIncomeOf(fee, timeCards) case MonthlySalary(salary, _) => salary case Commision(salary, saleReceipts, _) => salary + commistionOf(saleReceipts) }
利用模式匹配,针对Employee的特定Income类型可以计算雇员的不同收入。