学会像函数式编程者那样思考
英文原文:Learning to think like a functional programmer——Functional thinking: Thinking functionally, Part 1
学会像函数式编程者那样思考——函数式编程思想:以函数的方式来思考(第1部分)
让我们先来扯一下这样的一个话题,你是一个伐木工,在这森林中,你有着一把最好的斧头,这使得你成为了营地中最具生产效率的樵夫。后来有一天,有个家伙出现了,极力吹捧一种新的砍树工具的厉害之处,这种工具就是电锯。这卖东西的家伙很有说服力,因此你买了一把电锯,但你不知道它的工作方式。你试着举起它,并使用很大的力气在树上来回拉动,这就是你使用另外一种砍树工具的工作方式。你很快就断定这种新奇的电锯一类的玩意只不过是一种时髦的东西,于是你依旧使用斧头来把树砍倒。后来,有个家伙过来给你展示如何启动电锯。
你有可能就在这样的一个故事中出现过,不过是使用函数式编程(functional programming)代替了电锯。全新的编程范式的问题并不在于要学习一门新的语言,毕竟,语言的语法不过是细节,棘手的部分是要学会以一种不同的方式来思考。这就是我要开始的地方——电锯开动者和函数式编程者。
欢迎开始阅读函数式编程思想这一系列,该系列文章探讨了函数式编程这一主题,但内容并非是完全关于编程语言的,而是正如我将要说明的那样,以“函数”的方式来编写代码涉及了设计、权衡取舍、不同的可用构建块,以及在其他方面的许多领悟。我会尽可能用Java(或是类Java语言)来展示函数式编程的概念,并会转移到其他语言上以证明还未在Java语言中存在的功能。我不会仓促行事,马上讨论诸如monad(参见资源一节)一类时髦的东西(尽管我们到时会涉及这部分内容),相反,我们会慢慢给你展示一种新的思考问题的方式(其实你已经在某些地方用到了这一方式——只是你没有意识到罢了)。
这一部分和接下来的三部分内容可看作是在与函数式编程相关的一些主题中的一个旋风之旅,主题中包括了一些核心的概念。随着我在整个系列中构建出越来越多的上下文背景和细微差别,其中的一些概念会以更加细节化的方式再次出现。作为这一旅程的出发点,我会让你看到问题两种不同实现,一种是命令式的编写方式,另一种带有更多函数式的倾向。
数字分类器
要谈论不同的编程风格的话,就需要比较代码。我们的第一个例子是在我的书The Productive Programmer(参加资源一节)中, 以及在“Test-driven Design, Part 1”和“Test-driven design, Part 2“(我之前的developerWorks系列Evolutionary architecture and emergent design中的两部分)这两篇文章中给出的一个编码问题的一个变体。我选择这一代码至少部分原因是因为这两篇文章深入地描述了代码的设计,这一文章所赞同的设计不存在什么问题,不过我在这里会提供一种不同的设计理念。
需求的陈述是这样的,给定任何大于1的正数,你需要把它归类为完美的(perfect)、富余的(abundant)或是欠缺的(deficient)。一个完美数字是一个这样的数值,它的因子(数字本身不能作为因子)相加起来等于该数值。类似地,一个富余数字的因子之和大于该数字,而一个欠缺数字的因子之和小于该数字。
命令式的数字分类器
清单1给出一个满足这些需求的命令式的类:
清单1. 数字分类器,问题的命令式解决方案
public class Classifier6 {
private Set<Integer> _factors;
private int _number;
public Classifier6(int number) {
if (number < 1)
throw new InvalidNumberException(
"Can't classify negative numbers");
_number = number;
_factors = new HashSet<Integer>>();
_factors.add(1);
_factors.add(_number);
}
private boolean isFactor(int factor) {
return _number % factor == 0;
}
public Set<Integer> getFactors() {
return _factors;
}
private void calculateFactors() {
for (int i = 1; i <= sqrt(_number) + 1; i++)
if (isFactor(i))
addFactor(i);
}
private void addFactor(int factor) {
_factors.add(factor);
_factors.add(_number / factor);
}
private int sumOfFactors() {
calculateFactors();
int sum = 0;
for (int i : _factors)
sum += i;
return sum;
}
public boolean isPerfect() {
return sumOfFactors() - _number == _number;
}
public boolean isAbundant() {
return sumOfFactors() - _number > _number;
}
public boolean isDeficient() {
return sumOfFactors() - _number < _number;
}
public static boolean isPerfect(int number) {
return new Classifier6(number).isPerfect();
}
}
代码中的几件事情是值得关注一下的:
1. 它有许多的单元测试(部分原因是因为我写这一代码的目的是用于测试驱动的开发的讨论)。
2. 该类包含了许多内聚的方法,这是在构建过程中使用测试驱动开发的一个边际效应。
3. calculateFactors() 方法中内嵌了性能上的优化。该类的重要部分包括了因子的收集,这样接下来我就可以合计它们,最终对它们分类。因子总是可以成对获得,例如,如果问题中的数字是16,则当我取得因子2时,我也可以取得8,因为2x8=16。如果我是以成对方式获得因子的话,那么我只需要以目标数字的平方根为上限值来检查因子就可以了。而这正是calculateFactors()方法采用的做法。
(稍微)函数式的分类器
使用同样的测试驱动的开发技术,我创建了分类器的另一个版本,清单2给出了该版本:
清单2. 稍微函数化一点的数字分类器
public class NumberClassifier {
static public boolean isFactor(int number, int potential_factor) {
return number % potential_factor == 0;
}
static public Set<Integer> factors(int number) {
HashSet<Integer> factors = new HashSet<Integer>();
for (int i = 1; i <= sqrt(number); i++)
if (isFactor(number, i)) {
factors.add(i);
factors.add(number / i);
}
return factors;
}
static public int sum(Set<Integer> factors) {
Iterator it = factors.iterator();
int sum = 0;
while (it.hasNext())
sum += (Integer) it.next();
return sum;
}
static public boolean isPerfect(int number) {
return sum(factors(number)) - number == number;
}
static public boolean isAbundant(int number) {
return sum(factors(number)) - number > number;
}
static public boolean isDeficient(int number) {
return sum(factors(number)) - number < number;
}
}
这两个分类器版本之间的差异很细微但很重要。主要的差别在于清单2中有针对性地省去了一些共享的状态。在函数式编程中,共享状态的消除(或至少是减少)是被青睐的抽象之一。不是使用跨方法的共享状态来作为中间结果(参见清单1中的factors域),我直接调用方法,省去了状态的使用。从设计的角度来看,这导致了factors()方法变长,但它防止了factors从方法中”泄漏出去“。还要注意的一点是,清单2可以全部由静态方法构成。方法之间不存在要共享的知识,所以我不太需要通过划定作用域来封装。如果你给这些方法提供它们所预期的输入参数的话,这些方法完全能工作得很好。(这是一个纯函数(pure function)的例子,在后面的部分中,我会对这一概念做进一步研究。)
函数
函数式编程是计算科学中一个涉及广泛、正四处扩展的领域,已经可见到最近对它的兴趣正在爆炸式地增长。JVM上的新的函数式语言(比如说Scala和Lojure),以及框架(比如说Functional Java和Akka)都已出现(参见资源一节),随之而来的通常是这样的断言:更少出错、更具生产效率、更好的外观、赚取更多的钱等等。我不打算把函数式编程的整个主题都拿出来分析处理,我会把重点放在几个主要的概念上,并关注一些派生自这些概念的有趣的实现。
函数式编程的核心是函数(function),就像类是面向对象语言中的主要抽象一样。函数形成了处理过程的构建块,满带着一些传统的命令式语言(imperative language)中没有的功能特性。
高阶函数
高阶函数(Higher-order function)可以把其他函数当成参数,也可以把其他函数作为结果返回。我们在Java语言中没有加入这一构造,最接近的做法是,你可以使用类(通常是匿名类)来作为你需要执行的方法的“持有者”。Java没有独立的函数(或方法),所以它们不能从函数中返回,或是作为参数传递。
在函数式语言中,这一功能很重要,原因至少有两个。首先,有高阶函数意味着你可以假设语言的各个部分以什么方式来互相配合。例如,你可以通过一种通用的机制来把某个类层次结构中的一些方法别类整个地去掉,该机制遍历列表并在每个元素上应用一个(或多个)高阶函数。(很快我就会给你展示一个这一构造的例子。)其次,通过启用函数来作为返回值,你就有机会构建出高动态、可自适应的系统。
但是,适合用高阶函数来解决的问题不仅只取决于函数式语言,在你以函数的方式来思考时,你解决问题的方法也会有所不同。考虑一下清单3中的例子(从一个较大的代码库中拿来的),一个对受保护的数据进行访问的方法:
清单3. 潜在可重用的代码模板
public void addOrderFrom(ShoppingCart cart, String userName,
Order order) throws Exception {
setupDataInfrastructure();
try {
add(order, userKeyBasedOn(userName));
addLineItemsFrom(cart, order.getOrderKey());
completeTransaction();
} catch (Exception condition) {
rollbackTransaction();
throw condition;
} finally {
cleanUp();
}
}
清单3中的代码完成初始化,执行某些工作,如果一切都顺利的话就完成事务,否则回滚,最后清空资源。显然,这段代码的样板部分可以重用,我们在面向对象的语言中通常是通过创建结构来实现这一点。在这一例子中,我结合了四人组设计模式(Gang of Four Design Patterns)(参见资源一节)中的两种模式:模板方法(Template Method)模式和命令(Command)模式。按照模板方法模式的建议,我应该把共同的样板代码往继承的层次结构的顶部移动,把算法的细节推迟到子类中实现。命令设计模式提供了一种使用公认的执行语义来把行为封装在一个类中的方式。清单4给出了把这两种模式应用到清单3中的代码上的结果:
清单4. 重构的订单代码
public void wrapInTransaction(Command c) throws Exception {
setupDataInfrastructure();
try {
c.execute();
completeTransaction();
} catch (Exception condition) {
rollbackTransaction();
throw condition;
} finally {
cleanUp();
}
}
public void addOrderFrom(final ShoppingCart cart, final String userName,
final Order order) throws Exception {
wrapInTransaction(new Command() {
public void execute() {
add(order, userKeyBasedOn(userName));
addLineItemsFrom(cart, order.getOrderKey());
}
});
}
在清单4中,我把代码中的通有部分提取出来放入wrapInTransaction()方法(其语义你应该认得——基本上是Spring的TransactionTemplate的一个简易版)中,把Command对象作为工作的单元传入。addOrderFrom()方法里折叠放置了一个匿名的内部类的定义,该类创建命令类,把两项工作包装了起来。
把所需的行为包装在一个命令类中纯粹是一种Java的设计工件,这其中不包含任何类型的独立行为,Java中的所有行为都必须驻留在类的内部。即使是语言的设计者也很快看出了这一设计中的缺陷——事后再想,认为不会存在不与类相关的行为的这种想法是有点天真。JDK 1.1通过加入匿名的内部类来纠正了这一缺陷,这至少是提供了一种语法糖,用于创建许多小的类,这些类只是有着些一些纯粹是功能性的而非结构性的方法。关于Java的这一方面,很有一些热衷于搞笑而又不乏幽默的文章,可以看一看Steve Yegge的“Execution in the Kingdom of Nouns”(参见资源一节)。
Java强制我创建了一个Command类的实例,即使我真正想要的不过是类中的方法而已。类本身不提供什么好处:其没有域、没有构造函数(除了Java自动生成的那个之外),也没有状态。其纯粹是充当方法内部的行为的包装器而已。在函数式语言中,这会通过高阶函数的处理加以代替。
如果愿意暂时把Java语言搁在一边的话,则我可以使用闭包(closure),闭包是一种接近函数式编程想法的语义。清单5给出了同样重构过的例子,不过使用的是Groovy(参见资源一节)而不是Java。
清单5. 使用Groovy闭包而不是命令类
def wrapInTransaction(command) {
setupDataInfrastructure()
try {
command()
completeTransaction()
} catch (Exception ex) {
rollbackTransaction()
throw ex
} finally {
cleanUp()
}
}
def addOrderFrom(cart, userName, order) {
wrapInTransaction {
add order, userKeyBasedOn(userName)
addLineItemsFrom cart, order.getOrderKey()
}
}
在Groovy中,花括号{}中的任何东西都是一个代码块,代码块可被当作参数传递,模仿高阶函数的功能。背地里,Groovy为你实现了命令设计模式。Groovy中的每个闭包块实际上是Goovy闭包类型的一个实例,其中包含了一个call()方法,当你在持有闭包实例的变量后面放置一对空的括号时,该方法就会被自动调用。Groovy启用了一些类函数式编程的行为,做法是其使用相应的语法糖来把适当的数据结构加入到语言自身中。正如我将要在接下来的各部分中展示的那样,Groovy还包含了其他的一些超越了Java的函数式编程功能。在系列的后面某个部分中,我还会回头再谈闭包和高阶函数之间的一些有趣的比较。
第一类函数
函数式语言中的函数被看作是第一类(first class)的,这意味着函数可以在出现在任何其他的语言构造(比如说变量)能够出现的地方。第一类函数的出现允许我们以非预期的方式来使用函数,并迫使我们以不同的方式来思考解决方法,比如说在普通的数据结构上采用相对通用的操作(有着稍有差别的细节)。而这相应地又暴露出了函数式语言思想方面的一个基本转变:注重结果而非步骤(focus on results, not steps)。
在命令式编程语言中,我必须考虑算法中的每一个原子步骤,清单1中的代码说明了这一点。为了解决数字分类器的问题,我必须要明确如何收集因子,而这相应地又意味着我不得不编写具体的代码来循环遍历数字来判断因子。但是循环遍历列表,在每个元素上进行操作,这听起来确实是一种常见的事情。考虑一下清单6中给出的、使用了Functional Java框架来重新实现的数字分类代码:
清单6. 函数式的数字分类器
public class FNumberClassifier {
public boolean isFactor(int number, int potential_factor) {
return number % potential_factor == 0;
}
public List<Integer> factors(final int number) {
return range(1, number+1).filter(new F<Integer, Boolean>() {
public Boolean f(final Integer i) {
return number % i == 0;
}
});
}
public int sum(List<Integer> factors) {
return factors.foldLeft(fj.function.Integers.add, 0);
}
public boolean isPerfect(int number) {
return sum(factors(number)) - number == number;
}
public boolean isAbundant(int number) {
return sum(factors(number)) - number > number;
}
public boolean isDeficiend(int number) {
return sum(factors(number)) - number < number;
}
}
清单6和清单2的主要区别在于两个方法:sum()和factors()。sum()方法利用了Functional Java中的List类的一个方法foldLeft(),这是被称作风化变质作用(catamorphism)的列表操纵概念的一种具体变化,这一概念指的是列表折叠的一种泛化。在这一例子中,“折叠剩余部分(fold left)”是指:
1. 获得一个初始值,并且通过在列表中的第一个元素上的操作来合并该值。
2. 获得结果,然后在下一个元素上采用相同的操作。
3. 继续进行这一操作直到走完列表。
可以注意到,这正是你在合计列表中的数字时要做的事情:从零开始,加上第一个元素,获得结果,接着把它和第二个元素相加,一直这样做直到列表中元素被用完。Functional Java提供高阶函数(在这一例子中是 Integers.add这一枚举)并且帮你很好地应用它。(当然,Java并不是真的有高阶函数,但是如果把它限定到某种具体的数据结构或是类型上的话,则你能够写一个很好的模拟体出来。)
清单6中另一个有些神秘的方法是factors(),该方法例证了我的“注重结果而非步骤”的建议。找出数字的因子的问题实质是什么?换一种表述方式,给定以一个目标数字为上限的所有可能数字的一个列表,如何确定哪些数字是该目标数字的因子?这暗示了一种过滤操作——我可以过滤整个列表中的数字,去掉不符合我的标准的那些。该方法读起来就是这样的一种描述:取得从1到我的数字的一个数字范围(范围是开区间的,因此+1);基于f()方法中的代码来过滤列表,这是Functional Java允许你使用具体的数据类型来创建类的方式;然后返回值。
作为编程语言的一个大趋势,这段代码还说明了一个更大的概念。在过去,开发者需要处理各种各样烦人的事情,比如说内存分配、垃圾收集以及指针等。随着时间的过去,久而久之,语言负责起了更多这方面的责任。随着计算机变得越来越强大,我们把越来越多的单调的(可自动化的)任务卸给了语言和运行时。作为一个Java开发者,我已经相当习惯于把所有的内存问题都丢给了语言。函数式语言扩充了这样的授权,包揽起更多具体的细节。随着时间的推移,我们会花费越来越少的时间来考虑需要用来解决问题的步骤,而会把越来越多地思考放在处理过程方面。随着这一文章系列的进展,我会给出许多这方面的例子。
结论
函数式编程更多的是一种思维模式,而不仅是工具或是语言的一个特殊集合。在文章系列的这第一部分中,我先论及一些函数式编程中的主题,从简单的设计决策到一些颇具挑战性的问题反思都有涵盖到。我重写了一个简单的Java类,以让它变得更函数化一些,然后开始进入一些主题,通过使用传统的命令式语言来突出函数式编程的不同。
这里先给出了两个很重要的、有长远影响的概念。第一个是,注重结果而非步骤。函数式编程尝试以不同的方式来表现问题,因为你用的是一些不同的构建块,这些构建块培植出了一些解决方案。我在整个系列中要说明的第二个趋势是,枯燥无味的的细节会被卸给编程语言和运行时,这就允许我们把重点放在编程问题的一些独特方面上。在系列的下一部分中,我会继续考虑函数式编程的一些常见方面的问题,以及研究如何把它应用到现时的开发上。
资源
学习资料
1. The Productive Programmer(Neal Ford,O'Reilly Media,2008):Neal Ford的最新著作进一步阐述了这一系列中的许多主题。
2. Monads:Monads是函数式编程语言中一个传奇式的颇为恐怖的主题,在这一系列的后续部分中会提及。
3. Scala:Scala是一种现代的、位于JVM之上的函数式语言。
4. Clojure:Clojure是一种现代的、运行在JVM上的函数式Lisp语言。
5. Podcast: Stuart Halloway on Clojure:更多地了解Clojure,关于为什么它会被迅速采用以及在普及率方面快速飙升,在这里你可以找出两个主要的原因。
6. Akka:Akka是一个Java框架,其允许复杂的基于参与者的并发。
7. Functional Java:Functional Java是一个框架,其为Java加入了许多的函数式语言构造。
8. “Execution in the Kingdom of Nouns”(Steve Yegge,March 2006):关于Java语言设计的某些方面的一些戏谑之言。
9. 浏览technology bookstore来查找一些关于这些和另外一些技术主题的书籍。
10. developerWorks Java technology zone:可以找到几百篇关于Java编程的各个方面的文章。
讨论
加入 developerWorks社区。