【问题标题】:When is tail-recursion needed, in the context of lazy evaluation?在惰性求值的情况下,何时需要尾递归?
【发布时间】:2016-04-13 15:18:43
【问题描述】:

我的理解(可能不正确或不完整)是 惰性求值 可以提供 尾递归 的所有优点,并且做得更好。

如果是这样,是否意味着在惰性求值的上下文中不需要尾递归

更新

具体看下面的例子:

(define (foo f a)
  (if (number? a)
    (* a a)
    (lazy-map foo a)))

这个函数可以很容易地转换为尾递归函数。但是,如果是这样,我们将失去惰性求值的优势。

实际上,当输入是一个非常大的列表(或无限)时,这个非尾递归函数是否需要消耗很多堆栈?我不这么认为。那么,是否有充分的理由使用 尾递归 而不是 惰性求值

【问题讨论】:

  • 不太清楚你在问什么。惰性求值是一种求值策略(通常可以通过使用词法闭包在用户空间中实现),而尾递归只是某种递归调用。即使在那里,我假设您实际上是指尾调用消除/尾调用优化,它允许优化尾递归以在更少的空间中运行。这允许递归程序不消耗所有的堆栈空间。您能否提供任何您认为惰性评估将消除尾调用优化需求的示例,反之亦然?
  • 即使存在惰性求值,您可能仍然需要急切地计算一些东西。在这种情况下,您将使用带有严格计算的累积参数的尾递归。当你懒惰地构建你的结果时,即使用懒惰的构造函数,保护递归是要走的路,将递归调用放在懒惰的数据构造函数后面。有关 Haskell 的答案,请参阅 stackoverflow.com/questions/13042353/…
  • 暂停投票让我有点困惑。 OP 显然对递归和惰性感到困惑,但这就是问题的重点。她/他基于混淆提出了一个合理的问题。 “不清楚你在问什么”的唯一原因是 OP 还不明白。如果她/他理解,就没有问题了。正确的反应是解释他/她为什么感到困惑——消除误解。一些 cmets 会这样做;在我看来,它们是正确的答案。
  • @mars: '惰性求值是否比尾递归做得更好' - 这是什么意思?什么“更好的工作”?什么是“重要角色”?尾递归提供了哪些“优势”?缺点呢?这完全是模糊的。没有例子。没有细节。甚至不清楚这些概念是否相关。既然存在符号,为什么需要数字?我希望有一个更具体的问题,需要投入更多的工作。为什么 Lisp 被标记,因为它不使用惰性求值?为什么不用 Haskell,因为它使用惰性求值。
  • @Mars:'既然存在 B,为什么需要 A? A 能提供 B 的所有优点吗? A 比 B 做得更好吗? ...' 这是许多无用问题的模式,同时提供零上下文并且没有真正的编程问题。这是一个低质量的问题。

标签: haskell clojure functional-programming scheme lazy-evaluation


【解决方案1】:

TL;DR“尾递归”定义函数在惰性语言中通常没有用处。即使在这些情况下,您也可能会发生堆栈溢出(与具有适当尾调用优化的严格语言相反)。

通过对函数参数使用惰性求值(按名称调用),您将失去用递归表达简单迭代(使用常量空间)的能力。

例如,让我们比较两个版本的长度函数。首先我们看下非尾递归函数来比较惰性和非惰性:

length [] = 0
length (head:tail) = length tail + 1

严格评价length [1, 2, 3]

length [1, 2, 3] ->
  length (1:[2, 3]) = length [2, 3] + 1 ->
    length (2:[3]) + 1 = (length [3] + 1) + 1 ->
      (length (3:[]) + 1) + 1 = ((length [] + 1) + 1) + 1 ->
        ((length [] + 1) + 1) + 1 = ((0 + 1) + 1) + 1 ->
      (1 + 1) + 1 ->
    2 + 1
  3

懒惰的评价:

length [1, 2, 3] ->
length (1:[2, 3]) = length [2, 3] + 1 ->

此时需要减少+,它需要评估两个参数,第一个是length [2, 3],所以它像以前一样继续,但在一个少一个参数的列表上。 堆栈空间用于评估+(如果我们正在考虑一个简单的实现)。

因此,两个版本都使用堆栈,但对于懒惰的版本,+ 是这里的“递归”函数,而不是 +

尾递归变体(使用累加器):

length [] a = a
length (head:tail) a = length tail (a + 1)

利用尾调用优化的严格评估步骤:

length [1, 2, 3] 0 ->
length (1:[2, 3]) 0 = length [2, 3] (0 + 1) ->
length (2:[3]) 1 = length [3] (1 + 1) ->
length (3:[]) 2 = length [] 2 + 1 ->
length [] 3 = 3

这使用堆栈上的常量空间,堆上没有空间

懒惰的评价:

length [1, 2, 3] 0 ->
length (1:[2, 3]) 0 = length [2, 3] (0 + 1) ->
length (2:[3]) (0 + 1) = length [3] ((0 + 1) + 1) ->
length (3:[]) ((0 + 1) + 1) = length [] ((0 + 1) + 1) + 1 ->
length [] (0 + 1) + 1) + 1 = ((0 + 1) + 1) + 1 ->
magic -> 3

这使用堆栈上的恒定空间直到“魔术”部分,但在堆上建立延迟计算(加法)(通常)。标记为“魔术”的部分是所有的总和发生的地方,在简单的实现中它使用堆栈。 (请注意,在这种情况和类似情况下,优化评估器实际上可能会在评估期间进行加法运算,然后只返回 3,您无法真正分辨出区别,但没有使用堆栈。它可以使用其他技巧来防止堆栈溢出像 CPS)。

总结:

惰性求值函数最终在其直接实现中使用堆栈,无论它们是否编写为尾递归。

但是,您需要对编译器应用各种技巧来优化堆栈使用。不过,它们不像严格语言中的尾调用优化那么简单。

更好的选择是习惯性地使用这些语言并利用各种高阶函数,从而显着减少对递归函数定义的需求(并且在某种程度上也是出于这个原因在惰性语言中)。

【讨论】:

  • 我认为CPS一般会消耗堆资源。这就是你的意思吗:“它可以使用其他技巧来防止像 CPS 这样的堆栈溢出”?
  • 是的,它通过将所有调用置于尾部位置并将延续作为参数传递来用堆栈空间换取堆空间。但是这句话对于回答这个问题并不是很重要,只是暗示总有办法防止堆栈溢出,但在这种情况下可能相当昂贵。
猜你喜欢
  • 1970-01-01
  • 2012-08-15
  • 1970-01-01
  • 1970-01-01
  • 2015-01-18
  • 1970-01-01
  • 2013-07-21
  • 1970-01-01
  • 2012-03-31
相关资源
最近更新 更多