纯函数与领域模型

2019-06-05 17:05:35 浏览数 (1)

逸言 | 逸派胡言

本文是函数式编程思想与领域建模的第二部分,重点讲解无副作用的纯函数与领域模型之间的关系。

纯函数

在函数范式中,往往使用纯函数(pure function)来表现领域行为。所谓“纯函数”,就是指没有副作用(side effects)的函数。《Scala函数式编程》认为常见的副作用包括:

  • 修改一个变量
  • 直接修改数据结构
  • 设置一个对象的成员
  • 抛出一个异常或以一个错误终止
  • 打印到终端或读取用户的输入
  • 读取或写入一个文件
  • 在屏幕上绘画

例如,读取花名册文件对内容进行解析获得收件人电子邮件列表的函数为:

代码语言:javascript复制
def parse(rosterPath: String): List[Email] = {
    val lines = readLines(rosterPath)
    lines.filter(containsValidEmail(_)).map(toEmail(_))
}

代码中的readLines()函数需要读取一个外部的花名册文件,这是引起副作用的一个原因。该副作用为单元测试带来了影响。要测试parse()函数,就需要为它事先准备好一个花名册文件,增加了测试的复杂度。同时,该副作用使得我们无法根据输入参数推断函数的返回结果,因为读取文件可能出现一些未知的错误,如读取文件错误,又或者有其他人同时在修改该文件,就可能抛出异常或者返回一个不符合预期的邮件列表。

要将parse()定义为纯函数,就需要分离这种副作用,函数的计算结果就不会受到任何内部或外部过程状态改变的影响。一旦去掉副作用,调用函数返回的结果就与直接使用返回结果具有相同效果,二者可以互相替换,这称之为“引用透明(referential transparency)”。引用透明的替换性可以用于验证一个函数是否是纯函数。假设客户端要根据解析获得的电子邮件列表发送邮件,解析的花名册文件路径为roster.txt。假定解析该花名册得到的电子邮件列表为:

代码语言:javascript复制
List(Email("liubei@dddcompany.com"), Email("guanyu@dddcompany.com"))

如果parse()是一个纯函数,就需要遵循引用透明的原则,则如下函数调用的行为应该完全相同:

代码语言:javascript复制
// 调用解析方法
send(parse("roster.txt"))

// 直接调用解析结果
send(List(Email("liubei@dddcompany.com"), Email("guanyu@dddcompany.com")))

显然并非如此。后者传入的参数是一个电子邮件列表,而前者除了提供了电子邮件列表之外,还读取了花名册文件。函数获得的电子邮件列表不是由花名册文件路径决定的,而是由读取文件的内容决定。读取外部文件的这种副作用使得我们无法根据确定的输入参数推断出确定的计算结果。要将parse()改造为支持引用透明的纯函数,就需要分离副作用,即将产生副作用的读取外部文件功能推向parse()函数外部:

代码语言:javascript复制
def parse(content: List[String]): List[Emial] = 
    content.filter(containsValidEmail(_)).map(toEmail(_))

现在,以下代码的行为就是完全相同的:

代码语言:javascript复制
send(parse(List("liubei, liubei@dddcompany.com", "noname", "guanyu, guanyu@dddcompany.com")))

send(List(Email("liubei@dddcompany.com"), Email("guanyu@dddcompany.com")))

这意味着改进后的parse()可以根据输入结果推断出函数的计算结果,这正是引用透明的价值。保持函数的引用透明,不产生任何副作用,是函数式编程的基本原则。如果说面向对象设计需要将依赖尽可能向外推,最终采用依赖注入的方式来降低耦合;那么,函数式编程思想就是要利用纯函数来隔离变化与不变,内部由无副作用的纯函数组成,纯函数将副作用向外推,形成由不变的业务内核与可变的副作用外围组成的结构:

具有引用透明特征的纯函数更加贴近数学中的函数概念:没有计算,只有转换。转换操作不会修改输入参数的值,只是基于某种规则把输入参数值转换为输出。输入值和输出值都是不变的(immutable),只要给定的输入值相同,总会给出相同的输出结果。例如我们定义add1()函数:

代码语言:javascript复制
def add1(x: Int):Int => x   1

基于数学函数的转换(transformation)特征,完全可以翻译为如下代码:

代码语言:javascript复制
def add1(x: Int): Int => x match {
    case 0 => 1
    case 1 => 2
    case 2 => 3
    case 3 => 4
    // ...
}

我们看到的不是对变量x增加1,而是根据x的值进行模式匹配,然后基于业务规则返回确定的值。这就是纯函数的数学意义。

引用透明、无副作用以及数学函数的转换本质,为纯函数提供了模块化的能力,再结合高阶函数的特性,使纯函数具备了强大的组合(combinable)特性,而这正是函数式编程的核心原则。这种组合性如下图所示:

图中的andThen是Scala语言提供的组合子,它可以组合两个函数形成一个新的函数。Scala还提供了compose组合子,二者的区别在于组合函数的顺序不同。上图可以表现为如下Scala代码:

代码语言:javascript复制
sealed trait Fruit {
    def weight: Int
}
case class Apple(weight: Int) extends Fruit
case class Pear(weight: Int) extends Fruit
case class Banana(weight: Int) extends Fruit

val appleToPear: Apple => Pear = apple => Pear(apple.weight)
val pearToBanana: Pear => Banana = pear => Banana(pear.weight)

// 使用组合
val appleToBanana = appleToPear andThen pearToBanana

组合后得到的函数类型,以及对该函数的调用如下所示:

代码语言:javascript复制
scala> val appleToBanana = appleToPear andThen pearToBanana
appleToBanana: Apple => Banana = <function1>

scala> appleToBanana(Apple(15))
res0: Banana = Banana(15)

除了纯函数的组合性之外,函数式编程中的Monad模式也支持组合。我们可以简单地将一个Monad理解为提供bind功能的容器。在Scala语言中,bind功能就是flatMap函数。可以简单地将flatMap函数理解为是map与flattern的组合。例如,针对如下的编程语言列表:

代码语言:javascript复制
scala> val l = List("scala", "java", "python", "go")
l: List[String] = List(scala, java, python, go)

对该列表执行map操作,对列表中的每个元素执行toCharArray()函数,就可以把一个字符串转换为同样是Monad的字符数组:

代码语言:javascript复制
scala> l.map(lang => lang.toCharArray)
res7: List[Array[Char]] = List(Array(s, c, a, l, a), Array(j, a, v, a), Array(p, y, t, h, o, n), Array(g, o))

map函数完成了从List[String]到List[Array[Char]]的转换。对同一个列表执行相同的转换函数,但调用flatMap函数:

代码语言:javascript复制
scala> l.flatMap(lang => lang.toCharArray)
res6: List[Char] = List(s, c, a, l, a, j, a, v, a, p, y, t, h, o, n, g, o)

flatMap函数将字符串转换为字符数组后,还执行了一次拍平操作,完成了List[String]到List[Char]的转换。

然而在Monad的真正实现中,flatMap并非map与flattern的组合,相反,map函数是flatMap基于unit演绎出来的。因此,Monad的核心其实是flatMap函数:

代码语言:javascript复制
class M[A](value: A) { 
    private def unit[B] (value : B) = new M(value) 
    def map[B](f: A => B) : M[B] = flatMap {x => unit(f(x))} 
    def flatMap[B](f: A => M[B]) : M[B] = ... 
}

flatMap和map以及filter往往可以组合起来,实现更加复杂的针对Monad的操作。一旦操作变得复杂,这种组合操作的可读性就会降低。例如,我们将两个同等大小列表中的元素项相乘,使用flatMap与map的代码为:

代码语言:javascript复制
val ns = List(1, 2)
val os = List(4, 5)
val qs = ns.flatMap(n => os.map(o => n * o))

这样的代码并不好理解。为了提高代码的可读性,Scala提供了for-comprehaension。它本质上是Monad的语法糖,组合了flatMap、map与filter等函数;但从语法上看,却类似一个for循环,这就使得我们多了一种可读性更强的调用Monad的形式。同样的功能,使用for-comprehaension语法糖就变成了:

代码语言:javascript复制
val qs = for {
    n <- ns
    o <- os
} yield n * o

这里演示的for语法糖看起来像是一个嵌套循环,分别从ns和os中取值,然后利用yield生成器将计算得到的积返回为一个列表;实质上,这段代码与使用flatMap和map的代码完全相同。

在使用纯函数表现领域行为时,我们可以让纯函数返回一个Monad容器,再通过for-comprehaension进行组合。这种方式既保证了代码对领域行为知识的体现,又能因为不变性避免状态变更带来的缺陷。同时,结合纯函数的组合子特性,使得代码的表现力更加强大,非常自然地传递了领域知识。例如,针对下订单场景,需要验证订单,并对验证后的订单进行计算。验证订单时,需要验证订单自身的合法性、客户状态以及库存;对订单的计算则包括计算订单的总金额、促销折扣与运费。

在对这样的需求进行领域建模时,我们需要先寻找到表达领域知识的各个原子元素,包括具体的代数数据类型和实现原子功能的纯函数:

代码语言:javascript复制
// 积类型
case class Order(id: OrderId, customerId: CustomerId, desc: String, totalPrice: Amount, discount: Amount, shippingFee: Amount, orderItems: List[OrderItem])

// 以下是验证订单的行为,皆为原子的纯函数,并返回scalaz定义的Validation Monad
val validateOrder : Order => Validation[Order, Boolean] = order =>
    if (order.orderItems isEmpty) Failure(s"Validation failed for order $order.id") 
    else Success(true)

val checkCustomerStatus: Order => Validation[Order, Boolean] = order => 
    Success(true)

val checkInventory: Order => Validation[Order, Boolean] = order => 
    Success(true)

// 以下定义了计算订单的行为,皆为原子的纯函数
val calculateTotalPrice: Order => Order = order => 
    val total = totalPriceOf(order)
    order.copy(totalPrice = total)

val calculateDiscount: Order => Order = order => 
    order.copy(discount = discountOf(order))

val calculateShippingFee: Order => Order = order =>
    order.copy(shippingFee = shippingFeeOf(order))

这些纯函数是原子的、分散的、可组合的,接下来就可以利用纯函数与Monad的组合能力,编写满足业务场景需求的实现代码:

代码语言:javascript复制
val order = ...

// 组合验证逻辑
// 注意返回的orderValidated也是一个Validation Monad
val orderValidated = for {
    _ <- validateOrder(order)
    _ <- checkCustomerStatus(order)
    c <- checkInventory(order)
} yield c

if (orderValidated.isSuccess) {
    // 组合计算逻辑,返回了一个组合后的函数
    val calculate = calculateTotalPrice andThen calculateDiscount andThen calculateShippingFee
    // 返回具有订单总价、折扣与运费的订单对象
    // 在计算订单的过程中,订单对象是不变的
    val calculatedOrder = calculate(order)

    // ...
}

本文内容摘选自我在GitChat发布的文字课程《领域驱动战术设计实践》。

0 人点赞