很简单,什么是尾叫优化?
更具体地说,可以应用哪些小代码段,而在何处不行,并解释其原因?
很简单,什么是尾叫优化?
更具体地说,可以应用哪些小代码段,而在何处不行,并解释其原因?
Answers:
尾调用优化可以避免为函数分配新的堆栈帧,因为调用函数将简单地返回它从被调用函数获得的值。最常见的用法是尾部递归,其中利用尾部调用优化编写的递归函数可以使用恒定的堆栈空间。
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)相同的空间。非尾递归事实并非如此,因为如此大的值可能会导致堆栈溢出。
让我们来看一个简单的示例:用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;
}
正如我们在这里看到的那样,足够高级的优化器可以用迭代代替尾部递归,因为这样可以避免函数调用开销,并且仅使用恒定数量的堆栈空间,因此效率更高。
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)
这是因为在任何这些函数中发生的最后一件事情是调用另一个函数。
我可能发现的关于尾部调用,递归尾部调用和尾部调用优化的最佳高级描述是博客文章
由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
该对象,然后将其返回给调用者。
在尾递归上:
如果函数作为最后一个操作返回调用自身的结果,则会发生尾递归。尾递归更容易处理,因为不必跳到某个地方的某个随机函数的开头,您只需将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)的用户来说,它的简洁性和简便性
foo
函数的尾部调用是否没有优化?它只是在最后一步调用函数,并且只是返回该值,对吧?
首先请注意,并非所有语言都支持它。
TCO适用于递归的特殊情况。要点是,如果您在函数中所做的最后一件事情是调用自身(例如,它是从“ tail”位置调用自身的),则可以由编译器进行优化,使其像迭代而不是标准递归。
您会看到,通常在递归过程中,运行时需要跟踪所有递归调用,以便在返回时可以在上一个调用中恢复,依此类推。(尝试手动写出递归调用的结果,以直观了解其工作原理。)跟踪所有调用会占用空间,当函数多次调用自身时,这将变得很重要。但是使用TCO,它只能说“回到开始,只有这次将参数值更改为这些新值”。这样做是因为在递归调用之后没有任何东西引用这些值。
foo
方法的尾部调用是否没有优化?
具有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;
}
编译和反汇编:
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
,是因为它存储了第一个函数参数(n
)ebx
,然后调用factorial
。
GCC需要这样做,因为它正在准备另一个调用factorial
,它将使用new edi == n-1
。
ebx
之所以选择它,是因为该寄存器是被调用者保存的:哪些寄存器是通过linux x86-64函数调用保留的,因此子调用factorial
不会更改它并丢失n
。
在-foptimize-sibling-calls
不使用推到堆栈中的任何指示:它只做goto
中跳转factorial
与指示je
和jne
。
因此,此版本等效于while循环,没有任何函数调用。堆栈使用率是恒定的。
已在Ubuntu 18.10,GCC 8.2中测试。
看这里:
http://tratt.net/laurie/tech_articles/articles/tail_call_optimization
如您所知,递归函数调用可能会对堆栈造成严重破坏。很容易快速耗尽堆栈空间。尾调用优化是一种创建使用恒定堆栈空间的递归样式算法的方法,因此该算法不会越来越多,并且会出现堆栈错误。
我们应该确保在函数本身中没有goto语句..函数调用是被调用者函数中的最后事情,要小心。
大规模递归可以将其用于优化,但是在较小规模上,使函数调用尾部调用的指令开销降低了实际用途。
TCO可能会导致永远运行的功能:
void eternity()
{
eternity();
}
递归函数方法有一个问题。它建立了一个大小为O(n)的调用堆栈,这使我们的总内存成本为O(n)。这使得它容易受到堆栈溢出错误的影响,在该错误中,调用堆栈太大而空间不足。
尾部呼叫优化(TCO)方案。它可以优化递归函数,以避免建立高调用堆栈,从而节省了内存成本。
有许多执行TCO的语言(例如JavaScript,Ruby和少数C),而Python和Java则没有TCO。
JavaScript语言已确认使用:) http://2ality.com/2015/06/tail-call-optimization.html
在函数式语言中,尾部调用优化就像函数调用可以返回部分评估的表达式作为结果一样,然后由调用者对其进行评估。
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;
}
}
}