Recursion 在函数式语言中,编译器如何将非尾部递归转换为循环以避免堆栈溢出(如果有的话)?

Recursion 在函数式语言中,编译器如何将非尾部递归转换为循环以避免堆栈溢出(如果有的话)?,recursion,functional-programming,compiler-construction,compiler-optimization,tail-recursion,Recursion,Functional Programming,Compiler Construction,Compiler Optimization,Tail Recursion,我最近学习了函数式语言以及有多少不包含for循环。虽然我个人并不认为递归比for循环更难(通常更容易推理),但我意识到许多递归示例不是尾部递归,因此无法使用简单的尾部递归优化来避免堆栈溢出,所有迭代循环都可以转换为递归,而这些迭代循环可以转换为尾部递归,因此,当一个问题的答案表明,如果要避免堆栈溢出,您必须自己显式地管理递归到尾部递归的转换时,我会感到困惑。似乎编译器应该可以在没有堆栈溢出的情况下完成从递归到尾部递归,或从递归直接到迭代循环的所有转换 在更一般的递归情况下,函数编译器是否能够避免

我最近学习了函数式语言以及有多少不包含for循环。虽然我个人并不认为递归比for循环更难(通常更容易推理),但我意识到许多递归示例不是尾部递归,因此无法使用简单的尾部递归优化来避免堆栈溢出,所有迭代循环都可以转换为递归,而这些迭代循环可以转换为尾部递归,因此,当一个问题的答案表明,如果要避免堆栈溢出,您必须自己显式地管理递归到尾部递归的转换时,我会感到困惑。似乎编译器应该可以在没有堆栈溢出的情况下完成从递归到尾部递归,或从递归直接到迭代循环的所有转换

在更一般的递归情况下,函数编译器是否能够避免堆栈溢出?为了避免堆栈溢出,您真的被迫转换递归代码吗?如果它们不能执行一般的递归堆栈安全编译,为什么它们不能呢

根据这个问题,所有迭代循环都可以转化为递归

“翻译”可能有点夸张。如果你理解图灵完备性,那么证明每个迭代循环都有一个等价的递归程序是微不足道的:因为图灵机器可以使用严格迭代结构和严格递归结构来实现,所以可以用迭代语言表示的每个程序都可以用递归语言来表示,反之亦然。这意味着对于每个迭代循环,都有一个等价的递归构造(反之亦然)。然而,这并不意味着我们有一些自动的方式将一个转换成另一个

这些迭代循环可以转化为尾部递归

尾部递归可能很容易转换为迭代循环,反之亦然。但并非所有递归都是尾部递归。这里有一个例子。假设我们有一些二叉树。它由
节点
s组成。每个
节点
可以有一个
和一个
子节点和一个
。如果节点没有子节点,则
isLeaf
为其返回true。我们假设有一个函数
max
返回两个值中的最大值,如果其中一个值为
null
,则返回另一个值。现在我们要定义一个函数,在所有叶节点中查找最大值。这里是我编造的一些伪代码

findmax(node) {
    if (node == null) {
        return null
    }
    if (node.isLeaf) {
        return node.value
    } else {
        return max(findmax(node.left), findmax(node.right))
    }
}
max
函数中有两个递归调用,因此我们无法优化尾部递归。我们需要这两个函数的结果,然后才能将它们提供给
max
函数并确定当前节点的调用结果

现在,可能有一种方法可以得到相同的结果,使用递归,只使用一个尾部递归调用。它在功能上是等价的,但它是一种不同的算法。编译器可以进行大量转换,以创建具有大量优化的功能等效程序,但它们不够聪明,无法创建功能等效的算法

即使是将只递归调用自身一次的函数转换为尾部递归版本也绝非易事。这种自适应通常使用一些传递到递归调用的参数,该调用用作当前结果的“累加器”

看看下一个计算数字阶乘的简单实现(例如,事实(5)=5*4*3*2*1):

这不是尾部递归。但可以这样做:

fact(number, acc) {
    if (number == 1) {
        return acc
    } else {
        return fact(number - 1, number * acc)
    }
}
// Helper function
fact(number) {
    return fact(number, 1)
}
这需要对正在做的事情进行解释。识别这种情况很容易,但是如果你调用函数而不是乘法呢?编译器如何知道对于初始调用,累加器必须是1而不是0?你如何翻译这个程序

recsub(number) {
    if (number == 1) {
        return 1
    } else {
        return number - recsub(number - 1)
    }
}
到目前为止,这还不在我们现在所拥有的编译器的范围之内,事实上可能永远都是这样

也许在网上询问这一点会很有趣,看看他们是否知道一些论文或证据更深入地研究了这一点。

Tail Call Optimization: 执行参数和调用的自然方式是在退出或返回时进行清理

要使尾部调用正常工作,您需要对其进行修改,以便尾部调用继承当前帧。因此,它不会生成新的帧,而是对帧进行按摩,以便下一个调用返回到当前函数调用方,而不是此函数,如果是尾部调用,则此函数只会进行清理并返回

因此,TCO就是在最后一次呼叫之前进行清理

延续传球风格——用所有的东西做尾声 编译器可以更改代码,使其仅执行基本操作并将其传递给continuations。因此,堆栈的使用被转移到堆上,因为要继续的计算是一个函数

例如:

function hypotenuse(k1, k2) {
  return sqrt(add(square(k1), square(k2)))
}
变成

function hypotenuse(k, k1, k2) {
  (function (sk1) {
    (function (sk2) {
      (function (ar) {
         k(sqrt(ar));
      }(add(sk1,sk2));
    }(square(k2));
  }(square(k1));
}

请注意,现在每个函数只有一个调用,并且计算顺序已设置。

任何递归函数都可以转换为尾部递归函数。 例如,考虑图灵机的过渡函数,即 是从一个配置到下一个配置的映射。模拟 图灵机你只需要迭代转换函数直到 您将到达一个最终状态,该状态很容易用尾部递归表示 形式。类似地,编译器通常将递归程序转换为 一个迭代的方法就是简单地添加一堆激活记录

您还可以使用continuation将其转换为尾部递归形式 传球风格(CPS)。要做一个经典的例子,考虑斐波那契 傅
function hypotenuse(k, k1, k2) {
  (function (sk1) {
    (function (sk2) {
      (function (ar) {
         k(sqrt(ar));
      }(add(sk1,sk2));
    }(square(k2));
  }(square(k1));
}
def fibc(n, cont):
    if n <= 1:
        return cont(n)
    return fibc(n - 1, lambda a: fibc(n - 2, lambda b: cont(a + b)))