如何用 Groovy 闭包优雅地 FP 编程?

1,721 阅读15分钟

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 还是会隐式地赋予这个闭包一个值为 nullit 参数。这会影响到程序在运行期对闭包的动态判断,见后文的动态闭包。

闭包的参数可以声明严格的类型,比如:

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 相较 xy 而言,它的值似乎不总是变化:

// 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,然后再返回一个参数是 xy 的子闭包,根据描述来看,赋值的过程显然是被拆分成了两步。同时,expr 演变成了一个高阶函数 ( 高阶闭包 ) 。如果将求值过程比作是开箱子,那么原本只需要一次开箱的取值操作变成了两次。

// 之前:expr(2,3,3)
// 之后: 
def result = expr(3)(2,3)

事情变得更麻烦了吗?并不是如此。如果我们率先获取 expr(3) ,就能提前获取被保存的 z 的状态。因此在后续调用中,我们只管传入不同的 xy 即可。

// (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 为每一个闭包都定义了三个属性:thisObjectownerdelegate。所有的闭包都和它所在类的实例相绑定,并且会被 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_ 类定义了 func1out 闭包定义了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 的方法,其次才是 ownerthisObejct。具体做法是通过调用一个对象的 .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 从钢条切割问题探讨递归优化

该问题参考自:动态规划-钢杆切割问题 - RunningSnail - 博客园 (cnblogs.com)

假定我们是钢杆 ( 或者称钢条 ) 的卖家,不同长度的钢杆价位不同,**且钢杆长度和价格并不是线性相关的 ( 这就表明了整根卖不一定获利最大 ) **。现在给定一个长钢杆,我们希望对它做一些切割以获得最大利润。长度为 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 切割,都会产生两个短杆 s1s2 。设长度为 n 的钢杆的最大利润为 max(n),那显然我们只要求出 max(s1)max(s2),那么就能推算出 max(l) = max(s1) + max(p2) 。那 max(s1)又该怎么求呢?它肯定又能分出更短的杆 ss1ss2 ,我们继续求 max(ss1)max(ss2) 就好了 ......

从这段描述中,我们能够提取出四个要点:

  1. 大问题的目的是求最优解。
  2. 大问题可以分解成若干小问题。
  3. 大问题的最优解依赖于小问题的最优解。
  4. 我们现在正 "自上而下" 地将大问题不断分解成小问题。

因此,钢杆 ( 钢条 ) 切割问题本质上是一道动态规划题。在下面的叙述中,我们将 "求长度为 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) 调用的返回值即可。

rodCut(length)=Max{maxi(s1)+rodCut(s2)  1ilength}length=s1+s2rodCut(length) = Max\{max_i(s1) + rodCut(s2)\space|\space1≤i≤length\} \\ length = s1 + s2

另外还有一个细节:如果在某一次迭代中 s1 的长度 i == length,那么显然 s2 == 0 。这时表示不对长度 length 的杆进行切割,此时 max(length) == rodPrices[length]

根据上述思路,我们迅速给出了第一版解决方案:

Integer rodCut(Integer[] rodPrices, length) {

    // 虽然 length[0] 即为 0,但是我们想让调用短路返回,而不是继续执行。
    // 这个 if 不可省略。
    if (length == 0) return 0

    def max = -1

    // "左杆" s1 的长度至少为 1,否则就没意义。
    // 当 length = 1 时, length -1 = 0, 调用会进入临界条件。
    for (i in 1..length) {
        def p = rodPrices[i] + rodCut(rodPrices, length - i)
        if (p > max) max = p
    }

    return max
}

现在提出一个新的要求:不仅要求出最大利润,还要给出详细的切割方案。比如:长度为 27 的杆,它的最大利润是 43,切割方案是:[5,5,5,5,5,2] ,数组内的每个元素是短杆的长度。

此时不仅要在迭代过程中寻求最优解,还要在此记录每一次求得最优解时短杆的长度。出于这个目的,我们设计出了如下数据结构:

@Canonical
final class Plan {
    Integer amount
    ArrayList splits
}

Plan 表示一个切割方案,比如 new Plan(amount: 4, splits:[1,2]) 表示这个切割方案中有长度为 1 和 2 的两个子杆,这个方案的总收入为 4。

splits 是线性表,用于记录短杆的长度。可以预见的是:长杆 Plan 的创建基于短杆 Plan,新的 splits 总是之前 Plan 的 splits 再插入一个新的元素,而链表能够从容地应对不断追加元素的场合,所以 splits 被设置为 ArrayList 类。

第二版解决方案也出炉了:

@Newify(Plan)
def rodCut(rodPrices, length) {

    if (length == 0) return Plan(amount: 0, splits: [])

    def max = Plan(amount: -1, splits: [])

    // 至少切割成 (1,length-1),否则没有意义。
    // 当 length = 1 时, length - 1 = 0, 要考虑到边界条件。
    for (i in 1..length) {
        
        // left : 这里是 "剩下的" 意思。
        // 获得 "右杆" s2 的最优 Plan
        def leftRod = rodCut(rodPrices, length - i)

        // 推算当前迭代中的 "最优" Plan = "左杆" Plan + "右杆" Plan
        def try_ = Plan(amount: rodPrices[i] + leftRod.amount, splits: leftRod.splits + i)

        if (try_.amount > max.amount) max = try_
    }

    // 返回实际的长度 Length 的杆所对应的最优 Plan.
    return max
}

注意,这里存在 Groovy 的语法糖:那就是 leftRod.split + i。这是一个操作符重载,实际上可理解为 leftRod.splits.add(i) 。该算法的 Groovy 闭包版本是:

def rodCuts

rodCut = {
    rod_prices, length ->
        if (length == 0) return new Plan(amount: 0, splits: [])
        def max = new Plan(amount: -1, splits: [])
        for (i in 1..length) {
            def leftRod = rodCut(rodPrices, length - i)
            def try_ = new Plan(amount: rodPrices[i] + leftRod.amount, splits: leftRod.splits + i)
            if (try_.amount > max.amount) max = try_
        }
        return max
}

4.8.1 利用记忆化改进性能

人们常说:过早的优化是万恶之源。因此在之前笔者刻意回避了 rodCut 函数的运行效率。运行第二版的函数 / 闭包,程序需要运行大约 1 ~ 2 分钟的时间才能给出答案。

为什么会这样?我们其实还忽略了第五个要点:那就是将大问题分解为小问题时,应当避免对重复的小问题求解。比如:"将长为 10 的杆分为两个 5" 和 "将长为 8 的杆分成 3 和 5" 的两个情形都涉及到 "求杆长为 5 的最优解"。很不巧的是,在目前的递归过程中,rodCut 函数对传进来的 length 几乎来者不拒,却从来不检讨这个计算有无必要。

一个简单却十分有效的办法是,在调用这个函数的同时,传入一个足够长的 cache 数组作为缓存。其中,cache[n] 存储了长为 n 的杆的最佳方案,但在最开始,所有位置都是 null 。不过每次在 rodCut 返回计算结果之前,都会顺带着将它写入到这个缓存的对应位置内。

在后续的递归调用中,程序优先从查找缓存的对应位置:如果该位置非 null,则意味着当前调用能够立刻返回计算结果,而非重新计算。

def rodCuts_cached

rodCuts_cached = {

    Integer[] rod_prices, Integer length, Plan[] cache ->

        if (cache[length] != null) return cache[length]
        if (length == 0) return new Plan(amount: 0, splits: [])
        def max = new Plan(amount: 0, splits: [])

        for (i in 1..length) {
            def leftRod = rodCuts_cached(rod_prices, length - i, cache)
            def try_ = new Plan(amount: rod_prices[i] + leftRod.amount, splits: leftRod.splits + i)
            if (try_.amount > max.amount) {
                max = try_
            }
        }
        // 记录当前最优解。
        // 注意,传入的 cache 是引用,这意味着任意一处递归都能够随时感知 cache 的变化。
        cache[length] = max
        return max
}

依照这个思路,我们重新给出了第三版代码。在同等运行条件下,它的运算时间只相当于第二版的 1%。

这个套路其实和之前 n 阶乘的优化思路非常相像,那就是想方设法保存中间结果来节省后续递归的工作量,无论从形式上是值传递一个计算结果,还是引用传递一块缓存。如果为这个套路起一个专业点的名字,那么它就是 记忆化 ( Memoization ) 。

4.8.2 画龙点睛之 memoize()

我们已经从实例中理解并体会了函数记忆化带来的性能飞跃,体贴的 Groovy 同样也更理解我们。在其它语言中,我们可能会为了实现缓存而手写一段逻辑代码 ( 像第三版代码那样 ) 。但至少在 Groovy 中,实际的工作量就是在原有闭包的基础之上再补充上一个 momonize() 方法调用。

def rodCuts
rodCut = {
    rod_prices, length ->
        if (length == 0) return new Plan(amount: 0, splits: [])
        def max = new Plan(amount: -1, splits: [])
        for (i in 1..length) {
            def leftRod = rodCuts(rod_prices, length - i)
            def try_ = new Plan(amount: rodPrices[i] + leftRod.amount, splits: leftRod.splits + i)
            if (try_.amount > max.amount) max = try_
        }
        return max
}.memoize()

Groovy 会为此创建一个自带缓存空间的闭包,然后就像我们所做的一样,闭包在递归自身时会优先从缓存中读取运算结果,除此之外,Groovy 还考虑到了一些线程安全的内容。由于机制大体相同,因此这一版的运行效率跟第三版代码几乎差不多,而区别是 "我们操心的活变得更少了"。

根据天下没有免费的午餐定理,争取运算时间的代价将是牺牲或多或少的空间。如果问题的规模变得夸张一些,那么对空间的占用就无法忽略 ( 尤其是在本例中,Plan 还是一个携带链表的类 ) 。Groovy 将选择的权利交给了我们,因此 memonized() 还衍生出了 memonizeAtMost() 方法以及其它变种。当缓存达到临界状态时,Groovy 采取 LRU ( Least Last Used ) 策略对缓存内容进行替换。