我正在阅读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
功能背后的魔力是什么?命令式和功能性版本之间有什么区别?
答案 0 :(得分:4)
如果你习惯于严格编程,那么将你的头缠绕在懒惰上的麻烦就是,当严格的评估能够产生结果时,懒惰永远不会改变结果。 1 < / SUP>
因此,看看你已经理解的例子,并试图看到懒惰的评价如何改变图片,可能会令人困惑,因为答案主要是“它没有”。懒惰vs严格评估 有时会在性能特征方面(在任何一个方向上)产生很大的差异。更重要的是,有些代码可以在延迟评估下执行,在严格评估下会失败。
所以懒惰的评估基本上不会改变您已经知道如何编写的任何代码,但是使您能够编写您无法通过严格评估的代码!
所以print doubleMe(doubleMe(doubleMe([1, 2, 3, 4, 5])))
在严格或懒惰的评估中并不意味着什么不同。我们可以详细说明它是如何实现的,并发现各种倍增将以不同的顺序发生,也许这足以让一些人获得直觉。但我不确定这是引入这个概念的最佳方式。
懒惰真正开始变得与众不同的是,你可以做以下事情:
编写程序以打印第N个素数,方法是生成所有素数的列表,然后打印列表的第N个元素。使用延迟评估时,素数仅在请求时生成,因此不会运行。严格的程序必须有generate_primes
函数的参数来控制生成的数量。
通过将print
函数映射到所有素数列表上,编写程序以永久保持打印素数(直到控制-C被命中)。通过懒惰的评估,它们将在生成时打印出来;严格的程序会在打印任何素数之前尝试生成所有素数。注意,惰性程序可以使用(1)中相同的generate_primes
函数,而严格程序的generate_n_primes
函数在这里是无用的(除非我们想要再次生成所有素数到第N个 每次我们打印N + 1次素数时。
一个稍微不那么愚蠢的例子就是拥有一个将文档转换为HTML,PDF,Latex和其他几种格式的程序,并将它们全部返回到一个大词典中,以便调用者可以选择哪一个它想要。在严格的评估下,这将完成生成所有表示的工作,即使调用者只需要一个表示。在延迟评估下,调用者可以选择它想要的任何表示,程序只会实际执行实际需要的转换。
定义您自己的控制结构。在惰性语言中,可以在用户代码中将等同于if / then / else的内容定义为普通函数。该函数采用布尔值(条件),如果bool为真则返回一个值,如果条件为假则返回值。只会评估“then”或“else”值,所以即使您测试的原因是“then”分支会导致错误,如果条件为false则会导致程序失败!您也可以定义其他类似控件结构的函数。
等等。
这种方法的工作方式是,无论何时评估函数调用,而不是离开并完成所有工作然后返回结果,它将立即返回 。它还没有结果,因此它会返回一个占位符,其中包含足够的信息,以便在实际需要时进行工作。
这个占位符可以传递给其他函数,存储在数据结构等中,就像它是真实的结果一样。如果某些代码以后需要根据占位符所在的结果“做出决定”(要在屏幕上打印哪个字符,哪个分支采用条件,那种东西),则执行代码“多一点“提出足够的实际数据来做出决定。
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中,规则更像是这样(但并非如此):
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])))
:
[2, 4, 6]
,然后是列表[4, 8, 12]
,然后是列表[8, 16, 24]
,最后它将打印最后一个列表。 2
,然后计算第二个列表中的4
,然后计算第一个列表中的8
,并打印8
;然后计算第一个列表中的4
,第二个列表中的8
,依此类推。如果您正在打印列表,因为这需要计算所有元素,在任何一种情况下,它(或多或少)都是相同数量的工作和内存。但是,在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]