关于算法:什么是尾部调用优化?

What Is Tail Call Optimization?

很简单,什么是尾调用优化?更具体地说,是否有人能给出一些小的代码片段,在哪里可以应用,在哪里不能,并解释为什么?


尾调用优化就是你能避免新的堆栈帧allocating for a函数调用函数要返回简单的因为它的价值得到了从所谓的功能。最普遍使用的是尾递归,递归函数写在一个利用尾调用优化CAN使用恒定的栈空间。

计划是一个主要的编程语言,这是担保在任何规格的实现必须提供本优化(JavaScript做所以,最有es6),这是两个例子:在计划功能的因素

1
2
3
4
5
6
7
8
9
(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))

第一个是一个递归函数,因为当尾递归函数调用是由轨道,需要保持它的乘法需要做后,调用返回的结果。作为搜索,看起来如下:堆栈。

1
2
3
4
5
6
7
8
(fact 3)
(* 3 (fact 2))
(* 3 (* 2 (fact 1)))
(* 3 (* 2 (* 1 (fact 0))))
(* 3 (* 2 (* 1 1)))
(* 3 (* 2 1))
(* 3 2)
6

在对比,堆栈跟踪的因素如下:尾递归的外观。

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

你可以看到,我们只需要把履带相同数据量为每一个调用的事实,因为我们是简单的尾巴是通过返回值,我们得到的顶部。这意味着,即使一个呼叫(FACT万),我需要的空间只有相同的金额为(FACT)。这不是案例与非尾递归搜索值的事实,当大可能造成堆栈溢出。


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

我们从显而易见的递归定义开始

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

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

虽然fac()乍一看似乎是递归的尾部,但实际情况并非如此

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

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

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

1
2
3
4
5
6
7
8
9
10
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);
}

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

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

1
2
3
4
5
6
7
8
unsigned fac_tailrec(unsigned acc, unsigned n)
{
TOP:
    if (n < 2) return acc;
    acc = n * acc;
    n = n - 1;
    goto TOP;
}

这可以输入到fac()中,我们得出

1
2
3
4
5
6
7
8
9
10
unsigned fac(unsigned n)
{
    unsigned acc = 1;

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

相当于

1
2
3
4
5
6
7
8
9
unsigned fac(unsigned n)
{
    unsigned acc = 1;

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

    return acc;
}

正如我们在这里看到的,一个足够高级的优化器可以用迭代来替换尾部递归,这样做效率更高,因为您可以避免函数调用开销,并且只使用恒定数量的堆栈空间。


TCO(尾调用优化)是由这一过程A调用编译器的CAN智能化功能和没有额外的栈空间带。如果发生这种情况,这是一个负载指令执行,如果在函数f是一个函数调用(注:A G F G可以)。这里的关键是,不再需要堆栈空间F和G,然后返回调用它的任何G是返回。在本案例可以做的优化运行和返回任何值G,只是它会叫的东西。

这个递归优化CAN化妆需要花费比恒定的堆栈空间,而不是分裂。

例子:这是一个tcoptimizable因子函数:

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

这东西除了调用另一个函数的函数中的return语句的信息。

这是tcoptimizable以下功能:

1
2
3
4
5
6
7
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)

这是最后的事情,因为这些函数的一个调用另一个函数。


对于尾调用、递归尾调用和尾调用优化,我找到的最好的高级描述可能是博客帖子。

"见鬼的是:一个追尾电话"

作者:丹·苏加斯基。关于尾叫优化,他写道:

Consider, for a moment, this simple function:

1
2
3
4
sub foo (int a) {
  a += 15;
  return bar(a);
}

So, what can you, or rather your language compiler, do? Well, what it can do is turn code of the form return somefunc(); into the low-level sequence pop stack frame; goto somefunc();. In our example, that means before we call bar, foo cleans itself up and then, rather than calling bar as a subroutine, we do a low-level goto operation to the start of bar. Foo's already cleaned itself out of the stack, so when bar starts it looks like whoever called foo has really called bar, and when bar returns its value, it returns it directly to whoever called foo, rather than returning it to foo which would then return it to its caller.

尾部递归:

Tail recursion happens if a function, as its last operation, returns
the result of calling itself. Tail recursion is easier to deal with
because rather than having to jump to the beginning of some random
function somewhere, you just do a goto back to the beginning of
yourself, which is a darned simple thing to do.

因此:

1
2
3
4
5
6
sub foo (int a, int b) {
  if (b == 1) {
    return a;
  } else {
    return foo(a*a + a, b - 1);
  }

悄悄地变成:

1
2
3
4
5
6
7
8
9
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)的人。


所有这一切需要的是第一级的语言支持。

一个特殊的情况下,TCO applys递归。它的依据是,如果你在最后的事是调用函数本身(例如,它是呼叫它的"尾巴"的位置),这可以由编译器的优化,而不是像标准的递归迭代。

你看,通常在运行时需要保持递归,递归调用跟踪一切,这样当它在一个返回和恢复以前的呼叫等等。(try out the result of a手动编写递归调用get a本工程知识可视化的主意。)所有的电话需要保鲜的轨道上的空间,重要的是当函数调用,那么它的焊料。但与TCO,它只是说"回到一开始只改变参数时,这一新的价值命题"。它可以做什么,因为我们是一个递归调用后的值。


看这里:

tratt.net http:/ / / / /技术_劳丽文章文章/尾调用优化_ _

你可能知道,我wreak递归函数调用堆栈上的CAN,它很容易迅速跑出去的堆栈空间。尾调用优化单是由你可以创建一个堆栈的递归式算法使用恒定的空间,因此它不长长,你得到错误堆栈。


  • 我们应该确保函数本身没有goto语句。注意函数调用是被调用函数中的最后一件事。

  • 大规模递归可以将其用于优化,但在小范围内,使函数调用成为尾部调用的指令开销会减少实际用途。

  • TCO可能导致永久运行的功能:

    1
    2
    3
    4
    void eternity()
    {
        eternity();
    }

  • 递归函数方法有问题。它建立了一个O(n)大小的调用堆栈,这使得我们的总内存成本为O(n)。这使得它容易受到堆栈溢出错误的影响,因为调用堆栈太大,空间不足。尾部成本优化(TCO)方案。它可以优化递归函数以避免构建高调用堆栈,从而节省内存成本。

    有很多语言正在做TCO(JavaScript、露比和少数C),在这里Python和Java不做TCO。

    javascript语言已确认使用:)http://2ality.com/2015/06/tail-call-optimization.html


    使用x86反汇编分析的gcc最小可运行示例

    让我们看看GCC如何通过查看生成的程序集来自动为我们进行尾调用优化。

    这将是其他答案(如https://stackoverflow.com/a/9814654/895245)中提到的一个非常具体的例子,优化可以将递归函数调用转换为循环。

    这反过来又节省了内存并提高了性能,因为内存访问通常是当今使程序变慢的主要原因。

    作为输入,我们给gcc一个非优化的基于原始堆栈的阶乘:

    我叫C

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    #include <stdio.h>
    #include <stdlib.h>

    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
    ", factorial(input));
        return EXIT_SUCCESS;
    }

    Github上游。

    编译和反汇编:

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

    其中,-foptimize-sibling-calls是根据man gcc的尾调用泛化的名称:

    1
    2
    3
    4
       -foptimize-sibling-calls
           Optimize sibling and tail recursive calls.

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

    如前所述:如何检查gcc是否正在执行尾部递归优化?

    我选择-O1是因为:

    • 优化不是用-O0完成的。我怀疑这是因为缺少所需的中间转换。
    • -O3生成的代码效率不高,不具有教育意义,尽管它也是尾调用优化的。

    -fno-optimize-sibling-calls拆卸:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    0000000000001145 <factorial>:
        1145:       89 f8                   mov    %edi,%eax
        1147:       83 ff 01                cmp    $0x1,%edi
        114a:       74 10                   je     115c <factorial+0x17>
        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 <factorial>
        1157:       0f af c3                imul   %ebx,%eax
        115a:       5b                      pop    %rbx
        115b:       c3                      retq
        115c:       c3                      retq

    使用-foptimize-sibling-calls时:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    0000000000001145 <factorial>:
        1145:       b8 01 00 00 00          mov    $0x1,%eax
        114a:       83 ff 01                cmp    $0x1,%edi
        114d:       74 0e                   je     115d <factorial+0x18>
        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 <factorial+0xa>
        115c:       c3                      retq
        115d:       89 f8                   mov    %edi,%eax
        115f:       c3                      retq

    两者的关键区别在于:

    • -fno-optimize-sibling-calls使用callq,这是典型的非优化函数调用。

      此指令将返回地址推送到堆栈,因此增加了返回地址。

      此外,此版本还执行push %rbx,将%rbx推到堆栈中。

      gcc这样做是因为它将edi存储到ebx中,这是第一个函数参数(n),然后调用factorial

      GCC需要这样做,因为它正在准备另一个调用factorial,它将使用新的edi == n-1

      它之所以选择ebx,是因为这个寄存器是被调用保存的:哪些寄存器是通过Linux x86-64函数调用保存的,所以factorial的子调用不会更改它并丢失n

    • -foptimize-sibling-calls不使用任何推到堆栈的指令:它只使用jejne指令在factorial内跳转。

      因此,这个版本相当于一个while循环,没有任何函数调用。堆栈使用是常量。

    在Ubuntu 18.10,GCC 8.2中测试。