本文修改自:尾递归与Continuation - 老赵点滴 - 追求编程之美
http://blog.zhaojie.me/2009/03/tail-recursion-and-continuation.html原文是用C#描述的,但由于本人对C#并不是十分熟悉,无法快速理解,便用Haskell重写了一遍
关于递归操作,相信大家都已经不陌生。简单地说,一个函数直接或间接地调用自身,是为直接或间接递归。例如,我们可以使用递归来计算一个单向链表的长度:
let vector = [1, 2, 3]
编写一个递归的length’方法:
length' :: [Int] -> Int
length' [] = 0
length' x:xs = 1 + length xs
在调用时,length’方法会不断调用自身,直至满足递归出口。对递归有些了解的朋友一定猜得到,在立即求值的语言里,如果单项链表十分长,那么上面这个方法就可能会遇到栈溢出,也就是抛出StackOverflowException。这是由于每个线程在执行代码时,都会分配一定尺寸的栈空间(Windows系统中为1M),每次方法调用时都会在栈里储存一定信息(如参数、局部变量、返回地址等等),这些信息再少也会占用一定空间,成千上万个此类空间累积起来,自然就超过线程的栈空间了。(当然在Haskell里,活动记录以图上的节点形式存放在堆Heap,没有这个问题。并且由于惰性求值的缘故,时间复杂度为O(n),而在命令语言里为O(1.6^n)。)不过这个问题并非无解,我们只需把递归改成如下形式即可(在这篇文章里我们不考虑非递归的解法):
length'' :: [Int] -> Int -> Int
length'' [] acc = acc
length'' x:xs acc = length'' xs (acc + 1)
length”方法多了一个acc参数,acc的为accumulator(累加器)的缩写,它的功能是在递归调用时“积累”之前调用的结果,并将其传入下一次递归调用中——这就是length”方法与length’方法相比在递归方式上最大的区别:length’方法在递归调用后还需要进行一次“+1”,而length”的递归调用属于方法的最后一个操作。这就是所谓的“尾递归”。与普通递归相比,由于尾递归的调用处于方法的最后,因此方法之前所积累下的各种状态对于递归调用结果已经没有任何意义,因此完全可以把本次方法中留在堆栈中的数据完全清除,把空间让给最后的递归调用。这样的优化便使得递归不会在调用堆栈上产生堆积,意味着即时是“无限”递归也不会让堆栈溢出。这便是尾递归的优势。
有些朋友可能已经想到了,尾递归的本质,其实是将递归方法中的需要的“所有状态”通过方法的参数传入下一次调用中。对于length”方法,我们在调用时需要给出acc参数的初始值:
length'' vector 0
为了进一步熟悉尾递归的使用方式,我们再用著名的“菲波纳锲”数列作为一个例子。传统的递归方式如下:
fibonacci :: Int -> Int
fibonacci 0 = 0
fibonacci 1 = 1
fibonacci n = fibonacci (n - 1) + fibonacci (n - 2)
而改造成尾递归,我们则需要提供两个累加器:
fibonacci' :: Int -> Int -> Int -> Int
fibonacci' 0 acc1 acc2 = acc1
fibonacci' n acc1 acc2 = fibonacci' (n - 1) acc2 (acc1 + acc2)
于是在调用时,需要提供两个累加器的初始值:
fibonacci' 10 0 1
Continuation,即为“完成某件事情”之后“还需要做的事情”。例如,在.NET中标准的APM调用方式,便是由BeginXXX方法和EndXXX方法构成,这其实便是一种Continuation:在完成了BeginXXX方法之后,还需要调用EndXXX方法。而这种做法,也可以体现在尾递归构造中。例如以下为阶乘方法的传统递归定义:
factorial :: Int -> Int
factorial 1 = 1
factorial n = n * factorial (n - 1)
显然,这不是一个尾递归的方式,当然我们轻易将其转换为之前提到的尾递归调用方式。不过我们现在把它这样“理解”:每次计算n的阶乘时,其实是“先获取n - 1的阶乘”之后再“与n相乘并返回”,于是我们的factorial方法可以改造成:
factortalRecursively :: Int -> Int
factortalRecursively n = factorialContinuation (n - 1) (\r -> n * r)
-- factorialContinuation n (\r -> r)
factorialContinuation :: Int -> (Int -> Int) -> Int
factorialContinuation n continuation = ...
factorialContinuation方法的含义是“计算n的阶乘,并将结果传入continuation方法,并返回其调用结果”。于是,很容易得出,FactorialContinuation方法自身便是一个递归调用:
factorialContinuation :: Int -> (Int -> Int) -> Int
factorialContinuation n continuation =
factorialContinuation (n - 1) (\r -> continuation (n * r))
factorialContinuation方法的实现可以这样表述:“计算n的阶乘,并将结果传入continuation方法并返回”,也就是“计算n - 1的阶乘,并将结果与n相乘,再调用continuation方法”。为了实现“并将结果与n相乘,再调用continuation方法”这个逻辑,代码又构造了一个lambda表达式,再次传入factorialContinuation方法。当然,我们还需要为它补充递归的出口条件:
factorialContinuation :: Int -> (Int -> Int) -> Int
factorialContinuation 0 continuation = continuation 1
factorialContinuation n continuation =
factorialContinuation (n - 1) (\r -> continuation (n * r))
很明显,factorialContinuation实现了尾递归。如果要计算n的阶乘,我们需要如下调用factorialContinuation方法,表示“计算10的阶乘,并将结果直接返回”:
factorialContinuation 10 (\x -> x)
再加深一下印象,大家是否能够理解以下计算“菲波纳锲”数列第n项值的写法?
fibonacciContinuation :: Int -> (Int -> Int) -> Int
fibonacciContinuation 0 continuation = continuation 0
fibonacciContinuation 1 continuation = continuation 1
fibonacciContinuation n continuation =
fibonacciContinuation (n - 1) (\r1 -> fibonacciContinuation (n - 2) (\r2 -> continuation (r1 + r2)))
在函数式编程中,此类调用方式便形成了“Continuation Passing Style(CPS)”。