当前位置:  开发笔记 > 编程语言 > 正文

什么是尾部呼叫优化?

如何解决《什么是尾部呼叫优化?》经验,为你挑选了7个好方法。

很简单,什么是尾部调用优化?更具体地说,任何人都可以显示一些可以应用的小代码片段,而不是在哪里,并解释为什么?



1> Kyle Cronin..:

尾调用优化是您可以避免为函数分配新堆栈帧的地方,因为调用函数将只返回它从被调用函数获得的值.最常见的用法是尾递归,其中为利用尾调用优化而编写的递归函数可以使用常量堆栈空间.

Scheme是规范中保证任何实现都必须提供此优化的少数编程语言之一(JavaScript也从ES6开始),因此这里有两个Scheme中的阶乘函数示例:

(define (fact x)
  (if (= x 0) 1
      (* x (fact (- x 1)))))

(define (fact x)
  (define (fact-tail x accum)
    (if (= x 0) accum
        (fact-tail (- x 1) (* x accum))))
  (fact-tail x 1))

第一个函数不是尾递归,因为在进行递归调用时,函数需要跟踪调用返回后它需要对结果进行的乘法运算.因此,堆栈如下所示:

(fact 3)
(* 3 (fact 2))
(* 3 (* 2 (fact 1)))
(* 3 (* 2 (* 1 (fact 0))))
(* 3 (* 2 (* 1 1)))
(* 3 (* 2 1))
(* 3 2)
6

相反,尾递归阶乘的堆栈跟踪如下所示:

(fact 3)
(fact-tail 3 1)
(fact-tail 2 3)
(fact-tail 1 6)
(fact-tail 0 6)
6

正如您所看到的,我们只需要跟踪每次调用事实尾部的相同数据量,因为我们只是将我们直接返回的值返回到顶部.这意味着即使我打电话(事实1000000),我只需要与(事实3)相同的空间.非尾递归事实不是这种情况,因此大的值可能导致堆栈溢出.


如果您想了解更多相关信息,我建议您阅读计算机程序结构和解释的第一章.
严格地说,尾调用优化不一定用调用者替换调用者的堆栈帧,而是确保尾部位置的无限数量的调用仅需要有限的空间量.请参阅Will Clinger的论文"正确的尾部递归和空间效率":http://www.cesura17.net/~will/Professional/Research/Papers/tail.pdf
@ dclowd9901,TCO允许您更喜欢功能样式而不是迭代循环.你可以选择命令式的风格.许多语言(Java,Python)都没有提供TCO,那么你必须知道函数调用需要内存......而且必须使用命令式样式.
这只是一种以恒定空间方式编写递归函数的方法吗?因为使用迭代方法无法获得相同的结果?
很好的答案,完美解释。

2> Christoph..:

让我们来看一个简单的例子:在C中实现的阶乘函数.

我们从明显的递归定义开始

unsigned fac(unsigned n)
{
    if (n < 2) return 1;
    return n * fac(n - 1);
}

如果函数返回之前的最后一个操作是另一个函数调用,则函数以尾调用结束.如果此调用调用相同的函数,则它是尾递归的.

尽管fac()乍一看看起来是尾递归的,但事实并非如此

unsigned fac(unsigned n)
{
    if (n < 2) return 1;
    unsigned acc = fac(n - 1);
    return n * acc;
}

即最后一个操作是乘法,而不是函数调用.

但是,fac()通过将累积值作为附加参数传递给调用链并仅将最终结果作为返回值再次传递,可以重写为尾递归:

unsigned fac(unsigned n)
{
    return fac_tailrec(1, n);
}

unsigned fac_tailrec(unsigned acc, unsigned n)
{
    if (n < 2) return acc;
    return fac_tailrec(n * acc, n - 1);
}

现在,为什么这有用?因为我们在尾调用之后立即返回,所以我们可以在尾部位置调用函数之前丢弃先前的堆栈帧,或者,在递归函数的情况下,按原样重用堆栈帧.

尾调用优化将我们的递归代码转换为

unsigned fac_tailrec(unsigned acc, unsigned n)
{
TOP:
    if (n < 2) return acc;
    acc = n * acc;
    n = n - 1;
    goto TOP;
}

这可以内联到fac()我们到达

unsigned fac(unsigned n)
{
    unsigned acc = 1;

TOP:
    if (n < 2) return acc;
    acc = n * acc;
    n = n - 1;
    goto TOP;
}

这相当于

unsigned fac(unsigned n)
{
    unsigned acc = 1;

    for (; n > 1; --n)
        acc *= n;

    return acc;
}

正如我们在这里看到的,一个足够先进的优化器可以用迭代替换尾递归,这样可以避免函数调用开销并且仅使用恒定量的堆栈空间.


@Kasahs:堆栈帧是"属于"给定(活动)函数的调用堆栈的一部分; cf https://en.wikipedia.org/wiki/Call_stack#Structure

3> Claudiu..:

TCO(尾调用优化)是智能编译器可以调用函数并且不占用额外堆栈空间的过程.发生这种情况唯一情况是,函数f中执行的最后一条指令是对函数g的调用(注意:g可以是f).这里的关键是f不再需要堆栈空间 - 它只是调用g然后返回g将返回的任何内容.在这种情况下,可以进行优化,g只运行并返回它对调用f的东西所具有的任何值.

这种优化可以使递归调用占用不变的堆栈空间,而不是爆炸.

示例:此阶乘函数不是TCOptimizable:

def fact(n):
    if n == 0:
        return 1
    return n * fact(n-1)

除了在return语句中调用另一个函数之外,该函数还可以执

以下功能是TCOptimizable:

def fact_h(n, acc):
    if n == 0:
        return acc
    return fact_h(n-1, acc*n)

def fact(n):
    return fact_h(n, 1)

这是因为在这些函数中发生的最后一件事是调用另一个函数.


举例说明了这个概念.只要考虑到您选择的语言必须实现尾调用或尾调优化.在用Python编写的示例中,如果输入值1000,则会出现"RuntimeError:超出最大递归深度",因为默认的Python实现不支持Tail Recursion Elimination.请参阅Guido自己的帖子,解释原因:http://neopythonic.blogspot.pt/2009/04/tail-recursion-elimination.html.
整个'函数g可以是f'的东西有点令人困惑,但我得到了你的意思,这些例子确实澄清了事情.非常感谢!

4> btiernay..:

我发现尾部调用,递归尾调用和尾调用优化的最佳高级描述可能是博客文章

"到底是什么:尾巴叫"

作者Dan Sugalski.关于尾调用优化,他写道:

暂时考虑一下这个简单的功能:

sub foo (int a) {
  a += 15;
  return bar(a);
}

那么,你或者你的语言编译器能做什么呢?那么,它能做的是将表单的代码return somefunc();转换为低级序列pop stack frame; goto somefunc();.在我们的例子中,这意味着在我们调用之前bar,foo清理自己然后,而不是调用bar作为子例程,我们goto在开始时执行低级操作bar.Foo已经从堆栈中清除了自己,所以当bar启动它时,看起来好像有人foo调用了bar,当bar它返回它的值时,它会直接将它返回给任何调用者foo,而不是将其返回到foo然后将其返回给调用者.

并在尾递归:

如果函数作为其最后一个操作返回调用自身的结果,则会发生尾递归.尾部递归更容易处理,因为你不必在某个地方跳到某个随机函数的开头,而只是回到自己的开头,这是一件很容易做的事情.

这样:

sub foo (int a, int b) {
  if (b == 1) {
    return a;
  } else {
    return foo(a*a + a, b - 1);
  }

悄然变成:

sub foo (int a, int b) {
  label:
    if (b == 1) {
      return a;
    } else {
      a = a*a + a;
      b = b - 1;
      goto label;
   }

我喜欢这个描述的是那些来自命令式语言背景的人(C,C++,Java)如何简洁易懂


404错误.但是,它仍然可以在archive.org上找到:http://web.archive.org/web/20111030134120/http://www.sidhe.org/~dan/blog/archives/000211.html
谢谢,这比最投票的方案示例更简单,更容易理解(更不用说,Scheme不是大多数开发人员理解的通用语言)

5> J Cooper..:

首先要注意的是,并非所有语言都支持它.

TCO适用于递归的特殊情况.它的要点是,如果你在函数中做的最后一件事是调用它自己(例如它从"尾部"位置调用它自己),这可以由编译器优化,就像迭代而不是标准递归.

您会看到,通常在递归期间,运行时需要跟踪所有递归调用,以便在返回时它可以在上一次调用时恢复,依此类推.(尝试手动写出递归调用的结果,以便直观地了解其工作原理.)跟踪所有调用会占用空间,当函数调用自身时会占用很多空间.但是对于TCO,它可以说"回到开头,只是这次将参数值更改为这些新值." 它可以做到这一点,因为在递归调用之后没有任何内容引用那些值.


尾调用也可以应用于非递归函数.返回前的最后一次计算是对另一个函数的调用的任何函数都可以使用尾调用.
"TCO适用于递归的特殊情况".我担心这是完全错误的.尾部呼叫适用于尾部位置的任何呼叫.通常在递归的上下文中讨论但实际上没有特别与递归有关.

6> Ciro Santill..:

GCC minimal runnable example with x86 disassembly analysis

Let's see how GCC can automatically do tail call optimizations for us by looking at the generated assembly.

This will serve as an extremely concrete example of what was mentioned in other answers such as /sf/ask/17360801/ that the optimization can convert recursive function calls to a loop.

This in turn saves memory and improves performance, since memory accesses are often the main thing that makes programs slow nowadays.

As an input, we give GCC a non-optimized naive stack based factorial:

tail_call.c

#include 
#include 

unsigned factorial(unsigned n) {
    if (n == 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

int main(int argc, char **argv) {
    int input;
    if (argc > 1) {
        input = strtoul(argv[1], NULL, 0);
    } else {
        input = 5;
    }
    printf("%u\n", factorial(input));
    return EXIT_SUCCESS;
}

GitHub upstream.

Compile and disassemble:

gcc -O1 -foptimize-sibling-calls -ggdb3 -std=c99 -Wall -Wextra -Wpedantic \
  -o tail_call.out tail_call.c
objdump -d tail_call.out

where -foptimize-sibling-calls is the name of generalization of tail calls according to man gcc:

   -foptimize-sibling-calls
       Optimize sibling and tail recursive calls.

       Enabled at levels -O2, -O3, -Os.

as mentioned at: How do I check if gcc is performing tail-recursion optimization?

I choose -O1 because:

the optimization is not done with -O0. I suspect that this is because there are required intermediate transformations missing.

-O3 produces ungodly efficient code that would not be very educative, although it is also tail call optimized.

Disassembly with -fno-optimize-sibling-calls:

0000000000001145 :
    1145:       89 f8                   mov    %edi,%eax
    1147:       83 ff 01                cmp    $0x1,%edi
    114a:       74 10                   je     115c 
    114c:       53                      push   %rbx
    114d:       89 fb                   mov    %edi,%ebx
    114f:       8d 7f ff                lea    -0x1(%rdi),%edi
    1152:       e8 ee ff ff ff          callq  1145 
    1157:       0f af c3                imul   %ebx,%eax
    115a:       5b                      pop    %rbx
    115b:       c3                      retq
    115c:       c3                      retq

With -foptimize-sibling-calls:

0000000000001145 :
    1145:       b8 01 00 00 00          mov    $0x1,%eax
    114a:       83 ff 01                cmp    $0x1,%edi
    114d:       74 0e                   je     115d 
    114f:       8d 57 ff                lea    -0x1(%rdi),%edx
    1152:       0f af c7                imul   %edi,%eax
    1155:       89 d7                   mov    %edx,%edi
    1157:       83 fa 01                cmp    $0x1,%edx
    115a:       75 f3                   jne    114f 
    115c:       c3                      retq
    115d:       89 f8                   mov    %edi,%eax
    115f:       c3                      retq

The key difference between the two is that:

the -fno-optimize-sibling-calls uses callq, which is the typical non-optimized function call.

This instruction pushes the return address to the stack, therefore increasing it.

Furthermore, this version also does push %rbx, which pushes %rbx to the stack.

GCC does this because it stores edi, which is the first function argument (n) into ebx, then calls factorial.

GCC needs to do this because it is preparing for another call to factorial, which will use the new edi == n-1.

It chooses ebx because this register is callee-saved: What registers are preserved through a linux x86-64 function call so the subcall to factorial won't change it and lose n.

the -foptimize-sibling-calls does not use any instructions that push to the stack: it only does goto jumps within factorial with the instructions je and jne.

因此,此版本等效于while循环,没有任何函数调用。堆栈使用率是恒定的。

已在Ubuntu 18.10,GCC 8.2中测试。



7> BobbyShaftoe..:

看这里:

http://tratt.net/laurie/tech_articles/articles/tail_call_optimization

您可能知道,递归函数调用可能会对堆栈造成严重破坏; 很容易快速耗尽堆栈空间.尾调用优化是一种可以创建使用常量堆栈空间的递归样式算法的方法,因此它不会增长和增长,并且会出现堆栈错误.

推荐阅读
雨天是最美
这个屌丝很懒,什么也没留下!
DevBox开发工具箱 | 专业的在线开发工具网站    京公网安备 11010802040832号  |  京ICP备19059560号-6
Copyright © 1998 - 2020 DevBox.CN. All Rights Reserved devBox.cn 开发工具箱 版权所有