Java 下一代: 局部套用和部分应用

为函数调度添加功能和间接引用

所有 Java 下一代语言都包括局部套用(currying)和部分应用,但以不同的方式实现它们。本文将介绍这两种技术和它们之间的区别,并显示它们在 Scala、Groovy 和 Clojure 中的实现细节(和实际应用)。

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

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



2014 年 1 月 09 日

关于本系列

Java™ 传承的是平台,而不是语言。有超过 200 种语言可以在 JVM 上运行,它们之中不可避免地会有一种语言最终取代 Java 语言,成为编写 JVM 程序的最佳方式。本系列将探讨三种下一代 JVM 语言:Groovy、Scala 和 Clojure,比较并对比新的功能和范例,让 Java 开发人员对自己近期的未来发展有大体的认识。

局部套用部分应用 是来源于数学的语言技术(基于 20 世纪数学家 Haskell Curry 和其他人的工作成果)。这两种技术存在于各种类型的语言中,可以单独或同时存在于函数式语言中。局部套用和部分应用使您能够处理函数或方法的参数数量,通常的方法是为一些参数提供一个或多个默认值(称为修正 参数)。所有 Java 下一代语言都包括局部套用和部分应用,但以不同的方式实现它们。在本文中,我将介绍这两种技术的不同之处,并展示它们在 Scala、Groovy 和 Clojure 中的实现细节,以及实际应用。

注意术语

出于本部分的目的,方法(method)函数(function) 是可互换的。支持局部套用和部分应用的面向对象语言使用方法。同样,函数参数(function parameter)函数参数(function argument) 也是可互换的。由于这些概念起源于数学,因此我自始至终使用的是 函数(function)参数(argument),但这并不意味着这两种技术对方法不起作用。

IBM SDK for Java 8 的开放测试计划

IBM Worklight Developer Edition 下载

定义和区别

对于业余人士来说,局部套用和部分应用具有相同的效果。使用这两种技术时,都可以创建一个一些参数具有预先提供值的函数版本:

  • 局部套用是将多参数函数转换为一系列单参数函数。它描述了转换过程,而不是转换函数的调用。调用方可以确定应用了多少参数,从而创建一个参数更少的导出函数。
  • 部分应用将多参数函数转换为一个参数更少的多参数函数,其值为提前提供的省略参数的值。本技术的名称非常恰当:它将一些参数部分应用到函数,并返回一个具有签名(由剩余参数组成)的函数。

使用局部套用和部分应用,可以提供参数值并返回一个可使用缺少参数调用的函数。但是,对函数应用局部套用会返回链中的下一个函数,而部分应用会将参数值绑到在运算期间提供的值上,生成一个具有更少 元数(参数的数量)的函数。当考虑具有两个以上元数的函数时,这一区别会更加明显。例如,process(x, y, z) 函数的完全套用版本是 process(x)(y)(z),其中 process(x)process(x)(y) 都是接受一个参数的函数。如果只对第一个参数应用了局部套用,那么 process(x) 的返回值将是接受一个参数的函数,因此仅接受一个参数。与此相反,在使用部分应用时,会剩下一个具有更少元数的函数。对 process(x, y, z) 的一个参数使用部分应用会生成接受两个参数的函数:process(y, z)

这两种技术的结果通常是相同的,但二者的区别也很重要,人们通常会对它们之间的区别产生误解。更复杂的是,Groovy 可以实现部分应用和局部套用,但都将它们称为 currying。而 Scala 具有偏应用函数(partially applied function)和 PartialFunction,尽管它们的名称类似,但它们却是两个不同的概念。


在 Scala 中

Scala 支持局部套用和部分应用,还支持特征(trait),特征可以定义约束函数(constrained function)。

局部套用

在 Scala 中,函数可以将多个参数列表定义为括号组。调用参数数量比其定义数量少的函数时,会返回一个将缺少参数列表作为其参数的函数。请考虑 Scala 文档的示例,如清单 1 所示。

清单 1. Scala 的参数局部套用
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 modN(n: Int)(x: Int) = ((x % n) == 0)

val nums = List(1, 2, 3, 4, 5, 6, 7, 8)
println(filter(nums, modN(2)))
println(filter(nums, modN(3)))

在清单 1 中,filter() 函数递归地应用传递的过滤条件。modN() 函数定义了两个参数列表。在我使用 filter() 调用 modN 时,我传递了一个参数。filter() 函数被作为函数的第二个参数,具有一个 Int 参数和一个 Boolean 返回值,这与我传递的局部套用函数的签名相匹配。

偏应用函数

在 Scala 中还可以部分应用函数,如清单 2 所示。

清单 2. Scala 中部分应用的函数
def price(product : String) : Double =
  product match {
    case "apples" => 140
    case "oranges" => 223
}

def withTax(cost: Double, state: String) : Double =
  state match {
    case "NY" => cost * 2
    case "FL" => cost * 3
}


val locallyTaxed = withTax(_: Double, "NY")
val costOfApples = locallyTaxed(price("apples"))

assert(Math.round(costOfApples) == 280)

在清单 2 中,我首先创建了一个 price 函数,它返回了产品和价格之间的映射。然后我创建了一个 withTax() 函数,其参数为 coststate。但是,在特殊的源文件中,我知道要专门处理一个国家的税收。我没有对每次调用的额外参数应用局部套用,而是部分应用了 state 参数,并返回一个 state 值固定的函数。locallyTaxed 函数接受一个参数,即 cost

偏(约束)函数

Scala PartialFunction 特征可以与模式无缝地配合使用(请阅读函数式思维 系列的 "Either 树和模式匹配" 部分中的模式匹配)。尽管名称类似,但此特征不会创建偏应用函数。相反,可以使用它定义仅适用于值和类型定义子集的函数。

Case 块是应用偏函数(partial function)的一种方式。清单 3 使用了 Scala 的 case,没有传统对应的 match 操作符。

清单 3. 使用不带 match 的 case
val cities = Map("Atlanta" -> "GA", "New York" -> "New York",
  "Chicago" -> "IL", "San Francsico " -> "CA", "Dallas" -> "TX")

cities map { case (k, v) => println(k + " -> " + v) }

在清单 3 中,我创建了一个城市和该城市所对应的州的映射。然后,我对该集合调用了 map 函数,map 会拆开键值对以输出它们。在 Scala 中,包含 case 声明的代码块是定义匿名函数的一种方式。不使用 case 可以更简洁地定义匿名函数,但是,case 语法提供了如清单 4 所示的额外好处。

清单 4. mapcollect 之间的区别
List(1, 3, 5, "seven") map { case i: Int ? i + 1 } // won't work
// scala.MatchError: seven (of class java.lang.String)

List(1, 3, 5, "seven") collect { case i: Int ? i + 1 }
// verify
assert(List(2, 4, 6) == (List(1, 3, 5, "seven") collect { case i: Int ? i + 1 }))

在清单 4 中,我不能在具有 case 的异构集合上使用 map:我收到了 MatchError,因为函数试图增加 seven 字符串。但是 collect 工作正常。为什么会出现这种不同?什么地方出错了?

Case 块定义的是偏函数,而不是偏应用函数。偏函数 具有有限的允许值。例如,数学函数 1/x 是无效的,如果 x = 0。偏函数提供了一种定义允许值约束的方式。在 清单 4collect 示例中,定义了 Int 而不是 String 的约束,因此没有收集 seven 字符串。

要定义偏函数,还可以使用 PartialFunction 特征,如清单 5 所示。

清单 5. 在 Scala 中定义偏函数
val answerUnits = new PartialFunction[Int, Int] {
    def apply(d: Int) = 42 / d
    def isDefinedAt(d: Int) = d != 0
}

assert(answerUnits.isDefinedAt(42))
assert(! answerUnits.isDefinedAt(0))

assert(answerUnits(42) == 1)
//answerUnits(0)
//java.lang.ArithmeticException: / by zero

在清单 5 中,我从 PartialFunction 特征导出了 answerUnits,并提供了两个函数:apply()isDefinedAt()apply() 函数计算值。我使用了 isDefinedAt()PartialFunction 的必要方法)来定义确定参数适用性的约束。

还可以使用 case 块实现偏函数,清单 5answerUnits 可以采用更简洁的方式编写,如清单 6 所示。

清单 6. answerUnits 的另一种定义
def pAnswerUnits: PartialFunction[Int, Int] =
    { case d: Int if d != 0 => 42 / d }

assert(pAnswerUnits(42) == 1)
//pAnswerUnits(0)
//scala.MatchError: 0 (of class java.lang.Integer)

在清单 6 中,我结合使用了 case 和保卫条件来约束值并同时提供值。与 清单 5 的一个明显区别是 MatchError(而不是 ArithmeticException),因为清单 6 使用了模式匹配。

偏函数并不仅局限于数值类型。它可以使用所有类型的数值,包括 Any。可以考虑增量器(incrementer)的实现,如清单 7 所示。

清单 7. 在 Scala 中定义增量器
def inc: PartialFunction[Any, Int] =
    { case i: Int => i + 1 }

assert(inc(41) == 42)
//inc("Forty-one")
//scala.MatchError: Forty-one (of class java.lang.String)

assert(inc.isDefinedAt(41))
assert(! inc.isDefinedAt("Forty-one"))

assert(List(42) == (List(41, "cat") collect inc))

在清单 7 中,我定义了一个偏函数来接受任意类型的输入 (Any),但选择对类型子集做出反应。请注意,我还可以调用偏函数的 isDefinedAt() 函数。使用 casePartialFunction 特征的实现者可以调用 isDefinedAt(),它是隐式定义的。在 清单 4 中,我说明了 mapcollect 的表现不同。偏函数的行为解释了它们的区别:collect 旨在接受偏函数,并调用元素的 isDefinedAt() 函数,会忽略那些不匹配的函数。

在 Scala 中,偏函数和偏应用函数的名称类似,但是它们提供了不同的正交特性集。例如,没有什么可以阻止您部分地应用偏函数。


在 Groovy 中

在我的函数式思维 系列的 "运用函数式思维,第 3 部分" 中详细介绍了 Groovy 中的局部套用和部分应用。Groovy 通过 curry() 函数实现了局部套用,该函数来自 Closure 类。尽管名称如此,但 curry() 实际上通过处理其下面的闭包块来实现部分应用。但是,您可以模拟局部套用,方法是使用部分应用将函数减少为一系列部分应用的单参数函数,如清单 8 所示。

清单 8. Groovy 的部分应用和局部套用
def volume = { h, w, l -> return h * w * l }
def area = volume.curry(1)
def lengthPA = volume.curry(1, 1) //partial application
def lengthC = volume.curry(1).curry(1) // currying

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)}"

在清单 8 中,在两种 length 情况下,我使用 curry() 函数部分应用了参数。但是,在使用 lengthC 时,通过部分地应用参数,直到出现一连串的单参数函数为止,我制造了一种使用局部套用的幻觉。


在 Clojure 中

Clojure 包含 (partial f a1 a2 ...) 函数,它具有函数 f 以及比所需数量更少的参数,而且返回一个在提供剩余参数时调用的部分应用函数。清单 9 显示了两个示例。

清单 9. Clojure 的部分应用
(def subtract-from-hundred (partial - 100))

(subtract-from-hundred 10)      ; same as (- 100 10)
; 90

(subtract-from-hundred 10 20)   ; same as (- 100 10 20)
; 70

在清单 9 中,我将 subtract-from-hundred 函数定义为部分应用的 - 运算符(Clojure 中的运算符与函数无法区分),并提供 100 作为部分应用的参数。Clojure 中的部分应用适用于单参数函数和多参数函数,如清单 9 中的两个示例所示。

由于 Clojure 是动态类型的,并且支持可变参数列表,因此局部套用并不能作为一种语言功能来实现。部分应用将会处理必要的情况。但是,Clojure 被添加到 reducers 库(参见 参考资料)的命名空间私有 (defcurried ...) 函数,支持在该库中更轻松地定义一些函数。鉴于 Clojure 的 Lisp 传承的灵活特点,可以轻松扩大 (defcurried ...) 的使用范围。


常见用法

尽管局部套用和部分应用具有复杂的定义和大量实现细节,但是它们在实际编程中都占有一席之地。

函数工厂

局部套用(和部分应用)适合在传统的面向对象语言中实现工厂函数的位置使用。作为一个示例,清单 10 在 Groovy 中实现了一个简单的 adder 函数。

清单 10. Groovy 中的加法器和增量器
def adder = { x, y -> x + y}
def incrementer = adder.curry(1)

println "increment 7: ${incrementer(7)}" // 8

在清单 10 中,我使用 adder() 函数来导出 incrementer 函数。同样,在 清单 2 中,我使用部分应用创建了一个更简洁的本地函数版本。

Template Method 设计模式

Gang of Four 设计模式之一是 Template Method 模式。它的用途是帮助定义算法 shell,使用内部抽象方法来实现稍后的实现灵活性。部分应用和局部套用可以解决相同的问题。使用部分应用提供已知行为,并让其他参数免费用于实现细节,这模拟了此面向对象设计模式的实现。

隐含值

清单 2 类似,一种常见的情况是您有一系列使用相似参数值调用的函数。例如,当与持久性框架交互时,必须将数据源作为第一个参数进行传递。通过使用部分应用,可以隐式地提供值,如清单 11 所示。

清单 11. 使用部分应用提供隐含值
(defn db-connect [data-source query params]
      ...)

(def dbc (partial db-connect "db/some-data-source"))

(dbc "select * from %1" "cust")

在清单 11 中,我使用了便利的 dbc 函数来访问数据函数,无需提供数据源,就可以自动提供数据源。面向对象编程的精髓(隐含 this 上下文似乎出现在所有函数中)可以通过使用局部套用为所有函数提供 this 来实现,这使得它对用户不可见。

结束语

局部套用和部分应用以各种形式出现在所有 Java 下一代语言中。可以使用这些技术进行更简洁的函数定义,提供隐含值,并构建函数工厂。

在下一部分中,我将介绍所有 Java 下一代语言的函数式编程功能之间存在的惊人相似之处,以及这些功能有时完全不同的实现细节。

参考资料

学习

  • Scala:Scala 是一种现代函数编程语言,适用于 JVM。
  • Groovy:Groovy 是 Java 语言的一个动态变体,采用了更新的语法和功能。
  • Clojure:Clojure 是一种现代化的函数式 Lisp,运行在 JVM 之上。
  • 局部套用和部分应用之间有何区别?:Raganwald 是一个受欢迎的开发人员博客,介绍了局部套用和部分应用之间的区别。
  • reducers:reducers 库是强大的 Clojure 扩展,支持对操作符的复杂并发访问,比如 map
  • 函数式思维在 developerWorks 上 Neal Ford 的专栏系列中探索函数式编程。
  • "语言设计者的笔记本:" 在这个 developerWorks 系列中,Java 语言架构师 Brian Goetz 探讨了一些语言设计问题,这些问题为 Java 语言在 Java SE 7、Java SE 8 和更高版本中的演化带来了挑战。
  • developerWorks Java 技术专区:查找数百篇关于 Java 编程各个方面的文章。

获得产品和技术

讨论

  • 加入 developerWorks 中文社区。探索由开发人员推动的博客、论坛、组和维基,并与其他 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=959404
ArticleTitle=Java 下一代: 局部套用和部分应用
publish-date=01092014