懒散的例子来自于了解你是一本好书的Haskell

时间:2013-04-20 23:01:57

标签: haskell

我正在阅读the introduction of 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功能背后的魔力是什么?命令式和功能性版本之间有什么区别?

3 个答案:

答案 0 :(得分:4)

如果你习惯于严格编程,那么将你的头缠绕在懒惰上的麻烦就是,当严格的评估能够产生结果时,懒惰永远不会改变结果 1 < / SUP>

因此,看看你已经理解的例子,并试图看到懒惰的评价如何改变图片,可能会令人困惑,因为答案主要是“它没有”。懒惰vs严格评估 有时会在性能特征方面(在任何一个方向上)产生很大的差异。更重要的是,有些代码可以在延迟评估下执行,在严格评估下会失败。

所以懒惰的评估基本上不会改变您已经知道如何编写的任何代码,但是使您能够编写您无法通过严格评估的代码!

所以print doubleMe(doubleMe(doubleMe([1, 2, 3, 4, 5])))在严格或懒惰的评估中并不意味着什么不同。我们可以详细说明它是如何实现的,并发现各种倍增将以不同的顺序发生,也许这足以让一些人获得直觉。但我不确定这是引入这个概念的最佳方式。

懒惰真正开始变得与众不同的是,你可以做以下事情:

  1. 编写程序以打印第N个素数,方法是生成所有素数的列表,然后打印列表的第N个元素。使用延迟评估时,素数仅在请求时生成,因此不会运行。严格的程序必须有generate_primes函数的参数来控制生成的数量。

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

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

  4. 定义您自己的控制结构。在惰性语言中,可以在用户代码中将等同于if / then / else的内容定义为普通函数。该函数采用布尔值(条件),如果bool为真则返回一个值,如果条件为假则返回值。只会评估“then”或“else”值,所以即使您测试的原因是“then”分支会导致错误,如果条件为false则会导致程序失败!您也可以定义其他类似控件结构的函数。

  5. 等等。

    这种方法的工作方式是,无论何时评估函数调用,而不是离开并完成所有工作然后返回结果,它将立即返回 。它还没有结果,因此它会返回一个占位符,其中包含足够的信息,以便在实际需要时进行工作。

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


    1 除非涉及的代码有副作用,这就是命令式编程语言从未在任何地方进行隐式惰性求值的原因。他们最多只是手动声明了懒惰,但需要注意的是,程序员有责任确保何时/是否运行它们声明为懒惰的代码无关紧要。

答案 1 :(得分:3)

好吧,让我们来说明一下。首先,这是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定义那样),那么我们只评估它的论据,以确定要使用的案例。
  3. 所以我们得到类似的东西:

    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. 急切的评估会逐行攻击。首先,它将计算整个列表[2, 4, 6],然后是列表[4, 8, 12],然后是列表[8, 16, 24],最后它将打印最后一个列表。
    2. 懒惰评估逐列进行攻击。首先,它将计算第一个列表中的2,然后计算第二个列表中的4,然后计算第一个列表中的8,并打印8;然后计算第一个列表中的4,第二个列表中的8,依此类推。
    3. 如果您正在打印列表,因为这需要计算所有元素,在任何一种情况下,它(或多或少)都是相同数量的工作和内存。但是,在head (doubleMe (doubleMe (doubleMe [1, 2, 3])))示例中,延迟评估的工作量较少。

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

答案 2 :(得分: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]