3

我正在阅读Learn You a Haskell for Great Good 的介绍,我无法理解以下对“懒惰”一词的解释。

假设您有一个不可变的数字列表xs = [1,2,3,4,5,6,7,8]和一个doubleMe将每个元素乘以 2 然后返回一个新列表的函数。如果我们想用命令式语言将我们的列表乘以 8 并且做了doubleMe(doubleMe(doubleMe(xs))),它可能会通过列表一次并制作一个副本然后返回它。然后它会再通过列表两次并返回结果。在一种懒惰的语言中,调用doubleMe一个列表而不强制它显示结果最终会在程序中告诉你“是的,我稍后再做!”。但是一旦你想看到结果,第一个doubleMe告诉第二个它想要的结果,现在!第二个说给第三个,第三个不情愿地给了一个加倍的 1,这是一个 2。第二个收到了这个,然后把 4 还给了第一个。第一个看到并告诉你第一个元素是 8。所以它只通过列表,并且只有在你真正需要它时才通过。

该功能背后的魔力是doubleMe什么?命令式和函数式版本之间有什么区别?

4

3 回答 3

4

如果您习惯于严格的编程,那么绕着懒惰考虑的麻烦在于,当严格的评估能够产生结果时,懒惰永远不会改变结果。1

因此,查看您已经理解的示例并尝试查看惰性评估如何改变图片可能会令人困惑,因为答案大多是“它没有”。惰性评估与严格评估有时会在性能特征上产生很大差异(在任一方向上)。更重要的是,有些代码可以在惰性求值下执行,但在严格求值下会失败。

所以惰性求值基本上不会改变你已经知道如何编写的任何代码,但可以让你编写严格求值无法写出的代码!

所以print doubleMe(doubleMe(doubleMe([1, 2, 3, 4, 5])))在严格或惰性评估下并不意味着任何不同。我们可以讨论它是如何实现的细节,并发现各种加倍将以不同的顺序发生,也许这足以让一些人获得直觉。但我不确定这是介绍这个概念的最佳方式。

懒惰真正开始看起来不同的地方在于,您可以执行以下操作:

  1. 编写一个程序来打印第 N 个素数,方法是生成所有素数的列表,然后打印列表的第 N 个元素。使用惰性求值,仅在请求时生成素数,因此不会永远运行。一个严格的程序必须有一个函数参数generate_primes来控制生成多少。

  2. print编写一个程序,通过将函数映射到所有素数的列表,永远打印素数(直到按下 control-C) 。通过惰性评估,它们将在生成时被打印出来;一个严格的程序会在打印任何素数之前尝试生成所有素数。请注意,惰性程序可以使用generate_primes(1) 中的相同函数,而严格程序的generate_n_primes函数在这里是无用的(除非我们希望每次打印第 N + 1个素数时再次生成直到第 N 个的所有素数)。

  3. 一个稍微不那么愚蠢的例子是,有一个程序可以将文档转换为 HTML、PDF、Latex 和许多其他格式,并将它们全部返回到一个大字典中,以允许调用者选择它想要的一种。在严格的评估下,这将完成产生所有表示的工作,即使调用者只想要一个。在惰性求值下,调用者可以选择它想要的任何表示,并且程序只会实际执行实际需要的转换。

  4. 定义您自己的控制结构。在惰性语言中,相当于 if/then/else 的东西可以在用户代码中定义为普通函数。该函数接受一个布尔值(条件),如果布尔为真则返回一个值,如果条件为假则返回一个值。只会评估“then”或“else”值,因此即使您正在测试的原因是“then”分支会导致错误,如果条件为假,则会导致程序终止!您也可以定义其他类似控制结构的函数。

等等。

其工作方式是,每当对函数调用进行求值时,而不是离开并完成所有工作然后返回结果,而是立即返回。它还没有结果,所以它返回一个占位符,其中包含足够的信息来完成工作(如果确实需要的话)。

这个占位符可以传递给其他函数,存储在数据结构等中,就像它是真实的结果一样。如果稍后某些代码需要根据占位符所在的结果“做出决定”(将什么字符打印到屏幕上,在条件中采用哪个分支,诸如此类),则执行代码“多一点”来提出足够的实际数据来做出决定。


1除非所涉及的代码有副作用,这就是为什么命令式编程语言从不到处都有隐式惰性求值的原因。最多他们手动声明了惰性,随之而来的警告是程序员有责任确保何时/是否运行他们声明为惰性的代码并不重要。

于 2013-04-21T02:43:46.910 回答
4

好吧,让我们来说明一下。首先,这是一个简单的定义doubleMe

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

现在,让我们考虑一下严格/急切的语言(不是 Haskell)如何评估doubleMe (doubleMe (doubleMe [1, 2, 3])). 这种语言的规则是:评估函数调用,完整地评估所有参数,然后将它们传递给函数。所以我们得到这个:

doubleMe (doubleMe (doubleMe [1, 2, 3]))

  -- Expand the list literal into its structure
  == doubleMe (doubleMe (doubleMe (1 : 2 : 3 : [])))

  -- Eager evaluation requires that we start at the innermost use of doubleMe
  -- and work there until we produce the whole list.
  == doubleMe (doubleMe (2 : doubleMe (2 : 3 : [])))
  == doubleMe (doubleMe (2 : 4 : doubleMe (3 : [])))
  == doubleMe (doubleMe (2 : 4 : 6 : doubleMe []))
  == doubleMe (doubleMe (2 : 4 : 6 : []))

  -- Only now we can move on to the middle use of doubleMe:
  == doubleMe (4 : doubleMe (4 : 6 : []))
  == doubleMe (4 : 8 : doubleMe (6 : []))
  == doubleMe (4 : 8 : 12 : doubleMe [])
  == doubleMe (4 : 8 : 12 : [])
  == 8 : doubleMe (8 : 12 : [])
  == 8 : 16 : doubleMe (12 : [])
  == 8 : 16 : 24 : doubleMe []
  == 8 : 16 : 24 : []
  == [8, 16, 24]

在 Haskell 中,规则更像这样(但不完全是这样):

  1. 要评估函数应用程序,请在评估其参数之前应用该函数。
  2. 如果外部函数有多种情况(就像我们的doubleMe定义那样),那么我们只评估它的参数来确定使用哪种情况。

所以我们得到类似的东西:

doubleMe (doubleMe (doubleMe [1, 2, 3]))
  -- Here we only "pull out" the 1 from the list, because it's all we need to
  -- pick which case we want for doubleMe.
  == doubleMe (doubleMe (doubleMe (1 : [2, 3])))
  == doubleMe (doubleMe (1*2 : doubleMe [2, 3]))

  -- Now instead of continuing with the inner doubleMe, we move on immediately
  -- to the middle one:
  == doubleMe ((1*2)*2 : doubleMe (doubleMe [2, 3]))

  -- And now, since we know which case to use for the outer doubleMe, we expand 
  -- that one:
  == (1*2)*2)*2 : doubleMe (doubleMe (doubleMe [2, 3]))

而在 Haskell 中,除非有另一个调用者需要列表头部或尾部的值,否则评估将停止。(请注意,我什至没有进行乘法运算。)例如,head是返回列表第一个元素的函数:

head (x:xs) = x

假设我们正在评估head (doubleMe (doubleMe (doubleMe [1, 2, 3]))). 事情是这样的:

head (doubleMe (doubleMe (doubleMe [1, 2, 3])))
  -- repeat the steps from above for the doubleMe part
  head (((1*2)*2)*2 : doubleMe (doubleMe (doubleMe [2, 3]))

  -- By the definition of head:
  ((1*2)*2)*2

因此doubleMe (doubleMe (doubleMe [2, 3]))在这种情况下该部分被丢弃,因为head不需要它来产生结果。如果 Haskell 不是懒惰的,它会计算整个[8, 12, 24]列表,然后将其8放在前面。

GHC 比这更聪明。我们可以使用map函数来写doubleMe

doubleMe = map (*2)

GHC,当您使用-O优化编译程序的选项时,将以下规则纳入其中:

map f (map g xs) = map (f . g) xs

这意味着如果它看到 的嵌套使用map,它可以将它们减少到遍历列表的一次。使用它:

head (doubleMe (doubleMe (doubleMe [1, 2, 3])))
  == head (map (*2) (map (*2) (map (*2) [1, 2, 3])))
  == head (map ((*2) . (*2) . (*2)) [1, 2, 3])
  == head (map (\x -> ((x*2)*2)*2) [1, 2, 3])
  == head (map (\x -> ((x*2)*2)*2) (1 : [2, 3]))
  == head (((1*2)*2)*2 : map (\x -> ((x*2)*2)*2) [2, 3])
  == ((1*2)*2)*2

编辑:在这个问题的答案中,一通与三通的主题显然有很多混淆。我会把我的很多东西都扔进去。

简单的惰性评估(如我的第二个示例评估所示)不会改变通过次数。如果我们评估类似的东西print (doubleMe (doubleMe (doubleMe [1, 2, 3]))),惰性和急切的评估将做相同数量的工作,但顺序不同。让我们编写嵌套表达式的值并排列列表元素,如下所示:

                      doubleMe [1, 2, 3] = [2,  4,  6]
           doubleMe (doubleMe [1, 2, 3]) = [4,  8, 12]
doubleMe (doubleMe (doubleMe [1, 2, 3])) = [8, 16, 24]

现在,如果我们执行以下操作print (doubleMe (doubleMe (doubleMe [1, 2, 3])))

  1. 急切的评估将逐行攻击这一点。首先它将计算整个 list [2, 4, 6],然后是 list [4, 8, 12],然后是 list [8, 16, 24],最后它会打印最后一个 list 。
  2. 惰性求值逐列攻击。首先它将计算2第一个列表中的 ,然后4是第二个列表中的 ,然后是8第一个列表中的 ,并打印8; 然后计算4第一个列表中的8,第二个列表中的 ,依此类推。

如果您正在打印列表,因为这需要计算所有元素,所以在任何一种情况下(或多或少)相同数量的工作和内存。然而,在这个head (doubleMe (doubleMe (doubleMe [1, 2, 3])))例子中,惰性求值做的工作较少。

最后一个“融合”示例(使用 的示例map f . map g == map (f . g))比其他两个示例做的工作更少,因为它确实是一次通过。但这不是因为懒惰,而是因为纯度允许更积极的优化。

于 2013-04-21T18:55:48.463 回答
1

评价策略不同。

在命令式世界中,仅在对参数进行完全评估后才评估函数的主体。然后考虑你的例子。

-- double1 = double2 = double3 = double  
double3 ( double2 ( double1 [1,2,3] ) )  

最初要评估double3,我们必须评估double2
要评估double2,我们必须评估double1
要评估double1,我们必须评估[1,2,3]
但是[1,2,3]不需要评估,那么我们可以开始double1 的求值
然后 double1 ([1,2,3]) 求值为 [2,4,6] 并传递给 double2
然后 double2 ([2,4,6]) 求值为 [4,8,12 ] 并传递给 double3
最后对 double3 的求值可以启动并返回 [8,16,24]

double3 ( double2 ( double1 [1,2,3] ) )  
double3 ( double2 [2,4,6])  
double3 [4,8,12]  
[8,16,24]

对于像 haskell 这样的惰性语言来评估函数,我们不需要等待其参数的完全评估。然后,如果在评估参数期间已经产生了一大块数据,则可以开始评估函数体。

举个例子。

double3 ( double2 ( double1 [1,2,3] ) )
double3 ( double2 [2] ( double1 [2,3] ) )
double3 [4]( double2 [4] ( double1 [3] ) )
[8] double3 [8] ( double2 [6] )
[8,16] double3 [12]
[8,16,24]
于 2013-04-20T23:57:47.387 回答