尾调用

✍ dations ◷ 2024-12-22 18:05:17 #计算机编程,控制流程

在计算机学里,尾调用是指一个函数里的最后一个动作是返回一个函数的调用结果的情形,即最后一步新调用的返回值直接被当前函数的返回结果。此时,该尾部调用位置被称为尾位置。尾调用中有一种重要而特殊的情形叫做尾递归。经过适当处理,尾递归形式的函数的运行效率可以被极大地优化。尾调用原则上都可以通过简化函数调用栈的结构而获得性能优化(称为“尾调用消除”),但是优化尾调用是否方便可行取决于运行环境对此类优化的支持程度如何。

在计算机科学里,尾调用是指一个函数里的最后一个动作是一个函数调用的情形:即这个调用的返回值直接被当前函数返回的情形。这种情形下称该调用位置为尾位置。若这个函数在尾位置调用本身(或是一个尾调用本身的其他函数等等),则称这种情况为尾递归,是递归的一种特殊情形。尾调用不一定是递归调用,但是尾递归特别有用,也比较容易实现。

在程序运行时,计算机会为应用程序分配一定的内存空间;应用程序则会自行分配所获得的内存空间,其中一部分被用于记录程序中正在调用的各个函数的运行情况,这就是函数的调用栈。常规的函数调用总是会在调用栈最上层添加一个新的堆栈帧(stack frame,也翻译为“栈帧”或简称为“帧”),这个过程被称作“入栈”或“压栈”(意即把新的帧压在栈顶)。当函数的调用层数非常多时,调用栈会消耗不少内存,甚至会撑爆内存空间(栈溢出),造成程序严重卡顿或意外崩溃。尾调用的调用栈则特别易于优化,从而可减少内存空间的使用,也能提高运行速度。其中,对尾递归情形的优化效果最为明显,尤其是递归算法非常复杂的情形。

一般来说,尾调用消除是可选的,可以用,也可以不用。然而,在函数编程语言中,语言标准通常会要求编译器或运行平台实现尾调用消除。这让程序员可以用递归取代循环而不丧失性能。

尾调用 (tail call) 指的是一个函数的最后一条语句也是一个返回调用函数的语句。在函数体末尾被返回的可以是对另一个函数的调用,也可以是对自身调用(即自身递归调用)。

尾调用可能位于一个函数语法上最后的位置:

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);}

在这里,bc 的调用都在尾位置。这是因为尽管 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 的尾位置,但不处于 foo1foo3 的尾位置。这是因为程序必须返回这2个 a 函数的调用以检查、更动 a 的返回值。

传统模式的编译器对于尾调用的处理方式就像处理其他普通函数调用一样,总会在调用时创建一个新的栈帧(stack frame)并将其推入调用栈顶部,用于表示该次函数调用。

当一个函数调用发生时,电脑必须 “记住” 调用函数的位置 —— 返回位置,才可以在调用结束时带着返回值回到该位置,返回位置一般存在调用栈上。在尾调用这种特殊情形中,电脑理论上可以不需要记住尾调用的位置而从被调用的函数直接带着返回值返回调用函数的返回位置(相当于直接连续返回两次)。尾调用消除即是在不改变当前调用栈(也不添加新的返回位置)的情况下跳到新函数的一种优化(完全不改变调用栈是不可能的,还是需要校正调用栈上形式参数与局部变量的信息。)

由于当前函数帧上包含局部变量等等大部分的东西都不需要了,当前的函数帧经过适当的更动以后可以直接当作被尾调用的函数的帧使用,然后程序即可以跳到被尾调用的函数。产生这种函数帧更动代码与 “jump”(而不是一般常规函数调用的代码)的过程称作尾调用消除(Tail Call Elimination)或尾调用优化(Tail Call Optimization, TCO)。尾调用优化让位于尾位置的函数调用跟 goto 语句性能一样高,也因此使得高效的结构编程成为现实。

然而,对于 C++ 等语言来说,在函数最后 return g(x); 并不一定是尾递归——在返回之前很可能涉及到对象的析构函数,使得 g(x) 不是最后执行的那个。这可以通过返回值优化来解决。

若函数在尾位置调用自身(或是一个尾调用本身的其他函数等等),则称这种情况为尾递归。尾递归也是递归的一种特殊情形。尾递归是一种特殊的尾调用,即在尾部直接调用自身的递归函数。对尾递归的优化也是关注尾调用的主要原因。尾调用不一定是递归调用,但是尾递归特别有用,也比较容易实现。

尾递归在普通尾调用的基础上,多出了2个特征:

对函数调用在尾位置的递归或互相递归的函数,由于函数自身调用次数很多,递归层级很深,尾递归优化则使原本 O(n) 的调用栈空间只需要 O(1)。因此一些编程语言的标准要求语言实现进行尾调用消除,例如 Scheme与 ML 家族的语言。在 Scheme 中,语言标准还将尾位置形式化,指定了各种语法中允许尾调用的地方。

以 Python 为例,主要区分普通递归和尾递归对栈空间的使用:

def recsum(x):  if x == 1:    return x  else:    return x + recsum(x - 1)

调用recsum(5)为例,SICP中描述了相应的栈空间变化:

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 + 1015

可观察,堆栈从左到右,增加到一个峰值后再计算从右到左缩小,这往往是我们不希望的,所以在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

则是线性的。

要方便地实现尾调用优化,一般需借助编译器或运行环境提供的现成的尾递归优化特性,或是依赖所用程序语言能直接支持更底层的指令跳转。

在 Perl 里,程序员可以直接用一种带有函数名称的 “goto” 叙述变体:goto &NAME; 直接使用尾调用。

在程序语言实现中,消除尾递归里的尾调用比消除一般的尾调用容易很多。举例来说,Java 虚拟机(JVM)的实现会消除尾递归里的尾调用(因为重新使用了原来的调用栈),但是不会消除一般的尾调用(因为改变了的调用栈)。Scala 等同样基于 JVM 平台的语言可以有效地实现单个函数的尾递归优化,但是对于多个函数的相互尾递归就无法优化了。

JavaScript则原本不支持尾调用优化,到其第6代语言核心标准“ECMAScript 6”开始规定程序引擎应在严格模式下使用尾调用优化。而且ECMAScript 6限定了尾位置不含闭包的尾调用才能进行优化。

对于直接生成汇编的编译器,尾部调用消除很简单:只要校正栈上的形参之后把 “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)

其中 data1data2 是参数。编译器会把这个代码翻译成以下汇编:

foo:  mov  reg, ; 透过栈指针(sp)取得 data1 并放到暂用暂存器。  push reg            ; 将 data1 放到栈上以便 a 使用。  call a              ; a 使用 data1。  pop                 ; 把 data1 從栈上拿掉。  mov  reg, ; 透过栈指針(sp)取得 data2 並放到暂用暂存器。   push reg            ; 将 data2 放到栈上以便 b 使用。   call b              ; b 使用 data2。  pop                 ; 把 data2 從栈上拿掉。  ret

尾部调用优化会将代码变成:

foo:  mov  reg, ; 透过栈指针(sp)取得 data1 并放到暂用暂存器。  push reg            ; 将 data1 放到栈上以便 a 使用。  call a              ; a 使用 data1。  pop                 ; 把 data1 從栈上拿掉。  mov  reg, ; 透过栈指針(sp)取得 data2 並放到暂用暂存器。    mov  ,reg ; 把 data2 放到 b 预期的位置。  jmp  b              ; b 使用 data2 並返回到调用 foo 的函数。

更改后的代码不管在执行速度或是栈空间的使用上的性能都比较好。

由于很多 Scheme 的编译器使用 C 作为中间目标语言,问题可转化为如何在 C 里在不让栈向上增长的前提下实现尾部递归(假设 C 的编译器不优化尾部调用)。很多实现透过一种叫做弹跳床(英语:Trampoline_(computing)) (trampoline)的设备,也就是一块不断进行函数调用的代码。所有函数代码的加载过程都透过这个弹跳床。当一个函数需要调用另一个函数时,它不是直接调用该函数,而是将该函数的位置、该调用使用的参数等信息传递给弹跳床,让爱插手的弹跳床去代为执行。这样就可以确保 C 的栈不会向上增长并且可以让迭代无限地继续。

用 Groovy、Visual Basic .NET、C# 等等支持高阶函数的语言实现弹跳床是可能的。

通常被用于解释递归的程序是计算阶乘。以下计算阶乘的 Scheme 程序不是尾部递归,而只是一般递归:

(define (factorial n)  (if (= n 1)      1      (* n (factorial (- n 1)))))

因此,如果调用 factorial 时的参数 n 足够大,这一程序会出现堆栈溢出。然而,如果将同一程序写作尾部递归,按 Scheme 的标准将不会出现溢出:

(define (factorial n)  (define (iter product counter)    (if (> counter n)        product        (iter (* counter product)              (+ counter 1))))  (iter 1 1))

在第2个程序中,注意 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 参数)。在一些语言中的一些函数的实现中(像是过滤一个列的实现等等),如果要使用尾部递归则需要将本来没有副作用的纯函数改写成会更动其他参引的形式。

相关