列表生成函数的延迟评估?

时间:2016-04-21 13:34:10

标签: haskell lazy-evaluation

我目前正在阅读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中的等式检查。

这怎么可能? 这对于程序的执行顺序更难以推理吗?

3 个答案:

答案 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>的可能因素进行此操作,那么一旦找到不是1n的那个,您就不想停止吗? {1}}?如果是这样,迭代器是无限的也无关紧要!

当你了解它时,执行的顺序并不重要。你真正关心的是:

  • 结果的正确性
  • 及时终止
  • 任何副作用的相对顺序

现在,如果您有“纯功能”程序,则没有副作用。但是什么时候发生?除了直接数字/字符串运算和元代码(即高阶函数)之外,几乎任何有用的东西都会产生副作用。

幸运的是(或者不幸的是,取决于你问的是谁),我们在Haskell中使用monad作为设计模式,其目的是(除其他外)控制顺序评估,以及副作用。

但即使不了解monad和所有这些东西,它实际上也很容易推理执行顺序和过程语言一样。你只需要习惯它。