【问题标题】:Problem with Tail Recursion in g++g ++中的尾递归问题
【发布时间】:2011-05-28 16:46:13
【问题描述】:

我在 C++ 中处理尾递归函数,我在使用 g++ 编译器时遇到了一些问题。

numbers[] 的大小超过几百个整数时,以下代码会导致堆栈溢出。检查 g++ 生成的汇编代码,发现 twoSum_Helper 正在对自身执行递归 call 指令。

问题是以下哪一项导致了这种情况?

  • 以下我忽略的错误会阻止尾递归。
  • 我使用 g++ 的一个错误。
  • 在 g++ 编译器中检测尾递归函数存在缺陷。

我在 Windows Vista x64 上通过带有 g++ 4.5.0 的 MinGW 使用 g++ -O3 -Wall -fno-stack-protector test.c 进行编译。

struct result
{
    int i;
    int j;
    bool found;
};

struct result gen_Result(int i, int j, bool found)
{
    struct result r;
    r.i = i;
    r.j = j;
    r.found = found;
    return r;
}

// Return 2 indexes from numbers that sum up to target.
struct result twoSum_Helper(int numbers[], int size, int target, int i, int j)
{
    if (numbers[i] + numbers[j] == target)
        return gen_Result(i, j, true);
    if (i >= (size - 1))
        return gen_Result(i, j, false);
    if (j >= size)
        return twoSum_Helper(numbers, size, target, i + 1, i + 2);
    else
        return twoSum_Helper(numbers, size, target, i, j + 1);
}

【问题讨论】:

  • 您是否已经尝试单独执行条件增量并且只使用增量参数执行一次递归调用?它不如你的例子好,但它可能会对你的问题有所启发。
  • @stefaanv 是的,无济于事。似乎调用发生在 else 语句上,但没有任何调整会导致它使用 jmp 而不是调用。
  • 如果您使用单个语句ala return twoSum_Helper(numbers, size, target, i + j_ge_size, j_ge_size ? i + 2 : j + 1) 其中j_ge_sizebool j >= size 是否有效? (适合自己从 bool 进行隐式转换)。
  • Tom 在stackoverflow.com/questions/34125 中做了一个有趣的观察——他的尾递归需要函数是静态的...?

标签: c++ recursion functional-programming g++ tail-recursion


【解决方案1】:

C 或 C++ 中的尾调用优化非常有限,而且几乎是一个失败的原因。原因是通常没有安全的方法从传递指针或引用任何局部变量的函数进行尾调用(作为所讨论调用的参数,或者实际上是同一函数中的任何其他调用)——当然,这在 C/C++ 领域无处不在,而且几乎不可能没有。

您看到的问题可能与此有关:GCC 可能通过实际传递分配在调用者堆栈上的隐藏变量的地址来编译返回结构,被调用者将其复制到该地址中 - 这使其属于上述情况。

【讨论】:

    【解决方案2】:

    尝试使用 -O2 而不是 -O3 进行编译。

    How do I check if gcc is performing tail-recursion optimization?

    好吧,无论如何它都不适用于 O2。唯一可行的方法是将 result 对象返回到作为参数给出的引用中。

    但实际上,删除 Tail 调用并改用循环要容易得多。 TCO 旨在优化内联或执行积极展开时发现的尾调用,但无论如何处理大值时都不应尝试使用递归。

    【讨论】:

    • 如果你好奇的话,优化尾递归的标志是 -foptimize-sibling-calls,它包含在 -O2、-O3 和 -Os 中。
    • 嗯,我链接到的答案提出了一个 O2 有效但 O3 无效的情况。
    【解决方案3】:

    即使在这个简单的函数上,我也无法让 g++ 4.4.0(在 mingw 下)执行尾递归:

    static void f (int x)
      {
      if (x == 0) return ;
      printf ("%p\n", &x) ; // or cout in C++, if you prefer
      f (x - 1) ;
      }
    

    我尝试过-O3-O2-fno-stack-protector、C 和 C++ 变体。没有尾递归。

    【讨论】:

      【解决方案4】:

      我会看两件事。

      1. if 语句中的返回调用将在堆栈帧中为当前运行需要的函数提供 else 的分支目标待调用后解决(这意味着任何 TCO 尝试都无法覆盖正在执行的堆栈帧,从而否定 TCO)

      2. numbers[] 数组参数是一个可变长度的数据结构,它也可以防止 TCO,因为在 TCO 中以一种或另一种方式使用相同的堆栈帧。如果调用是自引用的(如您的),那么它将用新调用的值/引用覆盖堆栈定义的变量(或本地定义的)。如果尾调用是另一个函数,那么它将用新函数覆盖整个堆栈帧(在 TCO 可以在 A => B => C 中完成的情况下,TCO 可以使这看起来像内存中的 A => C执行期间)。我会尝试一个指针。

      自从我用 C++ 构建任何东西以来已经有几个月了,所以我没有运行任何测试,但我认为其中一个/两个都在阻止优化。

      【讨论】:

      • 没有“numbers[] 数组参数”,这是一种误导(而且它肯定不是可变长度的)。只有一个 pointer 参数,堆栈帧的使用总是相同的,这几乎肯定不是这里的原因。 if-else 也不是原因。 每个递归都需要一个基本情况,因此需要一个条件语句。
      • 自 C++ 以来已经有一段时间了,我知道它是一个指针,thnx。我确实认为条件是源,递归确实需要一个基本情况,但是尾调用优化意味着堆栈上没有任何指令,除了在递归调用之后返回,为了让编译器优化,递归调用需要是帧中的最后一条语句,第二个 IF 在执行堆栈中仍然有 ELSE 的分支目标。它是逻辑上的最后一条语句,但不是物理的(当程序集在内存中时)。
      • 我的意思是第三个 IF(决定下一个自引用递归调用的那个)
      【解决方案5】:

      尝试将您的代码更改为:

      // Return 2 indexes from numbers that sum up to target.
      struct result twoSum_Helper(int numbers[], int size, int target, int i, int j)
      {
          if (numbers[i] + numbers[j] == target)
              return gen_Result(i, j, true);
          if (i >= (size - 1))
              return gen_Result(i, j, false);
      
          if(j >= size)
              i++; //call by value, changing i here does not matter
          return twoSum_Helper(numbers, size, target, i, i + 1);
      }
      

      编辑:根据提问者的评论删除了不必要的参数

      // Return 2 indexes from numbers that sum up to target.
      struct result twoSum_Helper(int numbers[], int size, int target, int i)
      {
          if (numbers[i] + numbers[i+1] == target || i >= (size - 1))
              return gen_Result(i, i+1, true);
      
          if(i+1 >= size)
              i++; //call by value, changing i here does not matter
          return twoSum_Helper(numbers, size, target, i);
      }
      

      【讨论】:

      • 这可能会奏效,但不幸的是它破坏了我测试的总体思路。
      • 另外,在每次递归调用中,j 不一定总是 i+1。
      • @Swiss 我只是重写了 zour 代码的最后几行并保留了函数签名,但由于 zou 要求我更改了签名并缩短了一点。
      • @Swiss 如果这可行,还有什么问题,这意味着问题很可能是你有两个点可以递归。如示例所示,这可以很容易地重写(就像递归也可以通过使用循环来删除,我认为有一个定理,因为我不能足够快地用谷歌搜索它,所以有一个section in wikipedia 到此提示)跨度>
      【解决方案6】:

      对尾调用优化 (TCO) 的支持在 C/C++ 中受到限制。

      因此,如果代码依赖 TCO 来避免堆栈溢出,最好用循环重写它。否则需要进行一些自动测试以确保代码已优化。

      TCO 通常可以通过以下方式抑制:

      • 将指向递归函数堆栈上对象的指针传递给外部函数(如果 C++ 也通过引用传递此类对象);
      • 具有非平凡析构函数的局部对象,即使尾递归有效(析构函数在尾 return 语句之前调用),例如 Why isn't g++ tail call optimizing while gcc is?

      这里的 TCO 被按值返回结构混淆了。 如果所有递归调用的结果都将写入其他函数twoSum中分配的相同内存地址,则可以修复(类似于https://stackoverflow.com/a/30090390/4023446Tail-recursion not happening的答案)

      struct result
      {
          int i;
          int j;
          bool found;
      };
      
      struct result gen_Result(int i, int j, bool found)
      {
          struct result r;
          r.i = i;
          r.j = j;
          r.found = found;
          return r;
      }
      
      struct result* twoSum_Helper(int numbers[], int size, int target,
          int i, int j, struct result* res_)
      {
          if (i >= (size - 1)) {
              *res_ = gen_Result(i, j, false);
              return res_;
          }
          if (numbers[i] + numbers[j] == target) {
              *res_ = gen_Result(i, j, true);
              return res_;
          }
          if (j >= size)
              return twoSum_Helper(numbers, size, target, i + 1, i + 2, res_);
          else
              return twoSum_Helper(numbers, size, target, i, j + 1, res_);
      }
      
      // Return 2 indexes from numbers that sum up to target.
      struct result twoSum(int numbers[], int size, int target)
      {
          struct result r;
          return *twoSum_Helper(numbers, size, target, 0, 1, &r);
      }
      

      res_ 指针的值对于twoSum_Helper 的所有递归调用都是常量。 在汇编输出(-S 标志)中可以看出,twoSum_Helper 尾递归被优化为一个循环,即使有两个递归退出点。

      编译选项:g++ -O2 -S(g++ 版本 4.7.2)。

      【讨论】:

        【解决方案7】:

        我听过其他人抱怨,尾递归只用 gcc 而不是 g++ 优化。 可以试试 gcc。

        【讨论】:

          【解决方案8】:

          由于twoSum_Helper 的代码正在调用自身,因此程序集准确地显示了这种情况也就不足为奇了。这就是递归的全部意义 :-) 所以这与 g++ 没有任何关系。

          每次递归都会创建一个新的栈帧,栈空间默认是有限的。您可以增加堆栈大小(不知道如何在 Windows 上执行此操作,在 UNIX 上使用 ulimit 命令),但这只会延迟崩溃。

          真正的解决方案是摆脱递归。参见例如this questionthis question

          【讨论】:

          • 尾递归函数是独一无二的,因为理论上可以优化它们从调用指令到 jmp 指令的递归。这将函数的堆栈要求从 O(n) 更改为 O(1)。实际上它并不是那么简单,因为它依赖于编译器来进行区分和利用优化。
          • 这没有回答问题:尾递归应该忽略调用并防止堆栈溢出,但优化没有被应用,OP想知道为什么。
          • 对不起,我错过了没有应用的优化,因为问题中没有提到这一点。尽管如此,依赖特定的编译器优化还是有点危险,不是吗?
          • @DarkDust 我开始对尾递归有这种感觉。这在理论上很好,但如果没有真正的警告可能会出错。
          • @Swiss:这是一个 C++ 问题(或者通常是一个特定于语言的问题)。有些语言可以保证完成尾递归。对于尝试使用自己的编译器的人,最简单的开始方法是添加明确的tailcall %1 指令。
          猜你喜欢
          • 2013-03-31
          • 2011-12-10
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2016-12-18
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多