纯函数与领域模型

论坛 期权论坛 期权     
逸言   2019-6-9 22:13   918   0
  
  



逸言 | 逸派胡言

本文是函数式编程思想与领域建模的第二部分,重点讲解无副作用的纯函数与领域模型之间的关系。
纯函数
在函数范式中,往往使用纯函数(pure function)来表现领域行为。所谓“纯函数”,就是指没有副作用(side effects)的函数。《Scala函数式编程》认为常见的副作用包括:
  • 修改一个变量
  • 直接修改数据结构
  • 设置一个对象的成员
  • 抛出一个异常或以一个错误终止
  • 打印到终端或读取用户的输入
  • 读取或写入一个文件
  • 在屏幕上绘画

例如,读取花名册文件对内容进行解析获得收件人电子邮件列表的函数为:
  1. def parse(rosterPath: String): List[Email] = {
  2.     val lines = readLines(rosterPath)
  3.     lines.filter(containsValidEmail(_)).map(toEmail(_))
  4. }
复制代码
代码中的readLines()函数需要读取一个外部的花名册文件,这是引起副作用的一个原因。该副作用为单元测试带来了影响。要测试parse()函数,就需要为它事先准备好一个花名册文件,增加了测试的复杂度。同时,该副作用使得我们无法根据输入参数推断函数的返回结果,因为读取文件可能出现一些未知的错误,如读取文件错误,又或者有其他人同时在修改该文件,就可能抛出异常或者返回一个不符合预期的邮件列表。

要将parse()定义为纯函数,就需要分离这种副作用,函数的计算结果就不会受到任何内部或外部过程状态改变的影响。一旦去掉副作用,调用函数返回的结果就与直接使用返回结果具有相同效果,二者可以互相替换,这称之为“引用透明(referential transparency)”。引用透明的替换性可以用于验证一个函数是否是纯函数。假设客户端要根据解析获得的电子邮件列表发送邮件,解析的花名册文件路径为roster.txt。假定解析该花名册得到的电子邮件列表为:
  1. List(Email("liubei@dddcompany.com"), Email("guanyu@dddcompany.com"))
复制代码
如果parse()是一个纯函数,就需要遵循引用透明的原则,则如下函数调用的行为应该完全相同:
  1. // 调用解析方法
  2. send(parse("roster.txt"))
  3. // 直接调用解析结果
  4. send(List(Email("liubei@dddcompany.com"), Email("guanyu@dddcompany.com")))
复制代码
显然并非如此。后者传入的参数是一个电子邮件列表,而前者除了提供了电子邮件列表之外,还读取了花名册文件。函数获得的电子邮件列表不是由花名册文件路径决定的,而是由读取文件的内容决定。读取外部文件的这种副作用使得我们无法根据确定的输入参数推断出确定的计算结果。要将parse()改造为支持引用透明的纯函数,就需要分离副作用,即将产生副作用的读取外部文件功能推向parse()函数外部:
  1. def parse(content: List[String]): List[Emial] =
  2.     content.filter(containsValidEmail(_)).map(toEmail(_))
复制代码
现在,以下代码的行为就是完全相同的:
  1. send(parse(List("liubei, liubei@dddcompany.com", "noname", "guanyu, guanyu@dddcompany.com")))
  2. send(List(Email("liubei@dddcompany.com"), Email("guanyu@dddcompany.com")))
复制代码
这意味着改进后的parse()可以根据输入结果推断出函数的计算结果,这正是引用透明的价值。保持函数的引用透明,不产生任何副作用,是函数式编程的基本原则。如果说面向对象设计需要将依赖尽可能向外推,最终采用依赖注入的方式来降低耦合;那么,函数式编程思想就是要利用纯函数来隔离变化与不变,内部由无副作用的纯函数组成,纯函数将副作用向外推,形成由不变的业务内核与可变的副作用外围组成的结构:




具有引用透明特征的纯函数更加贴近数学中的函数概念:没有计算,只有转换。转换操作不会修改输入参数的值,只是基于某种规则把输入参数值转换为输出。输入值和输出值都是不变的(immutable),只要给定的输入值相同,总会给出相同的输出结果。例如我们定义add1()函数:
  1. def add1(x: Int):Int => x + 1
复制代码
基于数学函数的转换(transformation)特征,完全可以翻译为如下代码:
  1. def add1(x: Int): Int => x match {
  2.     case 0 => 1
  3.     case 1 => 2
  4.     case 2 => 3
  5.     case 3 => 4
  6.     // ...
  7. }
复制代码
我们看到的不是对变量x增加1,而是根据x的值进行模式匹配,然后基于业务规则返回确定的值。这就是纯函数的数学意义。

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




图中的andThen是Scala语言提供的组合子,它可以组合两个函数形成一个新的函数。Scala还提供了compose组合子,二者的区别在于组合函数的顺序不同。上图可以表现为如下Scala代码:
  1. sealed trait Fruit {
  2.     def weight: Int
  3. }
  4. case class Apple(weight: Int) extends Fruit
  5. case class Pear(weight: Int) extends Fruit
  6. case class Banana(weight: Int) extends Fruit
  7. val appleToPear: Apple => Pear = apple => Pear(apple.weight)
  8. val pearToBanana: Pear => Banana = pear => Banana(pear.weight)
  9. // 使用组合
  10. val appleToBanana = appleToPear andThen pearToBanana
复制代码
组合后得到的函数类型,以及对该函数的调用如下所示:
  1. scala> val appleToBanana = appleToPear andThen pearToBanana
  2. appleToBanana: Apple => Banana =
  3. scala> appleToBanana(Apple(15))
  4. res0: Banana = Banana(15)
复制代码
除了纯函数的组合性之外,函数式编程中的Monad模式也支持组合。我们可以简单地将一个Monad理解为提供bind功能的容器。在Scala语言中,bind功能就是flatMap函数。可以简单地将flatMap函数理解为是map与flattern的组合。例如,针对如下的编程语言列表:
  1. scala> val l = List("scala", "java", "python", "go")
  2. l: List[String] = List(scala, java, python, go)
复制代码
对该列表执行map操作,对列表中的每个元素执行toCharArray()函数,就可以把一个字符串转换为同样是Monad的字符数组:
  1. scala> l.map(lang => lang.toCharArray)
  2. 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函数:
  1. scala> l.flatMap(lang => lang.toCharArray)
  2. 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函数:
  1. class M[A](value: A) {
  2.     private def unit[B] (value : B) = new M(value)
  3.     def map[B](f: A => B) : M[B] = flatMap {x => unit(f(x))}
  4.     def flatMap[B](f: A => M[B]) : M[B] = ...
  5. }
复制代码
flatMap和map以及filter往往可以组合起来,实现更加复杂的针对Monad的操作。一旦操作变得复杂,这种组合操作的可读性就会降低。例如,我们将两个同等大小列表中的元素项相乘,使用flatMap与map的代码为:
  1. val ns = List(1, 2)
  2. val os = List(4, 5)
  3. val qs = ns.flatMap(n => os.map(o => n * o))
复制代码
这样的代码并不好理解。为了提高代码的可读性,Scala提供了for-comprehaension。它本质上是Monad的语法糖,组合了flatMap、map与filter等函数;但从语法上看,却类似一个for循环,这就使得我们多了一种可读性更强的调用Monad的形式。同样的功能,使用for-comprehaension语法糖就变成了:
  1. val qs = for {
  2.     n
  3.     if (order.orderItems isEmpty) Failure(s"Validation failed for order $order.id")
  4.     else Success(true)
  5. val checkCustomerStatus: Order => Validation[Order, Boolean] = order =>
  6.     Success(true)
  7. val checkInventory: Order => Validation[Order, Boolean] = order =>
  8.     Success(true)
  9. // 以下定义了计算订单的行为,皆为原子的纯函数
  10. val calculateTotalPrice: Order => Order = order =>
  11.     val total = totalPriceOf(order)
  12.     order.copy(totalPrice = total)
  13. val calculateDiscount: Order => Order = order =>
  14.     order.copy(discount = discountOf(order))
  15. val calculateShippingFee: Order => Order = order =>
  16.     order.copy(shippingFee = shippingFeeOf(order))
复制代码
这些纯函数是原子的、分散的、可组合的,接下来就可以利用纯函数与Monad的组合能力,编写满足业务场景需求的实现代码:

[code]val order = ...

// 组合验证逻辑
// 注意返回的orderValidated也是一个Validation Monad
val orderValidated = for {
    _
分享到 :
0 人收藏
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

积分:
帖子:
精华:
期权论坛 期权论坛
发布
内容

下载期权论坛手机APP