什么是尾部呼叫优化?

时间:2008-11-22 06:56:32

标签: algorithm recursion language-agnostic tail-recursion tail-call-optimization

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

10 个答案:

答案 0 :(得分:649)

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

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

(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)相同的空间。非尾递归事实并非如此,因此大值可能导致堆栈溢出。

答案 1 :(得分:497)

让我们来看一个简单的例子:在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;
}

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

答案 2 :(得分:176)

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)

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

答案 3 :(得分:56)

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

"What the heck is: A tail call"

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

悄然变成了:

{{1}}

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

答案 4 :(得分:13)

首先请注意,并非所有语言都支持它。

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

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

答案 5 :(得分:6)

看这里:

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

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

答案 6 :(得分:4)

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

  2. 大规模递归可以将其用于优化,但在小规模中,使函数调用尾调用的指令开销会降低实际目的。

  3. TCO可能会导致永久运行的功能:

    void eternity()
    {
        eternity();
    }
    

答案 7 :(得分:3)

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

有许多执行TCO的语言(例如Javascript,Ruby和C),而Python和Java则没有TCO。

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

确认

答案 8 :(得分:2)

具有x86拆卸分析功能的GCC最小可运行示例

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

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

memory accesses are often the main thing that makes programs slow nowadays开始,这又节省了内存并提高了性能。

作为输入,我们为GCC提供了一个非优化的基于天真的堆栈的阶乘:

tail_call.c

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

GitHub upstream

编译和反汇编:

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的尾部调用的概括名称:

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

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

How do I check if gcc is performing tail-recursion optimization?

所述

我之所以选择-O1是因为

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

使用-fno-optimize-sibling-calls进行反汇编:

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

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,这是典型的非优化函数调用。

    该指令将返回地址压入堆栈,因此增加了堆栈地址。

    此外,该版本还具有pushes %rbx to the stackpush %rbx

    GCC之所以这样做,是因为它将第一个函数参数(edin存储到ebx中,然后调用factorial

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

    之所以选择ebx,是因为该寄存器已被调用方保存:What registers are preserved through a linux x86-64 function call,因此对factorial的子调用不会更改它并丢失n

    < / li>
  • -foptimize-sibling-calls不使用任何压入堆栈的指令:它仅在goto和{{1 }}。

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

在Ubuntu 18.10,GCC 8.2中进行了测试。

答案 9 :(得分:0)

在函数式语言中,尾部调用优化就像函数调用可以返回部分评估的表达式作为结果一样,然后由调用者对其进行评估。

f x = g x

f 6减少到g6。因此,如果实现可以返回g 6作为结果,然后调用该表达式,它将保存一个堆栈帧。

f x = if c x then g x else h x.

将f 6减少为g 6或h6。因此,如果实现对c 6求值并且发现它为true,则可以将其减少,

if true then g x else h x ---> g x

f x ---> h x

一个简单的非尾部调用优化解释器可能看起来像这样,

class simple_expresion
{
    ...
public:
    virtual ximple_value *DoEvaluate() const = 0;
};

class simple_value
{
    ...
};

class simple_function : public simple_expresion
{
    ...
private:
    simple_expresion *m_Function;
    simple_expresion *m_Parameter;

public:
    virtual simple_value *DoEvaluate() const
    {
        vector<simple_expresion *> parameterList;
        parameterList->push_back(m_Parameter);
        return m_Function->Call(parameterList);
    }
};

class simple_if : public simple_function
{
private:
    simple_expresion *m_Condition;
    simple_expresion *m_Positive;
    simple_expresion *m_Negative;

public:
    simple_value *DoEvaluate() const
    {
        if (m_Condition.DoEvaluate()->IsTrue())
        {
            return m_Positive.DoEvaluate();
        }
        else
        {
            return m_Negative.DoEvaluate();
        }
    }
}

尾部呼叫优化解释器可能看起来像这样,

class tco_expresion
{
    ...
public:
    virtual tco_expresion *DoEvaluate() const = 0;
    virtual bool IsValue()
    {
        return false;
    }
};

class tco_value
{
    ...
public:
    virtual bool IsValue()
    {
        return true;
    }
};

class tco_function : public tco_expresion
{
    ...
private:
    tco_expresion *m_Function;
    tco_expresion *m_Parameter;

public:
    virtual tco_expression *DoEvaluate() const
    {
        vector< tco_expression *> parameterList;
        tco_expression *function = const_cast<SNI_Function *>(this);
        while (!function->IsValue())
        {
            function = function->DoCall(parameterList);
        }
        return function;
    }

    tco_expresion *DoCall(vector<tco_expresion *> &p_ParameterList)
    {
        p_ParameterList.push_back(m_Parameter);
        return m_Function;
    }
};

class tco_if : public tco_function
{
private:
    tco_expresion *m_Condition;
    tco_expresion *m_Positive;
    tco_expresion *m_Negative;

    tco_expresion *DoEvaluate() const
    {
        if (m_Condition.DoEvaluate()->IsTrue())
        {
            return m_Positive;
        }
        else
        {
            return m_Negative;
        }
    }
}