Java 语言是否应增加闭包以及如何添加?

315 阅读15分钟
原文链接: www.ibm.com

Java 理论与实践

闭包之争

Java 语言是否应增加闭包以及如何添加?

系列内容:

此内容是该系列的一部分:Java 理论与实践

跨越边界 系列最近的一篇文章中,我的朋友兼同事 Bruce Tate 以 Ruby 为例描述了闭包的强大功能。最近在安特卫普召开的 JavaPolis 会议上,听众人数最多的演讲是 Neal Gafter 的 “向 Java 语言增加闭包特性”。在 JavaPolis 的公告栏上,与会者可以写下和 Java 技术有关(或者无关)的想法,其中将近一半和关于闭包的争论有关。最近似乎 Java 社区的每个人都在讨论闭包——虽然闭包这一业已成熟的概念早在 Java 语言出现的 20 年之前就已经存在了。

本文中,我的目标是介绍关于 Java 语言闭包特性的种种观点。本文首先介绍闭包的概念及其应用,然后简要说明目前提出来的相互竞争的一些方案。

闭包:基本概念

闭包是可以包含自由(未绑定)变量的代码块;这些变量不是在这个代码块或者任何全局上下文中定义的,而是在定义代码块的环境中定义。“闭包” 一词来源于以下两者的结合:要执行的代码块(由于自由变量的存在,相关变量引用没有释放)和为自由变量提供绑定的计算环境(作用域)。在 Scheme、Common Lisp、Smalltalk、Groovy、JavaScript、Ruby 和 Python 等语言中都能找到对闭包不同程度的支持。

闭包的价值在于可以作为函数对象 或者匿名函数,对于类型系统而言这就意味着不仅要表示数据还要表示代码。支持闭包的多数语言都将函数作为第一级对象,就是说这些函数可以存储到变量中、作为参数传递给其他函数,最重要的是能够被函数动态地创建和返回。比如下面清单 1 所示的 Scheme 例子(摘自 SICP 3.3.3):

清单 1. Scheme 编程语言的函数示例,该函数接受另一个函数作为参数并返回缓存后的函数
(define (memoize f)
  (let ((table (make-table)))
    (lambda (x)
      (let ((previously-computed-result (lookup x table)))
        (if (not (null? previously-computed-result))
          previously-computed-result
          (let ((result (f x)))
            (insert! x result table)
            result))))))

上述代码定义了一个叫做 memoize 的函数,接受函数 f 作为其参数,返回和 f 计算结果相同的另一个函数,不过新函数将以前的计算结果保存在表中,这样读取结果更快。返回的函数使用 lambda 结构创建,该结构动态创建新的函数对象。斜体显示的标识符在新定义函数中是自由的,它们的值在创建该函数的环境中绑定。比如,用于存储缓存数据的表变量在调用 memoize 的时候创建,由于被新建的函数引用,因此直到垃圾回收器回收结果函数的时候才会被收回。如果调用结果函数时带有参数 x ,它首先检查是否已经计算过 f(x)。是的话返回已经得到的 f(x),否则计算 f(x) 并在返回之前保存到表中以备后用。

闭包为创建和操纵参数化的计算提供了一种紧凑、自然的方式。可以认为支持闭包就是提供将 “代码块” 作为第一级对象处理的能力:能够传递、调用和动态创建新的代码块。要完全支持闭包,这种语言必须支持在运行时操纵、调用和创建函数,还要支持函数可以捕获创建这些函数的环境。很多语言仅提供了这些特性的一个子集,具备闭包的部分但不是全部优势。关于是否要在 Java 语言中增加闭包,关键问题在于提高表达能力所带来的益处能否与更高的复杂性所带来的代价相抵消。

匿名类和函数指针

C 语言提供了函数指针,允许将函数作为参数传递给其他函数。但是,C 中的函数不能有自由变量:所有变量在编译时必须是已知的,这就降低了函数指针作为一种抽象机制的表达能力。

Java 语言提供了内部类,可以包含对封闭对象字段的引用。该特性比函数指针更强大,因为它允许内部类实例保持对创建它的环境的引用。乍看起来,内部类似乎确实提供了闭包的大部分作用,虽然这还不是全部作用。您可以很容易构造一个名为 UnaryFunction 的接口,并创建能够缓存任何 unary 函数的缓存包装程序。但是这种方法通常不易于实现,它要求与函数交互的所有代码在编写时都必须知道这个函数的 “框架”。

闭包作为一种模式模板

匿名类允许创建这样的对象,该对象能够捕获定义它们的一部分环境,但是对象和代码块不一样。以一个常见的编码模式为例,如执行带有 Lock 的代码块。如果需要递增带有 Lock 的计数器,代码如清单 2 所示——即使这么简单的操作也非常罗嗦:

清单 2. 执行加锁代码块的规范用法
lock.lock();
try { 
    ++counter;
}
finally {
    lock.unlock();
}

如果能够提取出加锁管理代码就好了,这样会使代码看起来更紧凑,也不容易出错。首先可以创建如清单 3 所示的 withLock() 方法:

清单 3. 提取了 “加锁执行” 的概念,但是问题在于缺乏异常的透明性
public static void withLock(Lock lock, Runnable r) {
    lock.lock();
    try { 
        r.run();
    }
    finally {
        lock.unlock();
    }
}

不幸的是,这种方法只能达到您预期的部分目标。创建这种抽象代码的目标之一是使代码更紧凑;但是,匿名内部类的语法不是很紧凑,调用代码看起来如清单 4 所示:

清单 4. 清单 3 中 withLock() 方法的客户端代码
withLock(lock, 
    new Runnable() {
        public void run() {
            ++counter;
        }
});

要递增一个加锁的计数器仍然需要编写很多代码!另外,将受到锁保护的代码块转化成方法调用所带来的抽象问题大大增加了问题的复杂性——如果受保护的代码块抛出一个检测异常怎么办?现在我们不能使用 Runnable 来表示执行的任务,而必须创建一种新的表示方法以允许在方法调用中抛出异常。不幸的是,在这里泛化也帮不上多少忙,虽然方法可以用泛型参数 E, 表示可能抛出的检测异常,但是这种方法不能很好地泛化抛出多种检测异常类型的方法(这就是为何 Callable 中的 call() 方法声明为抛出 Exception 而不是用类型参数指定一个类型的原因)。清单 3 中的方法最大的问题在于缺乏异常透明性,除此之外,还存在其他非透明性的问题,在 清单 4Runnable 上下文中,returnbreak 这类语句的含义,与 清单 2 中 try 语句块中的一般意义不同。

理想情况下,受保护的递增操作应该像清单 5 所示的那样,并且块中代码的含义和 清单 2 的扩展形式相同:

清单 5. 清单 3 客户端代码的理想形式(但是是假设形式)
withLock(lock, 
    { ++counter; });

在语言中添加闭包以后,就可以创建行为类似控制流结构的方法,比如 “加锁执行这段代码”、“操作流并在完成后将其关闭” 或者 “为代码块的执行计时” 等。这种策略有可能简化某些类型的代码,这些代码反复使用特定编码模式或者惯用法,比如 清单 2 所示的加锁用法。(在一定程度上提供类似表达能力的另一种技术是 C 预处理器,它可以将 withLock() 操作用预处理宏表示,虽然和闭包相比宏更难以组织,而且安全性也更差。)

泛化算法的闭包

闭包能够大大简化代码的另一个地方是泛化算法的使用。随着多处理器计算机越来越便宜,利用小粒度并行机制的重要性日渐突出。使用泛化算法定义计算为库实现在问题空间中采用并行机制提供了一种自然的方式。

比方说,假设要计算一个大型数字集合的平方和。清单 6 给出了一种计算方法,但这种方法是按顺序计算结果的,对于大规模多处理器系统可能不是效率最高的方法:

清单 6. 顺序计算平方和
double sum;
for (Double d : myBigCollection)
 sum += d*d;

每次循环迭代有两个操作:取平方,累加到最终结果。平方操作是互相独立的,可以并行执行;加法操作也不一定要执行 N 次,如果计算组织得当,只要 log(N) 次操作即可完成。

清单 6 中的操作是 map-reduce 算法的一个示例,对大批数据元素中的每一个数据元素应用一个函数,然后将每次应用该函数计算出的结果通过某种累加函数累加起来。假设有一个 map-reduce 实现过程接受数据集作为输入,用一元函数处理每个元素,用二元函数累加结果,则可用清单 7 所示的代码完成平方和运算:

清单 7. 使用 MapReduce 计算平方和,可以实现并行执行
Double sumOfSquares = mapReduce(myBigCollection,
 new UnaryFunction<Double> {
 public Double apply(Double x) {
 return x * x;
 }
 },
 new BinaryFunction<Double, Double> {
 public Double apply(Double x, Double y) {
 return x + y;
 }
 });

假设清单 7 中的 mapReduce() 实现知道哪些操作可以并行执行,因而可以将函数应用和累加过程并行执行,从而改进并行系统的吞吐量。但是清单 7 中的代码不简洁,用了更多代码来表达和清单 6 中三行代码等价的泛化算法。

通过闭包可以更好地管理清单 7 中的代码。比如,清单 8 中的闭包语法和目前提出的 Java 语言闭包方案都不一样,目的仅在于说明闭包对泛化算法的支持:

清单 8. 使用 MapReduce 和假设的闭包语法计算平方和
sumOfSquares = mapReduce(myBigCollection, 
 function(x) {x * x}, 
 function(x, y) {x + y});

清单 8 中基于闭包的算法具有两方面的好处:代码容易阅读和编写,抽象层次比顺序循环更高,能够有效地通过库实现并行。

闭包方案

目前至少提出了两种向 Java 语言增加闭包的方案。其一,绰号为 “BGGA”(名字源于其作者 Gilad Bracha、Neal Gafter、James Gosling 和 Peter von der Ahe),它扩展了类型系统,引入了 function 类型。其二,绰号为 “CICE” (代表 Concise Inner Class Expressions,简洁内部类表示),是由 Joshua Bloch、Doug Lea 和 “疯狂的” Bob Lee 所支持的,其目标更谦虚:简化匿名内部类实例的创建。 JSR 可能很快就会收到这方面的提议,考虑在未来的 Java 语言版本中支持闭包的形式和程度。

BGGA 方案

BGGA 方案提出了 function 类型的概念,即函数都带有一个类型参数列表、返回类型和 throws 子句。在 BGGA 方案中,计算平方和的代码将如清单 9 所示:

清单 9. 使用 BGGA 闭包语法计算平方和
sumOfSquares = mapReduce(myBigCollection, 
 { Double x => x * x }, 
 { Double x, Double y => x + y });

=> 字符到左侧花括号之间的代码表示参数的名称和类型,右侧的代码表示定义的匿名函数的实现。这段代码可以引用块中定义的局部变量、闭包的参数以及创建闭包的作用域中的变量。

在 BGGA 方案中,可以声明 function 类型的变量、方法参数和方法返回值。在需要一个抽象方法类(如 RunnableCallable)实例的任何上下文中都可以使用闭包,对于匿名类型的闭包,您可以使用带有给定参数列表的 invoke() 方法来调用。

BGGA 方案的主要目标之一是允许程序员创建行为类似控制结构的方法。因此,BGGA 还在语法上提出了一些吸引人的花招,允许像新的关键字那样调用接受闭包的方法,从而能够创建像 withLock()forEach() 这样的方法,然后向控制原语一样调用它们。清单 10 说明了根据 BGGA 方案如何定义 withLock() 方法,清单 11清单 12 说明了如何调用该方法,包括标准形式和“控制结构”形式:

清单 10. 采用 BGGA 闭包方案编写的 withLock() 方法
public static <T,throws E extends Exception>
T withLock(Lock lock, {=>T throws E} block) throws E {
 lock.lock();
 try {
 return block.invoke();
 } finally {
 lock.unlock();
 }
}

清单 10 中的 withLock() 方法接受锁和闭包。闭包的返回类型和 throws 子句是泛化参数,编译器中的类型推断通常允许在未指定 TE 值的情况下调用,如清单 11 和 12 所示:

清单 11. 调用 withLock()
withLock(lock, {=>
 System.out.println("hello");
});
清单 12. 使用控制结构的缩写形式调用 withLock()
withLock(lock) {
 System.out.println("hello");
}

和泛化一样,BGGA 方案中闭包的复杂性在很大程度上是由库的编写者来分担的,使用接受闭包的库方法更简单。

使用内部类实例是闭包所带来的好处,但是这种方法缺少透明性,BGGA 方案在一定程度上还有助于解决这个问题。比如,returnbreakthis 在某一代码块中的语义与其在 Runnable(或其他内部类实例)中同一代码块中的语义是不同的。为了利用泛化算法而对代码进行移值的时候,这些不透明因素可能会造成混乱。

CICE 方案

CICE 方案要简单得多,它解决了实例化内部类实例不太灵活的问题。它没有建立函数类型的概念,只不过为一个抽象方法(如 RunnableCallableComparator)内部类实例化提出了一种更紧凑的语法。

清单 13 说明了按照 CICE 如何计算平方和。它显示使用了 mapReduce() 中的 UnaryFunctionBinaryFunction 类型。mapReduce() 的参数是从 UnaryFunctionBinaryFunction 派生的匿名类,这种语法大大了降低了创建匿名实例的冗余。

清单 13. 采用 CICE 闭包方案计算平方和的代码
Double sumOfSquares = mapReduce(myBigCollection,
 UnaryFunction<Double>(Double x) { return x*x; },
 BinaryFunction<Double, Double>(Double x, Double y) { return x+y; });

由于为传递给 mapReduce() 的函数所创建的对象是普通的匿名类实例,其函数体可以引用封闭域中定义的变量,清单 13 中的方法和清单 7 相比,唯一的区别在于语法的繁简程度。

结束语

BGGA 方案为 Java 这种语言增加了功能强大的新武器,但是同时也为其语义和语法带来了可以预见的复杂性。另一方面,CICE 方案更简单:利用语言中已有的特性并使其更易于使用,但是没有增加重要的新功能。闭包是一种强大的抽象机制,用过之后多数人不愿意放弃。(问问那些熟悉 Scheme、Smalltalk 或 Ruby 编程的朋友对闭包的感想如何,他们可能会反问您对呼吸有什么感想。)但语言是有机的整体,为语言增加最初设计时没有预料到的新特性充满了危险,而且会增加语言的复杂性。争论的焦点不在于闭包是否有用——因为答案显然是肯定的——而在于为闭包重新改造 Java 语言的好处是否抵得上要付出的代价。

相关主题