【问题标题】:Is foldR Tail Recursive in Haskell?Haskell中的foldR尾递归吗?
【发布时间】:2021-04-27 20:48:51
【问题描述】:

我是 Haskell 的新手,从第一原理开始阅读 Haskell, 在第 384 页的 Folds 章节中,我遇到了 FoldR 并且似乎它不是尾递归的

foldr :: (a -> b -> b) -> b -> [a] -> b
foldr f z [] = z
foldr f z (x:xs) = f x (foldr f z xs)

1- 我们可以让它尾递归吗?

2- 会不会被优化?

【问题讨论】:

  • 不知道为什么人们不赞成,因为这是一个完全合理的问题。但是,正如答案正确指出的那样,“尾递归”在 Haskell 中并不是那么相关。您更担心 thunk 的存储空间(这是 foldl 在客观上比 foldl' 更糟糕的一个重要原因,尽管可以以直接的方式递归地实现这两个尾部。
  • 也不知道为什么这被否决了——而且我认为这并不是给出的性能问题的重复。恕我直言,这个问题和答案很好,而且独立存在,所以我想投票重新开放这个问题。可悲的是,这会立即重新打开它,我不想把它变成关闭/重新打开战争那么其他人怎么看?
  • @Carsten 的答案在这里似乎有些不同。

标签: haskell optimization ghc tail-recursion fold


【解决方案1】:

在像 Haskell 这样的惰性语言中,尾递归通常是个坏主意。这是其中一种情况。

我们可能会尝试使 foldr 尾部递归,例如从reverse 开始(也可以通过尾递归方式),然后以尾递归方式逐步累积foldr 结果。但是,这会破坏 foldr 语义。

使用标准foldr,我们有例如

foldr (\_ _ -> k1) k2 (x:xs) = k1

不管xs 是什么,包括像undefined 这样的底部值或像[0..] 这样的无限列表。此外,当xs 是一个有限但很长的列表时,上面的代码也很有效,因为它会立即停止计算而不扫描整个列表。

作为一个更实际的例子,

and :: [Bool] -> Bool
and = foldr (&&) True

只要xs 的某些元素计算为False,就使and xs 返回False,而不扫描列表的其余部分。

最后,将foldr 转换为尾递归函数将:

  • 在处理部分定义的列表 (1:2:undefined) 或无限列表 ([0..]) 时更改语义;
  • 在有限长度的列表上效率较低,即使没有必要,也总是需要完全扫描。

【讨论】:

  • stackoverflow.com/a/56958943/3862698 在这里他们已经定义了 foldr 使用延续(对我来说就像返回一个函数)它是否优化以在 haskell 中使用它?或者它也会破坏语义?
  • @hanan 你认为“优化”是什么意思?
  • @hanan 你在比较苹果和橘子。我的答案考虑了像 Haskell 这样的 lazy 语言,而你提到的答案是关于 eager 的 F#,因此语义不同。由于这种差异,我们不能将相同的推理应用于两种语言:一种语言的良好做法很容易成为另一种语言的不良做法,反之亦然。
  • ^^^ 具体来说,F# 和 Haskell 中的这种“CPS”技术首先从 n-long 列表构建一个 depth-n 闭包,然后将其应用于初始值(在您链接的答案)。然后,在 strict 语言中,执行 n 个应用程序链从下到上构建结果,这没关系。但是在惰性语言中,应用程序cont {func x r} 不会减少(即评估)应用程序func x r——相反,它将按原样传递给cont。结果是另外两个无关的遍历,其中一个在堆栈上,有可能发生堆栈溢出。 ... @hanan
  • ... 补救措施是使用(\ r -> cont $! func x r) 作为每个步骤的延续,以恢复所需的严格性。 @hanan
【解决方案2】:

foldr 不是尾递归的......但它可用于编写在常量空间中处理列表的函数。 chi 已经指出它可以有效地实现and。以下是它如何实现对列表求和的有效函数:

mySum :: Num a => [a] -> a
mySum xs = foldr go id xs 0
  where
    go x r acc = r $! x + acc

这是怎么回事?考虑mySum [1,2,3]。这扩展为

foldr go id [1,2,3] 0
==> -- definition of foldr
go 1 (foldr go id [2,3]) 0
==> -- definition of go
foldr go id [2,3] $! 1 + 0
==> -- strict application
foldr go id [2,3] 1

我们已将列表大小减一,并且没有在“堆栈”上累积任何内容。重复相同的过程直到我们得到

foldr go id [] 6
==> definition of foldr
id 6
==> definition of id
6

注意:如果此代码由 GHC 编译并启用了优化(-O-O2),那么它实际上会将其转换为极快的尾递归代码,而无需您提供任何进一步的帮助。但即使未优化,它也可以正常工作,不会消耗大量内存/堆栈。

【讨论】:

  • 很好的答案和例子
【解决方案3】:

foldr 不是尾递归的。 有时它被称为真正的“递归”折叠,而左折叠是“迭代”(因为尾递归,它相当于迭代)。

请注意,在 Haskell 中,由于懒惰,foldl 也不保证恒定空间,这就是它存在的原因foldl'

【讨论】:

  • 可以给我 fold 的参考吗?
【解决方案4】:

foldr 本身不是尾递归的,但取决于ffoldr f 可能是尾递归的。由于惰性求值,Haskell 中的尾递归非常微妙。

例如,考虑f = (&&)。在这种情况下,我们有

foldr (&&) acc lst =
   case lst of
      [] -> acc
      (x:xs) -> x && foldr (&&) acc xs
= 
   case lst of
      [] -> acc
      (x:xs) -> if x
                then foldr (&&) acc xs
                else False
=
   case lst of
      [] -> acc
      (x:xs) -> case x of
         True -> foldr (&&) acc xs
         False -> False

请注意,在这种情况下,我们清楚地看到foldr (&&) 是尾递归的。其实foldr (||)也是尾递归的。请注意,foldr (&&) 是尾递归的事实基本上是因为懒惰。如果不是因为懒惰,我们将不得不评估foldr (&&) acc xs之前将结果代入x && foldr (&&) acc xs。但由于懒惰,我们先评估x,然后才确定是否需要调用foldr (&&) acc xs,而无论何时调用,都是尾调用。

但是,在大多数情况下,foldr f 不会是尾递归函数。特别是,foldr ((+) :: Int -> Int -> Int) 不是尾递归的。

【讨论】:

  • 我相信几乎所有实际应用程序,foldr c n 实际上 是尾递归,或者至少(大约)尾递归模数。也有例外,例如 data SList a = SCons !a !(SList a) | SNil; fromList = foldr SCons SNil,在某些情况下不出现是合理的,但这种事情并不经常出现。
猜你喜欢
  • 2012-10-14
  • 1970-01-01
  • 2015-12-21
  • 1970-01-01
  • 1970-01-01
  • 2017-05-23
  • 1970-01-01
  • 2015-05-09
相关资源
最近更新 更多