【问题标题】:Question regarding tail call optimization关于尾调用优化的问题
【发布时间】:2021-11-28 16:48:47
【问题描述】:

据我所知,进行尾调用优化的前提是递归点应该是函数中的最后一句,并且递归调用的结果应该立即返回。但为什么呢?

这是 TCO 的一个有效示例:

int factorial(int num) {
  if (num == 1 || num == 0)
    return 1;
  return num * factorial(num - 1);
}

那么,按照规则,下面的代码也可以优化吗?为什么不呢?

#include <stdio.h>
int factorial(int num) {
  if (num == 1 || num == 0)
    return 1;
  int temp = num * factorial(num - 1);
  printf("%d", temp);
  return temp;
}

我想知道我应该如何向其他人解释为什么上述规则对于拥有 TCO 是必要的。但不仅仅是跟随。

【问题讨论】:

  • “为什么不”? -- 你回答了你自己的问题:因为“递归点应该是函数中的最后一句”,但不是(在递归计算点和返回点之间使用 temp)
  • 在文件yhspy.c 中编译您的C 代码,将GCC 调用为gcc -Wall -Wextra -fverbose-asm -S -O2 vhspy.c,然后查看生成的vhspy.s 汇编程序文件
  • 最好的做法是永远不要使用递归。确定是否可以优化递归函数既不直观也不琐碎-您基本上必须自己反汇编并查看。如果不能优化,递归的使用几乎肯定在所有可能的方面都是错误的:低效、危险、不可读、不可维护。

标签: c tail-call-optimization


【解决方案1】:

递归调用的结果应该立即返回。但为什么呢?

这是因为为了优化尾调用,您需要将最终的递归调用转换为简单的“跳转”指令。当你这样做时,你只是在“替换”函数参数,然后重新启动函数。

这只有在您可以“丢弃”当前堆栈帧并再次将其重新用于同一功能(可能会覆盖它)时才有可能。如果您需要记住一个值以进行更多计算然后返回,则不能将相同的堆栈帧用于递归调用(即不能将“调用”转换为“跳转”),因为它可能会擦除/修改该值你想在回来之前记住。

此外,如果您的函数非常简单(就像您的函数一样),很有可能它可以在完全不使用堆栈的情况下编写(可能除了返回地址之外),并且只将数据存储在寄存器中。即使在这种情况下,如果您需要在返回之前记住其中一个值,您也不希望跳转到相同的函数(使用相同的寄存器)。

这是 TCO 的一个有效示例:

int factorial(int num) {
  if (num == 1 || num == 0)
    return 1;
  return num * factorial(num - 1);
}

这对 TCO 无效!你正在做return num * &lt;recursive-call&gt;。递归调用不是函数做的最后一件事,在返回之前有一个乘法。和写一样:

int factorial(int num) {
    if (num == 1 || num == 0)
        return 1;
    int tmp = factorial(num - 1);
    tmp *= num;
    return tmp;
}

下面的代码也可以优化吗?

不!同样,那里根本没有尾声,而且更加明显。您首先进行递归调用,然后是其他一些东西(乘法和printf),然后返回。这不能被编译器优化为尾调用。

另一方面,以下代码可以优化为尾调用:

int factorial(int n, int x) {
    if (n == 1)
        return x;
    int tmp = factorial(n - 1, n * x);
    return tmp;
}

您不一定要在函数的最后一行进行递归调用。重要的是您不要在中间(递归调用和返回语句之间)进行工作,例如调用其他函数或进行额外的计算。


重要提示:请注意,不能执行经典 TCO 这一事实并不意味着编译器将无法以其他方式优化您的代码。实际上,您的第一个函数非常简单,以至于当在 x86-64 上使用 GCC 编译时,至少有 -O2 它只是从递归转换为迭代(它基本上变成了一个循环)。我上面的示例也是如此,编译器只是不关心 TCO,它认为在这种情况下可以进行更好的优化。

这是 GCC 11 在 x86-64 上生成的第一个函数的汇编转储(Godbolt link,如果你想使用它)。如果您不熟悉 x86:num 参数在 edi 中,eax 用于返回值。

factorial:
        mov     eax, 1
        cmp     edi, 1
        jbe     .L1
.L2:
        mov     edx, edi
        sub     edi, 1
        imul    eax, edx
        cmp     edi, 1
        jne     .L2
.L1:
        ret

【讨论】:

    【解决方案2】:

    函数的每次调用都会创建一个堆栈帧,其中包含通过参数传递给该函数的任何数据。如果一个函数调用另一个函数(包括自身)一个新的堆栈帧被压入堆栈。当一个函数完全完成时,它的帧从堆栈中弹出。

    堆栈内存是有限的。如果我们尝试将太多帧压入堆栈,则会出现堆栈溢出错误。

    尾调用优化发挥作用的地方是,如果在尾调用之后没有工作要做,则识别出一个函数完成的。

    考虑一种对一系列数字进行递归求和的方法。

    int sum(int start, int stop) {
        if (start == stop) {
            return start;
        }
        else {
            return start + sum(start + 1, stop);
        }
    }
    

    如果我们调用sum(1, 5),递归看起来像:

    sum(1, 5)
    1 + sum(2, 5)
    1 + 2 + sum(3, 5)
    1 + 2 + 3 + sum(4, 5)
    1 + 2 + 3 + 4 + sum(5, 5)
    1 + 2 + 3 + 4 + 5
    

    必须创建几个堆栈帧来保存它。

    通常情况下,需要建立值的尾部调用优化涉及传递给函数的 accumulator 参数。

    int sum_tco(int start, int stop, int acc) {
        if (start == stop) {
            return start + acc;
        }
        else {
            return sum_tco(start + 1, stop, start + acc);
        }
    }
    

    现在考虑递归的样子:

    sum_tco(1, 5, 0)
    sum_tco(2, 5, 1 + 0)
    sum_tco(3, 5, 2 + 1 + 0)
    sum_tco(4, 5, 3 + 2 + 0)
    sum_tco(5, 5, 5 + 4 + 3 + 2 + 1 + 0)
    5 + 4 + 3 + 2 + 1 + 0
    

    我们不需要知道sum(1, 5, 0)sum(3, 5, 2 + 1 + 0) 的结果是什么,就能知道sum(5, 5, 5 + 4 + 3 + 2 + 1 + 0) 的结果是什么,你的电脑也不知道。

    智能编译器会意识到这一点,并在执行过程中删除所有以前的堆栈帧。使用 TCO,无论此函数递归调用自身多少次,都不会溢出堆栈。

    (对堆栈行为方式的描述已被概括,并非旨在深入技术,而是展示 TCO 的概括概念。)

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2015-01-07
      • 1970-01-01
      • 1970-01-01
      • 2020-07-18
      • 2010-10-20
      • 1970-01-01
      • 2011-10-31
      • 2011-03-31
      相关资源
      最近更新 更多