函数式思维: Groovy 中的函数式特性,第 2 部分

元编程 + Functional Java

凭借 Groovy,元编程 (metaprogramming) 和函数式编程形成了一个强有力的组合。了解元编程如何支持您为 Integer 数据类型添加方法,从而使您能够利用 Groovy 的内置函数式特性。学习如何使用元编程将 Functional Java™ 框架的丰富函数式特性集无缝地整合到 Groovy 中。

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

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



2012 年 2 月 13 日

关于本系列

本系列的目标是重新调整您对函数式思维的认识,帮助您以全新的方式看待常见问题,并提升您的日常编码能力。本文探讨了函数式编程概念、允许在 Java 语言中进行函数式编程的框架、在 JVM 上运行的函数式编程语言,以及语言设计的未来方向。本系列面向那些了解 Java 及其抽象工作原理,但对函数式语言不甚了解的开发人员。

第 1 部分 中,我介绍了 Groovy 中一些已有的函数式特性,以及如何使用 Groovy 的原语来构建无限列表 (infinite list)。在这一部分中,我会继续探讨函数式编程和 Groovy 的交集。

Groovy 是一种多范式语言:它支持面向对象、元编程和函数式编程风格,这些风格几乎都是彼此正交的(参见侧边栏上的 正交性)。元编程支持您为语言及其核心库添加特性。通过将元编程与函数式编程结合在一起,您可以将自己的代码变得更加函数化,或者在 Groovy 中增加第三方函数库,让它们发挥更大的作用。我将首先介绍 Groovy 的 ExpandoMetaClass 添加类的方式,然后介绍如何使用这一机制将 Functional Java 库(参见 参考资料)整合到 Groovy 中。

通过 ExpandoMetaClass 使用开放类

正交性(Orthogonality)

正交的定义跨越了许多学科,其中包括数学和计算机科学。在数学中,两个互成直角的向量是正交的,即它们永不会相交。在计算机科学中,正交的组件彼此之间不会产生任何的影响(或是边际效应)。例如,Groovy 中的函数式编程和元编程是正交的,因为它们不会彼此干预:使用元编程并不会限制您使用函数式构造,反之亦然。虽然它们是正交关系,但这并不意味着它们不能放在一起使用,它们只是不会互相干预对方的使用而已。

Groovy 的一个更加强大的功能是开放类(open class),即重新打开现有类来增加或是删除其功能的能力。这与子类化不同,子类化是从一个现有类中派生出新的类。开放类允许您重新打开一个类(比如 String)并为它添加新的方法。测试库大量使用这一功能来为 Object 添加验证方法,这样,应用程序中的所有类现在都有了验证方法。

Groovy 有两种开放类技术:categories 和 ExpandoMetaClass(参阅 参考资料)。这两种技术都适用于本示例;我选择 ExpandoMetaClass 是因为它的语法更简单一些。

如果您一直在关注本系列的话,那么您可能熟悉我一直在使用的数字分类示例。使用 Groovy 编写的完整的 Classifier,如清单 1 所示,使用了 Groovy 自己的函数式构造:

清单 1. 使用 Groovy 编写的完整的 Classifier
class Classifier {
  def static isFactor(number, potential) {
    number % potential == 0;
  }

  def static factorsOf(number) {
    (1..number).findAll { i -> isFactor(number, i) }
  }

  def static sumOfFactors(number) {
    factorsOf(number).inject(0, {i, j -> i + j})
  }

  def static isPerfect(number) {
    sumOfFactors(number) == 2 * number
  }

  def static isAbundant(number) {
    sumOfFactors(number) > 2 * number
  }

  def static isDeficient(number) {
    sumOfFactors(number) < 2 * number
  }

  static def nextPerfectNumberFrom(n) {
    while (!isPerfect(++n));
    n
  }
}

如果有任何关于在这一版本中如何实现方法的问题,请参考之前的文章(具体地讲,是 "函数式思维:耦合和组合,第 2 部分" 和 "Groovy 中的函数式特性,第 1 部分")。要使用此类的方法,我可以按照 “正常的” 的函数式方法来调用这些方法:Classifier.isPerfect(7)。但是,在使用元编程时,我可以直接将这些方法 “捆绑” 到 Integer 类中,并允许 “询问” 某个数字是属于哪个类别的。

要将这些方法添加到 Integer 类,我访问该类的 metaClass 属性,该属性是 Groovy 为每个类预定义好的,如清单 2 所示:

清单 2. 为 Integer 添加分类
Integer.metaClass.isPerfect = {->
  Classifier.isPerfect(delegate)
}

Integer.metaClass.isAbundant = {->
  Classifier.isAbundant(delegate)
}

Integer.metaClass.isDeficient = {->
  Classifier.isDeficient(delegate)
}

初始化元编程方法

在第一次尝试调用元编程方法之前,必须先添加它们。初始化元编程方法的最安全位置是在使用它们的类的静态初始化程序中(因为这保证了在类的其他初始化程序之前运行它们),但是,当多个类都需要添加方法时,这一方法会增加复杂性。通常,用到许多元编程的应用程序最终都会使用某个引导类来确保在适当的时候进行了初始化。

清单 2 中,我为 Integer 添加了三个 Classifier 方法。现在,Groovy 中的所有整数都有三个方法。(Groovy 没有原始数据类型的概念;甚至 Groovy 中的常数都是使用 Integer 作为基础数据类型。)在定义每个方法的代码块中,我访问了预定义的 delegate 参数,该参数代表了调用类方法的对象的值。

在初始化了我的元编程方法之后(参阅侧边栏上的 初始化元编程方法),就可以 “询问” 数字的类别了,如清单 3 所示:

清单 3. 使用元编程对数字进行分类
@Test
void metaclass_classifiers() {
  def num = 28
  assertTrue num.isPerfect()
  assertTrue 7.isDeficient()
  assertTrue 6.isPerfect()
  assertTrue 12.isAbundant()
}

清单 3 说明了新添加的方法在变量和常量上都能够正常工作。现在为 Integer 添加一个返回特定数字的分类的方法已经不是什么难事了,也许和枚举一样简单。

为现有类添加新方法本身并不是特别的 “函数化”,尽管它们调用的代码有着强烈的函数式风格。但是,无缝添加方法的能力使整合到第三方库(比如 Functional Java 库)中变得非常简单,并添加了一些重要的函数式特性。我曾在 运用函数式思维,第 2 部分 中使用 Functional Java 库实现了数字分类器,我会在这里使用它来创建一个无限的完全数字流。


使用元编程映射数据类型

Groovy 本质上是 Java 的一个特殊化版本,所以引入 Functional Java 等第三方库并非难事。但是,我可以通过在数据类型之间执行一些元编程映射来深入整合这些库,从而使接缝变得不那么明显。Groovy 有一种原生的闭包类型(使用 Closure 类)。Functional Java 还没有奢侈到使用闭包的地步(它依赖于 Java 5 的语法),这就迫使作者使用泛型和包含了一个 f() 方法的泛类 F。利用 Groovy 的 ExpandoMetaClass,我可以通过在方法和闭包之间创建映射方法来解决这两者的类型差异问题。

我打算添加的类是 Functional Java 的 Stream 类,该类提供了无限列表的一个抽象。我希望传递 Groovy 的闭包来代替 Functional Java 的 F 实例,因此为 Stream 类添加了许多的方法,以便将闭包映射到 Ff() 方法,如清单 4 所示:

清单 4. 使用 ExpandoMetaClass 映射数据类型
Stream.metaClass.filter = { c -> delegate.filter(c as fj.F) }
//    Stream.metaClass.filter = { Closure c -> delegate.filter(c as fj.F) }
Stream.metaClass.getAt = { n -> delegate.index(n) }
Stream.metaClass.getAt = { Range r -> r.collect { delegate.index(it) } }

第一行在 Stream 上创建了一个接受闭包(代码块的 c 参数)的 filter() 方法。(注释的)第二行与第一行相同,但是添加了 Closure 的类型声明;这不会影响 Groovy 的代码执行方式,但用作文档的说明倒是不错的。代码块的主体内容调用 Stream 预先就存在的 filter() 方法,将 Groovy 闭包映射到 Functional Java 的 fj.F 类。我使用 Groovy 的神奇的 as 运算符来执行映射。

Groovy 的 as 运算符将闭包强制加入到接口定义中,允许闭包方法映射到接口所需的方法。请考虑一下清单 5 中的代码:

清单 5. 使用 as 来创建轻量级的迭代器
def h = [hasNext : { println "hasNext called"; return true}, 
         next : {println "next called"}] as Iterator
                                                          
h.hasNext()
h.next()
println "h instanceof Iterator? " + (h instanceof Iterator)

清单 5 中的示例中,我创建了一个有着两个 “名称-值” 对的哈希表。每个名称都是一个字符串(Groovy 不要求使用双引号来分隔哈希键,因为它们默认就是字符串),值则是代码块。as 运算符将这一哈希表映射到 Iterator 接口,该接口要求必须要有 hasNext()next() 方法。一旦执行了映射,就可以将哈希表作为一个迭代器来使用;清单中的最后一行输出 true。在有单一方法的接口的情况下,或是在希望接口中的所有方法都映射成单个闭包的情况下,我可以省去哈希表,直接使用 as 将闭包映射到某个函数上。回过头来看一下 清单 4 中的第一行,我将传递进来的闭包映射到只有一个方法的 F 类。 在 清单 4 中,我必须映射两个 getAt 方法(一个接受数字而另一个接受一个 Range),因为 filter 需要用到这些方法来进行操作。

使用这一新添加的 Stream,我可以使用一个无限序列来进行相关操作,如清单 6 所示:

清单 6. 在 Groovy 中使用无限的 Functional Java 流
@Test
void adding_methods_to_fj_classes() {

  def evens = Stream.range(0).filter { it % 2 == 0 }
  assertTrue(evens.take(5).asList() == [0, 2, 4, 6, 8])
  assertTrue(evens[3..6] == [6, 8, 10, 12])
}

清单 6 中,我创建了偶数的一个无限列表,从 0 开始,使用一个闭包块来过滤它们。您不能一下子取得无限序列中的所有元素,因此必须要使用 take() 获取所需数目的元素。清单 6 的余下部分内容给出了测试断言,这些断言说明了该流的工作方式。


Groovy 中的无限流

函数式思维:Groovy 中的函数式特性,第 1 部分 中,我介绍了如何在 Groovy 中实现一个无限的惰性列表。与其手动创建该列表,为什么不依靠 Functional Java 的无限序列来实现此操作呢?

为了创建一个由完全数构成的无限 Stream,我需要其他两种 Stream 方法映射来了解 Groovy 闭包,如清单 7 所示:

清单 7. 完全数字流的其他两种方法映射
Stream.metaClass.asList = { delegate.toCollection().asList() }
Stream.metaClass.static.cons = { head, closure -> delegate.cons(head, closure as fj.P1) }
// Stream.metaClass.static.cons = 
//  { head, Closure c -> delegate.cons(head, ['_1':c] as fj.P1)}

清单 7 中,我创建了一个 asList() 转换方法,使将 Functional Java 流转换为列表变得非常简单。我实现的另一种方法是重载 cons(),该方法是构建新列表的 Stream 上的方法。当创建无限列表时,数据结构通常包含首个元素和一个作为列表尾部的闭包块,该闭包块在调用时生成下一个元素。就我的 Groovy 完全数字流而言,我需要 Functional Java 知道 cons() 可以接受一个 Groovy 闭包。

如果我使用 as 来将单个闭包映射到有着多个方法的接口上的话,则在我调用接口中的任何方法时,都会执行该闭包。对于 Functional Java 类来说,这种简单映射类型在大多数情况下都是可行的。但是,少数几个方法要求使用 fj.P1 方法而不是 fj.F 方法。在其中的某些情况下,我仍然能够使用一个简单的映射来逃脱这种限制,因为下游的方法并不依赖 P1 的其他任何方法。在有着更高精确性要求的情况下,我可能不得不使用 清单 7 的注释行中给出的更复杂的映射,该做法必须使用 _1() 方法来创建一个映射到闭包上的哈希表。尽管该方法看起来有些奇怪,但它是 fj.P1 类的一个标准方法,返回首个元素。

一旦在 Stream 上拥有了元编程方式映射的方法,我就可以使用 清单 1 中的 Classifier 来创建一个无限的完全数字流,如清单 8 所示:

清单 8. 使用 Functional Java 和 Groovy 实现的完全数的无限流
import static fj.data.Stream.cons
import static com.nealford.ft.metafunctionaljava.Classifier.nextPerfectNumberFrom

def perfectNumbers(num) {
  cons(nextPerfectNumberFrom(num), { perfectNumbers(nextPerfectNumberFrom(num))})
}

@Test
void infinite_stream_of_perfect_nums_using_functional_java() {
  assertEquals([6, 28, 496], perfectNumbers(1).take(3).asList())
}

我使用静态导入的方式导入了 Functional Java 的 cons() 和我自己的 nextPerfectNumberFrom() 方法,从而让代码变得更简洁一些。perfectNumbers() 方法返回一个无限的完全数序列,其做法是预先附加(没错,cons 是一个动词)初始数字之后的首个完全数字,然后加入一个闭包块来作为第二个元素。该闭包块返回的无限序列以下一个数字作为头部,以计算另一个完全数字的闭包作为尾部。在测试代码中,我从 1 开始生成一个完全数字串,获取接下来的三个完全数字,并断言它们和列表中列出的数字相吻合。


结束语

当开发人员在考虑使用元编程时,他们通常只会考虑自己的代码,而不会想到往其他人的代码中添加内容。Groovy 不仅允许我往诸如 Integer 一类的内置类中添加新的方法,而且允许我往 Functional Java 一类的第三方库中添加新方法。将元编程和函数式编程组合起来,只需非常少的代码就可以实现非常强大的功能,这种组合创造出了一种无缝的链接。

尽管我可以在 Groovy 中直接调用 Functional Java 类,但相比于真正的闭包,许多库构建块都显得过于蠢笨。在使用元编程时,我可以通过映射 Functional Java 方法,让它们理解便利的 Groovy 数据结构,从而使双方都获得了最好的效用。在 Java 定义原生闭包类型之前,开发人员常常需要在一些语言类型之间执行这样的多语言映射:在字节码级别上,Groovy 闭包和 Scala 闭包并不是一回事。在 Java 中建立一个标准会将这些对话向下推至运行时中,并会消除我在本文中展示的这一类映射的需要。但是,在此之前,这一功能会创建简洁却强大的代码。

在下一部分中,我会谈到一些优化措施:函数式编程允许运行时使用记忆式 (memoization) 的 Groovy 创建和运行示例。

参考资料

学习

获得产品和技术

讨论

  • 加入 developerWorks 中文社区。查看开发人员推动的博客、论坛、组和 wikis,并与其他 developerWorks 用户交流。

条评论

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=793216
ArticleTitle=函数式思维: Groovy 中的函数式特性,第 2 部分
publish-date=02132012