Haskell的懒惰如何运作?

时间:2015-02-03 15:43:36

标签: haskell

考虑这个功能,使列表中的所有元素加倍:

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很懒惰。但有人可以给我一个更准确的答案吗?

是否有一些特殊的关于导致这种情况的列表,或者这个想法比那些只是列表更通用?

7 个答案:

答案 0 :(得分:44)

doubleMe (doubleMe [a,b,c])未展开至doubleMe ( (2*a):(doubleMe [b,c]) )。它扩展到:

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

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

现在需要扩展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]))

这就是我们如何得出结果。

答案 1 :(得分:6)

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

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

答案 2 :(得分:5)

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

  

是否存在导致此问题的列表的特殊情况,或者是   想法比仅仅列出更一般吗?

不,列表并不特别。 Haskell中的每个data类型都具有惰性语义。让我们尝试使用整数(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程序中并不常见。)

答案 3 :(得分:3)

这是推出等式推理的好时机,这意味着我们可以用一个函数替换它的定义(模数重命名的东西没有碰撞)。不过,为了简洁起见,我将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 /等。您无法重新排列计算,因为这些计算可能会产生依赖于顺序的副作用。但是,在执行纯计算时,您不会产生副作用,因此编译器可以更轻松地优化代码。

答案 4 :(得分:3)

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

答案 5 :(得分:2)

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

\y.m (\y.m t)

换句话说,有两个redex。 Haskell首先尝试触发最外层的redex,因为它是一种正常的order-ish语言。但是,这不是真的。 doubleMe不是真的\ y.m,只有当它的#34;参数"才真正有一个重新索引。具有正确的形状(列表的形状)。由于这还不是redex,并且(\ y.m)中没有重新索引,我们移动到应用程序的右侧。因为Haskell也希望首先评估最左边的重新索引。现在,t确实具有列表的形状,因此redex(\ y.m t)会触发。

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

然后我们回到顶部,再做一遍。除此之外,最外面的术语有一个redex。

答案 6 :(得分:1)

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