4. 闭包
闭包的通俗解释,就是内部状态与外部隔离,仅通过参数列表和返回值与外界进行交互的高阶函数,用于保护内部的函数不受外界影响。其概念派生自 Lambda 表达式。对于 Java 这类 OOP 语言来说,”闭包” 的概念则被 “类” 所代替:闭包的局部变量变成了 “属性”,而内部函数变成了 “方法”。方法的结果往往取决于属性,而属性又通常是不对外开放的 ( private
)。
FP 的一些基本概念或术语,笔者其实主要是从 Scala 的角度切入的。详情见:Scala 之:函数式编程之始
4.1 Groovy 中的闭包
Groovy 允许我们以函数式风格使用闭包 ( 至少看起来是那样 ) 并在各处传递。换句话说,Groovy 也支持函数式编程 FP,并具备所有 FP 应当具备的特性。我们在 Java 中曾感慨过 Lambda 表达式的简洁,而在 Groovy 中,它已是家常便饭。假定我们想自定义一个对数组进行遍历操作的 foreach
函数,该函数对内部的元素操作取决于传入的闭包 action
:
nums = 1..10
// 这段代码完全没有声明任何类型,我好像在写 javaScript.......
void foreach(nums ,action){
for(i in nums){
// 这个传入的 i 就是 i-> print "${i}" 当中的那个 i。
action(i)
}
}
foreach(nums,{i -> print "${i} "})
复制代码
前面的文章曾提到过,如果参数列表的最后几个参数是闭包,可以选择性的将这些闭包移动到函数调用后面,英语老师管这种表达方法叫 “定语后置”。尤其是当闭包较长时,这种表述方式要更加优雅。
//对那个数组进行 foreach 操作 | 怎么操作
foreach(nums) {i -> print "${i} "}
foreach(nums) {
i->
print i
print "当闭包很长时,这种写法看起来可读性更高"
print "这个写法已经很接近平日所用的 for-each 循环了。"
print "这就是闭包后置带来的惊喜。"
}
复制代码
甚至可以再抽象一点,将 nums
数组本身也看作是一个 {()->nums}
的闭包:
static foreach(nums,action){
// nums() 表示它是一个 ()->nums 的 supplyer.
def seq = nums()
for(i in seq) {action(i)}
}
// {nums} 实际上是 {->nums},这里可以省略掉 -> 箭头。
foreach {nums} {i->print("${i} ")}
复制代码
在这个例子中,我们隐约能感受到由 Groovy 创建出的 DSL 将是何其优雅而强大。其中,foreach
接收 action
闭包 ( 也可以称作是函数 ),因此 foreach
也被称作是高阶函数 。高阶函数的更直接解释是:一个接收函数,或者是返回另一个函数的函数。
在 Java 中,一个 Lambda 表达式的写法是这样的:(...) -> {...}
,即便在没有任何参数的情况下,也得写成 ()->{...}
的形式。而在 Groovy 中,闭包的参数不需要小括号括起来,形式上类似 { p1,p2 ->...}
。
当这个闭包不需要任何参数时,写法形如 { -> ...}
,或者是省略掉箭头符号而直接写成 {...}
。
小小的特殊情况:如果闭包只需要一个参数,我们就可以在内部以 it
来称呼它,然后忽略掉参数命名和小箭头。比如:
// {i -> print{"${i}"} =====> {print "${it}"}
// foreach nums, print it.
foreach {nums} {print("${it} ")}
复制代码
需要注意的是,如果闭包确实没有接收参数,但是写法却形如 {...}
,那么 Groovy 还是会隐式地赋予这个闭包一个值为 null
的 it
参数。这会影响到程序在运行期对闭包的动态判断,见后文的动态闭包。
闭包的参数可以声明严格的类型,比如:
foreach {nums} {Integer i -> print "${i} "}
复制代码
4.2 Execute Around Method & AOP
在 JVM 语言当中,只要我们能够将不需要的对象标记为不可达,那么它们就可以在适当的时机被 GC 回收。但是在 I/O 密集型任务中,我们希望 InputStream 和 OutputStream 在完成任务之后马上关闭,否则它们占据的文件句柄就仍然会在 GC 主动回收之前一直处于打开状态。
这也是为什么 Java ( 以及其它语言的 ) I/O 工具要设计 close()
或者是 destory()
这样的方法。然而,偶尔我们可能过分专注于功能业务,而忘记主动地调用这些方法 ……
这种琐碎的劳动不如交给程序来解决。假定所有需要主动关闭的资源都实现了 MustClosed()
接口:
interface MustClosed {
def close()
}
复制代码
然后,定义一个高阶函数,它接收 MustClosed
的实现类,并保证总是在最后调用其 close()
方法关闭资源,无论在使用过程中是否发生异常。至于使用这个 MustClosed
资源的具体细节,将它封装到另一个闭包 action
当中。
// r 指 Resource,指必须要主动关闭的 I/O 型资源。
def static safeUse(MustClosed r, action) {
try{
action(r)
}catch (Exception e){
e.printStackTrace()
}finally{
r.close()
print "${r.getClass().typeName} 实例已经被关闭。"
}
}
复制代码
一个简单的测试:自定义一个 MustClosed
的简单实现,传入 safeUse
函数当中观察控制台的打印顺序:
MustClosed resource = [
close: { println "doing close()..." }
] as MustClosed
def action = { MustClosed r ->
println "use resource r to do something..."
}
// 演示了如何调用一个闭包变量
safeUse(re) {action(re)}
/*
等价于... 这里的 re 和 r 其实都指代一个引用。
safeUse(re){
MustClosed r ->
println "use resource r to do something..."
}
*/
复制代码
一个更加真实的测试:将 Java 提供的 OutputStream
视作是一个 MustClosed
的实现类传入进去,然后感受一下:
fos = new FileOutputStream(new File("README.md"))
// 依赖于 Groovy 的动态类型判断
safeUse(fos as MustClosed){r -> r.write("Groovy!".bytes)}
复制代码
现在,我们无需再手动关闭传入的各种 I/O 资源,safeUse
会帮助搞定一切。诸如这种闭包的使用风格就是 Execute Around Method ( 环绕执行 ) 模式。把思维再发散一些,假设我们在完成一系列业务之前和之后总是要进行一些重复处理,则可以利用这种模式设计出一个模板,只需要替换掉中间不一样的业务。
这种方式有点似曾相识 …… 直觉是正确的。它的设计理念和 Spring 框架中的面向切面编程 AOP 没有什么不同,凡是支持 FP 的语言都可以通过环绕执行模式来实现切面化编程。
4.3 可利用闭包自动清理资源
Groovy 鉴于 I/O 资源类工具的这些 “痛点” 给出了相当体面的实现:它在原有的 I/O 工具类的基础上做了一些包装,我们可以在 FileOutputStream / FileInputStream 工具中,直接调用 withWriter
或者 withReader
( 字符操作 ),withStream
( 字节操作 ) 方法,并直接以闭包的形式告知 FileOutputStream 或者是 FileInputStream 作为 Reader / Writer / Stream 处理数据,并免去原来的装饰者模式带来的一个副作用 —— 不断地创建包装对象。在执行完之后,I/O流会自行关闭。
f = new File("README.md")
assert f.exists()
fos = new FileOutputStream(f)
String data = """{
status:200,
msg:"ok"
}
"""
// need no more fuxking close() and flush()
fos.withWriter {
// 其实 w -> w.write(data) 可以一次将长字符串全写进去。
// 这里主要是为了演示闭包的嵌套调用。
w ->
data.eachLine {
w.write("${it}\n")
}
}
复制代码
尤其是使用带缓冲的输出流时,我们再也不用担心因为忘记 close()
或者 flush()
而导致内容丢失的问题了。
4.4 闭包柯里化
凡是涉及函数式编程的语言都能进行柯里化 ( Curry ) 变换。我们对函数 ( 或称之闭包 ) 进行柯里化变换的的目的有二:要么是希望记忆 ( 缓存) 一些参数,要么是希望推迟执行某个闭包。举个例子,下面是一个平平无奇的表达式:
Closure<Integer> expr = { x, y, z -> (x + y) * z }
复制代码
假设在实际的运行当中,我们发现参数 z
相较 x
和 y
而言,它的值似乎不总是变化:
// z 不总是变化
expr(2,3,3)
expr(1,6,3)
expr(1,5,3)
expr(5,9,3)
复制代码
因此,我们希望闭包 expr
在一段时间之内记住 z
的值,以此来避免枯燥的重复传参。于是 expr
首先被改写成了这个样子:
Closure<Closure<Integer>> expr = {
z ->
// 对于这个闭包而言,z 是自由变量。
return { x, y ->
(x + y) * z
}
}
复制代码
现在,expr
首先要求提供 z
,然后再返回一个参数是 x
和 y
的子闭包,根据描述来看,赋值的过程显然是被拆分成了两步。同时,expr
演变成了一个高阶函数 ( 高阶闭包 ) 。如果将求值过程比作是开箱子,那么原本只需要一次开箱的取值操作变成了两次。
// 之前:expr(2,3,3)
// 之后:
def result = expr(3)(2,3)
复制代码
事情变得更麻烦了吗?并不是如此。如果我们率先获取 expr(3)
,就能提前获取被保存的 z
的状态。因此在后续调用中,我们只管传入不同的 x
和 y
即可。
// (x + y) * 3
def memoried_z = expr 3
memoried_z(2,3) // expr(2,3,3)
memoried_z(1,6) // expr(1,6,3)
memoried_z(1,5) // expr(1,5,3)
memoried_z(5,9) // expr(5,9,3)
复制代码
对于更加深层次的柯里化函数而言,随着函数参数不断被记忆,后续的调用将会变得越来越简单,所需的参数越来越少。而传递的参数越是复杂,或者传参的条目越多,函数柯里化体现出的优势就会越大。在获得所有的参数之前,柯里化函数总是返回闭包,而非真正执行,因此我们也称柯里化函数被推迟调用了。
4.4.1 便捷的柯里化转换方法
如果函数的功能再复杂一些,那么手动重构一个柯里化实现可能要花上一点时间 ( 更多的是思绪上的混乱 )。好在 Groovy 提供了一系列便捷的方法来代替我们完成柯里化转换。对于一个参数个数为 n 的函数,如果想将其前 k 个参数进行柯里化,可以调用curry()
方法来完成,其中 0 <= k <= n
。
expr = {x, y, z -> (x + y) * z}
// 返回一个新的表达式: ( 3 + 2 ) * z => 5z
expr1 = expr.curry(3,2)
// 10
println expr1(2)
// 返回一个新的表达式: (1 + 2) * 3 = 9
println expr.curry(1,2,3)()
复制代码
如果要从后向前开始柯里化,则需使用 rcurry()
方法:
expr = {x, y, z -> (x + y) * z}
// expr = (x + 3) * 2
expr1 = expr.rcurry(3,2)
// 12
println expr1(3)
复制代码
如果要从前面的第 k 个参数开始柯里化,则需要使用 ncurry()
方法,其中 0 <= k <= n
,当 k = 0
时,指从第一个参数开始柯里化,此时等价于 curry()
方法。
expr = {x,y,z -> (x + y) * z}
// expr1 = (x + 3) * 2
expr1 = expr.ncurry(1,3,2)
// 10
println expr1(2)
复制代码
4.5 动态闭包
Groovy 可以在程序运行时动态地判断传入闭包的参数列表长度,甚至是参数类型,我们可以利用这个特性赋予程序动态决策的能力。比如:假定公司要根据营收额来统计交税额。我们想以闭包的形式将税额计算方法传递到一个高阶函数当中去计算。然而,这个闭包有可能不需要提供税率 ( 用户直接给出计算公式 ),也有可能需要 ( 用户仅提供计算方法,此时由高阶函数提供一个默认值):
def tax(Double amount,Closure computer){
switch (computer.maximumNumberOfParameters){
case 1 : return computer(amount)
case 2 : return computer(amount,15)
default: throw new Exception("need 1 or 2 args.")
}
}
// 这个闭包传入了税率计算方式以及税率,只需要 1 个参数
println tax(14000.0) {
amount -> amount * 0.13
}
// 这个闭包不主动传入税率 rage,有 2 个参数
println tax(14000.0) {
amount,rage -> amount * (rage/100)
}
复制代码
如果 Groovy 能够确定一个参数是闭包,那么可以访问其 .maxinumNumberOfParameters
属性来判断闭包传入时实际的参数个数。如果用户不提供税率,那么就直接按照用户的公式计算交税额。否则,由高阶函数给出一个默认税率并计算。
除了动态判断参数的个数,还可以使用 parameterTypes
属性来动态获取参数的实际类型。比如:
def check(Closure cls){
int i = 1
for(args in cls.parameterTypes) { println "type[${i}] = ${args.typeName}";i++}
//.. 什么也不做
}
// type[1] = java.lang.String
check {
String i -> //.. 什么都不做
}
// type[1] = java.lang,Integer
check {
Integer i -> //.. 还是什么也不做
}
//type[1] = java.lang.Integer
//type[2] = java.lang.String
check {
Integer i , String s -> //.. 仍然什么也不做
}
复制代码
注意,前文曾提到过,如果闭包不需要任何参数,那么形式上可以写为 {...}
或者是 { -> ...}
,两者的区别是:Groovy 仍然会为前者赋予一个隐含的参数 it
,只不过这个值是 null
。但对于后者,Groovy 则认为这个它是一个严格的无参数闭包。
4.6 闭包执行上下文与闭包委托
假设这是一个闭包:
def closure = {
func()
}
复制代码
显然,如果没有其它的线索,那么就很难说 func()
调用是从哪来的。先把这个问题搁置在一边,我们有更重要的概念需要提及。
Groovy 为每一个闭包都定义了三个属性:thisObject
,owner
,delegate
。所有的闭包都和它所在类的实例相绑定,并且会被 Groovy 编译成一个内部类的实例。对于一般的闭包,this == owner == delegate
,比如说外部的 out
闭包。
class _Example_{
def out = {
// 这是相对而言的内部闭包。
def inner = {}
//------test of thisObject, owner, delegate----------//
println out.thisObject.getClass().name //_Example_
println out.owner.getClass().name //_Example_
println out.delegate.getClass().name //_Example_
}
}
new _Example_().out()
复制代码
但对于一些特殊情况就有所不同了:第一种情况是闭包嵌套的情况。比如说 out
闭包内部定义的 inner
闭包。如果访问该 inner
闭包的 owner
,它将指向外部闭包 out
的实例。
class _Example_{
def out = {
// 这是相对而言的内部闭包。
def inner = {}
//------test of thisObject, owner, delegate----------//
println out.thisObject.getClass().name //_Example_
println out.thisObject.hashCode() // == inner.thisObject.hashCode
println out.owner.getClass().name //_Example_
println out.delegate.getClass().name //_Example_
println inner.thisObject.getClass().name //_Example_
println inner.thisObject.hashCode() // == out.thisObject.hashCode
println inner.owner.getClass().name //_Example_$_closure1 (指外部闭包被编译的内部类)
println inner.delegate.getClass().name //_Example_$_closure1 (指外部闭包被编译的内部类)
}
}
new _Example_().out()
复制代码
在第二种特殊情况下,delegate
将不再指向 owner
,那就是进行闭包委托的情形。用简单的话来概述它,就是闭包内部调用的一些方法,可能来自于另一个委托类的实例。声明如下:
// "代理类"
class _Proxy_{}
class _Example_{
def out = {
def inner = {}
inner.delegate = new _Proxy_()
// thisObject, owner, delegate 都不一样。
println inner.thisObject.getClass().name
println inner.owner.getClass().name
println inner.delegate.getClass().name
}
}
//_Example_
//_Example_$_closure1
//_Proxy_
new _Example_().out()
复制代码
理解这三个闭包的基本属性之后,再去解释本节开头的那个问题就容易得多了:如果一个闭包内部使用的方法或变量在局部块内没有,那么 Groovy 优先在 thisObject
或者 owner
域查找;否则,试图从 delegate
那里查找;否则,就会报错返回。如果能在前两个域当中找到合适的内容,那么 Groovy 就绝对不会 “打扰” delegate
。
观察下面的完整代码。由于 _Example_
类定义了 func1
,out
闭包定义了func2
,因此即便是 inner
设置了闭包代理,Groovy 也没有进行路由。
class _Proxy_{
def func1 = { println "this [func1] is from the instance of Class: _Proxy_" }
def func2 = { println "this [func2] is still from the instance of Class: _Proxy_" }
}
class _Example_{
def func1 = { println "this [func1] is from the instance of Class:_Example_."}
def out = {
def func2 = { println "this [func2] is from the instance of closure:out"}
def inner = {
func1()
func2()
}
inner.delegate = new _Proxy_()
inner()
}
}
//this [func1] is from the instance of Class:_Example_.
//this [func2] is from the instance of closure:out
new _Example_().out()
复制代码
一旦注释掉 _Example_
内部的 func1
或者是 func2
闭包,Groovy 就会从 _Proxy_
那里尝试弥补缺失的内容,从而给出不同的运行结果。比如:
class _Proxy_{
def func1 = { println "this [func1] is from the instance of Class: _Proxy_" }
def func2 = { println "this [func2] is still from the instance of Class: _Proxy_" }
}
class _Example_{
def out = {
def inner = {
func1()
func2()
}
inner.delegate = new _Proxy_()
inner()
}
}
//this [func1] is from the instance of Class: _Proxy_
//this [func2] is still from the instance of Class: _Proxy_
new _Example_().out()
复制代码
另一种闭包委托的声明方式会令 Groovy 倒置查找顺序:即优先选择 delegate
的方法,其次才是 owner
或 thisObejct
。具体做法是通过调用一个对象的 .with
方法 “外挂” 闭包。如下所示:
class _Proxy_{
def func1 = { println "this [func1] is from the instance of Class: _Proxy_" }
def func2 = { println "this [func2] is still from the instance of Class: _Proxy_" }
}
class _Example_{
def func1 = { println "this [func1] is from the instance of Class: _Example_" }
def func2 = { println "this [func2] is still from the instance of Class: _Example_" }
def out = {
def inner = {
func1()
func2()
}
new _Proxy_().with inner
}
}
//this [func1] is from the instance of Class: _Proxy_
//this [func2] is still from the instance of Class: _Proxy_
new _Example_().out()
复制代码
注,delegate
在涉及 DSL 的方面会变得非常有用。
4.7 尾递归
递归代码常常被认为是 “有品位但是不好驾驭” 的典型,和迭代相比,递归从表达上可能更加晦涩,因此令大部分程序员避之不及。令一个经常遇到的麻烦是:当递归的层次过深时,JVM 会招架不住而抛出一个 StackOverFlowError
。我们都知道,JVM 的每一个线程都使用一个栈空间来管理它调用的函数,并为每一个函数分配一个栈帧。如果这个栈一直处于 “只进不出” 的状态,那么 JVM 理论上就有驾崩的危险。
解决栈溢出的思路却又很简单:只要让栈维持 “边进边出” 的状态就可以了。具体的做法是:将一般的递归函数改进成尾递归。尾递归的意思是:当调用下一个函数时,当前的调用直接将返回值传递给它,并且没有后续计算,线程也自然就认为没有必要保存当前调用的栈帧。
用一个例子来说明或许更实在一点:n 的阶乘。
/**
* 为什么这个方法不是尾递归?
* 如果它的返回值是 n * factorial(n-1),那么本次调用就需要等待下一次调用的 factorial(n-1) 来计算返回值。
* 因此,假定 n == 3 , 那么线程就需要 3 层栈空间来解决它。
* 假定 n 取了更大的值 , 那么 JVM 就会呻吟了。
* @param n
* @return
*/
int factorial(int n){
return n <= 1 ? 1 :n * factorial(n-1)
}
import groovy.transform.TailRecursive
/**
* 为什么这个方法是尾递归?
* 无论是哪个分支,该递归函数总是返回一个数值或者发起一个新的调用,且当前调用没有需要等待的后续运算了。
* 而递归过程的中间结果会不断地被装入 acc 参数中并传递。
* 所以,在当前调用结束之后,它可以安全地 "传宗接代且无后顾之忧"。
* 无论 n 取何值,这个尾递归始终只占用 1 层栈空间。
* 唯一不太方便的是,用户调用这个函数需要主动为 acc 传递一个初始值,而我们一般都靠包装函数来解决这个瑕疵。
* @param n
* @return
*/
@TailRecursive
int factorial_t(int n,int acc){
return n <= 1 ? acc : factorial_t(n-1,acc * n)
}
// good
println factorial_t(10000,1)
// bad, 99.99% 报错
println factorial(10000)
复制代码
其中,@TailRecursive
注解负责检查函数,如果它不是严格尾递归的,那么就会在编译器期间报错。闭包版本的尾递归则和函数版本不太一样,其尾递归需要借助 trampoline
( 意 “弹簧床” ) 方法来实现。
// 对于递归闭包,我们必须先定义出一个变量作为它的名字,才能在闭包内部调用自身。
def f
f = {
i, BigInteger n -> i <= 1 ? n : f.trampoline(i - 1, n * i)
}.trampoline()
f(1000)
复制代码
另外,考虑到用户体验,不妨将闭包的 acc
参数设置为默认值 1 ( 因为它是最后一个参数 ),因此该参数对用户来说可以是透明的。需要指出的是,同等的算法逻辑和入参,函数的执行速度要比闭包快得多。
BigInteger factorial_func(int i, BigInteger n) {
i <= 1 ? n : factorial_func(i - 1, n * i)
}
factorial_closure = {
i, BigInteger n = 1 -> i <= 1 ? n : factorial_closure.trampoline(i - 1, n * i)
}.trampoline()
l1 = System.currentTimeMillis()
println factorial_closure(1000)
// 250 ~ 300 millis
println System.currentTimeMillis() - l1
l1 = System.currentTimeMillis()
println factorial_func(1000, 1 as BigInteger)
// 4 ~ 10 millis
println System.currentTimeMillis() - l1
复制代码
4.8 从钢条切割问题探讨递归优化
假定我们是钢杆 ( 或者称钢条 ) 的卖家,不同长度的钢杆价位不同,**且钢杆长度和价格并不是线性相关的 ( 这就表明了整根卖不一定获利最大 ) **。现在给定一个长钢杆,我们希望对它做一些切割以获得最大利润。长度为 n
的钢杆,它的价格记作 rodPrices[n]
:
Integer[] rodPrices = [0, 1, 3, 4, 5, 8, 9, 11, 12, 14, 15, 15, 16, 18, 19, 15, 20, 21, 22, 24, 25, 24, 26, 28, 29, 35, 37, 38, 39, 40]
复制代码
比如,长度为 2 的钢杆,它的价格为 3,而长度为 5 的钢杆,它的价格仅为 8 ( 性价比似乎还 “提高” 了) 。此外,这里为了消除 0 下标的 “错位” 影响,令 rodPrices[0] == 0
。第一问:首先求长度为 27 的钢杆,它所能获得的最大利润。
最朴素的想法是:穷举所有的切割方案并择优选取。一提及穷举,我们自然就会想到迭代,或者递归,或者两者兼具。
进一步思考,每次将一段长杆 l
切割,都会产生两个短杆 s1
,s2
。设长度为 n
的钢杆的最大利润为 max(n)
,那显然我们只要求出 max(s1)
和 max(s2)
,那么就能推算出 max(l) = max(s1) + max(p2)
。那 max(s1)
又该怎么求呢?它肯定又能分出更短的杆 ss1
和 ss2
,我们继续求 max(ss1)
和 max(ss2)
就好了 ……
从这段描述中,我们能够提取出四个要点:
- 大问题的目的是求最优解。
- 大问题可以分解成若干小问题。
- 大问题的最优解依赖于小问题的最优解。
- 我们现在正 “自上而下” 地将大问题不断分解成小问题。
因此,钢杆 ( 钢条 ) 切割问题本质上是一道动态规划题。在下面的叙述中,我们将 “求长度为 length
的钢杆的最大利润” 封装为一个函数 rodCut(length)
。
设传进来长度为 length
的长杆,切割后的两杆其一为 s1
,长度记作 i
;而另一个 s2
的长度则为 length - i
。我们在一个迭代中动态平衡两杆的长度,且在每一次迭代内部,保留 s1
的长度不变,假设它的当前最优解就是 max'(s1)
。以此为前提去对 s2
进行递归切割,即调用 rodCut(s2)
。
我们确信 rodCut
总能正确的返回 max(s2)
,因此每次迭代的 max'(length) = max'(s1) + rodCut(s2)
。在迭代完成后,只需要从这些结果值中取出实际最大值作为当前 rodCut(length)
调用的返回值即可。