【问题标题】:How does Haskell's laziness work?Haskell 的懒惰是如何工作的?
【发布时间】:2015-04-02 21:19:25
【问题描述】:

考虑这个将列表中所有元素加倍的函数:

doubleMe [] = []
doubleMe (x:xs) = (2*x):(doubleMe xs)

然后考虑表达式

doubleMe (doubleMe [a,b,c])

很明显,在运行时,这首先扩展为:

doubleMe ( (2*a):(doubleMe [b,c]) )

(这很明显,因为据我所知不存在其他可能性)。

但我的问题是:为什么现在会扩展到

2*(2*a) : doubleMe( doubleMe [b,c] )

而不是

doubleMe( (2*a):( (2*b) : doubleMe [c] ) )

?

直觉上,我知道答案:因为 Haskell 很懒。但是谁能给我一个更准确的答案?

列表是否有什么特别之处会导致这种情况,或者这个想法是否比列表更普遍?

【问题讨论】:

  • 第二次展开会出现类型错误,因为您将数字和列表相乘。
  • 不完全是一个精确的答案,但直觉上对doubleMe 的最外部调用将首先扩展,而不是内部调用。
  • @DavidYoung 谢谢,错字已修正。
  • 我想你想从可简化表达式(redex)的角度来看待这个问题。 Here is a short introduction。基本上,redex 是可以使用函数重写的任何东西(如doubleMe (x:xs) -> 2*x : doubleMe xs)——这对于列表来说并不特殊。 “惰性”评估恰恰意味着您首先评估最外层(有时称为“最左侧”)的 redex,这似乎是您直觉上认为的“惰性评估”。
  • 非常感谢你写出这样一个格式良好的问题。我什至不了解 Haskell,发现这(以及很好的答案)是一本有趣的书。

标签: haskell


【解决方案1】:

doubleMe (doubleMe [a,b,c]) 不会扩展为 doubleMe ( (2*a):(doubleMe [b,c]) )。它扩展为:

case doubleMe [a,b,c] of
  [] -> []
  (x:xs) -> (2*x):(doubleMe xs)

即先展开外层函数调用。这是惰性语言和严格语言之间的主要区别:在扩展函数调用时,您不会首先评估参数 - 而是将函数调用替换为其主体并暂时保留参数。

现在doubleMe需要扩展,因为模式匹配需要知道其操作数的结构才能对其进行评估,所以我们得到:

case (2*a):(doubleMe [b,c]) of
  [] -> []
  (x:xs) -> (2*x):(doubleMe xs)

现在模式匹配可以替换为第二个分支的主体,因为我们现在知道第二个分支是匹配的。所以我们用(2*a)代替x,用doubleMe [b, c]代替xs,得到:

(2*(2*a)):(doubleMe (doubleMe [b,c]))

这就是我们得出这个结果的方式。

【讨论】:

    【解决方案2】:

    您“显而易见”的第一步实际上并不那么明显。事实上发生的事情是这样的:

    doubleMe (...)
    doubleMe ( { [] | (_:_) }? )
    doubleMe ( doubleMe (...)! )
    

    只有在那个时候它才真正“进入”内部函数。所以它继续

    doubleMe ( doubleMe (...) )
    doubleMe ( doubleMe( { [] | (_:_) }? ) )
    doubleMe ( doubleMe( a:_ ! ) )
    doubleMe ( (2*a) : doubleMe(_) )
    doubleMe ( (2*a):_ ! )
    

    现在,外部doubleMe 函数有了[] | (_:_) 问题的“答案”,这是对内部函数中的任何内容进行评估的唯一原因。

    其实,下一步也不一定是你想的那样:这取决于你如何评估外部结果!例如,如果整个表达式是tail $ doubleMe ( doubleMe [a,b,c] ),那么它实际上会扩展得更像

    tail( { [] | (_:_) }? )
    tail( doubleMe(...)! )
    tail( doubleMe ( { [] | (_:_) }? ) )
    ...
    tail( doubleMe ( doubleMe( a:_ ! ) ) )
    tail( doubleMe ( _:_ ) )
    tail( _ : doubleMe ( _ ) )
    doubleMe ( ... )
    

    即实际上它永远不会真正到达2*a

    【讨论】:

      【解决方案3】:

      其他人已经回答了一般性问题。让我在这个特定点上添加一些内容:

      导致这种情况的列表是否有什么特别之处,或者是 比仅列出的更通用的想法?

      不,列表并不特殊。 Haskell 中的每个 data 类型都有一个惰性语义。让我们尝试一个简单的例子,使用整数的pair类型(Int, Int)

      let pair :: (Int,Int)
          pair = (1, fst pair)
       in snd pair
      

      上面,fst,snd 是对投影,返回一对的第一个/第二个分量。另请注意,pair 是递归定义的对。是的,在 Haskell 中,您可以递归地定义所有内容,而不仅仅是函数。

      在惰性语义下,上面的表达式大致是这样计算的:

      snd pair
      = -- definition of pair
      snd (1, fst pair)
      = -- application of snd
      fst pair
      = -- definition of pair
      fst (1, fst pair)
      = -- application of fst
      1
      

      相比之下,使用渴望语义,我们会这样评估它:

      snd pair
      = -- definition of pair
      snd (1, fst pair)
      = -- must evaluate arguments before application, expand pair again
      snd (1, fst (1, fst pair))
      = -- must evaluate arguments
      snd (1, fst (1, fst (1, fst pair)))
      = -- must evaluate arguments
      ...
      

      在急切求值中,我们坚持在应用fst/snd之前对参数求值,得到一个无限循环的程序。在某些语言中,这会触发“堆栈溢出”错误。

      在惰性求值中,我们很快就会应用函数,即使参数没有被完全求值。这使得snd (1, infiniteLoop) 立即返回1

      因此,惰性求值并不特定于列表。 Haskell 中的任何东西都是惰性的:树、函数、元组、记录、用户定义的 data 类型等。

      (Nitpick:如果程序员真的需要它们,可以定义具有严格/急切评估组件的类型。这可以使用严格注释或使用未装箱类型等扩展来完成。虽然有时这些有其用途,它们在 Haskell 程序中并不常见。)

      【讨论】:

      • 我想说,在大多数语言中,您甚至无法声明这样的一对 :-)
      • @Bergi 事实上,大多数语言都遵循急切/严格的语义,这样的一对只会导致无限循环。在那种情况下,如果它实际上没有用,为什么还要添加定义它的能力?
      【解决方案4】:

      现在是提取等式推理的好时机,这意味着我们可以用一个函数代替它的定义(对事物进行模重命名以不产生冲突)。不过,为了简洁起见,我将把 doubleMe 重命名为 d

      d [] = []                           -- Rule 1
      d (x:xs) = (2*x) : d xs             -- Rule 2
      
      d [1, 2, 3] = d (1:2:3:[])
                  = (2*1) : d (2:3:[])    -- Rule 2
                  = 2 : d (2:3:[])        -- Reduce
                  = 2 : (2*2) : d (3:[])  -- Rule 2
                  = 2 : 4 : d (3:[])      -- Reduce
                  = 2 : 4 : (2*3) : d []  -- Rule 2
                  = 2 : 4 : 6 : d []      -- Reduce
                  = 2 : 4 : 6 : []        -- Rule 1
                  = [2, 4, 6]
      

      所以现在如果我们用 2 层 doubleMe/d 执行此操作:

      d (d [1, 2, 3]) = d (d (1:2:3:[]))
                      = d ((2*1) : d (2:3:[]))    -- Rule 2 (inner)
                      = d (2 : d (2:3:[]))        -- Reduce
                      = (2*2) : d (d (2:3:[]))    -- Rule 2 (outer)
                      = 4 : d (d (2:3:[]))        -- Reduce
                      = 4 : d ((2*2) : d (3:[]))  -- Rule 2 (inner)
                      = 4 : d (4 : d (3:[]))      -- Reduce
                      = 4 : 8 : d (d (3:[]))      -- Rule 2 (outer) / Reduce
                      = 4 : 8 : d (6 : d [])      -- Rule 2 (inner) / Reduce
                      = 4 : 8 : 12 : d (d [])     -- Rule 2 (outer) / Reduce
                      = 4 : 8 : 12 : d []         -- Rule 1 (inner)
                      = 4 : 8 : 12 : []           -- Rule 1 (outer)
                      = [4, 8, 12]
      

      或者,您可以选择在不同的时间点减少,从而导致

      d (d [1, 2, 3]) = d (d (1:2:3:[]))
                      = d ((2*1) : d (2:3:[]))
                      = (2*(2*1)) : d (d (2:3:[]))
                      = -- Rest of the steps left as an exercise for the reader
                      = (2*(2*1)) : (2*(2*2)) : (2*(2*3)) : []
                      = (2*2) : (2*4) : (2*6) : []
                      = 4 : 6 : 12 : []
                      = [4, 6, 12]
      

      这是此计算的两种可能的扩展,但它并不特定于列表。您可以将其应用于树类型:

      data Tree a = Leaf a | Node a (Tree a) (Tree a)
      

      如果您考虑列表定义,LeafNode 上的模式匹配将类似于分别在 []: 上的匹配

      data [] a = [] | a : [a]
      

      我之所以说这是两种可能的扩展,是因为它的扩展顺序取决于特定的运行时和您正在使用的编译器的优化。如果它看到可以使您的程序执行得更快的优化,它可以选择该优化。这就是为什么懒惰通常是一个好处,你不必考虑事情发生的顺序,因为编译器会为你考虑。这在没有纯度的语言中是不可能的,例如 C#/Java/Python/等。您不能重新排列计算,因为这些计算可能具有取决于顺序的副作用。但是在执行纯计算时,您不会产生副作用,因此编译器可以更轻松地优化您的代码。

      【讨论】:

      • 嗯,这个答案似乎并不关注懒惰。 OP 已经知道他可以选择要扩展的术语(并且可能还知道为什么/该顺序对结果没有影响)。现在,什么是懒惰?它不是掷骰子,也不是编译器找到优化。这是一个特定的策略。
      【解决方案5】:
      doubleMe [] = []
      doubleMe (x:xs) = (2*x):(doubleMe xs)
      
      doubleMe (doubleMe [a,b,c])
      

      我认为不同的人会以不同的方式扩展这些内容。我并不是说它们会产生不同的结果或任何东西,只是在正确执行此操作的人中并没有真正的标准符号。以下是我的做法:

      -- Let's manually compute the result of *forcing* the following expression.
      -- ("Forcing" = demanding that the expression be evaluated only just enough
      -- to pattern match on its data constructor.)
      doubleMe (doubleMe [a,b,c])
      
          -- The argument to the outer `doubleMe` is not headed by a constructor,
          -- so we must force the inner application of `doubleMe`.  To do that, 
          -- first force its argument to make it explicitly headed by a
          -- constructor.
          = doubleMe (doubleMe (a:[b,c]))
      
          -- Now that the argument has been forced we can tell which of the two
          -- `doubleMe` equations applies to it: the second one.  So we use that
          -- to rewrite it.
          = doubleMe (2*a : doubleMe [b,c])
      
          -- Since the argument to the outer `doubleMe` in the previous expression
          -- is headed by the list constructor `:`, we're done with forcing it.
          -- Now we use the second `doubleMe` equation to rewrite the outer
          -- function application. 
          = 2*2*a : doubleMe (doubleMe [b, c])
      
          -- And now we've arrived at an expression whose outermost operator
          -- is a data constructor (`:`).  This means that we've successfully 
          -- forced the expression, and can stop here.  There wouldn't be any
          -- further evaluation unless some consumer tried to match either of 
          -- the two subexpressions of this result. 
      

      这与 sepp2k 和 leftaroundabout 的答案相同,只是他们写得很有趣。 sepp2k 的答案有一个 case 表达式似乎突然出现 - doubleMe 的多方程定义被隐式重写为单个 case 表达式。 leftaroundabout 的答案中有一个 { [] | (_:_) }? 的东西,这显然是“我必须强制论证直到它看起来像 [](_:_)”的符号。

      bhelkir 的回答与我的类似,但它也递归地强制结果的所有子表达式,除非您有一个需要它的消费者,否则不会发生这种情况。

      所以没有不尊重任何人,但我更喜欢我的。 :-P

      【讨论】:

        【解决方案6】:

        写 \lambda y.m 表示 doubleMe 的抽象版本,并写 t 表示列表 [a,b,c]。那么你要减少的词是

        \y.m (\y.m t)
        

        换句话说,有两个redex。 Haskell 更喜欢首先触发最外层的 redex,因为它是一种正常的有序语言。然而,这并不完全正确。 doubleMe 不是真正的 \y.m,只有当它的“参数”具有正确的形状(列表的形状)时才真正有一个 redex。由于这还不是 redex,并且 (\y.m) 内部没有 redex,我们移动到应用程序的右侧。因为 Haskell 也更愿意先评估最左边的 redexes。现在,t 确实具有列表的形状,因此 redex (\y.m t) 触发。

        \y.m (a : (\y.m t'))
        

        然后我们回到顶部,重新做整个事情。除了这一次,最外面的词有一个 redex。

        【讨论】:

          【解决方案7】:

          这样做是因为列表的定义方式和惰性。当您请求列表的头部时,它会评估您请求的第一个元素并保存其余元素以供以后使用。所有列表处理操作都建立在 head:rest 概念之上,因此永远不会出现中间结果。

          【讨论】:

            猜你喜欢
            • 1970-01-01
            • 2015-09-06
            • 1970-01-01
            • 2011-02-16
            • 1970-01-01
            • 2015-09-06
            • 1970-01-01
            • 2016-12-14
            • 2014-03-23
            相关资源
            最近更新 更多