【问题标题】:Why does GHC make fix so confounding?为什么 GHC 使修复如此混乱?
【发布时间】:2012-10-11 13:14:31
【问题描述】:

查看GHC源代码可以看到fix的定义是:

fix :: (a -> a) -> a
fix f = let x = f x in x

在一个示例中,fix 是这样使用的:

fix (\f x -> let x' = x+1 in x:f x')

这基本上会产生一个从 1 增加到无穷大的数字序列。为了实现这一点,fix 必须将它接收到的函数返回到该函数,因为它是第一个参数。我不清楚上面列出的 fix 的定义是如何做到这一点的。

这个定义是我理解 fix 工作原理的方式:

fix :: (a -> a) -> a
fix f = f (fix f)

所以现在我有两个问题:

  1. 在第一个定义中,x 是如何表示 fix x 的?
  2. 使用第一个定义比使用第二个定义有什么优势吗?

【问题讨论】:

    标签: haskell recursion fixpoint-combinators


    【解决方案1】:

    通过应用等式推理,很容易看出这个定义是如何工作的。

    fix :: (a -> a) -> a
    fix f = let x = f x in x
    

    当我们尝试评估 fix f 时,x 的评估结果是什么?它被定义为f x,所以fix f = f x。但是这里的x 是什么?和以前一样,它是f x。所以你得到fix f = f x = f (f x)。以这种方式推理,您将获得f: fix f = f (f (f (f ...))) 的无限应用链。

    现在,将(\f x -> let x' = x+1 in x:f x') 替换为f 你得到

    fix (\f x -> let x' = x+1 in x:f x')
        = (\f x -> let x' = x+1 in x:f x') (f ...)
        = (\x -> let x' = x+1 in x:((f ...) x'))
        = (\x -> x:((f ...) x + 1))
        = (\x -> x:((\x -> let x' = x+1 in x:(f ...) x') x + 1))
        = (\x -> x:((\x -> x:(f ...) x + 1) x + 1))
        = (\x -> x:(x + 1):((f ...) x + 1))
        = ...
    

    编辑:关于您的第二个问题,@is7s 在 cmets 中指出,第一个定义更可取,因为它更有效。

    要找出原因,让我们看看fix1 (:1) !! 10^8 的核心:

    a_r1Ko :: Type.Integer    
    a_r1Ko = __integer 1
    
    main_x :: [Type.Integer]   
    main_x =
      : @ Type.Integer a_r1Ko main_x
    
    main3 :: Type.Integer
    main3 =
      !!_sub @ Type.Integer main_x 100000000
    

    如您所见,在转换后fix1 (1:) 基本上变成了main_x = 1 : main_x。请注意这个定义是如何指代自己的——这就是“打结”的意思。这种自引用在运行时表示为简单的指针间接:

    现在让我们看看fix2 (1:) !! 100000000

    main6 :: Type.Integer
    main6 = __integer 1
    
    main5
      :: [Type.Integer] -> [Type.Integer]
    main5 = : @ Type.Integer main6
    
    main4 :: [Type.Integer]
    main4 = fix2 @ [Type.Integer] main5
    
    main3 :: Type.Integer
    main3 =
      !!_sub @ Type.Integer main4 100000000
    

    这里实际上保留了fix2 应用程序:

    结果是第二个程序需要为列表的每个元素进行分配(但由于列表立即被消耗,程序仍然有效地在恒定空间中运行):

    $ ./Test2 +RTS -s
       2,400,047,200 bytes allocated in the heap
             133,012 bytes copied during GC
              27,040 bytes maximum residency (1 sample(s))
              17,688 bytes maximum slop
                   1 MB total memory in use (0 MB lost due to fragmentation)
     [...]
    

    将其与第一个程序的行为进行比较:

    $ ./Test1 +RTS -s          
              47,168 bytes allocated in the heap
               1,756 bytes copied during GC
              42,632 bytes maximum residency (1 sample(s))
              18,808 bytes maximum slop
                   1 MB total memory in use (0 MB lost due to fragmentation)
    [...]
    

    【讨论】:

    • 第一个定义更有效,因为它打结。例如,比较fix1 (1:) !! 1000000fix2 (1:) !! 1000000
    • @is7s 我认为使用 -O2 GHC 能够使第二个定义与第一个定义一样快。两个测试程序在我的机器上立即完成。不过,第二个定义在 GHCi 中要慢一些。
    • @MikhailGlushenkov 不,-O2 的第一个定义仍然快得多。尝试使用更大的数字,例如100000000。它在我的机器上快了大约 5 倍。
    【解决方案2】:

    x 在第一个定义中是如何表示修复 x 的?

    fix f = let x = f x in x
    

    让 Haskell 中的绑定是递归的

    首先,意识到 Haskell 允许递归 let 绑定。 Haskell 称之为“let”,其他一些语言称之为“letrec”。这对于函数定义来说感觉很正常。例如:

    ghci> let fac n = if n == 0 then 1 else n * fac (n - 1) in fac 5
    120
    

    但对于值定义来说,这似乎很奇怪。尽管如此,由于 Haskell 的非严格性,值可以递归定义。

    ghci> take 5 (let ones = 1 : ones in ones)
    [1,1,1,1,1]
    

    请参阅A gentle introduction to Haskell 第 3.3 和 3.4 节,了解有关 Haskell 惰性的更多详细信息。

    GHC 中的 Thunks

    在 GHC 中,一个尚未计算的表达式被包裹在一个“thunk”中:一个执行计算的承诺。只有在绝对必须时才会评估 Thunk。假设我们想要fix someFunction。根据fix的定义,就是

    let x = someFunction x in x
    

    现在,GHC 看到的是这样的。

    let x = MAKE A THUNK in x
    

    所以它会很高兴地为你发出一声闷响,然后继续前进,直到你要求知道 x 到底是什么。

    样本评估

    thunk 的表达式恰好引用了它自己。让我们以ones 为例,重写它以使用fix

    ghci> take 5 (let ones recur = 1 : recur in fix ones)
    [1,1,1,1,1]
    

    那么这个 thunk 会是什么样子?
    我们可以将ones 内联为匿名函数\recur -> 1 : recur 进行更清晰的演示。

    take 5 (fix (\recur -> 1 : recur))
    
    -- expand definition of fix
    take 5 (let x = (\recur -> 1 : recur) x in x)
    

    那么, x 是什么?好吧,即使我们不太确定x 是什么,我们仍然可以使用函数应用程序:

    take 5 (let x = 1 : x in x)
    

    嘿看,我们又回到了之前的定义。

    take 5 (let ones = 1 : ones in ones)
    

    因此,如果您相信自己了解那个的工作原理,那么您就会对fix 的工作原理有很好的了解。


    使用第一个定义比使用第二个定义有什么优势吗?

    是的。问题是第二个版本可能会导致空间泄漏,即使进行了优化。有关forever 定义的类似问题,请参阅GHC trac ticket #5205。这就是我提到 thunk 的原因:因为 let x = f x in x 只分配一个 thunk:x thunk。

    【讨论】:

      【解决方案3】:

      区别在于分享与复制。1

      fix1 f = x where x = f x    -- more visually apparent way to write the same thing
      
      fix2 f = f (fix2 f)
      

      如果我们将定义替换为自身,则两者都被简化为相同的无限应用程序链f (f (f (f (f ...))))。但是第一个定义使用显式命名;在 Haskell 中(与大多数其他语言一样)共享是通过命名事物的能力实现的:一个名称或多或少保证引用一个“实体”(此处为 x)。第二个定义不保证任何共享 - 调用 fix2 f 的结果被替换到表达式中,所以它也可以被替换为一个值。

      但是理论上,给定的编译器可能对此很聪明,并且在第二种情况下也可以使用共享。

      相关问题是“Y 组合器”。在没有 naming 构造(因此没有 self-reference)的无类型 lambda 演算中,Y 组合器通过安排定义要被复制,所以引用 self 的 copy 成为可能。但是在使用环境模型来允许语言中的命名实体的实现中,通过名称直接引用成为可能。

      要查看两个定义之间更显着的差异,请比较

      fibs1 = fix1 ( (0:) . (1:) . g ) where g (a:t@(b:_)) = (a+b):g t
      fibs2 = fix2 ( (0:) . (1:) . g ) where g (a:t@(b:_)) = (a+b):g t
      

      另见:

      (尤其是尝试找出上面最后一个链接中的最后两个定义)。


      1 根据定义工作,例如 fix (\g x -> let x2 = x+1 in x : g x2) 我们得到

      fix1 (\g x -> let x2 = x+1 in x : g x2)
       = fix1 (\g x -> x : g (x+1))
       = fix1 f where {f = \g x -> x : g (x+1)}
       = fix1 f where {f g x = x : g (x+1)}
       = x      where {x = f x ; f g x = x : g (x+1)}
       = g      where {g = f g ; f g x = x : g (x+1)}   -- both g in {g = f g} are the same g
       = g      where {g = \x -> x : g (x+1)}           -- and so, here as well
       = g      where {g x = x : g (x+1)}
      

      因此实际上创建了g 的适当递归定义。 (在上面,我们将....x.... where {x = ...} 写成let {x = ...} in ....x....,以便于阅读)。

      但第二个推导的关键区别在于将 value 替换为

      ,而不是 name
      fix2 (\g x -> x : g (x+1))
       = fix2 f             where {f g x = x : g (x+1)}
       = f (fix2 f)         where {f g x = x : g (x+1)}
       = (\x-> x : g (x+1)) where {g = fix2 f ; f g x = x : g (x+1)}
       = h                  where {h   x = x : g (x+1) ; g = fix2 f   ; f g x = x : g (x+1)}
      

      所以实际的通话将继续进行,例如

      take 3 $ fix2 (\g x -> x : g (x+1)) 10
       = take 3 (h 10)      where {h   x = x : g (x+1) ; g = fix2 f   ; f g x = x : g (x+1)}
       = take 3 (x:g (x+1)) where {x = 10 ;              g = fix2 f   ; f g x = x : g (x+1)}
       = x:take 2 (g x2)    where {x2 = x+1 ; x = 10 ;   g = fix2 f   ; f g x = x : g (x+1)}
       = x:take 2 (g x2)    where {x2 = x+1 ; x = 10 ; g = f (fix2 f) ; f g x = x : g (x+1)}
       = x:take 2 (x2 : g2 (x2+1))   where {             g2 = fix2 f  ;
                                   x2 = x+1 ; x = 10 ;                  f g x = x : g (x+1)}
       = ......
      

      我们看到这里建立了一个新的绑定(对于g2),而不是像fix1定义那样重用之前的绑定(对于g)。

      【讨论】:

        【解决方案4】:

        我可能有一些来自inlining 优化的简化解释。如果我们有

        fix :: (a -> a) -> a
        fix f = f (fix f)
        

        那么fix 是一个递归函数,这意味着它不能在使用它的地方内联(如果给定,INLINE 杂注将被忽略)。

        然而

        fix' f = let x = f x in x
        

        不是一个递归函数——它从不调用自己。只有 x inside 是递归的。所以打电话的时候

        fix' (\r x -> let x' = x+1 in x:r x')
        

        编译器可以将其内联到

        (\f -> (let y = f y in y)) (\r x -> let x' = x+1 in x:r x')
        

        然后继续简化,例如

        let y = (\r x -> let x' = x+1 in x:r x') y in y 
        let y = (\  x -> let x' = x+1 in x:y x')   in y 
        

        这就像函数是使用标准递归表示法定义的,没有fix

            y       x =  let x' = x+1 in x:y x'   
        

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 2011-04-28
          • 2015-06-09
          • 1970-01-01
          • 2022-11-25
          • 2016-05-13
          • 1970-01-01
          • 2018-12-21
          • 1970-01-01
          相关资源
          最近更新 更多