递归是“以一种自相似的方式来重复事物的过程”,也是向运行时托付操作细节的一个例子,而且和函数式编程有着极为密切的联系。以具体的实践来说,递归是以一种带点计算机科学味道的方式来对一组事物进行迭代,让事物的集合反复对自身调用同样的方法,使集合随着每次迭代不断缩小,同时要始终小心地保证退出条件的有效性。
问题核心就是对一个不断变短的列表反复地做同一件事,把递归用在这样的场合,写出来的代码就容易理解。
换个角度看列表
在C或类C语言(包括Java)角度,列表是一个带索引的集合。
依靠(不一定直接露面的)索引来完成的列表遍历:
Groovy还提供了eachWithIndex()
迭代子,要求传给它的代码块带有索引参数,适用于需要显式访问索引的场合。
作为“带索引的格子”的列表形象:
在函数式语言角度,列表是由列表的第一个元素(叫作头部)和列表的其余元素(叫作尾部)这两部分组合而成。
分成头和尾两部分的列表形象:
把列表想象成头部和尾部的组合,有利于使用递归的方式来组织迭代:
以递归方式进行的列表遍历:
recurseList()
方法首先检查传入的列表里还有没有元素。如果没有,那就表示迭代工作已经完成,可以返回了。如果还有元素,那么用Groovy提供的head()
方法取出第一个元素,把它打印出来,然后继续对列表的余下部分调用recurseList()
方法。
递归方法的缺点
递归操作往往受制平台而存在一些固有的技术限制,因此这种技法绝非万灵药。但对于长度不大的列表来说,递归操作是安全的。
从长远来看,还是应该更多地投入到良好的代码结构上,技术限制总会随着时间减少或者消失化中。递归写法作为一种有缺点的代码结构,其优点并不那么直观。
命令式和递归式的对比
命令式写法的筛选函数,Groovy实现:
先创建一个新列表来存放希望保留的元素,然后对原列表进行迭代,让谓词判定每个元素的去留,最后返回保留元素的列表。
递归式写法的筛选函数,Groovy实现:
filter()
函数首先检查传入的列表的大小,若列表中已经没有元素,则返回列表。否则用筛选条件检查列表的头部,如果头部满足筛选条件,就把它放入列表(代码中用了一个空列表“[]”作为初始值来保证返回类型是正确的),不然就继续递归地对列表的尾部筛选下去。
谁来管理状态?在命令式的写法中,是“我”在管理状态。“我”必须创建一个叫new_list
的新变量,“我”负责向新列表添加元素,“我”负责在筛选完成后返回新列表。而在递归写法中,是语言在管理返回值,它从递归栈里收集每次方法调用的返回结果,构造出最终的返回值。递归例子中的filter()
方法的每一条结束路径都返回到递归调用的上一层,随着栈中的调用一层一层地返回,各层得到的中间结果也自动汇集到一起。于是程序员卸下了对new_list
的管理责任,交由语言去替我们照料。
利用递归,把状态的管理责任推给运行时。
Scala语言递归的实现(还含柯里化)
Scala的列表构造运算符“
::
”起到了提高代码可读性的作用,筛选通过和不通过这两种情形下返回结果的变动,表述得清晰易懂。filter()
方法递归地使用参数p
来筛选一个整数列表,其中参数p
是一个布尔函数,或者按照函数式领域的术语叫作“谓词”(predicate)函数。filter()
方法检查列表是否为空,若是则直接返回;否则用谓词来检验列表的第一个元素(xs.head
),判断是否应放入筛选后的列表。
如果头部满足谓词条件,那么就返回以该头部为首,再加上尾部的筛选结果组成的新列表。如果头部通不过谓词的检验,返回的就只有列表余下部分的筛选结果。
递归对开发者的解放效果或许不像垃圾收集那么显著,不过它切实地揭示了编程语言的一个重要的发展方向:通过移交“不确定因素”的控制权给运行时来消解它们。如果程序员不准插手列表操作的中间结果,那么就不会引入那些在交互中产生的错误。
尾调用优化
递归没有成为一种平常的操作,其中一个主要原因是栈的增长。递归操作一般的实现方式,都是把中间结果放在栈里,于是没有为递归专门优化的语言就会遇到栈溢出的问题。
而像Scala、Clojure这些语言则各自采用了不同的方式来规避这方面的局限。开发者也可以使用尾调用优化(tail-call optimization)的写法来帮助运行时克服栈的增长问题。当递归调用是函数执行的最后一个调用的时候,运行时往往可以在栈里就地更新,而不需要增加新的栈空间。
很多函数式语言实现了没有栈增长的尾递归。Erlang用尾递归来实现长时间运行的进程,相当于运行在应用里面的一系列微服务,它们从别的进程接收消息,并按照消息中的要求来代表别的进程执行任务。这些接收消息并受消息左右的尾递归循环还有调整微服务内部状态的能力,因为对不可变的当前状态的任何作用结果,都可以放在表示新状态的变量里传入下一轮递归而生效。
递归在开发中用得不多的原因
应该部分地归咎于大多数命令式语言呆滞的语法配合,让一件不太容易的事情变得难上加难。函数式语言的简洁语法和灵活配合,才使递归成为简单可行的代码重用选项之一。