我目前正在阅读Graham Hutton的Haskell编程。
在p.40中,提出了玩具素性测试:
factors :: Int -> [Int]
factors n = [x | x <- [1..n], n `mod` x == 0]
prime :: Int -> Bool
prime n = factors n == [1,n]
然后,作者继续解释如何
&#34;判断数字不是素数不需要该函数 素数产生它的所有因素,因为在懒惰的评价下 结果
False
会在除了一个或多个因素之外立即返回 数字本身已生成&#34;
作为来自C和Java的人,我发现这令人震惊。我已经预期factors
调用首先完成,将结果保存在堆栈中并将控制传递给调用函数。但显然这里正在执行一个非常不同的程序:factors
中的列表理解必须有一个循环,并且正在检查添加到因子列表中的每个新元素的prime
中的等式检查。
这怎么可能? 这对于程序的执行顺序更难以推理吗?
答案 0 :(得分:38)
你发现它“令人震惊”,因为你并不期待它。一旦你习惯了......好吧,实际上它仍然让人们过来。但是过了一会儿,你最终会把它包裹起来。
Haskell如何工作是这样的:当你调用一个函数时,没有任何反应!这个调用是在某个地方记下的,就是这样。这几乎没有时间。你的“结果”实际上只是一个“我欠你”,告诉计算机运行什么代码来获得结果。请注意,不是整个结果;只是它的第一步。对于像整数这样的东西, 只有一步。但是对于列表,每个元素都是一个单独的步骤。
让我向您展示一个更简单的例子:
print (take 10 ([1..] ++ [0]))
我采访了一位C ++程序员,他对此感到“震惊”。当然,“++[0]
”部分必须“找到列表的末尾”,然后才能将其添加到零之前?这段代码如何在有限的时间内完成?!
看起来像这个构建[1..]
(在无限列表中),然后++[0]
扫描到此列表的末尾并插入零,然后{{1仅修剪前10个元素,然后打印。 当然需要无限时间。
所以这就是实际上发生的事情。 最外层函数是take 10
,这就是我们开始的地方。 (我没想到,是吗?)take
的定义是这样的:
take
很明显10!= 0,所以第一行不适用。所以第二行或第三行都适用。所以现在take 0 ( _) = []
take n ( []) = []
take n (x:xs) = x : (take (n-1) xs)
查看take
以查看它是空列表还是非空列表。
这里最外面的功能是[1..] ++ [0]
。它的定义与
(++)
所以我们需要弄清楚哪个等式适用。左参数是空列表(第一行适用),或者不是(第二行适用)。好吧,由于( []) ++ ys = ys
(x:xs) ++ ys = x : (xs ++ ys)
是一个无限列表,第二行始终适用。因此[1..]
的“结果”是[1..] ++ [0]
。如你所见,这并没有完全执行;但它执行得足以告诉这是一个非空列表。这是1 : ([2..] ++ [0])
所关注的全部内容。
take
你看到这是如何解开的吗?
现在,回到您的具体问题:take 10 ([1..] ++ [0])
take 10 (1 : ([2..] ++ [0]))
1 : take 9 ([2..] ++ [0])
1 : take 9 (2 : ([3..] ++ [0]))
1 : 2 : take 8 ([3..] ++ [0])
1 : 2 : take 8 (3 : ([4..] ++ [0]))
1 : 2 : 3 : take 7 ([4..] ++ [0])
...
1 : 2 : 3 : 4 : 5 : 6 : 7 : 8 : 9 : 10 : take 0 ([11..] ++ [0])
1 : 2 : 3 : 4 : 5 : 6 : 7 : 8 : 9 : 10 : []
运算符采用一对列表并迭代它们,逐个元素地比较它们以确保它们相等。 一旦找到差异,它会立即中止并返回false:
(==)
如果我们现在尝试,例如( []) == ( []) = True
(x:xs) == (y:ys) = (x == y) && (xs == ys)
( _) == ( _) = False
:
prime 6
答案 1 :(得分:16)
我将关注这一点:
这是不是更难以推断程序的执行顺序?
是的,但评估的顺序在纯函数式编程中并不重要。例如:
(1 * 3) + (4 * 5)
问:首先执行哪个乘法?答:我们不在乎,结果是一样的。即使C编译器也可以在这里选择任何订单。
(f 1) + (f 2)
问:首先执行哪个函数调用?答:我们不在乎,结果是一样的。在这里,C编译器也可以选择任何订单。但是,在C中,函数f
可能有副作用,使得上述总和的结果取决于评估的顺序。在纯函数式编程中,没有副作用,所以我们真的不关心。
此外,懒惰允许保留语义 - 扩展任何函数定义。假设我们定义
f x = e -- e is an expression which can use x
我们致电f 2
。结果应与e{2/x}
相同,即e
,其中x
的每个(免费)出现都已被2
取代。这只是&#34;展开定义&#34;,就像在数学中一样。例如,
f x = x + 4
-- f 2 ==> 2 + 4 ==> 6
但是,假设我们拨打f (g 2)
。懒惰使这相当于e{g 2/x}
。再次,如在数学中。例如:
f x = 42
g n = g (n + 1) -- infinite recursion
然后我们仍然拥有f (g 2) = 42 {g 2/x} = 42
,因为x
未被使用。我们不必担心g 2
是否被定义(永远循环)。定义展开始终有效。
这实际上使更简单来推断程序行为。
但是,懒惰有一些缺点。一个主要的一点是,虽然程序的语义(可以说)更简单,但估计程序的性能更难。要评估性能,您必须了解的不仅仅是最终结果:您需要拥有导致该结果的所有中间步骤的模型。特别是在高级代码中,或者当一些聪明的优化开始时,这需要一些关于运行时实际工作的专业知识。答案 2 :(得分:7)
这是不是更难以推断程序的执行顺序?
可能 - 至少,对于来自程序/ OO范例的人来说。我已经在其他急切评估语言中使用迭代器和函数式编程做了很多,对我来说,懒惰的评估策略并不是学习Haskell的主要问题。 (在您决定实际登录之前,您有多少次希望您的Java日志语句甚至不能获取该消息的数据?)
将Haskell中的所有列表处理视为已编译为基于迭代器的实现。如果您在Java中使用n
作为Iterator<Integer>
的可能因素进行此操作,那么一旦找到不是1
或n
的那个,您就不想停止吗? {1}}?如果是这样,迭代器是无限的也无关紧要!
当你了解它时,执行的顺序并不重要。你真正关心的是:
现在,如果您有“纯功能”程序,则没有副作用。但是什么时候发生?除了直接数字/字符串运算和元代码(即高阶函数)之外,几乎任何有用的东西都会产生副作用。
幸运的是(或者不幸的是,取决于你问的是谁),我们在Haskell中使用monad作为设计模式,其目的是(除其他外)控制顺序评估,以及副作用。
但即使不了解monad和所有这些东西,它实际上也很容易推理执行顺序和过程语言一样。你只需要习惯它。