Haskell中的尾部递归

Haskell中的尾部递归,haskell,recursion,tail-recursion,Haskell,Recursion,Tail Recursion,我试图理解Haskell中的尾部递归。我想我知道它是什么,它是如何工作的,但我想确保我没有把事情搞砸 以下是“标准”析因定义: factorial 1 = 1 factorial k = k * factorial (k-1) 例如,在运行时,factorial3,我的函数将调用自身3次(给定或获取)。如果我想计算阶乘9999999,这可能会造成问题,因为我可能会有堆栈溢出。在我到达阶乘1=1之后,我必须在堆栈中“返回”并将所有值相乘,因此我有6个操作(3个用于调用函数本身,3个用于相乘值)

我试图理解Haskell中的尾部递归。我想我知道它是什么,它是如何工作的,但我想确保我没有把事情搞砸

以下是“标准”析因定义:

factorial 1 = 1
factorial k = k * factorial (k-1)
例如,在运行时,
factorial3
,我的函数将调用自身3次(给定或获取)。如果我想计算阶乘9999999,这可能会造成问题,因为我可能会有堆栈溢出。在我到达
阶乘1=1
之后,我必须在堆栈中“返回”并将所有值相乘,因此我有6个操作(3个用于调用函数本身,3个用于相乘值)

现在,我向您介绍另一种可能的阶乘实现:

factorial 1 c = c
factorial k c = factorial (k-1) (c*k)
这个也是递归的。它将自己调用3次。但它不存在这样的问题,即仍然需要“返回”来计算所有结果的乘法,因为我已经将结果作为函数的参数传递了

就我所知,这就是尾部递归的含义。现在,它似乎比第一个好一点,但仍然可以很容易地发生堆栈溢出。我听说Haskell的编译器会在幕后将尾部递归函数转换为for循环。我想这就是为什么做尾部递归函数是值得的

如果这就是原因,那么如果编译器不打算做这个聪明的把戏,那么绝对没有必要尝试使函数尾部递归——对吗?例如,虽然理论上C#编译器可以检测尾部递归函数并将其转换为循环,但我知道(至少我听说过)目前它没有这样做。因此,现在绝对没有必要让函数尾部递归。是这样吗


谢谢

这里有两个问题。一个是一般的尾部递归,另一个是Haskell如何处理事情

关于尾部递归,您的定义似乎是正确的。有用的部分是,因为只需要每个递归调用的最终结果,所以早期调用不需要保留在堆栈上。函数没有“调用自身”,而是做了一些更接近于“替换”自身的事情,最终看起来就像一个迭代循环。这是一个相当简单的优化,体面的编译器通常会提供

第二个问题是惰性评估。因为Haskell只根据需要计算表达式,所以默认情况下尾部递归并不像通常那样工作。它没有在每次调用时替换它,而是构建了一大堆嵌套的“thunks”,即尚未请求其值的表达式。如果这个thunk堆足够大,它确实会产生堆栈溢出

Haskell中实际上有两种解决方案,具体取决于您需要做什么:

  • 如果结果由嵌套的数据构造函数组成——比如生成一个列表——那么您希望避免尾部递归;相反,将递归放在一个构造函数字段中。这将使结果也是惰性的,不会导致堆栈溢出

  • 如果结果由单个值组成,则需要严格计算它,以便在需要最终值时强制执行递归的每个步骤。这提供了尾部递归所期望的通常的伪迭代


另外,请记住,GHC非常聪明,如果您使用优化进行编译,它通常会发现评估应该严格的地方,并为您处理。不过,这在GHCi中不起作用。

您应该使用内置机制,这样就不必考虑如何使函数尾部递归

fac 0 = 1
fac n = product [1..n]
或者,如果产品尚未定义:

fac n = foldl' (*) 1 [1..n]

(请参阅要使用的折叠…版本)

只是指出“标准”阶乘定义是
factorial 0=1
是的,我想到了,但阶乘1=1更有效。你知道,在计算阶乘时,节省一步迭代可能是最不需要担心的事情。另外,如果你试图计算9999999!我敢肯定堆栈溢出将是您的最小问题。计算999999!不是线程停止挑剔的点。foldl是左关联的,并且是尾部递归的:
foldl◦ b[x1,x2,x3,…,xk]=(…)((b)◦ x1)◦ x2)◦ x3)◦ ...) ◦ xk
虽然foldr是右关联的,但它不是尾部递归的:
foldr◦ b[x1,x2,x3,…,xk]=x1◦ (x2◦ (x3)◦ (…(xk)◦ b) …)
+1:我只想补充一点,尾部调用优化在Haskell这样的纯函数式语言(但在C#或Python这样的混合语言或纯命令式语言中是毫无意义的)上非常重要,原因显而易见@rsenna:我不会说它毫无意义,只是当最简单情况的优化版本是一个语言原语时,它更容易处理。TCO仍然是严格的优越性,例如,你可以让两个函数互相调用尾部,或者做一些更复杂的事情。@rsenna:呃,我真的不明白省略编译器优化会让一种不那么令人困惑的语言?以及“唯一的方法”这件事很难认真对待,因为如果我们选择一种单一的迭代技术,一般的递归比大多数语言的随机循环要好得多。@rsenna:我不完全确定你在提倡什么。你是说命令式语言编译器不应该实现TCO,因为性能会受到影响吗会阻止用户编写递归函数,或者你认为命令式语言根本不应该允许递归函数?或者其他什么?@rsenna:我不是一个学者。我不做研究,我只是为了完成事情而编写程序。我学习新的抽象概念,因为它们扩展了我思考问题的能力程序的结构,我使用工具来自动化我能做的任何事情。你似乎在说忽略抽象和破坏性的工具…在某种程度上比