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); } |
如果函数返回之前的最后一个操作是另一个函数调用,则函数以尾调用结束。如果此调用调用相同的函数,则它是尾部递归。
虽然
1 2 3 4 5 6 | unsigned fac(unsigned n) { if (n < 2) return 1; unsigned acc = fac(n - 1); return n * acc; } |
即最后一个操作是乘法,而不是函数调用。
但是,通过将累积值作为附加参数传递给调用链,并且仅将最终结果作为返回值再次传递,可以将
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; } |
这可以输入到
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 sequencepop stack frame; goto somefunc(); . In our example, that means before we callbar ,foo cleans itself up and then, rather than callingbar as a subroutine, we do a low-levelgoto operation to the start ofbar .Foo 's already cleaned itself out of the stack, so whenbar starts it looks like whoever calledfoo has really calledbar , and whenbar returns its value, it returns it directly to whoever calledfoo , rather than returning it tofoo 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 |
其中,
1 2 3 4 | -foptimize-sibling-calls Optimize sibling and tail recursive calls. Enabled at levels -O2, -O3, -Os. |
如前所述:如何检查gcc是否正在执行尾部递归优化?
我选择
- 优化不是用
-O0 完成的。我怀疑这是因为缺少所需的中间转换。 -O3 生成的代码效率不高,不具有教育意义,尽管它也是尾调用优化的。
用
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 |
使用
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 不使用任何推到堆栈的指令:它只使用je 和jne 指令在factorial 内跳转。因此,这个版本相当于一个while循环,没有任何函数调用。堆栈使用是常量。
在Ubuntu 18.10,GCC 8.2中测试。