【问题标题】:Why does foldr use a helper function?为什么 foldr 使用辅助函数?
【发布时间】:2025-12-27 18:10:06
【问题描述】:

在向 Haskell 新手解释 foldr 时,规范定义是

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

但在 GHC.Base 中,foldr 被定义为

foldr k z = go
          where
            go []     = z
            go (y:ys) = y `k` go ys

似乎这个定义是对速度的优化,但我不明白为什么使用辅助函数go 会使其更快。源 cmets (see here) 提到了内联,但我也看不出这个定义将如何改进内联。

【问题讨论】:

  • 一个细节还没有提到:ghc 仅在函数完全应用时内联一个函数,语法,在它的左侧。如果您习惯于考虑柯里化和创建漂亮的无点式代码,这将是非常奇怪和丑陋的。这就是为什么您有时会在优化代码中看到 = 右侧的愚蠢 lambda。

标签: haskell fold


【解决方案1】:

正如 cmets 所说:

-- Inline only in the final stage, after the foldr/cons rule has had a chance
-- Also note that we inline it when it has *two* parameters, which are the
-- ones we are keen about specialising!

特别要注意“当它有 两个 参数时我们内联它,这是我们热衷于专门化的参数!”

这就是说,当foldr 被内联时,它只会内联fz 的特定选择,而不是折叠列表的选择。我不是专家,但它似乎可以在以下情况下内联它

map (foldr (+) 0) some_list

以便内联发生在这一行中,而不是在应用map 之后。这使得它可以在更多情况下更容易地进行优化。辅助函数所做的只是屏蔽第三个参数,以便 {-# INLINE #-} 可以做它的事情。

【讨论】:

    【解决方案2】:

    GHC 不能内联递归函数,所以

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

    不能内联。但是

    foldr k z = go
          where
            go []     = z
            go (y:ys) = y `k` go ys
    

    不是递归函数。它是一个具有局部递归定义的非递归函数!

    这意味着,正如@bheklilr 所写,在map (foldr (+) 0) 中,foldr 可以内联,因此fz 在新的go 中被(+)0 替换,并且很棒可能会发生一些事情,例如拆箱中间值。

    【讨论】:

      【解决方案3】:

      我可以补充一些关于 GHC 优化系统的重要细节。

      foldr 的幼稚定义传递了一个函数。调用函数存在固有的开销——尤其是当函数在编译时未知时。如果函数的定义在编译时是已知的,那么能够内联函数的定义会非常好。

      在 GHC 中可以使用一些技巧来执行该内联 - 这是其中的一个示例。首先,foldr 需要内联(稍后我会解释原因)。 foldr 的幼稚实现是递归的,因此不能内联。因此,工作人员/包装器转换应用于定义。工人是递归的,但包装器不是。这允许foldr 被内联,尽管递归了列表的结构。

      foldr 被内联时,它也会创建其所有本地绑定的副本。它或多或少是直接的文本内联(以一些重命名为模,并在脱糖传递之后发生)。这就是事情变得有趣的地方。 go 是一个本地绑定,优化器可以查看它的内部。它注意到它在本地范围内调用了一个函数,它命名为k。 GHC 通常会完全删除 k 变量,并将其替换为表达式 k 简化为。然后,如果函数应用程序适合内联,则此时可以内联 - 完全消除调用一等函数的开销。

      让我们看一个简单而具体的例子。该程序将回显一行输入,并删除所有尾随 'x' 字符:

      dropR :: Char -> String -> String
      dropR x r = if x == 'x' && null r then "" else x : r
      
      main :: IO ()
      main = do
          s <- getLine
          putStrLn $ foldr dropR "" s
      

      首先,优化器将内联foldr 的定义并简化,得到如下所示的代码:

      main :: IO ()
      main = do
          s <- getLine
          -- I'm changing the where clause to a let expression for the sake of readability
          putStrLn $ let { go [] = ""; go (x:xs) = dropR x (go xs) } in go s
      

      这就是 worker-wrapper 转换允许的事情。我将跳过其余步骤,但显然 GHC 现在可以内联 dropR 的定义,从而消除函数调用开销。这就是性能大获全胜的来源。

      【讨论】:

        【解决方案4】:

        其他答案中没有提到的一个重要细节是 GHC,给定一个函数定义,例如

        f x y z w q = ...
        

        在应用所有参数 xyzwq 之前,无法内联 f。这意味着使用 worker/wrapper 转换来公开最小的函数参数集通常是有利的,这些函数参数必须在内联发生之前应用。

        【讨论】: