尾调用-尾递归
[原文链接]
相关文章:
在计算机科学里,尾调用是指一个函数里的最后一个动作是一个函数调用的情形:即这个调用的返回值直接被当前函数返回的情形。这种情形下称该调用位置为尾位置。若这个函数在尾位置调用本身(或是一个尾调用本身的其他函数等等),则称这种情况为尾递归,是递归的一种特殊情形。尾调用不一定是递归调用,但是尾递归特别有用,也比较容易实现。
尾调用的重要性在于它可以不在调用栈上面添加一个新的堆栈帧——而是更新它,如同迭代一般。尾递归因而具有两个特征:
- 调用自身函数(Self-called);
- 计算仅占用常量栈空间(Stack Space)。
而形式上只要是最后一个return
语句返回的是一个完整函数,它就是尾递归。[1]
由于当前函数帧上包含局部变量等等大部分的东西都不需要了,当前的函数帧经过适当的更动以后可以直接当作被尾调用的函数的帧使用,然后程序即可以跳到到被尾调用的函数。产生这种函数帧更动代码与 “jump”(而不是一般常规函数调用的代码)的过程称作尾调用消除(Tail Call Elimination)或尾调用优化(Tail Call Optimization, TCO)。尾调用优化让位于尾位置的函数调用跟 goto
叙述性能一样高,也因此使得高效的结构编程成为现实。
一般来说,尾调用消除是可选的。然而,在函数编程语言中,语言标准通常会要求虚拟机实现尾调用消除,这让程序员可以用递归取代循环而不丧失性能。
描述
当一个函数调用发生时,电脑必须 “记住” 调用函数的位置 — 返回位置,才可以在调用结束时带着返回值回到该位置,返回位置一般存在调用栈上。在尾调用的情况中,电脑不需要记住尾调用的位置而可以从被调用的函数直接带着返回值返回调用函数的返回位置(相当于直接连续返回两次),尾调用消除即是在不改变当前调用栈(也不添加新的返回位置)的情况下跳到新函数的一种优化(完全不改变调用栈是不可能的,还是需要校正调用栈上形参与局部变量的信息。[2])
对函数调用在尾位置的递归或互相递归的函数,由于函数自身调用次数很多,递归层级很深,尾递归优化则使原本 O(n) 的调用栈空间只需要 O(1)。因此一些编程语言的标准要求语言实现进行尾调用消除,例如 Scheme[3][4]与 ML 家族的语言。在 Scheme 中,语言标准还将尾位置形式化,指定了各种语法中允许尾调用的地方[5]。
以 Python 为例,主要区分普通递归和尾递归对栈空间的使用:
def recsum(x): if x == 1: return x else: return x + recsum(x - 1)
调用recsum(5)
为例,SICP中描述了相应的栈空间变化[6][7]:
recsum(5) 5 + recsum(4) 5 + (4 + recsum(3)) 5 + (4 + (3 + recsum(2))) 5 + (4 + (3 + (2 + recsum(1)))) 5 + (4 + (3 + (2 + 1))) 5 + (4 + (3 + 3)) 5 + (4 + 6) 5 + 10 15
可观察,堆栈从左到右,增加到一个峰值后再计算从右到左缩小,这往往是我们不希望的,所以在C语言等语言中设计for, while, goto
等特殊结构语句,使用迭代、尾递归,对普通递归进行优化,减少可能对内存的极端消耗。修改以上代码,可以成为尾递归:
def tailrecsum(x, running_total=0): if x == 0: return running_total else: return tailrecsum(x - 1, running_total + x)
或者使用迭代:
for i in range(6): sum += i
对比后者尾递归对内存的消耗:
tailrecsum(5, 0) tailrecsum(4, 5) tailrecsum(3, 9) tailrecsum(2, 12) tailrecsum(1, 14) tailrecsum(0, 15) 15
则是线性的。
语法上的表现
尾调用可能位于一个函数语法上最后的位置:
function foo(data) { a(data); return b(data); }
在这里,a(data)
、b(data)
都是函数调用,但是 b(data)
是函数返回前的最后运行的东西,所以也是所谓的尾位置。然后,并非所有的尾调用都必须在一个函数语法上最后的位置。考虑:
function bar(data) { if ( a(data) ) { return b(data); } return c(data); }
在这里,b
、c
的调用都在尾位置。这是因为尽管 b(data)
不在 bar
语法上最后的位置,它是 if
叙述其中一个分支最后的位置。
现在考虑以下代码:
function foo1(data) { return a(data) + 1; }
function foo2(data) { var ret = a(data); return ret; }
function foo3(data) { var ret = a(data); return (ret === 0) ? 1 : ret; }
在这里,a(data)
处于 foo2
的尾位置,但不处于 foo1
或 foo3
的尾位置。这是因为程序必须返回这两个 a
函数的调用以检查、更动 a
的返回值。
实例
通常被用于解释递归的程序是计算阶乘。以下计算阶乘的 Scheme 程序不是尾部递归,而只是一般递归[8]:
(define (factorial n) (if (= n 1) 1 (* n (factorial (- n 1)))))
因此,如果调用 factorial
时的参数 n
足够大,这一程序会出现堆栈溢出。然而,如果将同一程序写作尾部递归,按 Scheme 的标准将不会出现溢出[8]:
(define (factorial n) (define (iter product counter) (if (> counter n) product (iter (* counter product) (+ counter 1)))) (iter 1 1))
在第二个程序中,注意 iter
函数直接返回其递归调用,而没有对其进行运算。因此,这是一个尾部递归,这让直译器或编译器将本来是
call factorial (3) call iter (3 1) call iter (2 3) call iter (1 6) call iter (0 6) return 6 return 6 return 6 return 6 return 6
的运行过程组合成在时间、空间上性能都较好的型态:
call factorial (3) call iter (3 1) 将参数变为 (2 3),跳至 "iter" 将参数变为 (1 6),跳至 "iter" 将参数变为 (0 6),跳至 "iter" return 6 return 6
因为在中间过程中重复使用 iter
的函数帧,这种重组节省了空间。这也代表程序员不需要为了担心栈空间或是堆空间用完。在一般的实现中,尾部递归的型态也比其他型态更快速,不过仅仅是常量倍数的差异(非指数差异)。
很多使用函数语言的程序员会为了使用这个优化将递归的代码写成为尾部递归的形式。这通常需要一个多出来代表 “搜集器” 的形参(上述例子的 product
参数)。在一些语言中的一些函数的实现中(像是过滤一个列的实现等等),如果要使用尾部递归则需要将本来没有副作用的纯函数改写成会更动其他参引的形式[来源请求]。
实现
在 Perl 里,程序员可以直接用一种带有函数名称的 “goto” 叙述变体:goto &NAME;
直接使用尾调用[9]。
在程序语言实现中,消除尾递归里的尾调用比消除一般的尾调用容易很多。举里来说,Java 虚拟机(JVM)的实现会消除尾递归里的尾调用(因为重新使用了原来的调用栈),但是不会消除一般的尾调用(因为改变了的调用栈)[10][11]。因此,Scala 等等使用 JVM 平台的函数语言可以有效的实现一个函数的尾递归,但是两个函数以上的尾递归就不行。
尾递归的实现方法有几个:
汇编重组
对于直接生成汇编的编译器,尾部调用消除很简单:只要校正栈上的形参之后把 “call” 的机器码换成一个 “jump” 的就行了。从编译器的观点,以下代码
function foo() return a()
先会被翻译成(这是合法的 x86 汇编):
foo: call a ret
然后,尾部调用消除指的是将最后两个指令以一个 “jump” 指令替换掉:
foo: jmp a
在 a
函数完成的时候,它会直接返回到 foo
的返回地址,省去了不必要的 ret
指令。
函数调用可能带有参数,因此生成的汇编必须确保被调用函数的函数帧在跳过去之前已设置好。举例来说,若是平台的调用栈除了返回位置以外还有函数参数,编译器需要输出调整调用栈的指令。在这类平台上,考虑代码:
function foo(data1, data2) a(data1) return b(data2)
其中 data1
、data2
是参数。编译器会把这个代码翻译成以下汇编:
foo: mov reg,[sp+data1] ; 透过栈指针(sp)取得 data1 并放到暂用暂存器。 push reg ; 将 data1 放到栈上以便 a 使用。 call a ; a 使用 data1。 pop ; 把 data1 從栈上拿掉。 mov reg,[sp+data2] ; 透过栈指針(sp)取得 data2 並放到暂用暂存器。 push reg ; 将 data2 放到栈上以便 b 使用。 call b ; b 使用 data2。 pop ; 把 data2 從栈上拿掉。 ret
尾部调用优化会将代码变成:
foo: mov reg,[sp+data1] ; 透过栈指针(sp)取得 data1 并放到暂用暂存器。 push reg ; 将 data1 放到栈上以便 a 使用。 call a ; a 使用 data1。 pop ; 把 data1 從栈上拿掉。 mov reg,[sp+data2] ; 透过栈指針(sp)取得 data2 並放到暂用暂存器。 mov [sp+data1],reg ; 把 data2 放到 b 预期的位置。 jmp b ; b 使用 data2 並返回到调用 foo 的函数。
更改后的代码不管在执行速度或是栈空间的使用上的性能都比较好。
透过弹跳床
然而,由于很多 Scheme 的编译器使用 C 作为中间目标语言,问题变成如何在 C 里在不让栈向上长的前提下实现尾部递归(假设 C 的编译器不优化尾部调用)。很多实现透过一种叫做弹跳床的装置,也就是一块不断进行函数调用的代码。所有进入函数的过程都透过这个弹跳床。当一个个函数需要尾部调用另一个函数时,它不是直接调用该函数,而是将该函数的位置、该调用使用的参数等等返回给弹跳床。这样就可以确保 C 的栈不会不会向上长而可以让循环继续运行。
用 Groovy、Visual Basic .NET、C# 等等支持高阶函数的语言实现弹跳床是可能的[12]。
注释
- ^ 什么是尾递归?
- ^ recursion - Stack memory usage for tail calls - Theoretical Computer Science. Cstheory.stackexchange.com. 2011-07-29 [2013-03-21].
- ^ Revised^6 Report on the Algorithmic Language Scheme. R6rs.org. [2013-03-21].
- ^ Revised^6 Report on the Algorithmic Language Scheme - Rationale. R6rs.org. [2013-03-21].
- ^ Revised^6 Report on the Algorithmic Language Scheme. R6rs.org. [2013-03-21].
- ^ SICP. chp1. 1.2 procedure and their computation.
- ^ What is tail recursion? - StackOverflow.
- ^ 8.0 8.1 Harold Abelson and Gerald Jay Sussman with Julie Sussman. Structure and Interpretation of Computer Programs. Cambridge, MA: MIT Press. 1996 [2011]. ISBN 0-262-01153-0 (英文).
- ^ Contact details. goto. perldoc.perl.org. [2013-03-21].
- ^ "What is difference between tail calls and tail recursion?", Stack Overflow
- ^ "What limitations does the JVM impose on tail-call optimization", Programmers Stack Exchange
- ^ Samuel Jack, Bouncing on your tail. Functional Fun. April 9, 2008.