【问题标题】:Tail Recursion in HaskellHaskell 中的尾递归
【发布时间】:2017-09-12 02:48:27
【问题描述】:

我正在尝试理解 Haskell 中的尾递归。我想我了解它是什么以及它是如何工作的,但我想确保我没有把事情搞砸。

这是“标准”阶乘定义:

factorial 1 = 1
factorial k = k * factorial (k-1)

在运行时,例如factorial 3,我的函数将调用自身 3 次(给予或接受)。如果我想计算阶乘 99999999,这可能会造成问题,因为我可能会出现堆栈溢出。到达factorial 1 = 1 后,我将不得不“返回”堆栈并乘以所有值,因此我有 6 个操作(3 个用于调用函数本身,3 个用于乘以值)。

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

factorial 1 c = c
factorial k c = factorial (k-1) (c*k)

这个也是递归的。它会调用自己 3 次。但它不存在然后仍然必须“回来”来计算所有结果的乘法的问题,因为我已经将结果作为函数的参数传递了。

据我所知,这就是尾递归。现在,它似乎比第一个好一点,但您仍然可以轻松地发生堆栈溢出。我听说 Haskell 的编译器会在后台将 Tail-Recursive 函数转换为 for 循环。我想这就是为什么做尾递归函数有回报的原因?

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

谢谢!

【问题讨论】:

  • 只是指出“标准”阶乘定义是factorial 0 = 1
  • 是的,我想到了,但是阶乘 1 = 1 更有效。
  • 您知道,保存单步迭代可能是计算阶乘时要担心的最后件事。另外,如果您尝试计算 99999999!我很确定堆栈溢出将是您遇到的最少的问题。
  • 计算 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)...)))

标签: haskell recursion tail-recursion


【解决方案1】:

这里有两个问题。一个是一般的尾递归,另一个是 Haskell 的处理方式。

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

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

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

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

  • 如果结果由单个值组成,您希望严格地评估它,以便在需要最终值时立即强制执行递归的每一步。这给出了尾递归所期望的通常的伪迭代。

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

【讨论】:

  • +1:我只想补充一点,尾调用优化是基本的,原因很明显,在像 Haskell 这样的纯函数式语言上(但在混合或纯命令式中有点毫无意义C# 或 Python 等语言)。
  • @rsenna:我不会说它毫无意义,只是当最简单情况的优化版本是语言原语时更容易解决。 TCO 仍然是绝对优越的,因为您可以例如有两个函数相互尾调用或更复杂的东西。
  • @rsenna:呃,我真的没有看到省略编译器优化会如何让语言不那么混乱?而且“只有一种方法可以做到”这件事很难认真对待,因为如果我们选择一种迭代技术,一般递归是比大多数语言所具有的随机分类循环更好的选择。
  • @rsenna:我不完全确定你在提倡什么。您是说命令式语言编译器不应该实现 TCO,因为性能下降会阻止用户编写递归函数,还是您认为命令式语言根本不应该允许递归函数?还是别的什么?
  • @rsenna:在任何意义上,我都不是学者。我不做研究,我只是编写程序来完成任务。我学习了新的抽象概念,因为它们扩展了我思考程序结构的能力,并且我使用工具来自动化我能做的任何事情。您似乎是在说忽略抽象和削弱工具……对于“现实世界”来说更好?我真的不明白。
【解决方案2】:

您应该使用内置机制,然后您不必考虑使函数尾递归的方法

fac 0 = 1
fac n = product [1..n]

或者如果产品尚未定义:

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

(请参阅http://www.haskell.org/haskellwiki/Foldr_Foldl_Foldl%27 关于使用哪个折叠...版本)

【讨论】:

  • 这似乎不是问题的重点。问题是关于什么是尾递归。 -1
  • camccann 已经给出了一个很好的、充分的答案。我不知道您对此有何看法,但如果我的问题两个都得到了解答,以及一些额外的信息、考虑或批评,我总是很高兴。您为什么不简单地写一个更有帮助的答案,而不是投反对票?
  • 您意识到如果不进行优化,foldl 将导致大型列表的堆栈溢出,对吧?在上下文中似乎有点讽刺。
  • @camccann:感谢您的提示,我总是忘记使用哪一个。已更正。
  • 这里有一个经验法则:foldl 几乎永远不会使用。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2012-10-14
  • 2015-05-09
  • 1970-01-01
  • 2021-11-27
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多