什么是尾叫优化?


Answers:


754

尾调用优化可以避免为函数分配新的堆栈帧,因为调用函数将简单地返回它从被调用函数获得的值。最常见的用法是尾部递归,其中利用尾部调用优化编写的递归函数可以使用恒定的堆栈空间。

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


99
如果您想了解更多有关此的内容,建议阅读《计算机程序的结构和解释》的第一章。
凯尔·克罗宁

3
很好的答案,完美解释。
约拿(Jonah)2012年

15
严格来说,尾部调用优化并不一定要用被调用者替换调用者的堆栈框架,而是确保尾部位置无限制的调用次数仅需要有限的空间。参见威尔·克林格(Will Clinger)的论文“适当的尾部递归和空间效率”:cesura17.net/~will/Professional/Research/Papers/tail.pdf
乔恩·哈罗普

3
这只是以恒定空间的方式编写递归函数的一种方法吗?因为使用迭代方法无法达到相同的结果?
dclowd9901

5
@ dclowd9901,TCO使您更喜欢功能样式而不是迭代循环。您可以选择命令式样式。许多语言(Java,Python)不提供TCO,然后您必须知道函数调用会占用内存...并且首选命令式样式。
mcoolive

551

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

现在,这为什么有用?因为我们在tail调用之后立即返回,所以我们可以在调用tail位置的函数之前丢弃先前的stackframe,或者,如果是递归函数,则按原样重用stackframe。

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

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

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


您能解释一下堆栈框架的确切含义吗?调用堆栈和stackframe之间有区别吗?
沙萨克2015年

10
@Kasahs:堆栈框架是调用堆栈中“属于”给定(活动)函数的一部分;cf en.wikipedia.org/wiki/Call_stack#Structure
Christoph


198

TCO(尾部调用优化)是智能编译器可以调用函数而无需占用额外堆栈空间的过程。发生这种情况唯一情况是,如果在函数f中执行的最后一条指令是对函数g的调用(注意:g可以为f)。这里的关键是f不再需要堆栈空间-它仅调用g然后返回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
整个“函数g可以是f”这件事有点令人困惑,但是我明白了您的意思,这些示例确实阐明了这些事情。非常感谢!
majelbstoat

10
很好的例子说明了这一概念。只需考虑到您选择的语言必须实现尾声消除或尾声优化即可。在以Python编写的示例中,如果输入值1000,则会得到“ RuntimeError:超出最大递归深度”,因为默认的Python实现不支持尾递归消除。请参阅Guido本人自己的文章,解释其原因:neopythonic.blogspot.pt/2009/04/tail-recursion-elimination.html
rmcc 2012年

唯一的情况”有点太绝对了;至少从理论上讲,还有TRMC,它们将以相同的方式优化(cons a (foo b))(+ c (bar d))处于尾部位置。
尼斯

我喜欢您的f和g方法胜于公认的答案,也许是因为我是一个数学人。
Nithin

我认为您的意思是TCOptimized。说它不是TCOptimizable意味着无法对其进行优化(实际上可以进行优化)
Jacques Mathieu

65

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

“到底是什么:尾声”

由Dan Sugalski撰写。关于尾部呼叫优化,他写道:

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

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

那么,您或者您的语言编译器可以做什么?好吧,它所能做的就是将格式的代码return somefunc();转换为低级序列pop stack frame; goto somefunc();。在我们的例子中,我们称之为手段前barfoo自清洁起来,然后,而不是调用bar的子程序,我们做一个低级别的goto操作开始barFoo的内容已经从堆栈中清除了,因此在bar启动时看起来像被调用foo者真正调用过bar,并且在bar返回其值时,它将直接将其返回给被调用者foo,而不是返回到foo该对象,然后将其返回给调用者。

在尾递归上:

如果函数作为最后一个操作返回调用自身的结果则会发生尾递归。尾递归更容易处理,因为不必跳到某个地方的某个随机函数的开头,您只需将goto返回自己的开头即可,这很简单。

这样:

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)的用户来说,它的简洁性和简便性


4
404错误。但是,它仍然可以在archive.org:web.archive.org/web/20111030134120/http://www.sidhe.org/~dan/...
汤米

我没有理解,初始foo函数的尾部调用是否没有优化?它只是在最后一步调用函数,并且只是返回该值,对吧?
SexyBeast

1
@TryinHard可能与您的想法不符,但我对其进行了更新,以大致了解它的含义。抱歉,不重复整篇文章!
btiernay 2015年

2
谢谢,这比投票最多的方案示例更简单,更易理解(更不用说,方案不是大多数开发人员都理解的通用语言)
Sevin7

2
作为一个很少使用功能语言的人,很高兴看到“我的方言”中的解释。函数式程序员有一种(可以理解的)趋势来宣传他们选择的语言,但是来自命令式世界,我发现将脑袋围绕这样的答案要容易得多。
詹姆斯·贝宁格

15

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

TCO适用于递归的特殊情况。要点是,如果您在函数中所做的最后一件事情是调用自身(例如,它是从“ tail”位置调用自身的),则可以由编译器进行优化,使其像迭代而不是标准递归。

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


3
尾调用也可以应用于非递归函数。任何在返回之前最后一次计算是对另一个函数的调用的函数都可以使用尾部调用。
布赖恩

在每种语言之间不一定是正确的-64位C#编译器可以插入尾部操作码,而32位版本则不能。和F#发布版本,但默认情况下不会进行F#调试。
史蒂夫·吉勒姆

3
“ TCO适用于递归的特殊情况”。恐怕那是完全错误的。尾部呼叫适用于尾部位置的任何呼叫。通常在递归上下文中讨论,但实际上与递归无关。
乔恩·哈罗普

@Brian,请看上面提供的链接@btiernay。初始foo方法的尾部调用是否没有优化?
SexyBeast

13

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

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

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

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

作为输入,我们为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上游

编译和反汇编:

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.

如以下内容所述:如何检查gcc是否正在执行尾递归优化?

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

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

    此外,此版本也可以push %rbx%rbx送到堆栈

    GCC之所以这样做edi,是因为它存储了第一个函数参数(nebx,然后调用factorial

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

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

  • -foptimize-sibling-calls不使用推到堆栈中的任何指示:它只做goto中跳转factorial与指示jejne

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

已在Ubuntu 18.10,GCC 8.2中测试。



3
  1. 我们应该确保在函数本身中没有goto语句..函数调用是被调用者函数中的最后事情,要小心。

  2. 大规模递归可以将其用于优化,但是在较小规模上,使函数调用尾部调用的指令开销降低了实际用途。

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

    void eternity()
    {
        eternity();
    }
    

3尚未优化。那是未经优化的表示,编译器将其转换为使用恒定堆栈空间而不是递归代码的迭代代码。TCO并不是对数据结构使用错误的递归方案的原因。
nomen 2013年

“ TCO并不是对数据结构使用错误的递归方案的原因”请详细说明这与给定情况如何相关。上面的示例仅指出在具有和不具有TCO的情况下在调用堆栈上分配的帧的示例。
grillSandwich

您选择使用毫无根据的递归遍历()。这与总拥有成本无关。eternity恰好是尾注位置,但没有必要设置尾注位置:void eternity(){eternity(); 出口(); }
nomen 2013年

在进行此操作时,什么是“大规模递归”?为什么我们要避免在函数中使用goto?这对于允许TCO既不是必需的也不是足够的。还有什么指令开销?TCO的全部意义在于,编译器将goto替换为尾部位置的函数调用。
nomen 2013年

TCO是关于优化调用堆栈上使用的空间的。大规模递归是指帧的大小。每当发生递归时,如果我需要在被调用者函数上方的调用堆栈上分配一个巨大的帧,则TCO会更有帮助,并允许我进行更多级别的递归。但是,如果我的帧大小较小,则可以不使用TCO而仍然可以很好地运行我的程序(这里我不是在谈论无限递归)。如果在该函数中剩下goto,则“ tail”调用实际上不是尾部调用,并且TCO不适用。
grillSandwich

3

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

尾部呼叫优化(TCO)方案。它可以优化递归函数,以避免建立高调用堆栈,从而节省了内存成本。

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

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


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;
        }
    }
}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.