为什么当递归函数结果相乘时,g++ 仍然优化尾递归

Why g++ still optimizes tail recursion when the recursion function result is multiplied?

本文关键字:g++ 优化 尾递归 递归函数 结果 为什么      更新时间:2023-10-16

他们说,尾递归优化仅在调用刚好从函数返回之前时才有效。因此,他们将以下代码作为 C 编译器不应优化的示例:

long long f(long long n) {
    return n > 0 ? f(n - 1) * n : 1;
}

因为那里的递归函数调用乘以 n这意味着最后一个操作是乘法,而不是递归调用。但是,它甚至在-O1层面上:

recursion`f:
    0x100000930 <+0>:  pushq  %rbp
    0x100000931 <+1>:  movq   %rsp, %rbp
    0x100000934 <+4>:  movl   $0x1, %eax
    0x100000939 <+9>:  testq  %rdi, %rdi
    0x10000093c <+12>: jle    0x10000094e               
    0x10000093e <+14>: nop    
    0x100000940 <+16>: imulq  %rdi, %rax
    0x100000944 <+20>: cmpq   $0x1, %rdi
    0x100000948 <+24>: leaq   -0x1(%rdi), %rdi
    0x10000094c <+28>: jg     0x100000940               
    0x10000094e <+30>: popq   %rbp
    0x10000094f <+31>: retq   

他们说:

因此,您的最终规则足够正确。但是,返回n * fact(n - 1)确实在尾部位置有操作!这是乘法*,这将是函数做的最后一件事 在它返回之前。在某些语言中,这实际上可能是 实现为函数调用,然后可以是尾调用 优化。

但是,正如我们从 ASM 列表中看到的那样,乘法仍然是一个 ASM 指令,而不是一个单独的函数。所以我真的很难看到累加器方法的区别:

int fac_times (int n, int acc) {
    return (n == 0) ? acc : fac_times(n - 1, acc * n);
}
int factorial (int n) {
    return fac_times(n, 1);
}

这会产生

recursion`fac_times:
    0x1000008e0 <+0>:  pushq  %rbp
    0x1000008e1 <+1>:  movq   %rsp, %rbp
    0x1000008e4 <+4>:  testl  %edi, %edi
    0x1000008e6 <+6>:  je     0x1000008f7               
    0x1000008e8 <+8>:  nopl   (%rax,%rax)
    0x1000008f0 <+16>: imull  %edi, %esi
    0x1000008f3 <+19>: decl   %edi
    0x1000008f5 <+21>: jne    0x1000008f0               
    0x1000008f7 <+23>: movl   %esi, %eax
    0x1000008f9 <+25>: popq   %rbp
    0x1000008fa <+26>: retq   

我错过了什么吗?或者只是编译器变得更聪明了?

正如您在汇编代码中看到的那样,编译器足够聪明,可以将您的代码转换为基本上等效于(忽略不同数据类型)的循环:

int fac(int n)
{
    int result = n;
    while (--n)
        result *= n;
    return result;
}

GCC 足够聪明,知道每次调用原始f所需的状态可以在整个递归调用序列中保存在两个变量(nresult)中,因此不需要堆栈。可以这么说,它可以将f转换为fac_times,也可以将两者转换为fac。这很可能不仅是严格意义上的尾部调用优化的结果,而且是 GCC 用于优化的其他启发式方法的负载之一。

(关于这里使用的特定启发式方法,我无法更详细地介绍它们,因为我对它们了解不够。

非累加器f不是尾递归的。 编译器的选项包括通过转换它或call/某些insns/ret将其转换为循环,但它们不包括没有其他转换的jmp f

尾调用优化适用于以下情况:

int ext(int a);
int foo(int x) { return ext(x); }

来自 Godbolt 的 ASM 输出:

foo:                                    # @foo
        jmp     ext                     # TAILCALL

尾调用优化意味着将函数(或递归)保留为jmp而不是ret。 其他任何事情都不是尾调用优化。 不过,使用jmp优化的尾递归实际上是一个循环。

一个好的编译器会做进一步的转换,尽可能将条件分支放在循环的底部,删除无条件分支。 (在asm中,循环的do{}while()风格是最自然的)。