在函数式语言中,编译器如何将非尾递归转换为循环以避免堆栈溢出(如果有的话)?
In functional languages, how is the compiler able to translate non-tail recursion into loops to avoid stack overflows (if at all)?
我最近一直在学习函数式语言以及有多少不包含 for 循环。虽然我个人并不认为递归比 for 循环更难(而且通常更容易推理),但我意识到许多递归示例不是尾递归,因此不能使用简单的尾递归优化来避免堆栈溢出。 According to this question, all iterative loops can be translated into recursion, and those iterative loops can be transformed into tail recursion, so it confuses me when the answers on a question like this 建议如果您想避免堆栈溢出,您必须自己明确管理递归到尾递归的转换。似乎编译器应该可以完成从递归到尾递归的所有转换,或者从递归直接到迭代循环的所有转换,而不会出现堆栈溢出。
函数式编译器是否能够在更一般的递归情况下避免堆栈溢出?您是否真的被迫自己转换递归代码以避免堆栈溢出?如果他们不能执行一般的递归堆栈安全编译,他们为什么不能?
According to this question, all iterative loops can be translated into recursion
"Translated" 可能有点牵强。如果你理解图灵完备性,证明每个迭代循环都有一个等效的递归程序是微不足道的:因为图灵机可以使用严格的迭代结构和严格的递归结构来实现,所以每个可以用迭代语言表达的程序都可以表达在递归语言中,vice-versa。这意味着对于每个迭代循环, 是 一个等效的递归结构(反之亦然)。然而,这并不意味着我们有一些自动的方法将一个转换成另一个。
and those iterative loops can be transformed into tail recursion
尾递归也许可以很容易地转化为迭代循环,反之亦然。但并不是所有的递归都是尾递归。这是一个例子。假设我们有一些二叉树。它由 node
组成。每个 node
可以有一个 left
和一个 right
child 和一个 value
。如果一个节点没有 children,则 isLeaf
returns 为真。我们假设有一些函数 max
是 returns 两个值的最大值,如果其中一个值是 null
它 returns 另一个。现在我们要定义一个函数来查找所有叶节点中的最大值。这是我做的pseudo-code
findmax(node) {
if (node == null) {
return null
}
if (node.isLeaf) {
return node.value
} else {
return max(findmax(node.left), findmax(node.right))
}
}
max
函数中有两次递归调用,所以我们无法针对尾递归进行优化。在将它们提供给 max
函数并确定当前节点的调用结果之前,我们需要两者的结果。
现在,可能有一种方法可以获得相同的结果,即使用递归且仅调用一次 tail-recursive。它在功能上是等效的,但它是一种不同的算法。编译器可以做很多转换来创建功能等效的程序并进行大量优化,但它们还不够聪明,无法创建功能等效的算法。
即使将仅递归调用一次自身的函数转换为 tail-recursive 版本也绝非易事。这种改编通常使用一些参数传递给递归调用,用作当前结果的 "accumulator"。
查看下一个计算数字阶乘的简单实现(例如 fact(5) = 5*4*3*2*1):
fact(number) {
if (number == 1) {
return 1
} else {
return number * fact(number - 1)
}
}
不是tail-recursive。但是可以这样制作:
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)
}
}
到目前为止,这超出了我们现在拥有的那种编译器的范围,事实上可能永远都是。
也许在 computer science Stack Exchange 上问这个问题会很有趣,看看他们是否知道一些对此进行更多研究的论文或证明 in-depth。
尾调用优化:
进行参数和调用的自然方式是在退出或返回时整理清理。
要使尾调用起作用,您需要对其进行更改,以便尾调用继承当前帧。因此,它不会创建一个新框架,而是对框架进行按摩,以便下一次调用 returns 到当前函数调用者而不是这个函数,如果它是尾调用,它实际上只会清理和 returns。
因此 TCO 就是最后一次调用之前的清理。
Continuation Passing Style - 对所有内容进行尾调用
编译器可以更改代码,使其仅执行原始操作并将其传递给延续。因此,由于要继续的计算成为一个函数,因此堆栈使用被移到堆上。
一个例子是:
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)。举一个经典的例子,考虑斐波那契
功能。
这可以通过以下方式用 CPS 样式表示,其中第二个
参数是延续(本质上是一个回调函数):
def fibc(n, cont):
if n <= 1:
return cont(n)
return fibc(n - 1, lambda a: fibc(n - 2, lambda b: cont(a + b)))
同样,您正在使用动态数据结构模拟递归堆栈:
在这种情况下,lambda 抽象。
在所有以前的动态结构(列表、堆栈、函数等)中的使用
例子是必不可少的。也就是说,为了模拟一个泛型
递归函数迭代,你无法避免动态内存分配,
因此,您通常无法避免堆栈溢出。
因此,内存消耗不仅与iterative/recursive有关
程序的性质。另一方面,如果你阻止动态内存
分配,你的
程序本质上是有限状态机,具有有限的计算能力
功能(更有趣的是根据
输入的维度)。
一般来说,就像你无法预测终止一样,你也不能
预测程序的未绑定内存消耗:使用
编译时的图灵完备语言
发散无法避免,栈溢出也无法避免
我最近一直在学习函数式语言以及有多少不包含 for 循环。虽然我个人并不认为递归比 for 循环更难(而且通常更容易推理),但我意识到许多递归示例不是尾递归,因此不能使用简单的尾递归优化来避免堆栈溢出。 According to this question, all iterative loops can be translated into recursion, and those iterative loops can be transformed into tail recursion, so it confuses me when the answers on a question like this 建议如果您想避免堆栈溢出,您必须自己明确管理递归到尾递归的转换。似乎编译器应该可以完成从递归到尾递归的所有转换,或者从递归直接到迭代循环的所有转换,而不会出现堆栈溢出。
函数式编译器是否能够在更一般的递归情况下避免堆栈溢出?您是否真的被迫自己转换递归代码以避免堆栈溢出?如果他们不能执行一般的递归堆栈安全编译,他们为什么不能?
According to this question, all iterative loops can be translated into recursion
"Translated" 可能有点牵强。如果你理解图灵完备性,证明每个迭代循环都有一个等效的递归程序是微不足道的:因为图灵机可以使用严格的迭代结构和严格的递归结构来实现,所以每个可以用迭代语言表达的程序都可以表达在递归语言中,vice-versa。这意味着对于每个迭代循环, 是 一个等效的递归结构(反之亦然)。然而,这并不意味着我们有一些自动的方法将一个转换成另一个。
and those iterative loops can be transformed into tail recursion
尾递归也许可以很容易地转化为迭代循环,反之亦然。但并不是所有的递归都是尾递归。这是一个例子。假设我们有一些二叉树。它由 node
组成。每个 node
可以有一个 left
和一个 right
child 和一个 value
。如果一个节点没有 children,则 isLeaf
returns 为真。我们假设有一些函数 max
是 returns 两个值的最大值,如果其中一个值是 null
它 returns 另一个。现在我们要定义一个函数来查找所有叶节点中的最大值。这是我做的pseudo-code
findmax(node) {
if (node == null) {
return null
}
if (node.isLeaf) {
return node.value
} else {
return max(findmax(node.left), findmax(node.right))
}
}
max
函数中有两次递归调用,所以我们无法针对尾递归进行优化。在将它们提供给 max
函数并确定当前节点的调用结果之前,我们需要两者的结果。
现在,可能有一种方法可以获得相同的结果,即使用递归且仅调用一次 tail-recursive。它在功能上是等效的,但它是一种不同的算法。编译器可以做很多转换来创建功能等效的程序并进行大量优化,但它们还不够聪明,无法创建功能等效的算法。
即使将仅递归调用一次自身的函数转换为 tail-recursive 版本也绝非易事。这种改编通常使用一些参数传递给递归调用,用作当前结果的 "accumulator"。
查看下一个计算数字阶乘的简单实现(例如 fact(5) = 5*4*3*2*1):
fact(number) {
if (number == 1) {
return 1
} else {
return number * fact(number - 1)
}
}
不是tail-recursive。但是可以这样制作:
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)
}
}
到目前为止,这超出了我们现在拥有的那种编译器的范围,事实上可能永远都是。
也许在 computer science Stack Exchange 上问这个问题会很有趣,看看他们是否知道一些对此进行更多研究的论文或证明 in-depth。
尾调用优化:
进行参数和调用的自然方式是在退出或返回时整理清理。
要使尾调用起作用,您需要对其进行更改,以便尾调用继承当前帧。因此,它不会创建一个新框架,而是对框架进行按摩,以便下一次调用 returns 到当前函数调用者而不是这个函数,如果它是尾调用,它实际上只会清理和 returns。
因此 TCO 就是最后一次调用之前的清理。
Continuation Passing Style - 对所有内容进行尾调用
编译器可以更改代码,使其仅执行原始操作并将其传递给延续。因此,由于要继续的计算成为一个函数,因此堆栈使用被移到堆上。
一个例子是:
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)。举一个经典的例子,考虑斐波那契 功能。 这可以通过以下方式用 CPS 样式表示,其中第二个 参数是延续(本质上是一个回调函数):
def fibc(n, cont):
if n <= 1:
return cont(n)
return fibc(n - 1, lambda a: fibc(n - 2, lambda b: cont(a + b)))
同样,您正在使用动态数据结构模拟递归堆栈: 在这种情况下,lambda 抽象。
在所有以前的动态结构(列表、堆栈、函数等)中的使用 例子是必不可少的。也就是说,为了模拟一个泛型 递归函数迭代,你无法避免动态内存分配, 因此,您通常无法避免堆栈溢出。
因此,内存消耗不仅与iterative/recursive有关 程序的性质。另一方面,如果你阻止动态内存 分配,你的 程序本质上是有限状态机,具有有限的计算能力 功能(更有趣的是根据 输入的维度)。
一般来说,就像你无法预测终止一样,你也不能 预测程序的未绑定内存消耗:使用 编译时的图灵完备语言 发散无法避免,栈溢出也无法避免