【问题标题】:Why does function concat use foldr? Why not foldl'为什么函数 concat 使用 foldr?为什么不折叠'
【发布时间】:2020-05-29 21:56:03
【问题描述】:

在大多数资源中,建议使用 foldl',但是在 concat 中使用 foldr 而不是 foldl' 的原因是什么?

【问题讨论】:

    标签: haskell


    【解决方案1】:

    编辑我在这个答案中谈到了懒惰和生产力,我兴奋地忘记了 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 并且不阻止他们,你是什么样的朋友?

    【讨论】:

      【解决方案2】:
      在 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 的出色回答,我们看到两个问题:

      1. 需要能够懒惰地访问结果列表的开头
      2. 需要避免二次成本以进行全面评估

      碰巧以相同的方式投票,即赞成从右边折叠。

      因此 Haskell 库 concat 函数使用 foldr

      附录:

      在使用带有 -O3 选项而不是 ghci 的 GHC v8.6.5 运行一些测试后,我认为优化器会干扰测量的先入为主的想法似乎是错误的。

      即使使用 -O3,对于 20,000 个问题大小,基于 foldr 的 concat 函数也比基于 foldl' 的 concat 函数快约 500 倍。

      因此,要么优化器无法证明可以更改/重用左操作数,要么根本不尝试。

      【讨论】:

      • 我的直觉是,像ghc 这样的真正编译器不够聪明,无法就地覆盖您提到的链表的尾部。这是一个很大的问题。你知道吗?
      • @luqui - 我当然不熟悉 GHC 内部结构。我只能从实验上看出你的直觉是正确的。目前,如果您坚持使用基于 foldl' 的 concat,即使在 O3 优化级别,GHC 也不够聪明,无法拯救您。为了完整起见,我在答案末尾的新附录中提到了这一点。
      猜你喜欢
      • 2014-12-02
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2014-08-26
      • 1970-01-01
      • 1970-01-01
      • 2020-11-26
      • 2014-03-16
      相关资源
      最近更新 更多