【发布时间】:2020-05-29 21:56:03
【问题描述】:
在大多数资源中,建议使用 foldl',但是在 concat 中使用 foldr 而不是 foldl' 的原因是什么?
【问题讨论】:
标签: haskell
在大多数资源中,建议使用 foldl',但是在 concat 中使用 foldr 而不是 foldl' 的原因是什么?
【问题讨论】:
标签: haskell
编辑我在这个答案中谈到了懒惰和生产力,我兴奋地忘记了 jpmariner 在他们的答案中关注的一个非常重要的点:左关联(++) 是二次时间!
foldl' 适用于您的累加器是严格类型的情况,例如大多数小型类型,例如Int,甚至是大型脊椎严格数据结构,例如Data.Map。如果累加器是严格的,则必须先消耗整个列表,然后才能给出任何输出。 foldl' 使用尾递归来避免在这些情况下炸毁堆栈,但 foldr 不会而且会执行得很差。另一方面,foldl'必须以这种方式消费整个列表。
foldl f z [] = z
foldl f z [1] = f z 1
foldl f z [1,2] = f (f z 1) 2
foldl f z [1,2,3] = f (f (f z 1) 2) 3
列表的 final 元素是评估 outermost 应用程序所必需的,因此无法部分使用列表。如果我们用(++) 扩展它,我们会看到:
foldl (++) [] [[1,2],[3,4],[5,6]]
= (([] ++ [1,2]) ++ [3,4]) ++ [5,6]
^^
= ([1,2] ++ [3,4]) ++ [5,6]
= ((1 : [2]) ++ [3,4]) ++ [5,6]
^^
= (1 : ([2] ++ [3,4])) ++ [5,6]
^^
= 1 : (([2] ++ [3,4]) ++ [5,6])
(我承认,如果您对缺点列表没有很好的感觉,这看起来有点神奇;不过,值得一提的是细节)
看看在1 冒泡之前,我们必须如何评估每个 (++)(评估时标有^^)?在此之前,1 一直“隐藏”在功能应用程序下。
另一方面,foldr 对列表等非严格累加器很有用,因为它允许累加器在整个列表被消耗之前产生信息,这可以将许多经典的线性空间算法降低到恒定空间!这也意味着,如果您的列表是无限的,foldr 是您唯一的选择,除非您的目标是使用 CPU 为房间供暖。
foldr f z [] = z
foldr f z [1] = f 1 z
foldr f z [1,2] = f 1 (f 2 z)
foldr f z [1,2,3] = f 1 (f 2 (f 3 z))
foldr f z [1..] = f 1 (f 2 (f 3 (f 4 (f 5 ...
我们无需查看整个列表即可轻松表达最外层的应用程序。以与 foldl 相同的方式扩展 foldr:
foldr (++) z [[1,2],[3,4],[5,6]]
= [1,2] ++ ([3,4] ++ ([5,6] ++ []))
= (1 : [2]) ++ (3,4] ++ ([5,6] ++ []))
^^
= 1 : ([2] ++ ([3,4] ++ ([5,6] ++ [])))
1 立即生成,无需评估除第一个之外的任何(++)s。因为这些(++)s 都没有被评估,而且Haskell 是惰性的,所以它们甚至不必生成,直到消耗更多的输出列表,这意味着concat 可以在恒定空间中运行对于这样的功能
concat [ [1..n] | n <- [1..] ]
在严格的语言中需要任意长度的中间列表。
如果这些缩减看起来有点太神奇,并且如果您想更深入,我建议检查source of (++) 并根据其定义进行一些简单的手动缩减以感受一下。 (请记住[1,2,3,4] 是1 : (2 : (3 : (4 : []))) 的符号)。
一般来说,以下似乎是一个强有力的效率经验法则:当你的累加器是一个严格的数据结构时使用foldl',而不是foldr。如果你看到一个朋友使用普通的foldl 并且不阻止他们,你是什么样的朋友?
【讨论】:
在 concat 中使用 foldr 而不是 foldl' 的原因?
如果您在命令式编程思维中考虑[1,2,3] ++ [6,7,8],您所要做的就是将节点 3 处的 next 指针重定向到节点 6,当然前提是您可以更改左侧操作数。
这是 Haskell,您可以不更改左侧操作数,除非优化器能够证明 ++ 是其左侧操作数的唯一用户。
缺少这样的证明,其他指向节点 1 的 Haskell 表达式完全有权假设节点 1 永远位于长度为 3 的列表的开头。在 Haskell 中,pure 的属性> 表达式在其生命周期内不能更改。
因此,在一般情况下,运算符++ 必须通过复制其左侧操作数来完成其工作,然后可以设置节点3的复制指向节点 6。另一方面,右侧操作数可以按原样获取。
因此,如果从右侧开始折叠 concat 表达式,则连接的每个组件都必须精确复制一次。但是如果从左边开始折叠表达式,就会面临大量重复的重复工作。
让我们尝试定量地检查一下。为了确保没有优化器通过证明任何事情来妨碍,我们将只使用 ghci 解释器。它的强项是交互性而非优化。
所以让我们介绍一下 ghci 的各种候选者,并打开统计模式:
$ ghci
λ>
λ> myConcat0 = L.foldr (++) []
λ> myConcat1 = L.foldl (++) []
λ> myConcat2 = L.foldl' (++) []
λ>
λ> :set +s
λ>
我们将通过使用数字列表并打印它们的总和来强制进行全面评估。
首先,让我们通过从右侧折叠获得基线性能:
λ>
λ> sum $ concat [ [x] | x <- [1..10000::Integer] ]
50005000
(0.01 secs, 3,513,104 bytes)
λ>
λ> sum $ myConcat0 [ [x] | x <- [1..10000::Integer] ]
50005000
(0.01 secs, 3,513,144 bytes)
λ>
其次,让我们从左边折叠,看看这是否有所改善。
λ>
λ> sum $ myConcat1 [ [x] | x <- [1..10000::Integer] ]
50005000
(1.26 secs, 4,296,646,240 bytes)
λ>
λ> sum $ myConcat2 [ [x] | x <- [1..10000::Integer] ]
50005000
(1.28 secs, 4,295,918,560 bytes)
λ>
所以从左边折叠会分配更多的临时内存并花费更多的时间,这可能是因为这种重复的复制工作。
作为最后的检查,让我们将问题规模扩大一倍:
λ>
λ> sum $ myConcat2 [ [x] | x <- [1..20000::Integer] ]
200010000
(5.91 secs, 17,514,447,616 bytes)
λ>
我们看到,问题规模翻倍导致资源消耗乘以大约 4。在 concat 的情况下,从左侧折叠具有二次成本。
查看 luqui 的出色回答,我们看到两个问题:
碰巧以相同的方式投票,即赞成从右边折叠。
因此 Haskell 库 concat 函数使用 foldr。
在使用带有 -O3 选项而不是 ghci 的 GHC v8.6.5 运行一些测试后,我认为优化器会干扰测量的先入为主的想法似乎是错误的。
即使使用 -O3,对于 20,000 个问题大小,基于 foldr 的 concat 函数也比基于 foldl' 的 concat 函数快约 500 倍。
因此,要么优化器无法证明可以更改/重用左操作数,要么根本不尝试。
【讨论】:
ghc 这样的真正编译器不够聪明,无法就地覆盖您提到的链表的尾部。这是一个很大的问题。你知道吗?