函数式编程思想:以函数的方式思考,第3部分
过滤、单元测试和代码重用技术
译者:Elaine.Ye原文作者:Neal Ford
在函数式编程思想的第一部分和第二部分中, 我考察了一些函数式编程的主题,研究了这些主题如何与Java™及其相关语言产生关联。本篇文章继续这一探索过程,给出来自前面文章的数字分类器的一个 Scala版本,并会讨论一些颇具学术色彩的主题,比如说局部套用(currying)、部分应用(partial application)和递归等。
用Scala编写的数字分类器
我把数字分类器的Scala版本留在最后,这是因为它是在语法方面最不神秘的那一个,至少对于Java开发者来说是如此。(重述一下分类器的 需求要点:给定任何大于1的正整数,你需要把它归类为完美的(perfect)、富余的(abundant)或是缺乏的(deficient)。一个完美 的数字是这样的一个数,它的因子,但其自身不能做为因子,这些因子相加的总和等于该数字。富余数字的因子总和大于该数字,而缺乏数字的因子总和小于该数 字。)清单1给出了Scala版本。
清单1. 用Scala编写的数字分类器
package com.nealford.conf.ft.numberclassifier
object NumberClassifier {
def isFactor(number: Int, potentialFactor: Int) =
number % potentialFactor == 0
def factors(number: Int) =
(1 to number) filter (number % _ == 0)
def sum(factors: Seq[Int]) =
factors.foldLeft(0)(_ + _)
def isPerfect(number: Int) =
sum(factors(number)) - number == number
def isAbundant(number: Int) =
sum(factors(number)) - number > number
def isDeficient(number: Int) =
sum(factors(number)) - number < number
}
即使到目前为止你都从未见过Scala的话,这一段代码也应该是相当有可读性的。和前面一样,令人感兴趣的两个方法是factors()和 sum()。factors()方法用到了一个范围从1到目标数字的数字列表, 并在之上应用了Scala内置的filter()方法,右侧的代码块被用作过滤条件(也称为断言(predicate))。该代码块利用了Scala的隐式参数, 在不需要命名变量时,其允许使用一个未命名的占位符(_这一字符)。这要感谢Scala的语法的灵活性,因为你可以以调用运算符的方式来调用 filter()方法,如果你喜欢的话,(1 to number).filter((number % _ == 0))这种写法也是有效的。
sum()方法使用了现在已经熟悉了的左折叠(fold left,从左边开始折叠,折叠剩余部分)操作(在Scala中,作为foldLeft()方法实现)。在这一例子中,我不需要命名变量,因此我使用_来 作为占位符,这种做法利用了简单、清晰的语法来定义代码块。foldLeft()方法执行的任务与来自Functional Java库(参见资源一节)的有着相似命名的方法执行的任务相同。该方法在本系列的第一篇文章中给出:
1. 获得一个初始值,并且通过在列表中的第一个元素上的操作来合并该值。
2. 获得结果,然后在下一个元素上采用相同的操作。
3. 继续进行这一操作直到走完列表。
这是一个如何把累加运算一类的操作运用到数字列表上的通用版本:从零开始,加上第一个元素,获得结果,然后把结果与第二个元素相加,如此继续直到列表中的元素被加完。
单元测试
尽管我没有给出前面版本的单元测试,但所有的例子都有测试。Scala版本有一个名为ScalaTest的高效的单元测试库可用(参见资源一节)。清单2给出了首个单元测试,我编写该测试来验证清单1中的isPerfect()方法:
清单2. Scala版本的数字分类器的单元测试
@Test def negative_perfection() {
for (i <- 1 until 10000)
if (Set(6, 28, 496, 8128).contains(i))
assertTrue(NumberClassifier.isPerfect(i))
else
assertFalse(NumberClassifier.isPerfect(i))
}
不过像你一样,我也试着更多地以函数的方式来思考,清单2中的代码有两个方面困扰着我。首先,其通过遍历来做某些事情,这展示出来的是规则式的思 考方式;其次,我不喜欢这个一分为二全方位捕获的if语句。我要解决的是什么问题?我需要确保我的数字分类器不会把一个非完美的数字标识成完美的。清单3 给出了这一问题的解决方法,表述上有一点点的不同。
清单3. 完美数字分类的另一个测试
@Test def alternate_perfection() {
assertEquals(List(6, 28, 496, 8128),
(1 until 10000) filter (NumberClassifier.isPerfect(_)))
}
清单3断言从1到100,1000范围中的完美数字只有已知数字列表中的那些。函数式思考不仅扩展了你的代码,还扩展了你考虑测试的方式。
部分应用和局部套用
我所展示的过滤列表的函数式方法是跨函数式编程语言和库常见的,把代码作为参数来传递(例如清单3中的filter()方法),这种能力的使 用显示了以不同的方式来考虑代码的重用。如果你来自于一个传统的设计模式驱动的面向对象世界的话,可以比较一下这一方法和来自四人组(Gang of Four)的设计模式 (Design Patterns)一书(参见资源一节)中的模板方法(Template Method )设计模式。模板方法模式在基类中定义算法的骨架,使用抽象方法和重载来把个别细节推迟到子类中实现。通过使用组合,函数式方法允许你把功能传递给那些可以正确地应用这些功能的方法。
另一种实现代码重用的方式是通过局部套用(currying),这是以数学家Haskell Curry的名字来命名的(Haskell这一编程语言也是以他的名字命名),局部套用转换一个多参数函数,这样就可以把该函数当成一个单参数函数链来调用。与此密切相关的一种技术是部分应用(partial application),这是一种把一个固定值赋给函数的一个或多个参数的技术,由此而产生出另一个有着更小元数(arity)(函数的参数的个数)的函数。为了理解这其中的不同,先来研究一下清单4中的Groovy代码,该段代码用来说明局部套用:
清单4. Groovy中的局部套用
def product = { x, y -> return x * y }
def quadrate = product.curry(4)
def octate = product.curry(8)
println \"4x4: ${quadrate.call(4)}\"
println \"5x8: ${octate(5)}\"
在清单4中,我把product定义成一个接收两个参数的代码块。通过使用Groovy内置的curry()方法,我把product用作两个新 的代码块:quadrate和octate的构建块。Groovy把代码块的调用变得很容易:你可以显式地执行call()调用,或是使用所提供的语言层 面的语法糖,在代码块名称的后面放置一对包含了任意参数的括号(比如说像octate(5)这样)。
部分应用是一种效仿局部套用的范围更广泛一些的技术,其不仅限于产生出只有一个参数的函数。Groovy使用curry()方法来处理局部套用和部分应用两种情况,如清单5所示:
清单5. 部分应用和局部套用的对比,两者都使用了Groovy的curry()方法
def volume = { h, w, l -> return h * w * l }
def area = volume.curry(1)
def lengthPA = volume.curry(1, 1) //部分应用
def lengthC = volume.curry(1).curry(1) // 局部套用
println \"The volume of the 2x3x4 rectangular solid is ${volume(2, 3, 4)}\"
println \"The area of the 3x4 rectangle is ${area(3, 4)}\"
println \"The length of the 6 line is ${lengthPA(6)}\"
println \"The length of the 6 line via curried function is ${lengthC(6)}\"
清单5中的volume代码块使用大家熟知的公式来计算长方体的体积。然后我通过把volume的第一个维度(高度h)固定为1来创建了一个 area代码块。为了使用volume来作为构建块,构建返回线段的长度的代码块,我可以执行一个部分应用或是局部套用。lengthPA通过把头两个参 数的值都固定为1方式来使用部分应用,而lengthC则是两次运用局部套用来达成相同的效果。不同之处很细微,最终的结果是一样的。但是如果你在函数式 编程者的圈子内互换着使用局部套用和部分应用这两个术语的话,肯定会被人纠正。
函数式编程给你提供了新的、不同的构建块来达成与命令式语言使用其他机制来实现的相同的目标。这些构建块之间的关系是经过深思熟虑的。此前我展示 过作为代码重用机制的一种组合,对于可以把局部套用和组合这两种情况合并起来使用你应该不会感到惊讶。考虑一下清单6中的Groovy代码:
清单6. 部分应用的组合
def composite = { f, g, x -> return f(g(x)) }
def thirtyTwoer = composite.curry(quadrate, octate)
println \"composition of curried functions yields ${thirtyTwoer(2)}\"
在清单6中,我创建了一个composite代码块,该代码块组合了两个函数。通过使用这一代码块,我创建了一个ThirtyTwoer代码块,使用部分应用来把这两个方法组合在一起。
通过使用部分应用和局部套用,你可以实现与模板方法设计模式一类的机制相类似的目标。例如,你可以通过在adder代码块之上构建另一个代码块的方式来创建inrementer代码块,如清单7所示:
清单7. 不同的构建块
def adder = { x, y -> return x + y }
def incrementer = adder.curry(1)
println \"increment 7: ${incrementer(7)}\"
当然,Scala支持局部套用,正如清单8中这一来自Scala文档的代码段所说明的那样:
清单8. Scala中的局部套用
object CurryTest extends Application {
def filter(xs: List[Int], p: Int => Boolean): List[Int] =
if (xs.isEmpty) xs
else if (p(xs.head)) xs.head :: filter(xs.tail, p)
else filter(xs.tail, p)
def dividesBy(n: Int)(x: Int) = ((x % n) == 0)
val nums = List(1, 2, 3, 4, 5, 6, 7, 8)
println(filter(nums, dividesBy(2)))
println(filter(nums, dividesBy(3)))
}
清单8中的代码展示了如何实现一个被filter()方法使用的dividesBy()方法,我把一个匿名方法传递给filter()方式,使用 局部套用来把dividesBy()方法的第一个参数固定成被用来创建该代码块的值。在我传递这一代码块时,该代码块是我通过把目标数字作为参数传递而创 建的,这时Scala通过局部套用得到了一个新的函数。
通过递归来过滤
另一个与函数式编程密切相关的话题是递归(recursion), 它(根据Wikipedia)是一个“以一种自相似的方式来重复一些操作项的过程。”实际上,这是一种计算机科学化的遍历事物的方式,做法是调用与自身相 同的方法来遍历事物(始终要小心的一个地方是,要确保你有一个退出条件)。许多时候,递归带来了易于理解的代码,因为问题的核心是你需要一遍又一遍的做相 同的事情来递减列表中的内容。
考虑一下使用一种遍历方法来过滤列表。我接受一个过滤条件并在内容上做循环,把我不想要的元素过滤出去。清单9展示了使用Groovy实现的一个简单的过滤:
清单9. 使用Groovy实现的过滤
def filter(list, criteria) {
def new_list = []
list.each { i ->
if (criteria(i))
new_list << i
}
return new_list
}
modBy2 = { n -> n % 2 == 0 }
l = filter(1..20, modBy2)
println l
清单9中的filter()方法接受一个list和一个criteria参数(一个指明如何过滤该列表的代码块),然后在列表上进行迭代,如果某个项目符合断言的话就把它加入到新的列表中。
现在回过头来看一下清单8,这是Scala中的过滤功能的一个递归式的实现,其遵循了函数式语言中处理列表的一种常见模式。一种看待列表的方式 是,列表由两部分组成:列表的头一项(头部)和其他所有的项。许多的函数式语言都有一些特定的方法,它们使用这一惯用技法来遍历列表。filter()方 法首先查看列表是否为空——这是这一方法的极其重要的退出条件。如果列表为空的话,就简单地返回;否则的话,把断言条件(p)作为参数传递。如果这一条件 为真的话(这意味着我想把该项放入我的列表中),我返回一个新的列表,该列表由当前的头部和过滤后的列表的其余部分构成;如果断言条件失败的话,我返回的 新列表只是包含了过滤后的其余部分(除去了第一个元素)。Scala中的列表构造的运算符使得两种情况下的返回条件都很易于读懂,且很容易理解。
我猜你现在一点都没有用到递归——它甚至不再你的工具箱中。然而,部分原因是因为这样的一种事实,即大部分的命令式语言对递归的支持都很平淡无奇,这使其比本应该有的情况变得更加的难用。通过加入清晰的语法和支持,函数式语言把递归变成了一种简单的代码重用的可选方式。
结论
在系列的这一部分中,我继续考察函数式编程思想领域中的一些功能特性。巧合的是,这篇文章的大部分内容都是关于过滤方面的,展示了许多使用和 实现过滤的方法。但也不必太过奇怪,许多的函数范式都是围绕着列表来构建的,因为许多的编程最终都归结为处理一些事物的列表。所以创建出来的语言和框架都 有着重装备的列表处理设施,这是不无道理的。
在系列的下一部分中,我将会完成函数式编程范式这一旅程。