函数式思维: 运用函数式思维,第 2 部分

探索函数编程和控制

函数语言和框架可以让运行时来控制繁琐的编码细节,如遍历、并发和状态。但这并不意味着无法在需要的时候收回控制。以函数式思维的一个重要方面是知道放弃多少控制,以及何时放弃。

Neal Ford, 软件架构师, ThoughtWorks Inc.

Neal Ford 是一家全球性 IT 咨询公司 ThoughtWorks 的软件架构师和 Meme Wrangler。他的工作还包括设计和开发应用程序、教材、杂志文章、课件和视频/DVD 演示,而且他是各种技术书籍的作者或编辑,包括最近的新书 The Productive Programmer。他主要的工作重心是设计和构建大型企业应用程序。他还是全球开发人员会议上的国际知名演说家。请访问他的 Web 站点



2011 年 8 月 03 日

关于本系列

本系列文章旨在将您的思维方式向函数式思维方式调整,使您以全新的角度来思考常见问题,并提高您的日常编码工作。本系列介绍了函数式编程概念,函数式编程在 Java 语言中运行的框架、在 JVM 上运行的函数式编程语言、以及语言设计未来的一些方向。本系列主要面向了解 Java 以及其抽象层的工作方式,但缺乏函数式语言使用经验的开发人员。

在本系列的 第一期 中,我首先讨论函数编程的一些特点,并演示如何在 Java 和其他函数语言中体现这些观念。在本文中,我将继续讨论这些概念,讲解一级函数、优化和闭包。但本期的内在主题是控制:什么时候想要控制、什么时候需要控制、什么时候应该放弃控制。

一级(First-class)函数和控制

我在上一部分最后通过使用 Functional Java 库(见 参考资料),演示了用函数 isFactor()factorsOf() 方法实现数字分类器,如清单 1 所示:

清单 1. 数字分类器的函数版本
public class FNumberClassifier {

    public boolean isFactor(int number, int potential_factor) {
        return number % potential_factor == 0;
    }

    public List<Integer> factorsOf(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(factorsOf(number)) - number == number;
    }

    public boolean isAbundant(int number) {
        return sum(factorsOf(number)) - number > number;
    }

    public boolean isDeficiend(int number) {
        return sum(factorsOf(number)) - number < number;
    }
}

isFactor()factorsOf() 方法中,我停止了对框架循环算法的控制 — 它决定如何通过最好的方式遍历所有数字。如果框架(或者 — 如果您选择一门函数语言,如 Clojure 或 Scala — 语言)能优化底层实现,那么您就可以自动从中获益。尽管您一开始可能不愿放弃这么多控制,但要知道这是编程语言和运行时的普遍趋势:随着时代发展,开发人员会越来越远离那些平台能更有效处理的细节。我从不担心 JVM 的内存管理,因为平台可以让我忘了它。当然,有时候它也会让事情变得更复杂,但是对于您从日复一日的编码中获得的收益来说,这是值得的。函数语言结构,如高阶和一级函数,能让我对抽象的理解更进一步,让我更多地将精力放在代码能做什么 而不是怎么做 上。

即使使用 Functional Java 框架,在 Java 中以这种风格编程也很麻烦,因为这种语言并没有真正的这类语法和结构。在支持的语言中进行函数编码是什么样的呢?

Clojure 中的分类器

Clojure 是一种用于 JVM 的 Lisp(见 参考资料)。看看用 Clojure 编写的数字分类器,如清单 2 所示:

清单 2. 数字分类器的 Clojure 实现
(ns nealford.perfectnumbers)
(use '[clojure.contrib.import-static :only (import-static)])
(import-static java.lang.Math sqrt)

(defn is-factor?[factor number]
  (= 0 (rem number factor)))

(defn factors [number] 
  (set (for [n (range 1 (inc number)) :when (is-factor? n number)] n)))

(defn sum-factors [number] 
    (reduce + (factors number)))

(defn perfect?[number]
  (= number (- (sum-factors number) number)))

(defn abundant?[number]
  (< number (- (sum-factors number) number)))

(defn deficient?[number]
  (> number (- (sum-factors number) number)))

即使您不是熟练的 Lisp 开发人员,也能轻松读懂 清单 2 中的大多数代码 — 您可以学着从内向外读。例如,is-factor? 方法有两个参数,它判断 number 除以 factor 时余数是否等于零。同样,perfect?abundant?deficient? 方法也很容易理解,尤其是参考一下 清单 1 的 Java 实现之后。

sum-factors 方法使用内置的 reduce 方法。sum-factors 一次减少一个列表元素,它使用函数(本例中,是 +)作为每个元素的第一个参数。reduce 方法在几种语言和框架中会有不同的形式;清单 1foldLeft() 方法的 Functional Java 版本。factors 方法会返回一个数字列表,因此我一次处理一个数字,将每个元素加入和中,该和数就是 reduce 的返回值。您会看到,一旦您习惯了从高阶和一级函数的角度思考,您就能减少(一语双关)代码中无用的部分。

factors 方法可能看上去像一个随机符号集。但如果您理解了列表的含义,您就知道这么做是有道理的,这是 Clojure 中强大的列表操作功能之一。和之前一样,从内向外阅读 factors 就很容易理解。不要被这些混在一起的语言术语搞糊涂。Clojure 中的 for 关键词并不表示 for 循环。相反,将它当成是所有过滤和转换结构的来源。本例中,我让它过滤从 1 到(number + 1)范围的数字,使用 is-factor? 谓词(我在之前的 清单 2 中定义的 is-factor 方法 — 请注意一类函数的大量使用),返回匹配的数字。从此操作返回的是一组满足过滤标准的数字列表,我将其放入一个集合来删除重复值。

尽管学习一门新的语言很麻烦,但对于函数语言,当您了解其特点后,学起来就容易得多。

优化

转换到函数样式的收益之一就是能利用语言或框架提供的高阶函数。那么不想放弃控制的时候呢?我在之前的例子中,把遍历机制的内部行为比作内存管理器的内部运作:大多数时候,您会很高兴不用关心那些细节。但有时候您会关心这些问题,特别是在遇到优化或类似任务时。

在 “运用函数式思维,第 1 部分” 的数字分类器的两个 Java 版本中,我优化了确定因子的代码。原先是简单的使用取模运算符(%)的实现,它非常低效,它自己检查从 2 到目标数的每个数字,确定是否是因子。因子是成对出现的,可以通过这点来优化算法。例如,如果您查找 28 的因子,当您找到 2 时,那么同时会找到 14。如果您成对获取因子,您只需要检查到目标数的平方根即可。

在 Java 版本中很容易完成的实现似乎在 Functional Java 版本中很难做到,因为我无法直接控制遍历机制。但作为函数式思维的一部分,您需要放弃这种控制观念,学会用另一种控制。

我会以函数式思维重新说明原来的问题:过滤所有 1 到 number 的因子,只保留匹配 isFactor() 谓词的因子。其实现见清单 3:

清单 3. isFactor() 方法
public List<Integer> factorsOf(final int number) {
    return range(1, number+1).filter(new F<Integer, Boolean>() {
        public Boolean f(final Integer i) {
            return number % i == 0;
        }
    });
}

尽管看上去很优雅,但 清单 3 中的代码效率很低,因为它会检查每个数。在了解优化(成对获取因子,只检查到平方根)之后,重述问题如下:

  1. 过滤目标数的所有因子,从 1 到其平方根。
  2. 用这些因子除以目标数,以获得对称因子,并将它加入因子列表中。

记住了这个目标,我就可以用 Functional Java 库写出 factorsOf() 方法的优化版本,如清单 4 所示:

清单 4. 优化的因子查找方法
public List<Integer> factorsOfOptimzied(final int number) {
    List<Integer> factors = 
        range(1, (int) round(sqrt(number)+1))
        .filter(new F<Integer, Boolean>() {
            public Boolean f(final Integer i) {
                return number % i == 0;
            }});
    return factors.append(factors.map(new F<Integer, Integer>() {
                                      public Integer f(final Integer i) {
                                          return number / i;
                                      }}))
                                      .nub();
}

清单 4 中的代码是基于我之前讲过的算法,其中有一些独特的语法,这是 Functional Java 框架所必需的。首先,获取数的范围是从 1 到目标数的平方根加 1(确保能取到所有因子)。第二步,根据与之前版本一样的取模操作方法过滤结果,这些都包含在 Functional Java 代码段中。我将过滤后的列表放在 factors 变量中。第四步(从内到外阅读),获取因子列表,并执行 map() 函数,它在代码中对每个元素进行处理(将每个元素 映射到一个新值),从而产生一个新的列表。因子列表中包含到目标数平方根的所有因子;需要除以每个数以获得对称因子,而 map() 方法就是完成这个任务的。第五步,现在已经有了对称因子列表,我将它添加到原来的列表中。最后一步,有个情况我必需考虑,因子保存在 List 中,而不是 Set 中。List 方法对于这些类型操作很方便,但我的算法有个副作用,就是出现整数平方根时,会有重复。例如,如果目标数是 16,平方根的整部部分 4 会在因子列表中出现两次。为了能继续使用方便的 List 方法,我只要在最后调用 nub() 方法,它将删除所有重复值。

一般情况下在使用高级抽象,比如函数编程时,您不需要了解实现细节,但这并不意味这在必要的情况下,就无法了解。Java 平台大多数情况下不需要您知道底层内容,但如果您下定决心,您就可以了解到你想达到的层次的内容。同样,在函数编程结构中,您可以把细节留给抽象机制,在出现问题的时候才去关注它。

到目前为止所演示的 Functional Java 代码中,最精华的部分是代码的语法,它使用了泛型和匿名内部类作为伪代码段和闭包类型结构。闭包是函数语言的共有特征之一。为什么它们用处这么大?


闭包有什么特别之处?

闭包是一个会对它内部引用的所有变量进行隐式绑定的函数。换句话说,这个函数(或方法)会对它引用的所有内容关闭上下文。闭包经常会在函数语言和框架中用作可移动执行机制,会作为转换代码传递给高阶函数,如 map()。Functional Java 使用匿名内部闭包来模仿 “实际” 闭包行为,但不能一直这样做,因为 Java 不支持闭包。这意味着什么?

清单 5 是一个样例,演示了闭包的特别之处。这是用 Groovy 编写的,它通过代码段块机制支持闭包。

清单 5. Groovy 代码演示闭包
def makeCounter() {
  def very_local_variable = 0
  return { return very_local_variable += 1 }
}

c1 = makeCounter()
c1()
c1()
c1()
c2 = makeCounter()

println "C1 = ${c1()}, C2 = ${c2()}"
// output:C1 = 4, C2 = 1

makeCounter() 方法首先定义了一个具有适当名称的本地变量,然后返回使用该变量的代码段。请注意,makeCounter() 方法的返回类型是代码段,而不是值。代码段做的事就是增加本地变量的值并返回。我在代码中设置了显式 return 调用,这在 Groovy 中都是可选的,但如果不用,代码就更难懂了!

为了能使用 makeCounter() 方法,我将代码段指定为 C1 变量,然后调用三次。我使用了 Groovy 的语法糖(syntactic sugar)来执行此代码段,结果放置在代码段变量旁边的圆括号中。接下来,我再次调用 makeCounter(),将代码段的一个新实例指定为 C2。最后,我再次执行 C1C2。请注意,每个代码段都能追踪到 very_local_variable 的一个单独实例。这就是封闭的上下文 的含义。即使在方法内部定义本地变量,代码段仍会绑定到该变量,因为变量引用了代码段,这意味着在代码段可用时,变量必须能追踪到它。

在 Java 中实现相同操作的最简单的方法如清单 6:

清单 6. Java 中的 Counter
public class Counter {
    private int varField;

    public Counter(int var) {
        varField = var;
    }

    public static Counter makeCounter() {
        return new Counter(0);
    }

    public int execute() {
        return ++varField;
    }
}

Counter 类进行一些变化都可以,但您一定要自己管理状态。以上演示了为什么闭包体现了函数思想:让运行时管理状态。与其强迫自己处理字段创建和原始状态(包括在多线程环境中使用代码的可怕前景),还不如让语言或框架默默地管理状态。

在以后的 Java 版本中会有闭包(关于本话题的讨论已超出本文范围)。Java 中包含它们将有两项收益。首先,在改进语法的同时,大大简化框架和库作者的能力。第二,为所有在 JVM 上运行的语言提供一个底层的共同基础。即使很多 JVM 语言支持闭包,但它们都实现自己的版本,这造成在语言之间传递闭包非常麻烦。如果 Java 定义了一个单一格式,那么其他所有语言都能利用它。


结束语

回避对底层细节的控制是软件开发的普遍趋势。我们很高兴不用再操心垃圾回收、内存管理和硬件差异。函数编程代表了下一个抽象阶段:将更加琐碎的细节如遍历、并发性和状态尽可能留给运行时处理。这并不意味着您在需要的时候无法再控制它们 — 是您想要控制,而不是被迫控制。

在下一篇文章中,我将继续探讨 Java 及同类语言中的函数编程结构,我将会介绍 curryingpartial method application

参考资料

学习

获得产品和技术

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology
ArticleID=750922
ArticleTitle=函数式思维: 运用函数式思维,第 2 部分
publish-date=08032011